Skip to content

fix(security): redact raw error messages from public PR comments - #90

Merged
chrisleekr merged 4 commits into
mainfrom
fix/sanitize-failure-surfacing
May 2, 2026
Merged

fix(security): redact raw error messages from public PR comments#90
chrisleekr merged 4 commits into
mainfrom
fix/sanitize-failure-surfacing

Conversation

@chrisleekr

@chrisleekr chrisleekr commented May 1, 2026

Copy link
Copy Markdown
Owner

Summary

The bot's tracking comments and bot-authored PR/issue comments were inlining raw error strings (e.g. Claude Code returned an error result: You've hit your limit · resets 6pm (UTC), octokit error stacks, K8s spawn exceptions) into bodies that GitHub renders publicly. Octokit error messages embed the request URL with the installation token (https://x-access-token:GHS_xxx@api.github.com/...), so this surface was a token-leak vector.

This PR sanitizes every public-comment write path while still surfacing the raw error to operator-only channels (logs, DB state.failedReason, internal WS payloads), and wires the orchestrator to auto-defer the ship iteration to the quota reset time when the SDK returns a transient usage-limit error (instead of stalling the intent until an operator re-arms it).

Diagram

flowchart TD
    classDef public fill:#fde2e1,stroke:#c40000,color:#000
    classDef internal fill:#dcefdc,stroke:#1b5e20,color:#000
    classDef bad fill:#ffd6d6,stroke:#a40000,color:#000

    SDK["Agent SDK throws<br/>You've hit your limit · resets 6pm UTC"]:::internal

    SDK --> Before["BEFORE — leaks raw error"]:::bad
    Before --> BeforeOps["operator log"]:::internal
    Before --> BeforePub["public PR comment<br/>review failed: review pipeline execution failed"]:::public

    SDK --> After["AFTER — split surfaces"]:::internal
    After --> AfterOps["state.failedReason DB column<br/>+ pino log<br/>+ ExecutionResult.errorMessage"]:::internal
    After --> AfterPub["public PR comment<br/>review pipeline execution failed — see server logs for details."]:::public
    AfterOps --> Tickle["orchestrator parses reset clock<br/>ZADD ship:tickle score=resetMs<br/>iteration auto-resumes at boundary"]:::internal
Loading

Changes

Visibility — surface SDK errors to operator-only channels

  • src/types.tsExecutionResult carries errorMessage?: string.
  • src/core/executor.ts — both throw-path catch and non-throw SDK terminal subtypes (error_max_turns, error_max_budget_usd, etc.) populate errorMessage.
  • src/core/pipeline.ts outer-catch returns errorMessage; the public tracking-comment finalize keeps the existing safe constant.
  • src/workflows/handlers/{review,implement,resolve}.ts — propagate result.errorMessage into the failure reason (DB state.failedReason) but explicitly set a safe humanMessage so the public comment never carries the raw text.

Security — redact raw errors from every public sink

  • src/daemon/workflow-executor.ts — failure-branch fallback humanMessage no longer interpolates result.reason; uncaught-throw branch no longer interpolates err.message (the highest-stakes leak — uncaught octokit errors carry the installation token in the URL).
  • src/workflows/orchestrator.ts — failed-child cascade humanMessage no longer interpolates result.reason.
  • src/webhook/router.ts — ephemeral-spawn rejection comment no longer interpolates decision.spawnError (raw K8s API errors).
  • src/workflows/ship/scoped/open-pr.ts — both classifier-failure and PR-create-failure replies drop the inlined error_message.

In every case the raw text is preserved in operator surfaces (pino logs, workflow_runs.state.failedReason, executions.error_message, internal WS payloads, scoped-job-completion handler logs).

Auto-recover on transient quota error

  • src/workflows/orchestrator.ts — adds two pure helpers:
    • extractFailedReason(state) — reads state.failedReason written by markFailed.
    • detectTransientQuotaError(reason, nowMs) — matches the Anthropic usage-limit signature and parses resets <time> UTC. Falls back to +1h when the clock cannot be parsed.
  • maybeEarlyWakeShipIntent — when a child failed with a transient quota signature, ZADDs ship:tickle with score = unix-ms of the reset boundary (instead of the existing skip-failed-child path). The periodic tickle scanner re-fires the intent once the quota resets.

Tests

  • test/workflows/orchestrator.test.ts — 6 new pure-function tests for extractFailedReason + detectTransientQuotaError (parses 6pm (UTC) and 18:30 UTC forms, rolls past-boundary to next day, falls back to +1h, ignores unrelated reasons, ignores empty/undefined).
  • test/webhook/router.test.ts — flipped the assertion from body.toContain("api-unavailable: boom") (which was pinning the leak) to body.not.toContain(...) so the test now validates the security property.

Related Issues

Test plan

  • `bun run typecheck` clean
  • `bun run lint` 0 errors (270 pre-existing warnings on unrelated files unchanged)
  • `bun run format:fix` clean
  • All touched-surface tests pass in isolation: `test/core/`, `test/workflows/handlers/`, `test/workflows/orchestrator.test.ts`, `test/webhook/router.test.ts`, `test/workflows/ship/scoped/open-pr.test.ts`, `test/daemon/scoped-*-executor.test.ts`
  • Updated `test/webhook/router.test.ts` asserts the public comment does NOT contain the raw `spawnError`

Summary by CodeRabbit

  • Bug Fixes

    • Public comments and tracking messages now hide raw internal error text and show safe, generic guidance.
    • Child-workflow quota/usage-limit failures are detected as transient and automatically defer retries until quota resets.
  • Documentation

    • Added failure-handling guidance clarifying public vs operator error reporting and retry semantics.
  • Chores

    • CI now cleans stale dev pre-release tags before running releases.

Copilot AI review requested due to automatic review settings May 1, 2026 22:47
@coderabbitai

coderabbitai Bot commented May 1, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Separates public GitHub tracking comments from internal operator error details, adds errorMessage to execution results, surfaces SDK/timeout errors internally, detects Anthropic quota/usage-limit failures to auto-defer ship intents, adds CI step to clean stale dev pre-release tags, and updates docs/tests accordingly.

Changes

Error Handling and Transient Quota Recovery

Layer / File(s) Summary
Type System
src/types.ts
Adds optional ExecutionResult.errorMessage?: string.
Core Error Propagation
src/core/executor.ts, src/core/pipeline.ts
executeAgent sets timeout/abort messages into errorMessage; buildExecutionResult formats SDK terminal errors into errorMessage; runPipeline now returns { success: false, errorMessage } on throws.
Handler Returns
src/workflows/handlers/implement.ts, src/workflows/handlers/resolve.ts, src/workflows/handlers/review.ts
Handlers return structured failures with internal reason (prefers result.errorMessage) and a separate user-facing humanMessage fixed string.
Public Comment Safety
src/daemon/workflow-executor.ts, src/webhook/router.ts, src/workflows/ship/scoped/open-pr.ts
Public GitHub comments / tracking-mirror humanMessage no longer interpolate raw error text; internal details are still logged and persisted to state.failedReason.
Orchestrator: quota detection & deferral
src/workflows/orchestrator.ts
Adds exported extractFailedReason and detectTransientQuotaError; maybeEarlyWakeShipIntent extracts failedReason, detects Anthropic quota errors (parses resets … UTC, supports formats, falls back to +1h) and defers by ZADD ship:tickle <retryAtMs> <intent_id> when transient.
Tests
test/core/executor.test.ts, test/webhook/router.test.ts, test/workflows/orchestrator.test.ts
Executor tests assert errorMessage on timeout/abort; webhook test asserts public comment omits raw spawnError; orchestrator tests cover extractFailedReason/detectTransientQuotaError parsing and integration asserting single ZADD ship:tickle at parsed retry time.
Documentation
docs/use/workflows/implement.md, docs/use/workflows/resolve.md, docs/use/workflows/review.md, docs/use/workflows/ship.md
Adds “Failure handling” sections explaining public vs operator error surfaces; ship doc documents Anthropic quota auto-defer detection and scheduling.

CI Cleanup

Layer / File(s) Summary
Workflow Step
.github/workflows/dev-release.yml
Adds semrel-dev step “Clean stale dev pre-release tags for this branch” that derives BRANCH_SLUG, lists local tags matching the dev prerelease pattern for that slug, deletes matching local tags, and best-effort deletes corresponding remote origin tags before running semantic-release.

Sequence Diagram

sequenceDiagram
    participant CW as Child Workflow
    participant O as Orchestrator
    participant DB as workflow_runs DB
    participant V as Valkey (tickle queue)

    CW->>DB: mark run failed with<br/>state.failedReason: "Anthropic ... resets 6pm (UTC)"
    O->>DB: onStepComplete reads child run state
    O->>O: extractFailedReason(state)
    O->>O: detectTransientQuotaError(reason) → {retryAtMs, resetPhrase}
    alt Quota detected
        O->>V: ZADD ship:tickle retryAtMs intent_id
        V-->>O: scheduled
        O-->>O: return early (defer retry)
    else Not quota
        O->>O: continue skip_failed_child path
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main security fix: redacting raw error messages from public PR comments. This is the core objective across all code changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Sanitizes bot-authored public GitHub comments to avoid leaking raw upstream error strings (including Octokit token-bearing URLs), while preserving detailed errors for operator-only surfaces and adding orchestrator support to auto-defer ship retries when an Anthropic quota-reset time is detected.

Changes:

  • Add ExecutionResult.errorMessage and propagate raw failure detail through executor/pipeline and workflow handlers for internal storage/logging.
  • Redact raw error strings from all public comment write paths (tracking comments, ship halt messages, ephemeral spawn rejection, open-pr scoped comments).
  • Add orchestrator helpers to detect transient quota errors and schedule a deferred ship:tickle retry at the reset boundary (with unit tests).

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/types.ts Extends ExecutionResult with errorMessage to carry internal failure detail.
src/core/executor.ts Populates errorMessage for thrown and non-throw SDK terminal outcomes.
src/core/pipeline.ts Ensures public tracking comment doesn’t receive raw error text while returning errorMessage to callers.
src/workflows/handlers/review.ts Uses errorMessage for internal reason and a safe humanMessage for public comments.
src/workflows/handlers/implement.ts Same internal/public split on failure as review handler.
src/workflows/handlers/resolve.ts Same internal/public split on failure as review handler.
src/daemon/workflow-executor.ts Defense-in-depth: prevents defaulting public humanMessage to raw reason (incl. uncaught throws).
src/workflows/orchestrator.ts Stops inlining raw failure reasons into ship halt messages; adds quota-reset detection + deferred tickle scheduling.
src/webhook/router.ts Removes spawnError interpolation from the public infra-unavailable rejection comment.
src/workflows/ship/scoped/open-pr.ts Removes raw error_message interpolation from public open-pr scoped comments.
test/workflows/orchestrator.test.ts Adds unit tests for pure quota-detection helpers (extractFailedReason, detectTransientQuotaError).
test/webhook/router.test.ts Updates assertion to ensure the public comment does not include raw spawnError.

Comment thread src/workflows/orchestrator.ts Outdated
Comment thread src/workflows/orchestrator.ts
Comment thread src/core/executor.ts
@chrisleekr
chrisleekr force-pushed the fix/sanitize-failure-surfacing branch 2 times, most recently from 9d85147 to 1a15c13 Compare May 1, 2026 23:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/webhook/router.ts (1)

35-36: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update stale JSDoc on spawnError — it now incorrectly says the field is "surfaced in the tracking comment"

The comment reads:

/** Set when `reason === "ephemeral-spawn-failed"` — surfaced in the tracking comment. */

This PR explicitly removes spawnError from the public tracking comment. A developer reading this JSDoc later may assume surfacing the raw error text in a public comment is the intended behaviour and unintentionally reintroduce the token-leak vector.

📝 Proposed fix
-  /** Set when `reason === "ephemeral-spawn-failed"` — surfaced in the tracking comment. */
+  /** Set when `reason === "ephemeral-spawn-failed"`. Retained for operator-side surfaces
+   * (structured logs, executions row) only — never interpolated into public GitHub comments. */
   spawnError?: string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/webhook/router.ts` around lines 35 - 36, The JSDoc for the spawnError
field is stale and incorrectly claims the error text is "surfaced in the
tracking comment"; update the comment on the spawnError property (the spawnError
field in the router type/interface) to reflect that this value is not exposed
publicly and is intended for internal logging/troubleshooting only (remove any
mention of being surfaced in tracking comments or public output) so future
contributors won't reintroduce token-leak behavior.
src/workflows/handlers/implement.ts (1)

156-160: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Same outer catch gap as review.ts — raw Octokit error text in reason without humanMessage.

octokit.rest.issues.get (line 43) and octokit.rest.repos.get (line 55) can throw Octokit RequestErrors whose .message embeds https://x-access-token:GHS_xxx@…. That string ends up in reason with no humanMessage. Apply the same fix as the runPipeline failure path above.

🔒 Proposed fix
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    log.warn({ err }, "implement handler caught error");
-   return { status: "failed", reason: `implement failed: ${message}` };
+   return {
+     status: "failed",
+     reason: `implement failed: ${message}`,
+     humanMessage: "implement pipeline execution failed — see server logs for details.",
+   };
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/workflows/handlers/implement.ts` around lines 156 - 160, The catch block
in implement.ts returns raw err.message (which can include sensitive Octokit
token URLs); change the returned reason to prefer a sanitized human message when
available: use the same pattern as the runPipeline failure path by extracting a
humanMessage from the error (e.g., const humanMessage = (err as
any)?.humanMessage ?? (err instanceof Error ? err.message : String(err))) and
return { status: "failed", reason: `implement failed: ${humanMessage}` } while
keeping the existing log.warn({ err }, "implement handler caught error") for
diagnostics.
src/workflows/handlers/resolve.ts (1)

206-210: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Catch block still exposes raw error message in the public reason field.

The runPipeline failure path (lines 152-160) correctly separates internal/public messages, but the outer catch block at line 209 returns reason: \resolve failed: ${message}`without ahumanMessageoverride. If this exception path is reached, the daemon's default fallback will use a safe message, but thereasonfield (which gets persisted tostate.failedReason) will contain raw error text — which is correct. However, for consistency and defense-in-depth, consider adding an explicit humanMessage` here too.

🛡️ Optional: add explicit humanMessage to catch block
   } catch (err) {
     const message = err instanceof Error ? err.message : String(err);
     log.warn({ err }, "resolve handler caught error");
-    return { status: "failed", reason: `resolve failed: ${message}` };
+    return {
+      status: "failed",
+      reason: `resolve failed: ${message}`,
+      humanMessage: "resolve failed — see server logs for details.",
+    };
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/workflows/handlers/resolve.ts` around lines 206 - 210, The outer catch in
the resolve handler currently returns { status: "failed", reason: `resolve
failed: ${message}` } exposing raw error text; update the catch to include an
explicit humanMessage (e.g., "Resolve failed, please try again" or similar safe
user-facing string) alongside the existing reason so internal details remain in
reason while humanMessage provides a sanitized message for consumers; modify the
catch block that defines message and calls log.warn (the block catching err in
the resolve handler) to return both reason and humanMessage consistent with
runPipeline's failure return shape.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/webhook/router.ts`:
- Around line 35-36: The JSDoc for the spawnError field is stale and incorrectly
claims the error text is "surfaced in the tracking comment"; update the comment
on the spawnError property (the spawnError field in the router type/interface)
to reflect that this value is not exposed publicly and is intended for internal
logging/troubleshooting only (remove any mention of being surfaced in tracking
comments or public output) so future contributors won't reintroduce token-leak
behavior.

In `@src/workflows/handlers/implement.ts`:
- Around line 156-160: The catch block in implement.ts returns raw err.message
(which can include sensitive Octokit token URLs); change the returned reason to
prefer a sanitized human message when available: use the same pattern as the
runPipeline failure path by extracting a humanMessage from the error (e.g.,
const humanMessage = (err as any)?.humanMessage ?? (err instanceof Error ?
err.message : String(err))) and return { status: "failed", reason: `implement
failed: ${humanMessage}` } while keeping the existing log.warn({ err },
"implement handler caught error") for diagnostics.

In `@src/workflows/handlers/resolve.ts`:
- Around line 206-210: The outer catch in the resolve handler currently returns
{ status: "failed", reason: `resolve failed: ${message}` } exposing raw error
text; update the catch to include an explicit humanMessage (e.g., "Resolve
failed, please try again" or similar safe user-facing string) alongside the
existing reason so internal details remain in reason while humanMessage provides
a sanitized message for consumers; modify the catch block that defines message
and calls log.warn (the block catching err in the resolve handler) to return
both reason and humanMessage consistent with runPipeline's failure return shape.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 56b0dfe9-443a-46fd-90ef-60bdf9b206e7

📥 Commits

Reviewing files that changed from the base of the PR and between be28b87 and 1a15c13.

📒 Files selected for processing (18)
  • .github/workflows/dev-release.yml
  • docs/use/workflows/implement.md
  • docs/use/workflows/resolve.md
  • docs/use/workflows/review.md
  • docs/use/workflows/ship.md
  • src/core/executor.ts
  • src/core/pipeline.ts
  • src/daemon/workflow-executor.ts
  • src/types.ts
  • src/webhook/router.ts
  • src/workflows/handlers/implement.ts
  • src/workflows/handlers/resolve.ts
  • src/workflows/handlers/review.ts
  • src/workflows/orchestrator.ts
  • src/workflows/ship/scoped/open-pr.ts
  • test/core/executor.test.ts
  • test/webhook/router.test.ts
  • test/workflows/orchestrator.test.ts

Tracking comments and bot-authored replies were inlining raw error
strings (Agent SDK throws, octokit error stacks with the installation
token in the request URL, K8s spawn exceptions) into bodies that
GitHub renders publicly — a real token-leak vector. Sanitize every
public sink while keeping the raw error on operator-only channels
(pino logs, DB state.failedReason, executions.error_message, internal
WS payloads).

Also wires the orchestrator to auto-defer the ship iteration to the
quota reset time when the SDK returns a transient usage-limit error
(parses "resets 6pm UTC" via detectTransientQuotaError and ZADDs
ship:tickle with score = reset unix ms), so the loop self-recovers
instead of stalling until manual re-arming.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@chrisleekr
chrisleekr force-pushed the fix/sanitize-failure-surfacing branch from 1a15c13 to f82910c Compare May 1, 2026 23:34
@chrisleekr

Copy link
Copy Markdown
Owner Author

Outside-diff findings from review 4213790770 addressed in f82910c:

  • src/webhook/router.ts:35 — JSDoc on spawnError rewritten to clarify it's operator-only (logs + executions row); explicitly notes the field is never interpolated into public GitHub comments.
  • src/workflows/handlers/implement.ts:156-160 — outer catch now returns the same safe humanMessage ("implement pipeline execution failed — see server logs for details.") that the runPipeline failure path already used. Raw message stays in reason for operator surfaces only.
  • src/workflows/handlers/resolve.ts:206-210 — same fix applied for symmetry/defense-in-depth.
  • src/workflows/handlers/review.ts — same outer-catch gap closed (was not flagged by CodeRabbit but the inner/outer asymmetry was identical).

Docs in docs/use/workflows/{review,implement,resolve}.md updated to state both the inner pipeline-failure path and the outer handler catch apply the public/operator surface separation.

@chrisleekr-bot

chrisleekr-bot Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

bot workflow review — succeeded

🔍 Code review complete — 18 files, +461/-21.

Review — PR #90: fix(security): redact raw error messages from public PR comments

Summary

LGTM with two non-blocking observations posted inline. The PR delivers on its stated security goal (token redaction from public GitHub comments) and adds a useful self-healing behaviour for transient Anthropic usage-limit errors. The two-channel separation — public humanMessage is generic, raw reason lives in state.failedReason + Pino logs + executions.error_message — is implemented consistently across handlers, the daemon executor, the orchestrator cascade, the pipeline, and the webhook router. Tests in isolation pass; cross-file mock.module pollution that produces failures under bun test is a pre-existing limitation (main branch shows 186 fails of the same shape vs. this branch's 193 — delta accounts for the new orchestrator helper tests).

What was checked

  • Diff review: 18 files, +461/−21. Read every changed src/ and test/ file in full.
  • Two-channel contract: traced the public-vs-operator split through markFailed (writes state.failedReason) → setState (writes generic humanMessage to tracking comment) → onStepComplete cascade (uses generic message for parent) → job:result payload (carries raw errorMessage to operator). Confirmed no path inlines reason into a public comment.
  • Quota-detection two-signal gate (detectTransientQuotaError): hand-traced the regex against the SDK fixture string and the test cases. The conjunction hasAnthropicPhrase || hasResetsClock plus the post-parse if (!hasAnthropicPhrase) return null guard is what prevents bare GitHub secondary-rate-limit messages from auto-deferring.
  • Cascade scoping: confirmed maybeEarlyWakeShipIntent only fires for orphan iteration runs (carry state.shipIntentId), not for composite ship workflow children (orchestrator inserts those with '{}'::jsonb). Verified handlers in src/workflows/handlers/ never write shipIntentId themselves.
  • Idempotency / re-entrancy: runIteration's in-flight guard (findInflightShipIntentRun) refuses to double-enqueue if a previous iteration is still queued/running. Resume path via tickle-scheduleronDueresumeShipIntentrunIteration creates a fresh workflow_run rather than resuming the failed one.
  • Validation: bun run typecheck ✅, bun run lint ✅, bun run scripts/check-docs-citations.ts ✅, bun run scripts/check-docs-versions.ts ✅. Per-file test runs pass for test/workflows/orchestrator.test.ts, test/core/executor.test.ts, test/webhook/router.test.ts.

Findings

[minor] Deferred-quota wake has no Postgres durability backstop

Posted inline at src/workflows/orchestrator.ts:493.

The transient-quota path ZADDs ship:tickle with a future score but does not write a corresponding ship_continuations row. If Valkey loses the entry between defer and reset, the boot reconciler in tickle-scheduler.ts:51-64 cannot recover it (no row to find). The intent is then stranded until the next user activity re-triggers via webhook.

This is the same single-storage failure mode as the existing immediate-tickle ZADD a few lines down, but the operator-visibility framing of this PR makes the gap more conspicuous: the parent workflow_run is already marked failed, the user sees a generic "see server logs" message, and reset windows can be up to ~5h.

Suggested follow-up: persist wake_at to ship_continuations alongside the deferred ZADD. The tickle-scheduler.ts doc-comment already promises "boot reconciliation reads from Postgres" — wiring it makes that contract honest.

Not a blocker for this PR's primary goal.

[nit] parseResetsClock regex parses bare resets 6 UTC as 06:00

Posted inline at src/workflows/orchestrator.ts:415.

Both (am|pm)? and (:\d{2})? are optional, so a bare resets 6 UTC slips through as hour=6, minute=0. If now is past 06:00, the next-day rollover at line 437 pushes the wake out by up to ~24h — strictly worse than the +1h fallback the unparseable branch would produce. Anthropic's current copy always includes am/pm, so this is unlikely in practice; cheap to defend against future format drift by tightening the regex or guarding when both meridiem and minute are absent.

Reasoning

Why the security fix lands cleanly

The token-leak vector is real: octokit error stacks embed https://x-access-token:GHS_xxx@api.github.com/... in error.request.url. PR #90's redaction strategy is consistent across surfaces:

  • Inner handler failures (src/workflows/handlers/{review,implement,resolve}.ts): result.errorMessage ?? "<workflow> pipeline execution failed" for reason, generic message for humanMessage.
  • Outer handler catches: same shape; raw stack stays in reason only.
  • Daemon executor (src/daemon/workflow-executor.ts): markFailed(runId, reason, failState) runs BEFORE setState, so the public-facing humanMessage defaults to a generic string even when the handler doesn't supply one.
  • Orchestrator cascade (src/workflows/orchestrator.ts:107): parent's terminal humanMessage no longer inlines result.reason from the failing child.
  • Pipeline outer catch (src/core/pipeline.ts): "An internal error occurred. Check server logs for details." instead of err.message.
  • Router spawn-failure path (src/webhook/router.ts): generic ephemeral-spawn-failure comment with raw spawnError operator-only.
  • Open-PR scoped handler (src/workflows/ship/scoped/open-pr.ts): classifier-failure and PR-create-failure replies stripped of error_message.

Why the auto-defer doesn't introduce new races

  • markFailed precedes onStepComplete in workflow-executor.ts, so by the time the cascade reads state.failedReason, it's already persisted.
  • extractFailedReason filters non-string and empty values, so legacy rows where markFailed wasn't called (or wrote a non-string sentinel) safely return undefined.
  • detectTransientQuotaError returns null for both undefined reason and bare rate limit/usage limit strings — the dedicated tests around lines 740-810 of test/workflows/orchestrator.test.ts cover the regression-guard cases.
  • Composite ship workflow's children (inserted with '{}'::jsonb) carry no shipIntentId, so the early-wake path is iteration-only — no risk of accidentally deferring a one-shot composite run that should fail-fast.

Test polution context

bun test (full suite) shows 193 fails on this branch vs 186 on main. The +7 delta matches the new orchestrator helper tests added in this PR; they pass in isolation (bun test test/workflows/orchestrator.test.ts → all green) and fall over only under cross-file mock.module interaction — a documented limitation of Bun's test runner with the SDK module-replacement pattern this repo uses. Not a regression introduced by this PR.

What I deliberately did not flag

  • extractFailedReason rejecting empty strings — appropriate; an empty failedReason carries no operator value and would skew detectTransientQuotaError's emptiness check.
  • reactOnParentTrigger not firing for transient deferrals — correct; maybeEarlyWakeShipIntent runs on orphan iteration runs (no parent) so the parent-cascade reaction path is structurally not entered. For composite ship workflows the deferral path doesn't trigger at all (see "no new races" above).
  • markFailed and setState ordering risk — a markFailed throw would propagate to the outer catch, which calls markFailed again. Idempotent and acceptable.
  • setState failures swallowed — already wrapped in try/catch with structured logging; DB state is authoritative per the existing comment.

cost: $8.6388 · turns: 99 · duration: 1036s

Comment thread src/workflows/orchestrator.ts
Comment thread src/workflows/orchestrator.ts
@chrisleekr-bot

chrisleekr-bot Bot commented May 2, 2026

Copy link
Copy Markdown
Contributor

bot workflow resolve — succeeded

🔎 Resolve iteration complete — 0 failing checks, 5 open comment threads (some may already be resolved).

Resolve — PR #90 (fix/sanitize-failure-surfacing)

Summary

PR #90 (fix(security): redact raw error messages from public PR comments) is resolve-clean after this iteration. All 21 status checks pass on b3cccda, three of the five review threads (the original Copilot triplet) were already resolved by 1a15c13/f82910c ahead of this run, and the two remaining [minor]/[nit] findings from the latest bot self-review have been actioned: the [nit] regex ambiguity is fixed in code with a regression guard, and the [minor] Postgres durability gap is replied with a documented out-of-scope rationale and a tracked follow-up. No outstanding blockers — pending only reviewDecision: APPROVED from a human reviewer (the bot self-review left reviewDecision empty).

CI status

All passing on the prior tip (613c579) before this run; new commit b3cccda has not yet been polled here but contains only an additive guard + test, no surface-area expansion.

Check Status
CI / Lint & Test SUCCESS
CodeQL (actions, javascript-typescript) SUCCESS
Dev Release / CI / Lint & Test SUCCESS
Dev Release / Semantic Release (dev) SUCCESS
Docker / Build orchestrator (amd64, arm64) SUCCESS
Docker / Build daemon (amd64, arm64) SUCCESS
Docker / Merge manifest (orchestrator, daemon) SUCCESS
Docker / Trivy scan (×4 matrix entries) SUCCESS
Docs / build SUCCESS
Generate Labels SUCCESS
Secrets Scan / Gitleaks (×2) SUCCESS
CodeRabbit (status context) SUCCESS
Trivy (action) NEUTRAL (always neutral on this repo)

No failing checks → no diagnose-and-fix attempt was required.

Review comments

# DB id Path:line Classification Action
1 3175509720 src/workflows/orchestrator.ts (detectTransientQuotaError) Already resolved Pre-existing reply on 1a15c13 — thread isResolved: true. No-op.
2 3175509732 src/workflows/orchestrator.ts:517 (ZADD test) Already resolved Pre-existing reply on 1a15c13 — thread isResolved: true. No-op.
3 3175509749 src/core/executor.ts:247 (errorMessage in tests) Already resolved Pre-existing reply on 1a15c13 — thread isResolved: true. No-op.
4 3175628643 src/workflows/orchestrator.ts:493 ([minor] Postgres durability backstop) Valid — out of scope for this PR Reply 3175643543: rationale (bot itself flagged [minor] "Not a blocker"; persistContinuation has no callers yet so wiring it requires a state_blob.phase design call; same gap exists on the immediate-tickle path so deserves a dedicated follow-up issue covering both writes).
5 3175628942 src/workflows/orchestrator.ts:415 ([nit] regex parses resets 6 UTC as 06:00) Valid — fixed Commit b3cccda adds a guard if (match[2] === undefined && match[3] === undefined) return null; so ambiguous bare-hour forms fall through to the +1h fallback instead of next-day 06:00:30. Regression test added in test/workflows/orchestrator.test.ts. Reply 3175643205 confirms the fix and links the new test.

Commits pushed

  • b3cccda · fix(quota): reject ambiguous bare-hour resets clock as unparseable

Validation

  • bun run typecheck ✅ (clean)
  • bun run lint ✅ (0 errors, 288 pre-existing warnings unchanged)
  • bun run format ✅ (prettier --check . all match)
  • bun test test/workflows/orchestrator.test.ts ✅ — 8 pass, 11 skip (DB-bound tests skip without DATABASE_URL), 0 fail. The new "treats ambiguous 'resets 6 UTC' (no am/pm, no minute) as fallback, not 06:00" passes alongside all existing detectTransientQuotaError cases.

Outstanding

  • Approval. PR has reviewDecision: "" — the prior bot review posts inline findings but cannot submit an APPROVE decision. A human reviewer's APPROVE is the remaining merge gate.
  • Follow-up issue (not a blocker for this PR). Persist wake_at to ship_continuations on both the deferred-quota path (src/workflows/orchestrator.ts:493) and the immediate-tickle path (a few lines below) so the boot reconciler in src/workflows/ship/tickle-scheduler.ts:51-64 can self-heal Valkey loss. Requires a design call on state_blob.phase for a quota-wait state — flagged in the reply to comment 4.

cost: $3.1461 · turns: 45 · duration: 417s

`parseResetsClock` matched "resets 6 UTC" (no am/pm, no minute) and
parsed it as 06:00. If now was past 06:00, the next-day rollover at
line 437 pushed the wake out by ~24h — strictly worse than the +1h
fallback the unparseable branch produces.

Anthropic's current copy always carries am/pm so this is unlikely in
practice, but defends against future format drift.

Addresses [nit] review comment on PR #90.

Co-authored-by: chrisleekr-bot[bot] <chrisleekr-bot[bot]@users.noreply.github.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/core/executor.test.ts (1)

98-198: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add test coverage for buildExecutionResult's non-success SDK terminal path.

The new !success block in buildExecutionResult (executor.ts lines 290-300) has two branches — errors.length > 0"SDK ${subtype}: …" and the fallback "SDK terminal subtype: …" — neither of which is asserted. The emptyIterator implicitly hits the fallback (no result message → result = undefined → subtype "unknown"), but the test doesn't assert result.errorMessage. The errors-populated branch is never reached at all.

Per the 90% per-file coverage guideline, these need explicit assertions. A minimal addition to the suite:

🧪 Suggested test additions
+  it("surfaces SDK terminal subtype when SDK returns non-success without errors", async () => {
+    nextIterator = (): AsyncIterableIterator<unknown> => {
+      let done = false;
+      return {
+        [Symbol.asyncIterator]() { return this; },
+        next: () => {
+          if (done) return Promise.resolve({ value: undefined, done: true as const });
+          done = true;
+          return Promise.resolve({
+            value: { type: "result", subtype: "error_max_turns" },
+            done: false,
+          });
+        },
+        return: () => Promise.resolve({ value: undefined, done: true as const }),
+      } as AsyncIterableIterator<unknown>;
+    };
+
+    const result = await executeAgent(baseParams());
+
+    expect(result.success).toBe(false);
+    expect(result.errorMessage).toBe("SDK terminal subtype: error_max_turns");
+  });
+
+  it("includes SDK errors array in errorMessage when non-empty", async () => {
+    nextIterator = (): AsyncIterableIterator<unknown> => {
+      let done = false;
+      return {
+        [Symbol.asyncIterator]() { return this; },
+        next: () => {
+          if (done) return Promise.resolve({ value: undefined, done: true as const });
+          done = true;
+          return Promise.resolve({
+            value: {
+              type: "result",
+              subtype: "error_usage_limit",
+              errors: ["You've hit your limit · resets 6pm (UTC)"],
+            },
+            done: false,
+          });
+        },
+        return: () => Promise.resolve({ value: undefined, done: true as const }),
+      } as AsyncIterableIterator<unknown>;
+    };
+
+    const result = await executeAgent(baseParams());
+
+    expect(result.success).toBe(false);
+    expect(result.errorMessage).toBe(
+      "SDK error_usage_limit: You've hit your limit · resets 6pm (UTC)",
+    );
+  });

As per coding guidelines: **/*.test.{ts,tsx} — maintain minimum 90% coverage threshold per-file (lines + functions).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/core/executor.test.ts` around lines 98 - 198, Add two unit tests in
executor.test.ts to cover buildExecutionResult's non-success SDK terminal
branches: (1) create a nextIterator that returns a terminal SDK result object
with success=false and a non-empty errors array (and subtype like
"response_error"), call executeAgent(baseParams()) and assert result.success is
false and result.errorMessage starts with `SDK response_error:` and contains the
first error message; (2) create a nextIterator that returns the
empty/undefined-message terminal path (reuse emptyIterator or produce a terminal
result with no message/errors so subtype becomes "unknown"), call executeAgent
and assert result.errorMessage matches the fallback pattern `SDK terminal
subtype: unknown` (or the exact fallback string used by buildExecutionResult).
Reference the buildExecutionResult function and the nextIterator/emptyIterator
helpers to locate where to add these assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/dev-release.yml:
- Around line 95-97: The current PATTERN uses a shell glob fed to git tag -l
which can backtrack and over-match unrelated tags; replace the glob-based lookup
with a pipeline that lists all tags and filters via an anchored extended regex
that includes BRANCH_SLUG literally. Concretely, change the STALE_TAGS
assignment to call git tag -l and pipe to grep -E using an anchored pattern like
"^v[[:alnum:].-]*-${BRANCH_SLUG}\." (escape the dot) so you only match tags that
end with "-${BRANCH_SLUG}." followed by the suffix; keep the fallback "|| true"
and update the variables PATTERN/STALE_TAGS usages accordingly.

In `@src/workflows/orchestrator.ts`:
- Around line 492-521: The deferred-quota branch currently schedules a ZADD via
requireValkeyClient/TICKLE_KEY before verifying the intent's terminal state; to
fix, run the same terminal-intent guard used on the success path (call
getIntentById(...) and isSessionTerminalState(...) for intentId) before calling
detectTransientQuotaError/requireValkeyClient and ZADD so you skip scheduling
for intents already in terminal states (use intentId and childRunId to
log/return early); keep the existing log.warn/ZADD error handling but ensure
ZADD only executes after the terminal-state check passes.

In `@test/workflows/orchestrator.test.ts`:
- Around line 686-737: The test only asserts the ZADD score is "in the future"
which doesn't ensure the parsed reset time logic is used; update the test around
onStepComplete/run creation to freeze the clock (e.g., mock Date.now or use
jest.useFakeTimers with a fixed base time) before calling
insertIntent/markFailed/onStepComplete, compute the exact expected next 18:00:30
UTC timestamp from that frozen time, and assert the ZADD score (mockValkeySend
call where c[0]==="ZADD" and c[1][0]==="ship:tickle") equals that computed
timestamp instead of just being > Date.now(); keep references to onStepComplete,
markFailed, insertIntent and mockValkeySend so the change locates the right
calls.

---

Outside diff comments:
In `@test/core/executor.test.ts`:
- Around line 98-198: Add two unit tests in executor.test.ts to cover
buildExecutionResult's non-success SDK terminal branches: (1) create a
nextIterator that returns a terminal SDK result object with success=false and a
non-empty errors array (and subtype like "response_error"), call
executeAgent(baseParams()) and assert result.success is false and
result.errorMessage starts with `SDK response_error:` and contains the first
error message; (2) create a nextIterator that returns the
empty/undefined-message terminal path (reuse emptyIterator or produce a terminal
result with no message/errors so subtype becomes "unknown"), call executeAgent
and assert result.errorMessage matches the fallback pattern `SDK terminal
subtype: unknown` (or the exact fallback string used by buildExecutionResult).
Reference the buildExecutionResult function and the nextIterator/emptyIterator
helpers to locate where to add these assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 47ef9b95-2dcb-421b-9b58-3c21af31da47

📥 Commits

Reviewing files that changed from the base of the PR and between 1a15c13 and b3cccda.

📒 Files selected for processing (18)
  • .github/workflows/dev-release.yml
  • docs/use/workflows/implement.md
  • docs/use/workflows/resolve.md
  • docs/use/workflows/review.md
  • docs/use/workflows/ship.md
  • src/core/executor.ts
  • src/core/pipeline.ts
  • src/daemon/workflow-executor.ts
  • src/types.ts
  • src/webhook/router.ts
  • src/workflows/handlers/implement.ts
  • src/workflows/handlers/resolve.ts
  • src/workflows/handlers/review.ts
  • src/workflows/orchestrator.ts
  • src/workflows/ship/scoped/open-pr.ts
  • test/core/executor.test.ts
  • test/webhook/router.test.ts
  • test/workflows/orchestrator.test.ts

Comment thread .github/workflows/dev-release.yml Outdated
Comment thread src/workflows/orchestrator.ts
Comment thread test/workflows/orchestrator.test.ts
Three independent fixes from CodeRabbit's outside-diff review on b3cccda:

1. dev-release.yml: replace `git tag -l "v*-${SLUG}.*"` glob with an
   anchored ERE grep. fnmatch backtracking let the glob over-match
   sibling-branch tags (e.g. slug `fix-test` would also match
   `v0.4.0-fix-some-other-fix-test.1`), risking deletion of another
   branch's dev tags during semrel-dev cleanup.

2. orchestrator.ts: hoist the `getIntentById` / `isSessionTerminalState`
   guard above the failed-child branch. Previously the deferred-quota
   ZADD ran without checking terminal state, so a late quota-failed
   child could re-arm an already-aborted/merged intent and reprocess a
   session that should stay dead. Both the immediate cascade and the
   deferred retry now share the same guard.

3. orchestrator.test.ts (H2): freeze `Date.now` and assert the exact
   `18:00:30 UTC` deferred score instead of "any future timestamp".
   The looser check passed even if the parsed-reset path silently
   regressed to the +1h fallback or any other future score, defeating
   the test's stated intent.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/dev-release.yml:
- Around line 105-119: The current STALE_TAGS selection grabs all tags for the
branch slug and then deletes them, which removes tags that are still reachable
from HEAD; change the selection so STALE_TAGS contains only tags that match the
regex for ${BRANCH_SLUG} AND are not reachable from HEAD. Concretely, filter the
tag list returned by the regex against the set of tags reachable from HEAD
(e.g., using git tag --merged HEAD or git merge-base checks) and only pass those
unreachable tags into the existing deletion loop that uses git tag -d and git
push origin --delete; keep the rest of the deletion logic (echo, xargs, loop)
the same but operate on the filtered STALE_TAGS variable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 78281980-b013-4355-82c2-96206eb00513

📥 Commits

Reviewing files that changed from the base of the PR and between b3cccda and df7f0dd.

📒 Files selected for processing (3)
  • .github/workflows/dev-release.yml
  • src/workflows/orchestrator.ts
  • test/workflows/orchestrator.test.ts

Comment thread .github/workflows/dev-release.yml
@chrisleekr
chrisleekr merged commit cc70949 into main May 2, 2026
22 checks passed
@chrisleekr
chrisleekr deleted the fix/sanitize-failure-surfacing branch May 2, 2026 00:52
chrisleekr pushed a commit that referenced this pull request May 2, 2026
# [1.8.0](v1.7.0...v1.8.0) (2026-05-02)

### Bug Fixes

* **logger:** redact paths and scrub err.* before pino emits (closes [#52](#52)) ([#89](#89)) ([641f138](641f138))
* **security:** redact raw error messages from public PR comments ([#90](#90)) ([cc70949](cc70949))

### Features

* **workflows:** unify bot reply format and harden research/resolve guards ([#91](#91)) ([7d39fb4](7d39fb4))
@chrisleekr

Copy link
Copy Markdown
Owner Author

🎉 This PR is included in version 1.8.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants