Fabrik Stage Lifecycle
This document describes what the Fabrik engine does before, during, and after each stage invocation, including comment processing. It is intended as a reference for writing and refining stage skills.
Pipeline Overview
Backlog → Specify → Research → Plan → Implement → Review → Validate → Done
| Stage | Order | Read-Only | PostToPR | CreateDraftPR | MarkPRReady | MaxTurns |
|---|---|---|---|---|---|---|
| Specify | 0 | Yes | No | No | No | 50 |
| Research | 1 | Yes | No | No | No | 50 |
| Plan | 2 | Yes | No | No | No | 50 |
| Implement | 3 | No | Yes | Yes | Yes | 50 |
| Review | 4 | No | Yes | No | Yes | 50 |
| Validate | 5 | No | Yes | No | No | 50 |
| Done | 99 | N/A | No | No | No | N/A |
Phase 1: Item Qualification (Poll Loop)
Two-Phase Filtering
-
Shallow pre-filter (
itemMayNeedWork): Uses board data only (no comments). Checks stage exists,updatedAtchanged, not paused (unless awaiting-input), not locked by another user. Does NOT filter on completion labels — completed items may have new comments. -
Deep fetch (
FetchItemDetails): Only for items that pass the shallow filter. Fetches comments and linked PRupdatedAtfrom GitHub GraphQL (~2 points each). -
Full check (
itemNeedsWork): With comments loaded. New comments trigger processing even on completed stages. PRs only support comment processing. Awaiting-input items only pass if new comments exist (the resume trigger).
Rate Limit Cost
Shallow query: ~16 points/poll. Deep fetch: ~2 points per active item. Typical poll: ~20-30 points, well within the 5,000/hour GraphQL limit.
Phase 2: Pre-Stage Setup
Lock & Label Acquisition
fabrik:locked:<user>— prevents other instances from picking up the issue (<user>is the configured user in PAT mode and an App-derived<slug>-<hash>identity under GitHub App auth — seedocs/state-machine.md§7.4)stage:<name>:in_progress— signals active work on the board- Both held through cooldown retries (not released until completion or permanent failure)
Worktree Setup
Each issue gets .fabrik/worktrees/issue-<N> on branch fabrik/issue-<N>:
- First run: Created from
origin/main, rebased onto latest - Retry (
attempted=true): Returned as-is — no rebase, preserves Claude’s context - Rebase conflicts: Silently aborted (
git rebase --abort) — Claude works from current base
Dependency Install Responsibility Split
The engine’s updateWorktreeFromMain rebases the worktree onto main but does not run any dependency install. The Review and Validate skills are responsible for prompting Claude to run the project’s install step after the rebase step completes. The project’s CLAUDE.md is the authoritative source for the install command. This split keeps Fabrik package-manager-agnostic. See .fabrik/plugin/skills/validate/SKILL.md and .fabrik/plugin/skills/review/SKILL.md for the skill-side instruction.
Read-Only Stage Stashing
For read_only: true stages (Specify, Research, Plan): dirty state is auto-stashed before Claude runs and restored after. Claude sees a clean worktree.
Engine-written spec (persist_spec, ADR 2034). Specify is the one read-only stage with a write: when persist_spec: true, the engine itself projects the stage’s FABRIK_ISSUE_UPDATE body to specs/<issue>-<slug>/spec.md and commits that single file (persistSpec, engine/spec_persist.go). The write happens after the issue-body update — after the stash pop on the stage path, and in publishCommentOutput on the comment-review path — so a committed file is never stashed or popped, and the pathspec-scoped commit (git commit -- <path>) never captures other dirty state. Claude’s permissions are unchanged (no Write, no git), so there is no worktree-boundary rewrite or audit to consider. commitWIP stays skipped for read-only stages. The stage path relies on the existing post-run branch push; the comment-review path, which never pushes by itself, pushes (pushBranchUnlessQueued) only when a spec commit was actually created. See USER_GUIDE.
Context Files
Before each Claude invocation, the engine writes context documents to .fabrik-context/ in the worktree. These files are excluded from git by two mechanisms: a .gitignore file written inside .fabrik-context/ that excludes all files in the directory, and a pre-rebase step that runs git rm -rf --cached .fabrik-context/ to remove any accidentally tracked context files before rebasing.
| File | Content |
|---|---|
.fabrik-context/issue.md |
The issue body (spec) — always written |
.fabrik-context/stage-Specify.md |
Specify stage comment output |
.fabrik-context/stage-Research.md |
Research stage comment output |
.fabrik-context/stage-Plan.md |
Plan stage comment output |
.fabrik-context/stage-Implement.md |
Implement stage comment output |
.fabrik-context/stage-Review.md |
Review stage comment output |
.fabrik-context/pr-description.md |
Linked PR description (for post_to_pr stages) |
.fabrik-context/codebase-changes.md |
Files changed on origin/<baseBranch> since the prior stage ran (omitted on first stage or when no changes) |
.fabrik-context/ci-status.md |
PR number, live PR head SHA, CI verdict for that head (green, red, pending, none, unknown), ci_gated (the stage’s wait_for_ci), ci_gate_ahead (a later stage has wait_for_ci), a timestamp and, for a red verdict, a ## Failing checks section; written for stage and comment invocations whenever the item has a linked open PR, removed otherwise |
ci-status.md and what to run locally (#1997, ADR-1997; #2130, ADR-2130; #2116, ADR-2116): the verdict comes from live, uncached reads (FindPRForIssue, FetchPRDetails, FetchCheckRuns) fed through the landing paths’ classifyLandingCI (and so ciSuiteHold) unmodified. Zero check runs is always none — never the ADR-933 mergeable_state green — a read error is unknown, and a classifier green is demoted to pending unless every latest check run is completed with success or neutral (skipped is not passing — a path-filtered test job reports it without running the suite). A CI gate stands behind a push when ci_gated (the current stage’s wait_for_ci) or ci_gate_ahead (any later managed stage has it, computed from stage config) is true. The test-running skills (validate, review, implement and their -comment variants) read the file to decide what to run locally:
ci-status.mdabsent, or no gate stands behind the push (ci_gatedandci_gate_aheadbothfalse): the full-suite step, unchanged, reported asPASSED. For aredverdict the reproduction is still targeted first; the full suite comes only after the fix, as the stand-in for the missing gate.- A gate stands behind the push,
verdictgreen,git rev-parse HEADequal tohead_shaandgit status --porcelainempty: skip the test invocation, reported asSKIPPED — CI green on <sha>. - A gate stands behind the push,
verdictred: read the## Failing checkssection (thengh pr checks, or the run log, if it is thin) and reproduce only what it names — never the full suite as a first step. A test check is narrowed to the failing tests or the smallest unit containing them; a lint, build or format check runs its own command. After a fix the skill re-runs those tests plus the tests covering the changed code and leaves the full-suite verdict to CI. - A gate stands behind the push,
verdictpending,unknownornone, or HEAD differs fromhead_sha(this run pushed or rebased, or a previous invocation pushed the head), or a dirty tree: the build plus the tests covering the changed files, once.
Both targeted cases are reported as TARGETED — <packages>; full suite left to CI on <sha>. Where a gate stands behind the push the full suite is never run locally; CI and the wait_for_ci gate on the pushed head decide it.
Red verdict — ## Failing checks (#2116): for a red head the engine adds the latest run of each check name that is failure, timed_out or action_required (the gh.ClassifyCheckRuns predicate itself, so the section and the verdict cannot disagree and a re-run that passed supersedes its earlier failure), with name, conclusion, details URL and the CI-fix prompt’s rendering of output.title, output.summary/output.text and annotations (checkRunFailureDetail, ADR-1991’s bounds). At most 10 failing checks are listed (“N more omitted” beyond that) and annotations — one REST call each, checks:read only — are read for the first 5; a failed or unavailable annotation read is logged and the section keeps the check-level fields. Test names are not parsed out of annotations or job logs (no language-specific parsing, no actions permission). A red verdict with no failing run (a required status failed) says so. Other verdicts add no section and make no extra reads.
Which pushes are re-gated (#2116): a stage dispatch, comment review or review reinvoke in a wait_for_ci stage is re-gated by checkCIGate on the new head; the CI-fix and rebase reinvokes are dispatched from the gate of such a stage; merge-train auto-repair is an ordinary Validate dispatch followed by the train’s own CI. In a stage that does not wait for CI the push is not gated in-stage, but a later gated stage re-gates it when ci_gate_ahead is true. The -comment variants follow the same table: a comment-pushed change is verified with the targeted run where a gate stands behind it.
Load and hard rules (#2116, #2130): every local test command carries a framework timeout of 5m or less per test process (never raised to fit a broader run — narrow the run instead), runs synchronously in the foreground and is never backgrounded and polled, and a worker never waits on a suite in a sleep loop past that timeout. Before any local run broader than the failing tests the skills check uptime against the CPU count. A run that times out or fails is answered by checking host load and rerunning only the failing test, once; a failure that does not reproduce in isolation and passes in CI on the same head is reported as load-induced / host load — not a fix trigger and not a failure of the change. The shipped skills name no single toolchain’s commands.
The file is a snapshot taken at invocation start: a mid-run push moves HEAD and makes it inert.
Stage invocation: Writes only stages prior to the current stage. Implement sees Specify, Research, Plan but not its own output.
Comment processing: Writes prior stages and the current stage. Claude needs to see the current stage output to build upon it.
Session Resume
Session file: .fabrik/sessions/issue-<N>/<stageName>.session, relative to the process working directory — or .fabrik/sessions/<owner>-<repo>/issue-<N>/<stageName>.session on multi-repo projects (engine/claude.go:227-242). On retry, loaded via --resume to restore conversation context.
The session ID is captured even when the invocation ends by hitting max_turns — parseClaudeJSON accepts a response whose result is empty as long as session_id is present (engine/claude.go:1163-1166), because a turn-cap kill produces exactly that shape. This is deliberate: it is what allows a turn-capped stage to be resumed rather than restarted.
Retry after a turn-cap kill
When a stage exceeds max_turns, the Claude CLI self-terminates with a non-zero exit, so completed = false and err != nil. The progress-based extension loop requires err == nil, so this falls through to the cooldown/retry path. A later poll re-invokes the same stage with resume = true (engine/item.go:682 — resume := !lastAttempt.IsZero()).
As of #1178, the CLI’s own result classification (subtype: "error_max_turns") is captured alongside this exit and threaded into InvocationRecorded.TurnLimited, so history.json/the TUI can render this case as incomplete-and-resumable rather than as a generic failure. This changes only the classification/rendering surfaced from the invocation — the retry/cooldown mechanics described above are unchanged.
The retry continues the same Claude session. It is not a fresh agent inheriting a dirty worktree — it retains the killed run’s conversation context, including the output of every command that run executed before the cap.
Two consequences follow, and both routinely look like defects to someone reading only the posted comment:
- A retry may legitimately report on work performed before the kill. If the predecessor ran a test suite at turn 12 and was killed at turn 51, the successor can summarise those results without re-running anything. The work was genuinely performed, by the same session — this is continuation, not fabrication.
- A retry completing in very few turns is the expected shape, not a warning sign. The work is already done; the continuation only needs enough turns to write it up. A two-turn completion following a capped predecessor is a resume working correctly.
Conversely, a successor that announces the situation (e.g. “found the implementation already complete from a prior interrupted session”) is accurately describing its own resumed state.
Distinguishing a legitimate few-turn resume from a stall. The prior paragraph describes a healthy capped-then-short-retry shape — a resume that only needs a few turns to write up work already done. A stalled retry looks superficially similar (short, incomplete) but never completes and, critically, is followed by a further decline rather than a completion. §7.10 of docs/state-machine.md covers the engine-side detection of that pattern (a turn-capped attempt followed by a strictly-declining, still-incomplete one) and the one-shot corrective hint it arms for the next invocation (#1146). That detection is purely additive to the resume mechanics described here — it does not change resume, session-file handling, or output attribution, which is what keeps it clear of the successor-reports-phantom-work problem #1081 fixed.
Failure modes — the session ID is not always honoured, but the fallback is now logged. Before building args, InvokeClaude and InvokeClaudeForComments each call resolveResumeSessionID(issue.Number, stage.Name, sessFilePath, resume) (engine/claude.go), which reads and classifies the session file via classifySessionFile — trimming before checking for emptiness, so whitespace-only content is never mistaken for a usable ID. buildClaudeArgs itself is a pure formatter: it receives the already-resolved resumeSessionID string and appends --resume <id> only when it’s non-empty.
classifySessionFile produces one of four outcomes:
| Outcome | Condition | Behaviour |
|---|---|---|
resumeFound |
Trimmed content is non-empty | --resume <id> appended; no log output (the healthy path stays silent) |
resumeAbsent |
Session file does not exist (os.IsNotExist) |
--resume omitted → fresh session; warning logged naming the issue, stage, and path |
resumeUnreadable |
Session file exists but os.ReadFile errors for another reason (e.g. it’s a directory) |
--resume omitted → fresh session; warning logged, including the underlying error |
resumeBlank |
File reads successfully but its trimmed content is empty (zero bytes or whitespace-only) | --resume omitted → fresh session; warning logged |
Unlike the earlier untrimmed-length-check bug, a whitespace-only file can no longer produce --resume "" — it is folded into resumeBlank and treated identically to a zero-byte file. Both InvokeClaude and InvokeClaudeForComments go through the same resolveResumeSessionID call, so neither call site can silently regress independently.
engine.ReadSessionID (engine/claude.go, used by cmd/resume.go) now shares the same classifySessionFile classifier internally (discarding the status), so it and the invocation path agree on what counts as a usable session ID.
Dead-session detection and self-heal
A session file can point at a conversation Claude Code itself has since reaped (the CLI’s own cleanupPeriodDays retention, default 30 days) — distinct from #1117’s load failures above: the file is present and well-formed, but the ID no longer resolves to anything. --resume <dead-id> then fails structurally, and the CLI echoes the requested session_id back unchanged in its error response, so a naive unconditional resave would rewrite the exact same dead pointer every retry, making the failure self-renewing rather than merely stale.
interpretClaudeResult (engine/claude.go) detects this from the parsed response, requiring both signals together:
resp.Subtype == "error_during_execution"(structural)- an entry in
resp.Errorscontaining"No conversation found with session ID"(substring)
On detection, it removes the session file immediately and skips the resave of resp.SessionID for that invocation — the one place saveSessionIDDirect would otherwise persist the dead ID right back to disk. The next invocation reads no session file, buildClaudeArgs omits --resume, and a fresh session starts and is saved normally. No retry counter or label is involved: because the specific dead ID is never rewritten, the identical failure cannot recur for that pointer, which bounds the loop without any additional machinery.
This applies uniformly to both the stage-invocation and comment-processing call paths, since both flow through interpretClaudeResult.
Consecutive resume-failure abandonment (#1414)
The dead-session self-heal above only fires for the one specific error signature it structurally matches (error_during_execution + the “No conversation found” substring). Any other resume failure — most notably, a session that has simply grown too large for Claude Code to reload — falls through to a generic error and, before #1414, re-saved the identical session ID for the next attempt: every retry re-triggered the same condition and appended to the transcript that was already the cause. The reporter’s workaround (moving the 36-byte .session pointer aside so the next run started cold) worked precisely because resolveResumeSessionID already treats an absent file as a fresh session; this mechanism automates that workaround.
interpretClaudeResult maintains a second, durable sidecar file next to the session pointer — <stageName>.session.resumefails, a plain-text integer, read/written with the same idiom saveSessionIDDirect uses for the session ID itself — counting consecutive failures where the invocation itself was a resume attempt (resumeSessionID != "") and none of the more specific classifiers above claimed the failure. The counter is deliberately file-based rather than living in itemstate.Store (confirmed entirely in-memory, resetting on every engine restart): this is the one counter that must survive a restart, or a restart would silently re-enable the indefinite-retry loop this mechanism exists to close.
The counter resets to 0 on any evidence the session is healthy — a clean exit, FABRIK_STAGE_COMPLETE found despite a trailing error, or a turn-cap exit (real turns and cost consumed is the strongest possible evidence against a poisoned transcript). It is left untouched by a usage-limit or api_error exit, since the stage never ran and those outcomes say nothing about session health either way. A cold start’s own failure (resumeSessionID == "") also resets it, clearing any stale count left over from a since-abandoned session lineage.
Once the count reaches MaxResumeFailures (--max-resume-failures / FABRIK_MAX_RESUME_FAILURES, default 2), the session file is removed — the same os.Remove call the dead-session path above already uses — and the sidecar resets to 0. No new “force cold start” signal is threaded anywhere: the removed file is itself the cold-start signal, since resolveResumeSessionID already treats an absent file identically for both InvokeClaude and InvokeClaudeForComments. Because both invocation paths (and, transitively, merge_train.go’s conflict-resolution invocation) compute the identical session file path for a given (issue, stage), the counter and the abandonment are shared across all three — a session poisoned by a long stage run is abandoned for the next comment-review invocation too, and vice versa, and an alternating stage/comment-review failure sequence still reaches the threshold.
Unlike the dead-session self-heal (a structural fault with no ambiguity), a resume failure caught by this mechanism is exempted from max_retries accounting entirely — StageAttempted is still recorded (so the normal dispatch cooldown applies), but StageRetryIncremented never fires for it, for every consecutive resume failure up to the threshold, not only the one that triggers abandonment. This mirrors the fabrik:claude-limit precedent and exists so the guaranteed cold-start attempt cannot be starved by the very failures it is counting. Nothing is posted to the issue and no label is applied — only a log line (session id, stage, consecutive-failure count, threshold, last error) — since the mechanism is self-healing by construction; if the cold-started attempt also fails, that failure carries no --resume session ID and is therefore a genuine, unexempted failure that flows into the normal max_retries → fabrik:paused path. See docs/state-machine.md §2.17 and ADR-1414 for the full per-outcome classification table and design rationale.
Model Override
Labels matching model:<name> on the issue override the stage’s configured model.
Worktree Boundary Enforcement
For non-read-only, non-unrestricted stages (Implement, Review, Validate, and any custom stage with read_only: false), the engine replaces the bare Edit and Write entries in --allowedTools with path-scoped variants:
--allowedTools Edit(<workDir>/**)
--allowedTools Write(<workDir>/**)
This proactively restricts Claude Code’s file-editing tools to the assigned worktree directory. If Claude attempts to edit or write a file outside the worktree, it receives an error from Claude Code and the stage continues running (the attempt is blocked, not the whole stage).
Scope: Enforced for all stages where read_only: false. Skipped for read-only stages (Specify, Research, Plan by default) — they do not write files and receive bare Edit/Write entries (or none, if allowed_tools: is overridden in stage YAML). Specify’s persist_spec write is made by the engine, not by Claude, so it is outside this layer by construction.
Bypass: When fabrik:unrestricted is present on the issue, --dangerously-skip-permissions is passed instead of --allowedTools, bypassing this restriction entirely (consistent with the existing semantics of that label).
Known gap: Bash shell commands that write files (e.g., cat > /other/path) cannot be path-restricted at the tool-permission layer — Bash(cmd:*) restricts command name, not argument paths. The post-run boundary audit (Phase 3) covers the primary remaining attack surface.
Phase 2.5: Pre-Implement Step (Implement Stage Only)
Before the Claude invocation on every Implement dispatch, the engine calls preImplement() (engine/spawn.go). For most issues this is an instant no-op; for issues whose Plan stage output contains FABRIK_SPAWN_CHILD_BEGIN/END blocks, it performs the GitHub mutations that create child issues and link them as blockedBy dependencies of the parent.
Inputs
- Plan stage comment body — read via
findStageComment(item.Comments, "Plan"). If no Plan comment is found, the outcome depends on thestage:Plan:completelabel:- Label absent — true no-op; Plan hasn’t run yet.
preImplementreturns immediately. - Label present — an inconsistency (#982): the label says Plan finished, but the comment the spawn logic reads from is missing from
item.Comments(a stale deep-field snapshot; see #957).preImplementdoes not silently no-op here — it recovers the true spawn intent via a live, uncached re-read before deciding. See State Machine §6.7 for the full detect → recover → three-way-outcome flow.
- Label absent — true no-op; Plan hasn’t run yet.
FABRIK_SPAWN_CHILD_BEGIN/ENDblocks in the Plan comment — structured declarations of child issues to create:FABRIK_SPAWN_CHILD_BEGIN owner/repo TITLE: <single-line title> DEPENDS_ON: <n> # optional — forward-only 1-based index into this block list <scoped spec body> FABRIK_SPAWN_CHILD_ENDParsed by
ParseSpawnBlocksDetailed()(header keys are case- and whitespace-tolerant, #2091). If no blocks are found,preImplementreturns immediately. If any block is malformed,preImplementcreates nothing and pauses viapauseForMalformedSpawnwith a comment naming each bad block’s index and quoted line; the same failure in Review/Validate output commits and pushes the stage’s work first (preserveWorkOnMarkerFailure), as does a malformedFABRIK_PR_CREATEin Implement.DEPENDS_ON:, when present, must immediately followTITLE:(no blank line between) — see State Machine §6.7 for the full grammar and validation rules (ADR-1337).fabrik:children-spawnedlabel — idempotency guard. If present on the parent issue,preImplementreturns immediately without making any mutations.
Flow (when spawn blocks are present and guard label is absent)
DEPENDS_ONvalidation:validateSpawnDependsOnchecks every declared index upfront, before any GitHub mutation — a purely structural forward-reference check (1 <= DEPENDS_ON < ownIndex). Any invalid value (out-of-range, non-forward, or malformed) is a hard failure: post an error comment (Created so far: none), addfabrik:paused, and stop — no children are created.- Repo validation: Call
ensureRepoReady(owner, repo)for each unique target repo across all blocks. If any repo is not in Fabrik’s managed set (clone fails), post an error comment listing the unmanaged repos, addfabrik:paused, and stop — no children are created. - Per-child mutations (for each block in document order — the block-index → child node-ID mapping is retained for step 4):
CreateIssue(owner, repo, title, body)— RESTPOST /repos/{owner}/{repo}/issues; body = block body + engine-appended back-reference footerAddProjectV2ItemById(board.ProjectID, childNodeID)— adds child to the same project board; returnschildItemIDAddBlockedByIssue(parent.NodeID, childNodeID)— links child as ablockedBydependency of the parentAddLabelToIssue(childNodeID, "fabrik:sub-issue")— informational; no engine semanticsUpdateProjectItemStatus(board.ProjectID, childItemID, sf.FieldID, specifyOptionID)— moves child to theSpecifycolumn (or first non-Backlog, non-terminal column as fallback). Non-fatal: ife.statusFieldis nil or no viable column exists, child lands in Backlog and a warning is logged.- Conditional
AddLabelToIssueforfabrik:yoloif the parent hasfabrik:yolo; conditionalAddLabelToIssueforfabrik:cruiseif the parent hasfabrik:cruise. Both are non-fatal.base:<branch>labels are not inherited. - On any failure in the fatal steps (CreateIssue, AddProjectV2ItemById, AddBlockedByIssue): post error comment naming completed and failed children, add
fabrik:pausedto parent, stop;fabrik:children-spawnedis NOT added
- Sibling-wiring pass (after all children from step 3 exist): for each block that declared
DEPENDS_ON, callAddBlockedByIssue(childNodeID, blockerChildNodeID)linking it to the earlier sibling it referenced. On failure: post an error comment listing children created so far, addfabrik:pausedto parent, stop;fabrik:children-spawnedis NOT added. - After all children and sibling edges succeed: Add
fabrik:children-spawnedlabel to the parent.
After spawn
preImplement returns (spawned=true, nil). processItem returns without invoking Claude. On the next poll cycle, checkDependencies sees the new blockedBy edges — parent edges and any sibling edges alike — and adds fabrik:blocked, gating the parent’s Implement until all children close. No changes were needed to checkDependencies or PushUnblockObserver to support this: both already operate generically over item.BlockedBy regardless of edge origin.
Idempotency and retry
fabrik:children-spawned is the durable idempotency guard, applied only after both the creation pass (step 3) and the sibling-wiring pass (step 4) succeed. If pre-Implement fails after creating some but not all children, or after creating all children but failing to wire some DEPENDS_ON edges, it pauses the parent without adding fabrik:children-spawned. On retry (after user removes fabrik:paused), preImplement re-runs all steps from the start — v1 does not skip already-created children. The error comment names the orphaned children so the user knows what to close before re-advancing.
To trigger a fresh spawn (e.g., after Plan is revised), the user must manually remove fabrik:children-spawned and close any orphaned children.
Recursive decomposition
A child issue created by preImplement runs the full Fabrik pipeline. If the child’s own Plan emits FABRIK_SPAWN_CHILD_* blocks, the child’s Implement dispatch triggers another preImplement — grandchildren are created by the same mechanism. There is no depth limit.
References: ADR-048: Engine-Side Pre-Implement Spawn, State Machine §6.7
Phase 3: Claude Invocation
Prompt Construction
When skill: is set (recommended), the prompt is a minimal directive:
You are operating as the Fabrik <StageName> agent for issue #<N>.
Follow the instructions in the <skill-name> skill exactly.
---
# Issue #N: <title>
URL: <url>
## Spec / Issue Body
<full issue body>
## Labels
<comma-separated>
## Prior Discussion
<all comments>
## New Comments
<unprocessed comments only>
---
Context files are available in .fabrik-context/ in your working directory:
- .fabrik-context/issue.md — the issue body (spec)
- .fabrik-context/stage-{Name}.md — output from prior stages
- .fabrik-context/pr-description.md — the linked PR description (if applicable)
When you have completed all work for this stage, end your response with:
FABRIK_STAGE_COMPLETE
If you have unresolved questions that must be answered before the stage can proceed:
FABRIK_BLOCKED_ON_INPUT
These two markers are mutually exclusive.
Comment Review Prompt
When comment_skill: is set, comment processing uses a similar directive:
You are operating as the Fabrik <StageName> comment reviewer for issue #<N>.
Follow the instructions in the <comment-skill> skill exactly.
---
# Issue #N: <title>
URL: <url>
## New Comments to Process
<each comment with author, timestamp, body>
---
Context files are available in .fabrik-context/
...
Claude Arguments
--plugin-dir <absolute-path-to-.fabrik/plugin>
--plugin-dir <absolute-path-to-.fabrik/plugins/local-workflows> (only when that directory exists; #2139, state-machine §7.19)
--plugin-dir <absolute-path-of-each-configured-plugin_dirs-entry> (zero or more, in configured order; ADR 2136)
--output-format json
--verbose
--disallowedTools <tool> ... (always: ScheduleWakeup, Workflow, Monitor, CronCreate — see below)
--resume <sessionID> (if retry)
--model <override> (if label or stage config)
--max-turns <N> (if configured)
--allowedTools <tool> ... (if restricted)
--name <sentinel> (if claude supports --name; probed once at startup)
Skill name in the prompt (ADR 2137). The skill the prompt tells the worker to follow is the fully-qualified <plugin>:<skill> name. Before the invocation (after the suspension and apiKeyHelper gates, ahead of the toolchain check and InvokeOptions), runInvocationWithExtension resolves the stage’s skill against the plugins loaded for the worker — the same directories the --plugin-dir arguments above name — into a copy of the stage; comment review, CI-fix and rebase do the same for comment_skill in processCommentsClassified. A bare name is qualified to the loaded plugin that provides it; a reference no loaded plugin provides is refused before a worker is spawned (no retry consumed, fabrik:skill-unresolved). See docs/state-machine.md §7.18.
--disallowedTools is emitted unconditionally, outside the --dangerously-skip-permissions/--permission-mode dontAsk branch, so it applies on both invocation paths — it is a construction-time exclusion from the tool schema Claude is offered, not a call-time permission check like --allowedTools. ScheduleWakeup, Workflow, Monitor, and CronCreate are suppressed because each promises delivery to a future turn, session, or external callback a headless stage cannot receive — the membership test and full sweep (adds and rejects) are recorded in ADR-1365’s #1558 amendment (see disallowedTools in engine/claude.go). Agent is deliberately not suppressed — subagents complete within the parent turn.
Worker Session Naming (--name)
Every worker invocation carries a --name <sentinel> flag of the form fabrik:<owner>/<repo>#<issue>:<stage> (e.g. fabrik:handarbeit/fabrik#1284:Implement), built by sessionNameSentinel (engine/claude.go) from issue.Repo, issue.Number, and stage.Name. This gives every Fabrik worker a self-describing identity in ps output — a ps aux | grep -- "--name fabrik:" finds every worker on the host, and the sentinel itself names which repo, issue, and stage each one is serving, without fingerprinting incidental flags (--output-format, --plugin-dir) or recovering cmd.Dir via lsof/--add-dir.
The stage-name component is passed through sanitizeSentinelComponent, which collapses any run of whitespace to a single - and replaces any : or # with -. The whitespace rule keeps the value a single token, since args are passed as an argv slice (never through a shell) and the sole hard constraint is that naive ps-based parsing not break; the :/# rule keeps a custom stage name (e.g. Review: Final or Review #2, set via .fabrik/stages/*.yaml) from colliding with the sentinel’s own fabrik:<repo>#<issue>:<stage> field delimiters, which would otherwise make the rendered sentinel ambiguous to split back into fields by position. An empty issue.Repo (not expected in production; GraphQL always populates it for real board items) falls back to the literal unknown/repo rather than producing a malformed sentinel. The sentinel is identical for a stage run and its comment-review invocations (both are built from the same stage.Name), and is deterministic for a given (repo, issue, stage) so repeated and resumed invocations produce the same value.
Capability probe. Older claude binaries reject unrecognized flags outright, which would kill every in-flight worker — a far worse failure than the ps-identification problem this feature solves. To avoid that, --name is gated on claudeNameFlagSupported (engine/claude.go), a package-level boolean probed exactly once, in engine.New(), by running claude --help with a 5s timeout and checking the output for the literal --name <name> flag documentation (probeClaudeNameFlagSupport/parseNameFlagSupport). This mirrors the existing claudePluginDir pattern: computed once at engine construction, read on every buildClaudeArgs call, never re-probed per invocation. The probe’s zero value is false, and any ambiguity — the binary is missing from PATH, --help exits non-zero, the probe times out, or the output doesn’t mention --name — fails safe to false (flag omitted, workers still run) rather than risking a fleet-wide outage. When unsupported, a single [startup] log line explains why and workers proceed exactly as they did before this feature. buildClaudeArgs itself stays a pure formatter here too: it receives the already-resolved sessionName string and appends --name <sessionName> only when both the probe passed and a non-empty sentinel was supplied.
Interaction with --resume. --name is passed unconditionally whenever the capability probe passes, resume or not — live verification during Research confirmed --name and --resume combine cleanly on the installed CLI (same, unforked session_id across a resumed invocation), so there is no conditional-omit branch on the resume path.
Also a liveness-verification signal (#1779). The sentinel never appears in prompts or context files, and its original human-facing uses — ps/the session picker/terminal title — are unchanged. But the engine itself now reads it back in two places, both in engine/worker_liveness.go/engine/sentinel_probe*.go:
runWorkerDetectorScan’sPID<=0branch: before clearing a worker whose PID was never recorded (the pre-#1779 timeout-only clear from #1303 — seedocs/state-machine.md§9.7), it probes for a live process still carrying that worker’s sentinel viaps, exact-argv-token match. A live sentinel is treated like a live PID (log and wait, adopting the discovered PID so ordinary signal-0 liveness governs afterward); an affirmatively-absent sentinel clears exactly as before; a probe that itself cannot run (unsupported platform,pserror) defers the clear for a bounded number of scan cycles before falling back to the plain clear, logged as unverified.dispatchCandidates(engine/poll.go): independently refuses to start a second worker for an (issue, stage) whose sentinel is still live, even if a prior worker’s handle was already cleared — the exact “two workers on one worktree” shape the mechanism exists to close, asserted at the dispatch site itself rather than relying solely on the scan above having caught it first. Unlike the scan (which checks one worker’s sentinel at a time viaprobeSentinelLive/sentinelProbeFn), this site may need to check many candidates in a single call, so it fetches the live process table at most once perdispatchCandidatescall (listProcessArgvFn, lazily on first need) and matches every candidate’s sentinel against that one snapshot in-process (matchSentinelInArgvList) — not once per item, which would otherwise serialize onepssubprocess spawn per dispatch-eligible item ahead of the first dispatch in a poll with many of them at once.
Both consumption patterns build on the same underlying ps-invocation and exact-token-matching code (listProcessArgv/matchSentinelInArgvList in engine/sentinel_probe*.go) and are gated on claudeNameFlagSupported — an older claude binary that never got --name means no worker in this process could carry a sentinel, so the probe is skipped rather than spawning ps for a guaranteed miss.
Worker Environment: GitHub Identity
The Claude worker subprocess’s environment is assembled as mergeEnv(os.Environ(), extraEnv) — the base is the engine process’s own environment, with extraEnv entries taking precedence on key collision (last-wins). Alongside CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING and CLAUDE_CODE_EFFORT_LEVEL, extraEnv includes GH_TOKEN and GITHUB_TOKEN. In PAT mode (the default), both are set to the engine’s resolved GitHub token (Config.Token, the same value used for the engine’s own gh.NewClient calls). Under GitHub App auth (#1713), claudeGHTokenOverrideFn is non-nil and buildClaudeEnv prefers it over claudeGHToken: both env vars are instead set to the live installation token, read directly off the engine’s own *gh.Client at env-build time — riding that client’s background refresh loop rather than a value copied once at startup. Either way this guarantees the worker’s gh invocations (available via the default Bash(gh:*) allowed tool) always authenticate as the same identity as the engine itself, regardless of what GH_TOKEN/GITHUB_TOKEN the launching shell happens to export. Injection is skipped only when the resolved token is empty, which should not occur in practice since a token (PAT or App-auth installation token) is mandatory at startup.
Worker git inherits its credentials from the same base environment (minus the Fabrik daemon secrets — see “Worker Environment: Fabrik Daemon Secret Scrub” below). Under App auth with HTTPS git, Run()’s startup (setUpAppGitCredential, engine/app_git_credential.go) injects a credential.https://github.com.helper into the engine’s own process environment via GIT_CONFIG_COUNT/GIT_CONFIG_KEY_<n>/GIT_CONFIG_VALUE_<n> — an empty reset entry followed by a helper reading .fabrik/state/github-app-git-token — so engine and worker git both authenticate as the installation regardless of host helpers, and pick up token rotations mid-invocation. mergeEnv passes these entries through unchanged. Under git_ssh/an SSH rewrite, or in PAT mode, no helper is injected and any left by a previous exec is stripped. See ADR-1846.
extraEnv also includes GH_HOST, set to the engine’s resolved GHES host (Config.GHESHost, the same value used for the engine’s own client construction via gh.NewClientForHost — see ADR-1391), immediately after GH_TOKEN/GITHUB_TOKEN — but only when Config.GHESHost is non-empty. This is the gh CLI’s own established convention for pointing it at a non-github.com host, so it requires no extra wiring on the worker side: validate’s Pre-Completion Gate (which runs gh pr view --json baseRefName on every Validate invocation) and any other stage’s ambient gh calls automatically target the same GHES instance as the engine rather than silently falling through to github.com. Unlike GH_TOKEN/GITHUB_TOKEN, absence is the default and expected case — when no GHES host is configured, GH_HOST is omitted from extraEnv entirely (not emitted as an empty value), preserving today’s behavior byte-for-byte. GH_HOST is a Fabrik-computed override like GH_TOKEN, not part of the Anthropic auth namespace described below, so it is never subject to the scrub or passthrough machinery.
Worker Environment: Anthropic Auth Namespace Scrub
mergeEnv gained a bare-key removal sentinel (#1346, R1): an entry in extraEnv with no = (just KEY) strips that key from baseEnv without supplying a replacement, in addition to the pre-existing KEY=VALUE add/shadow/last-wins semantics, which are unchanged. buildClaudeEnv uses this to scrub the Anthropic/Claude-Code auth namespace out of the worker’s environment by default, so no ambient credential the engine process happens to have (e.g. from a .env a human or an agent added locally) can silently redirect a Fabrik invocation from subscription billing to metered API billing.
Default-deny over a namespace, not a deny-list. Every inherited variable whose name starts with ANTHROPIC_ is removed unconditionally (scrubAnthropicAuthEnv, engine/claude.go) — matching on the exact parsed key, the same “up to the first =” extraction mergeEnv itself uses, never a substring, so FANTASY_ANTHROPIC_API_KEY passes through untouched (R5). This is deliberately wildcard-wide: a newly-introduced upstream ANTHROPIC_* billing variable is denied automatically, with no Fabrik code change required (R2). It also means non-auth ANTHROPIC_* variables (ANTHROPIC_MODEL, ANTHROPIC_CONFIG_DIR, etc.) are scrubbed too — an accepted, deliberate side effect of “namespace, not deny-list,” documented in docs/USER_GUIDE.md and ADR-1346.
CLAUDE_CODE_* is a much broader general-configuration namespace — it already carries Fabrik’s own non-auth CLAUDE_CODE_EFFORT_LEVEL/CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING — so it is not wildcard-scrubbed. Instead, claudeCodeAuthSelectors enumerates the specific CLAUDE_CODE_* names verified (against the installed Claude Code binary) to select a non-subscription auth path or supply raw credentials: CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR, CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR, and the CLAUDE_CODE_USE_* provider selectors (BEDROCK, VERTEX, FOUNDRY, ANTHROPIC_AWS, ANTHROPIC_GOOGLE_CLOUD, MANTLE, GATEWAY). A not-yet-enumerated future selector would require a Fabrik code change to be scrubbed — an accepted residual risk, since wildcard-scrubbing all of CLAUDE_CODE_* would also block legitimate non-billing configuration.
Explicit API-billing opt-in. FABRIK_ANTHROPIC_API_KEY, resolved once at engine construction into the package-level claudeAnthropicAPIKey (mirroring the existing claudeGHToken pattern), is translated into an explicit ANTHROPIC_API_KEY=<value> override when non-empty (R6) — the only supported way to obtain API billing through this variable. When unset or empty, ANTHROPIC_API_KEY never reaches the worker regardless of what the engine inherited (R7). FABRIK_ANTHROPIC_API_KEY itself is never forwarded (R8) — buildClaudeEnv emits it as a bare removal token, since it is itself present in os.Environ(), the very baseEnv the worker would otherwise inherit unfiltered (the same ambient-leak reasoning FABRIK_REPO’s always-emitted override already documents above). When active, a one-time [startup] notice (logAnthropicAPIKeyOptIn) states that invocations will be billed to the Anthropic API — never logging the value (R9).
Explicit passthrough allow-list, for the long tail. FABRIK_ANTHROPIC_ENV_PASSTHROUGH, resolved once into claudeAnthropicEnvPassthrough via parseAnthropicEnvPassthrough (comma-separated exact variable names), names keys to re-inherit from the ambient environment unchanged, overriding the scrub for only those names (R14). A named variable absent from the ambient environment is a no-op, not an error (R15); a variable not named remains scrubbed even if present (R16); a named variable outside the scrubbed namespace (e.g. PATH) is a redundant no-op, not an error (R19) — it was never going to be removed. FABRIK_ANTHROPIC_ENV_PASSTHROUGH itself is never forwarded, mirroring R8 (R17). The re-add loop is itself restricted to isAnthropicAuthNamespaceKey (ANTHROPIC_*-prefixed or a claudeCodeAuthSelectors entry) — this is what makes R19’s “no-op” claim actually hold for every key outside the namespace, including Fabrik’s own computed overrides (GH_TOKEN, the FABRIK_* invocation facts, CLAUDE_CODE_EFFORT_LEVEL, CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING): naming one of those in the passthrough list is a no-op rather than letting a stale or attacker-controlled ambient value silently win over Fabrik’s own value via mergeEnv/os/exec’s last-occurrence-wins duplicate-key resolution. This exists specifically for Bedrock/Vertex-style non-subscription auth: their actual credential chains (AWS_ACCESS_KEY_ID, GOOGLE_APPLICATION_CREDENTIALS, etc.) already fall outside the scrubbed namespace and need no passthrough entry — only the ANTHROPIC_*/CLAUDE_CODE_*-namespaced selectors do, e.g. FABRIK_ANTHROPIC_ENV_PASSTHROUGH=CLAUDE_CODE_USE_BEDROCK,ANTHROPIC_AWS_API_KEY. When non-empty, a one-time [startup] notice (logAnthropicEnvPassthrough) names which variables were passed through and warns that invocations may not be subscription-billed as a result — never logging values (R18).
Ordering. buildClaudeEnv emits the scrub removals, then the passthrough re-adds, then the FABRIK_ANTHROPIC_API_KEY translation last — so if a passthrough entry and the translation both name ANTHROPIC_API_KEY (an edge case R7 permits but nobody is expected to actually hit), the translation wins deterministically via mergeEnv/os/exec’s last-occurrence-wins duplicate-key resolution.
apiKeyHelper is refused outright, not scrubbed. apiKeyHelper is a settings.json key, not an environment variable — a command Claude Code shells out to for credentials — so no amount of environment scrubbing can prevent it from supplying an API key. checkAPIKeyHelper (engine/startup.go) fails startup (non-zero exit) if it is set anywhere in the resolved managed-policy/user/fabrikDir-project settings chain; see “apiKeyHelper Detection Path (Worktree)” below for the structurally identical per-invocation check against a worktree’s own repo-resident settings, and docs/USER_GUIDE.md/ADR-1346 for the full rationale and accepted residual risks.
Worker Environment: PM Plugin Exclusion
buildClaudeArgs (engine/claude.go, the one argument builder for stage dispatch and for comment, CI-fix, rebase and merge-train conflict invocations) appends --settings <json> after the --plugin-dir entries. The JSON is built with json.Marshal and holds only {"enabledPlugins":{"fabrik@fabrik":false}} (plugin.PMPluginID), so the human-facing PM plugin installed in the user’s own settings is not loaded in a worker (#2140, ADR 2140). The stage plugins are --plugin-dir entries, identified separately from the marketplace plugin, and are unaffected. The inline JSON carries no apiKeyHelper and is not read by the file-scanning apiKeyHelper preflight.
Worker Environment: Fabrik Daemon Secret Scrub
Root cause (#2027, ADR-2027): cmd/root.go calls config.LoadDotenv(), so every .env entry lands in the daemon’s process environment, and buildClaudeEnv builds the worker environment from os.Environ(). Before this scrub only the Anthropic namespace and the invocation facts were removed, so a worker inherited FABRIK_TOKEN, the App key path, the webhook secret, and anything else in .env.
buildClaudeEnv now appends bare mergeEnv removal sentinels (same mechanism as the Anthropic scrub) for an explicit, audited list in internal/workerenv — exact names, never a FABRIK_* wildcard, since the invocation facts share the prefix and are emitted in the same slice. The list is every FABRIK_* variable the engine reads, classified scrubbed by default; the only forwarded class is the five worker facts the engine sets (FABRIK_ISSUE, FABRIK_REPO, FABRIK_WORKTREE, FABRIK_ROOT, FABRIK_PR). TestEveryFabrikLiteralIsClassified fails when a FABRIK_* literal in the engine’s sources is in neither list.
Dynamic names. FABRIK_HOOKDECK_API_KEY_ENV/FABRIK_HOOKDECK_WEBHOOK_SECRET_ENV name the variables holding the Hookdeck secrets. Engine.New resolves the effective names once into the package-level claudeWorkerEnv (beside claudeAnthropicEnvPassthrough); both those variables and the defaults (HOOKDECK_API_KEY, FABRIK_GITHUB_WEBHOOK_SECRET) are scrubbed regardless of event_source. A configured name that is Protected is refused with a startup warning.
Sanctioned access survives. GH_TOKEN/GITHUB_TOKEN (live installation token under App auth via claudeGHTokenOverrideFn; the configured token in PAT mode), GH_HOST and the ADR-1846 GIT_CONFIG_* helper entries are on no list and are Protected. A worker can fetch, push and use gh with only the App-minted token. An ambient GH_TOKEN/GH_HOST with nothing for the engine to inject is left unchanged.
Opt-in: FABRIK_WORKER_ENV_PASSTHROUGH. Comma-separated exact scrubbed names, resolved once in New() via workerenv.Resolve. It works by not emitting the sentinel for an admitted name, so the value inherits from the base env and is never re-added as KEY=VALUE — it cannot shadow an engine-computed override. Non-scrubbed, Protected, the opt-in itself and the ADR-1346 control pair are ignored with a [startup] warning; admitted names get a [startup] notice (logWorkerEnvOptIn).
Test hermeticity. internal/testenv (Isolate, IsolateGit, ScrubProcess) unsets every one of these, GH_TOKEN/GITHUB_TOKEN/GH_HOST and all GIT_CONFIG_COUNT/KEY_<n>/VALUE_<n> entries (enumerated) so a suite run inside a worker behaves as in CI; cmd and engine call ScrubProcess from TestMain. Readiness waits on testReadyCh select on Execute() returning and a deadline. scripts/ci/worker-shaped-test.sh runs the suite in a worker-shaped environment (CI’s worker-shaped job in .github/workflows/ci.yml).
Worker Environment: Invocation Facts (FABRIK_*)
Alongside GitHub identity, buildClaudeEnv (engine/claude.go) also injects six facts Fabrik uniquely holds about the current invocation, so repo-side scripts running inside the worktree can provision or namespace a resource (a database schema, a port, a fixture namespace, a preview environment) without guessing. This is deliberately expose-the-facts, not a lifecycle-hook mechanism: Fabrik publishes what it knows; the consuming repo owns what happens, when, and how (see ADR-1288). No credential is added to this set, and nothing in the engine reads or branches on these variables — they exist solely for repo-side consumption.
| Variable | Value | Presence |
|---|---|---|
FABRIK_ISSUE |
The issue number (bare integer, e.g. 1085) |
Always |
FABRIK_REPO |
The owner/repo this invocation belongs to — the item’s repo, not the engine’s configured default (multi-repo aware) |
Always |
FABRIK_WORKTREE |
Absolute path to the issue’s worktree (the process’s working directory) | Always |
FABRIK_ROOT |
Absolute path to fabrikDir (where .fabrik/ config, stages, and plugin live) |
Always |
FABRIK_PR |
The linked pull request number | Only once a PR exists |
FABRIK_WORKER_OWNER |
Path of this invocation’s owner file (.fabrik/state/worker-owners/<nonce>.json, holding the worker’s PID and start token). An internal marker, not a value for scripts to use: every descendant inherits it, which is how the engine reaps orphans by owner and how a test binary notices its worker is gone (#2128, see “Kill escalation”) |
Always |
FABRIK_ISSUE and FABRIK_WORKTREE are derived directly from the issue.Number/workDir values buildClaudeEnv’s callers already hold — workDir is guaranteed non-empty on every real invocation, since it also becomes the subprocess’s cmd.Dir. FABRIK_ROOT and FABRIK_PR require Engine-only state (e.fabrikDir, e.readClient) and are resolved by the shared Engine.resolveFabrikEnvOpts helper (engine/repo.go), called from every InvokeOptions-constructing site — stage invocation (item.go), comment processing (comments.go), and merge-train conflict resolution (merge_train.go, FABRIK_ROOT only; see below) — so the worker environment is identical across invocation paths.
Re-resolved on every extend-turns iteration, not once per call. runInvocationWithExtension’s Turn-Limit Extension Loop (below) can invoke Claude multiple times within a single call, flipping its local resume variable to true partway through once a turn-capped attempt shows progress. resolveFabrikEnvOpts is called fresh at the top of every loop iteration (using the iteration’s current resume value), not once before the loop — otherwise a PR that came to exist between iterations (e.g. pushed externally to fabrik/issue-N while the loop was still running) would stay invisible to FABRIK_PR for the rest of that call, since the resume-aware cost-control gate above would never re-evaluate.
FABRIK_REPO and the not-yet-backfilled item case. FABRIK_REPO prefers issue.Repo, which is populated from every board fetch and deep-fetch (github/project.go) and is therefore set on every item that has gone through Fabrik’s normal dispatch path. The one exception is an item freshly constructed from a projects_v2_item.created webhook delta, which initially carries only a node ID — issue.Repo stays empty there until a subsequent FetchItemDetails backfills it (github/project.go’s FetchItemDetails explicitly comments on this path). To keep FABRIK_REPO’s “Always” guarantee true even in that window, buildClaudeEnv falls back to opts.FabrikRepo — the engine’s configured default repo (e.defaultRepo()), set at all three InvokeOptions-constructing call sites — whenever issue.Repo is empty.
FABRIK_REPO is always added to buildClaudeEnv’s returned overrides, even in the (practically unreachable) case where both issue.Repo and opts.FabrikRepo are empty — the resolved value is then an empty string, but the key is still present. This is deliberate: mergeEnv only strips a key from the base environment (os.Environ()) when that key appears in overrides. Omitting FABRIK_REPO entirely in that case would leave mergeEnv with nothing to strip, letting an ambient FABRIK_REPO already present in the engine process’s own environment — e.g. the distinct engine-startup-config FABRIK_REPO (see docs/USER_GUIDE.md’s “Not the same FABRIK_REPO” note) — pass straight through to the worker unmodified. Always emitting the key, even empty, guarantees the worker-injected value (or its deliberate absence) always wins over anything the launching shell exported.
FABRIK_PR resolution and the base:<branch> case. The board-sourced item.LinkedPRNumber is populated from GraphQL closedByPullRequestsReferences, which GitHub leaves structurally empty for a PR targeting a non-default base branch. resolveFabrikEnvOpts therefore trusts item.LinkedPRNumber when non-zero, and otherwise falls back to FetchLinkedPR via REST — the identical fallback pattern the review gate already uses (handleBrokenReviewLinkage in reviews.go). A result that errors, is nil, or isn’t an open, unmerged PR is treated as “no PR”: non-fatal, logged at warn, and never delaying or failing the invocation.
FetchLinkedPR matches by branch name (fabrik/issue-N) alone, which is never sufficient confirmation on its own: a stale or repurposed fabrik/issue-N branch could carry someone else’s unrelated open PR — on a base:<branch> repo especially, since closingIssuesReferences is structurally empty there for every PR, not just the linked one, but the same risk exists on a default-branch repo too. resolveFabrikEnvOpts therefore always confirms the branch-name match via FetchPRClosingIssues before trusting it — the same confirmation handleBrokenReviewLinkage performs for both its base-label and non-base-label branches — rather than skipping the check on a default-branch repo. FetchPRClosingIssues parses the PR body directly via REST, independent of any GraphQL cross-reference field, so it resolves correctly on both repo shapes; every Fabrik-created PR carries a closing keyword (Closes #N, per repo convention), so this confirmation succeeds transparently for the common case — including a draft PR that was just created moments ago, before the next GraphQL board refetch has repopulated item.LinkedPRNumber. A transient FetchPRClosingIssues error fails open (the branch-name match is still trusted, mirroring handleBrokenReviewLinkage’s own fail-open behavior) — only a successful fetch that doesn’t list this issue withholds the PR number.
Cost control. The REST fallback only fires when a PR could plausibly exist yet, which is both stage-config- and attempt-aware, not stage-config-alone:
- A stage with neither
PostToPRnorCreateDraftPR(Specify/Research/Plan in the default stage set) never calls the fallback — structurally, no PR can exist yet. - A stage that only posts to an already-existing PR (
PostToPRwithoutCreateDraftPR, e.g. Review/Validate) calls the fallback from its very first attempt, since an earlier stage (Implement) already created the PR. - A stage that creates its own draft PR (
CreateDraftPR, e.g. Implement) calls the fallback only on a resumed attempt (resume == true, i.e. this issue has a prior invocation attempt already on record) — its own first attempt has no PR to find yet, sinceensureDraftPRonly runs after Claude completes. Gating on the stage flags alone (ignoringresume) would burn a REST call on every first Implement invocation that can never succeed — exactly the “network call on every invocation” the cost-control requirement forbids.
Comment processing (comments.go) always passes resume = true: reaching the comment-review path already implies the stage produced at least one prior attempt.
Absent is absent, never a misleading zero. buildClaudeEnv omits FABRIK_PR entirely when the resolved PR number is 0 — it never emits FABRIK_PR=0, which would read as a real PR number to a naive consumer.
Merge-train conflict resolution is a deliberate partial case. merge_train.go’s inline conflict-resolution invocation (resolveConflictWithClaude) sets FabrikRoot for consistency but leaves PRNumber unset: that invocation resolves a merge conflict on a trial branch, not the member issue’s own PR, so there is no single PR for FABRIK_PR to name.
Output Logging
Each invocation writes one NDJSON stream file to .fabrik/logs/<owner>-<repo>/issue-<N>/ as <stage>-<timestamp>-<nanos>.log. This file is written live during execution (tee’d from Claude’s stdout) and is the sole on-disk copy of the stream. Viewable via cat <file> | fabrik stream-filter | less -R or through the TUI’s l key.
Turn Progress Emission
During each Claude invocation, the engine counts logical turns in real time via a turnCountingWriter wrapping the stdout pipe. Each time a {"type":"user"} NDJSON line is detected (one per logical turn — the initial prompt or a tool-result round), the writer increments a per-invocation counter and fires the claudeTurnProgress callback (set during engine construction), which emits a TurnProgressEvent to the TUI channel. The event carries:
IssueNumber— the issue being processedTurnsUsed— the current per-invocation logical-turn countMaxTurns— the effective budget for this invocation (accounts foropts.MaxTurnsOverridefrom the extension loop)
This is a purely additive display mechanism — it does not affect Claude’s execution, the output buffer, or any engine state. In plain-text mode and tests, claudeTurnProgress is nil and no events are emitted.
Subprocess Cleanup
After cmd.Run() returns, two cleanup steps run unconditionally:
-
Kill escalation and the wrapper contract: When the engine needs to stop a Claude invocation (max_wall_time, inactivity timeout, daemon shutdown, or supplant-by-new-invocation), it uses a three-signal escalation sequence rather than an immediate SIGKILL:
SIGINT → sleep(sigintGrace) → liveness probe SIGTERM → sleep(sigtermGrace) → liveness probe SIGKILLClaude is started as its own session leader (
Setsid: trueon Unix, #1798 — previouslySetpgid: trueonly).setsid()makes the worker’s PID both its process-group ID and its session ID (SID), and the SID is inherited by every descendant for life, surviving reparenting to PID 1 — until a descendant callssetsid()itself. Claude’s Bash tool does exactly that: every command runs through a shell that leads a session of its own, with thego test/ vitest / tsc tree below it in that session (observed live: the worker issid == pid; itszsh -c …command shell is a child with a different SID, and its grandchildren carry the shell’s SID). A kill keyed on the worker’s SID — let alonekill(-pgid, sig)— reaches none of it.The signals (#1989) are therefore sent to every member of every session the worker owns: its own SID plus its command sessions. Command sessions are discovered from the parent chain while the worker is alive — walk the process table by PPID from the worker; every descendant whose SID is not the worker’s names one — and sampled on a cadence during the invocation (
commandSessionSampleInterval, 2 s, one process listing per sample), because once the worker exits its children are reparented to PID 1 and the chain is gone. The stop path also re-discovers just before each signal, so a command started during a grace window is still reached. Members are found by session ID (unix.Getsidover one process listing —/procon Linux,sysctl kern.proc.allon macOS; neverps’s session column, which reads 0 on macOS) by the sharedinternal/sessionreappackage, whichprueferuses too (Pruefer also starts Claude withSetsidand tracks command sessions the same way).Each step is logged (the R5 line) as
[#N kill] sending SIG<X> to session <pid>[ + <k> command session(s) [<sid> …]] (reason=<reason>): signalled <n> member(s): <comm>, <comm>, … (+k more)— names truncated, at most five listed. Reason codes:max_wall_time,inactivity_timeout,daemon_shutdown,supplant_by_new_invocation,context_cancel.Safety (R4). Only processes in a target session are ever signalled, and each member’s SID is re-checked immediately before its signal against the SID it was enumerated under. A session ID of
0,1or the engine’s own session is refused and logged (session reap refused), signalling nothing. If the process listing itself fails, the escalation degrades to the worker’s process group (the pre-#1989 behaviour), never to a wider target — and that group signal is itself ownership-checked (see “Stored-PID group kills” below). Two PID-reuse guards stop an unrelated session being mistaken for ours: (1) a command session’s SID is trusted only while it provably names the sampled session — if no live process holds PID == SID the leader is gone and its members are the old session’s; if one does, it must carry the leader’s start-time token recorded at sampling, else the session is dropped (command session … dropped … recycled PID); discovery itself runs only while the worker’s PID still carries the start token captured right after it was started, so a recycled worker PID’s descendants are never adopted; (2) a leader is reaped while a grace window may still be running, so the escalation watches each session: once a listing has shown it without its leader, a later listing showing PID == SID again is a different process that became a session leader, and that session is abandoned (session reap stopped … recycled PID).A step is skipped if the grace duration is zero (via per-stage
kill_grace:with"0s"). Each grace window ends early, and the sequence stops, as soon as the session has no members left.Stored-PID group kills (#1957). Every
kill(-pid, …)aimed at a PID recorded earlier —killProcGroup(engine and Pruefer, called right after the worker is reaped, and by the webhook manager on a subprocess that may have long since exited), the twointernal/sessionreaplisting-failure fallbacks, andscripts/sim/run.sh’s trap — goes through one ownership-checked primitive (sessionreap.SignalGroup;scripts/lib/killgroup.sh’skill_own_groupin bash). The Go gate runner (tests/gate, #1994) needs no such check: it sends no negative-PID group signal — it reaps throughsessionreap.Escalateand signals a single PID intermPID, because a negative PID signals whatever group currently has that ID and a recorded PID can be recycled. R4: a target of0/1(so-0/-1can never be formed) or the caller’s own process group is refused before anything else is tested ([#N kill] group kill refused: …). R1: if a live process holds the PID it must lead its own group and be the process we started — its start token equals the one recorded at spawn (Tracker’s worker token, orRecordStartfor the webhook subprocess), or, with no token recorded, it is our child; otherwise the signal is skipped ([#N kill] group kill of PGID <n> skipped: <reason>). If no process holds the PID (the leader was reaped — the normal post-exit case) the group is signalled only while it still has a live non-zombie member (POSIX never reuses a PID that still names a populated group); an empty group is a no-op, and unreadable membership is a skip. Nothing is ever widened to a broader signal. The check-then-kill gap is microseconds and is not closed.Wrapper contract: The SIGINT grace window exists specifically so that shell wrappers spawned by stage skills (e.g. a CI test runner that posts a final Commit Status on interrupt) can catch SIGINT, complete their cleanup, and exit normally before SIGTERM arrives. The default SIGINT grace is 10 seconds. Wrappers must be designed to finish their cleanup within this window.
Grace window configuration: Engine-wide defaults are set via
--kill-grace-sigint/--kill-grace-sigtermflags (orFABRIK_KILL_GRACE_SIGINT/FABRIK_KILL_GRACE_SIGTERMenv vars, default 10s each). Per-stage overrides are expressed in stage YAML (kill_grace:); an omitted field inherits the engine default;"0s"skips that signal step entirely.After
cmd.Wait()returns, two unconditional cleanups run, regardless of how the main subprocess exited: aSIGKILLto the process group (e.g.tail -ffrom the Monitor tool; ownership-checked, see “Stored-PID group kills” below), then a post-exit session sweep (#1989) thatSIGKILLs any process still in the worker’s session or in a command session sampled while the worker lived — including a command shell that is itself still alive, orphaned. No discovery runs here (the parent chain is gone, and the worker’s PID may be recycled); only sessions sampled during the invocation are used, each re-validated every pass. For the worker’s own SID, a live process with PID equal to the SID can only be a recycled PID that became a session leader itself, so that SID is refused (session reap sweep refused … recycled leader PID) and left to the #1814 sweep. The sweep logs one line only when it signalled something:[#N kill] session reap: signalled <n> member(s) of session <pid>[ + <k> command session(s) […]] (exit=clean_exit|after_stop): <comms>. A non-zero count withexit=clean_exitmeans the worker left work running (typically a backgroundedgo test). The #1798 tracker reaper and #1814 registry-independent sweep (see “Session-Scoped Descendant Reaping” below) run after it unchanged; they filter onGetsid == worker PID, so they remain the backstop for what stays in the worker’s own session only — not for Bash-tool commands.Shared servers. A command session is reaped because a command in it was started below the worker, not because it is provably exclusive to that worker. A long-lived server that a Bash-tool command started, that called
setsid()while still a live descendant of the worker at a sampling instant, and that another worker or a later command reuses (e.g. a build server) is reaped when the worker’s invocation ends. A daemon that double-forks is reparented to PID 1 at once, is never named by the parent-chain walk, and is not touched. The R5 line names every reaped command, so aclean_exitreap of such a server is visible.Surviving an engine restart. Each newly sampled command session is persisted in
.fabrik/state/workers.jsonas a child record of the worker’s record (parent_id,pid= the session leader’s PID,start_token). The proc-janitor reaps a command session only once its parent worker record is confirmed dead or recycled, or no longer recorded — never while the worker lives — and only if the start token still validates; a command-session record is never swept as a worker. At invocation end the records of now-empty sessions are removed; a session with a surviving member keeps its record for the janitor, which prunes it after two consecutive empty scans.Reaping by owner marker (#2128). Process-tree shape is never a kill signal: a backgrounded Bash-tool command that is still in use looks exactly like an orphan by ppid/pgid (ppid 1, own session, empty group). Ownership is instead positive evidence carried by the process.
runClaudesetsFABRIK_WORKER_OWNER=<owner file path>(under.fabrik/state/worker-owners/) in every worker’s environment and, right aftercmd.Start(), writes{pid, start_token}into that file; the path is also stored asOwneron the worker’sworkers.jsonrecord. Every descendant inherits the variable, whatever its session, group, parent or cwd. The post-exit sweep and the periodic proc-janitor (which also serves as the startup sweep, since it readsworkers.jsonacross engine restarts) then callsessionreap.ReapMarked, which reads each process’s initial environment (kern.procargs2on macOS,/proc/<pid>/environon Linux), matches the exact marker, and sends SIGTERM then SIGKILL to those exact PIDs after re-checking the start token and marker and applying theCheckSID/CheckPGIDrefusals. Only for recordsclassifyWorkerreports dead or recycled — a live or inconclusive worker’s marked processes are never reached. A process with no marker is never signalled on shape alone. A record is pruned (and its owner file removed) only after its session and marked set were both empty on two consecutive scans; the janitor also removes owner files with no record that are older than 24 h. Limitation: macOS returns only a stub environment for SIP-protected platform binaries, so those are invisible to the marker (Go test binaries are readable). See ADR-2128.Test binaries bound themselves (#2128).
tests/sim’sTestMaininstallsinternal/testlife.Guard: a hard wall-time ceiling (SIM_TEST_CEILING, default 30m, independent of-test.timeout, exit status 2 with a message naming the ceiling), an owner watch (exit within about 5 s once the worker named byFABRIK_WORKER_OWNERis provably gone — PID absent or a different start token; never inferred from the ppid-1 shape, and never when the owner is unresolved or unreadable), for helpers started throughtestlife.ReExecCommanda parent watch plus an explicit-test.timeout, and (#2226) a main-binary parent watch: a binary with no owner marker and no explicit parent watches the process that started it (PID plus start token captured at install; a recycled PID counts as gone; an unreadable identity keeps it running), so asim.testreparented to init when itsgo testis gone exits too. Every bail goes through one routine that arms a watchdog before asking the process to exit: if the ordinary exit has not taken effect after a fixed 5 s grace, the process sends itselfSIGKILL, so an exit that blocks or spins under-racecannot leave an orphan.Known gaps. A command that starts and orphans itself between two samples, after which the worker exits cleanly, is not seen by the session sweep; it is reaped by the owner marker above unless its environment was scrubbed or it is a SIP-protected macOS binary, and the cwd-rooted worktree-teardown reaper below is the further cover, for processes whose cwd is in the worktree. A process that calls
setsid()and is not below the worker in the parent chain at any sample (a double-forked daemon whose intermediate parent exited between samples) is likewise covered by the marker, then by that reaper. Theenginepackage’s own test binaries do not yet install the testlife guard (ADR-2128 follow-up). -
WaitDelay bound:
cmd.WaitDelayis set to 30s (configurable via--claude-wait-delay/FABRIK_CLAUDE_WAIT_DELAY). When grandchild processes hold the stdout pipe open after Claude exits, Go’scmd.Wait()would otherwise block indefinitely. WithWaitDelay, Go forcibly closes its end of the pipe after the deadline and returnsexec.ErrWaitDelay. The engine detects this error, logs a diagnostic warning, clears the error, and processes the buffered output normally — including anyFABRIK_STAGE_COMPLETEmarker. This prevents the worker goroutine from being permanently stuck when Claude usesrun_in_backgroundor the Monitor tool.
Session-Scoped Descendant Reaping
Scope note (#1989). This mechanism, and #1814’s worker-session sweep, key on
Getsid(pid) == worker PID. That reaches processes that stay in the worker’s own session (anythingclaudeforks withoutsetsid, e.g. a hook or an MCP server). It does not reach Claude’s Bash-tool commands: each runs through a shell that callssetsid()and so leads a session of its own. Those command sessions are discovered, signalled, swept and persisted byinternal/sessionreapas described under “Kill escalation” above; #1814’s periodic sweep reaches them through the persisted child records, not through this filter.
The PGID-scoped grandchild cleanup above (item 1) and the cwd-rooted worktree-teardown reaper (below) each leave a gap: a descendant that leaves the worker’s process group (e.g. a job-control-enabled shell’s setpgid() call on a backgrounded job — confirmed by direct reproduction: set -m; nohup cmd & disown produces a child in a brand-new PGID) escapes the first, and a descendant whose cwd is outside the worktree (a Go test’s t.TempDir() fixture, the dominant real-world shape per #1798) escapes the second regardless of when it runs. Neither gap is closed by running either mechanism more often or earlier — the target simply isn’t reachable by either primitive.
engine/descendant_reap_unix.go (!windows) adds a third, independent mechanism keyed on session ID (SID) rather than process-group membership or cwd:
-
Discovery, live during the invocation:
trackWorkerDescendantsruns as a goroutine parallel to the inactivity watchdog (same lifecycle, stopped via the samewatchdogCtx), ticking everydescendantScanInterval(3s in production, test-overridable). Each tick,sessionScopedDescendantsFromProcsfilters a process-table snapshot down to every live process whose session ID equals the worker’s own PID — viagolang.org/x/sys/unix.Getsid, called per candidate PID rather than parsed frompsoutput (see below for why) — and records any not already seen into a durable registry with an identity fingerprint (comm+lstart, from a single-PIDps -p <pid> -o comm=,lstart=query). A PID is only marked “seen” once itsupsertTrackedDescendantwrite actually succeeds — a transient registry-write failure (e.g..fabrik/state/momentarily unwritable) leaves it unmarked so a later tick retries the write, rather than silently and permanently dropping that descendant from tracking for the rest of the invocation.The process-table snapshot itself comes from
sharedProcessTableScan, a package-level cache (TTL =descendantScanInterval) rather than a freshps -eo pid=,args=call per tick: onetrackWorkerDescendantsgoroutine runs per in-flight Claude invocation, and without sharing,MaxConcurrentconcurrent invocations meant that many redundant full-table scans every tick — the same class ofpscost found responsible for real CPU spikes elsewhere in this mechanism (#1805). The first goroutine to tick after the cache goes stale performs the real scan and repopulates it; every other goroutine ticking within the same window reuses that result. MirrorsdispatchCandidates’s existing “fetch once, reuse across many checks” shape (poll.go, vialistProcessArgvFn) but as a time-based cache rather than a single-call memoization, since sharing here spans concurrently-running, independently-ticking goroutines over the life of several invocations rather than one function call.sessionScopedDescendants(an uncached, always-fresh variant used directly by this mechanism’s own tests) is unaffected.This must observe the tree while the invocation is live, not just once at teardown: a
nohup/disown-detached descendant is reparented to init on a sub-second timescale — well before any post-hoc walk could see the transient worker→…→child PPID chain. Session ID does not have this problem: POSIX guarantees it is assigned once at process creation and inherited unchanged acrossfork(), changing only via an explicitsetsid()call by the descendant itself (reparenting does not touch it) — so a descendant discovered at any later tick is still correctly attributed to the worker, no matter how quickly its intermediate parent exited.Why
getsid(2)directly, notps’s own session-ID column: on at least one macOS release,ps -eo pid=,ppid=,sess=(the BSD equivalent of GNU/Linux’ssid=) reports0for every process unconditionally, apparently masked at theps-output layer. The underlyinggetsid(2)syscall is not masked — verified directly on the same host — so discovery goes through it instead. See ADR 1798. -
Reap at invocation end (R2): immediately after the pre-existing unconditional grandchild-cleanup
SIGKILL(item 1 above) — the same already-unconditional call site, not a new hook —reapTrackedDescendantsloads every registry entry recorded against the worker’s PID, re-verifies each one’scomm+lstartfingerprint immediately before killing it (a mismatch means the process already exited or the PID was reused for something unrelated since it was recorded — the entry is dropped, never signalled), andSIGKILLs the survivors. Logged via the existing[#N kill] sending SIGKILL to PID <pid> (<comm>) — session-scoped descendant of worker PID <workerPID> (reason=invocation_end)line.A registry entry is only ever dropped on a positive signal: confirmed dead via a cheap signal-0 probe (
isProcessAlive), or confirmed a mismatch via a successful fingerprint lookup whosecomm/lstartdiffer. A fingerprint lookup that itself errors for a PIDisProcessAlivestill reports as alive — e.g. theps-subprocess timeout expiring under the same host contention this reaper exists to handle — is treated as an inconclusive, transient probe failure: the entry is left in the registry for a later attempt rather than being dropped, mirroringtrackWorkerDescendants’s own transient-failure handling (below).sweepStaleDescendants(R3) applies the identical rule to its own per-descendant fingerprint check.Before this reap runs,
runClaudewaits (via async.WaitGroup) for thetrackWorkerDescendantsgoroutine to actually observewatchdogCtx’s cancellation and return — not merely forwatchdogCancel()to have been called. Cancelling the context doesn’t interrupt a tick already in progress: if the goroutine is blocked inside apidFingerprintFncall for a just-discovered descendant whencmd.Wait()returns, it can still be about to persist that descendant to the registry. Reaping before that write lands would miss the entry entirely — silently deferring that descendant’s reap from “unconditional at invocation end” to the next R3 backstop sweep (bounded byJanitorIntervalHours, potentially hours later). The wait is bounded bypidFingerprintFn’s own internal timeout, so it cannot hang. -
Backstop sweep (R3):
runProcessSweepJanitor(engine/janitor.go) is a fourth periodic janitor, wired into the sameJanitorIntervalHours-gated call sites (startup, and the hourly ticker) as the worktree/log/session janitors — no new config knob. It callssweepStaleDescendants, which loads the entire durable registry (every worker, including ones from a previous engine run — the registry lives at.fabrik/state/descendants.jsonand survives a restart) and, for each entry whose recorded worker PID is confirmed dead, re-verifies its fingerprint and kills it exactly as the invocation-end reap does. “Confirmed dead” is a live signal-0 probe plus an identity re-check:trackWorkerDescendantsalso stamps each entry with the worker’s owncomm/lstartfingerprint at discovery time, and a live PID whose fingerprint no longer matches (the worker exited and the OS reused its PID) is treated as dead too — otherwise such an entry would pass the signal-0 probe forever and never be swept. An entry whose worker is still alive and still the same worker is left alone — that invocation is still in flight and R2 will reap it — so the sweep only ever acts on orphans that genuinely escaped R1/R2, including ones left behind by a prior engine run or orphaned by worker-PID reuse. -
Registry-independent worker-session sweep (#1814): every path above starts from a registry entry, so a descendant that was never recorded — spawned within the last
descendantScanIntervalbefore its worker exited, or during a transientpsfailure — is invisible to all of them, and nothing would ever reap it.runClaudetherefore also writes a durable worker record (.fabrik/state/workers.json,engine/worker_registry.go) synchronously right aftercmd.Start(), when the worker’s PID belongs to exactly one process, capturing itscomm/lstartfingerprint (retried in the background if that first lookup fails).engine/worker_sweep_unix.gothen finds orphans by enumerating live processes and callingunix.Getsidon each: any process whose SID equals the PID of a recorded, confirmed-dead worker is reaped. Ownership is by session lineage alone — nocomm/args allow-list — so an orphaned Bash-tool shell with zero children is reaped like any test binary. It runs (a) at invocation end, immediately afterreapTrackedDescendants, using a fresh, uncached process scan (a cached snapshot could predate the racing descendant), rescanning briefly (≤2s) to catch members still visible afterSIGKILL; and (b) from the proc-janitor (see state-machine.md §11.7), reading every record from disk so an orphan from a previous engine run is still reapable. The per-descendant registry stays as the fast path; this is additive.A process is signalled only when it is provably a descendant of a dead recorded worker — a dead session leader alone is never enough, and no record means no kill. A worker is “dead” when its PID is not alive, or when the live process at that PID has a different
lstartthan recorded (a recycled PID). In the recycled case SID equality alone would also match the new holder’s descendants, so a member is reaped only if it started at or after the dead worker and strictly before the current holder; equal or unparseable timestamps skip. Every ambiguity fails open: a fingerprint lookup error, an empty recorded fingerprint while the PID is alive, or a scan error signals nothing and keeps the record. Each candidate is re-verified (fresh fingerprint,Getsidstill equal) immediately before a single-PIDSIGKILL, logged as[#N kill] sending SIGKILL to PID <pid> (<comm>) — orphaned session member of dead worker PID <W> (reason=session_sweep_invocation_end|session_sweep_periodic). A record is pruned only once the worker is confirmed dead and two consecutive successful scans find no live member and nothing was skipped. See ADR 1814.
Kill scope: both reap paths signal the descendant’s own PID directly (syscall.Kill(pid, SIGKILL)), not a process group — matching the worktree-teardown reaper’s own killWorktreeProcess precedent.
Known gaps (see ADR 1798 Consequences): a descendant that calls its own setsid() is not caught (its SID permanently diverges from the worker’s — this is the cwd-rooted reaper’s domain instead); a detached descendant that itself forks further descendants is reaped only at the recorded leaf, not recursively; a descendant that was never recorded is invisible to the registry paths above (closed by the worker-session sweep, #1814, for any worker that has a record). Still not covered by the worker-session sweep (ADR 1814 Consequences): orphans that predate worker records, other Fabrik instances’ orphans, and — by design, failing open — a member whose lookups are inconclusive or whose recorded worker fingerprint could not be captured while its PID is alive.
Worktree Teardown Process Reaping
(Complementary to, not superseded by, “Session-Scoped Descendant Reaping” above — the two catch different failure classes: this one is cwd-based and runs only at worktree removal; the other is session-based and runs on every invocation end plus a periodic backstop. See ADR 1798.)
The process-group SIGKILL above is PGID-scoped: it can only reach descendants that stayed in the worker’s process group. A descendant that calls setsid() — exactly what Claude Code’s background-bash tool does to keep a backgrounded process (e.g. npm run dev) alive across tool calls — leaves the worker’s process group entirely, taking on a fresh PGID equal to its own PID. kill(-workerPGID, SIGKILL) cannot reach it, so it survives Claude’s exit and outlives the worktree directory it was started in.
reapWorktreeProcesses (engine/reaper_unix.go) closes this gap with a cwd-rooted reaper, run immediately before every worktree directory removal: it enumerates live processes whose current working directory is the worktree path or a subdirectory of it — regardless of process-group membership — and sends SIGKILL to each. It is a package-level function (not a WorktreeManager method), so it can be called from contexts with no WorktreeManager instance available.
Platform implementations:
- Linux: scans
/proc/*/cwdsymlinks and prefix-matches each against the worktree directory. It makes no external call and spawns no process, so there is nothing to bound. Immediately before killing a match, it re-reads/proc/<pid>/cwdto close the TOCTOU window in which the pid could have been recycled for an unrelated process between enumeration and kill. - macOS: runs
lsof -a -d cwd -n -P -Fpcn, which inspects only each process’s cwd file descriptor (not every open fd, unlikelsof +D) — kept fast on the synchronous teardown path even for anode_modules-heavy worktree. The call is bounded: it runs under a 10-second timeout (worktreeReapLsofTimeout, ADR 1805), because it is made under the per-repo worktree mutex and an unboundedlsofwould stall every worktree operation for that repo. On timeoutlsofis killed, its partial output is discarded, one[#N kill] warn: worktree process reap: lsof timed out after 10s for <worktree>; skipping reapline is logged, and the reap is skipped — worktree removal proceeds. macOS has no/proc-equivalent cheap re-check primitive, so the same TOCTOU window is accepted rather than mitigated here. - Windows: no-op (
engine/reaper_windows.go), matchingkillProcGroup’s existing Windows precedent.
Both platform paths resolve symlinks in the worktree directory before matching (filepath.EvalSymlinks), since /proc/*/cwd and lsof’s reported path are both fully-resolved real paths (e.g. macOS resolves /tmp → /private/tmp), which would otherwise defeat a literal prefix match.
Each kill is logged via the existing [#N kill] ... convention: [#N kill] sending SIGKILL to PID <pid> (<comm>) rooted in <wtDir> (worktree cwd cleanup), alongside the (grandchild cleanup) PGID-kill log line so both are visible in the same log stream. Enumeration and kill failures (missing lsof, a /proc/<pid>/cwd read racing process exit, permission errors) are always non-fatal: logged as a warning, never blocking the caller’s own worktree removal.
Call sites (every path that removes a worktree directory):
WorktreeManager.CleanupWorktree— issue worktree teardown (Done-stage teardown, periodic janitor)WorktreeManager.ensureTrainWorktreeFromRef’s stale-worktree removal — crash-recovery cleanup before a merge-train trial worktree is recreated under the same nameWorktreeManager.CleanupTrainWorktree— merge-train trial worktree teardown- The worktree janitor’s
os.RemoveAllfallback (engine/janitor.go), which fires when noWorktreeManageris available for the repo (bare repo missing) — this is why the reaper is a free function rather than aWorktreeManagermethod
Documented residual risks (see ADR 063):
- PID-reuse TOCTOU on macOS: unlike Linux, there is no cheap re-check between enumeration and kill, so a small window exists where a recycled pid could receive an unintended SIGKILL.
- A timed-out reap leaves orphans on macOS: on a very loaded host
lsofcan exceed its 10-second bound; the reap is then skipped (with the warning above) and asetsid‘d descendant rooted in the worktree is not killed by this path (ADR 1805). chdir‘d descendants are not caught: this reaper is cwd-based by design (matching the issue’s reported failure mode); a process that opened a file in the worktree and thenchdir‘d elsewhere is out of scope and will still leak.
Progress Baseline Snapshot
Immediately before the first invocation, snapshotBaseline captures observable progress state for this stage:
| Stage | Baseline fields captured |
|---|---|
| Implement | gitHeadSHA — git rev-parse HEAD in the worktree |
| Review | gitHeadSHA + resolvedThreadCount — LinkedPRResolvedThreadCount from the poll cycle’s FetchItemDetails |
| Validate | commentCount — len(item.Comments) from the poll cycle’s FetchItemDetails |
| All others | (empty — no extension possible) |
The baseline is purely in-memory; it is lost on engine restart (an acceptable risk per ADR 030).
Turn-Limit Extension Loop
The e.claude.Invoke() call runs inside an extension loop. On each iteration:
opts.MaxTurnsOverrideis set tocurrentBudget(first iteration:stage.MaxTurns, or2 × stage.MaxTurnsiffabrik:extend-turnsis present).opts.FabrikRoot/opts.PRNumberare also re-resolved here viaresolveFabrikEnvOpts(#1288), using the currentresumevalue — see “Worker Environment: Invocation Facts” above.- Claude is invoked. Output is appended to
totalOutput; usage is accumulated intototalUsage. - Turn-limit check:
!completed && err == nil && stage.MaxTurns > 0 && invUsage.TurnsUsed >= currentBudget. - If turn limit was NOT hit (or stage completed), exit the loop.
- If
totalMultiple >= 3(hard cap), exit the loop (fail as turn-limit). - Call
detectProgress. If progress →totalMultiple++, setcurrentBudget = stage.MaxTurns, setresume = true, log[#N extend-turns], loop. - If no progress or progress check fails → exit the loop (fail as turn-limit).
detectProgress per stage:
- Implement:
git rev-parse HEADin worktree; progress if SHA changed. - Review:
git rev-parse HEAD; if SHA same →FetchItemDetailsre-fetch; progress ifLinkedPRResolvedThreadCountincreased. One GraphQL call only when no new commits. - Validate:
FetchItemDetailsre-fetch; progress iflen(Comments)increased. One GraphQL call per check. - All others: return
falseimmediately.
Output accumulation: Each --resume invocation produces only the delta output for that session continuation. The engine concatenates all invocations’ output before posting. The empty-output check (strings.TrimSpace(output) == "") applies to the accumulated total.
Deferred WIP commit and push: The commitWIP and PushBranch calls happen AFTER the extension loop completes, not between invocations. This preserves worktree state across extensions.
Stats footer: After the loop, usage.MaxTurns is set to totalMultiple × stage.MaxTurns, so the stats line reflects the total budget (e.g., used 130/150 turns).
Post-Run Boundary Audit
After the extension loop completes, a cross-repo ref audit runs for non-read-only, non-unrestricted stages when e.cfg.WorktreeBoundaryAudit is true (default: false). The audit detects git-layer mutations in any repository other than the active worktree’s own repo.
Default off (pending #808): The audit is disabled by default because routine
git fetch originin sibling bare clones produces false-positive violations. Enable it withworktree_boundary_audit: truein.fabrik/config.yaml,--worktree-boundary-auditon the CLI, orFABRIK_WORKTREE_BOUNDARY_AUDIT=true.
How it works:
- Pre-audit snapshot (taken immediately before the extension loop): For each registered
WorktreeManagerin the engine, rungit for-each-ref --format=%(refname) %(objectname) refs/heads/ refs/tags/in its bare-clone directory. Capturerepo → (refname → SHA)for locally-authored refs only.refs/remotes/is intentionally excluded — remote-tracking refs are passively-observed upstream state updated bygit fetchfor reasons unrelated to Claude’s activity; including them would cause false-positive violations when a concurrent fetch updates a sibling bare clone. Skipped entirely whenWorktreeBoundaryAuditisfalse. - Post-audit snapshot (taken immediately after the extension loop): Same operation. Skipped when the pre-audit snapshot was not taken (i.e.,
WorktreeBoundaryAuditisfalse). - Violation check (
crossRepoViolations): Compare before/after for every repo key except the active issue’s repo. Any ref that is new or has a changed SHA is a violation.
On violation:
[#N audit]log line names the stage and count of mutations.- A comment is posted on the issue listing the specific refs mutated (names and SHAs). No automatic cleanup.
fabrik:pausedis added soitemNeedsWorkskips the issue until the user investigates.stage:<name>:failedlabel is added.StageAttemptedis recorded (cooldown applies).MaxRetriesis NOT consumed — violations require human investigation, not auto-retry.EnginePausedis recorded in the store so thatclearFailedStagefires (removing the failed label and resetting state) when the user removesfabrik:paused.- The stage returns without posting output or advancing to the next stage.
- To retry: remove
fabrik:paused. The engine will clearstage:<name>:failedand re-run the stage on the next poll.
No violation: The audit is silent. Stage proceeds to normal output posting and completion.
Bypass: Skipped when WorktreeBoundaryAudit is false (default), stage.ReadOnly == true, or fabrik:unrestricted is present on the issue.
Limitation — Bash shell writes: The audit checks git refs, not the filesystem. A Claude session that writes files outside the worktree via raw shell commands (e.g., cat > /other/path) would not be caught unless those files were also committed and pushed to another repo. The Edit/Write path restriction (Phase 2) is the primary mitigation for direct file writes.
Limitation — unregistered repos: Only repos in worktreeManagers at the time of the snapshot are audited. Repos that Claude navigated into but that are not registered in Fabrik’s managed set are not detected.
Phase 4: Post-Stage Handling
Output Parsing
Three JSON formats supported (tried in order):
- Single result object:
{"result": "...", "session_id": "..."} - JSON array:
[{"type":"system",...}, ..., {"type":"result","result":"..."}] - NDJSON (stream-json): One JSON object per line
Empty result with valid session ID is accepted (max turns hit — Claude was mid-tool-use).
If parsing fails: error message posted instead of raw output. Full output in log files.
Assistant-Turn Artifact Harvest (R1/R2)
The CLI’s terminal result field is whichever text the agent emitted in its very last turn — not necessarily the same turn that produced the stage’s real artifact. If the agent makes one more tool call after emitting its output (a common shape: emit the Plan/Research/Review content, then run one more verification command before signaling done), result ends up carrying only the wrap-up, sometimes nothing but the bare FABRIK_STAGE_COMPLETE marker itself. Before this harvest existed, that wrap-up was trusted verbatim and the real artifact was silently discarded — the stage was still labelled stage:<name>:complete, with no comment and no .fabrik-context/stage-<Name>.md behind it, discovered only when a later stage found nothing to read (#1632, #1782).
interpretClaudeResult (engine/claude.go) detects this via artifactMissingOnComplete: FABRIK_STAGE_COMPLETE is present, FABRIK_NO_WORK_NEEDED is not (see the exclusion below), and the text carries nothing beyond Fabrik’s own bare control-marker lines (hasArtifactContent — the same five markers FABRIK_STAGE_COMPLETE/FABRIK_BLOCKED_ON_INPUT/FABRIK_NO_WORK_NEEDED/FABRIK_SUMMARY_BEGIN/FABRIK_SUMMARY_END that Marker Stripping below removes before posting). When that fires, extractLastSubstantialAssistantTurn re-scans the raw NDJSON output (the same forEachAssistantText line-scanner that already powers the narrower FABRIK_ISSUE_UPDATE_BEGIN-specific fallback immediately preceding it in the function) for the last assistant turn whose text is non-empty after that same stripping — not “the turn containing the marker,” since the reported case’s marker-bearing turn contains only the marker itself. The recovered turn is prepended to the CLI’s own result text, so the marker itself is always preserved for the completion check that follows.
This only ever adds content ahead of an already-thin result — an ordinary completion where the artifact is already present in result never triggers the scan (hasArtifactContent(resp.Result) is already true) and is byte-identical to before this existed.
No-Artifact Completion Guard (R3)
FABRIK_STAGE_COMPLETE being present is not, by itself, sufficient evidence that the stage produced anything — the harvest above can come back empty too (no assistant turn had any content beyond control markers). artifactMissingOnComplete gates every place interpretClaudeResult would otherwise convert “marker present” into completed = true (the marker-found-despite-a-trailing-error path, and the ordinary clean-exit path); when it holds, completed is forced false and the invocation falls through to the stage’s normal non-completion handling instead — for a stage dispatch, that is the existing retry/escalate machinery (finalizeStageOutcome, engine/item.go): the attempt counts against MaxRetries (mirroring the pre-existing degenerate-output guard, #1065 — the invocation did real work, so this is not exempted the way a usage-limit or tools-denied exit is), a one-time first-detection comment (“no artifact harvested”) posts on the first occurrence, and the eventual escalation at MaxRetries names this cause distinctly from the unrelated bare-file-reference one. For the comment-review path (processComments/publishCommentOutput, engine/comments.go), completed = false flows through unchanged into the same no-progress/no-op-cycle handling comment processing already has — no separate guard was needed there, since both invocation paths bottom out in this same interpretClaudeResult call.
Exclusion: a stage that legitimately produces no artifact — FABRIK_STAGE_COMPLETE co-occurring with FABRIK_NO_WORK_NEEDED — is never treated as this defect. Both the harvest scan and the completion guard check !CheckNoWorkNeeded(text) before doing anything, so a no-work-needed completion’s established “nothing posted, stage skipped straight to Done” behavior is unaffected — including never risking a scan pulling unrelated earlier-turn reasoning into it.
This does not change or duplicate recoverMissingPlanComment (#982): that recovery targets a stale-cache race (a fresh Plan comment existing on GitHub but not yet visible in item.Comments), a different root cause producing the same symptom (stage:Plan:complete present, no Plan comment). This guard targets the harvest defect directly, at the point the artifact is first assembled, so new occurrences of the #1632 shape should no longer reach that recovery path at all.
See ADR-1782.
Issue Body Update
Before posting output, checks for FABRIK_ISSUE_UPDATE_BEGIN/END markers:
- When present, the issue body is updated unconditionally
- By convention, only the Specify stage produces these markers
- Markers are always stripped from output before posting
Marker Stripping
All Fabrik markers are stripped from output before posting:
FABRIK_STAGE_COMPLETEFABRIK_BLOCKED_ON_INPUTFABRIK_SUMMARY_BEGIN/FABRIK_SUMMARY_ENDFABRIK_ISSUE_UPDATE_BEGIN/FABRIK_ISSUE_UPDATE_END
Output Posting
If post_to_pr: true (Implement, Review, Validate):
- Detailed output posted on the linked PR
- Brief summary (from
FABRIK_SUMMARYmarkers) posted on the issue - Falls back to issue if no PR found
Otherwise (Specify, Research, Plan):
- Full output posted directly on the issue as a stage comment
Spawn Receipt Note
If the Plan stage’s posted output contains N > 0 well-formed spawn blocks — as counted by ParseSpawnBlocks, never by string-matching the marker text — a deterministic note is appended stating that N sub-issues are declared and will be created when the parent advances to the Implement stage. The note is gated to stage.Name == "Plan", matching exactly what preImplement itself reads (findStageComment(item.Comments, "Plan"), engine/spawn.go): a note on any other stage’s comment would promise a spawn that mechanism never performs — for example, a later stage’s Claude quoting a spawn block back verbatim from its own context (later stages receive the Plan comment through .fabrik-context/stage-Plan.md). The gate applies to both output-posting paths above. The note is folded into the same footer value that also carries the stats footer below, computed once and threaded unchanged through every posting path — so it renders identically whether output goes straight to the issue or through post_to_pr. N == 0, or a non-Plan stage, produces no note and byte-identical output to before this existed. See ADR-048 and #1338.
Token Stats Reporting
Every invocation reports token usage in two places: an operator-facing log line (e.logf(..., "stats", ...), emitted from finalizeStageOutcome in engine/item.go and from comment-processing finalization in engine/comments.go) and a human-facing footer appended to the posted comment/PR output (formatStatsFooter, engine/claude.go). Both are built from the same TokenUsage struct, which carries four independent token counts per invocation: InputTokens (raw, uncached input), OutputTokens, CacheReadTokens (context served from Claude’s prompt cache), and CacheCreationTokens (new context written to the cache this turn).
Why cached input dominates. With prompt caching active — which it always is once a conversation has any history — InputTokens alone is structurally near-zero: almost all context is served from cache, not resent as fresh input. Reporting only InputTokens (as Fabrik did before this reporting was added) makes every stage look like it consumed 0k input regardless of actual cost, and a short resumed invocation that replays a large accumulated context looks artificially cheap. Cache tokens are not a rounding error; on long-running or resumed invocations they routinely dwarf raw input by two or three orders of magnitude.
Log line format (formatStatsLogLine, shared by item.go and comments.go) uses raw, unscaled numbers in the same key: value | key: value convention as the pre-existing cumulative line at engine/poll.go:1172:
used 41/250 turns | in: 476 | out: 171009 | cache_read: 25003551 | cache_write: 993820
Footer format (formatStatsFooter, posted into the GitHub comment/PR a human reads) is k/M-scaled and leads with an “effective input” total (InputTokens + CacheReadTokens + CacheCreationTokens) so a reader can’t mistake the raw figure for total input consumed, with a raw/cache-read/cache-write breakdown in parentheses when cache activity is non-zero. Cache reads and cache writes are broken out separately rather than folded into one “cached” figure, since Anthropic prices them differently per token:
Used 41/250 turns, 26.0M input (476 raw + 25.0M cache-read + 993k cache-write) / 171k output tokens.
Emptiness guards. Both formatters return an empty string — suppressing the line entirely — only when all five fields (TurnsUsed, InputTokens, OutputTokens, CacheReadTokens, CacheCreationTokens) are zero. An invocation with meaningful cache activity but zero raw input/output still reports, since caching means that combination is a real, common case rather than a signal of an empty invocation.
TokenUsage.InputTokens keeps its existing meaning (raw uncached input) everywhere, including in internal/itemstate and the poll-level cumulative log — this is a display change only, not a change to what the field means or how cost (CostUSD) is calculated.
Comments Marked as Seen
After a stage runs, any pre-existing user comments get a rocket reaction via markCommentsSeenByStage. They were included in the prompt as context and should not trigger the awaiting-input unblock logic on subsequent polls.
Completion Path
When FABRIK_STAGE_COMPLETE is detected (regardless of Claude’s exit code — as of v0.0.26, a non-zero exit is treated as a warning, not a failure, when the marker is present):
- Lock released (
fabrik:locked:<user>andstage:<name>:in_progressremoved) - Retry tracking cleared
- Draft PR created (if
create_draft_pr: true) - PR marked ready (if
mark_pr_ready_on_complete: true) stage:<name>:completelabel added- Validate only:
ValidateCompletedAtSHAmutation applied with the worktree’s currentHEADSHA (git rev-parse HEAD). This records the exact post-commit SHA so the SHA-invalidation scan (docs/state-machine.md§2.16) can detect future SHA changes (force-push, external commits) and automatically re-enter Validate. On error (e.g. bare git call fails), the SHA is left empty — the SHA-invalidation scan’s FR-5 guard treats empty completion SHA as “do nothing,” preserving safe degraded behavior. - Auto-advance to next stage (if
auto_advance: trueor globalyolo)
Validate + yolo: At Validate completion, if the issue carries fabrik:yolo (and not fabrik:cruise), and the Validate stage’s review gate is either not opted in (wait_for_reviews unset) or already satisfied — including, when review_authority: authoritative is set, no outstanding CHANGES_REQUESTED review (see docs/state-machine.md §6.1.1) — Fabrik calls enablePullRequestAutoMerge on the linked PR and applies fabrik:auto-merge-enabled rather than calling MergePR directly. GitHub then merges the PR atomically once branch-protection requirements are satisfied. The post-Validate convergence monitor (checkAutoMergeConvergence) tracks the PR in subsequent poll cycles until it reaches a terminal state or the convergence budget expires. If enablePullRequestAutoMerge fails (PR already in a terminal GitHub state — CLEAN, UNSTABLE, or similar), Fabrik falls back to a direct MergePR call — the only merge path with no independent mergeable_state gate upstream of it (reached whenever wait_for_ci: false). MergePR self-gates regardless of enforce_admins: it refuses to merge unless mergeable_state ∈ {clean, unstable}, returning the distinct sentinel gh.ErrNotMergeableCI when a required check is still blocked or pending (ADR-072). This is not a merge conflict — fabrik:rebase-needed is not applied and no rebase cycle is consumed; the item simply retries via a full Validate re-dispatch on the next poll. See docs/state-machine.md §5.4–5.5 for full details.
Note: fabrik:extend-turns is not removed here. It persists across all intermediate stages and is removed only during the Done stage’s cleanup path (see Cleanup Stage below).
Blocked-on-Input Path
When FABRIK_BLOCKED_ON_INPUT is detected (and Claude ran without error):
fabrik:paused+fabrik:awaiting-inputlabels added- Lock released
- Retry count NOT incremented, no
stage:<name>:failedlabel - Issue waits until user comments (auto-detected, see Comment Processing)
Incomplete Path (No Marker)
- Partial-progress commit (unless read-only):
git add -A && git commit -m "chore: partial <StageName> stage progress (incomplete)". A read-only Specify stage withpersist_spechas already committed its spec file itself (including on a blocked round), so nothing is left for this step. - Branch pushed
- Cooldown timer:
pollSeconds * 10seconds - Lock held through cooldown
- Stall detection (
detectAndArmStallHint, #1146, #1767): this attempt’s turn usage is compared against the previous incomplete attempt’s. A clean incomplete predecessor — capped or not — followed by a strictly-declining, still-incomplete, uncapped attempt arms a one-shot-per-episode corrective hint — consumed by the next invocation of this stage, injected into its prompt viaInvokeOptions.CorrectiveHint— and posts an informational comment. Seedocs/state-machine.md§7.10 for the full detection and injection rule. - Retry count incremented; after
max_retries:fabrik:paused+stage:<name>:failed, lock released
Claude Usage-Limit Path
A fourth outcome, distinct from Completion/Blocked-on-Input/Incomplete above: Claude exits non-zero
because the account’s usage limit was hit, not because the stage genuinely failed.
interpretClaudeResult (engine/claude.go) detects this structurally, from the CLI’s own parsed
result object only — never from anything the assistant wrote: classifyUsageLimitExit checks
resp.TerminalReason == "blocking_limit" (the same terminal_reason field the turn-cap check
consults) or resp.TerminalReason == "api_error" with api_error_status == 429 (the CLI’s other
session-limit shape, #1811 / ADR-1811; other api_error statuses stay transient and never trigger it),
and only when a result object actually parsed. The result text is never matched. When Claude exited non-zero without a
FABRIK_STAGE_COMPLETE marker, no turn cap, and TerminalReason matches, it returns a
*claudeUsageLimitError sentinel in place of the generic error; an unparseable-JSON invocation is
never classified as a usage-limit exit by any means. finalizeStageOutcome classifies it via
errors.As after stash-restore (which must run regardless of outcome) but immediately following the
engine-shutdown guard — before the normal claudeRan/retry classification logic runs — and routes to
handleUsageLimitExit:
StageAttemptedrecorded — the normal cooldown (pollSeconds * 10) applies, so the item does not retry on the very next poll and hammer the limit in a tight loop.- Retry count is not incremented — the stage never ran, so this does not consume
max_retries. - If
fabrik:claude-limitis absent, an explanatory comment naming the condition is posted and the label is added — gated on the label’s own absence so a repeated hit within the same episode does not repost the comment. Neitherfabrik:pausednorstage:<name>:failedis applied. The comment names the reset time ((resets <local time>)) only when it was sourced from the CLI’s structuredunifiedWindows.*.resetsAt(ADR-1815); it is never parsed from prose, and is omitted on the one-hour fallback. - No partial-progress commit, no branch push, no
markCommentsSeenByStage— nothing was produced. - Lock released.
fabrik:claude-limit clears per-issue on the next invocation that is not itself classified as a
usage-limit exit (success, blocked-on-input, incomplete/genuine-failure, or PR-creation failure alike),
and account-wide via settleClaudeLimitLabelSweep, a per-poll settle scan that removes it from every
open item once the account-wide usage-limit hold has lifted — so an issue that is paused, blocked, or simply
never redispatched no longer keeps the label indefinitely. An operator can also end an active usage-limit hold
early, without restarting the engine, by applying fabrik:clear-claude-limit to any open board item;
settleClaudeLimitClearRequests reads it each poll and clears the usage-limit hold. See
docs/state-machine.md §7.3, ADR-1119, and
ADR-1183 for the full rationale, including
why this reuses the same StageAttempted-without-StageRetryIncremented split as the Post-Run Boundary
Audit above (with the opposite pause/fail outcome — a usage limit is transient and self-resolving, a
boundary violation is not).
apiKeyHelper Detection Path (Worktree)
A fifth outcome, checked in runInvocationWithExtension immediately alongside the account-wide
usage-limit hold gate above — before InvokeOptions is built or the stall hint consumed, and
before Claude is ever invoked. findAPIKeyHelper (engine/startup.go, shared with the engine-startup
preflight — see “Worker Environment: Anthropic Auth Namespace Scrub” above) checks the worktree’s own
.claude/settings.json and .claude/settings.local.json (mirroring the startup preflight’s file
coverage for the user/project layers): a repo-resident setting Fabrik cannot see at engine startup,
since the worktree doesn’t exist yet (#1346, R13). If either sets apiKeyHelper, the invocation is skipped and
*apiKeyHelperDetectedError is returned in place of invoking Claude at all; finalizeStageOutcome
classifies it via errors.As (alongside the usage-limit check) and routes to
handleAPIKeyHelperDetected, which mirrors handleUsageLimitExit exactly:
StageAttemptedrecorded — the normal cooldown applies, so the item does not retry on the very next poll.- Retry count is not incremented — the stage never ran.
- If
fabrik:api-key-helper-detectedis absent, an explanatory comment naming the offending file is posted and the label is added — gated on the label’s own absence, matchingfabrik:claude-limit’s non-spamming behavior. Neitherfabrik:pausednorstage:<name>:failedis applied. - No partial-progress commit, no branch push — nothing was produced.
- Lock released.
fabrik:api-key-helper-detected clears on the next invocation that is not itself classified as a
usage-limit exit or an apiKeyHelper detection (the same unconditional label-clear site as
fabrik:claude-limit, later in finalizeStageOutcome) — a human removing apiKeyHelper from the
worktree’s .claude/settings.json and letting the next poll reach Claude successfully is enough to
self-resolve; no manual label removal is required. Unlike fabrik:claude-limit, there is no
account-wide settle sweep for this label — the condition is inherently per-worktree, not
account-wide. See ADR-1346.
Toolchain Declaration Drift Detection (Worktree)
A sixth check, checkToolchainDrift (engine/toolchain.go), runs at both invocation entry points —
runInvocationWithExtension (stage dispatch, immediately after the apiKeyHelper check above) and
processComments (comment review, right after context files are written) — closing the gap the
apiKeyHelper check leaves on the comment-review path (#1786 R5 explicitly names both cycles).
Unlike every check above, this one never skips the invocation. A long-lived daemon inherits PATH
once, from whatever shell hosted it at startup; a worktree can declare a toolchain version
(.nvmrc, package.json engines.node, go.mod’s toolchain/go directive, or .tool-versions)
newer than what that inherited PATH resolves, and nothing before this check ever notices — workers
keep running the stale toolchain silently, indefinitely, restarting fabrik itself does not help
(it re-inherits the same PATH from the same parent shell). Re-resolving PATH or driving a version
manager (nvm use, asdf install, …) is explicitly out of scope; this is a warn-and-continue signal,
not a gate, so it returns nothing — finalizeStageOutcome is never involved, StageAttempted/
StageRetryIncremented bookkeeping is untouched, and the invocation proceeds exactly as it would have.
detectToolchainDriftreads whichever declaration files exist inworkDirand, for each comparable declaration, resolves the corresponding binary (node --version/go version) viaexec.LookPathagainst the daemon’s own inheritedPATH. A binary entirely absent fromPATHis “cannot compare” (a distinct, pre-existing condition — not this check’s concern), never a mismatch. A successful resolution is cached process-wide per tool for the daemon’s lifetime (resolveToolVersion) — since the entire premise of the bug is that thisPATHis invariant until a restart, a second check for the same tool is a cache hit, not a second shell-out. A resolution failure (subprocess timeout, transient exec error) is never cached, since unlikePATHitself a failure carries no invariance guarantee — caching it would permanently and silently disable detection for that tool after a single bad moment.- Comparison is deliberately narrow and fails closed:
.nvmrc,package.jsonengines.node, and.tool-versionsare compared at major-version granularity only (an alias likelts/*, a complex semver range using||/x/multiple clauses, or any other unparseable content is “not comparable” — silence, never a false-positive warning);go.mod’stoolchain/godirective is compared at major.minor granularity, using minimum-version (>=) semantics, since ago.moddeclares a floor, not a pin (andGOTOOLCHAIN=auto, the Go 1.21+ default, already self-corrects in the common case — this only catches theGOTOOLCHAIN=local/ auto-download-disabled edge case). - On any comparable mismatch, if
fabrik:toolchain-staleis absent: an explanatory comment naming every mismatched declaration (source, declared version, resolved version) is posted and the label is applied — gated on the label’s own absence, so the warning fires once per episode (per issue), not once per invocation (#1786 R5), matchingfabrik:claude-limit/fabrik:api-key-helper-detected. - If no comparable mismatch is found and
fabrik:toolchain-staleis present, the label is cleared — self-resolving once a worktree’s declaration file is fixed (or, in practice, the daemon is restarted with a correctedPATH), exactly likefabrik:api-key-helper-detected. No dedicated settle scan: this is a fire-once informational label, added totransientLifecycleLabelsfor closed-issue sweep.
A daemon/worktree whose resolved toolchain already satisfies every declaration sees no new behavior and no new output at all (#1786 R4) — no comment, no label, no added latency worth noticing beyond a few small file reads and, at most once per tool per daemon lifetime, a version-check subprocess. See ADR-1786.
Branch Pushing
Always pushed after Claude runs (success or failure): git push --force-with-lease -u origin fabrik/issue-<N>
Phase 5: Comment Processing
Comment processing is triggered when new comments from the configured user are found. It runs independently of stage processing — even completed stages can process new comments.
Comment Detection
A comment is “new” if it:
- Is authored by the configured user
- Is not in the in-memory processed set
- Doesn’t start with
🏭 **Fabrik(skip Fabrik’s own output) - Doesn’t have a ROCKET reaction (durable “processed” marker)
Comment Processing Flow
- Eyes reaction added to all new comments
fabrik:editinglabel added- Worktree prepared (fresh rebase, not a retry)
- Context files written (prior stages + current stage)
- Claude invoked with
comment_skill(or default comment prompt)- Always resumes existing session
- Output processed:
FABRIK_ISSUE_UPDATEmarkers: applied unconditionally when present, then stripped- All Fabrik markers stripped
- Stage comment rewritten (or created) via
findStageComment+UpdateComment - Exception:
post_to_prstages post a new “(comment review)” comment on the issue
fabrik:editinglabel removed- Rocket reaction added to processed comments
- Completion check: If
FABRIK_STAGE_COMPLETEwas in the output,handleStageCompletefires — the stage completes directly from comment processing without needing an extra stage invocation
Awaiting-Input Auto-Resume
When a user comments on an issue with fabrik:paused + fabrik:awaiting-input:
itemMayNeedWorklets it through (special exception for awaiting-input)itemNeedsWorkchecksfindNewComments— returns true only if new comments existprocessItemcallsunblockAwaitingInput→ removes both labels, clears cooldown- Routes to
processCommentswith the new comments - Comment processing can signal
FABRIK_STAGE_COMPLETEto complete the stage immediately
Stage Comment Rewriting
For non-post_to_pr stages, comment processing rewrites the existing stage comment:
findStageCommentscans for the most recent comment matching🏭 **Fabrik — stage: {Name}**- If found:
UpdateCommentreplaces its body - If not found:
AddCommentcreates a new stage comment
For post_to_pr stages, comment processing posts a new “(comment review)” comment on the issue (not the PR).
Key Differences: Stage Run vs Comment Processing
| Aspect | Stage Run | Comment Processing |
|---|---|---|
| Session | Fresh or resume on retry | Always resume |
| Worktree update | Skip on retry | Always rebase |
| Completion | Checked, honored | Checked, honored |
| Blocked-on-input | Checked, honored | Not checked |
| Issue body update | When markers present | When markers present |
| Output destination | Stage comment or PR | Rewrite stage comment or new issue comment |
| Lock | fabrik:locked:<user> |
fabrik:editing |
| Reaction flow | Comments marked seen (rocket) | Eyes → editing → rocket |
Phase 6: Cleanup Stage (Done)
The Done stage (cleanup_worktree: true) is terminal:
- No Claude invocation, no lock, no in-progress label management
- Skipped entirely if
stage:Done:completeis already present - Removes worktree directory when it exists (for non-PR items)
- Adds
stage:Done:completelabel - Removes
fabrik:extend-turnslabel if present (this is the designated removal site; the label is not removed during any earlier stage completion) - Respects
fabrik:paused(skips if paused)
Phase 6b: Unmanaged Stage (Backlog)
An unmanaged: true stage (the default Backlog, stages/examples/backlog.yaml) declares a
“parking column” Fabrik recognizes but runs no workflow for:
- No Claude invocation, no lock, no in-progress label management — same as a cleanup stage, but no worktree action either (nothing to clean up; the item never had one)
itemMayNeedWorkanditemNeedsWorkboth returnfalsefor it, so it is never dispatched- The yolo/cruise catch-up loop skips it, so it is never auto-advanced out of the column
runProbeAndDeepFetch’s stage-membership guard treats it as unconfigured for deep-fetch purposes — items sitting in an unmanaged column are neverFetchItemDetails-fetched, even though a matchingStageexists (preserving the pre-existing Backlog deep-fetch avoidance)- Items sit here until a human moves them to a real stage’s column
Markers Reference
| Marker | Direction | Purpose | Where Checked |
|---|---|---|---|
FABRIK_STAGE_COMPLETE |
Claude -> Engine | Stage finished successfully. Intended behavior: honored even on non-zero Claude exit in both stage runs and comment processing — the non-zero exit is recorded separately (as an Err/ExitOK field on the invocation record) and does not veto completion; engine logs a warning. Current reality: stage runs honor this marker on non-zero exit, but comments.go:168 currently gates completed on err == nil, so comment processing does NOT honor FABRIK_STAGE_COMPLETE when the process exits non-zero (regression introduced by commit 5acf2609). A code fix restoring parity is tracked in a separate follow-up PR. |
Stage runs (both exit paths); comment processing (non-zero exit vetoes marker — known divergence) |
FABRIK_BLOCKED_ON_INPUT |
Claude -> Engine | Stage needs user input | Stage runs only |
FABRIK_ISSUE_UPDATE_BEGIN/END |
Claude -> Engine | Updated issue body | Stage runs AND comment processing |
FABRIK_SUMMARY_BEGIN/END |
Claude -> Engine | Brief summary for issue | Stage runs with post_to_pr: true |
Labels Reference
| Label | Set by | Purpose |
|---|---|---|
fabrik:locked:<user> |
Engine | Lock during stage processing |
fabrik:editing |
Engine | Lock during comment processing |
fabrik:paused |
Engine or User | Pause processing |
fabrik:awaiting-input |
Engine | Paused waiting for user comment (auto-resumes) |
stage:<name>:in_progress |
Engine | Stage actively running |
stage:<name>:complete |
Engine | Stage completed successfully |
stage:<name>:failed |
Engine | Stage hit max retries |
model:<name> |
User | Override Claude model |
Stage YAML Options
name: Research # Required: matches board column name
order: 2 # Required: processing priority (lower = earlier)
skill: fabrik-stages:research # Plugin skill name, <plugin>:<skill> (recommended)
comment_skill: fabrik-stages:research-comment # Plugin skill for comment processing
prompt: | # Inline prompt (legacy, used when skill not set)
...
comment_prompt: | # Inline comment prompt (legacy)
...
model: sonnet # Optional: Claude model
max_turns: 50 # Optional: turn limit per invocation
comment_max_turns: 15 # Optional: max turns for comment review (default: min(max_turns, 15))
allowed_tools: # Optional: restrict Claude's tools
- Read
- Grep
read_only: false # Stash/restore worktree (for analysis stages)
post_to_pr: false # Route output to linked PR
create_draft_pr: false # Create draft PR on completion
mark_pr_ready_on_complete: false # Mark PR ready on completion
auto_advance: null # Override global yolo (true/false/null)
cleanup_worktree: false # Terminal stage — remove worktree
holding_stage: false # Engine-managed batch holding pen (e.g. Queued) — no per-item dispatch
unmanaged: false # Parking column (e.g. Backlog) — recognized but never dispatched or auto-advanced
kill_grace:
sigint: 10s # Grace window after SIGINT before SIGTERM (empty = engine default; "0s" = skip SIGINT)
sigterm: 10s # Grace window after SIGTERM before SIGKILL (empty = engine default; "0s" = skip SIGTERM)
completion:
type: claude # Only supported type
Either skill or prompt is required (unless cleanup_worktree, holding_stage, or unmanaged is true — these three flags mark stages that are never dispatched to Claude). When skill is set, the engine sends a directive prompt and the skill is loaded via --plugin-dir.