Skip to content

feat: jig Milestone 2 — definitions, runs, admission triggers, publish, and Codex - #4

Merged
Steel-tech merged 7 commits into
mainfrom
feat/jig-m2
Aug 7, 2026
Merged

feat: jig Milestone 2 — definitions, runs, admission triggers, publish, and Codex#4
Steel-tech merged 7 commits into
mainfrom
feat/jig-m2

Conversation

@Steel-tech

@Steel-tech Steel-tech commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Milestone 2 — definitions in, changes out

Builds on Milestone 1's core loop. A definition can now be authored through the API, invoked manually or by a trigger, fanned out across repositories, and the accepted work published as a real branch and pull request.

Plan: docs/plans/2026-08-05-001-feat-jig-software-factory-plan.md

Includes the three commits from PR #3 (truncation guard, adapter drain bound, fail-closed path resolution) since that PR is still open and this branch descends from it. Merging this supersedes #3.

Milestone 2 exit gate — passed on real GitHub

An accepted run through the worker path published a real pull request on a scratch repo: attempt-scoped branch jig/<job>/1, PR open against main, containing exactly one file — the changed-paths staging discipline holding against a real remote rather than only a local bare repo. The worktree was deleted only after remote-ref proof.

The gate also found a bug the faked gateway could not: gh repo view takes its project positionally and has no --repo flag.

What landed

Unit Content
U5 Definitions edit in place with generation counters (no user-facing revision library); runs freeze source, parameters, and composed prompt by value with a base SHA pinned per target at admission; fan-out to independent jobs; run state recomputed as accepted/failed/mixed; re-admit at head as the explicit escape hatch
U6 Schedule and GitHub-polling triggers, created disabled; hand-rolled cron with IANA zones and real DST semantics; commit-occurrence-then-dispatch exactly-once admission
U7 Fenced publish: authorize → perform → record per step, attempt-scoped branch, find-or-create PR by head ref, remote-ref proof, publish-only retry that skips proven steps
U10 Codex runtime adapter with capabilities probed rather than declared

Decisions worth reviewing

Untrusted context is fenced, not just bounded. The M1 security review flagged "bounded untrusted context" as underspecified. Composition now states the untrusted rule before any untrusted byte, fences each section with explicit markers, and neutralizes forged markers inside the body while preserving content so an agent can report an injection attempt. Oversized sections truncate; dropped sections are declared in the prompt. Tests cover a section trying to close its own fence and a label trying to smuggle markup.

Codex reports cumulative usage, so the adapter reports deltas. Measured across four real turns (20,180 → 40,575 → 61k → 81,603 input tokens), turn.completed.usage is cumulative per thread. Reporting it raw would re-count earlier turns on every correction — inflating spend accounting on exactly the repair loops that matter. Context occupancy is absent from the exec stream entirely and stays zero rather than borrowing the cumulative figure.

A Codex-rostered role declaring a tool allowlist fails the send. codex exec has no tool-allowlist flag and approvals are bypassed, so silently dropping the allowlist would hand the agent wider reach than the definition asked for. Failing loudly is the honest option; definition validation should warn at save time.

Run aggregation has one rule now. The direct-run path previously mapped accepted_unpublished → accepted for single-job runs while the plan's R12 said otherwise. Reconciled to R12 read literally: an accepted_unpublished job aggregates to mixed. jig run's exit code is unaffected — it reports the attempt state.

R12's third conjunct is enforced server-side. CompleteAttempt previously accepted state: accepted without checking for a publish-proof record, so "published" was a worker-side convention. It is now checked inside the completion transaction, after the lease fence.

The fence cannot fence GitHub. A zombie whose lease expires mid-publish can still complete a push. Damage is confined by attempt-scoped branch naming and surfaced by a stray-branch report at reconcile time — reported, never deleted. This is stated rather than papered over.

Testing

just check and just test-race green. Real SQLite, real git fixture repos, real HTTP servers; gh is faked behind an interface in unit tests and exercised for real only in the exit gate. Codex's live smoke ran against the real CLI (codex-cli 0.146.0, authenticated) and confirmed live-session repair: a forced parse-correction resumed the same thread and still knew the filename.

Known gaps

  • jig serve and jig worker do not exist yet (U9), so triggers and the publish pipeline have no runtime host. Two one-line wirings are documented in the code: wrap the engine runner with NewPublishingRunner, and call Recover before serving so interrupted occurrences do not stick in dispatching.
  • The Codex adapter's end-to-end jig run verification is outstanding until a roster can name codex.
  • CODEX_HOME auth must be seeded into the ephemeral HOME or Codex cannot authenticate under KTD11.
  • A failed occurrence is terminal — no automatic retry. A changed event re-admits via its content-derived key; a permanently failed one waits for an operator.
  • M1 residuals carry forward: the phase.go split, cross-package helper dedup, git diff HEAD on an empty repo, stat-based fingerprints for wholly-ignored directories, trace redaction (U8).

Post-Deploy Monitoring & Validation

No production or runtime impact — local-first developer tool, no deployed surface. Validation is CI on both platforms, the exit gate above, and the Codex live smoke.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added definition authoring, run invocation/readmission, and schedule or GitHub-based triggers.
    • Added idempotent publishing with proof tracking and publish-only retries.
    • Added an embedded UI for queues, runs, traces, evidence, and worktrees.
    • Added cron scheduling, Codex runtime support, and event streaming.
    • Added CLI commands for definitions, triggers, serving, workers, and version information.
  • Bug Fixes
    • Improved recovery, deduplication, security checks, worktree validation, diagnostics, and publish-proof enforcement.
  • Documentation
    • Added comprehensive README and quickstart guides.

Second CodeRabbit pass on the review fixes.

- The process-group guard narrowed int64 to int without checking the
  conversion, so a value above MaxInt32 could truncate onto 0 or 1 on a
  32-bit build — back onto the syscall the guard exists to prevent. The
  round-trip check now lives in the predicate, so the signal path, the
  identity gate, and manifest validation all inherit it.
- Result() waited for stdout EOF before reaping, so a descendant holding
  the stream blocked it indefinitely on the normal-exit path; stderr was
  a buffer rather than a file, so Cmd.Wait blocked for the same reason
  and waiting on exit was not an available fallback. jig now owns both
  pipes, waits on process exit, releases the group, then drains under a
  bound.
- worktreeRegistered swallowed resolution failures into false, making
  'could not tell' indistinguishable from 'not registered' in a
  fail-closed path.
- A reconcile assertion read a missing manifest field as an empty one.
… U10)

U5: definitions edit in place with generation counters; runs freeze
source, parameters, and composed prompt by value with a base SHA
pinned per target at admission; fan-out to independent jobs with
recomputed accepted/failed/mixed aggregation; re-admit-at-head as the
explicit escape hatch. Prompt composition fences untrusted context
and neutralizes forged markers. Repository identity normalization
hoisted to protocol so the cap skip-over, ledger, and clone source
share one implementation.

U7: publish is a fenced critical section — authorize, perform, record
per step, each carrying the lease token. Attempt-scoped branch,
find-or-create PR by head ref, remote-ref proof, publish-only retry
that skips proven steps without re-running phases. Staging uses the
engine's declared changed paths and aborts if anything else reached
the index. Stray branches a zombie pushed before expiry are reported,
not deleted — the fence cannot fence GitHub.

U10: Codex adapter with capabilities probed rather than declared.
turn.completed.usage is cumulative per thread, so the adapter reports
per-send deltas; context occupancy is absent from the exec stream and
stays zero rather than misreporting the cumulative figure. A declared
tool allowlist fails the send, since codex exec cannot honor one and
silently dropping it would widen the agent's reach.
…(U6)

Schedule and GitHub-polling triggers, created disabled. Hand-rolled
five-field cron with IANA zones whose DST semantics fall out of a
UTC-minute walk: a fall-back overlap hour fires twice, a spring-forward
local minute never fires. Wake after downtime admits the single stored
overdue instant and jumps to the next future match rather than
catching up.

Exactly-once admission: the occurrence is reserved, resolution happens
outside any transaction, then the run and the dispatched mark commit
together — so a crash rolls back the run with its mark and startup
recovery re-drives without risking a second run. GitHub polling uses
fixed argument vectors, strict JSON decoding, a match limit, and
repository-scoped URL checks, with a distinct diagnostic per failure
mode. Issue and PR text enters as untrusted context sections; PR head
SHAs pin without touching the network.

Also closes R12's third conjunct server-side: CompleteAttempt now
refuses 'accepted' without a recorded proof of publish, so the
guarantee lives in the transaction rather than worker-side only.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds durable definitions, runs, schedules, GitHub admission, publish proof, runtime adapters, worker trace ingestion, CLI commands, an embedded UI, migrations, release automation, and integration tests.

Changes

Jig platform integration

Layer / File(s) Summary
Protocols, definitions, runs, and admission
internal/protocol/*, internal/controlplane/definitions.go, internal/controlplane/runs.go, internal/controlplane/cron.go, internal/controlplane/schedule.go, internal/controlplane/github_poll.go, migrations/003_admission_triggers.sql
Adds validated protocol contracts, repository normalization, definition persistence, frozen run admission, timezone-aware schedules, GitHub polling, durable occurrences, recovery, and trigger APIs.
Publish proof and worker safety
internal/controlplane/publish_ledger.go, internal/controlplane/store.go, internal/worker/publish.go, internal/worker/reconcile.go, internal/worker/repocache.go, internal/worker/worktree.go
Adds lease-fenced publishing, proof-gated acceptance, publish-only retries, remote verification, repository normalization, and path/process safety checks.
Runtime supervision and trace transport
internal/runtime/claudecode/*, internal/runtime/codex/*, internal/worker/events.go, internal/controlplane/ingest.go
Adds bounded CLI stream handling, Codex sessions, event normalization, process cleanup, redaction, ordered buffering, replay, and trace ingestion.
CLI, server, and web UI
cmd/jig/*, internal/controlplane/http.go, web/*
Adds server and worker commands, definition and trigger commands, read and mutation APIs, an embedded SPA, typed API access, polling, run timelines, evidence views, and worktree controls.
Examples, documentation, CI, and release automation
examples/definitions/*, README.md, docs/quickstart.md, .github/workflows/*, Justfile, scripts/release.sh
Adds stock workflow definitions, usage documentation, definition validation, UI checks, tagged releases, cross-platform artifacts, and checksums.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • StructuPath/jig#1: Extends overlapping control-plane, direct-run, worker, and runtime components.
  • StructuPath/jig#2: Overlaps process-group validation and server startup handling.
  • StructuPath/jig#3: Includes related Claude adapter, process-group, and worktree path-resolution changes.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.75% 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 title clearly summarizes the PR's main Milestone 2 features, including definitions, runs, admission triggers, publishing, and Codex support.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/jig-m2

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

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

🧹 Nitpick comments (8)
internal/controlplane/store_test.go (1)

308-308: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unused branch variable and its placeholder assertion.

branch is computed at Line 308 but is only read by the branch == "" check at Lines 333-335. protocol.PublishBranch is already covered by the publish-ledger tests, so this assertion adds no coverage and only exists to keep the variable used.

♻️ Proposed fix
-	branch := protocol.PublishBranch(claim.Job.ID, claim.Attempt.AttemptNumber)
 	if _, err := recordStep(store, claim, tokenA, protocol.PublishStepPush, shaA, ""); err != nil {
 	if unpublished.State != protocol.AttemptAcceptedUnpublished {
 		t.Fatalf("attempt state = %q, want accepted_unpublished", unpublished.State)
 	}
-	if branch == "" {
-		t.Fatal("the attempt-scoped branch is empty")
-	}
 }

Also applies to: 333-335

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/store_test.go` at line 308, Remove the unused branch
variable declaration near the claim setup and delete the corresponding branch ==
"" placeholder assertion, leaving the surrounding test behavior unchanged.
internal/worker/publish.go (2)

1074-1079: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Populate Number on a created pull request, or drop the field from the create path contract.

FindOrCreatePullRequest returns PullRequest{URL: url, State: "open"} after a create, so Number stays zero, while findPullRequest returns the real number. The current callers only read URL, so nothing breaks today. A later consumer that reads Number would see zero only for freshly created pull requests, which is a hard defect to trace.

Parse the number from the URL, or document that Number is only set on the find path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/worker/publish.go` around lines 1074 - 1079, Update
FindOrCreatePullRequest’s create path to populate PullRequest.Number by parsing
the pull request number from the URL returned by firstPullRequestURL, matching
the value provided by findPullRequest; handle an unparseable URL through the
existing publish failure path rather than returning Number as zero.

791-794: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Load manifest.WorktreePath instead of reconstructing the worktree path.

RetryPublish rebuilds the worktree path from w.worktreeRoot() and the attempt ID. The manifest already records the path that prepareAttempt created (line 32), and disposeAttemptWorktree (called at line 810 in the same function) loads and uses manifest.WorktreePath. If the worktreeRoot directory layout ever changes, the retry will silently use a stale reconstructed path while initial publish and cleanup use the manifest, causing them to diverge. Reading manifest.WorktreePath directly via w.manifests.load(retry.Attempt.ID) ensures consistency and makes the manifest the single source of truth for the worktree location.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/worker/publish.go` around lines 791 - 794, Update RetryPublish to
load the attempt manifest through w.manifests.load(retry.Attempt.ID) and use its
WorktreePath for target.worktreePath, removing the reconstructed
filepath.Join(w.worktreeRoot(), retry.Attempt.ID) path and related stat check.
internal/worker/publish_test.go (1)

702-742: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document that the exit gate leaves a branch and a pull request behind.

Each run of TestMilestone2ExitGate pushes a new attempt-scoped branch and opens a new pull request on the scratch repository, and nothing removes them. Repeated runs accumulate open pull requests. Add a line to the doc comment that states the operator must close and delete them, or close the pull request in a t.Cleanup after the assertions pass.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/worker/publish_test.go` around lines 702 - 742, Update
TestMilestone2ExitGate to document that each run leaves an attempt-scoped branch
and pull request in the scratch repository, requiring the operator to close the
pull request and delete the branch; alternatively, register t.Cleanup after the
assertions to perform that cleanup.
internal/controlplane/github_poll.go (1)

395-406: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate handling differs between the issue path and the pull-request path.

ListIssues rejects a repeated number with gh_conflicting_duplicate on line 364. Here a repeated number is skipped silently. A pull request has exactly one base branch, so two --base queries cannot both return it. A duplicate therefore means one gh response contained the same pull request twice, which is the same contract violation the issue path refuses.

Align the two paths so the same gh anomaly produces the same diagnostic.

♻️ Proposed alignment
 		for _, match := range values {
 			if seen[match.Number] {
-				continue
+				return nil, checkFailure("gh_conflicting_duplicate",
+					"gh pr list returned pull request #%d twice", match.Number)
 			}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/github_poll.go` around lines 395 - 406, Update the
duplicate handling in the pull-request aggregation loop around the seen map so a
repeated match.Number returns checkFailure("gh_conflicting_duplicate") instead
of being silently skipped. Align its diagnostic and failure behavior with the
existing ListIssues duplicate handling, while preserving normal unique-match
collection and the MaxTriggerMatches limit.
internal/controlplane/schedule.go (2)

416-441: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bound the number of schedule targets.

validateTriggerConfig rejects an empty target list and duplicate repositories, but it does not cap the count. The only limit is MaxTriggerConfigBytes, which still admits several hundred repositories. Every firing then fans out one job per target, with no named diagnostic. The GitHub path has MaxTriggerMatches for exactly this reason.

Add an explicit cap so a mis-authored schedule fails at save time with an actionable code.

♻️ Proposed target-count bound
 		if len(config.Targets) == 0 {
 			return zero, invalid("no_targets", "a schedule trigger requires at least one target repository")
 		}
+		if len(config.Targets) > protocol.MaxTriggerMatches {
+			return zero, invalid("too_many_targets", fmt.Sprintf(
+				"a schedule trigger may fan out to at most %d repositories", protocol.MaxTriggerMatches))
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/schedule.go` around lines 416 - 441, Update
validateTriggerConfig’s schedule-target validation to reject configurations
whose targets exceed an explicit maximum, using a named diagnostic code and
actionable message that includes the allowed limit. Apply the check before
building the normalized targets list, while preserving the existing empty-list
and duplicate-repository validation.

619-625: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Name the lateness threshold as a constant.

2 * time.Minute decides whether a firing is reported as "admitted after downtime". Every other bound in this path comes from internal/protocol/limits.go, including TriggerTickInterval. A literal here can drift from the tick interval, and the diagnostic then appears for firings that were merely one tick late.

Define the threshold beside the other trigger limits and derive it from TriggerTickInterval.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/schedule.go` around lines 619 - 625, Define a named
lateness-threshold constant alongside the trigger limits in
internal/protocol/limits.go, deriving it from TriggerTickInterval. Update the
schedule logic around the due.Add comparison in the firing admission path to use
this constant instead of the literal 2 * time.Minute.
internal/controlplane/http.go (1)

174-181: 🚀 Performance & Scalability | 🔵 Trivial

Consider a projection or pagination for the run list.

Runs selects snapshot and parameters for every run, and this route returns all of them in one response. The frozen snapshot is a full definition YAML, and the composed prompt can reach tens of kilobytes. The response therefore grows without bound as runs accumulate. Add a limit plus a summary projection for the list route, and keep the full snapshot on the detail route.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/http.go` around lines 174 - 181, Update API.listRuns
and its store query to return a bounded, paginated run list using a summary
projection that excludes the full snapshot and parameters; preserve the complete
snapshot and parameters on the run detail route.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/controlplane/cron.go`:
- Around line 142-143: Update parseCronField so wildcard is true only for a bare
"*" value, not stepped fields such as "*/2" or "*/3"; preserve wildcard behavior
for unstepped fields and ensure day-of-month/day-of-week matching uses the
existing OR rule for restricted stepped fields.
- Around line 224-234: Optimize the minute-by-minute search in schedule.Next by
skipping ahead when coarse fields cannot match: advance to the first minute of
the next local month when the candidate’s local month is not admitted, and
advance to the next local midnight when its local day matches neither day rule.
Preserve the existing candidate matching and DST behavior, while retaining the
cronSearchYears limit and no-match error.

In `@internal/controlplane/github_poll.go`:
- Around line 265-270: Update boundedBody to truncate by UTF-8 rune boundaries,
matching the rune-safe approach used by boundedDiagnostic, while preserving the
existing maximum byte limit and unchanged behavior for shorter bodies.
- Around line 107-118: Check rows.Err() after the rows.Next() loop and before
relying on ids, returning unavailable(err) when iteration failed. Keep the
existing rows.Scan handling and rows.Close error handling unchanged, and ensure
the id list is used only after confirming iteration completed without error.
- Around line 162-179: Re-read the trigger’s enabled state inside the
transaction before the occurrence loop, and only execute the inserts in the poll
flow when the trigger remains enabled. Preserve the existing trigger update
guard and ensure a trigger disabled after observe commits no new pending
occurrences.

In `@internal/controlplane/publish_ledger.go`:
- Line 262: Update AttemptPublishRecords and its final sort key to use an
explicit pipeline step rank rather than alphabetically sorting p.step. Ensure
the rank orders proof, pull_request, and push according to the documented
pipeline order, while preserving the existing timestamp and other sort keys.

In `@internal/controlplane/runs_test.go`:
- Around line 240-247: Remove the initial head return value assignment from
newFixtureRepository in the fixture setup loop, since it is immediately
overwritten by commitTo. Preserve the subsequent commitTo result assignment and
fixture construction using the updated head.

In `@internal/controlplane/runs.go`:
- Around line 127-151: Update ReadmitRunAtHead and the underlying
protocol.RunTarget persistence flow to retain each target’s Ref alongside
Repository and BaseSHA, then replay that Ref when constructing InvocationTarget
values so re-admission resolves the original branch rather than another branch’s
HEAD. Add a regression test covering a run admitted with a non-empty Ref and
verifying the same Ref is used during re-admission.

In `@internal/controlplane/schedule.go`:
- Around line 543-554: Check rows.Err() after each id-collecting loop in the
schedule admission and pending-occurrence dispatch paths, including
admitDueSchedules and dispatchPendingOccurrences. If iteration ended because of
a read error, return unavailable(err) before treating the collected IDs as
complete; retain the existing scan and close error handling.
- Around line 969-975: Update boundedDiagnostic to truncate value on a valid
UTF-8 rune boundary rather than slicing at an arbitrary byte offset; use
unicode/utf8 to ensure the returned diagnostic remains valid UTF-8 while
respecting protocol.MaxTriggerDiagnosticBytes.
- Around line 826-829: In the occurrenceWithTrigger error path within the
dispatch flow, call a best-effort releaseOccurrenceReservation helper with
occurrenceID before returning the read error. Add the helper beside
failOccurrence to update only dispatching rows back to pending, refresh
updated_at, and warn if the release update fails, allowing the next tick to
retry the occurrence.

In `@internal/runtime/claudecode/adapter.go`:
- Around line 552-563: Update claudeHandle.drainStream and consume so the drain
timeout is based on read progress rather than wall time since process exit: have
consume increment an atomic progress counter after each successful ReadLine, and
have drainStream monitor that counter during the grace window, closing
stdoutReader and stderrReader only when no progress occurs. Preserve waiting for
h.done and h.stderrDone after readers are closed, while allowing ongoing
backpressure-driven draining to continue.
- Around line 504-510: Synchronize the final process-group signal and anchor
reap around the Result() cleanup flow: ensure every stopEverything/stopGroup
call is excluded once h.anchor.Wait() begins, including repeated Result() calls
and concurrent Kill(). Replace any standalone groupReaped check with the same
lock or synchronization mechanism guarding the signal/reap transition, and add
regression coverage for repeated Result() plus concurrent Kill().

In `@internal/runtime/codex/adapter.go`:
- Around line 236-239: Update the error path after anchor.Start in the
surrounding adapter flow to explicitly discard the watchdog.Close error using
the existing _ = convention, matching the abandon closure below; preserve the
current returned start-process error.

In `@internal/worker/publish.go`:
- Around line 896-904: Update the branch-processing logic around
pushedBranchesForJob so a nil result, indicating no control-plane answer, skips
accusation for that job’s branches. Distinguish nil from an empty non-nil map:
continue treating explicitly absent branch records as stray, while avoiding
pushed[branch.Branch] lookup fallthrough when the result is nil.
- Around line 774-798: Derive a publishCtx using context.WithoutCancel(ctx)
before creating the heartbeat context, then derive heartbeatCtx from publishCtx
so lease.keepAlive remains active through the uncancellable critical section.
Pass the same publishCtx to w.publish instead of creating a separate
context.WithoutCancel(ctx) inline, preserving cancellation behavior after the
publish completes.

---

Nitpick comments:
In `@internal/controlplane/github_poll.go`:
- Around line 395-406: Update the duplicate handling in the pull-request
aggregation loop around the seen map so a repeated match.Number returns
checkFailure("gh_conflicting_duplicate") instead of being silently skipped.
Align its diagnostic and failure behavior with the existing ListIssues duplicate
handling, while preserving normal unique-match collection and the
MaxTriggerMatches limit.

In `@internal/controlplane/http.go`:
- Around line 174-181: Update API.listRuns and its store query to return a
bounded, paginated run list using a summary projection that excludes the full
snapshot and parameters; preserve the complete snapshot and parameters on the
run detail route.

In `@internal/controlplane/schedule.go`:
- Around line 416-441: Update validateTriggerConfig’s schedule-target validation
to reject configurations whose targets exceed an explicit maximum, using a named
diagnostic code and actionable message that includes the allowed limit. Apply
the check before building the normalized targets list, while preserving the
existing empty-list and duplicate-repository validation.
- Around line 619-625: Define a named lateness-threshold constant alongside the
trigger limits in internal/protocol/limits.go, deriving it from
TriggerTickInterval. Update the schedule logic around the due.Add comparison in
the firing admission path to use this constant instead of the literal 2 *
time.Minute.

In `@internal/controlplane/store_test.go`:
- Line 308: Remove the unused branch variable declaration near the claim setup
and delete the corresponding branch == "" placeholder assertion, leaving the
surrounding test behavior unchanged.

In `@internal/worker/publish_test.go`:
- Around line 702-742: Update TestMilestone2ExitGate to document that each run
leaves an attempt-scoped branch and pull request in the scratch repository,
requiring the operator to close the pull request and delete the branch;
alternatively, register t.Cleanup after the assertions to perform that cleanup.

In `@internal/worker/publish.go`:
- Around line 1074-1079: Update FindOrCreatePullRequest’s create path to
populate PullRequest.Number by parsing the pull request number from the URL
returned by firstPullRequestURL, matching the value provided by findPullRequest;
handle an unparseable URL through the existing publish failure path rather than
returning Number as zero.
- Around line 791-794: Update RetryPublish to load the attempt manifest through
w.manifests.load(retry.Attempt.ID) and use its WorktreePath for
target.worktreePath, removing the reconstructed filepath.Join(w.worktreeRoot(),
retry.Attempt.ID) path and related stat check.
🪄 Autofix

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 568ef957-6d7c-434d-944c-d9bfa33f5cbc

📥 Commits

Reviewing files that changed from the base of the PR and between 75f6c82 and d55c05a.

📒 Files selected for processing (37)
  • internal/controlplane/admission_test.go
  • internal/controlplane/claim_test.go
  • internal/controlplane/cron.go
  • internal/controlplane/cron_test.go
  • internal/controlplane/definitions.go
  • internal/controlplane/definitions_test.go
  • internal/controlplane/embedded.go
  • internal/controlplane/embedded_test.go
  • internal/controlplane/github_poll.go
  • internal/controlplane/http.go
  • internal/controlplane/publish_ledger.go
  • internal/controlplane/publish_ledger_test.go
  • internal/controlplane/runs.go
  • internal/controlplane/runs_test.go
  • internal/controlplane/schedule.go
  • internal/controlplane/store.go
  • internal/controlplane/store_test.go
  • internal/protocol/limits.go
  • internal/protocol/prompt.go
  • internal/protocol/prompt_test.go
  • internal/protocol/publish.go
  • internal/protocol/repository.go
  • internal/protocol/repository_test.go
  • internal/runtime/claudecode/adapter.go
  • internal/runtime/claudecode/adapter_test.go
  • internal/runtime/codex/adapter.go
  • internal/runtime/codex/adapter_test.go
  • internal/runtime/codex/live_test.go
  • internal/worker/claiming_test.go
  • internal/worker/publish.go
  • internal/worker/publish_test.go
  • internal/worker/reconcile.go
  • internal/worker/reconcile_test.go
  • internal/worker/repocache.go
  • internal/worker/worktree.go
  • internal/worker/worktree_test.go
  • migrations/003_admission_triggers.sql

Comment on lines +142 to +143
func parseCronField(value string, minimum, maximum int, names map[string]int, sundaySeven bool) (cronField, error) {
field := cronField{allowed: make([]bool, maximum+1), wildcard: strings.HasPrefix(value, "*")}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

A stepped day-of-week field is treated as a wildcard.

parseCronField sets wildcard from strings.HasPrefix(value, "*"), so */2 and */3 are marked as wildcards even though they restrict the field. matches then applies the wrong branch of cron's day rule.

Example: 0 0 1 * */2. The day-of-week field admits only even weekdays, but wildcard is true, so matches takes case schedule.dayOfWeek.wildcard: return dayOfMonth. The schedule fires on the 1st only. Vixie cron fires on the 1st OR on every even weekday. The same inversion applies when day-of-month carries a step, for example */2 * */2 * MON.

Set the flag only for a bare * field with no step.

🐛 Proposed fix
 func parseCronField(value string, minimum, maximum int, names map[string]int, sundaySeven bool) (cronField, error) {
-	field := cronField{allowed: make([]bool, maximum+1), wildcard: strings.HasPrefix(value, "*")}
+	// Only a bare `*` is unrestricted: `*/2` selects a subset, and cron's
+	// day-of-month/day-of-week OR rule must see it as restricted.
+	field := cronField{allowed: make([]bool, maximum+1), wildcard: value == "*"}
 	if value == "" {
 		return field, fmt.Errorf("field is empty")
 	}

Add the case to cron_test.go:

// A stepped day-of-week field is restricted, so cron's OR rule applies.
{"0 0 1 * */2",
    time.Date(2026, 8, 6, 0, 0, 0, 0, time.UTC), // a Thursday
    time.Date(2026, 8, 6, 0, 0, 0, 0, time.UTC).AddDate(0, 0, 2)}, // the next even weekday

Also applies to: 245-256

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/cron.go` around lines 142 - 143, Update parseCronField
so wildcard is true only for a bare "*" value, not stepped fields such as "*/2"
or "*/3"; preserve wildcard behavior for unstepped fields and ensure
day-of-month/day-of-week matching uses the existing OR rule for restricted
stepped fields.

Comment on lines +224 to +234
func (schedule cronSchedule) Next(after time.Time) (time.Time, error) {
candidate := after.UTC().Truncate(time.Minute).Add(time.Minute)
limit := candidate.AddDate(cronSearchYears, 0, 0)
for !candidate.After(limit) {
if schedule.matches(candidate) {
return candidate, nil
}
candidate = candidate.Add(time.Minute)
}
return time.Time{}, fmt.Errorf("cron has no matching instant within %d years", cronSearchYears)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Bound the cost of the minute-by-minute walk.

Next advances one minute at a time for up to cronSearchYears. A never-matching expression therefore performs about 5.3 million iterations, each with a timezone conversion, before it returns the error. The comment on line 39 states that such an expression is "a save-time error, not a scheduler that spins", but validateTriggerConfig reaches that error through this same walk, on the request thread that serves POST /api/triggers. TestAnExpressionThatCanNeverMatchFailsRatherThanSpinning exercises exactly that worst case.

Skip ahead in larger units when the coarse fields cannot match. If the local month is not admitted, advance to the first minute of the next month. If the local day matches neither day rule, advance to the next local midnight. Both reduce the worst case from millions of iterations to thousands, and neither changes the DST semantics, because the walk still tests every candidate minute it visits.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/cron.go` around lines 224 - 234, Optimize the
minute-by-minute search in schedule.Next by skipping ahead when coarse fields
cannot match: advance to the first minute of the next local month when the
candidate’s local month is not admitted, and advance to the next local midnight
when its local day matches neither day rule. Preserve the existing candidate
matching and DST behavior, while retaining the cronSearchYears limit and
no-match error.

Comment on lines +107 to +118
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return unavailable(err)
}
ids = append(ids, id)
}
if err := rows.Close(); err != nil {
return unavailable(err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Check rows.Err() before using the id list.

rows.Next() returns false both at the end of the result set and on a read error, and rows.Close() does not report the iteration error. A truncated list then looks complete, so due GitHub triggers are silently skipped for that tick. golangci-lint flags this line.

🐛 Proposed fix
 		ids = append(ids, id)
 	}
+	if err := rows.Err(); err != nil {
+		rows.Close()
+		return unavailable(err)
+	}
 	if err := rows.Close(); err != nil {
 		return unavailable(err)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return unavailable(err)
}
ids = append(ids, id)
}
if err := rows.Close(); err != nil {
return unavailable(err)
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return unavailable(err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return unavailable(err)
}
if err := rows.Close(); err != nil {
return unavailable(err)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/github_poll.go` around lines 107 - 118, Check
rows.Err() after the rows.Next() loop and before relying on ids, returning
unavailable(err) when iteration failed. Keep the existing rows.Scan handling and
rows.Close error handling unchanged, and ensure the id list is used only after
confirming iteration completed without error.

Source: Linters/SAST tools

Comment on lines +162 to +179
for _, source := range sources {
// A key collision means an earlier poll already committed this exact
// event. That is the dedup working, not an anomaly, so it is silent —
// counting it would turn "this issue is still open" into a rising
// skip counter.
if _, err := insertOccurrenceTx(ctx, tx,
trigger.ID, githubRequestKey(trigger, source), OccurrencePending,
source, nil, "", now); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `
UPDATE triggers SET last_checked_at = ?, next_poll_at = ?,
diagnostic_code = '', diagnostic = '', updated_at = ?
WHERE id = ? AND enabled = 1
`, now.UnixMilli(), s.nextPollAt(trigger, now), now.UnixMilli(), trigger.ID); err != nil {
return unavailable(err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

A poll in flight can still admit work for a trigger the operator just disabled.

The occurrence inserts on lines 167-172 carry no enabled guard, but the trigger update on line 176 does. If the operator disables the trigger after observe returns and before this transaction commits, the occurrences are committed anyway. SetTriggerEnabled in internal/controlplane/schedule.go clears the cursors only; it does not cancel pending occurrences. The next dispatchPendingOccurrences therefore turns them into runs for a disabled trigger.

Re-read enabled inside the transaction and skip the inserts when it is 0.

🐛 Proposed fix
 	defer tx.Rollback()
+	// Disabling must stop admission, including a poll that was already in
+	// flight when the operator turned the trigger off.
+	var stillEnabled int
+	if err := tx.QueryRowContext(ctx,
+		`SELECT enabled FROM triggers WHERE id = ?`, trigger.ID).Scan(&stillEnabled); err != nil {
+		return unavailable(err)
+	}
+	if stillEnabled == 0 {
+		return nil
+	}
 	for _, source := range sources {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/github_poll.go` around lines 162 - 179, Re-read the
trigger’s enabled state inside the transaction before the occurrence loop, and
only execute the inserts in the poll flow when the trigger remains enabled.
Preserve the existing trigger update guard and ensure a trigger disabled after
observe commits no new pending occurrences.

Comment on lines +265 to +270
func boundedBody(body string) string {
if len(body) <= protocol.MaxUntrustedSectionBytes {
return body
}
return body[:protocol.MaxUntrustedSectionBytes]
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Truncate the observed body on a rune boundary.

body[:protocol.MaxUntrustedSectionBytes] can split a multi-byte UTF-8 rune. Issue and pull-request bodies are arbitrary user text, so non-ASCII content is common. The invalid tail is then frozen into the occurrence source and into the composed prompt. Apply the same rune-safe truncation suggested for boundedDiagnostic in internal/controlplane/schedule.go.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/github_poll.go` around lines 265 - 270, Update
boundedBody to truncate by UTF-8 rune boundaries, matching the rune-safe
approach used by boundedDiagnostic, while preserving the existing maximum byte
limit and unchanged behavior for shorter bodies.

Comment on lines 504 to 510
waitErr := h.command.Wait()
// The CLI is gone; release the group. The anchor ignores TERM by design,
// so this is the one place it dies.
// so this is the one place it dies. This is also what frees the inherited
// descriptor: any descendant still holding stdout is in this group.
h.stopEverything(0)
h.drainStream()
_ = h.anchor.Wait()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: find Result/Kill call sites on runtime handles.
rg -nP --type=go -C4 '\.Result\s*\(\s*\)|\.Kill\s*\(\s*\)' -g '!internal/runtime/**' 
rg -nP --type=go -C6 'handle\.Result|handle\.Kill' internal/runtime

Repository: StructuPath/jig

Length of output: 153


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- target symbols and surrounding implementation ---'
rg -n -C8 'type claudeHandle|func \(h \*claudeHandle\) (Result|Kill|stopEverything)|groupReaped|reapOnce|stopOnce|anchor|stopGroup' internal/runtime/claudecode/adapter.go

printf '%s\n' '--- all Result and Kill call sites ---'
rg -n -P --type=go '\.(Result|Kill)\s*\(\s*\)' .

printf '%s\n' '--- interface and handle construction references ---'
rg -n -C5 --type=go 'claudeHandle|Result\(\)|Kill\(\)|New.*Claude|Claude' internal/runtime

Repository: StructuPath/jig

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- claude handle state and teardown ---'
sed -n '297,355p' internal/runtime/claudecode/adapter.go
sed -n '487,530p' internal/runtime/claudecode/adapter.go
sed -n '568,595p' internal/runtime/claudecode/adapter.go

printf '%s\n' '--- engine lifecycle around Result and Kill ---'
sed -n '1165,1270p' internal/engine/phase.go

printf '%s\n' '--- Claude-specific lifecycle references ---'
rg -n -C6 --type=go 'claude|handle\.Result|handle\.Kill|context\.Canceled|ErrKilled' internal/engine internal/runtime/claudecode

Repository: StructuPath/jig

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
# Read-only model of the proposed reap flag.
# It checks whether a signal can pass the flag check before the reaper marks
# the anchor as reaped, then execute after the anchor is reaped.
states = []

def explore(trace, reaped, signal_checked):
    if len(trace) >= 4:
        if signal_checked and reaped:
            states.append(trace)
        return

    # Result reaps the anchor, then sets groupReaped.
    if not reaped:
        explore(trace + ["Result: anchor.Wait()"], True, signal_checked)
        explore(trace + ["Kill: read groupReaped=false"], reaped, True)
    else:
        explore(trace + ["Result: set groupReaped=true"], True, signal_checked)
        if signal_checked:
            explore(trace + ["Kill: stopGroup(groupID)"], True, signal_checked)

explore([], False, False)
for trace in states:
    if "Kill: stopGroup(groupID)" in trace:
        print("unsafe interleaving:")
        print(" -> ".join(trace))
        break
else:
    print("no unsafe interleaving found")
PY

printf '%s\n' '--- current stopEverything idempotence check ---'
python3 - <<'PY'
from pathlib import Path
s = Path("internal/runtime/claudecode/adapter.go").read_text()
start = s.index("func (h *claudeHandle) stopEverything")
end = s.index("\n}", start) + 2
body = s[start:end]
print(body)
print("stopGroup calls:", body.count("stopGroup("))
print("guarded by stopOnce:", "stopOnce.Do" in body and "stopGroup(" in body)
PY

Repository: StructuPath/jig

Length of output: 570


Serialize the final anchor reap with all group signals.

stopOnce only closes h.stopped; every stopEverything call still invokes stopGroup. A repeated Result() call or a context-cancellation Kill() can therefore signal -h.groupID after h.anchor.Wait() reaps the anchor and its PID is reused. A standalone groupReaped check is also racy. Serialize the signal/reap transition so no stopGroup call can occur after the reap, and add a regression test for repeated Result() and concurrent Kill().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/runtime/claudecode/adapter.go` around lines 504 - 510, Synchronize
the final process-group signal and anchor reap around the Result() cleanup flow:
ensure every stopEverything/stopGroup call is excluded once h.anchor.Wait()
begins, including repeated Result() calls and concurrent Kill(). Replace any
standalone groupReaped check with the same lock or synchronization mechanism
guarding the signal/reap transition, and add regression coverage for repeated
Result() plus concurrent Kill().

Comment on lines +552 to +563
func (h *claudeHandle) drainStream() {
timer := time.NewTimer(streamDrainGrace)
defer timer.Stop()
select {
case <-h.done:
case <-timer.C:
}
_ = h.stdoutReader.Close()
_ = h.stderrReader.Close()
<-h.done
<-h.stderrDone
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

The drain grace also fires on normal backpressure and then discards stream data.

h.done closes only when consume returns. consume blocks in emit whenever the event channel (depth 256) is full, which is the documented backpressure path, not a stuck descendant. If the engine needs more than streamDrainGrace to drain the queued events, Line 559 closes stdoutReader under the reader. consume then returns on ErrClosed, and every byte still in the pipe is lost — including a terminal result line, which surfaces as "claude returned no terminal result event".

Bound the wait on absence of read progress, not on wall time since exit. For example, record a progress counter in consume and only close the readers if the counter did not advance during the window.

🐛 Sketch: only close when the capture made no progress
 func (h *claudeHandle) drainStream() {
-	timer := time.NewTimer(streamDrainGrace)
-	defer timer.Stop()
-	select {
-	case <-h.done:
-	case <-timer.C:
-	}
+	for {
+		before := h.progress.Load()
+		timer := time.NewTimer(streamDrainGrace)
+		select {
+		case <-h.done:
+			timer.Stop()
+			return
+		case <-timer.C:
+		}
+		// Progress means the stream is alive and the consumer is slow, not
+		// that a descendant is holding the write end.
+		if h.progress.Load() == before {
+			break
+		}
+	}
 	_ = h.stdoutReader.Close()
 	_ = h.stderrReader.Close()
 	<-h.done
 	<-h.stderrDone
 }

consume increments h.progress (an atomic.Uint64) after each successful ReadLine.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/runtime/claudecode/adapter.go` around lines 552 - 563, Update
claudeHandle.drainStream and consume so the drain timeout is based on read
progress rather than wall time since process exit: have consume increment an
atomic progress counter after each successful ReadLine, and have drainStream
monitor that counter during the grace window, closing stdoutReader and
stderrReader only when no progress occurs. Preserve waiting for h.done and
h.stderrDone after readers are closed, while allowing ongoing
backpressure-driven draining to continue.

Comment on lines +236 to +239
if err := anchor.Start(); err != nil {
watchdog.Close()
return nil, fmt.Errorf("start process-group anchor: %w", err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assign the ignored Close error to satisfy errcheck.

The lint gate reports Line 237. The abandon closure below already uses the _ = form.

🔧 Proposed fix
 	if err := anchor.Start(); err != nil {
-		watchdog.Close()
+		_ = watchdog.Close()
 		return nil, fmt.Errorf("start process-group anchor: %w", err)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if err := anchor.Start(); err != nil {
watchdog.Close()
return nil, fmt.Errorf("start process-group anchor: %w", err)
}
if err := anchor.Start(); err != nil {
_ = watchdog.Close()
return nil, fmt.Errorf("start process-group anchor: %w", err)
}
🧰 Tools
🪛 golangci-lint (2.12.2)

[error] 237-237: Error return value of watchdog.Close is not checked

(errcheck)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/runtime/codex/adapter.go` around lines 236 - 239, Update the error
path after anchor.Start in the surrounding adapter flow to explicitly discard
the watchdog.Close error using the existing _ = convention, matching the abandon
closure below; preserve the current returned start-process error.

Source: Linters/SAST tools

Comment on lines +774 to +798
lease := newAttemptLease(w.client, retry.Attempt.ID, token)
heartbeatCtx, stopHeartbeat := context.WithCancel(ctx)
defer stopHeartbeat()
go lease.keepAlive(heartbeatCtx, w.logger)

w.trackActive(retry.Attempt.ID)
defer w.untrackActive(retry.Attempt.ID)

target := publishTarget{
attemptID: retry.Attempt.ID,
jobID: retry.Job.ID,
attemptNumber: retry.Attempt.AttemptNumber,
repository: retry.Job.Repository,
branch: protocol.PublishBranch(retry.Job.ID, retry.Attempt.AttemptNumber),
baseSHA: retry.Job.BaseSHA,
lease: lease,
}
worktreePath := filepath.Join(w.worktreeRoot(), retry.Attempt.ID)
if _, err := os.Stat(worktreePath); err == nil {
target.worktreePath = worktreePath
}
// The critical section again: a retry that has just pushed must not be
// abandoned before its pull request exists.
summary := w.publish(context.WithoutCancel(ctx), gateway, options, target,
changedPathsFromResult(retry.Attempt.Result))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Derive the heartbeat context from the uncancellable context.

publish runs on context.WithoutCancel(ctx) at Line 797, but heartbeatCtx at Line 775 derives from ctx. If ctx is cancelled during a retry — worker shutdown, for example — lease.keepAlive stops while the publish pipeline keeps running. The lease then expires mid-pipeline and the next AuthorizePublishStep or RecordPublishStep fails with lease_not_owner. A push that already reached the remote cannot be recorded, which produces exactly the stray branch this design tries to avoid.

Keep the lease alive for as long as the critical section runs.

🔒 Proposed fix
 	lease := newAttemptLease(w.client, retry.Attempt.ID, token)
-	heartbeatCtx, stopHeartbeat := context.WithCancel(ctx)
+	// Publish is uncancellable, so the lease that fences it must be too:
+	// a heartbeat that stops mid-pipeline fences the publisher out of its
+	// own push.
+	publishCtx := context.WithoutCancel(ctx)
+	heartbeatCtx, stopHeartbeat := context.WithCancel(publishCtx)
 	defer stopHeartbeat()
 	go lease.keepAlive(heartbeatCtx, w.logger)

Then pass publishCtx at Line 797 in place of context.WithoutCancel(ctx).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
lease := newAttemptLease(w.client, retry.Attempt.ID, token)
heartbeatCtx, stopHeartbeat := context.WithCancel(ctx)
defer stopHeartbeat()
go lease.keepAlive(heartbeatCtx, w.logger)
w.trackActive(retry.Attempt.ID)
defer w.untrackActive(retry.Attempt.ID)
target := publishTarget{
attemptID: retry.Attempt.ID,
jobID: retry.Job.ID,
attemptNumber: retry.Attempt.AttemptNumber,
repository: retry.Job.Repository,
branch: protocol.PublishBranch(retry.Job.ID, retry.Attempt.AttemptNumber),
baseSHA: retry.Job.BaseSHA,
lease: lease,
}
worktreePath := filepath.Join(w.worktreeRoot(), retry.Attempt.ID)
if _, err := os.Stat(worktreePath); err == nil {
target.worktreePath = worktreePath
}
// The critical section again: a retry that has just pushed must not be
// abandoned before its pull request exists.
summary := w.publish(context.WithoutCancel(ctx), gateway, options, target,
changedPathsFromResult(retry.Attempt.Result))
lease := newAttemptLease(w.client, retry.Attempt.ID, token)
// Publish is uncancellable, so the lease that fences it must be too:
// a heartbeat that stops mid-pipeline fences the publisher out of its
// own push.
publishCtx := context.WithoutCancel(ctx)
heartbeatCtx, stopHeartbeat := context.WithCancel(publishCtx)
defer stopHeartbeat()
go lease.keepAlive(heartbeatCtx, w.logger)
w.trackActive(retry.Attempt.ID)
defer w.untrackActive(retry.Attempt.ID)
target := publishTarget{
attemptID: retry.Attempt.ID,
jobID: retry.Job.ID,
attemptNumber: retry.Attempt.AttemptNumber,
repository: retry.Job.Repository,
branch: protocol.PublishBranch(retry.Job.ID, retry.Attempt.AttemptNumber),
baseSHA: retry.Job.BaseSHA,
lease: lease,
}
worktreePath := filepath.Join(w.worktreeRoot(), retry.Attempt.ID)
if _, err := os.Stat(worktreePath); err == nil {
target.worktreePath = worktreePath
}
// The critical section again: a retry that has just pushed must not be
// abandoned before its pull request exists.
summary := w.publish(publishCtx, gateway, options, target,
changedPathsFromResult(retry.Attempt.Result))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/worker/publish.go` around lines 774 - 798, Derive a publishCtx using
context.WithoutCancel(ctx) before creating the heartbeat context, then derive
heartbeatCtx from publishCtx so lease.keepAlive remains active through the
uncancellable critical section. Pass the same publishCtx to w.publish instead of
creating a separate context.WithoutCancel(ctx) inline, preserving cancellation
behavior after the publish completes.

Comment on lines +896 to +904
for _, branch := range branches {
pushed, exists := pushedByJob[branch.JobID]
if !exists {
pushed = w.pushedBranchesForJob(ctx, branch.JobID, &scanErrors)
pushedByJob[branch.JobID] = pushed
}
if pushed[branch.Branch] {
continue
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fix the "cannot tell" case: a nil map makes every branch of that job stray.

pushedBranchesForJob returns nil when the control plane could not answer, and the comment states that nothing is accused in that case. The caller does not honor that. pushed[branch.Branch] on a nil map returns false, so the branch falls through and is appended to stray. A transient control-plane failure therefore reports legitimate branches as stray, which is the opposite of the documented behavior.

Distinguish "no records" from "no answer" and skip the branch when the answer is missing.

🐛 Proposed fix
 		for _, branch := range branches {
 			pushed, exists := pushedByJob[branch.JobID]
 			if !exists {
 				pushed = w.pushedBranchesForJob(ctx, branch.JobID, &scanErrors)
 				pushedByJob[branch.JobID] = pushed
 			}
+			if pushed == nil {
+				// The control plane could not answer for this job. Without an
+				// answer, nothing is accused.
+				continue
+			}
 			if pushed[branch.Branch] {
 				continue
 			}

Also applies to: 931-948

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/worker/publish.go` around lines 896 - 904, Update the
branch-processing logic around pushedBranchesForJob so a nil result, indicating
no control-plane answer, skips accusation for that job’s branches. Distinguish
nil from an empty non-nil map: continue treating explicitly absent branch
records as stray, while avoiding pushed[branch.Branch] lookup fallthrough when
the result is nil.

… (U8, U9)

U8: the worker streams bounded seq-ordered event batches to the
control plane and dual-writes attempt-local JSONL, one batch in flight
so a UI cursor can never advance past an undelivered seq. Ingestion is
insert-or-ignore on (attempt_id, seq) and projects gate and envelope
evidence in the same transaction, so replay adds no second copy.
Buffer overflow emits a declared trace_gap range rather than losing
events silently. R15 redaction replaces sensitive env values before
the event leaves the worker, in both the stream and the raw record.
React UI with per-attempt swim lanes, envelope and gate inspection,
queue depth, and confirm-gated worktree release; no metrics dashboard.

U9: jig serve recovers interrupted occurrences before it serves,
jig worker wraps the engine in the publishing runner and reconciles
including stray publish branches, jig def and jig trigger give the
API a CLI, and both runtimes get their auth seeded into the ephemeral
HOME. Stock definitions cover scout, plan-build-test, and simple-sdlc
including its conditional retest.

Also: direct runs now project the same gate and envelope evidence the
worker path does, and the scripted runtime can emit tool-call events
and error verdicts rather than dropping them.

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

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (20)
internal/worker/events_test.go-239-242 (1)

239-242: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The exact pending assertion races with the background sender.

sendOnce removes up to protocol.MaxEventsPerBatch events from pending before it calls the offline ingester, and it restores them only after the call fails. Undelivered therefore can report fewer than 11 during that window, so this assertion is flaky. Assert the invariant that matters: nothing beyond seq 1 was delivered, and no event was dropped.

🧪 Proposed fix
 	pending, dropped := stream.Undelivered()
-	if pending != 11 || dropped != 0 {
-		t.Fatalf("expected 11 buffered and 0 dropped during the outage, got %d/%d", pending, dropped)
+	if pending == 0 || pending > 11 || dropped != 0 {
+		t.Fatalf("expected up to 11 buffered and 0 dropped during the outage, got %d/%d", pending, dropped)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/worker/events_test.go` around lines 239 - 242, Replace the exact
pending-count assertion in the outage test with assertions that no event beyond
sequence 1 was delivered and dropped remains zero. Avoid relying on the
transient value returned by stream.Undelivered while sendOnce is processing a
failed batch.
internal/worker/events_test.go-76-78 (1)

76-78: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

os.NewFile(0, os.DevNull) wraps standard input, not /dev/null.

The first argument is a file descriptor. 0 is standard input; os.DevNull is only the name label. Log writes therefore target fd 0, and os.NewFile attaches a finalizer that can close it. Write to io.Discard instead.

🧪 Proposed fix
 func discardLogger() *slog.Logger {
-	return slog.New(slog.NewTextHandler(os.NewFile(0, os.DevNull), nil))
+	return slog.New(slog.NewTextHandler(io.Discard, nil))
 }

Add the import:

 	"errors"
+	"io"
 	"log/slog"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/worker/events_test.go` around lines 76 - 78, Update discardLogger to
use an io.Discard-backed slog handler instead of os.NewFile(0, os.DevNull), and
add the required io import while removing any now-unused os import.
internal/worker/events.go-569-573 (1)

569-573: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Undelivered undercounts events while a batch is in flight.

sendOnce removes the batch from pending before the call and restores it only on failure. Between those points Undelivered reports fewer events than are actually undelivered, so a caller that checks completeness mid-attempt can read a low count. Close is unaffected because it waits for the sender to stop. Count the in-flight batch.

🔢 Proposed fix to count the in-flight batch
 	sending   bool
+	inFlight  int
 	dropped   int64
 	batch := append([]protocol.Event(nil), s.pending[:count]...)
 	s.pending = append([]protocol.Event(nil), s.pending[count:]...)
 	s.sending = true
+	s.inFlight = count
 	s.mutex.Unlock()
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
 	s.sending = false
+	s.inFlight = 0
 func (s *TraceStream) Undelivered() (pending int, dropped int64) {
 	s.mutex.Lock()
 	defer s.mutex.Unlock()
-	return len(s.pending), s.dropped
+	return len(s.pending) + s.inFlight, s.dropped
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/worker/events.go` around lines 569 - 573, Update
TraceStream.Undelivered to include the batch currently being processed by
sendOnce in its pending count, using the existing in-flight batch state and
protecting the read with the mutex. Preserve the current pending and dropped
counts when no batch is in flight.
cmd/jig/serve_test.go-90-90 (1)

90-90: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Resolve the noctx lint findings.

golangci-lint reports errors for http.Get on lines 90, 102, and 181, and for db.Exec on line 219. If the linter covers test files in CI, this file fails just check. Use http.NewRequestWithContext with http.DefaultClient.Do, and db.ExecContext.

🐛 Example fix for one call site
-	health, err := http.Get(base + "/api/health")
+	request, err := http.NewRequestWithContext(
+		context.Background(), http.MethodGet, base+"/api/health", nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	health, err := http.DefaultClient.Do(request)

Also applies to: 102-102, 181-181, 219-219

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/jig/serve_test.go` at line 90, Resolve the noctx lint findings in the
test calls: replace each http.Get invocation with an http.NewRequestWithContext
request executed via http.DefaultClient.Do, and replace db.Exec with
db.ExecContext. Update the affected call sites around the existing health checks
and database operation, supplying the appropriate test context while preserving
current request and query behavior.

Source: Linters/SAST tools

internal/controlplane/ingest.go-270-275 (1)

270-275: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Inconsistent not-found behavior on the read surface.

AttemptEventPage calls s.Attempt first, so an unknown attempt returns 404. AttemptGateEvidence and AttemptEnvelopes do not, so an unknown attempt returns 200 with []. A scripted caller cannot tell "this attempt has no gate evidence" from "this attempt does not exist". Add the same existence check to both.

🐛 Add the existence check
 func (s *Store) AttemptGateEvidence(ctx context.Context, attemptID string) ([]GateEvidence, error) {
+	if _, err := s.Attempt(ctx, attemptID); err != nil {
+		return nil, err
+	}
 	rows, err := s.db.QueryContext(ctx, `

Apply the same guard in AttemptEnvelopes.

Also applies to: 304-308

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/ingest.go` around lines 270 - 275, Update
AttemptGateEvidence and AttemptEnvelopes to match AttemptEventPage by calling
s.Attempt(ctx, attemptID) before querying their tables, so an unknown attempt
returns the same not-found error instead of an empty 200 response. Keep the
existing query and result handling unchanged for valid attempts, and apply the
same existence guard in both methods using the visible Attempt,
AttemptGateEvidence, and AttemptEnvelopes symbols.
cmd/jig/main.go-4-7 (1)

4-7: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The comment count no longer matches the usage text.

The comment says "Five subcommands" and names run, serve, worker, def, and trigger. The usage text on lines 27-32 lists six commands, because the same change added version. Update the comment so the two agree.

🐛 Name the version command
-// Five subcommands, one binary, no Node (R18): run is the serverless direct
+// Six subcommands, one binary, no Node (R18): run is the serverless direct
 // harness (U11), serve is the control plane with its embedded UI (U2/U6/U8),
 // worker is the execution host (U3/U4/U7), and def and trigger are the
-// operator's surface over definitions and admission (U5/U6).
+// operator's surface over definitions and admission (U5/U6). version prints
+// the build-stamped release identity.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/jig/main.go` around lines 4 - 7, Update the introductory command-count
comment in main to include the version subcommand and accurately state six
subcommands, naming version alongside run, serve, worker, def, and trigger so it
matches the usage text.
cmd/jig/runtime.go-117-127 (1)

117-127: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Bound the keychain lookup and gate it on darwin.

Two problems in this block:

  • Line 119 uses exec.Command, so the security lookup has no deadline. golangci-lint reports it as a noctx error. The seeder runs on the attempt path, so a stalled keychain prompt stalls the attempt.
  • The comment calls the seeder "darwin-aware", but there is no runtime.GOOS check. On Linux with no credentials file, the operator sees an error that names a macOS keychain item that cannot exist on that platform.

HomeSeeder does not appear to receive a context, so use exec.CommandContext with a locally bounded context.

🐛 Bound and gate the lookup
 	credentials, readErr := os.ReadFile(filepath.Join(real, ".claude", ".credentials.json"))
 	if readErr != nil {
-		extracted, keychainErr := exec.Command("security",
-			"find-generic-password", "-s", "Claude Code-credentials", "-w").Output()
+		if goruntime.GOOS != "darwin" {
+			return fmt.Errorf(
+				"seed claude auth: %s is unreadable (%v); run `claude login` first, "+
+					"or use --no-seed-auth if the CLI authenticates through its environment",
+				filepath.Join(real, ".claude", ".credentials.json"), readErr)
+		}
+		lookupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+		defer cancel()
+		extracted, keychainErr := exec.CommandContext(lookupCtx, "security",
+			"find-generic-password", "-s", "Claude Code-credentials", "-w").Output()
 		if keychainErr != nil {

Import "time" and goruntime "runtime". The alias avoids a collision with the existing runtime import.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/jig/runtime.go` around lines 117 - 127, Update the credential fallback in
HomeSeeder to attempt the `security` keychain lookup only when `goruntime.GOOS`
is `darwin`; otherwise return the existing missing-credentials error without
invoking macOS tooling. Create a locally bounded context using a short timeout
from `time` and pass it to `exec.CommandContext` for the keychain command,
preserving the existing extracted-credentials and error handling behavior.

Source: Linters/SAST tools

cmd/jig/def.go-418-434 (1)

418-434: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

An unknown --kind produces a misleading error.

The else branch treats every value that is not TriggerSchedule as a GitHub kind. A typo such as --kind shedule with two --repo flags reports "a shedule trigger takes exactly one --repo" instead of naming the unknown kind. Validate *kind against the accepted vocabulary first.

🐛 Validate the kind
+	switch *kind {
+	case controlplane.TriggerSchedule,
+		controlplane.TriggerGitHubIssue,
+		controlplane.TriggerGitHubPullRequest:
+	default:
+		fmt.Fprintf(stderr,
+			"jig trigger create: unknown --kind %q — expected schedule, github_issue, or github_pull_request\n",
+			*kind)
+		return exitUsage
+	}
 	if *kind == controlplane.TriggerSchedule {

Confirm the exported constant name for the pull-request kind.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/jig/def.go` around lines 418 - 434, Validate *kind against the accepted
trigger vocabulary before entering the schedule/GitHub repository handling in
the trigger creation flow. Include the exported constant for the pull-request
trigger alongside controlplane.TriggerSchedule and the valid GitHub kind, and
return the existing usage error naming the unknown kind before repository-count
validation; preserve the current schedule and single-repository behavior for
valid kinds.
cmd/jig/serve.go-145-153 (1)

145-153: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

The background loops are not awaited before the store closes.

stopBackground cancels the sweeper and the admission runner, but nothing waits for them to return. The deferred store.Close on line 100 then runs immediately after cancellation, so a sweep or dispatch still in flight can observe a closed database and log a spurious failure on every shutdown. Add a sync.WaitGroup around both goroutines and wait after stopBackground.

🐛 Wait for the background loops
 	background, stopBackground := context.WithCancel(context.WithoutCancel(ctx))
 	defer stopBackground()
+	var loops sync.WaitGroup
+	defer loops.Wait()
 	sweeper := controlplane.NewSweeper(store)
-	go sweeper.Run(background, logger)
-	go admission.Run(background)
+	loops.Add(2)
+	go func() { defer loops.Done(); sweeper.Run(background, logger) }()
+	go func() { defer loops.Done(); admission.Run(background) }()

defer loops.Wait() must be registered after defer stopBackground() so it runs first.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/jig/serve.go` around lines 145 - 153, Use a sync.WaitGroup to track both
background goroutines started for sweeper.Run and admission.Run, calling Add
before launch and Done on each goroutine’s return. Register defer loops.Wait()
after defer stopBackground() so waiting occurs first during shutdown, ensuring
both loops exit before the deferred store.Close runs.
cmd/jig/run.go-126-130 (1)

126-130: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

An unknown --runtime value exits 1 instead of the documented 2.

selectRuntime returns unknown runtime %q — expected one of ... for a flag typo (cmd/jig/runtime.go lines 86-89). This block maps every selection error to exitInfraFailed, but the usage text on lines 50-54 reserves exit 2 for usage errors. A typo in --runtime is a usage error, not an infrastructure failure. cmd/jig/worker.go lines 112-116 have the same mapping.

Validate the name against runtimeNames before calling selectRuntime, or return a sentinel error the callers can distinguish.

🐛 Sentinel error the callers can classify

In cmd/jig/runtime.go:

+// errUnknownRuntime marks a bad --runtime value: a usage error, not an
+// infrastructure failure.
+var errUnknownRuntime = errors.New("unknown runtime")
+
 	default:
-		return selectedRuntime{}, fmt.Errorf("unknown runtime %q — expected one of %s",
-			name, strings.Join(runtimeNames, ", "))
+		return selectedRuntime{}, fmt.Errorf("%w %q — expected one of %s",
+			errUnknownRuntime, name, strings.Join(runtimeNames, ", "))

At each call site:

 	selected, err := selectRuntime(ctx, *runtimeName, !*noSeedAuth)
 	if err != nil {
 		fmt.Fprintf(stderr, "jig run: %v\n", err)
+		if errors.Is(err, errUnknownRuntime) {
+			return exitUsage
+		}
 		return exitInfraFailed
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/jig/run.go` around lines 126 - 130, Map unknown --runtime values to the
documented usage exit code instead of exitInfraFailed. Update the runtime
selection flow around selectRuntime and its corresponding call site in worker.go
to distinguish the unknown-runtime error, using runtimeNames validation or a
shared sentinel from runtime.go; retain infrastructure-failure handling for
other selection errors.
internal/controlplane/ingest_test.go-263-281 (1)

263-281: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

The test passes only because insertEvent does not modify the original event when capping the payload.

insertEvent receives a struct copy and modifies event.Payload in that copy before storing. The original event from the loop retains the full uncapped payload. projectEvidence receives this original event, unmarshals the full payload.Raw (69632 bytes), and then caps it to MaxInvalidEnvelopeBytes before storing in the envelopes table.

If insertEvent were refactored to cap the payload before the call to projectEvidence (for example, by using a pointer parameter or a separate output), projectEvidence would receive the truncated {"truncated": true, ...} object instead of the original payload. The invalid_envelope case would fail to unmarshal the expected shape and skip the envelope row entirely, causing the test assertion to fail.

Clarify whether both constants must remain equal or whether oversize should be sized to exercise both caps deliberately. If MaxEventPayloadBytes can differ from MaxInvalidEnvelopeBytes in the future, add an assertion about their relationship to document this implicit dependency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/ingest_test.go` around lines 263 - 281, Update the
oversize payload test around insertEvent and projectEvidence to explicitly
document the dependency between MaxEventPayloadBytes and
MaxInvalidEnvelopeBytes. Size oversize to exercise both limits deliberately, or
assert their required relationship before constructing the batch, so the test
remains valid if the constants diverge; preserve the invalid_envelope
envelope-row assertion.
web/src/SwimLane.tsx-89-99 (1)

89-99: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix the ARIA roles on the phase block and the mark pin.

role="listitem" on the <button> replaces the implicit button role. Assistive technology then announces a list item and not an activatable control, even though the element still receives focus and responds to Enter. Move the role to a wrapper element.

aria-label on a plain <span> with no role is not exposed by most screen readers. Give MarkPin a role so its label reaches the accessibility tree.

♿ Proposed change
-    <button
-      type="button"
-      role="listitem"
-      className={`phase-block phase-${status}`}
-      style={{
-        left: `${offsetPercent(span, startMs, endMs)}%`,
-        width: `${widthPercent(span, startMs, endMs, nowMs)}%`,
-      }}
-      aria-label={`phase ${span.label}, ${status}, ${duration}`}
-      onClick={() => onSelect?.(span.phase)}
-    >
-      <span className="phase-name">{span.label}</span>
-      <span className="phase-duration">{duration}</span>
-    </button>
+    <div
+      role="listitem"
+      className={`phase-block phase-${status}`}
+      style={{
+        left: `${offsetPercent(span, startMs, endMs)}%`,
+        width: `${widthPercent(span, startMs, endMs, nowMs)}%`,
+      }}
+    >
+      <button
+        type="button"
+        className="phase-block-button"
+        aria-label={`phase ${span.label}, ${status}, ${duration}`}
+        onClick={() => onSelect?.(span.phase)}
+      >
+        <span className="phase-name">{span.label}</span>
+        <span className="phase-duration">{duration}</span>
+      </button>
+    </div>
     <span
+      role="img"
       className={`mark mark-${mark.kind}`}
       style={{ left: `${left}%` }}

The wrapper needs the absolute positioning rules in styles.css if you move left/width to it.

Also applies to: 110-117

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/SwimLane.tsx` around lines 89 - 99, Update the phase block markup in
SwimLane so role="listitem" is applied to an absolutely positioned wrapper
rather than the interactive button, preserving the wrapper’s left and width
positioning through the existing styles.css rules while keeping the button’s
implicit button role and behavior. Update MarkPin to assign an appropriate role
to the labeled span so its aria-label is exposed to assistive technology.
examples/definitions/simple-sdlc.yaml-133-149 (1)

133-149: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Delete $HOME/commit-message on the nothing-to-commit path.

The commit phase returns early when the index is clean. It leaves an authored message file behind. commit-revision later reads the same path, so a stale message from the build phase can become the subject of the revision commit. Remove the file before the early exit.

♻️ Proposed change
       git add -A
       if git diff --cached --quiet; then
         echo "commit-build: nothing to commit"
+        rm -f "$message"
         exit 0
       fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/definitions/simple-sdlc.yaml` around lines 133 - 149, Update the
clean-index early-exit path in the commit command to remove $HOME/commit-message
before returning. Preserve the existing “nothing to commit” output and exit
behavior, while ensuring stale build-phase messages cannot be consumed by
commit-revision.
cmd/jig/definitions_test.go-28-50 (1)

28-50: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Filter the directory entries before you parse them.

The loop treats every entry as a definition file. A subdirectory makes os.ReadFile fail and stops the test with t.Fatal. A README.md or a .yml file fails ParseDefinition, and the strings.TrimSuffix(entry.Name(), ".yaml") comparison is wrong for any other extension. The len(entries) < 5 check counts those entries too.

💚 Proposed fix
-	if len(entries) < 5 {
-		t.Fatalf("the stock library has %d definitions; the plan ships five", len(entries))
-	}
+	var definitions []string
 	for _, entry := range entries {
-		source, err := os.ReadFile(stockDefinition(entry.Name()))
+		if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" {
+			continue
+		}
+		definitions = append(definitions, entry.Name())
+	}
+	if len(definitions) < 5 {
+		t.Fatalf("the stock library has %d definitions; the plan ships five", len(definitions))
+	}
+	for _, name := range definitions {
+		source, err := os.ReadFile(stockDefinition(name))
 		if err != nil {
 			t.Fatal(err)
 		}
 		spec, err := protocol.ParseDefinition(source)
 		if err != nil {
-			t.Errorf("%s does not validate: %v", entry.Name(), err)
+			t.Errorf("%s does not validate: %v", name, err)
 			continue
 		}
 		// The file name is how an operator refers to it; a definition whose
 		// declared name disagrees is a definition that lists confusingly.
-		if want := strings.TrimSuffix(entry.Name(), ".yaml"); spec.Name != want {
-			t.Errorf("%s declares name %q", entry.Name(), spec.Name)
+		if want := strings.TrimSuffix(name, ".yaml"); spec.Name != want {
+			t.Errorf("%s declares name %q", name, spec.Name)
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/jig/definitions_test.go` around lines 28 - 50, Filter the entries
returned by os.ReadDir before the count and parsing loop, retaining only regular
files with the .yaml extension. Apply the filtered collection to the len check,
os.ReadFile, and name validation so non-definition files and directories are
excluded.
web/src/lanes.ts-204-210 (1)

204-210: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Replace the argument spreads in laneEnd.

Math.max receives one argument per phase, tool, and mark. Engines cap the argument count, so a long attempt with a large event count raises RangeError: too many function arguments and the detail view stops rendering. Fold the maximum instead.

🐛 Proposed fix
   if (lane.endMs !== null) return Math.max(lane.endMs, lane.startMs + 1);
-  const latest = Math.max(
-    lane.startMs,
-    ...lane.phases.map((span) => span.endMs ?? span.startMs),
-    ...lane.tools.map((span) => span.endMs ?? span.startMs),
-    ...lane.marks.map((mark) => mark.atMs),
-  );
+  let latest = lane.startMs;
+  for (const span of lane.phases) latest = Math.max(latest, span.endMs ?? span.startMs);
+  for (const span of lane.tools) latest = Math.max(latest, span.endMs ?? span.startMs);
+  for (const mark of lane.marks) latest = Math.max(latest, mark.atMs);
   return Math.max(nowMs, latest, lane.startMs + 1);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/lanes.ts` around lines 204 - 210, Update laneEnd to avoid spreading
phase, tool, and mark arrays into Math.max; compute their maximum values
incrementally or via reductions, then combine those maxima with lane.startMs
while preserving the existing nowMs and minimum-duration behavior.
web/src/format.ts-16-22 (1)

16-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Carry the rounded seconds into the minute.

Math.round can return 60, so formatDuration(119_700) renders 1m 60s. The same rounding makes formatDuration(59_600) render 60s. Round the total seconds once before you split the value.

🐛 Proposed fix
-  const seconds = milliseconds / 1000;
-  if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
-  const minutes = Math.floor(seconds / 60);
-  const rest = Math.round(seconds - minutes * 60);
-  if (minutes < 60) return `${minutes}m ${rest}s`;
-  const hours = Math.floor(minutes / 60);
-  return `${hours}h ${minutes - hours * 60}m`;
+  const exact = milliseconds / 1000;
+  if (exact < 9.95) return `${exact.toFixed(1)}s`;
+  const seconds = Math.round(exact);
+  if (seconds < 60) return `${seconds}s`;
+  const minutes = Math.floor(seconds / 60);
+  if (minutes < 60) return `${minutes}m ${seconds - minutes * 60}s`;
+  const hours = Math.floor(minutes / 60);
+  return `${hours}h ${minutes - hours * 60}m`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/format.ts` around lines 16 - 22, Update formatDuration’s duration
decomposition to round the total seconds once before calculating minutes and
remaining seconds. Use that rounded total for all subsequent unit calculations
so values such as 119_700 milliseconds normalize to 2m 0s and 59_600
milliseconds normalize to 1m rather than emitting 60 seconds.
docs/quickstart.md-36-45 (1)

36-45: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify the output fence language.

Add text to each output fence. These fences trigger markdownlint rule MD040.

Proposed documentation fix
-```
+```text
 run: ee31d5e7-baae-415d-b421-11d6b135419e
 ...
</details>


Also applies to: 127-129, 164-169

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @docs/quickstart.md around lines 36 - 45, Update every output code fence in
the quickstart documentation, including the sections around the shown output and
the referenced additional locations, to specify the text language as ```text
while preserving all output content unchanged.


</details>

<!-- cr-comment:v1:86fcf6f0fc2f9ee9f5d937f7 -->

_Source: Linters/SAST tools_

</blockquote></details>
<details>
<summary>README.md-18-22 (1)</summary><blockquote>

`18-22`: _📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_

**Specify the diagram fence language.**

Add `text` to this fence. The current fence triggers markdownlint rule MD040.






<details>
<summary>Proposed documentation fix</summary>

```diff
-```
+```text
 definition (YAML)  →  run (frozen by value)  →  job per repository  →  attempt
                                                                         │
                               phase → phase → phase, each gated ────────┘
 ```
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 18 - 22, Update the fenced diagram in the README to
specify the text language, changing the opening fence from an untyped fence to a
text fence while preserving the diagram content unchanged.
```

</details>

<!-- cr-comment:v1:9d63441550162a8b10c2e2e9 -->

_Source: Linters/SAST tools_

</blockquote></details>
<details>
<summary>web/src/styles.css-17-17 (1)</summary><blockquote>

`17-17`: _📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_

**Fix the Stylelint declaration-spacing error.**

Stylelint requires an empty line before `font-family` at line 17.

   

<details>
<summary>Proposed fix</summary>

```diff
   --bad: `#f87171`;
   --tool: `#7c8cf8`;
+
   font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/styles.css` at line 17, Insert an empty line immediately before the
font-family declaration in the stylesheet to satisfy Stylelint’s
declaration-spacing rule, without changing the declaration itself.
```

</details>

<!-- cr-comment:v1:a8a870488cc162fb7cf4de3f -->

_Source: Linters/SAST tools_

</blockquote></details>
<details>
<summary>web/src/router.ts-19-20 (1)</summary><blockquote>

`19-20`: _🩺 Stability & Availability_ | _🟡 Minor_ | _⚡ Quick win_

**Handle malformed encoded route IDs.**

If `decodeURIComponent` fails for `/runs/%` or `/jobs/%`, return `{ name: "unknown", path }` so invalid URLs do not break route parsing.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/router.ts` around lines 19 - 20, Handle decodeURIComponent failures
in the route parsing branches for “runs” and “jobs” by catching malformed
encoded IDs and returning { name: "unknown", path }. Preserve the existing
decoded run/job results for valid route IDs and use the current path value in
the fallback.
```

</details>

<!-- cr-comment:v1:cca28a0fd05d5233e8d088c8 -->

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (9)</summary><blockquote>

<details>
<summary>cmd/jig/def.go (1)</summary><blockquote>

`649-656`: _🎯 Functional Correctness_ | _🔵 Trivial_ | _⚡ Quick win_

**Use `errors.New` for the message-only rejection.**

Line 653 passes a runtime string as the format argument to `fmt.Errorf` with no arguments. Any `%` in the control plane's message is then interpreted as a verb and renders as `%!s(MISSING)`. `errors.New` avoids that.





<details>
<summary>♻️ Replace with errors.New</summary>

```diff
-		return fmt.Errorf("%s", document.Error.Message)
+		return errors.New(document.Error.Message)
```

Add `"errors"` to the import block.
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/jig/def.go` around lines 649 - 656, Update the message-only rejection in
the error-handling branch of the control-plane response to use errors.New with
document.Error.Message, and add the errors import. Leave the formatted error
path for document.Error.Code unchanged.
```

</details>

<!-- cr-comment:v1:d607421344e62a63c33862b7 -->

</blockquote></details>
<details>
<summary>cmd/jig/serve_test.go (1)</summary><blockquote>

`53-67`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _💤 Low value_

**Simplify the listen-address parse.**

Lines 56-58 call `strings.CutPrefix`, discard the remainder, then recompute the same value with `Split` and `TrimPrefix`. Use the remainder that `CutPrefix` already returns.





<details>
<summary>♻️ Use the CutPrefix remainder</summary>

```diff
-		if _, found := strings.CutPrefix(stdout.String(), "jig serve: listening on "); found {
-			base = strings.TrimSpace(strings.TrimPrefix(
-				strings.Split(stdout.String(), "\n")[0], "jig serve: listening on "))
-			break
+		if rest, found := strings.CutPrefix(stdout.String(), "jig serve: listening on "); found {
+			line, _, complete := strings.Cut(rest, "\n")
+			if !complete {
+				continue
+			}
+			base = strings.TrimSpace(line)
+			break
 		}
```

The `complete` check avoids reading a partially written line.
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/jig/serve_test.go` around lines 53 - 67, Update the listen-address
parsing loop in the serve test to capture and reuse the remainder returned by
strings.CutPrefix instead of recomputing it with strings.Split and
strings.TrimPrefix. Retain the complete-prefix check so partially written output
is ignored, then trim the captured remainder before assigning it to base.
```

</details>

<!-- cr-comment:v1:5359fbc6d35beb126bb5084b -->

</blockquote></details>
<details>
<summary>cmd/jig/run.go (1)</summary><blockquote>

`381-400`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _💤 Low value_

**The scripted event cannot carry `Text` or per-step `Usage`.**

`runtime.Event` has a `Text` field, and `enginetest.Step` has a `Usage` field. Neither is reachable from the script schema, so a scripted run cannot exercise text-bearing events or cost accounting. Add the keys when a scenario needs them.





<details>
<summary>♻️ Add the text key</summary>

```diff
 			Events []struct {
 				Type    string          `json:"type"`
 				Name    string          `json:"name"`
+				Text    string          `json:"text"`
 				Payload json.RawMessage `json:"payload"`
 			} `json:"events"`
```

```diff
 			events = append(events, runtime.Event{
 				Kind:    event.Type,
 				Name:    event.Name,
+				Text:    event.Text,
 				Payload: event.Payload,
 			})
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/jig/run.go` around lines 381 - 400, Extend the scripted runtime schema
and conversion in the script-loading flow around json.Unmarshal and the event
loop to expose runtime.Event.Text and enginetest.Step.Usage. Add the
corresponding JSON keys to the parsed event and step structures, then copy them
into the constructed runtime.Event and enginetest.Step so scripted scenarios
support text-bearing events and per-step usage.
```

</details>

<!-- cr-comment:v1:08917111b2ac6a0e100de539 -->

</blockquote></details>
<details>
<summary>cmd/jig/worker_test.go (1)</summary><blockquote>

`302-315`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_

**Parse the JSON instead of scanning for the field marker.**

`jsonField` finds the first occurrence of `"<field>": ` anywhere in the payload, including inside nested objects, and it does not handle escaped quotes. Decode the payload instead. The result is shorter and cannot match the wrong field.





<details>
<summary>♻️ Decode the payload</summary>

```diff
-// jsonField pulls one top-level string field out of a --json payload.
+// jsonField pulls one top-level string field out of a --json payload.
 func jsonField(t *testing.T, body, field string) string {
 	t.Helper()
-	marker := fmt.Sprintf("%q: ", field)
-	index := strings.Index(body, marker)
-	if index < 0 {
-		t.Fatalf("payload has no %q field:\n%s", field, body)
-	}
-	rest := body[index+len(marker):]
-	value, _, _ := strings.Cut(strings.TrimPrefix(strings.TrimSpace(rest), `"`), `"`)
-	if value == "" {
-		t.Fatalf("field %q is empty:\n%s", field, body)
-	}
-	return value
+	var document map[string]any
+	if err := json.Unmarshal([]byte(body), &document); err != nil {
+		t.Fatalf("payload is not a JSON object: %v\n%s", err, body)
+	}
+	value, ok := document[field].(string)
+	if !ok || value == "" {
+		t.Fatalf("field %q is missing or empty:\n%s", field, body)
+	}
+	return value
 }
```

This removes the `fmt` import requirement if no other use remains.
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/jig/worker_test.go` around lines 302 - 315, Update jsonField to unmarshal
body as JSON into a map and retrieve the requested top-level field, preserving
fatal errors for invalid JSON, missing fields, or empty values. Remove the
now-unused fmt and strings imports if no other code uses them.
```

</details>

<!-- cr-comment:v1:dc8f2739f5562a7d2eeb5dc0 -->

</blockquote></details>
<details>
<summary>internal/controlplane/ingest.go (1)</summary><blockquote>

`87-100`: _🗄️ Data Integrity & Integration_ | _🔵 Trivial_ | _💤 Low value_

**Consider projecting evidence from the capped event.**

`insertEvent` caps `event.Payload` on its own copy, so line 97 passes the uncaught, uncapped payload to `projectEvidence`. The `invalid_envelope` branch caps `body` separately, but the gate branch inserts `check.Item` and `check.Note` verbatim. An oversized gate payload therefore produces `gate_results` rows larger than the stored `events` row.

Cap once before both calls.





<details>
<summary>♻️ Cap the payload once</summary>

```diff
 	for _, event := range batch.Events {
+		event.Payload = capIngestedPayload(event.Payload)
 		inserted, err := insertEvent(ctx, tx, attemptID, event, now)
```

Then remove the capping line from `insertEvent`.
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controlplane/ingest.go` around lines 87 - 100, Cap the event payload
once in the batch loop before calling insertEvent, then pass that capped event
to both insertEvent and projectEvidence so stored events and projected evidence
match. Remove the independent payload-capping logic from insertEvent and
preserve the existing replay and error-handling flow.
```

</details>

<!-- cr-comment:v1:c47ed183ac882a37a65013c0 -->

</blockquote></details>
<details>
<summary>web/src/lanes.ts (1)</summary><blockquote>

`93-95`: _🎯 Functional Correctness_ | _🔵 Trivial_ | _💤 Low value_

**Close the open tool span before you replace it.**

The `tool_call` branch assigns `openTool` without closing the previous span when the previous span belongs to another phase. That span then keeps `endMs === null` and renders as running until a `phase_end` arrives. A `phase_end` for the earlier phase does close it, so the gap only appears when events from two phases interleave. Close unconditionally on a new `tool_call` to remove the dependency on event interleaving.

<details>
<summary>♻️ Proposed change</summary>

```diff
       case "tool_call": {
+        closeTool(at);
         const span: Span = {
```
</details>





Also applies to: 132-143

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/lanes.ts` around lines 93 - 95, Update the tool_call handling around
openTool so any existing open tool span is closed before assigning the new tool
span, regardless of phase. Preserve the existing phase_end behavior while
ensuring interleaved phases cannot leave the previous span with endMs === null.
```

</details>

<!-- cr-comment:v1:04611bed415d0c87bffc4dea -->

</blockquote></details>
<details>
<summary>web/embed.go (1)</summary><blockquote>

`84-90`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _💤 Low value_

**Consider `http.ServeContent` for the SPA shell.**

The current code writes the index body directly. A HEAD response therefore carries no `Content-Length`, and no `ETag` or `Last-Modified` is offered. `http.ServeContent` handles the HEAD case, sets the length, and keeps the `Cache-Control: no-cache` header you already set.

<details>
<summary>♻️ Proposed change</summary>

```diff
 func (h *handler) serveIndex(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 	w.Header().Set("Cache-Control", "no-cache")
-	if r.Method == http.MethodGet {
-		_, _ = w.Write(h.index)
-	}
+	http.ServeContent(w, r, "index.html", h.modTime, bytes.NewReader(h.index))
 }
```
</details>

Store a fixed `modTime` (for example the build time or the zero time) on `handler` if you adopt this.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/embed.go` around lines 84 - 90, Update handler.serveIndex to serve the
SPA index through http.ServeContent instead of writing h.index directly,
preserving the existing Content-Type and Cache-Control headers. Add and use a
fixed handler modTime value, such as build time or zero time, so ServeContent
can provide correct HEAD handling, Content-Length, and modification metadata.
```

</details>

<!-- cr-comment:v1:da0e0ed91b583a1f72978270 -->

</blockquote></details>
<details>
<summary>web/embed_test.go (1)</summary><blockquote>

`70-76`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_

**Add HEAD coverage.**

The handler allows GET and HEAD, and `serveIndex` branches on the method. No test exercises HEAD, so a regression that returns a body or a wrong status for HEAD would pass.

<details>
<summary>💚 Proposed test</summary>

```diff
 func TestNonReadMethodsAreRefused(t *testing.T) {
 	recorder := httptest.NewRecorder()
 	Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/", nil))
 	if recorder.Code != http.StatusMethodNotAllowed {
 		t.Fatalf("expected 405 for POST, got %d", recorder.Code)
 	}
 }
+
+func TestHeadReturnsHeadersWithoutABody(t *testing.T) {
+	recorder := httptest.NewRecorder()
+	Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodHead, "/runs", nil))
+	if recorder.Code != http.StatusOK {
+		t.Fatalf("expected 200 for HEAD, got %d", recorder.Code)
+	}
+	if recorder.Body.Len() != 0 {
+		t.Fatalf("HEAD must not carry a body, got %d bytes", recorder.Body.Len())
+	}
+}
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/embed_test.go` around lines 70 - 76, Add HEAD-method coverage to
TestNonReadMethodsAreRefused or a nearby handler test, invoking
Handler().ServeHTTP with http.MethodHead and asserting the expected successful
status and HEAD semantics, including no response body. Keep the existing POST
rejection assertion unchanged.
```

</details>

<!-- cr-comment:v1:36da24219cd2999501ad77b5 -->

</blockquote></details>
<details>
<summary>examples/definitions/simple-sdlc.yaml (1)</summary><blockquote>

`150-158`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _💤 Low value_

**Use YAML anchors to eliminate duplication across test, retest, commit-build, and commit-revision phases.**

The `test` and `retest` phases (lines 150–158, 186–195) contain identical test-detection scripts. The `commit-build` and `commit-revision` phases (lines 130–149, 165–185) contain identical commit commands; only log messages differ. YAML anchors and aliases will keep these blocks synchronized when the commands are edited.

The definition parser's `KnownFields(true)` setting does not block YAML anchors and aliases, since the YAML parser resolves them before struct field validation occurs.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/definitions/simple-sdlc.yaml` around lines 150 - 158, Use YAML
anchors to centralize the shared test-detection script between the test and
retest phases, and the shared commit command between commit-build and
commit-revision. Define each command once on the appropriate phase and reference
it with aliases in the duplicate phases, preserving the existing phase-specific
log messages.
```

</details>

<!-- cr-comment:v1:3273e55758c1d33197657c4c -->

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:

  • Around line 57-64: Harden the ui job by adding job-level permissions limited
    to contents: read, and update its actions/checkout step to set
    persist-credentials: false. Keep the existing checkout revision and
    setup-node/install flow unchanged.

In @.github/workflows/release.yml:

  • Around line 22-24: Separate the build and publication workflows so the build
    job uses read-only contents permissions, while a dedicated publish job runs only
    when the ref matches refs/tags/v*. Have the publish job depend on the build,
    download its artifact, and assign contents: write only within that job.

In @cmd/jig/worker.go:

  • Around line 319-338: Update anyClosed to return both the merged cancellation
    channel and a release function that closes merged through the existing
    sync.Once, allowing waiting goroutines to exit when the attempt completes. At
    the call site that invokes anyClosed for Execute, capture the release function
    and register defer releaseCancellation() before returning, ensuring it runs
    after Execute returns.

In @internal/worker/events.go:

  • Around line 480-506: Update TraceStream.sendLoop to track a retry deadline
    whenever sendOnce fails, and ensure wake events do not trigger another send
    until that deadline has passed. When nudge wakes the loop during backoff,
    reschedule or wait on the timer rather than calling sendOnce; retain immediate
    wake-driven sends when no retry backoff is active and preserve the existing
    exponential backoff limits.
  • Around line 544-563: Update sendOnce’s ingest-error handling to classify
    invalid_event_seq, invalid_event_type, and lease_not_owner for the current token
    as permanent rejections: remove the rejected batch, record a valid gap marker
    with the appropriate reason, and do not restore it to s.pending. Continue
    prepending the batch and retrying for transport and storage failures, while
    preserving the existing warning and return behavior.

In @web/src/polling.ts:

  • Around line 141-148: The polling stop condition in the arrived-empty branch
    must not terminate after two empty pages based only on terminal status. Update
    the polling flow around arrived, terminal, and idleRef to continue until the
    event API exposes an explicit finalized/completion signal, or until the control
    plane guarantees ingestion has ended; use that signal as the sole condition for
    clearing timer.

In @web/tsconfig.json:

  • Around line 3-6: Update the referenced projects in tsconfig.app.json and
    tsconfig.node.json to use build-compatible configurations with composite enabled
    and emit output enabled, then retain separate no-emit configurations for regular
    type checking. Ensure the references in tsconfig.json target the build configs
    so tsc -b no longer reports TS6306 or TS6310.

Minor comments:
In @cmd/jig/def.go:

  • Around line 418-434: Validate *kind against the accepted trigger vocabulary
    before entering the schedule/GitHub repository handling in the trigger creation
    flow. Include the exported constant for the pull-request trigger alongside
    controlplane.TriggerSchedule and the valid GitHub kind, and return the existing
    usage error naming the unknown kind before repository-count validation; preserve
    the current schedule and single-repository behavior for valid kinds.

In @cmd/jig/definitions_test.go:

  • Around line 28-50: Filter the entries returned by os.ReadDir before the count
    and parsing loop, retaining only regular files with the .yaml extension. Apply
    the filtered collection to the len check, os.ReadFile, and name validation so
    non-definition files and directories are excluded.

In @cmd/jig/main.go:

  • Around line 4-7: Update the introductory command-count comment in main to
    include the version subcommand and accurately state six subcommands, naming
    version alongside run, serve, worker, def, and trigger so it matches the usage
    text.

In @cmd/jig/run.go:

  • Around line 126-130: Map unknown --runtime values to the documented usage exit
    code instead of exitInfraFailed. Update the runtime selection flow around
    selectRuntime and its corresponding call site in worker.go to distinguish the
    unknown-runtime error, using runtimeNames validation or a shared sentinel from
    runtime.go; retain infrastructure-failure handling for other selection errors.

In @cmd/jig/runtime.go:

  • Around line 117-127: Update the credential fallback in HomeSeeder to attempt
    the security keychain lookup only when goruntime.GOOS is darwin; otherwise
    return the existing missing-credentials error without invoking macOS tooling.
    Create a locally bounded context using a short timeout from time and pass it
    to exec.CommandContext for the keychain command, preserving the existing
    extracted-credentials and error handling behavior.

In @cmd/jig/serve_test.go:

  • Line 90: Resolve the noctx lint findings in the test calls: replace each
    http.Get invocation with an http.NewRequestWithContext request executed via
    http.DefaultClient.Do, and replace db.Exec with db.ExecContext. Update the
    affected call sites around the existing health checks and database operation,
    supplying the appropriate test context while preserving current request and
    query behavior.

In @cmd/jig/serve.go:

  • Around line 145-153: Use a sync.WaitGroup to track both background goroutines
    started for sweeper.Run and admission.Run, calling Add before launch and Done on
    each goroutine’s return. Register defer loops.Wait() after defer
    stopBackground() so waiting occurs first during shutdown, ensuring both loops
    exit before the deferred store.Close runs.

In @docs/quickstart.md:

  • Around line 36-45: Update every output code fence in the quickstart
    documentation, including the sections around the shown output and the referenced
    additional locations, to specify the text language as ```text while preserving
    all output content unchanged.

In @examples/definitions/simple-sdlc.yaml:

  • Around line 133-149: Update the clean-index early-exit path in the commit
    command to remove $HOME/commit-message before returning. Preserve the existing
    “nothing to commit” output and exit behavior, while ensuring stale build-phase
    messages cannot be consumed by commit-revision.

In @internal/controlplane/ingest_test.go:

  • Around line 263-281: Update the oversize payload test around insertEvent and
    projectEvidence to explicitly document the dependency between
    MaxEventPayloadBytes and MaxInvalidEnvelopeBytes. Size oversize to exercise both
    limits deliberately, or assert their required relationship before constructing
    the batch, so the test remains valid if the constants diverge; preserve the
    invalid_envelope envelope-row assertion.

In @internal/controlplane/ingest.go:

  • Around line 270-275: Update AttemptGateEvidence and AttemptEnvelopes to match
    AttemptEventPage by calling s.Attempt(ctx, attemptID) before querying their
    tables, so an unknown attempt returns the same not-found error instead of an
    empty 200 response. Keep the existing query and result handling unchanged for
    valid attempts, and apply the same existence guard in both methods using the
    visible Attempt, AttemptGateEvidence, and AttemptEnvelopes symbols.

In @internal/worker/events_test.go:

  • Around line 239-242: Replace the exact pending-count assertion in the outage
    test with assertions that no event beyond sequence 1 was delivered and dropped
    remains zero. Avoid relying on the transient value returned by
    stream.Undelivered while sendOnce is processing a failed batch.
  • Around line 76-78: Update discardLogger to use an io.Discard-backed slog
    handler instead of os.NewFile(0, os.DevNull), and add the required io import
    while removing any now-unused os import.

In @internal/worker/events.go:

  • Around line 569-573: Update TraceStream.Undelivered to include the batch
    currently being processed by sendOnce in its pending count, using the existing
    in-flight batch state and protecting the read with the mutex. Preserve the
    current pending and dropped counts when no batch is in flight.

In @README.md:

  • Around line 18-22: Update the fenced diagram in the README to specify the text
    language, changing the opening fence from an untyped fence to a text fence while
    preserving the diagram content unchanged.

In @web/src/format.ts:

  • Around line 16-22: Update formatDuration’s duration decomposition to round the
    total seconds once before calculating minutes and remaining seconds. Use that
    rounded total for all subsequent unit calculations so values such as 119_700
    milliseconds normalize to 2m 0s and 59_600 milliseconds normalize to 1m rather
    than emitting 60 seconds.

In @web/src/lanes.ts:

  • Around line 204-210: Update laneEnd to avoid spreading phase, tool, and mark
    arrays into Math.max; compute their maximum values incrementally or via
    reductions, then combine those maxima with lane.startMs while preserving the
    existing nowMs and minimum-duration behavior.

In @web/src/router.ts:

  • Around line 19-20: Handle decodeURIComponent failures in the route parsing
    branches for “runs” and “jobs” by catching malformed encoded IDs and returning {
    name: "unknown", path }. Preserve the existing decoded run/job results for valid
    route IDs and use the current path value in the fallback.

In @web/src/styles.css:

  • Line 17: Insert an empty line immediately before the font-family declaration
    in the stylesheet to satisfy Stylelint’s declaration-spacing rule, without
    changing the declaration itself.

In @web/src/SwimLane.tsx:

  • Around line 89-99: Update the phase block markup in SwimLane so
    role="listitem" is applied to an absolutely positioned wrapper rather than the
    interactive button, preserving the wrapper’s left and width positioning through
    the existing styles.css rules while keeping the button’s implicit button role
    and behavior. Update MarkPin to assign an appropriate role to the labeled span
    so its aria-label is exposed to assistive technology.

Nitpick comments:
In @cmd/jig/def.go:

  • Around line 649-656: Update the message-only rejection in the error-handling
    branch of the control-plane response to use errors.New with
    document.Error.Message, and add the errors import. Leave the formatted error
    path for document.Error.Code unchanged.

In @cmd/jig/run.go:

  • Around line 381-400: Extend the scripted runtime schema and conversion in the
    script-loading flow around json.Unmarshal and the event loop to expose
    runtime.Event.Text and enginetest.Step.Usage. Add the corresponding JSON keys to
    the parsed event and step structures, then copy them into the constructed
    runtime.Event and enginetest.Step so scripted scenarios support text-bearing
    events and per-step usage.

In @cmd/jig/serve_test.go:

  • Around line 53-67: Update the listen-address parsing loop in the serve test to
    capture and reuse the remainder returned by strings.CutPrefix instead of
    recomputing it with strings.Split and strings.TrimPrefix. Retain the
    complete-prefix check so partially written output is ignored, then trim the
    captured remainder before assigning it to base.

In @cmd/jig/worker_test.go:

  • Around line 302-315: Update jsonField to unmarshal body as JSON into a map and
    retrieve the requested top-level field, preserving fatal errors for invalid
    JSON, missing fields, or empty values. Remove the now-unused fmt and strings
    imports if no other code uses them.

In @examples/definitions/simple-sdlc.yaml:

  • Around line 150-158: Use YAML anchors to centralize the shared test-detection
    script between the test and retest phases, and the shared commit command between
    commit-build and commit-revision. Define each command once on the appropriate
    phase and reference it with aliases in the duplicate phases, preserving the
    existing phase-specific log messages.

In @internal/controlplane/ingest.go:

  • Around line 87-100: Cap the event payload once in the batch loop before
    calling insertEvent, then pass that capped event to both insertEvent and
    projectEvidence so stored events and projected evidence match. Remove the
    independent payload-capping logic from insertEvent and preserve the existing
    replay and error-handling flow.

In @web/embed_test.go:

  • Around line 70-76: Add HEAD-method coverage to TestNonReadMethodsAreRefused or
    a nearby handler test, invoking Handler().ServeHTTP with http.MethodHead and
    asserting the expected successful status and HEAD semantics, including no
    response body. Keep the existing POST rejection assertion unchanged.

In @web/embed.go:

  • Around line 84-90: Update handler.serveIndex to serve the SPA index through
    http.ServeContent instead of writing h.index directly, preserving the existing
    Content-Type and Cache-Control headers. Add and use a fixed handler modTime
    value, such as build time or zero time, so ServeContent can provide correct HEAD
    handling, Content-Length, and modification metadata.

In @web/src/lanes.ts:

  • Around line 93-95: Update the tool_call handling around openTool so any
    existing open tool span is closed before assigning the new tool span, regardless
    of phase. Preserve the existing phase_end behavior while ensuring interleaved
    phases cannot leave the previous span with endMs === null.

</details>

<details>
<summary>🪄 Autofix</summary>

Fix all unresolved CodeRabbit comments on this PR:

- [ ] <!-- {"checkboxId": "4b0d0e0a-96d7-4f10-b296-3a18ea78f0b9"} --> Push a commit to this branch (recommended)
- [ ] <!-- {"checkboxId": "ff5b1114-7d8c-49e6-8ac1-43f82af23a33"} --> Create a new PR with the fixes

</details>

---

<details>
<summary>ℹ️ Review info</summary>

<details>
<summary>⚙️ Run configuration</summary>

**Configuration used**: defaults

**Review profile**: CHILL

**Plan**: Pro Plus

**Run ID**: `52b30671-5658-4e9d-b27a-2ca08b212ef9`

</details>

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between d55c05a27e149ba7327729f83fd32f18d00948ef and 635f88b19d3c84b4e9c6adbb1e79f52c128e9042.

</details>

<details>
<summary>⛔ Files ignored due to path filters (4)</summary>

* `web/dist/assets/index-CMBiLIMc.js` is excluded by `!**/dist/**`
* `web/dist/assets/index-vg-WGSXA.css` is excluded by `!**/dist/**`
* `web/dist/index.html` is excluded by `!**/dist/**`
* `web/package-lock.json` is excluded by `!**/package-lock.json`

</details>

<details>
<summary>📒 Files selected for processing (52)</summary>

* `.github/workflows/ci.yml`
* `.github/workflows/release.yml`
* `.gitignore`
* `Justfile`
* `README.md`
* `cmd/jig/def.go`
* `cmd/jig/definitions_test.go`
* `cmd/jig/main.go`
* `cmd/jig/run.go`
* `cmd/jig/runtime.go`
* `cmd/jig/serve.go`
* `cmd/jig/serve_test.go`
* `cmd/jig/worker.go`
* `cmd/jig/worker_test.go`
* `docs/quickstart.md`
* `examples/definitions/plan-build-test.yaml`
* `examples/definitions/scout.yaml`
* `examples/definitions/simple-sdlc.yaml`
* `internal/controlplane/embedded.go`
* `internal/controlplane/http.go`
* `internal/controlplane/ingest.go`
* `internal/controlplane/ingest_test.go`
* `internal/protocol/types.go`
* `internal/worker/events.go`
* `internal/worker/events_test.go`
* `scripts/release.sh`
* `web/.gitignore`
* `web/embed.go`
* `web/embed_test.go`
* `web/index.html`
* `web/package.json`
* `web/src/App.tsx`
* `web/src/Queue.tsx`
* `web/src/RunDetail.test.tsx`
* `web/src/RunDetail.tsx`
* `web/src/SwimLane.tsx`
* `web/src/Worktrees.tsx`
* `web/src/api.ts`
* `web/src/format.ts`
* `web/src/lanes.test.ts`
* `web/src/lanes.ts`
* `web/src/main.tsx`
* `web/src/polling.ts`
* `web/src/router.ts`
* `web/src/styles.css`
* `web/src/test/setup.ts`
* `web/src/types.ts`
* `web/src/ui.tsx`
* `web/tsconfig.app.json`
* `web/tsconfig.json`
* `web/tsconfig.node.json`
* `web/vite.config.ts`

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* internal/controlplane/http.go

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Comment thread .github/workflows/ci.yml
Comment on lines +57 to +64
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 24.18.0
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install
run: npm ci

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# First, check the actual workflow file to see the full context and current permissions
cat -n .github/workflows/ci.yml | head -100

Repository: StructuPath/jig

Length of output: 3498


🌐 Web query:

actions/checkout persist-credentials default behavior GitHub Actions

💡 Result:

The persist-credentials input for the actions/checkout GitHub Action defaults to true [1][2]. When set to true, the action configures the authentication token (or SSH key) used to fetch the repository into the local git configuration [3][4]. This allows subsequent steps in your workflow to execute authenticated git commands (e.g., git fetch, git push) without needing to manually re-authenticate [3][5][4]. To maintain security, the action performs a post-job cleanup to remove these credentials [3][4]. In recent versions of the action, credentials are stored in a separate file under $RUNNER_TEMP rather than being written directly into the.git/config file, which improves security [3][6][7]. You can set persist-credentials to false if you wish to opt out of this behavior and prevent the action from configuring these credentials [3][5]. Although there have been community discussions and pull requests proposing a change to the default value to improve security, it remains true by default as of August 2026 [8][2].

Citations:


🌐 Web query:

GITHUB_TOKEN security risk npm ci GitHub Actions persist-credentials

💡 Result:

The primary security risk associated with GITHUB_TOKEN and actions/checkout is that by default, the checkout action persists credentials (the GITHUB_TOKEN) into the runner's local.git/config file [1][2][3]. When this occurs, any subsequent step in the workflow—including third-party actions, build scripts, or dependency installation commands like npm ci—can potentially access and exfiltrate these credentials [4][2][3]. If a compromised or malicious dependency executes code during the installation process (e.g., via npm scripts like preinstall or postinstall), it can read the persisted token from the.git/config file and use it to perform unauthorized actions, such as pushing code, modifying releases, or dispatching workflows, depending on the token's granted permissions [4][5][2]. To mitigate this risk, you should follow these security best practices: 1. Explicitly disable credential persistence: Set persist-credentials to false in your actions/checkout step unless your workflow specifically requires authenticated git operations (like pushing commits back to the repository) [1][4][2][3]. - uses: actions/checkout@v4 with: persist-credentials: false 2. Implement least-privilege permissions: Always define an explicit permissions block at the job or workflow level [3][6][7]. Grant only the minimum necessary permissions required for the task rather than relying on default settings, which are often too broad [5][6][8]. permissions: contents: read # Default to read-only where possible 3. Harden dependency installation: Since commands like npm ci can execute arbitrary scripts from dependencies, consider using flags like --ignore-scripts to prevent them from running during installation, especially if you have not fully audited the dependency tree [7]. By setting persist-credentials to false, you ensure that the GITHUB_TOKEN is not stored in the git configuration, effectively preventing malicious scripts from easily harvesting the token during the CI process [4][2].

Citations:


Set persist-credentials: false and add explicit job permissions.

The ui job executes npm ci, which runs dependency scripts (preinstall, postinstall) that can access persisted Git credentials. The default actions/checkout behavior stores the GITHUB_TOKEN in the runner environment. A compromised or malicious dependency can read this token and perform unauthorized actions (code push, release modification, workflow dispatch) within the scope of the job's permissions. Disable credential persistence and restrict the job to the minimum necessary permissions.

Add permissions: contents: read to the ui job and set persist-credentials: false on the checkout step.

Proposed workflow hardening
   ui:
     name: UI tests and committed bundle
     runs-on: ubuntu-latest
     timeout-minutes: 20
+    permissions:
+      contents: read
     defaults:
       run:
         working-directory: web
     steps:
       - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          persist-credentials: false
🧰 Tools
🪛 zizmor (1.29.0)

[warning] 57-57: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 57 - 64, Harden the ui job by adding
job-level permissions limited to contents: read, and update its actions/checkout
step to set persist-credentials: false. Keep the existing checkout revision and
setup-node/install flow unchanged.

Source: Linters/SAST tools

Comment on lines +22 to +24
permissions:
# Only the release step needs write, and only on a tag.
contents: write

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- workflow ---'
cat -n .github/workflows/release.yml

printf '%s\n' '--- related workflow references ---'
rg -n -C 3 'release\.yml|workflow_dispatch|contents:\s*(read|write)|gh release|upload-artifact|download-artifact|if:' .github/workflows .github 2>/dev/null || true

Repository: StructuPath/jig

Length of output: 8065


Limit contents: write to a tag-only publish job.

workflow_dispatch runs the build job with contents: write, even though the publish step does not run. Keep the build job read-only. Move publication to a separate job that runs only for refs/tags/v*, downloads the artifact, and receives contents: write.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 22 - 24, Separate the build and
publication workflows so the build job uses read-only contents permissions,
while a dedicated publish job runs only when the ref matches refs/tags/v*. Have
the publish job depend on the build, download its artifact, and assign contents:
write only within that job.

Source: Linters/SAST tools

Comment thread cmd/jig/worker.go
Comment on lines +319 to +338
// anyClosed merges cancellation channels: the result closes as soon as any
// input does. It never blocks the caller and never leaks past the first
// close, which is all the engine's one-shot cancellation signal needs.
func anyClosed(channels ...<-chan struct{}) <-chan struct{} {
merged := make(chan struct{})
var once sync.Once
for _, channel := range channels {
if channel == nil {
continue
}
go func(c <-chan struct{}) {
select {
case <-c:
once.Do(func() { close(merged) })
case <-merged:
}
}(channel)
}
return merged
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

anyClosed leaks two goroutines per attempt on the normal path.

Each spawned goroutine blocks until its input channel closes or merged closes. On a successful attempt neither event occurs: the operator does not interrupt, and the control plane does not cancel. merged is therefore never closed, and both goroutines stay parked forever. A long-lived worker accumulates two leaked goroutines per completed attempt, each retaining the merged channel and the prepared cancellation channel.

Give the caller a way to release the merge when the attempt ends.

🐛 Add a release function and call it when the attempt returns
-func anyClosed(channels ...<-chan struct{}) <-chan struct{} {
+// anyClosed merges cancellation channels. The returned stop function releases
+// the watcher goroutines when the attempt ends without cancellation.
+func anyClosed(channels ...<-chan struct{}) (<-chan struct{}, func()) {
 	merged := make(chan struct{})
 	var once sync.Once
+	closeMerged := func() { once.Do(func() { close(merged) }) }
 	for _, channel := range channels {
 		if channel == nil {
 			continue
 		}
 		go func(c <-chan struct{}) {
 			select {
 			case <-c:
-				once.Do(func() { close(merged) })
+				closeMerged()
 			case <-merged:
 			}
 		}(channel)
 	}
-	return merged
+	return merged, closeMerged
 }

At the call site:

+		cancelled, releaseCancellation := anyClosed(signalCtx.Done(), prepared.Cancelled())
+		defer releaseCancellation()
 		return runner.Execute(context.WithoutCancel(ctx), engine.Attempt{
 			Claim:        prepared.Claim,
 			WorktreePath: prepared.WorktreePath,
 			Branch:       prepared.Branch,
 			BaseSHA:      prepared.BaseSHA,
-			Cancelled:    anyClosed(signalCtx.Done(), prepared.Cancelled()),
+			Cancelled:    cancelled,
 			FreshenLease: prepared.FreshenLease,
 		})

Register defer releaseCancellation() before the return statement so it runs after Execute returns.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/jig/worker.go` around lines 319 - 338, Update anyClosed to return both
the merged cancellation channel and a release function that closes merged
through the existing sync.Once, allowing waiting goroutines to exit when the
attempt completes. At the call site that invokes anyClosed for Execute, capture
the release function and register defer releaseCancellation() before returning,
ensuring it runs after Execute returns.

Comment thread internal/worker/events.go
Comment on lines +480 to +506
func (s *TraceStream) sendLoop(ctx context.Context) {
defer close(s.done)
backoff := traceRetryFloor
timer := time.NewTimer(s.flushEvery)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.wake:
case <-timer.C:
}
delivered, err := s.sendOnce(ctx)
switch {
case err != nil:
resetTraceTimer(timer, backoff)
backoff = min(backoff*traceRetryBackoff, traceRetryCeiling)
case delivered > 0:
// More may already be waiting: come straight back.
backoff = traceRetryFloor
resetTraceTimer(timer, 0)
default:
backoff = traceRetryFloor
resetTraceTimer(timer, s.flushEvery)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Backoff is bypassed by nudge, so a failing ingester is retried per event.

Emit calls nudge for every event. The loop then wakes on s.wake and calls sendOnce immediately, so backoff never delays the next attempt while events keep arriving. During an ingest outage of a chatty phase the stream retries at event rate against a control plane that is already failing. Track a retry deadline and reschedule the timer instead of sending before it passes.

🔁 Proposed fix to honour the backoff deadline
 func (s *TraceStream) sendLoop(ctx context.Context) {
 	defer close(s.done)
 	backoff := traceRetryFloor
+	var retryAt time.Time
 	timer := time.NewTimer(s.flushEvery)
 	defer timer.Stop()
 	for {
 		select {
 		case <-ctx.Done():
 			return
 		case <-s.wake:
 		case <-timer.C:
 		}
+		// A wake during backoff must not shorten it: reschedule instead.
+		if wait := time.Until(retryAt); wait > 0 {
+			resetTraceTimer(timer, wait)
+			continue
+		}
 		delivered, err := s.sendOnce(ctx)
 		switch {
 		case err != nil:
+			retryAt = time.Now().Add(backoff)
 			resetTraceTimer(timer, backoff)
 			backoff = min(backoff*traceRetryBackoff, traceRetryCeiling)
 		case delivered > 0:
 			// More may already be waiting: come straight back.
 			backoff = traceRetryFloor
+			retryAt = time.Time{}
 			resetTraceTimer(timer, 0)
 		default:
 			backoff = traceRetryFloor
+			retryAt = time.Time{}
 			resetTraceTimer(timer, s.flushEvery)
 		}
 	}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (s *TraceStream) sendLoop(ctx context.Context) {
defer close(s.done)
backoff := traceRetryFloor
timer := time.NewTimer(s.flushEvery)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.wake:
case <-timer.C:
}
delivered, err := s.sendOnce(ctx)
switch {
case err != nil:
resetTraceTimer(timer, backoff)
backoff = min(backoff*traceRetryBackoff, traceRetryCeiling)
case delivered > 0:
// More may already be waiting: come straight back.
backoff = traceRetryFloor
resetTraceTimer(timer, 0)
default:
backoff = traceRetryFloor
resetTraceTimer(timer, s.flushEvery)
}
}
}
func (s *TraceStream) sendLoop(ctx context.Context) {
defer close(s.done)
backoff := traceRetryFloor
var retryAt time.Time
timer := time.NewTimer(s.flushEvery)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.wake:
case <-timer.C:
}
// A wake during backoff must not shorten it: reschedule instead.
if wait := time.Until(retryAt); wait > 0 {
resetTraceTimer(timer, wait)
continue
}
delivered, err := s.sendOnce(ctx)
switch {
case err != nil:
retryAt = time.Now().Add(backoff)
resetTraceTimer(timer, backoff)
backoff = min(backoff*traceRetryBackoff, traceRetryCeiling)
case delivered > 0:
// More may already be waiting: come straight back.
backoff = traceRetryFloor
retryAt = time.Time{}
resetTraceTimer(timer, 0)
default:
backoff = traceRetryFloor
retryAt = time.Time{}
resetTraceTimer(timer, s.flushEvery)
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/worker/events.go` around lines 480 - 506, Update
TraceStream.sendLoop to track a retry deadline whenever sendOnce fails, and
ensure wake events do not trigger another send until that deadline has passed.
When nudge wakes the loop during backoff, reschedule or wait on the timer rather
than calling sendOnce; retain immediate wake-driven sends when no retry backoff
is active and preserve the existing exponential backoff limits.

Comment thread internal/worker/events.go
Comment on lines +544 to +563
_, err := s.ingest.IngestEvents(ctx, s.attemptID, protocol.EventBatch{
LeaseToken: s.leaseToken,
Events: batch,
})

s.mutex.Lock()
defer s.mutex.Unlock()
s.sending = false
if err != nil {
if ctx.Err() == nil {
s.logger.Warn("trace_ingest_failed", "attempt_id", s.attemptID,
"events", count, "error", err)
}
// Back at the head, where they were: the batch is the oldest run, so
// restoring it in front of whatever arrived meanwhile keeps the buffer
// in seq order.
s.pending = append(batch, s.pending...)
return 0, err
}
return count, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect how the worker HTTP client surfaces control-plane service errors, to see whether permanent rejections are distinguishable.
set -euo pipefail

fd -t f -e go . internal/worker | xargs -r rg -n -C 6 'func \(c \*client\) call|ServiceError|StatusCode|http\.Status'
rg -n -C 4 'func invalid\(|func conflict\(|func unavailable\(' internal/controlplane

Repository: StructuPath/jig

Length of output: 7736


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- client error decoding and IngestEvents call ---'
ast-grep outline internal/worker/client.go
rg -n -C 12 'decodeAPIError|IngestEvents|APIError|sendOnce|dropOldest|gap' internal/worker internal/controlplane

printf '%s\n' '--- relevant source sections ---'
sed -n '1,90p' internal/worker/client.go
sed -n '124,180p' internal/worker/client.go
sed -n '500,575p' internal/worker/events.go
sed -n '1,110p' internal/controlplane/ingest.go

Repository: StructuPath/jig

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- ingest validation and lease ownership ---'
sed -n '53,125p' internal/controlplane/ingest.go
rg -n -C 10 'lease_not_owner|lease_superseded|validateLeaseToken|Owner|token|LeaseToken' internal/controlplane internal/worker/claiming.go

printf '%s\n' '--- sendOnce, Flush, and close behavior ---'
sed -n '521,610p' internal/worker/events.go
sed -n '610,655p' internal/worker/events.go

printf '%s\n' '--- error status mapping ---'
rg -n -C 8 'func writeError|ServiceError|Status' internal/controlplane

Repository: StructuPath/jig

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '53,115p' internal/controlplane/ingest.go
sed -n '521,605p' internal/worker/events.go

printf '%s\n' '--- lease checks used by ingestion ---'
rg -n -C 5 'func \(s \*Store\) IngestEvents|verify.*Lease|lease_not_owner|isLeasedState' internal/controlplane/ingest.go internal/controlplane/store.go

printf '%s\n' '--- error response writer ---'
rg -n -C 12 'func writeError|func \(a \*API\).*write|ServiceError' internal/controlplane --glob '*.go' | head -n 180

Repository: StructuPath/jig

Length of output: 25701


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- lease loss mutations ---'
sed -n '680,730p' internal/controlplane/embedded.go
rg -n -C 12 'UPDATE attempts SET state = .lost|AttemptLost|lose.*Attempt|lease_digest = NULL' internal/controlplane --glob '*.go' | head -n 220

printf '%s\n' '--- event creation and validation boundaries ---'
rg -n -C 8 'protocol.Event\{|EventError|invalid_event_type|Seq:' internal/worker/events.go internal/worker --glob '*.go' | head -n 260

Repository: StructuPath/jig

Length of output: 23906


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline internal/worker/events.go
sed -n '250,395p' internal/worker/events.go
rg -n -C 5 'TraceStream|NewTrace|Emit|eventType|EventType|NextSeq|seq' internal/worker --glob '*.go' | head -n 260

Repository: StructuPath/jig

Length of output: 25565


Stop replaying permanent ingest rejections.

TraceStream.Emit accepts events without validating Seq or Type. sendOnce restores every rejected batch at the head. A batch with invalid_event_seq or invalid_event_type can therefore block Flush and later events until Close times out. Classify these errors, and lease_not_owner for the current token, as permanent. Remove the rejected batch and record a valid gap marker with the reason. Retry transport and storage failures only. batch_too_large cannot occur here because sendOnce caps each batch, and lease loss alone does not produce lease_not_owner because ingestion accepts the stale digest for a terminal lost attempt.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/worker/events.go` around lines 544 - 563, Update sendOnce’s
ingest-error handling to classify invalid_event_seq, invalid_event_type, and
lease_not_owner for the current token as permanent rejections: remove the
rejected batch, record a valid gap marker with the appropriate reason, and do
not restore it to s.pending. Continue prepending the batch and retrying for
transport and storage failures, while preserving the existing warning and return
behavior.

Comment thread web/src/polling.ts
Comment on lines +141 to +148
if (arrived.length === 0) {
idleRef.current += 1;
// A finished attempt whose tail has stopped arriving needs no more
// polling; a running one keeps its interval.
if (terminal && idleRef.current >= 2 && timer) {
clearInterval(timer);
timer = null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Inspect whether attempt completion is ordered after trace ingestion and
# whether the HTTP event response exposes a completion marker.
ast-grep outline internal/controlplane/ingest.go --items all
ast-grep outline internal/controlplane/http.go --items all
rg -n -C 5 'CompleteAttempt|AttemptEventPage|next_cursor|trace.*complete|finalized|ingest' \
  internal/controlplane internal/worker

Repository: StructuPath/jig

Length of output: 50371


🏁 Script executed:

# Check protocol definitions for event/attempt structures
fd -e go -path '*/protocol/*' | head -20

# Search for completion-related timing or windows
rg -n 'CompleteAttempt|LeaseDuration|eventWindow|flushInterval|inFlight' \
  --type go internal/worker internal/protocol -A 3 -B 1 | head -100

# Check worker event buffer and flush behavior on completion
rg -n -A 10 'func.*complete|events.*flush|drainTrace' internal/worker/

Repository: StructuPath/jig

Length of output: 5616


🏁 Script executed:

# Check protocol definitions for EventPage and Attempt structures
cat internal/protocol/types.go | grep -A 20 'type EventPage\|type Attempt struct'

# Search for event buffer draining on completion
rg -n 'func.*drain|stopDrain|close.*trace|eventBuffer|flushEvents' \
  internal/worker/ -A 5

# Check if there's a window/timeout for events after completion
rg -n 'after.*complet|terminal.*event|drainTrace|sendBatch|flush' \
  internal/worker/events.go -A 3 -B 3 | head -80

Repository: StructuPath/jig

Length of output: 2755


🏁 Script executed:

# Check the web polling logic more carefully
cat web/src/polling.ts | head -160 | tail -50

# Also check if there's any mechanism that stops accepting events after completion
rg -n 'AttemptTerminal|AttemptFinal|State.*terminal|completeAttempt' \
  web/src/ -A 2 -B 2

# Check attempt states to see if terminal prevents event ingestion
rg -n 'AttemptAccepted|AttemptFailed|terminal' internal/protocol/ -B 2 -A 2 | head -60

Repository: StructuPath/jig

Length of output: 4284


🏁 Script executed:

# Verify the IngestEvents route accepts events even after terminal state
rg -n -B 5 -A 15 'func.*IngestEvents' internal/controlplane/ingest.go | head -40

# Check if there's any lease validation that would prevent events after completion
rg -n 'token.*valid|lease.*check|terminal.*event' internal/controlplane/ -A 3 | head -50

# Verify event flush behavior on worker completion (grace period)
rg -n -B 3 -A 10 'Close|Flush.*context|grace|drainCtx' internal/worker/events.go

Repository: StructuPath/jig

Length of output: 6102


Keep polling until the trace is known complete, or expose an explicit completion signal.

Line 145 stops polling after two empty pages. The control plane accepts events indefinitely after terminal completion (see ingest.go lines 48–52); a worker's 5-second grace window (traceCloseGrace) for event flush can deliver events after the UI stops polling. The EventPage protocol has no completion marker. Delayed events then arrive after the UI stops polling and are never rendered, silently omitting trace tail, gates, or errors.

Either expose an explicit finalized signal from the control plane in the event API response, or extend polling until the control plane guarantees no further ingestion will occur.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/polling.ts` around lines 141 - 148, The polling stop condition in the
arrived-empty branch must not terminate after two empty pages based only on
terminal status. Update the polling flow around arrived, terminal, and idleRef
to continue until the event API exposes an explicit finalized/completion signal,
or until the control plane guarantees ingestion has ended; use that signal as
the sole condition for clearing timer.

Comment thread web/tsconfig.json
Comment on lines +3 to +6
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Confirm every project reference targets a composite project.
jq '.references' web/tsconfig.json
jq '.compilerOptions.composite // false' web/tsconfig.app.json
jq '.compilerOptions.composite // false' web/tsconfig.node.json

Repository: StructuPath/jig

Length of output: 255


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- web TypeScript configuration files ---'
for f in web/tsconfig.json web/tsconfig.app.json web/tsconfig.node.json; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    cat -n "$f"
  fi
done

printf '%s\n' '--- TypeScript project-reference and build commands ---'
rg -n --glob '!*node_modules*' --glob '!dist/**' \
  '"references"|"composite"|tsc(\\.exe)?[[:space:]]+-b|vue-tsc|typescript' \
  web package.json 2>/dev/null || true

printf '%s\n' '--- Resolved composite values for referenced projects ---'
python3 - <<'PY'
import json
from pathlib import Path

def load(path):
    return json.loads(Path(path).read_text())

def resolve_compiler_options(path, seen=None):
    path = Path(path)
    seen = set() if seen is None else seen
    if path in seen:
        raise RuntimeError(f"extends cycle at {path}")
    seen.add(path)
    data = load(path)
    opts = {}
    ext = data.get("extends")
    if ext:
        base = Path(ext)
        if not base.is_absolute():
            base = path.parent / base
        if base.suffix == "":
            base = base.with_suffix(".json")
        opts.update(resolve_compiler_options(base, seen))
    opts.update(data.get("compilerOptions", {}))
    return opts

root = load("web/tsconfig.json")
for ref in root.get("references", []):
    path = Path("web") / ref["path"]
    if path.suffix == "":
        path = path.with_suffix(".json")
    opts = resolve_compiler_options(path)
    print(f"{path}: composite={opts.get('composite', False)!r}")
PY

Repository: StructuPath/jig

Length of output: 2466


🌐 Web query:

TypeScript project references composite noEmit referenced project tsc -b TS6310

💡 Result:

The TypeScript error TS6310, "Referenced project may not disable emit," occurs when a project references another project that has "noEmit": true enabled in its configuration while also being marked as "composite": true [1][2][3]. TypeScript project references require referenced projects to be "composite" to ensure that the compiler can reliably determine where the outputs (such as.d.ts declaration files) of those dependencies are located [4][5]. Because composite projects are designed to be built incrementally, they must be capable of producing output files [4][5]. Consequently, TypeScript explicitly forbids the combination of "composite": true and "noEmit": true [6][7]. To resolve this error, you generally have a few options depending on your specific requirements: 1. Disable "noEmit" (Recommended for Composite Projects): If the referenced project must be composite, you should set "noEmit": false in its tsconfig.json [8][2]. If you only want to generate declaration files without emitting full JavaScript code, use "emitDeclarationOnly": true instead of "noEmit": true [1][7][9]. 2. Remove "composite" (If the Project is Not Used as a Dependency): If the project does not actually need to be referenced by other projects as a composite module, you can remove "composite": true [10][11]. However, if other projects still need to reference it, this will likely cause further errors, as they require the composite flag to manage build outputs correctly [4][11]. 3. Adjust Configuration for Type Checking Only: If you are trying to run a type-check command on a composite project and encounter this error, ensure that your build configuration does not propagate "noEmit": true to your library packages [12]. Some developers use separate configuration files (e.g., tsconfig.json for building and a separate file for type-checking) to manage these conflicting requirements [8][2]. In summary, the TS6310 error is a consistency check to ensure that referenced composite projects are actually capable of emitting the files that parent projects rely on for correct compilation [4][10]. Enabling "noEmit" breaks this contract, triggering the error [1][12].

Citations:


Configure referenced projects for tsc -b.

Neither referenced project is composite, so tsc -b reports TS6306. Adding composite: true alone is insufficient because both projects also set noEmit: true, which causes TS6310. Use build configs that enable composite output, and retain separate no-emit configs for type checking.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/tsconfig.json` around lines 3 - 6, Update the referenced projects in
tsconfig.app.json and tsconfig.node.json to use build-compatible configurations
with composite enabled and emit output enabled, then retain separate no-emit
configurations for regular type checking. Ensure the references in tsconfig.json
target the build configs so tsc -b no longer reports TS6306 or TS6310.

Steel-tech and others added 3 commits August 6, 2026 19:28
ListenConfig.Listen consults the context only where it resolves a name,
so an address needing no resolution binds happily on a cancelled
context — and which addresses need resolution differs by platform and
resolver. Check the context explicitly before the bind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scan's repository set comes from attempt manifests, which outlive the
repository they name. A remote that is deleted or renamed therefore stays
in the set forever, and every worker start reported the same permanently
incomplete scan — noise that buries the strays an operator can still act
on.

Classify a gone remote and skip it, logged at info rather than counted as
a scan failure. The classifier stays narrow on purpose: timeouts, DNS
failures, and refused credential prompts mean "ask again later" and must
keep their place in the scan errors, because silently skipping a live
repository is how a real stray branch goes unreported. A vanished file://
remote arrives typed as ENOENT and is matched that way; a missing git
binary is not, since exec reports it as ErrNotFound.

The one ambiguity left is a private repository whose credentials no
longer reach it — GitHub answers not-found rather than forbidden so as
not to leak existence — which is why the skip is logged, not swallowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
drainStream waited only on the stdout capture before closing both read
ends, but closing a read end is what ends its capture. When stdout
finished first the stderr copy was cut off with the CLI's diagnostic
still sitting unread in the pipe, and a nonzero exit reached the trace as
"codex exited: exit status 3:" with no cause after the colon — the one
thing that path exists to carry.

It is timing-dependent, which is why it passed locally and failed on
loaded CI runners: the capture goroutine is normally already blocked in
read() and wakes with the data long before drainStream runs. This is what
has been failing CI on both platforms since 635f88b.

Wait on both captures under one shared deadline, so the grace still
bounds the whole drain rather than each stream separately. Both adapters
carried the identical bug; both are fixed, and both get a regression test
that gates the capture's first read to make the losing order
deterministic (each fails on the old code with an empty tail).

Co-Authored-By: Claude Fable 5 <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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/runtime/codex/adapter_test.go`:
- Around line 799-802: Replace the fixed 50 ms sleep in the test with an
observable synchronization hook or close observer tied to drainStream’s
stderrReader closure. Wait for that signal before calling close(gate), ensuring
the test deterministically verifies the closure ordering and cannot pass due to
timing variance.
🪄 Autofix

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b5467fe6-983b-40a8-b1bf-f55705915626

📥 Commits

Reviewing files that changed from the base of the PR and between 635f88b and b7fd9db.

📒 Files selected for processing (7)
  • internal/controlplane/server.go
  • internal/runtime/claudecode/adapter.go
  • internal/runtime/claudecode/adapter_test.go
  • internal/runtime/codex/adapter.go
  • internal/runtime/codex/adapter_test.go
  • internal/worker/publish.go
  • internal/worker/publish_test.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • internal/runtime/claudecode/adapter.go
  • internal/runtime/claudecode/adapter_test.go
  • internal/worker/publish.go
  • internal/worker/publish_test.go
  • internal/runtime/codex/adapter.go

Comment on lines +799 to +802
// Long enough that a drainStream which closes on stdout alone has already
// done so; the release then finds a dead descriptor instead of the tail.
time.Sleep(50 * time.Millisecond)
close(gate)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace the fixed delay with an observable synchronization point.

The 50 ms delay does not prove that drainStream closed stderrReader before the test releases gate. On a slow runner, the old faulty implementation can start later, capture "codex fell over", and pass this regression test.

Add a test hook or close observer. Wait for that observer before releasing gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/runtime/codex/adapter_test.go` around lines 799 - 802, Replace the
fixed 50 ms sleep in the test with an observable synchronization hook or close
observer tied to drainStream’s stderrReader closure. Wait for that signal before
calling close(gate), ensuring the test deterministically verifies the closure
ordering and cannot pass due to timing variance.

@Steel-tech
Steel-tech merged commit 5a55bc7 into main Aug 7, 2026
5 checks passed
@Steel-tech
Steel-tech deleted the feat/jig-m2 branch August 7, 2026 03:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant