Operations: Diagnostics and Recovery#
Part of the operations guide: reading the artifacts a run produced, and the playbook for a task parked in manual_action_required.
6. Diagnostics — reading what a run produced#
All artifacts live under <repo>/.worc/logs/<task-id>/. SQLite (<repo>/.worc/state.db) is the authoritative state; the artifacts and ledger are the human-facing index.
logs/
completed.jsonl # append-only ledger: one record per terminal transition
<task-id>/
task.normalized.json # the parsed task
validation_report.json # §19 gate verdict (pass or reject reason)
task.enriched.md # refinement output (if it ran)
plan.md # planning output
current.diff # working-tree diff at the last checkpoint (redacted)
change-overview.txt # its bounded map: changed paths, kinds, line counts, base SHA
publish-error.txt # the redacted git stderr of a failed publish node
hitl/*.json # durable redacted question/approval + recovery handles
prompt-audit/ # who-ran-what + redacted prompt (only when prompt_audit)
<NNNNNN>-<node>[-subNN].md # one step: fenced-json header, then the prompt verbatim
timeline.jsonl # the machine-readable half: one whole record per line
summary.md / summary.json # the whole-task handoff → PR body ({what, summary, [follow_ups], …})
failure_report.json / stuck.md# written when a loop guard or budget stopped the task
recovered-failure_report.json / recovered-stuck.md # the same pair, renamed at `done`
merge/conflicts.md # merge-task's conflict inventory (only on a conflicted merge)
checks/<NNN>.log # each check command's output (redacted)
stages/<node-id>/
history.jsonl # one line per run of this node (run_id, outcome, findings, dir)
run-<node-run-id>/ # one dir per node run — every re-run kept, never clobbered
rendered-prompt.md # the exact node prompt sent (redacted; from the node's role_file)
findings.json / summary.md# review/evaluator findings + summary (this pass)
<node-id>.out.md # the node's output, exposed downstream as {<node-id>_path}
citation.json / dependency_scan.json # checks-node reports (this pass)
stdout.txt / stderr.txt # tool-node redacted streams
<attempt>-<provider>/ # provider attempts for this run
request.json # redacted request (argv, permission profile, context paths) — kept at every level
stdout.log / stderr.log # redacted process output
events.jsonl # redacted provider event stream
result.json # normalized AgentRunResult, incl. effective model/reasoning
publish/terminal-cleanup.json # the terminal base-branch cleanup decision
- The ledger (
completed.jsonl): grep here first — id, title, branch,pr_url,final_status,fix_iterations, terminal cleanup status, and a pointer tofailure_report.jsonwhen stuck. A record of a run that did not finish also names where and why it stopped:stop_node,stuck_loop(null for an infrastructure or operator stop) andstuck_limit(max_fix_cycles,repeated_findings, …, or the first line of the stop reason) — taken from this run's own stop, not from a report path an earlier attempt left on the task row, andnullon adonerecord or an older one. It holds one record per terminal transition — not per run and not per provider attempt: a task that failed, was rerun and then finished has two records, an interrupted run that was resumed has one.attemptis the record's ordinal among the id's terminals (gate refusals excluded), counted when it is appended. Adonerecord withrecovered_from_stuck: trueand arecovered_loopis a task that finished although a loop guard had stopped one of its fix loops (below); read cost and duration fromstate.db, not from a record count. - Why a task is stuck: open
stuck.md(human-readable) /failure_report.json(machine). They record which fix loop and which limit was exhausted, all counter values, the failing node's provider attempts, the last failing check output, the last blocking review findings (rendered instuck.mdas Markdown bullets — severity, gating, reason, paths, proposed fix), and the final diff — plus, for a decomposed task, the failing subtaskkofnand the SHAs already committed. When the stuck node is atoolnode, which never writes a check log,last_check_log_notesays so and points at itsstdout.txtinstead of leaving a barenull.limit_exhaustednames the limit — a budget (max_fix_cycles,max_total_fix_iterations,budget:<from>-><to>; a budget of N allows N fix rounds, so withmax_fix_cycles: 15fifteen rounds run and the sixteenthfail/reworkis the one refused) or one of the two no-progress guards that end a loop before its budget:no_file_change(the agent kept producing output without editing the tree across two consecutive rework charges; in-memory, so it resets on resume) andrepeated_findings(the same node returned an identical non-empty finding set three times in a row — the fixer kept working and the verdict never moved, which usually means the finding asks for something the node cannot change). The second is derived from the recorded verdicts of the current attempt, so a restart does not reset it and a freshrerundoes not inherit it. Neither guard, and no budget, ever parks a non-blocking evaluator (blocking: false): it accepts on whichever limit it reaches first — its ownmax_rework_per_stageor the budget of the loop itsreworkedge charges (or the global backstop) — with a consoleWARNINGnaming that limit (evaluator accepted after exhausting its rework budget (<limit>) — continuing; stage may need follow-up) and a ⚠️ trace, nevermanual_action_required. A task can still reachdoneafter such a stop — continued by an operator, or past an evaluator that does not gate publication — and at the transition todonethe pair is renamedrecovered-failure_report.json/recovered-stuck.md, the task's pointer to it is cleared, andworc listshowsdone (recovered: <loop>). - Audit completeness: SQLite records every
node_runsandprovider_attemptsrow (primary and any fallback), each artifact is registered with a sha256 checksum, and every commit/push/PR carries an idempotency fingerprint so a restart never double-publishes. Everyprovider_attemptsrow carries atask_id, and the constant supervisor layer's own provider calls are recorded too (withnode_run_idNULL, since it is not a flow node) — so a whole-task cost/usage roll-up isSELECT … FROM provider_attempts WHERE task_id = ?(nonode_runsjoin) and includes the supervisor spend. Per-attempt cost (usage_cost) is filled from the provider's own figure when the CLI emits one — Claude's stream-jsontotal_cost_usd, which covers the subagents the attempt ran, and its token counts are read from the same result's per-modelmodelUsageso they cover them too (a CLI that reports nomodelUsagefalls back tousage, the main agent's last turn only). An attempt the orchestrator rejected after it ran (e.g.background_work_lost) still records its usage — the money was spent. Codex emits no dollar figure, so itsusage_coststays NULL, and that gap is named rather than estimated:summary.json'scost_not_accounted,worc status'scost_not_accounted=line and theworc listtable footer say how many attempts and input tokens carry no price. The recorded total sits beside it:summary.json'stask_usage(every node attempt plus the supervisor layer, in total and per provider),worc status'scost=line and the PR body's## Run costsection, all in one wording —$114.11 over 19 provider calls (Claude's reported cost includes its subagents), the note present only when Claude ran. Each row also records the effectivemodel/reasoningthe attempt ran on, so per-model spend is aGROUP BY modelwithprompt_auditoff.artifacts.pathis stored POSIX and relative to the worc home (the lifecycle summary reads../tasks/done/<id>.summary.md), sostate.dbnames no home directory. A hard--force-fullkill SIGKILLs the daemon mid-node, so the running node's own completion write never happens; the next terminal transition (finalize, or a clean pipeline terminal) reconciles those orphans — thenode_runsrow is closed toabortedwith its reason inabort_reasonand the killed attempt gets aprovider_attemptsrow withusage_delta_status='unknown'— so an operator-killed run is auditable, not silently strandedrunning. A node that stops before its provider call (a containment breach, an integrity failure, an unresolvable tool) closes its own row the same way on any path, the merge flow included, because the row's lifetime belongs to the node layer.skip_reasonmeans only awhen-false or disabled node (skipped = 1); it never carries an abort. Both audit tables record the attempt/check's real measured interval (started_at/finished_attaken from the run, not two clock reads at row-write time), so a duration roll-up is meaningful. - Node run vs. attempt:
run-<node-run-id>is reserved in SQLite before the provider starts and changes for every repeated node invocation, including each fixing cycle and recovery run.<attempt>starts at1inside that run and increments only for provider fallback. - Per-run history is preserved, never overwritten: every operator-facing artifact a node produces (the rendered prompt, review
findings.json/summary.md, the generic<node-id>.out.md, checks reports, tool streams) is written under that run'sstages/<node-id>/run-<node-run-id>/dir, next to its provider attempts — so areview → fixing → testing → reviewloop keeps each pass's findings instead of clobbering the last. Readstages/<node-id>/history.jsonlfor the chronological index of a node's runs (one line per run:run_id,outcome,findingscount,dir). This history is exempt fromlogging.artifactspruning and is removed only by an explicitworc logs cleanof the whole task tree. - No secrets anywhere:
request.json, the stdout/stderr/events logs, diffs, SQLite rows, the ledger, and the failure report are all redacted;denied_read_paths(.env,secrets/**) are excluded from agent reads and their values are scrubbed from any sink. -
The agent-facing exchange and its terminal snapshots (WRI-007): while a task runs, its curated agent-readable artifacts (plan, diff and its
change-overview.txt, findings, checks input, HITL packet) live in the in-repo exchange<repo>/.worc-io/<task-id>/. At every terminal status (done/failed/manual_action_required) the orchestrator seals a checksum-verified copy into the private audit at.worc/runs/exchange-seals/<task-id>/seal-<NNNNNN>/(manifest.json+ the sealed files) and then removes the active.worc-io/<task-id>/— so a terminal task never leaves an agent-readable exchange behind for the next task. Debug a terminal exchange from the newestseal-*snapshot (and the raw.worc/logs/<task-id>/audit), not from.worc-io/.rerun --continuerestores the latest verified snapshot before resuming; a freshrerun/restart starts from an empty exchange. If mutation detection (WRI-002) flags an agent-side change, the tree is quarantined instead of sealed under.worc/runs/exchange-quarantine/<task-id>/<NNNNNN>/(withevidence.jsonrecording the expected vs observed manifests pluscreated_at, the mutatingnode_idand the taskattempt; a bundle with nothing to record is not written) and named on the task row inquarantine_refs, which outlives the contamination flag. The daemon's own crash recovery refuses to resume over a contaminated exchange; an operatorrerun --continueclears the flag and continues, logging where the kept bundles are. There is no configuration option that keeps a terminal exchange agent-readable. Seals are evicted automatically for a successful task (logging.clean_runs_on_success) and on demand withworc runs clean; quarantined evidence is removed only byruns clean --include-quarantine. -
Rendered node prompts:
stages/<node-id>/run-<node-run-id>/rendered-prompt.mdis the exact (redacted) instruction the agent received for that run — read it first to confirm arole_fileedit took effect and rendered as intended (a re-running node keeps one per run). This includes the constant supervisor layer's own turns (stages/supervisor/run-<node-run-id>/) — observe/finalize/handoff are audited the same way, even though the supervisor is not a flow node. -
What a node was handed about the change: from the first
workspace-writeedit on, everycurrent.diffis written together withchange-overview.txtfrom the same snapshot — one line per changed path (A/M/D/R old → new, added/removed line counts; asecurity.denied_read_pathsmatch listed by name only,[denied]), the totals and the full diff's size, at most 200 paths per section, headed by the full base SHA; once the orchestrator has committed inside the task (each subtask of a decomposed run) a second section lists the change since that commit. A node'srequest.jsoncarries both undercontext_paths(change_overview_path,diff_base), and its rendered prompt's context footer lists the overview just before the diff and closes withChange base: <sha>and one where-to-start sentence: start from the overview and inspect files withgit diff <base> -- <path>when every provider on the node's route grants a shell, read the full diff otherwise. A fallback attempt handed the partial diff of a failed provider gets none of the three, because they describe a different change.
Use the operator log for live monitoring. Provider stdout.log and stderr.log are finalized and redacted after the subprocess exits, so do not tail them while an attempt is still running.
Troubleshooting node prompts#
- A
role_fileedit "did nothing" — confirm you edited the role file the node actually uses.installdelivers the built-in flows + their role files under.worc/flows/(each flow's prompts in its own<task_type>/subdir), and.worc/flows/is the only copy the orchestrator reads, so edit the copy there (a custom operator flow likewise keeps its role files under its own.worc/flows/<task_type>/subdir). Comparerendered-prompt.mdagainst your file. - A
{placeholder}printed literally — only the allowlisted variables interpolate (see prompt templates); any other{...}is intentionally left verbatim so code/JSON braces survive. A path variable with no value for that node renders empty.
7. Recovery playbook — manual_action_required#
A task ends in manual_action_required (exit 2) when the orchestrator stops safely and needs a human. It is not failed (which is for unrecoverable invalid-task/config/security/git errors). The task file is left in place (not moved to tasks/done or tasks/failed) and automatic continuation is blocked until you resolve it.
Common causes and what to do:
Cause (from stuck.md / logs / cleanup_last_error) |
Action |
|---|---|
Fix budget exhausted — max_fix_cycles (or a flow budgets / inline budget: entry) or the global max_total_fix_iterations hit: a budget of N allowed N fix rounds and the next fail/rework was refused. |
Read stuck.md: the last failing check / blocking findings and the final diff. Fix manually on the task branch, or refine the task, then re-submit — or, when more rounds would help (e.g. a rate limit turned fixing into a no-op), rerun --continue --reset-fix-budget, which also starts every non-blocking evaluator's max_rework_per_stage count afresh. The global backstop cannot be reset. |
A loop cut short by a no-progress guard — limit_exhausted is no_file_change or repeated_findings. |
no_file_change: read the latest fixing output for the blocker that kept the agent from editing. repeated_findings: read the repeated finding first — a request the node cannot satisfy (an input it may not edit, a judgement the flow cannot meet) never converges however many rounds it gets; fix the task, the role prompt or the environment, then rerun --continue (it keeps counting the same loop). |
A tool node repeated the same failure — the park message quotes the repeated output and says no top-level findings array was returned. |
With neither findings nor data the second byte-identical fail parks; with data alone the flow's declared fix budget for that loop is the limit. Make the tool report typed findings, or fix what it measures, then rerun --continue. |
Background work lost (background_work_lost, Claude) — the agent ended its turn while backgrounded subagents it started were still running, and the CLI stopped them at exit; the error names each stopped task. Not fallback-eligible and never a park: an agent node ends the task failed, an evaluator manual_action_required. The attempt's cost is still recorded. |
Keep agents.providers.claude.background_tasks: false (the default — the CLI is launched with CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1, so subagents run in the foreground and this cannot happen); if you set it true, make sure the role prompt never ends a turn waiting on background work. Then rerun --continue. |
Checks gate incomplete — a required toolchain could not launch, or every selected set was skipped (including the skip_if_unavailable set that was the only one the diff selected). |
Install the toolchain on the host, or narrow/extend the selecting paths, or disable that checks node for the task (nodes.<id>.enabled: false). A fix loop cannot install toolchains, so this never routes to fixing. |
| Git control state drifted (WRI-009) — the index / HEAD / task ref / repo-local config / hooks changed across a workspace-write attempt. | Inspect git status and the repo-local config/hooks; reconcile by hand, then rerun --continue. Note this does not park on a read-only node holding the git-evidence grant — there it only warns and continues. |
| Terminal cleanup unsafe — base-branch checkout would lose uncommitted work or the branch state is ambiguous (§8.3). | Inspect the repo (git status); reconcile by hand, commit/stash or discard intentionally, return to base_branch, then re-run watch. |
| More than one active task on restart (inconsistent state, §13). | Only one task may be active. Decide which to keep, mark the others resolved, then re-run. |
Exchange contaminated (WRI-002/007) — the agent mutated the read-only exchange; the tree was quarantined to .worc/runs/exchange-quarantine/<task-id>/. |
Inspect evidence.json (expected vs observed, plus created_at / node_id / attempt); tasks.quarantine_refs lists every bundle the task produced. Automatic recovery will not resume over it. Once you have read the diagnosis, rerun --continue clears the contamination flag and continues (the evidence is kept), or a fresh rerun discards the contaminated tree and re-derives the exchange. |
Terminal exchange cleanup blocked — the snapshot sealed but the active .worc-io/<task-id>/ could not be removed (a Windows lock/read-only). Later launches are blocked (exchange_active_unsafe). |
Clear the lock / remove the stale .worc-io/<task-id>/ by hand (the verified seal-* snapshot is safe), then re-run. |
Provider tree not proven quiescent (WRI-012 containment_unverified) — a background/detached descendant may still be writing, so the terminal seal, commit/push, and base-branch cleanup are all withheld and the tree is left in place (cleanup_last_error reads "not proven quiescent"). Later launches are blocked (exchange_active_unsafe). |
Find and kill the surviving descendant (the diagnostic records pids), then remove the stale .worc-io/<task-id>/ by hand and re-run. The uncommitted work stays on the task branch working tree. |
Exchange not clean at start — a stale/foreign entry in .worc-io/ (e.g. a prior task whose seal was interrupted) blocks the next launch; the new task ends manual_action_required naming the offending entry, without launching any provider. |
Remove the named .worc-io/<entry>/ by hand (a verified seal-* snapshot under .worc/ is safe to keep), then re-run. |
Codex sandbox could not enforce (WRI-003 capability_unavailable) — before each codex exec, a no-model codex sandbox canary proves the generated profile is OS-enforced. On an old Codex CLI or a host missing the sandbox helper it cannot run, so the attempt fails pre-model with capability_unavailable (fallback-eligible only to a provider that can itself isolate the node, else manual_action_required). |
Upgrade/repair the Codex CLI (on Windows see the sandbox-helper entry under Windows troubleshooting); run worc preflight, whose live capability smoke reproduces this before a task runs. Or route the node to Claude / opt out with strict_isolation: false. |
Codex permission profile not enforcing (WRI-003 configuration_error) — the canary found a path that should be denied but was actually readable/writable: the profile is not enforcing. The same class covers Codex refusing to load the generated profile (its own first-line Error: naming a config, profile, permission or filesystem path problem — permission-profile canary FAILED: codex rejected the generated profile before running probe …), at either value of strict_isolation. A non-fallback security result — the run stops, no other provider is tried. |
A leak is a real isolation failure, not a flake: do not weaken the profile. Check for a conflicting managed/MDM Codex policy or a tampered install; worc preflight's capability smoke reports the leaking probe. A rejected profile is ours to fix — read the quoted Error: line, then correct the config it names or upgrade the Codex CLI; worc preflight's smoke builds the same profile a real attempt launches, so it reproduces the refusal. Fix it, then re-run. |
A §19-rejected task is different: it is terminal failed, quarantined to .worc/tasks/rejected/ (under the gitignored home, so a rejected task never rides the audit commit) with a validation_report.json (and a validation_reason in the ledger), and never gets a branch. Fix the task file (e.g. add a Description, a valid id, remove injection-shaped front-matter) and re-submit by promoting it from tasks/preparing/ (or dropping the corrected file back into tasks/pending/). worc list --all lists such ids under rejected: with the reason (see worc list); a re-submission under the same id is accepted, and the id leaves that section once it has a row.
A finished task's own file reappearing in tasks/pending/ is not a failure. With the lifecycle tree tracked and audit_on_branch: task, promote left your commit of the file on base while the terminal move into tasks/done/ was committed on the task branch — so returning to base restores the pending copy. The orchestrator recognises it by content (tasks.source_sha256, newline-normalized so a core.autocrlf checkout still matches): the daemon skips it, worc run on it refuses (exit 1, run: <id> already reached a terminal; its task file was left in place. …, pointing at rerun / finalize), and it is neither re-run nor quarantined. A reject of an id that already reached a terminal status appends no second ledger record and sends no notification. Only a file that merely reuses a settled id — different content — is still quarantined (id already used by a <status> task; give this file a new id), because left in the queue it would be re-rejected every tick.
Recovery is idempotent: re-running watch/run resumes the single in-flight task, reuses the existing branch, continues from its persisted flow checkpoint ({completed_nodes, current_node, loop_counters, publish_operations} — not a per-stage status, those are gone), and never re-commits/re-pushes a completed operation. A resumed fixing/review node is handed the same inputs a fresh run produced — they are rebuilt from disk plus state.db (current.diff and its change-overview.txt — the change base is read back from the overview the run wrote, not resolved again from a clone that may have moved — plan.md, the newest quality-failed checks/<NNN>.log, and the findings of the last recorded in_flow_verdict) and re-published into the current task's exchange, so the agent reads exchange paths exactly as it would on a first pass, and the fix counters are not incremented a second time.
The tasks/ lifecycle tree at the repo root is where live task files belong; it is gitignored by default and becomes the git-tracked audit trail only if you asked install to track it (see §5). The runtime home .worc/ and the agent-facing exchange .worc-io/ are always gitignored by install (all three lines appended to .gitignore), so their contents never ride a commit.