Skip to content

feat(ship): wire ship iteration loop, tickle scheduler, and four scoped executors - #79

Merged
chrisleekr merged 15 commits into
mainfrom
20260429-212559-ship-iteration-wiring
May 1, 2026
Merged

feat(ship): wire ship iteration loop, tickle scheduler, and four scoped executors#79
chrisleekr merged 15 commits into
mainfrom
20260429-212559-ship-iteration-wiring

Conversation

@chrisleekr

@chrisleekr chrisleekr commented Apr 29, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the three integration gaps left by PR #77 so the ship workflow runs end-to-end: (1) the iteration loop bridges non-ready probe verdicts onto the existing daemon workflow_runs pipeline, (2) the tickle scheduler boots from app.ts and the orchestrator cascade ZADDs ship:tickle only on succeeded children, and (3) four scoped commands (bot:rebase, bot:fix-thread, bot:explain-thread, bot:open-pr) execute on the wire via a new scoped-job-offer / scoped-job-completion WS protocol with capacity accounting. Includes the senior-review remediation commit (c4c300c) that connected the seams the per-phase commits left dead, plus follow-up fixes from three CodeRabbit rounds and four senior-review nits.

Diagram

flowchart TD
    classDef before fill:#7a1f1f,stroke:#fff,color:#fff
    classDef after  fill:#0b4d2c,stroke:#fff,color:#fff
    classDef shared fill:#1f3b7a,stroke:#fff,color:#fff

    Comment["@bot comment"]:::shared --> Intent["ship_intents row"]:::shared

    Intent --> StubIter["runIteration is a stub<br/>verdict ignored"]:::before
    Intent --> RunIter["runIteration -> workflow_runs<br/>state.shipIntentId set"]:::after

    StubIter --> Stranded["intent stranded<br/>no executor"]:::before

    RunIter --> Daemon["daemon executes<br/>workflow-run job"]:::after
    Daemon --> Cascade["onStepComplete<br/>maybeEarlyWakeShipIntent"]:::after
    Cascade --> CascadeOld["ZADD even on failed child<br/>burns iteration cap"]:::before
    Cascade --> CascadeNew["ZADD only when status=succeeded<br/>tickle skips stale entries"]:::after
    CascadeNew --> Tickle["ship:tickle ZSET<br/>scheduler onDue"]:::after
    Tickle --> ResumeOld["resumeShipIntent<br/>logs and returns"]:::before
    Tickle --> ResumeNew["resumeShipIntent runs probe<br/>terminates or runs next iteration"]:::after

    Comment --> ScopedOld["scoped-job-offer arrives<br/>daemon switch has no case<br/>orchestrator times out"]:::before
    Comment --> ScopedNew["daemon evaluateScopedOffer<br/>WS_REJECT_REASONS on miss<br/>scoped field threaded via job:payload"]:::after
    ScopedNew --> ScopedExec["runScopedJob<br/>rebase deterministic<br/>thread/open-pr scaffold halted"]:::after
    ScopedExec --> Complete["handleScopedJobCompletion<br/>decrement activeCount + daemonActiveJobs"]:::after
Loading

Changes

Phase commits (per-phase wiring):

  • 59bd0ff Phase 1 — QueuedJob Zod-validated discriminated union, scoped-job-offer/completion WS schemas, ShipIntentContextSchema.
  • 2eebd81 Phase 2 — daemon scoped-job dispatch surface, orchestrator early-wake cascade, SHIP_LOG_EVENTS namespace.
  • 1ef3924 Phase 3 — US1 runIteration handler + cap/deadline accessors + session-runner placeholder replaced.
  • d250ad4 Phase 4 — US2 tickle scheduler boot wiring + graceful shutdown.
  • 96e6db8 Phase 5 — US3 four daemon executors + dispatch-scoped enqueue + connection-handler completion bridge.
  • f41a2a4 Phase 6 — docs/BOT-WORKFLOWS.md bridge diagram + docs/OBSERVABILITY.md event keys + retrospective annotations.

Senior-review remediation (c4c300c, 11 issues):

  • C1 case "scoped-job-offer" added to src/daemon/main.ts; new evaluateScopedOffer with capacity + memory + disk floors.
  • C2 JobAcceptParams.scoped threaded through src/orchestrator/job-dispatcher.ts; handleScopedAccept validates PendingOffer.scoped via Zod re-parse before cast.
  • C3 resumeShipIntent now runs runProbe and bridges to runIteration/transitionToTerminal; octokitFactory injected from src/app.ts.
  • C4 handleScopedJobCompletion calls decrementActiveCount + decrementDaemonActiveJobs.
  • C5 scoped-rebase-executor: token via env var, credential helper inside workDir with 0700, single rm in finally.
  • H1 Orchestrator cascade reads child status and skips ZADD on non-succeeded children.
  • H2 Three scaffold executors (scoped-fix-thread, scoped-explain-thread, scoped-open-pr) wrap Octokit calls with 4xx-halt vs 5xx-rethrow branching.
  • H3 WS_REJECT_REASONS constants now used in daemon (replaces ad-hoc strings).
  • H4 Exhaustiveness default added to runScopedJob switch.
  • H6 findInflightShipIntentRun guard prevents cascade-while-running double-enqueue.
  • M-cluster Hardcoded bot login replaced with config.botAppLogin; halted semantics fixed.

CodeRabbit follow-up rounds (5bb04d0, c57d058, c15bada) + e2e/integration fixes (cd36ac9, bcc0b51).

CodeRabbit round 3 (c15bada, 9 threads):

  • Scoped-job cancel race: jobAbortController + sendIfNotAborted wrapper stop double-finalization (synthetic job:result + later scoped-job-completion) and the matching double capacity-decrement.
  • registerExitCleanup skips rmSync when workDir === "" — guards against deleting .cred.sh in the daemon CWD if a process exit fires during a scoped run.
  • 4xx→halt rule tightened: 429 and rate-limited 403 now rethrow in scoped-fix-thread, scoped-explain-thread, scoped-open-pr so throttling reaches the orchestrator retry layer instead of silently halting the execution.
  • job-queue parse failures + leased-job poison-pill logs now record rawLength only — raw.slice(0, 200) could leak triggerBodyPreview / verdictSummary user content into operator logs.
  • RunIterationDeps drops unused valkey field — public contract no longer advertises an injectable seam with no callers.
  • tasks.md: tests/integration/test/integration/ (5 sites); QueuedJob variant count fivesix.
  • scoped-fix-thread test fixtures: distinct commentId (7777) vs triggerCommentId (8888); assertion now pins outbound comment_id to threadRef.commentId so a future swap fails loudly.

Senior-review nits (15caa9d):

  • Exhaustive switch over RebaseOutcome.kind in runScopedJob — future variants now fail TypeScript instead of silently mapping to closed.
  • Iteration handler now uses serializeShipWorkflowContext so producer-side validation matches the orchestrator's reader.
  • SUPPORTED_SCOPED_KINDS const moved below the import block in daemon/main.ts.
  • Comment numbering: duplicate // 8.// 9. in iteration.ts.

Tests added/updated:

  • New: test/daemon/scoped-offer-evaluator.test.ts (4 cases — accept supported, reject unknown via SCOPED_KIND_UNSUPPORTED, memory floor, capacity baseline).
  • New: test/orchestrator/connection-handler.test.ts (3 capacity-decrement cases for succeeded/failed/halted).
  • New: test/integration/ship-iteration-loop.test.ts, test/integration/ship-tickle-resume.test.ts, test/integration/scoped-rebase-roundtrip.test.ts.
  • Extended: test/workflows/orchestrator.test.ts (H1 — failed child does not ZADD), test/workflows/ship/iteration.test.ts (H6 — in-flight guard).
  • Rewritten: test/workflows/ship/session-runner.resume.test.ts (mock runProbe, exercise terminal/missing/active branches).

Related Issues

Test plan

  • Tested locally
  • Added/updated tests
  • Quality gates clean (bun run typecheck, bun run lint, bun run format, bun run check:no-destructive)
  • CI green (bun run check + bun run test)
  • Manual quickstart S1–S2 (iteration loop end-to-end on a non-ready PR)
  • Manual quickstart S3 (pause + resume via tickle)
  • Manual quickstart S4–S6 (bot:rebase no-op, clean merge, conflict)
  • Manual quickstart S7–S9 (bot:fix-thread / bot:explain-thread / bot:open-pr scaffolding boundaries)
  • Operator review of new event keys against Datadog dashboards

Summary by CodeRabbit

Release Notes

  • New Features

    • Added daemon-based execution for bot commands (rebase, fix-thread, explain-thread, open-pr)
    • Implemented ship iteration loop with automated resumption via scheduled tickling
    • Enhanced intent lifecycle management with terminal state handling
  • Tests

    • Added integration tests for iteration loops and resumption workflows
    • Added scoped job execution validation tests
    • Added tickle scheduler functionality verification
  • Documentation

    • Added specification for ship iteration wiring architecture
    • Documented bot workflow orchestration and job dispatch
    • Updated observability logging specifications

chrisleekr and others added 6 commits April 29, 2026 23:41
Sets up the type system for ship-iteration-wiring before any behavior
changes land in later phases.

- QueuedJob refactored to a Zod-validated discriminated union with six
  variants: legacy, workflow-run, scoped-rebase, scoped-fix-thread,
  scoped-explain-thread, scoped-open-pr. Every dequeue boundary now
  parses against the schema; producers (router, dispatcher, ship handler,
  orchestrator cascade) set kind explicitly.
- WS message schemas extended with scoped-job-offer (server→daemon) and
  scoped-job-completion (daemon→server) per
  specs/.../contracts/ws-messages.md, plus scoped-kind-unsupported in the
  reject reason taxonomy. Discriminator lives at the schema level.
- ShipIntentContextSchema added in src/workflows/ship/workflow-context.ts
  for the workflow_runs.context_json.shipIntentId convention (research.md
  Q1). WorkflowRunRef itself is unchanged — convention is documented in
  JSDoc.
- PendingOffer extended with optional scoped payload so reject/timeout
  can re-queue scoped jobs without lossy field copying.
- ws-messages.test.ts asserts round-trip + negative-validation across
  every new variant.

T001-T005 of specs/20260429-212559-ship-iteration-wiring/tasks.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the daemon-side dispatch switch and the orchestrator-side
ship-intent early-wake cascade. No scoped executors yet (US3); each
scoped JobKind throws "scoped executor not implemented" so a daemon
image without the implementations surfaces a clean halt instead of a
silent drop.

- src/daemon/job-executor.ts: branch on payload.scoped before the legacy
  workflowRun path; new runScopedJob switches on the Zod-validated
  jobKind discriminator and emits scoped-job-completion for every kind.
- src/workflows/orchestrator.ts: onStepComplete now reads
  workflow_runs.state.shipIntentId via extractShipIntentId; if present
  and the intent is non-terminal, ZADDs ship:tickle with score 0 so the
  scheduler re-enters the intent on the next tick. Valkey blip is
  caught and demoted to a warn log — the periodic scan picks up.
- src/workflows/ship/log-fields.ts: SHIP_LOG_EVENTS const enumerates
  every event-key used by the new code path so a typo is a compile
  error.
- src/workflows/ship/workflow-context.ts: align with the actual
  workflow_runs.state JSONB column; extractShipIntentId tolerates
  legacy/missing rows.
- test/workflows/orchestrator.test.ts: three new cases covering the
  early-wake hook, with a mock.module Valkey shim shared with the
  existing cascade tests.

T006-T009 of specs/20260429-212559-ship-iteration-wiring/tasks.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implements User Story 1: a non-ready probe verdict is bridged into the
existing daemon workflow_runs pipeline. The orchestrator's completion
cascade (already in place from phase 2) ZADDs ship:tickle on terminal
write so the next iteration re-enters via the scheduler in US2.

- src/workflows/ship/iteration.ts (NEW): runIteration writes a probe
  ship_iteration row, inserts a workflow_runs row carrying
  state.shipIntentId, enqueues a kind="workflow-run" daemon job, and
  appends an action ship_iteration row. Cap (config.maxShipIterations,
  env MAX_SHIP_ITERATIONS) and deadline (intent.deadline_at, env
  MAX_WALL_CLOCK_PER_SHIP_RUN) are enforced before any side-effects.
  Verdict→workflow mapping is one-action-per-iteration per research.md
  Q4. Shape-by-shape JSDoc on every export per Constitution VIII.
- src/workflows/ship/session-runner.ts: replaces the "iteration loop
  pending US2" placeholder with runIteration; adds resumeShipIntent for
  the US2 tickle path (probe-on-resume wiring is a documented follow-up
  so the e2e quickstart S3 has a deterministic ship.tickle.due log line
  to grep for).
- test/workflows/ship/iteration.test.ts (NEW): four cases — non-ready
  bridges to daemon pipeline; cap reached → terminal:halted with
  iteration-cap blocker; deadline exceeded → terminal:halted; ready
  verdict → ready-shortcut returns without writing iteration state.
- Existing test fixtures' job-queue mocks updated with isScopedJob +
  SCOPED_JOB_KINDS so the dispatcher's new imports resolve under
  mock.module replacement.

T010-T016 of specs/20260429-212559-ship-iteration-wiring/tasks.md.
523 → 530 tests passing (no new regressions; the 251 pre-existing
failures are unchanged from baseline main and remain a follow-up).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the ship-intent tickle scheduler into the orchestrator boot
sequence and graceful-shutdown path. Paused/active intents whose
ship_continuations.wake_at has elapsed (or that were early-woken via
the workflow_runs cascade in phase 2) get re-entered via the scheduler
on the next tick.

- src/app.ts: createTickleScheduler boots after startQueueWorker and
  before isReady=true, so a daemon pickup cannot land before the
  scheduler is reconciling. start() performs the boot reconciliation
  against ship_continuations AND begins the periodic scan in a single
  call (verified in tickle-scheduler.ts) — no separate reconcile method
  is invoked. Graceful shutdown stops the scheduler FIRST so no resume
  callback fires mid-drain.
- Emits event=ship.tickle.started on successful boot so quickstart S0
  has a deterministic log line to grep for.
- test/workflows/ship/session-runner.resume.test.ts (NEW): three cases
  covering resumeShipIntent on missing intent (no-op), terminal intent
  (no-op, status preserved), and active intent (probe-on-resume wiring
  is a documented follow-up — for now the function logs ship.tickle.due
  so operators can verify the scheduler is firing).
- test/workflows/ship/tickle-scheduler.test.ts (NEW): two integration
  cases against a fake-Valkey ZSET driver — round-trip onDue dispatch
  for a due intent, and re-arm semantics on transient onDue failure.

T017-T023 of specs/20260429-212559-ship-iteration-wiring/tasks.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the daemon-side scoped-job dispatch surface end-to-end so the
orchestrator's dispatch path no longer falls back to the legacy
maintainer-notice strings (FR-021 / SC-007). Each scoped JobKind now has
a dedicated daemon executor; the policy layer enqueues a typed scoped
job; the daemon emits scoped-job-completion via the new WS schema; the
orchestrator-side bridge (T033b) acknowledges + finalizes execution.

- src/daemon/scoped-rebase-executor.ts (NEW): full deterministic-git
  impl. Reuses runRebase from the policy layer; injects a runMerge
  callback that clones head, fetches base, runs git merge --no-edit
  (NEVER --force / --rebase / --ff-only), pushes the merge commit, or
  on conflict aborts cleanly and returns conflict_paths. Always cleans
  up the temp dir.
- src/daemon/scoped-fix-thread-executor.ts (NEW): scaffolding-boundary
  impl. Posts the maintainer-facing thread reply; full Agent SDK
  invocation lands as a follow-up. Returns structured halted outcome.
- src/daemon/scoped-explain-thread-executor.ts (NEW): same boundary;
  read-only thread reply. Agent SDK with write-tool denylist is the
  follow-up.
- src/daemon/scoped-open-pr-executor.ts (NEW): same boundary; posts
  reply on the originating issue carrying the policy verdictSummary
  verbatim. clone + Agent SDK + createPullRequest is the follow-up.
- src/daemon/job-executor.ts: real switch on payload.scoped.jobKind
  routes to each executor; emits scoped-job-completion with the
  per-kind result fields. Errors are caught and surfaced as failed
  status with a structured reason.
- src/orchestrator/connection-handler.ts: new handleScopedJobCompletion
  branch in handleDaemonMessage. Releases pending offer, finalizes the
  execution row, emits ship.scoped.<verb>.daemon.<completed|failed>
  log line. The user-facing reply was already posted by the daemon
  executor (it has the installation token), so the bridge does not
  double-post — a deviation from the contract's "policy layer formats
  and posts" but architecturally simpler with no behavior change.
- src/workflows/ship/scoped/dispatch-scoped.ts: replaces all four
  legacy-maintainer-notice paths AND the createBranchAndPr throw with
  enqueueJob calls. resolveThreadRef fetches file/line ranges via
  Octokit before enqueueing fix/explain-thread offers.
- scripts/check-no-destructive-actions.ts: extends scan to cover the
  four new daemon executor files (T035 explicit per-file roots).
- test/daemon/scoped-*-executor.test.ts (4 new): surface-level
  assertions on each executor's wire shape (mocked Octokit).

Test status: the 4 daemon tests pass when run in isolation or alongside
each other, but interfere with the pre-existing test/daemon/ws-client.test.ts
which globally mock.module's "../../src/logger" — a pre-existing test
isolation issue unrelated to this PR. Investigation deferred.

T024-T036 of specs/20260429-212559-ship-iteration-wiring/tasks.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the ship-iteration-wiring loop with operator-facing documentation
and bookkeeping that retroactively credits the merged PR-shepherding
spec for the work this PR landed.

- docs/BOT-WORKFLOWS.md: new "Bridge architecture: ship-iteration →
  daemon workflow_runs" section with a Mermaid diagram showing comment
  → intent → iteration → workflow_runs row → daemon → completion →
  tickle ZADD → resume. Captures the architectural choice of bridging
  rather than duplicating the daemon execution path.
- docs/OBSERVABILITY.md: enumerated every new `event` key (FR-018) —
  ship.iteration.{enqueued,terminal_cap,terminal_deadline},
  ship.tickle.{started,due,skip_terminal}, ship.scoped.<verb>.enqueued,
  ship.scoped.<verb>.daemon.{completed,failed} — with where each fires
  and what it indicates. Cross-references the typed SHIP_LOG_EVENTS
  const so a typo is a compile error.
- specs/20260427-201332-pr-shepherding-merge-ready/tasks.md: T021,
  T046, T070, T071, T082, T083, T085, T088, T092 each annotated
  "(superseded by specs/20260429-212559-ship-iteration-wiring/tasks.md
  TXX)" with the matching replacement task. The original [x] checkbox
  is preserved (universal Markdown renders only [ ] / [x]).
- bun run typecheck / lint / format / check:no-destructive: all clean.
  248 lint warnings are all pre-existing.

`grep -rn "not yet wired" src/` returns one match in resolve.ts (a
pre-existing prompt mentioning cross-run enforcement; out of scope).
The dispatch path is clean per SC-007.

T037-T041, T041b of specs/20260429-212559-ship-iteration-wiring/tasks.md.

T042 (quickstart S1-S11 against @chrisleekr-bot-dev via ngrok) is
deferred to manual verification — see PR description.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@chrisleekr has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 40 minutes and 49 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 168a207c-a917-44a5-b001-dad8ba6d9ce0

📥 Commits

Reviewing files that changed from the base of the PR and between 4038175 and 74f2b30.

📒 Files selected for processing (7)
  • specs/20260429-212559-ship-iteration-wiring/tasks.md
  • src/daemon/scoped-explain-thread-executor.ts
  • src/daemon/scoped-fix-thread-executor.ts
  • src/daemon/scoped-open-pr-executor.ts
  • src/workflows/ship/iteration.ts
  • src/workflows/ship/scoped/dispatch-scoped.ts
  • src/workflows/ship/scoped/open-pr.ts
📝 Walkthrough

Walkthrough

Bridges the ship-intent iteration loop into the daemon workflow_runs pipeline: adds a Valkey-based tickle scheduler and resume entrypoint, enqueues workflow_runs with state.shipIntentId, introduces scoped job kinds and four daemon executors, extends WS offer/completion contracts, and adds specs, docs, and tests.

Changes

Cohort / File(s) Summary
Specification & Documentation
CLAUDE.md, docs/BOT-WORKFLOWS.md, docs/OBSERVABILITY.md, specs/20260429-212559-ship-iteration-wiring/*, specs/20260427-201332-pr-shepherding-merge-ready/tasks.md
Adds Ship Iteration Wiring spec suite, BOT workflow docs, deterministic ship logging literals, quickstart/playbook, and task supersession notes; updates CLAUDE stack and active feature plan.
Iteration & Session Management
src/workflows/ship/iteration.ts, src/workflows/ship/session-runner.ts, src/workflows/ship/workflow-context.ts, src/workflows/ship/log-fields.ts
Implements runIteration, iteration counting/caps/deadlines, inserts workflow_runs with shipIntentId, exports resumeShipIntent, Zod context helpers, and centralized SHIP_LOG_EVENTS.
Scoped Command Dispatch
src/workflows/ship/scoped/dispatch-scoped.ts, src/workflows/ship/scoped/*
Replaces “not yet wired” responses by enqueuing daemon-bound scoped-* jobs; resolves thread refs, derives triggerCommentId, and builds scoped queue payloads.
Daemon Scoped Executors
src/daemon/scoped-rebase-executor.ts, src/daemon/scoped-fix-thread-executor.ts, src/daemon/scoped-explain-thread-executor.ts, src/daemon/scoped-open-pr-executor.ts
Adds four scoped executors for deterministic git/Octokit actions returning kind-specific outcomes (succeeded/failed/halted).
Daemon Job Execution & WS
src/daemon/job-executor.ts, src/daemon/main.ts, src/daemon/*
Adds scoped-offer evaluation (evaluateScopedOffer), runScopedJob routing, active-job tracking, new WS reject reasons, and CLI for scoped-job-offer/scoped-job-completion.
Orchestrator Queue & Schemas
src/orchestrator/job-queue.ts, src/shared/ws-messages.ts, src/shared/daemon-types.ts, src/shared/ws-messages.test.ts
Introduces Zod-discriminated QueuedJobSchema with kind variants (including scoped kinds), new WS schemas (scoped-job-offer/scoped-job-completion), WS_REJECT_REASONS, and preserves scoped payloads on pending offers.
Dispatch & Connection Handling
src/orchestrator/job-dispatcher.ts, src/orchestrator/connection-handler.ts, src/orchestrator/queue-worker.ts, src/workflows/orchestrator.ts
Routes scoped jobs via scoped-job-offer, persists scoped payloads, validates/reconstructs offers, handles scoped-job-completion (cleanup, capacity decrements, finalize), and early-wakes intents by ZADD ship:tickle 0 <intentId> on child completion.
Startup & Scheduler Wiring
src/app.ts
Starts/stops a ship-intent tickle scheduler at boot/shutdown and wires onDue to resumeShipIntent via an Octokit factory.
Queue/Workflow Dispatch Updates
src/webhook/router.ts, src/workflows/dispatcher.ts, src/workflows/handlers/ship.ts
Adds kind discriminators: legacy jobs labeled "legacy", child runs enqueued as kind: "workflow-run".
Workflow Types Comment
src/shared/workflow-types.ts
Documents state.shipIntentId JSONB convention used to link workflow_runs to ship intents for orchestrator wake-up.
Safety Scan Expansion
scripts/check-no-destructive-actions.ts
Expands static destructive-action scan to include daemon executor files and refactors scan iteration/reporting.
NL/Classifiers
src/workflows/ship/nl-classifier.ts, src/workflows/ship/scoped/meta-issue-classifier.ts
Adds stripJsonFence and pre-strips fenced JSON code blocks before JSON.parse to accept fenced LLM outputs.
Tests & Mocks
test/... (many files), test/integration/*, test/daemon/*
Adds surface and integration tests (scoped executors, runIteration, resume/tickle scheduler, WS schema tests, integration roundtrips), updates test mocks to export isScopedJob/SCOPED_JOB_KINDS and schema behaviors.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Orchestrator
    participant Database
    participant Valkey
    participant Daemon
    participant Executor

    Client->>Orchestrator: trigger ship action / probe
    Orchestrator->>Database: load intent, persist probe/result
    Orchestrator->>Database: insert workflow_runs (state.shipIntentId)
    Orchestrator->>Daemon: enqueue `workflow-run` job or send `scoped-job-offer`
    Daemon->>Executor: accept & execute scoped job (or run pipeline)
    Executor->>Database: post side-effects (comments, PRs, git pushes)
    Daemon->>Orchestrator: send `scoped-job-completion`
    Orchestrator->>Database: finalize execution row
    Orchestrator->>Valkey: ZADD ship:tickle 0 <intentId> (on completion)
    Valkey->>Orchestrator: scheduler ZRANGEBYSCORE -> due intent
    Orchestrator->>Orchestrator: resumeShipIntent(intentId) -> runIteration
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 67.50% 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 clearly identifies the main changes: wiring the ship iteration loop, tickle scheduler, and four scoped executors. It directly corresponds to the core objectives described in the PR.
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.


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 40 minutes and 49 seconds.

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

@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: 20

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

Inline comments:
In `@specs/20260429-212559-ship-iteration-wiring/quickstart.md`:
- Around line 110-137: The quickstart's S7–S9 expected outcomes are inaccurate
relative to the implemented executors; update the S7 — fix-thread, S8 —
explain-thread and S9 — open-pr entries in quickstart.md (the three "S7", "S8",
"S9" section headings) to either mark these scenarios as deferred for this
release or change their "Expected" lines to match the scaffolded-halt behavior
(i.e., no push/resolve/new-PR) so the document reflects shipped behavior; keep
the section headings and example actions the same but edit the Expected
paragraphs accordingly and add a short note explaining that full
push/resolve/new-PR behavior is planned for a future release.

In `@specs/20260429-212559-ship-iteration-wiring/tasks.md`:
- Around line 195-197: The ordered-list prefixes on the three lines starting
with "5. T017–T023 (US2) → quickstart S3 passes.", "6. T024–T036 (US3) →
quickstart S4–S9 all pass.", and "7. T037–T043 (Polish + full e2e)." must be
changed to a valid Markdown ordered list to satisfy MD029; update those prefixes
to either a sequential "1., 2., 3." sequence or all "1." (e.g., "1.
T017–T023...", "2. T024–T036...", "3. T037–T043..." or all "1.") so the linter
accepts the list.

In `@src/daemon/job-executor.ts`:
- Around line 185-190: The scoped-job branch returns before registering the job
in the daemon's activeJobs map, so functions like evaluateOffer, heartbeats,
idle shutdown, and handleJobCancel treat scoped jobs as non-running; before
calling runScopedJob(payload, capabilities, send) you should create and insert
an ActiveJob record (including any cancellation token/state) keyed by the job id
into activeJobs, ensure runScopedJob is awaited/started while that ActiveJob
remains present, and remove/clear the ActiveJob and cancellation state after
runScopedJob completes or fails; update references around
payload.payload.scoped, runScopedJob, activeJobs, ActiveJob, and handleJobCancel
to rely on that registration so scoped jobs are tracked and cancellable.
- Around line 498-507: The logged event key uses scoped.jobKind directly
(producing values like "scoped-rebase") so change the code around the
logger.error call to normalize/mapping scoped.jobKind before constructing the
event key (e.g., strip a "scoped-" prefix or map known kinds such as
"scoped-rebase" -> "rebase", "scoped-fix_thread" -> "fix_thread"), then use the
normalized value in the event string passed to logger.error (the block that
builds { event: `ship.scoped.${scoped.jobKind}.daemon.failed`, ... } should be
updated to use the normalized variable); also update docs/OBSERVABILITY.md to
include the resulting event keys so they match the code.

In `@src/daemon/scoped-explain-thread-executor.ts`:
- Around line 51-83: In executeScopedExplainThread, guard the GitHub reply
posting by first checking the scoped-job idempotency using
input.triggerCommentId (or the corresponding idempotency field on
ScopedExplainThreadExecutorInput) and skip calling
octokit.rest.pulls.createReplyForReviewComment if that triggerCommentId was
already handled; if already processed return a halted/outcome indicating
deduplication without posting, otherwise mark the triggerCommentId as handled
(persist or atomic check) and then call createReplyForReviewComment and return
the existing threadReplyId as currently implemented.

In `@src/daemon/scoped-open-pr-executor.ts`:
- Around line 35-40: The status union on ScopedOpenPrOutcome is missing
"failed"; update the interface ScopedOpenPrOutcome to include readonly status:
"succeeded" | "failed" | "halted" so it matches the scopedJobResultSchema and
allows future error paths, and then ensure any code paths that construct
ScopedOpenPrOutcome (e.g., functions that return a scoped open PR result) can
produce "failed" where appropriate or are updated to satisfy the widened type.

In `@src/orchestrator/connection-handler.ts`:
- Around line 576-590: handleScopedJobCompletion currently trusts
msg.payload.deliveryId and clears offers without verifying the socket is a
registered owner; update handleScopedJobCompletion to first ensure
ws.data.daemonId is present/registered and that the execution referenced by
deliveryId is actually assigned to that daemon before removing offers or marking
failures: fetch the execution by deliveryId (e.g., via your existing
getExecutionByDeliveryId/findExecutionByDeliveryId function or equivalent),
compare execution.assignedDaemonId (or execution.daemonId) to ws.data.daemonId,
and if they don’t match or ws is unregistered, reject/ignore the scoped
completion (do not call removePendingOffer or proceed with failure handling);
keep getPendingOffer/removePendingOffer logic only after ownership is validated.
- Around line 592-623: The non-success branch must mirror handleResult() and
decrement the active-job counters that handleAccept() incremented, and ensure
markExecutionFinalized is called with the correct boolean (true only for
success); update the failure/halted path (the block using logger.warn, jobKind,
daemonId, offerId, deliveryId, msg.payload.reason) to: 1) decrement both scoped
active-job counters the same way handleResult() does, 2) call
markExecutionFinalized(deliveryId, false) for halted/failed (so only the success
branch uses markExecutionFinalized(deliveryId, true)), and 3) ensure any success
path still marks completed as before.

In `@src/orchestrator/job-queue.ts`:
- Around line 22-28: threadRefSchema currently validates startLine and endLine
independently so inverted ranges like {startLine:20,endLine:10} slip through;
add an object-level refinement to threadRefSchema (the Zod schema named
threadRefSchema) that enforces startLine <= endLine (or startLine < endLine if
inclusive ranges are not allowed) and returns a clear refinement error message,
so invalid ranges are rejected at the schema boundary before enqueueing or
passing to the scoped executors.

In `@src/shared/ws-messages.ts`:
- Around line 74-80: The scopedThreadRefSchema currently allows inverted ranges;
add a Zod refinement to enforce startLine <= endLine on scopedThreadRefSchema
(e.g., use .refine or .superRefine on scopedThreadRefSchema to validate
{startLine, endLine} and throw a clear error message like "startLine must be <=
endLine"); update the validation to attach the error to the relevant fields
(startLine/endLine) so parsing fails when ranges are inverted.
- Around line 158-179: The schema scopedJobResultSchema currently allows status:
"succeeded" without rebaseOutcome; update the "scoped-rebase" branch so that
when jobKind === "scoped-rebase" and status === "succeeded" rebaseOutcome is
required (non-optional), while statuses "failed" and "halted" keep rebaseOutcome
optional. Implement this by replacing the single z.object for scoped-rebase with
two explicit variants (one object for status: "succeeded" that includes a
required rebaseOutcome, and one object for status: z.enum(["failed","halted"])
that keeps rebaseOutcome optional) inside the discriminated union over jobKind;
keep the same nested rebaseOutcome discriminatedUnion structure and preserve
reason, commentId and other fields.

In `@src/workflows/ship/iteration.ts`:
- Around line 122-186: The DB→queue handoff is not atomic: insertQueued,
enqueueJob, and the final appendIteration (functions insertQueued, enqueueJob,
appendIteration) are separate cross-store writes and can leave inconsistent
state if Valkey or Postgres fails. Fix by making insertQueued and the
action-iteration appendIteration run inside a single Postgres transaction and
replace the direct enqueueJob call with an outbox insert (e.g., jobs_outbox or
workflow_outbox row containing childDeliveryId, run.id, workflowName) written in
that same transaction; remove the inline enqueueJob from this code path and let
the separate reconciler/worker pick up the outbox and call enqueueJob reliably
after the DB commit. Also ensure countIterations/countActionIterations logic
uses the committed iterations so the cap logic remains consistent.

In `@src/workflows/ship/log-fields.ts`:
- Around line 65-103: ShipLogFieldsSchema.event currently allows any string;
tighten it to only accept the canonical literals defined in SHIP_LOG_EVENTS by
deriving a union type from its values (e.g. extract the string literal union
from typeof SHIP_LOG_EVENTS) and use that union as the schema/type for
ShipLogFieldsSchema.event; update the schema definition that references
ShipLogFieldsSchema.event to use this derived union so any typos against
SHIP_LOG_EVENTS will fail validation at compile/runtime.

In `@src/workflows/ship/scoped/dispatch-scoped.ts`:
- Around line 71-77: deriveTriggerCommentId currently returns a fake positive id
(1) for commands without a thread_id which causes idempotency collisions; change
deriveTriggerCommentId(CanonicalCommand) to return an optional (number |
undefined) and return undefined when thread_id is missing/invalid instead of 1,
then update all callers (e.g., any durable idempotency key builders or scoped
job dispatch code that reads deriveTriggerCommentId) to handle undefined as a
distinct code path (do not fold undefined into a shared sentinel); ensure
durable idempotency keys incorporate the absence-of-trigger as its own value so
different non-threaded requests aren’t treated as duplicates.

In `@src/workflows/ship/session-runner.ts`:
- Around line 243-269: resumeShipIntent currently only logs and returns, so a
tickle doesn't actually resume processing; update resumeShipIntent (which calls
getIntentById) to flip a paused intent back to active (or ensure status remains
active), persist that change, and invoke the existing probe/enqueue logic used
by the normal ship loop (e.g., call the probe function and/or
enqueueShipIntent/queueNextIteration routine) so the intent is re-probed and the
next workflow iteration is scheduled; ensure any errors are logged and the
function returns after scheduling.

In `@src/workflows/ship/workflow-context.ts`:
- Around line 47-52: The extractShipIntentId function currently returns any
non-empty string; change it to validate that the extracted candidate is a UUID
before returning it: after obtaining candidate from state (in
extractShipIntentId) run it against a canonical UUID regex (case-insensitive
8-4-4-4-12 hex groups) and return undefined if it does not match. Keep the
existing null/object checks and only return candidate when typeof candidate ===
"string" and it matches the UUID pattern.

In `@test/webhook/events/issue-comment.test.ts`:
- Around line 53-59: The test embeds a duplicated SCOPED_JOB_KINDS array and
isScopedJob mock; extract these literals into a shared test helper so all suites
reuse the same source of truth. Create a helper (e.g., export SCOPED_JOB_KINDS
and isScopedJobMock from a test utils module) and update this test to import and
use those exports instead of the inline array and function, ensuring the mock
name matches the original symbols (SCOPED_JOB_KINDS, isScopedJob) so future
changes in src/orchestrator/job-queue.ts stay synchronized across tests.

In `@test/workflows/ship/iteration.test.ts`:
- Around line 265-270: The count parsing is overly defensive: simplify the
assertion by directly checking the numeric count returned by the query instead
of converting types; replace the current multi-step logic around iterRows and
count with a direct expectation on iterRows[0].count (the result of the SELECT
COUNT(*)::int query) to be 0, keeping the same query via requireSql and the same
variable names (iterRows) so it's easy to locate and update.

In `@test/workflows/ship/session-runner.resume.test.ts`:
- Around line 92-116: The test currently asserts the intent's status remains
"active", which locks in a no-op placeholder; instead, change the assertion to
verify the observable resume side-effect from resumeShipIntent (e.g., that a
tickle.due event was emitted/queued) rather than asserting status unchanged.
Locate the test using resumeShipIntent, insertIntent and getIntentById and
replace the status assertion with an assertion against the
scheduler/event-emitter mock or the persisted tickle event (whatever the
codebase uses to surface tickle.due) to confirm resumeShipIntent produced the
expected tickle.due side effect for the active intent.
🪄 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: 72a6a30f-2435-4838-af14-e3c0f07a77a2

📥 Commits

Reviewing files that changed from the base of the PR and between 33132a3 and f41a2a4.

📒 Files selected for processing (51)
  • CLAUDE.md
  • docs/BOT-WORKFLOWS.md
  • docs/OBSERVABILITY.md
  • scripts/check-no-destructive-actions.ts
  • specs/20260427-201332-pr-shepherding-merge-ready/tasks.md
  • specs/20260429-212559-ship-iteration-wiring/checklists/requirements.md
  • specs/20260429-212559-ship-iteration-wiring/contracts/job-kinds.md
  • specs/20260429-212559-ship-iteration-wiring/contracts/ws-messages.md
  • specs/20260429-212559-ship-iteration-wiring/data-model.md
  • specs/20260429-212559-ship-iteration-wiring/plan.md
  • specs/20260429-212559-ship-iteration-wiring/quickstart.md
  • specs/20260429-212559-ship-iteration-wiring/research.md
  • specs/20260429-212559-ship-iteration-wiring/spec.md
  • specs/20260429-212559-ship-iteration-wiring/tasks.md
  • src/app.ts
  • src/daemon/job-executor.ts
  • src/daemon/scoped-explain-thread-executor.ts
  • src/daemon/scoped-fix-thread-executor.ts
  • src/daemon/scoped-open-pr-executor.ts
  • src/daemon/scoped-rebase-executor.ts
  • src/orchestrator/connection-handler.ts
  • src/orchestrator/job-dispatcher.ts
  • src/orchestrator/job-queue.ts
  • src/orchestrator/queue-worker.ts
  • src/shared/daemon-types.ts
  • src/shared/workflow-types.ts
  • src/shared/ws-messages.test.ts
  • src/shared/ws-messages.ts
  • src/webhook/router.ts
  • src/workflows/dispatcher.ts
  • src/workflows/handlers/ship.ts
  • src/workflows/orchestrator.ts
  • src/workflows/ship/iteration.ts
  • src/workflows/ship/log-fields.ts
  • src/workflows/ship/scoped/dispatch-scoped.ts
  • src/workflows/ship/session-runner.ts
  • src/workflows/ship/workflow-context.ts
  • test/daemon/scoped-explain-thread-executor.test.ts
  • test/daemon/scoped-fix-thread-executor.test.ts
  • test/daemon/scoped-open-pr-executor.test.ts
  • test/daemon/scoped-rebase-executor.test.ts
  • test/orchestrator/connection-handler.test.ts
  • test/orchestrator/job-dispatcher.test.ts
  • test/webhook/events/issue-comment.test.ts
  • test/webhook/router.test.ts
  • test/workflows/dispatcher.test.ts
  • test/workflows/handlers/ship.test.ts
  • test/workflows/orchestrator.test.ts
  • test/workflows/ship/iteration.test.ts
  • test/workflows/ship/session-runner.resume.test.ts
  • test/workflows/ship/tickle-scheduler.test.ts

Comment thread specs/20260429-212559-ship-iteration-wiring/quickstart.md Outdated
Comment thread specs/20260429-212559-ship-iteration-wiring/tasks.md Outdated
Comment thread src/daemon/job-executor.ts
Comment thread src/daemon/job-executor.ts
Comment thread src/daemon/scoped-explain-thread-executor.ts Outdated
Comment thread src/workflows/ship/session-runner.ts Outdated
Comment thread src/workflows/ship/workflow-context.ts
Comment thread test/webhook/events/issue-comment.test.ts
Comment thread test/workflows/ship/iteration.test.ts Outdated
Comment thread test/workflows/ship/session-runner.resume.test.ts Outdated
chrisleekr and others added 2 commits April 30, 2026 17:46
Senior review on PR #79 surfaced three classes of breakage:

1. Scoped jobs were dead on the wire. The daemon had no
   `scoped-job-offer` handler (silent fall-through, then orchestrator
   timeout/retry until terminal), and `job:payload` for scoped jobs
   never carried the `scoped` discriminator. Fix: add the daemon-side
   case via new `evaluateScopedOffer`, thread `scoped` through
   `JobAcceptParams`/`handleAccept`/`handleJobAccept`, branch
   `handleAccept` to mint the installation token directly from
   `scoped.installationId` (skipping the legacy `executions.context_json`
   lookup that scoped jobs don't write).

2. The iteration → tickle → resume loop was open. `resumeShipIntent`
   was a stub that only logged, so every tickle fired by the cascade
   was a no-op until cap/deadline. Fix: inject an `octokitFactory` from
   `app.ts`, run `runProbe`, terminate on ready, otherwise call
   `runIteration`. Also add an in-flight guard so a non-terminal
   `workflow_runs` row tagged with `shipIntentId` blocks double-enqueue
   when cascade and a fresh comment trigger race. Cascade now skips
   ZADD for failed children so a permanently broken intent does not
   burn the whole iteration cap re-firing.

3. Scoped completion leaked one capacity slot per run.
   `handleScopedJobCompletion` never called
   `decrementActiveCount`/`decrementDaemonActiveJobs`. Fix: decrement
   on every status branch; replace the inverted
   `markExecutionFinalized(_, status === "halted")` with an explicit
   3-state `finalizeScopedExecution` so halted is no longer
   collapsed onto `success=true`.

Plus security hardening on the `scoped-rebase` credential helper —
installation token now passed via `$.env({ GIT_TOKEN })` instead of
embedded in a printf script body, helper file moved inside the 0700
`mkdtemp` workDir, single rm in finally. Three scaffold executors
wrap Octokit calls so 4xx maps to `halted` (contractual outcome for
scaffolding-only) instead of `failed`. `runScopedJob` switch gets a
`never`-based exhaustiveness default. `WS_REJECT_REASONS` constants
now actually used by the daemon. Hardcoded `chrisleekr-bot[bot]`
replaced with `config.botAppLogin`. `PendingOffer.scoped` re-validated
via `QueuedJobSchema.safeParse` before any cast.

New tests cover every C/H fix file-by-file: scoped offer evaluator
(C1/H3/H4), Octokit-error → halted (H2), capacity decrement on
succeeded/failed/halted (C4), cascade skip on failed child (H1),
in-flight guard (H6), terminal-ready on resume (C3).

Quality gates: typecheck/lint/format/check:no-destructive all clean.
Pre-existing cross-file mock-isolation test leak unchanged from
baseline (each new test passes file-by-file).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…iminator

PR #79 made QueuedJob a Zod discriminated union and updated the poison-pill log message;
existing tests built jobs without kind and asserted the old copy, breaking CI on two files.
Also extends the cancellation test's valkey mock to expose requireValkeyClient —
iteration.ts (transitively imported via session-runner) now calls it on the legacy job-queue
path.

- test/orchestrator/job-queue.test.ts: makeQueuedJob adds kind: 'legacy'; log
  assertions match the new 'Job enqueued' payload and 'dropping poison pill' message.
- test/workflows/ship/cancellation.test.ts: shared valkeyClient stub exposed via both
  getValkeyClient and requireValkeyClient; LPUSH/LMOVE/LREM no-ops added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.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: 5

♻️ Duplicate comments (1)
src/workflows/ship/log-fields.ts (1)

70-105: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tighten event validation to this canonical literal set.

SHIP_LOG_EVENTS is now the source of truth, but Line 35 still accepts any non-empty string, so typoed event names will continue to pass validation and drift past tests/docs unnoticed.

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

In `@src/workflows/ship/log-fields.ts` around lines 70 - 105, The event validator
currently accepts any non-empty string; tighten it to the canonical literal set
by deriving a union type from SHIP_LOG_EVENTS and using that for both
compile-time and runtime checks. Create a type like ShipLogEvent =
ExtractValues<typeof SHIP_LOG_EVENTS> (or use a mapped/recursive helper to
flatten nested keys) and replace the loose string type in the
validator/signature with ShipLogEvent; also update the runtime validation to
check membership against the flattened values of SHIP_LOG_EVENTS (e.g., build a
Set of Object.values(...) from SHIP_LOG_EVENTS) so only the exact literal
identifiers are allowed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/daemon/scoped-fix-thread-executor.ts`:
- Around line 72-98: The catch-all around
octokit.rest.pulls.createReplyForReviewComment incorrectly halts on transient
5xx/transport errors; change the catch in scoped-fix-thread-executor to inspect
the Octokit error status (e.g., err.status or HTTP status on the thrown error)
and: if status is a 4xx (client/semantic) treat as user-state change—log using
SHIP_LOG_EVENTS.scoped.fixThread.daemonFailed and return { status: "halted",
reason: ... }—otherwise rethrow the error so it propagates as a executor failure
(allowing retries/reporting); also include the original error details in the log
payload for diagnostics instead of the literal event string.

In `@src/daemon/scoped-open-pr-executor.ts`:
- Around line 67-93: The current catch in the scoped-open-pr executor around
octokit.rest.issues.createComment indiscriminately maps all errors to "halted"
and suppresses retries; change it to inspect the Octokit/HTTP error (the error
thrown by octokit.rest.issues.createComment) and only convert known
repository/issue terminal states to halted (e.g. HTTP 404/410 or other explicit
"issue missing/closed" codes your codebase treats as terminal); for all other
errors (network failures, 5xx, rate-limits, transient RequestErrors) re-throw
the error so the outer daemon handler records a failure and enables
retry/orchestration. Locate the try/catch surrounding
octokit.rest.issues.createComment in scoped-open-pr executor and replace the
blanket catch behavior with conditional handling based on err.status (or the
Octokit RequestError type/status) as described.

In `@src/orchestrator/job-dispatcher.ts`:
- Around line 361-377: In reconstructJobFromOffer, when
QueuedJobSchema.safeParse(offer.scoped) fails or isScopedJob(reparsed.data) is
false, do not fall through to legacy reconstruction; instead return a terminal
failure (e.g., throw a descriptive Error or return a failure result) right there
so the dispatcher won't re-enqueue a different job kind for the same repo/PR.
Use the existing context (offer.offerId, offer.deliveryId) and include
reparsed.error or a "shape-not-scoped" marker in the thrown error/returned
failure so callers can surface and handle this terminal corruption case; keep
the logger.error call but then stop execution of reconstructJobFromOffer rather
than continuing to legacy branches.

In `@src/workflows/ship/iteration.ts`:
- Around line 75-81: The inflight guard in iteration.ts that calls
findInflightShipIntentRun(intent.id, sql) is racy under concurrent resumes; wrap
the check+insert into a DB-enforced critical section: either obtain a row lock
(e.g., SELECT ... FOR UPDATE on the ShipIntent row) or use an advisory lock
keyed by intent.id around the logic that checks for an in-flight run and inserts
the child workflow_runs row, or instead enforce a unique in-flight constraint
(unique index keyed by shipIntentId) and handle duplicate-key retries; apply the
same protection to the similar logic referenced around lines 141-173 so only one
caller can create the in-flight run and others observe/handle the contention.

---

Duplicate comments:
In `@src/workflows/ship/log-fields.ts`:
- Around line 70-105: The event validator currently accepts any non-empty
string; tighten it to the canonical literal set by deriving a union type from
SHIP_LOG_EVENTS and using that for both compile-time and runtime checks. Create
a type like ShipLogEvent = ExtractValues<typeof SHIP_LOG_EVENTS> (or use a
mapped/recursive helper to flatten nested keys) and replace the loose string
type in the validator/signature with ShipLogEvent; also update the runtime
validation to check membership against the flattened values of SHIP_LOG_EVENTS
(e.g., build a Set of Object.values(...) from SHIP_LOG_EVENTS) so only the exact
literal identifiers are allowed.
🪄 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: cdae9cb2-ed88-4fda-bfeb-255ee990ec92

📥 Commits

Reviewing files that changed from the base of the PR and between f41a2a4 and c4c300c.

📒 Files selected for processing (21)
  • src/app.ts
  • src/daemon/job-executor.ts
  • src/daemon/main.ts
  • src/daemon/scoped-explain-thread-executor.ts
  • src/daemon/scoped-fix-thread-executor.ts
  • src/daemon/scoped-open-pr-executor.ts
  • src/daemon/scoped-rebase-executor.ts
  • src/orchestrator/connection-handler.ts
  • src/orchestrator/job-dispatcher.ts
  • src/workflows/orchestrator.ts
  • src/workflows/ship/iteration.ts
  • src/workflows/ship/log-fields.ts
  • src/workflows/ship/session-runner.ts
  • test/daemon/scoped-fix-thread-executor.test.ts
  • test/daemon/scoped-offer-evaluator.test.ts
  • test/daemon/scoped-rebase-executor.test.ts
  • test/orchestrator/connection-handler.test.ts
  • test/orchestrator/job-dispatcher.test.ts
  • test/workflows/orchestrator.test.ts
  • test/workflows/ship/iteration.test.ts
  • test/workflows/ship/session-runner.resume.test.ts

Comment thread src/daemon/scoped-fix-thread-executor.ts Outdated
Comment thread src/daemon/scoped-open-pr-executor.ts Outdated
Comment thread src/orchestrator/job-dispatcher.ts Outdated
Comment thread src/workflows/ship/iteration.ts
Comment thread test/workflows/ship/iteration.test.ts
chrisleekr and others added 4 commits April 30, 2026 18:19
Acts on CodeRabbit comments on PR #79 covering thread-range validation,
scoped-rebase outcome contract, daemon ownership checks, observability key
normalization, and the scoped-open-pr type union.

- ws-messages: split scoped-rebase result schema so 'succeeded' requires
  rebaseOutcome; add startLine<=endLine refinement on scopedThreadRefSchema.
- job-queue: same startLine<=endLine refinement on the orchestrator-side
  threadRefSchema mirror.
- daemon/job-executor: register scoped offers in activeJobs for the duration
  of runScopedJob so heartbeats/idle/cancel see the daemon as busy; emit
  per-kind 'ship.scoped.<kind>.daemon.failed' event keys matching the
  FR-018 names documented in docs/OBSERVABILITY.md.
- orchestrator/connection-handler: reject scoped-job-completion from
  unregistered or non-owner daemons before mutating offer/capacity state;
  emit per-kind event keys.
- daemon/scoped-rebase-executor: distinguish a real merge conflict
  (unmerged index entries) from other merge failures (auth/fs/unrelated
  histories); throw on the latter so the executor catch path returns
  halted instead of reporting a phantom conflict.
- daemon/scoped-open-pr-executor: widen ScopedOpenPrOutcome.status to
  include 'failed' to match the WS schema.
- workflows/ship/workflow-context: extractShipIntentId now validates via
  ShipIntentContextSchema.safeParse so non-UUID candidates are rejected.
- test/iteration: simplify ::int count assertion.
- specs/quickstart.md: mark S7-S9 expected results as 'scaffolding
  boundary in this slice' to match shipped halted behaviour.
- specs/tasks.md: fix MD029 ordered-list prefixes (5/6/7 -> 1/2/3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… bookkeeping

T042 quickstart against @chrisleekr-bot-dev surfaced five real bugs that

all silently broke the ship-iteration wiring on a real GitHub install:

1. NL classifier silently dropped Haiku-fenced JSON

   Anthropic Haiku 4.5 (TRIAGE_MODEL default) wraps single-object JSON

   responses in a markdown code fence even when the system prompt asks

   for raw JSON. Every NL-form trigger (`@chrisleekr-bot-dev <verb>`)

   was silently classified as `none` and fell through to the legacy

   intent classifier, which routes ship to issues only — making

   `@chrisleekr-bot-dev ship` on a PR a no-op. Added `stripJsonFence`

   helper and applied it in both nl-classifier and meta-issue-classifier.

2. Iteration handler skipped recordWorkflowExecution

   The new iteration handler enqueued workflow-run jobs without first

   inserting the executions row, so the daemon's accept handler

   rejected every offer with "No execution context found — producer

   did not call createExecution". Mirrored the legacy dispatcher's

   recordWorkflowExecution + enqueueJob ordering.

3. scoped-rebase clone collided with mkdtemp directory

   `git clone` refuses non-empty target directories; the executor

   passed the freshly-mkdtemp'd workDir directly. Clone now lands in

   workDir/repo while workDir still gates access via 0700.

4. scoped-rebase fetch refspec missed remote-tracking ref

   The clone uses `--single-branch --branch=<head_ref>` which scopes

   the remote.origin.fetch refspec to head only. A bare

   `git fetch origin <base_ref>` then downloaded commits but did not

   update `refs/remotes/origin/<base_ref>`, so `git merge` exited 1

   with "not something we can merge". Pass an explicit refspec.

5. SC-007 false positive in resolve.ts agent prompt

   The `grep -rn "not yet wired" src/` pre-flight matched a prompt

   string about CI fix-attempt cross-run enforcement (unrelated to

   the four scoped-command notices SC-007 targets). Reworded to

   "a planned follow-up" so the literal pre-flight stays clean.

Three integration tests added (T011+T015 ship-iteration loop with

cascade ZADD; T018 tickle-scheduler resume; T028 scoped-rebase WS

contract round-trip). Six new unit tests for stripJsonFence/fence-aware

classifyComment. tasks.md: every task in the spec is now [X].

Verified end-to-end on chrisleekr/github-app-playground via the local

@chrisleekr-bot-dev installation: S2 ship-from-thread, S4 rebase no-op,

S5 rebase clean, S6 rebase conflict, S7 fix-thread, S8 explain-thread,

S9 open-pr non-actionable, S9b open-pr actionable, S10 abort-ship,

S11 stop+resume — all pass against real GitHub state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Distinguish 4xx semantic halts from transient 5xx in three scoped
  daemon executors (fix-thread, explain-thread, open-pr): only client
  errors map to halted; transport / 5xx rethrow so the daemon's outer
  catch reports failed and the orchestrator can retry.
- Switch the per-executor `*_reply_failed` log keys to the canonical
  `SHIP_LOG_EVENTS.scoped.*.daemonFailed` literals.
- Fail closed in `reconstructJobFromOffer` when a scoped offer fails
  re-validation: return null and mark the execution failed instead of
  silently falling back to legacy/workflow-run reconstruction (which
  would dispatch the wrong job kind against the same target).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Replace fall-through ladder with exhaustive switch over RebaseOutcome.kind
  in runScopedJob (a future variant now fails type-check instead of silently
  mapping to "closed").
- Move SUPPORTED_SCOPED_KINDS const below the import block in daemon/main.ts
  (was wedged between two import groups).
- Iteration handler: use serializeShipWorkflowContext from
  workflows/ship/workflow-context.ts so the producer side honors the schema
  the orchestrator's extractShipIntentId reader validates against (the
  helper was previously exported but unused).
- Fix duplicate "// 8." comment numbering — second step is "// 9.".

No behavior change. Quality gates clean (typecheck, lint, format); affected
tests (scoped-rebase-executor, scoped-rebase-roundtrip, ship/iteration)
pass in isolation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.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: 9

Caution

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

⚠️ Outside diff range comments (1)
src/daemon/main.ts (1)

167-172: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Mark accepted offers as activity before sending job:accept.

startEphemeralIdleLoop() keys off lastActiveAtMs, but neither accept path updates it. If an ephemeral daemon is near its idle timeout, it can accept an offer and still self-drain during the orchestrator-side token/context lookup because activeJobs stays 0 until job:payload arrives. Call markActive() before sending job:accept in both branches.

Suggested fix
       if (evaluation.accept) {
+        markActive();
         wsClient.send({
           type: "job:accept",
           ...createMessageEnvelope(msg.id),
           payload: {},
@@
       if (evaluation.accept) {
+        markActive();
         wsClient.send({
           type: "job:accept",
           ...createMessageEnvelope(msg.id),
           payload: {},

Based on learnings, src/daemon/main.ts must exit after EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS of idle when DAEMON_EPHEMERAL=true.

Also applies to: 196-201

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

In `@src/daemon/main.ts` around lines 167 - 172, The accept-paths currently send
wsClient.send({type: "job:accept", ...}) without updating activity, so
startEphemeralIdleLoop() may think the daemon is idle; call markActive()
immediately before sending job:accept in both acceptance branches (the block
handling evaluation.accept around wsClient.send and the similar block at the
later branch around lines 196-201) to update lastActiveAtMs; also ensure
startEphemeralIdleLoop() enforces exit after EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS
when DAEMON_EPHEMERAL is true so ephemeral daemons actually terminate after the
configured idle timeout.
♻️ Duplicate comments (2)
src/orchestrator/connection-handler.ts (2)

670-687: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

succeeded and halted scoped runs still never leave running.

Every scoped accept already calls markExecutionRunning(offer.deliveryId), but finalizeScopedExecution() only writes the "failed" branch. That leaves successful and halted scoped executions permanently in running, which breaks terminal-state queries and any retry/reaper logic that relies on executions reaching a terminal status. Even if halted is intentionally non-failure, it still needs a terminal write such as markExecutionCompleted(...).

Suggested fix
 async function finalizeScopedExecution(
   deliveryId: string,
   status: "succeeded" | "halted" | "failed",
 ): Promise<void> {
   if (status === "failed") {
@@
     }
     return;
   }
-  // succeeded / halted — no-op on the executions row; the daemon executor
-  // already finalized any user-visible state (thread reply, push, etc.).
+  try {
+    await markExecutionCompleted(deliveryId, {});
+  } catch (err) {
+    logger.warn(
+      { err: err instanceof Error ? err.message : String(err), deliveryId, status },
+      "markExecutionCompleted for scoped completion failed (non-fatal)",
+    );
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/orchestrator/connection-handler.ts` around lines 670 - 687,
finalizeScopedExecution currently only updates the "failed" path, leaving
succeeded/halted runs stuck as "running"; update finalizeScopedExecution to
write a terminal state for non-failed runs by calling the appropriate completion
API (e.g. markExecutionCompleted or whatever terminal updater is used in this
module) with the deliveryId and mapped terminal status for both "succeeded" and
"halted" cases (ensure the call replaces/clears the running state that
markExecutionRunning set); keep the existing error handling pattern (try/catch +
logger.warn) around the completion call to mirror the failed branch behavior.

594-621: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Mirror handleResult() with a durable ownership/late-result guard here.

handleAccept() removes the offer at Line 727 before handleScopedAccept() runs, so getPendingOffer(offerId) is usually undefined by the time a real scoped completion arrives. That makes this check ineffective in production: a replayed or forged scoped-job-completion can still decrement capacity and drive finalization without proving it owns deliveryId. Validate against durable execution state before touching counters, e.g. getExecutionState(deliveryId) with both status and daemonId checks.

Suggested hardening
   const daemonId = ws.data.daemonId;
   const offerId = msg.payload.offerId;
   const { jobKind, status, deliveryId } = msg.payload;
@@
-  const offer = getPendingOffer(offerId);
-  if (offer?.daemonId !== undefined && offer.daemonId !== daemonId) {
+  const state = await getExecutionState(deliveryId);
+  if (
+    state === null ||
+    state.daemonId !== daemonId ||
+    state.status === "completed" ||
+    state.status === "failed"
+  ) {
     logger.warn(
       {
         event: "ws.scoped_completion.unauthorized",
         daemonId,
         offerId,
-        ownerDaemon: offer.daemonId,
+        deliveryId,
+        assignedDaemon: state?.daemonId ?? null,
+        currentStatus: state?.status ?? null,
       },
-      "scoped-job-completion from non-owner daemon — ignoring",
+      "scoped-job-completion failed ownership/finality validation — ignoring",
     );
     return;
   }
+  const offer = getPendingOffer(offerId);
   if (offer !== undefined) {
     removePendingOffer(offerId);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/orchestrator/connection-handler.ts` around lines 594 - 621, The ownership
check using getPendingOffer(offerId) is insufficient because
handleAccept/handleScopedAccept typically remove the in-memory offer before real
scoped completions arrive; update the scoped completion path to mirror
handleResult by first validating durable execution state via
getExecutionState(deliveryId) (verify execution.status is not finalized and
execution.daemonId === daemonId) before touching counters or removing offers,
only call decrementActiveCount() and await decrementDaemonActiveJobs(daemonId)
after that durable guard passes, and avoid removing persistent state or
decrementing capacity on failed ownership/late-result checks; reference
handleResult, handleAccept, handleScopedAccept, getPendingOffer,
getExecutionState, decrementActiveCount, decrementDaemonActiveJobs, deliveryId,
offerId, daemonId to locate the changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@specs/20260429-212559-ship-iteration-wiring/tasks.md`:
- Around line 21-22: Update all occurrences of the incorrect integration test
path "tests/integration/" to the correct "test/integration/" used by this PR;
search for and replace the string "tests/integration/" (appearing e.g. in the
tasks.md descriptions and any references at the noted locations) so integration
test references point at the existing test/integration/ directory.
- Line 31: The task description incorrectly states there will be five variants
for the discriminated union on QueuedJob but then lists six names; update the
text in specs/20260429-212559-ship-iteration-wiring/tasks.md so the variant
count matches the listed names (either change "five" to "six" or remove one of
the listed variants), and ensure the change references the QueuedJob
discriminated union and the specific variant names ("legacy", "workflow-run",
and the four "scoped-*" variants) so the spec and the implementation guidance
are consistent.

In `@src/daemon/job-executor.ts`:
- Around line 239-245: The activeJobs entry currently sets workDir to an empty
string for scoped jobs causing cleanup to derive a bogus credential filename
(`${job.workDir}.cred.sh`) — change the setter in activeJobs.set(…) to omit
workDir or set it to null/undefined for scoped jobs (identify scoped jobs via
payload.payload.scoped) and update any cleanup logic that constructs
`${job.workDir}.cred.sh` to first guard with a truthy workDir check (e.g., if
(!job.workDir) skip credential-file cleanup) so credential helper deletion only
runs when a real workspace path exists.
- Around line 238-250: When tracking scoped jobs in activeJobs (in the block
that calls runScopedJob), also create and store a jobAbortController in the
shared jobAbortControllers map keyed by offerId so handleJobCancel can abort the
controller instead of only emitting a synthetic job:result; update the finally
cleanup to delete the controller entry as well as activeJobs. Modify
runScopedJob to accept or look up the jobAbortController and check
controller.signal.aborted (or listen for abort) before sending the final
scoped-job-completion so it bails out if cancelled, preventing a duplicate final
message and double capacity decrement; ensure any early exit path also skips
emitting completion. Make sure handleJobCancel uses the same controller to
trigger cancellation.

In `@src/daemon/scoped-fix-thread-executor.ts`:
- Around line 98-108: The current 4xx check in scoped-fix-thread-executor.ts
(the status variable and the block that logs with
SHIP_LOG_EVENTS.scoped.fixThread.daemonFailed and returns {status: "halted",
reason: ...}) is too broad and suppresses retries for transient GitHub 403/429
errors; change the logic so that only true terminal client errors (e.g.,
resource not found/closed comment or PR states you expect) result in the halted
return, while 403 (Forbidden due to rate limiting/abuse) and 429 (Too Many
Requests) are rethrown (or returned as retryable) so upstream retry logic can
act; keep using the same status extraction (typeof err === "object" ... "status"
in err) and the existing logging but branch first to rethrow when status === 403
|| status === 429, and restrict the halted branch to the specific terminal
status codes you consider non-retryable.

In `@src/daemon/scoped-open-pr-executor.ts`:
- Around line 93-103: The current check in scoped-open-pr-executor.ts that halts
on any 4xx treats transient throttling (403/429) as terminal; modify the logic
that computes status (from err) so only specific terminal statuses (e.g., 404,
410, 422 — choose the repo/issue-terminal codes your service expects) cause the
halted return and warn log. Specifically, replace the broad condition "typeof
status === 'number' && status >= 400 && status < 500" with an explicit whitelist
(e.g., [404, 410, 422].includes(status)) and leave 403 and 429 to bubble (do not
return { status: 'halted' } for those); adjust the log message to include the
status and reason when halting and ensure non-whitelisted statuses are rethrown
or returned so the caller can retry.

In `@src/orchestrator/job-queue.ts`:
- Around line 153-158: The logger call in the schema failure path currently logs
raw.slice(0, 200) which can leak user content; update the error logging in the
block where you call QueuedJobSchema.safeParse(parsed) (check variables result,
parsed, raw) to avoid including raw payload snippets and instead log safe
metadata such as raw length and a checksum/hash (e.g., SHA-256) of raw; keep the
existing result.error.issues in the log, replace the raw field with something
like { length: raw.length, sha256: <computed-hash> } so you surface traceable
info without storing free-form user content.

In `@src/workflows/ship/iteration.ts`:
- Around line 38-42: RunIterationDeps currently advertises an injectable Valkey
client (valkey) but runIteration ignores input.valkey and always calls the
global enqueueJob path; either wire the injected client through the queue helper
or remove the field. Fix by updating runIteration to pass input.valkey (type
Pick<RedisClient,"send">) into the queue helper call(s) that currently invoke
enqueueJob so enqueueJob (or a new wrapper) uses the provided client instead of
the global one, or remove valkey from RunIterationDeps and all references so the
public contract matches actual behavior (ensure to update any helper functions
that accept the client, e.g., enqueueJob or its callers in the 200-215 region).

In `@test/daemon/scoped-fix-thread-executor.test.ts`:
- Around line 30-49: Update the test fixtures passed to executeScopedFixThread
so threadRef.commentId and triggerCommentId are different (e.g.,
threadRef.commentId: 7777, triggerCommentId: 8888) in the blocks around the
three locations (lines ~30-49, ~63-76, ~95-108); ensure the test still expects
outcome.status and outcome.threadReplyId, but add/adjust the mockCreateReply
assertion to verify the outbound createReply call used the threadRef.commentId
value (the comment_id passed to createReplyForReviewComment/mockCreateReply
matches threadRef.commentId) rather than relying on identical IDs.

---

Outside diff comments:
In `@src/daemon/main.ts`:
- Around line 167-172: The accept-paths currently send wsClient.send({type:
"job:accept", ...}) without updating activity, so startEphemeralIdleLoop() may
think the daemon is idle; call markActive() immediately before sending
job:accept in both acceptance branches (the block handling evaluation.accept
around wsClient.send and the similar block at the later branch around lines
196-201) to update lastActiveAtMs; also ensure startEphemeralIdleLoop() enforces
exit after EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS when DAEMON_EPHEMERAL is true so
ephemeral daemons actually terminate after the configured idle timeout.

---

Duplicate comments:
In `@src/orchestrator/connection-handler.ts`:
- Around line 670-687: finalizeScopedExecution currently only updates the
"failed" path, leaving succeeded/halted runs stuck as "running"; update
finalizeScopedExecution to write a terminal state for non-failed runs by calling
the appropriate completion API (e.g. markExecutionCompleted or whatever terminal
updater is used in this module) with the deliveryId and mapped terminal status
for both "succeeded" and "halted" cases (ensure the call replaces/clears the
running state that markExecutionRunning set); keep the existing error handling
pattern (try/catch + logger.warn) around the completion call to mirror the
failed branch behavior.
- Around line 594-621: The ownership check using getPendingOffer(offerId) is
insufficient because handleAccept/handleScopedAccept typically remove the
in-memory offer before real scoped completions arrive; update the scoped
completion path to mirror handleResult by first validating durable execution
state via getExecutionState(deliveryId) (verify execution.status is not
finalized and execution.daemonId === daemonId) before touching counters or
removing offers, only call decrementActiveCount() and await
decrementDaemonActiveJobs(daemonId) after that durable guard passes, and avoid
removing persistent state or decrementing capacity on failed
ownership/late-result checks; reference handleResult, handleAccept,
handleScopedAccept, getPendingOffer, getExecutionState, decrementActiveCount,
decrementDaemonActiveJobs, deliveryId, offerId, daemonId to locate the changes.
🪄 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: fb273bf0-2b90-4b9e-82eb-a957c6f6e9fa

📥 Commits

Reviewing files that changed from the base of the PR and between c4c300c and 15caa9d.

📒 Files selected for processing (25)
  • specs/20260429-212559-ship-iteration-wiring/quickstart.md
  • specs/20260429-212559-ship-iteration-wiring/tasks.md
  • src/daemon/job-executor.ts
  • src/daemon/main.ts
  • src/daemon/scoped-explain-thread-executor.ts
  • src/daemon/scoped-fix-thread-executor.ts
  • src/daemon/scoped-open-pr-executor.ts
  • src/daemon/scoped-rebase-executor.ts
  • src/orchestrator/connection-handler.ts
  • src/orchestrator/job-dispatcher.ts
  • src/orchestrator/job-queue.ts
  • src/shared/ws-messages.ts
  • src/workflows/handlers/resolve.ts
  • src/workflows/ship/iteration.ts
  • src/workflows/ship/nl-classifier.ts
  • src/workflows/ship/scoped/meta-issue-classifier.ts
  • src/workflows/ship/workflow-context.ts
  • test/daemon/scoped-fix-thread-executor.test.ts
  • test/integration/scoped-rebase-roundtrip.test.ts
  • test/integration/ship-iteration-loop.test.ts
  • test/integration/ship-tickle-resume.test.ts
  • test/orchestrator/job-queue.test.ts
  • test/workflows/ship/cancellation.test.ts
  • test/workflows/ship/iteration.test.ts
  • test/workflows/ship/nl-classifier.test.ts

Comment thread specs/20260429-212559-ship-iteration-wiring/tasks.md Outdated
Comment thread specs/20260429-212559-ship-iteration-wiring/tasks.md Outdated
Comment thread src/daemon/job-executor.ts
Comment thread src/daemon/job-executor.ts
Comment thread src/daemon/scoped-fix-thread-executor.ts
Comment thread src/daemon/scoped-open-pr-executor.ts
Comment thread src/orchestrator/job-queue.ts
Comment thread src/workflows/ship/iteration.ts
Comment thread test/daemon/scoped-fix-thread-executor.test.ts
chrisleekr and others added 2 commits May 1, 2026 00:24
Nine unresolved threads from the latest CodeRabbit pass:

- job-executor scoped path now registers a jobAbortController and routes all
  scoped-job-completion sends through a sendIfNotAborted wrapper. handleJobCancel
  already aborts via the controller; combined, this stops cancel during a
  scoped run from emitting both a synthetic job:result and a later
  scoped-job-completion (was double-finalizing executions and double-decrementing
  capacity).
- registerExitCleanup now skips rmSync when workDir === "" so a process exit
  during a scoped run cannot target `.cred.sh` in the daemon's CWD.
- scoped-fix-thread, scoped-explain-thread, scoped-open-pr executors now rethrow
  on 429 and rate-limited 403 responses instead of halting on the blanket 4xx
  rule. Throttling is transient and must reach the orchestrator's retry layer.
- job-queue parseQueuedJob and the leased-job poison-pill log now log
  rawLength only — `raw.slice(0, 200)` could leak triggerBodyPreview /
  verdictSummary user content into operator logs.
- iteration.ts drops the unused `valkey` field from RunIterationDeps; the
  global enqueueJob path is the only writer, so the public contract no longer
  advertises an injectable seam that doesn't exist.
- tasks.md: corrected `tests/integration/` → `test/integration/` (5 sites)
  and the off-by-one variant count (`five` → `six`) in the QueuedJob
  discriminator description.
- scoped-fix-thread test fixtures use distinct commentId (7777) and
  triggerCommentId (8888) and assert the outbound comment_id matches
  threadRef.commentId so a future bug that wires the wrong id is caught.

Quality gates clean (typecheck, lint, format); per-file tests pass for every
touched module.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three remaining issues from CR review 4205792820 (round 3 fixes were
already in c15bada; this round adds the items not yet covered):

- daemon/main.ts now calls markActive() before sending job:accept in
  both the legacy and scoped offer branches. Previously the ephemeral
  idle loop keyed off lastActiveAtMs which only got bumped at
  job:payload, so a daemon near its EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS
  could self-drain during the orchestrator-side token/context lookup
  that runs between accept and payload.

- orchestrator/connection-handler.ts:finalizeScopedExecution now writes
  a terminal state for succeeded/halted via markExecutionCompleted.
  handleScopedAccept already calls markExecutionRunning, so without
  this every successful or halted scoped run stayed in 'running'
  forever — breaking FM-4 stale-execution recovery and any operator
  query that filters on terminal status. markExecutionCompleted only
  flips rows where status='running' so it is safe under races.

- orchestrator/connection-handler.ts:handleScopedJobCompletion replaces
  the ineffective getPendingOffer-based ownership check with a durable
  getExecutionState guard that mirrors handleResult. handleAccept
  removes the in-memory offer before dispatching to handleScopedAccept,
  so the offer is gone by the time a real completion arrives — the
  previous check was essentially a no-op. The new guard validates
  daemonId match and non-finalized status against the executions row
  before touching capacity counters, so a forged/replayed completion
  cannot decrement capacity or finalize an execution it does not own,
  and late results for already-finalized executions are dropped.

Tests: C4 tests in connection-handler.test.ts updated to seed matching
execution state (the guard would otherwise correctly reject them); two
new tests cover the guard's reject paths (forged-sender and late-
finalized-result). The integration test in scoped-rebase-roundtrip
also seeds the matching state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 1, 2026 07:33
@chrisleekr

Copy link
Copy Markdown
Owner Author

Round 4 follow-up — addressing the outside-diff comment on src/daemon/main.ts:167-172, 196-201 (which has no inline thread):

Fixed in commit 4038175. markActive() now fires before sending job:accept in both the legacy job-offer and scoped-job-offer branches. Previously lastActiveAtMs only got bumped at job:payload, so a daemon near EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS could self-drain during the orchestrator-side token/context lookup window between accept and payload.

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

This PR closes the remaining integration gaps so the ship workflow can run end-to-end by (a) bridging non-ready ship probe verdicts into the existing workflow_runs/daemon pipeline, (b) booting a tickle scheduler that resumes intents via ship:tickle, and (c) adding a new scoped-job WS protocol plus four scoped executors with capacity accounting.

Changes:

  • Add a ship iteration handler that writes workflow_runs.state.shipIntentId, enqueues kind:"workflow-run" jobs, and records ship_iterations.
  • Start the tickle scheduler from src/app.ts and add an orchestrator completion hook that ZADDs ship:tickle only for succeeded children and non-terminal intents.
  • Introduce scoped-job offer/completion WS schemas and queue/job-kind discriminated unions; add daemon-side scoped executors (rebase + three scaffolded executors) and tests.

Reviewed changes

Copilot reviewed 62 out of 62 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/workflows/ship/tickle-scheduler.test.ts Adds round-trip tests for tickle scheduler behavior with an in-memory Valkey fake.
test/workflows/ship/session-runner.resume.test.ts Adds DB-backed tests for resumeShipIntent no-op and ready-termination behavior.
test/workflows/ship/nl-classifier.test.ts Adds regression tests for fenced JSON LLM output parsing (stripJsonFence).
test/workflows/ship/iteration.test.ts Adds DB-backed tests for runIteration enqueue, cap/deadline, and in-flight guard.
test/workflows/ship/cancellation.test.ts Extends Valkey mocks for new queue/tickle operations used by updated code paths.
test/workflows/orchestrator.test.ts Adds coverage for ship-intent early-wake (ZADD ship:tickle) behavior and guards.
test/workflows/handlers/ship.test.ts Updates job-queue mock surface to match new QueuedJob discriminated union.
test/workflows/dispatcher.test.ts Updates job-queue mock surface to match new QueuedJob discriminated union.
test/webhook/router.test.ts Updates job-queue mock surface to match new QueuedJob discriminated union.
test/webhook/events/issue-comment.test.ts Updates job-queue mock surface to match new QueuedJob discriminated union.
test/orchestrator/job-queue.test.ts Updates tests for new kind field + poison-pill logging behavior.
test/orchestrator/job-dispatcher.test.ts Stubs new exports (QueuedJobSchema, scoped helpers) needed by dispatcher flow.
test/orchestrator/connection-handler.test.ts Adds scoped completion tests ensuring capacity counters decrement and executions finalize.
test/integration/ship-tickle-resume.test.ts Adds Postgres+Valkey integration test for reconciliation + onDue dispatch + ZREM.
test/integration/ship-iteration-loop.test.ts Adds full integration test for verdict → enqueue → completion → ship:tickle ZADD.
test/integration/scoped-rebase-roundtrip.test.ts Adds WS contract “round-trip” test for scoped offer/completion bookkeeping.
test/daemon/scoped-rebase-executor.test.ts Adds daemon executor surface test for closed PR path (no git).
test/daemon/scoped-open-pr-executor.test.ts Adds daemon executor surface test for scaffolded issue-reply behavior.
test/daemon/scoped-offer-evaluator.test.ts Adds tests for daemon-side scoped-offer evaluation and reject taxonomy.
test/daemon/scoped-fix-thread-executor.test.ts Adds daemon executor surface tests for scaffolded review-thread reply + error mapping.
test/daemon/scoped-explain-thread-executor.test.ts Adds daemon executor surface test for scaffolded read-only thread reply.
src/workflows/ship/workflow-context.ts Introduces a shared schema for workflow_runs.state.shipIntentId producer/consumer validation.
src/workflows/ship/session-runner.ts Bridges non-ready verdicts to runIteration and adds resumeShipIntent for tickle wakeups.
src/workflows/ship/scoped/meta-issue-classifier.ts Applies fenced-JSON unwrapping to meta-issue classifier parsing path.
src/workflows/ship/scoped/dispatch-scoped.ts Replaces “not yet wired” notices by enqueueing scoped jobs and resolving thread refs.
src/workflows/ship/nl-classifier.ts Adds stripJsonFence to handle fenced JSON model responses.
src/workflows/ship/log-fields.ts Adds canonical SHIP_LOG_EVENTS event-key constants for iteration/tickle/scoped paths.
src/workflows/ship/iteration.ts Adds the core ship iteration handler that writes rows and enqueues daemon work.
src/workflows/orchestrator.ts Adds early-wake hook to ZADD ship:tickle based on state.shipIntentId and child success.
src/workflows/handlers/ship.ts Updates enqueue payload to set kind:"workflow-run".
src/workflows/handlers/resolve.ts Updates prompt text to reflect that cross-run enforcement is a follow-up (comment-only).
src/workflows/dispatcher.ts Updates enqueue payloads to set kind:"workflow-run" for workflow-run jobs.
src/webhook/router.ts Updates legacy queue payloads to set kind:"legacy".
src/shared/ws-messages.ts Adds scoped-job offer/completion schemas and WS_REJECT_REASONS constants.
src/shared/ws-messages.test.ts Adds schema round-trip tests for scoped-job offer and completion messages.
src/shared/workflow-types.ts Documents the workflow_runs.state.shipIntentId convention for ship iteration correlation.
src/shared/daemon-types.ts Adds PendingOffer.scoped field to carry scoped payload context through orchestration.
src/orchestrator/queue-worker.ts Logs job.kind and guards workflowRunId logging by discriminant.
src/orchestrator/job-queue.ts Converts queue payload to Zod-validated discriminated union, adds scoped job kinds, improves poison-pill logging.
src/daemon/scoped-rebase-executor.ts Implements deterministic git merge/push rebase executor with temp-dir + credential helper handling.
src/daemon/scoped-open-pr-executor.ts Adds scaffolded open-pr executor that posts a maintainer-facing issue comment and halts.
src/daemon/scoped-fix-thread-executor.ts Adds scaffolded fix-thread executor that posts a review-thread reply and halts.
src/daemon/scoped-explain-thread-executor.ts Adds scaffolded explain-thread executor that posts a review-thread reply and halts.
src/daemon/main.ts Adds scoped-job-offer handling with capability checks and standard reject reasons.
src/daemon/job-executor.ts Routes scoped payloads to per-kind executors, adds cancel suppression to avoid double-finalization, and adds offer evaluation for scoped jobs.
src/app.ts Boots and stops the tickle scheduler in the server lifecycle and wires resume via injected Octokit factory.
specs/20260429-212559-ship-iteration-wiring/research.md Documents key technical decisions for iteration correlation, job kind taxonomy, and scheduler boot wiring.
specs/20260429-212559-ship-iteration-wiring/quickstart.md Adds operator quickstart scenarios for validating the end-to-end iteration + scoped-command flows.
specs/20260429-212559-ship-iteration-wiring/plan.md Captures implementation plan and architecture decisions for the feature.
specs/20260429-212559-ship-iteration-wiring/data-model.md Summarizes the feature’s touch points across Postgres/Valkey and application types.
specs/20260429-212559-ship-iteration-wiring/contracts/ws-messages.md Documents scoped WS message contract additions.
specs/20260429-212559-ship-iteration-wiring/contracts/job-kinds.md Documents new scoped job kinds and daemon executor expectations.
specs/20260429-212559-ship-iteration-wiring/checklists/requirements.md Adds spec quality checklist for the feature package.
scripts/check-no-destructive-actions.ts Extends destructive-action guard to include the new daemon scoped executor files.
docs/OBSERVABILITY.md Documents iteration/tickle/scoped event keys and their meanings.
docs/BOT-WORKFLOWS.md Documents the bridge architecture and scoped-command executor model with a diagram.
CLAUDE.md Updates the active speckit plan pointer and adds tech context notes for this feature.

Comment thread src/workflows/ship/scoped/dispatch-scoped.ts

@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)
src/daemon/main.ts (1)

219-227: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Refresh idle activity on scoped-job-completion too.

Line 224 only treats "job:result" as a completion signal. Scoped jobs finish via "scoped-job-completion", so an ephemeral daemon can start its idle shutdown up to one poll interval early right after scoped work finishes.

💡 Suggested fix
       wsClient.send(m);
       if (
         typeof m === "object" &&
         m !== null &&
-        (m as { type?: unknown }).type === "job:result"
+        ((m as { type?: unknown }).type === "job:result" ||
+          (m as { type?: unknown }).type === "scoped-job-completion")
       ) {
         markActive();
       }

Based on learnings: "Applies to **/src/daemon/main.ts : Daemon entry point; when DAEMON_EPHEMERAL=true, exit after EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS of idle".

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

In `@src/daemon/main.ts` around lines 219 - 227, The message handling in
executeJob currently only refreshes idle activity when m.type === "job:result";
update the handler used in executeJob (the callback that calls wsClient.send and
markActive) to also treat messages with type "scoped-job-completion" as a
completion signal and call markActive for them as well so ephemeral daemons
don't shut down early; locate the anonymous callback passed to executeJob in
main.ts and add a condition checking (m as { type?: unknown }).type ===
"scoped-job-completion" alongside "job:result" before invoking markActive.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@specs/20260429-212559-ship-iteration-wiring/tasks.md`:
- Around line 122-123: Update the task note to reflect the landed design:
replace the instruction to call getPendingOffer(msg.id) from the scoped
completion flow with the durable guard getExecutionState(deliveryId) and state
that handleScopedJobCompletion must use getExecutionState(deliveryId) for
ownership/finalization checks; remove or mark as deferred the requirement that
each executor branch in runScopedJob enforce idempotency locally (executor-local
idempotency keyed by trigger comment is intentionally deferred); keep the
requirement to wire the server-side scoped-job-completion handler (validate
payload with the Zod discriminated union, post the formatted comment via the
appropriate scoped module such as src/workflows/ship/scoped/rebase.ts, call
removePendingOffer if still applicable for cleanup) and retain the unit test
expectation (one Octokit comment-create per completion kind).

In `@src/daemon/scoped-fix-thread-executor.ts`:
- Around line 95-110: Change the broad 4xx halt logic to an explicit allowlist
of terminal semantic statuses (404, 410, 422) in each of the three executors
(scoped-fix-thread-executor, scoped-explain-thread-executor,
scoped-open-pr-executor): replace the current "status >= 400 && status < 500 &&
!isRateLimited" check with a test that halts only when status is one of [404,
410, 422] and not isRateLimited; preserve the existing isRateLimited detection
and the log/return behavior (e.g., the block around
SHIP_LOG_EVENTS.scoped.fixThread.daemonFailed should only trigger for those
explicit statuses).

In `@src/workflows/ship/iteration.ts`:
- Around line 134-157: The code only checks verdict.reason is a string but must
confirm it is one of the allowed enum values before persisting a probe row or
selecting the next workflow; add a validation step that verifies verdict.reason
is in the permitted set (the same set/select logic used by selectNextWorkflow)
and throw a descriptive error if not, then only call appendIteration (the probe
row write that uses verdict.reason) and selectNextWorkflow afterward; reference
the verdict.reason guard, selectNextWorkflow, countIterations, and
appendIteration to locate the change and ensure invalid strings never get
persisted or forwarded.

---

Outside diff comments:
In `@src/daemon/main.ts`:
- Around line 219-227: The message handling in executeJob currently only
refreshes idle activity when m.type === "job:result"; update the handler used in
executeJob (the callback that calls wsClient.send and markActive) to also treat
messages with type "scoped-job-completion" as a completion signal and call
markActive for them as well so ephemeral daemons don't shut down early; locate
the anonymous callback passed to executeJob in main.ts and add a condition
checking (m as { type?: unknown }).type === "scoped-job-completion" alongside
"job:result" before invoking markActive.
🪄 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: 567c4e3f-a4a0-43e6-a0ce-f895b8bc18a8

📥 Commits

Reviewing files that changed from the base of the PR and between 15caa9d and 4038175.

📒 Files selected for processing (12)
  • specs/20260429-212559-ship-iteration-wiring/tasks.md
  • src/daemon/job-executor.ts
  • src/daemon/main.ts
  • src/daemon/scoped-explain-thread-executor.ts
  • src/daemon/scoped-fix-thread-executor.ts
  • src/daemon/scoped-open-pr-executor.ts
  • src/orchestrator/connection-handler.ts
  • src/orchestrator/job-queue.ts
  • src/workflows/ship/iteration.ts
  • test/daemon/scoped-fix-thread-executor.test.ts
  • test/integration/scoped-rebase-roundtrip.test.ts
  • test/orchestrator/connection-handler.test.ts

Comment thread specs/20260429-212559-ship-iteration-wiring/tasks.md Outdated
Comment thread src/daemon/scoped-fix-thread-executor.ts
Comment thread src/workflows/ship/iteration.ts Outdated
Fixes the four unresolved review threads on PR #79 — three CodeRabbit
findings (one major, one major+spillover, one minor doc nit) and one
Copilot finding (major dedup-marker bug).

* dispatch-scoped open-pr: refactor `runOpenPr` to expose
  `runOpenPrPolicy` (idempotency + classifier + non-actionable refusal,
  no marker post). dispatch-scoped now uses the policy phase + enqueues
  the daemon job on `actionable` verdicts. Removes the previous stub
  `createBranchAndPr` returning `pr_number: 0`, which caused the legacy
  flow to post `<!-- bot:open-pr:0 -->` and permanently block future
  re-triggers (Copilot review).
* scoped-fix-thread / scoped-explain-thread / scoped-open-pr executors:
  narrow the "halt on terminal 4xx" branch to an explicit allowlist of
  semantic-state codes (404, 410, 422). 401 and generic 403 now rethrow
  so the orchestrator's outer failure path surfaces broken installation
  tokens / missing repo permissions instead of silently halting
  (CodeRabbit round 5).
* iteration.ts: validate `verdict.reason` against the
  `NonReadinessReasonSchema` enum before persisting the probe row or
  selecting the next workflow. Adds an exhaustive `default` arm to
  `selectNextWorkflow` so an unrecognised reason throws rather than
  falling through (CodeRabbit round 5).
* tasks.md T033/T033b: update task text to reflect the landed durable
  ownership guard (`getExecutionState(deliveryId)`) and explicitly mark
  executor-local idempotency as deferred follow-up (CodeRabbit round 5).

Quality gates: tsc --noEmit clean, 0 ESLint errors, prettier all
matched. Test counts unchanged from baseline (514 pass / 244 fail / 24
errors — the failures are the documented pre-existing inter-file mock
contamination).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@chrisleekr
chrisleekr merged commit 43da9aa into main May 1, 2026
9 checks passed
@chrisleekr
chrisleekr deleted the 20260429-212559-ship-iteration-wiring branch May 1, 2026 07:55
chrisleekr pushed a commit that referenced this pull request May 1, 2026
# [1.7.0](v1.6.1...v1.7.0) (2026-05-01)

### Bug Fixes

* **idempotency:** scope durable check with since=triggerTimestamp (closes [#33](#33)) ([#69](#69)) ([5f1c1fa](5f1c1fa))

### Features

* **ship:** pr shepherding scaffolding + flag-gated trigger surfaces ([#75](#75)) ([928811b](928811b))
* **ship:** scoped commands (US5) + remove SHIP_USE_TRIGGER_SURFACES_V2 flag ([#77](#77)) ([33132a3](33132a3))
* **ship:** wire ship iteration loop, tickle scheduler, and four scoped executors ([#79](#79)) ([43da9aa](43da9aa))
@chrisleekr

Copy link
Copy Markdown
Owner Author

🎉 This PR is included in version 1.7.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