diff --git a/CHANGELOG.md b/CHANGELOG.md
index 96dee162..1f81accb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,21 @@ All notable changes to codexclaw are documented here. The format follows
## [Unreleased]
+### Fixed
+
+- Subagent waiting guidance instructed coordinators to retire workers after
+ about three empty waits, even when analysis could still be progressing.
+ `waiting.md` now requires evidence of progress or stagnation, preserves
+ unavailable observations, and uses non-interrupting checkpoints. Delegation
+ and DISPATCH-RETIRE-01 clarify actual shutdown checks and managed recovery;
+ cancellation or exhausted limits grant no retry or replacement. Progress
+ assessment remains agent-followed (#178).
+- Managed dispatch now accepts explicit `task_failed` reports for confirmed
+ stagnation or unusable final output. A recorded stopped child, task evidence
+ and reconciliation are required before the existing bounded handoff can run.
+ Provider errors keep their existing classification; cancellation and permission
+ denials cannot be overridden by a task-failure label (#178).
+
## [0.2.28] - 2026-09-14
### Fixed
diff --git a/README.ko.md b/README.ko.md
index 35736a36..e6b0c524 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -13,7 +13,7 @@
-
+
diff --git a/README.md b/README.md
index 2dcee674..e3fd4f04 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
diff --git a/README.zh.md b/README.zh.md
index f0a07a0b..0bab5bb6 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -13,7 +13,7 @@
-
+
diff --git a/devlog/_fin/260915_subagent_retirement/000_plan.md b/devlog/_fin/260915_subagent_retirement/000_plan.md
new file mode 100644
index 00000000..5fa48a15
--- /dev/null
+++ b/devlog/_fin/260915_subagent_retirement/000_plan.md
@@ -0,0 +1,101 @@
+# Require evidence before retiring a subagent
+
+An executor can still be investigating while its working tree is clean. Issue
+[#178](https://github.com/lidge-jun/codexclaw/issues/178) records a worker stopped
+seconds after a successful read. This unit replaces the wait-count heuristic with
+an activity-based decision procedure in the existing waiting reference. It also
+keeps repeated busywork, explicit limits and safe handoff in view.
+
+## Scope and completion
+
+- Class C2; one satisfy-spec PABCD work-phase, `wp1`.
+- Trigger: implement #178 and open a linked pull request.
+- Goal: a coordinator can distinguish slow progress, suspected stagnation,
+ confirmed failure and unavailable observations before deciding to retire.
+- Out: runtime watchdogs, provider retries, new configuration/schema fields,
+ installed-payload changes, merge, release and deployment.
+- Tools: local Git/source reads, native V1 subagents, existing checks and GitHub
+ branch/PR publication. Writes stay in this task's linked worktree and native
+ session evidence. No user token/cost/wall-clock budget was specified; preserve
+ explicit limits when present and do not invent a universal timeout.
+- Verifier: the baseline commands below plus independent semantic review of
+ [the scenario matrix](010_policy.md#acceptance-scenarios).
+- Stop: close the cycle with verified text and publish an ordinary PR to `dev`.
+ DONE requires a linked PR and accurate check results; unmet evidence stays
+ incomplete. Host limits or cancellation are reported as such, never success.
+- Memory artifact: this unit, then its `_fin/` archive; raw consultation/dispatch
+ handles and receipts remain in untracked native session evidence.
+- Escalation: main resolves scope/judgment gaps. A new worker handoff requires a
+ plan amendment. Retirement and replacement follow managed dispatch results;
+ `reconcile`/`stop` never authorize direct work or another spawn.
+
+## Existing owners and baseline
+
+`plugins/codexclaw/skills/loop/references/waiting.md:31-35` retires after about
+three waits. `structure/20_pabcd_dispatch_doctrine.md:181-195` includes bare
+timeout among failure reasons. The V1 table in
+`plugins/codexclaw/skills/pabcd/references/delegation.md:133` calls a wait timeout
+a normal outcome. The waiting reference already owns the shared decision rule;
+the other files should point to it rather than copy a second classifier.
+
+Verified on upstream `dev` at `03541398` before edits:
+
+| Command | Result | What it observes |
+| --- | --- | --- |
+| `npm run gate` | exit 0 | `gate.mjs` scans skill references and `structure/*.md` for claim hygiene and checks inventory; not retirement semantics |
+| `node plugins/codexclaw/scripts/test.mjs plugins/codexclaw/test/manifest-policy.test.mjs` | 7 pass, 0 fail | existing owner-route resolution includes `waiting.md`; not model behavior |
+
+No phrase-presence test or unused decision helper will be added. They would test
+the wording or a second implementation that the coordinator never executes
+(`dev-testing` TEST-PROMPT-SEAM-01). The source is agent-followed E7 guidance;
+execution surface: main's judgment; bypass: ignore/misread it; residual: model
+variance and missing observations; final enforcement layer: none.
+
+## Architect consultation and main decisions
+
+The native V1 architect proposed D1-D5 from the owner files. Its initial proposal
+included phrase tests; its revised proposal withdrew them after the existing
+testing rule was supplied. Actual handle, proposal and reflection are retained
+with this session's dispatch evidence, outside published project documents.
+
+| Decision | Main disposition |
+| --- | --- |
+| D1 trigger taxonomy | Accept with amendment: a confirmed stop/cancel is not automatically a provider failure. Separate wait timeout, terminal error and evidenced stagnation. |
+| D2 activity evidence | Accept with amendment: new reads or command events are candidate evidence only when they advance the packet. Repeated reads/messages prompt investigation, not an automatic new count threshold. |
+| D3 checkpoint | Accept: use supported non-interrupting delivery, explain queued-input limits, request findings/remaining work/next artifact. Reviewers report next review result, not a forced first edit. |
+| D4 retirement evidence | Amend: record decision before stop; verify terminal state and owned processes after stop. A returned *previous* `running` status does not prove termination. Preserve managed fallback gates. |
+| D5 observation and proof | Accept revised semantic review. No assumed access to child rollouts. Do not link installed skills to repository-only devlogs. |
+
+No-code alternatives: doing nothing retains the contradiction; merely lengthening
+three waits still mistakes elapsed observations for failure; a runtime helper has
+no existing consumer and expands this change. Reuse the current protocol owners.
+
+Reflection of this concrete plan: ALIGNED on D1-D5, with the named main
+amendments accepted. Independent A audit: PASS, no blockers. S3 now explicitly
+permits retirement only after stagnation is evidenced; the canonical recovery
+rule must also govern shorthand references such as `plan-output.md:18`.
+
+## Change ownership
+
+The executor owns the four bounded source-document edits listed in
+[010_policy.md](010_policy.md). Main owns this plan, semantic acceptance decisions,
+consultation records, final review/integration, checks and publication. One commit
+may carry the coherent document fix; follow repository `[agent] fix(...)` style.
+No branch stacking is needed. Preserve unrelated local integration work.
+
+## Delivery conclusion
+
+The four document edits are implemented. The final independent reviewer derived
+all twelve intended actions from the policy without seeing expected answers;
+its initial and focused follow-up verdicts were PASS. The existing gate, seven
+owner-route tests and whitespace check passed. See
+[011_verification.md](011_verification.md) for scope and limits.
+
+The chosen hypothesis was that clearer evidence requirements can prevent the
+observed premature retirement. This unit does not measure model compliance;
+there is no basis for claiming faster execution or a runtime guarantee. A
+watchdog and phrase-presence tests were rejected because neither would verify
+the coordinator behavior without a real runtime consumer.
+
+Delivery stops at a reviewed contribution to upstream `dev`, linked to #178.
+No merge, release, deployment or installed-payload update is part of this unit.
diff --git a/devlog/_fin/260915_subagent_retirement/010_policy.md b/devlog/_fin/260915_subagent_retirement/010_policy.md
new file mode 100644
index 00000000..7b796ffd
--- /dev/null
+++ b/devlog/_fin/260915_subagent_retirement/010_policy.md
@@ -0,0 +1,63 @@
+# wp1: Change the retirement decision at its existing owners
+
+Depends on `000_plan.md` D1-D5. One C2 work-phase; no production code or new types.
+
+## Exact edit map
+
+| File | Before | After | Owner |
+| --- | --- | --- | --- |
+| `plugins/codexclaw/skills/loop/references/waiting.md` | final bullet retires after about three waits | Replace that bullet with compact progress/checkpoint/retirement sections. Keep existing peer boundary and V1/V2 wait visibility. | executor |
+| `structure/20_pabcd_dispatch_doctrine.md` | DISPATCH-RETIRE-01 treats bare timeout as failure and implies unconditional replacement/reclaim | Refer to waiting's evidence rule; qualify timeout as actual terminal error only; keep bounded recovery but defer managed replacement/reclaim to protocol result. | executor |
+| `plugins/codexclaw/skills/pabcd/references/delegation.md` | wait route and normal-timeout table; generic failure recovery | Clarify non-interrupting checkpoint delivery and timeout observation. Preserve transport schemas and existing first-fallback gates. | executor |
+| `CHANGELOG.md` | empty Unreleased | One concise Fixed entry for #178, explicitly agent-followed guidance. | executor |
+
+The waiting rule must distinguish substantive progress, suspected stagnation,
+confirmed failure and unavailable observation. Refresh task-scoped observations
+just before retiring. Liveness alone and file timestamps alone do not prove
+semantic progress; no edits alone do not prove a stall. A healthy long command
+may have sparse output and must be inspected before cancellation.
+
+For suspected stagnation, request one non-interrupting checkpoint where supported,
+then compare new evidence with the prior observation at a stated, task-appropriate
+next review point. Repeated/no-op work must not reset that review point forever.
+That review point is not a new cancellation budget. An unread queued message and
+silence with missing visibility do not confirm a stall. If an opaque task cannot
+be assessed within authorized limits, report the observation gap and seek direction
+without claiming failure or silently replacing it.
+
+Explicit cancellation, actual terminal failures and stated user/host resource
+limits take precedence over progress. Keep cancellations/exhausted limits separate
+from provider errors. Record pre-stop reason and last meaningful activity, then
+post-stop terminal state, owned processes/partial edits, retained results and
+remaining work. If termination is unknown, no overlapping writer may be started.
+Managed `ready` requires a new claim; `main-direct` permits reclaim;
+`reconcile`/`stop` permit neither. Never manufacture an OCX error code for a stall.
+
+## Acceptance scenarios
+
+These are semantic review cases, not executable runtime tests. A fresh C reviewer
+must derive actions from the final policy and name ambiguities before acceptance.
+
+| ID | Activation | Expected observable decision |
+| --- | --- | --- |
+| S1 | Four waits, no edits, nine meaningful reads; last read seconds ago | keep active; no retirement from polls/clean Git |
+| S2 | A long build is running with sparse output and no expired limit | inspect command state; sparse output alone cannot retire |
+| S3 | Same unchanged reads/status repeatedly; delivered checkpoint and later comparable observations show no advancement | eligible to retire once stagnation is evidenced at the stated review point; repeats alone only prompt investigation |
+| S4 | No child log access; clean Git; checkpoint may still be queued | observation unavailable; no invented failure or unanswered-message deadline |
+| S5 | Native V2 wait reports updates without final text | obtain the separately delivered answer before judging |
+| S6 | Actual terminal error | preserve original error and use managed failure/reconciliation path |
+| S7 | User cancels or an explicit resource bound expires despite progress | stop within authority; report cancellation/bound, not provider failure |
+| S8 | Stop call returns previous status `running` | verify current terminal state and owned jobs before handoff |
+| S9 | Failure report returns `reconcile` or `stop` | neither direct implementation nor replacement spawn |
+| S10 | New irrelevant reads or identical heartbeats keep arriving | liveness only; do not postpone reassessment forever |
+| S11 | Reviewer produces no files while making new findings | treat review artifacts as progress; never require an edit |
+| S12 | Finished child still has a queued checkpoint response/turn | reconcile actual current status; a checkpoint request is not permission to duplicate work |
+
+## Verification and delivery
+
+Run `npm run gate`, the existing manifest-policy test command in `000_plan.md`,
+and `git diff --check`. Review links and all active retirement-rule references.
+The first two checks already ran successfully at baseline; whitespace check is
+mechanical only. No typecheck/build is claimed for prose. Record per-scenario
+review and limitations in `011_verification.md`, archive this unit at completion,
+and publish an ordinary fork PR to upstream `dev` with `Closes #178`.
diff --git a/devlog/_fin/260915_subagent_retirement/011_verification.md b/devlog/_fin/260915_subagent_retirement/011_verification.md
new file mode 100644
index 00000000..a6c9a6e3
--- /dev/null
+++ b/devlog/_fin/260915_subagent_retirement/011_verification.md
@@ -0,0 +1,119 @@
+# Verification of the retirement guidance
+
+The changed artifact is coordinator guidance. Structural checks and semantic
+review are separate evidence; neither is a guarantee of future model compliance.
+
+## Independent review
+
+A fresh C reviewer received the four changed source documents and twelve
+observation-only scenarios, without the plan or its expected-answer matrix.
+It independently derived the following actions and returned PASS, no blockers.
+Main compared the results with the pre-written acceptance rows.
+
+| Scenario | Derived action | Result |
+| --- | --- | --- |
+| S1 meaningful reads, no edits | continue bounded waiting | matches |
+| S2 healthy long command | inspect state; sparse output alone is not failure | matches |
+| S3 evidenced stagnation | retire only after evidence, then verify shutdown and permitted recovery | matches |
+| S4 unavailable observations | report the gap; queued input/timeouts do not prove failure | matches |
+| S5 V2 mailbox | obtain separately delivered answer | matches |
+| S6 terminal error | preserve error and follow managed reconciliation | matches |
+| S7 cancellation/explicit limit | stop; do not automatically continue | matches |
+| S8 previous status running | withhold overlapping work until current state and owned jobs are checked | matches |
+| S9 reconcile/stop result | no replacement or direct implementation | matches |
+| S10 irrelevant activity | keep reassessment point; noise is not progress | matches |
+| S11 reviewer findings | continue without requiring edits | matches |
+| S12 queued checkpoint after completion | reconcile current state before handoff | matches |
+
+The reviewer briefly stumbled over the Unobservable bullet's comma list and
+the changelog's verb "retired". Main clarified insufficient observations as
+the condition and changed the historical mechanism to "instructed coordinators
+to retire". These are readability fixes, not new runtime behavior. The same
+reviewer checked those two edits and returned PASS with no remaining notes;
+all twelve independently derived actions were unchanged.
+
+## Checks observed by the independent reviewer
+
+- `npm run gate`: exit 0; no status/claim/count/inventory drift.
+- `node plugins/codexclaw/scripts/test.mjs plugins/codexclaw/test/manifest-policy.test.mjs`:
+ exit 0, 7 pass, 0 fail.
+- `git diff --check`: exit 0.
+- Source hashes were stable during the initial four-file review.
+
+The existing route test reads the waiting reference's routing path. The gate
+walks skill references and structure documents for claim hygiene. These checks
+do not execute a model's retirement decisions. The scenario matrix is a semantic
+review, not a deterministic unit test or an instrumented lifecycle replay.
+
+## Initial policy revision: limits and retained evidence
+
+At the initial policy revision, no runtime code, model routing, installed plugin
+bytes, configuration or tests were changed. No phrase-presence test or unused helper was introduced. Source
+tests cannot establish cross-model compliance or delivery timing on every host.
+A future coordinator ignoring substantive progress despite reading this policy
+would falsify the assumption that clearer guidance alone is sufficient.
+
+Raw native dispatch handles, reviewer responses and check receipts remain in
+untracked session evidence. Public evidence is summarized here; no private
+session transcript or host-specific path is published. The contribution is
+tracked by [issue #178](https://github.com/lidge-jun/codexclaw/issues/178).
+
+## PR review follow-up: plan template recovery
+
+[Review comment](https://github.com/lidge-jun/codexclaw/pull/179#discussion_r4009201474)
+identified an unconditional two-failure reclaim instruction still present in
+`pabcd/references/plan-output.md`. The earlier review treated it as shorthand;
+that was insufficient because coordinators use the template to write plans.
+
+The escalation row now distinguishes unmanaged recovery from configured first
+fallback and links to the existing recovery owner. Main reclaims only on
+`main-direct` under managed dispatch; `reconcile` and `stop` authorize neither
+reclaim nor replacement, even after two failures. Unmanaged reclaim requires
+prior work to have stopped. The P-phase amendment requirement is retained.
+
+Manual comparison with `delegation.md` and DISPATCH-RETIRE-01 confirmed the
+`main-direct`, `reconcile`, `stop`, and unmanaged-recovery cases. Fresh checks:
+`npm run gate` passed; manifest-policy tests passed 7/7; `git diff --check`
+was clean. These remain document checks and semantic review, not runtime proof.
+
+## PR review follow-up: architect recovery
+
+[Review comment](https://github.com/lidge-jun/codexclaw/pull/179#discussion_r4012925635)
+found the same incomplete propagation in the architect-specific recovery clause.
+Accepted: a role-specific instruction must not bypass the common recovery owner.
+The clause now defers managed retries and reclaim to the returned action, and
+limits the same-handle/two-context rule to unmanaged dispatch. Prior work must
+be stopped and inspected before unmanaged recovery. Missing architect consultation
+still blocks dependent completion, including when main reclaims planning.
+
+Reviewed recovery references in the active skills and structure doctrine. Manual
+comparison covered managed `ready`, `main-direct`, `reconcile`, `stop`, unmanaged
+recovery, and the unmet-consultation constraint. Fresh `npm run gate`, all 7
+manifest-policy tests, and `git diff --check` passed. These are document checks
+and main's semantic review; the initial independent scenario review is unchanged.
+
+## PR review follow-up: unusable final output
+
+[Review comment](https://github.com/lidge-jun/codexclaw/pull/179#discussion_r4012960563)
+identified another owner mismatch: the doctrine includes nonsense output as a
+failure, but the waiting classification only named terminal errors and stagnation.
+Accepted: a transport-successful final response can still fail the task packet.
+The waiting reference now includes demonstrably nonsensical or unusable final
+output and requires concrete evidence. Interim updates and supported disagreement
+alone do not count as failure. Existing shutdown and managed recovery rules apply.
+
+Fresh `npm run gate`, all 7 manifest-policy tests, and `git diff --check` passed.
+These checks cover document hygiene and routes, not the output-quality judgment.
+
+## Later review: managed recovery reachability
+
+[Review comment](https://github.com/lidge-jun/codexclaw/pull/179#discussion_r4013017929)
+found that recognizing task failures did not make managed recovery executable:
+descriptive stagnation or unusable-output reports remained `reconcile` even after
+termination was confirmed. A source review at `20e50485` correctly verified that
+unknown error prose cannot rotate providers, but missed the need for a separate
+task-failure path. Main reproduced that gap with the real state machine.
+
+The follow-up cycle is recorded in `devlog/_fin/260915_task_failure_recovery/`.
+Its runtime/CLI evidence supersedes the initial policy-only scope above; it must
+not be inferred from the earlier semantic scenario review.
diff --git a/devlog/_fin/260915_task_failure_recovery/000_plan.md b/devlog/_fin/260915_task_failure_recovery/000_plan.md
new file mode 100644
index 00000000..ef3ced7a
--- /dev/null
+++ b/devlog/_fin/260915_task_failure_recovery/000_plan.md
@@ -0,0 +1,48 @@
+# Managed task failure recovery
+
+PR #179 follow-up to [review](https://github.com/lidge-jun/codexclaw/pull/179#discussion_r4013017929).
+The prior documentation cycle preserved managed recovery authority. A later review
+showed that confirmed task failures have no executable recovery path through it.
+This cycle changes that conclusion with direct runtime evidence, not another prose exception.
+
+## Requirements and scope
+
+- Represent confirmed stagnation and unusable final output separately from provider errors.
+- Require a recorded child, confirmed termination, partial-work inspection and concrete
+ task-failure evidence before handing off remaining work.
+- Preserve provider decoding, cancellation/policy stops, identity, claim/spawn deduplication,
+ the two-candidate bound and independent-review obligations.
+- Retain old dispatch-state compatibility and consistent installed caller guidance.
+- Finish with relevant runtime/CLI regression evidence, current CI and no medium-or-higher
+ unresolved source or GitHub review findings.
+
+One bounded PABCD cycle follows this specification. The affected report contract receives
+C4 review and negative-case coverage. Main owns decisions, integration, records and PR;
+the configured executor will own the audited implementation bundle. The configured
+architect proposes the contract and reflects on the executable plan before independent audit.
+
+Use the existing subagent-config module, tests, waiting/delegation owners and structure
+index. No new dependency, retry framework, watchdog, provider call or installed configuration.
+No merge, release or deployment. No user token, cost or wall-clock budget was specified;
+the work remains bounded by this PR and its review findings. Preserve other checkouts.
+
+## Reproduction and trust boundary
+
+At `20e50485`, an isolated real `runDispatch` fixture creates and claims an executor,
+records its child, then reports unusable final output with `executionState: stopped`,
+the recorded identity and reconciliation evidence. First and repeated reports both
+return `reconcile`, retain one attempt and request structured OCX evidence that does not
+exist for this failure. Existing dispatch/CLI tests pass 23/23; they omit this recovery case.
+
+The assets are exclusive work ownership and bounded model invocation. The boundary is
+main-supplied CLI JSON into persisted dispatch state. Malformed or conflicting reports,
+quoted child output and stale identities must not create recovery permission. Main can
+misreport observations already; these are caller assertions, not authenticated native
+receipts. Tests must prove validation and state transitions without claiming to observe
+real child termination or judge model output automatically.
+
+The executable file map, decision IDs, acceptance cases and consultation record belong
+in `010_recovery.md`. Existing code-only checks and the normal build are available:
+dispatch/CLI tests exit 0 (23 tests), `npm run build` exits 0 (181 files), and the gate
+exits 0 on the baseline. Tests invoke the actual state machine and separate CLI processes;
+the build compiles component source; the gate covers document hygiene only.
diff --git a/devlog/_fin/260915_task_failure_recovery/010_recovery.md b/devlog/_fin/260915_task_failure_recovery/010_recovery.md
new file mode 100644
index 00000000..fe72e7d6
--- /dev/null
+++ b/devlog/_fin/260915_task_failure_recovery/010_recovery.md
@@ -0,0 +1,108 @@
+# Task failure report: executable plan
+
+| Field | Contract |
+|---|---|
+| Goal | Safely recover confirmed task failures and close PR #179's substantive reviews. |
+| Mode / class | Scoped HOTL; report contract receives C4 care. One recovery cycle. |
+| Scope / resources | This checkout and PR only; no explicit token or time budget; no installation, live provider probe, merge or release. |
+| Completion | Runtime/CLI positive and negative cases, affected suites/build, independent review and current CI pass; no medium-or-higher findings remain. |
+| Escalation | Managed results govern all child recovery. `reconcile`/`stop` grant no reclaim or replacement; broader unrelated changes require a new decision. |
+
+## Decisions and consultation
+
+Architect proposal accepted as D1-D8 below. Main reproduced the stranded state and
+accepts rotation to the existing next candidate, not a new same-model retry mode.
+Generated repository payload is in scope; rebuilding it does not install the plugin.
+Use `task_failed` consistently. Stagnation's review-point evidence stays in bounded
+evidence text; no additional scheduler or observation database is needed.
+
+- D1: Add `outcome: task_failed`; keep `failed` provider decoding unchanged.
+- D2: Require `taskFailure: {kind: stagnation|unusable_output, evidence: string}`.
+ Reject unknown keys/kinds, empty evidence, non-string values and text over 2000 characters.
+- D3: Require the recorded child identity, `executionState: stopped`, and existing
+ bounded reconciliation evidence. Running/unknown state returns `reconcile`; no-child
+ task failure is rejected. Check state before any candidate or direct-work grant.
+- D4: If `error` is supplied with `task_failed`, provider restrictions win: decoded
+ stop records the code and stops; unknown returns `reconcile`; next-eligible errors
+ are rejected as mixed reports and must use `outcome: failed`. No error means task path.
+- D5: Reuse the existing failed-attempt tail: next configured candidate is `ready`,
+ then requires claim; after the last candidate return `main-direct`. No third attempt.
+- D6: Persist nullable `Attempt.taskFailure`; preserve `code` for provider codes.
+ Old version-1 records missing the field normalize to null. Invalid stored values
+ fail closed. New records initialize null. Expose metadata through existing `attempts`.
+- D7: Keep `DispatchResult` actions, role constraints, locks and native issuance unchanged.
+ Optional reason text may explain task recovery; it is not the authority signal.
+- D8: Document the same payload in SessionStart guidance, delegation, waiting and
+ the public subagents guide. Update CHANGELOG and the structure index owner pointer.
+
+Alternative rejected: adding a fake OCX code or treating arbitrary `failed.error`
+prose as next-eligible. That weakens the provider boundary and confuses observation
+with provider error. A new `task_failed` report is an explicit main judgment with
+separate evidence. It is not inferred from model output or a wait count.
+
+Architect proposal and reflection completed: ALIGNED for D1-D8, no material gaps.
+Main accepts both implementation clarifications: decoded stop precedes the child-state
+gate; ordinary `failed` reports retain their existing behavior even with stray task metadata.
+Actual handles and raw consultation outputs stay in local task evidence.
+
+## File and field chain
+
+Paths below are repository-relative. After A, the executor owns the state-machine
+implementation and two test files. During B it explicitly released the untouched
+guidance/documentation files to main; the sequential handoff was delaying integration.
+Main owns that wording, planning records, generated build output, measured README
+test badges, verification records, source review and PR publication. D1-D8 and the
+acceptance criteria are unchanged; this is a successful scope split, not failure recovery.
+
+| File | Change |
+|---|---|
+| `plugins/codexclaw/components/subagent-config/src/fallback-dispatch.ts` | MODIFY input outcome validation, task payload parser, attempt metadata factory/read validation, report transition; share bounded handoff tail. |
+| `plugins/codexclaw/components/subagent-config/src/fallback-dispatch-cli.ts` | MODIFY `DISPATCH_GUIDANCE` with concrete task report. CLI already passes parsed JSON to `runDispatch`; no new command or flags. |
+| `plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts` | MODIFY existing real fixture tests for state transitions and negatives; no deleted assertions. |
+| `plugins/codexclaw/components/subagent-config/test/fallback-dispatch-cli.test.ts` | MODIFY real separate-process round trip covering task failure and persisted metadata. |
+| `plugins/codexclaw/skills/pabcd/references/delegation.md` | MODIFY report instructions and evidence/limit precedence. |
+| `plugins/codexclaw/skills/loop/references/waiting.md` | MODIFY recovery route for confirmed task failures; keep wait classification evidence-based. |
+| `docs-site/src/content/docs/guides/subagents.md` | MODIFY public protocol example and limits. |
+| `CHANGELOG.md`, `structure/INDEX.md` | MODIFY change record and owner description to include runtime recovery. |
+| `plugins/codexclaw/components/subagent-config/dist/fallback-dispatch{,-cli}.js` | REGENERATE from source with the standard build; never hand-edit. |
+| `README.md`, `README.ko.md`, `README.zh.md` | MODIFY test count only if new tests change measured inventory; use normal inventory tool. |
+
+Creation is explicit CLI JSON → `runDispatch`/`report`; validation is the task parser
+and stopped-child gate; serialization is `saveState`; deserialization is `readState`;
+consumption is the existing `result().attempts` plus main guidance. `managedSpawn` and
+`issueManagedSpawn` consume unchanged candidate/claim/state fields. The decoder remains
+provider-only. No SDK, GUI input form or alternative report parser owns this field.
+
+## Acceptance and verification
+
+| Reachable trigger | Required observation |
+|---|---|
+| Created, stopped child; each task kind with valid evidence | First failure → ready; claim selects configured fallback; metadata round-trips with null provider code. |
+| Same valid failure on last candidate, all roles | main-direct, exactly two attempts; reviewer still requires independent review. |
+| State persisted without the new key | status works and later task failure recovers; malformed new metadata is rejected. |
+| Running/unknown child state | reconcile, unchanged candidate count, no spawn/direct permission. |
+| not_created, missing/wrong child, missing reconciliation | Rejected; no recovery grant or hidden state advancement. |
+| Invalid kind/shape/unknown key, blank/oversized evidence | Rejected; existing state remains readable and unchanged. |
+| Permission/policy/cancellation error plus task label | stop wins and persists; a later label cannot reopen it. |
+| Unknown error plus task label | reconcile; no extra attempt. |
+| Next-eligible provider error plus task label | Rejected as mixed; ordinary failed path retains old behavior. |
+| Plain failed report with task metadata and no provider error | Existing unknown-error reconcile behavior, never implicit task recovery. |
+| Before claim, stale ID, duplicate claim/issuance | Existing rejection/reconciliation and one-spawn invariant remain. |
+| Already complete/stopped dispatch | No reopening by task report. |
+| Separate CLI processes, valid task failures through both candidates | ready → claim → main-direct, metadata survives status/restart. |
+
+Record a failing task-recovery assertion before source changes, then the same test
+passing. Focused baseline command (exit 0, 23 tests) directly loads the two target suites:
+`node plugins/codexclaw/scripts/test.mjs plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts plugins/codexclaw/components/subagent-config/test/fallback-dispatch-cli.test.ts`.
+After implementation run the complete subagent-config suite, `npm run build`,
+`npm run gate`, full root tests for the measured badge count, inventory check and
+`git diff --check`. Normal hosted CI covers OS and packed-payload compatibility.
+Semantic review checks the caller docs against the live report contract. A fresh C
+reviewer re-derives recovery reachability; merely preserving unknown-error rejection
+does not prove task recovery works, which was the earlier review's blind spot.
+
+Enforcement scope: CLI validation and persisted transitions execute in code for managed
+callers; direct native calls bypass this protocol. Evidence truth and actual child
+termination remain main observations. No universal lifecycle-enforcement claim is made.
+Keep this repair in PR #179 because its new failure classification otherwise strands
+managed callers; the larger aggregate diff includes the original planning/evidence records.
diff --git a/devlog/_fin/260915_task_failure_recovery/011_verification.md b/devlog/_fin/260915_task_failure_recovery/011_verification.md
new file mode 100644
index 00000000..fe6d2eae
--- /dev/null
+++ b/devlog/_fin/260915_task_failure_recovery/011_verification.md
@@ -0,0 +1,61 @@
+# Task failure recovery verification
+
+The implemented `task_failed` path closes the runtime gap reported in
+[PR review](https://github.com/lidge-jun/codexclaw/pull/179#discussion_r4013017929).
+Both confirmed task kinds now reach the existing bounded handoff after a recorded
+child is stopped and evidence is supplied. Unknown provider prose still reconciles;
+no provider error code was invented or added to the decoder.
+
+## Observed checks
+
+- RED: `confirmed task failure recovers through the next candidate, then main-direct`
+ failed with `invalid report outcome` against the prior source.
+- GREEN: the same test passed after implementation; the initial focused run passed
+ 24 tests. Later coverage added malformed metadata, legacy records, identities,
+ provider precedence, terminal guards and separate CLI persistence.
+- Main reproduced and corrected stale provider-code metadata after a prior
+ reconciled report; accepted task failures now clear that code.
+- Standard build passed: 181 source files compiled, manifest layout validated.
+- Compiled root CLI QA passed 35 separate invocations with captured stdin,
+ stdout, stderr and exit codes. Cases covered failure recovery through both
+ candidates, status/resume, duplicate claim, live/unknown child, malformed input,
+ 2000/2001-character bounds and terminal cancellation/permission precedence.
+ No native agents or providers were invoked by the fixtures; temporary QA
+ state was removed and its absence confirmed.
+- Full root suite: 3165 total, 3091 pass, 74 skip, 0 fail. The source diff hash
+ was unchanged throughout the run. Inventory badges were then regenerated from
+ that measured total; inventory check, document gate and diff check passed.
+
+The initial full run failed five unrelated environment checks: this checkout had
+no installed React dependency, and an existing `/tmp/.git` made four temporary-root
+fixtures resolve their ancestor. Installing the lockfile's existing dependencies
+and setting an isolated temporary directory fixed the environment. The ten tests
+in those two files passed, then the whole suite passed. No product code or test
+assertion was changed to suppress these failures; package manifests and lockfile
+are unchanged.
+
+## Independent review
+
+The architect proposed D1-D8 and reflected ALIGNED on the concrete plan. Independent
+A audit found no blockers and reran the 23-test baseline. A fresh C reviewer reviewed
+all changed source, tests, generated bytes, caller guidance and records; it independently
+derived positive recovery and negative cases before returning PASS with no substantive
+findings. Source-to-dist compilation matched, 19 reviewed file hashes stayed stable,
+and a later pass accounted for the three generated README badge updates.
+
+Reviewer VM probes covered eight scenario groups. Its first documentation-example
+probe selected the wrong example and failed; correcting that extractor proved the
+actual task-report example reaches `ready`. Removing the new task branch in memory
+restored `invalid report outcome`, confirming the new recovery test is not vacuous.
+
+## Evidence limits
+
+Runtime tests and CLI fixtures prove report validation and state transitions. They
+do not authenticate main's observations, measure live model compliance or establish
+that a real child/process stopped. The output-quality judgment remains main-owned.
+No plugin was installed, and no merge or release occurred. The fixtures made no
+provider calls.
+The earlier policy-only review missed recovery reachability; broader runtime evidence
+was necessary to close this issue. Raw receipts and native handles remain local.
+
+Hosted CI and GitHub re-review are verified on the published commit separately.
diff --git a/docs-site/src/content/docs/guides/subagents.md b/docs-site/src/content/docs/guides/subagents.md
index 482f1c2f..8bde1a62 100644
--- a/docs-site/src/content/docs/guides/subagents.md
+++ b/docs-site/src/content/docs/guides/subagents.md
@@ -106,6 +106,15 @@ authentication and cancellation failures stop. Ambiguous creation or ongoing wor
must be reconciled before replacement; stopped executors require a change review
and cleanup evidence. Unknown error prose does not trigger blind rotation.
+Confirmed stagnation or unusable final output uses a separate `outcome:task_failed`
+report with `taskFailure: {kind: "stagnation" | "unusable_output", evidence: "..."}`.
+Include the recorded child ID, `executionState:stopped`, and `reconciliation`
+describing termination and partial-work inspection. Both evidence strings must be
+non-empty and at most 2000 characters. This report has no provider `error`; it uses
+the same bounded handoff and returned actions as provider recovery. Cancellation,
+exhausted limits and a wait timeout alone are not task failures. Validate final
+work before reporting `outcome:complete`, which cannot be reopened for recovery.
+
OCX retains its own retries and global/per-model fallback. The two-attempt limit
applies to CXC-issued native attempts, not every downstream provider request.
Requested and observed models are recorded separately; an unobserved actual model
diff --git a/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch-cli.js b/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch-cli.js
index 76d2fbda..4a224ce2 100644
--- a/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch-cli.js
+++ b/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch-cli.js
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
import { runDispatch } from "./fallback-dispatch.js";
import { readConfig, ROLES } from "./store.js";
-export const DISPATCH_GUIDANCE = `Roles with a first fallback use the main-owned dispatch protocol before native spawn. Run cxc subagents dispatch with one JSON object on stdin: {action:"start",sessionId:,dispatchId:,role:}. Then claim with {action:"claim",sessionId,dispatchId,attemptId}. Only action=spawn authorizes one native call; prepend its marker followed by a newline to the original task/skills, pass candidate model and effort when non-null, and use a fresh context. Report creation with {action:"report",outcome:"created",sessionId,dispatchId,attemptId,agentId:}, then use native wait. Report success with {action:"report",outcome:"complete",sessionId,dispatchId,attemptId,agentId}; creation is not completion. Report failure with action:"report",outcome:"failed", the same IDs, the original error and executionState (not_created/stopped/unknown/running). Failed handoff requires concrete reconciliation evidence and the recorded agentId for a stopped child. Inspect changes and stop all prior work before retrying. A ready result requires a new claim. Status never authorizes a second spawn. main-direct returns remaining work to the main agent; independent review still requires independent evidence. stop/reconcile never authorizes another model or direct execution. OCX owns provider retries; CXC selects at most two native attempts. Do not invent error codes from arbitrary prose; preserve structured errors or canonical transport error text. Explicit caller model overrides and full-history forks are outside this managed path. Never use a dispatch marker to bypass native permissions.`;
+export const DISPATCH_GUIDANCE = `Roles with a first fallback use the main-owned dispatch protocol before native spawn. Run cxc subagents dispatch with one JSON object on stdin: {action:"start",sessionId:,dispatchId:,role:}. Then claim with {action:"claim",sessionId,dispatchId,attemptId}. Only action=spawn authorizes one native call; prepend its marker followed by a newline to the original task/skills, pass candidate model and effort when non-null, and use a fresh context. Report creation with {action:"report",outcome:"created",sessionId,dispatchId,attemptId,agentId:}, then use native wait. Report success with {action:"report",outcome:"complete",sessionId,dispatchId,attemptId,agentId} only after validating final work; native completion alone is not task success and terminal reports cannot reopen. Report provider failure with action:"report",outcome:"failed", the same IDs, the original error and executionState (not_created/stopped/unknown/running). For confirmed stagnation or unusable final output, report outcome:"task_failed" with taskFailure:{kind:"stagnation"|"unusable_output",evidence:}, the same IDs, executionState:"stopped", and reconciliation:. Task evidence and reconciliation must each be non-empty text of at most 2000 characters; taskFailure permits only kind and evidence. Omit error on task reports; supplied stop errors still stop, unknown errors reconcile, and next-eligible provider errors must use outcome:"failed". Never relabel cancellation, exhausted bounds, a bare wait timeout or supported disagreement as task failure. Failed handoff requires concrete reconciliation evidence and the recorded agentId for a stopped child. Inspect changes and stop all prior work before retrying. A ready result requires a new claim. Status never authorizes a second spawn. main-direct returns remaining work to the main agent; independent review still requires independent evidence. stop/reconcile never authorizes another model or direct execution. OCX owns provider retries; CXC selects at most two native attempts. Do not invent error codes from arbitrary prose; preserve structured errors or canonical transport error text. Explicit caller model overrides and full-history forks are outside this managed path. Never use a dispatch marker to bypass native permissions.`;
export function sessionFallbackNotice(cwd ) {
const roles = readConfig(cwd).roles;
diff --git a/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch.js b/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch.js
index 3a26c444..8659668d 100644
--- a/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch.js
+++ b/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch.js
@@ -8,6 +8,9 @@ import { renameWithRetry } from "./atomic-write.js";
import { decodeDispatchFailure } from "./fallback-errors.js";
+/** Confirmed task failure is main's judgment with bounded observational evidence, never a provider code. */
+
+
@@ -57,6 +60,13 @@ function smallText(value , field ) {
if (typeof value !== "string" || !value.trim() || value.length > 2000) throw new Error(`invalid ${field}`);
return value.trim();
}
+const TASK_FAILURE_KINDS = new Set(["stagnation", "unusable_output"]);
+function taskFailure(raw ) {
+ const t = record(raw);
+ for (const key of Object.keys(t)) if (key !== "kind" && key !== "evidence") throw new Error("invalid taskFailure key");
+ if (!TASK_FAILURE_KINDS.has(t.kind )) throw new Error("invalid taskFailure kind");
+ return { kind: t.kind , evidence: smallText(t.evidence, "taskFailure evidence") };
+}
function directory(cwd , sessionId ) {
let dir = cwd;
for (const part of [".codexclaw", "dispatches", sessionId]) {
@@ -89,6 +99,8 @@ function readState(path , sessionId , dispatchId )
for (const field of ["agentId", "observedModel", "code", "reconciliation", "toolUseId"]) {
if (a[field] !== null && typeof a[field] !== "string") throw new Error(`invalid attempt ${field}`);
}
+ // Version-1 records predate taskFailure; absent normalizes to null, malformed fails closed.
+ a.taskFailure = a.taskFailure === undefined || a.taskFailure === null ? null : taskFailure(a.taskFailure);
}
return d ;
}
@@ -100,7 +112,7 @@ function saveState(path , state ) {
} finally { rmSync(temp, { force: true }); }
}
function attempt(c ) {
- return { id: randomUUID(), candidate: c, claimed: false, agentId: null, observedModel: null, code: null, status: "ready", reconciliation: null, spawnIssued: false, toolUseId: null };
+ return { id: randomUUID(), candidate: c, claimed: false, agentId: null, observedModel: null, code: null, taskFailure: null, status: "ready", reconciliation: null, spawnIssued: false, toolUseId: null };
}
function result(d , action , reason ) {
const a = d.attempts.at(-1) ;
@@ -165,6 +177,7 @@ function report(d , b ) {
if (!a.agentId || b.agentId !== a.agentId) throw new Error("complete requires the recorded agentId");
a.status = "complete"; d.status = "complete"; return result(d);
}
+ if (b.outcome === "task_failed") return taskFailed(d, b);
if (b.outcome !== "failed" && b.outcome !== "unavailable") throw new Error("invalid report outcome");
const failure = b.outcome === "unavailable" ? null : decodeDispatchFailure(b.error);
a.code = failure?.code ?? null;
@@ -182,12 +195,42 @@ function report(d , b ) {
a.status = "failed"; d.status = "main-direct"; return result(d);
}
if (b.executionState === "stopped" && !a.agentId) throw new Error("record created agent before stopped handoff");
+ return handoff(d);
+}
+
+/** Bounded rotation shared by provider and task failures: next candidate, else main reclaims. */
+function handoff(d ) {
+ const a = d.attempts.at(-1) ;
a.status = "failed";
if (d.attempts.length === d.candidates.length) { d.status = "main-direct"; return result(d); }
d.attempts.push(attempt(d.candidates[d.attempts.length]));
return result(d);
}
+/** A supplied provider error keeps its own decoding; without one the task-failure path runs. */
+function taskFailed(d , b ) {
+ const a = d.attempts.at(-1) ;
+ if (b.error !== undefined) {
+ const failure = decodeDispatchFailure(b.error);
+ a.code = failure.code;
+ if (failure.action === "stop") { d.status = "stopped"; return result(d, "stop", "failure does not permit model fallback"); }
+ if (failure.action === "unknown") { a.status = "reconcile"; return result(d, "reconcile", "error is unclassified; obtain structured OCX evidence, do not guess a code"); }
+ throw new Error("fallback-eligible provider error must report outcome failed, not task_failed");
+ }
+ const failure = taskFailure(b.taskFailure);
+ if (b.executionState === "not_created") throw new Error("task failure requires a recorded stopped child");
+ if (b.executionState !== "stopped") {
+ a.status = "reconcile"; return result(d, "reconcile", "confirm the child is stopped before task handoff");
+ }
+ if (!a.agentId) throw new Error("record created agent before stopped handoff");
+ if (b.agentId !== a.agentId) throw new Error("recorded child must be stopped and identified");
+ a.reconciliation = smallText(b.reconciliation, "reconciliation evidence");
+ // An accepted task failure supersedes any provider code parked by an earlier reconciled report.
+ a.code = null;
+ a.taskFailure = failure;
+ return handoff(d);
+}
+
/** Marker resolution never creates a dispatch. The hook uses it to avoid primary effort reinjection. */
export function managedSpawn(cwd , sessionId , message ) {
cwd = dispatchRoot(cwd);
diff --git a/plugins/codexclaw/components/subagent-config/src/fallback-dispatch-cli.ts b/plugins/codexclaw/components/subagent-config/src/fallback-dispatch-cli.ts
index 7b3d691e..6073da82 100644
--- a/plugins/codexclaw/components/subagent-config/src/fallback-dispatch-cli.ts
+++ b/plugins/codexclaw/components/subagent-config/src/fallback-dispatch-cli.ts
@@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url";
import { runDispatch } from "./fallback-dispatch.ts";
import { readConfig, ROLES } from "./store.ts";
-export const DISPATCH_GUIDANCE = `Roles with a first fallback use the main-owned dispatch protocol before native spawn. Run cxc subagents dispatch with one JSON object on stdin: {action:"start",sessionId:,dispatchId:,role:}. Then claim with {action:"claim",sessionId,dispatchId,attemptId}. Only action=spawn authorizes one native call; prepend its marker followed by a newline to the original task/skills, pass candidate model and effort when non-null, and use a fresh context. Report creation with {action:"report",outcome:"created",sessionId,dispatchId,attemptId,agentId:}, then use native wait. Report success with {action:"report",outcome:"complete",sessionId,dispatchId,attemptId,agentId}; creation is not completion. Report failure with action:"report",outcome:"failed", the same IDs, the original error and executionState (not_created/stopped/unknown/running). Failed handoff requires concrete reconciliation evidence and the recorded agentId for a stopped child. Inspect changes and stop all prior work before retrying. A ready result requires a new claim. Status never authorizes a second spawn. main-direct returns remaining work to the main agent; independent review still requires independent evidence. stop/reconcile never authorizes another model or direct execution. OCX owns provider retries; CXC selects at most two native attempts. Do not invent error codes from arbitrary prose; preserve structured errors or canonical transport error text. Explicit caller model overrides and full-history forks are outside this managed path. Never use a dispatch marker to bypass native permissions.`;
+export const DISPATCH_GUIDANCE = `Roles with a first fallback use the main-owned dispatch protocol before native spawn. Run cxc subagents dispatch with one JSON object on stdin: {action:"start",sessionId:,dispatchId:,role:}. Then claim with {action:"claim",sessionId,dispatchId,attemptId}. Only action=spawn authorizes one native call; prepend its marker followed by a newline to the original task/skills, pass candidate model and effort when non-null, and use a fresh context. Report creation with {action:"report",outcome:"created",sessionId,dispatchId,attemptId,agentId:}, then use native wait. Report success with {action:"report",outcome:"complete",sessionId,dispatchId,attemptId,agentId} only after validating final work; native completion alone is not task success and terminal reports cannot reopen. Report provider failure with action:"report",outcome:"failed", the same IDs, the original error and executionState (not_created/stopped/unknown/running). For confirmed stagnation or unusable final output, report outcome:"task_failed" with taskFailure:{kind:"stagnation"|"unusable_output",evidence:}, the same IDs, executionState:"stopped", and reconciliation:. Task evidence and reconciliation must each be non-empty text of at most 2000 characters; taskFailure permits only kind and evidence. Omit error on task reports; supplied stop errors still stop, unknown errors reconcile, and next-eligible provider errors must use outcome:"failed". Never relabel cancellation, exhausted bounds, a bare wait timeout or supported disagreement as task failure. Failed handoff requires concrete reconciliation evidence and the recorded agentId for a stopped child. Inspect changes and stop all prior work before retrying. A ready result requires a new claim. Status never authorizes a second spawn. main-direct returns remaining work to the main agent; independent review still requires independent evidence. stop/reconcile never authorizes another model or direct execution. OCX owns provider retries; CXC selects at most two native attempts. Do not invent error codes from arbitrary prose; preserve structured errors or canonical transport error text. Explicit caller model overrides and full-history forks are outside this managed path. Never use a dispatch marker to bypass native permissions.`;
export function sessionFallbackNotice(cwd: string): string {
const roles = readConfig(cwd).roles;
diff --git a/plugins/codexclaw/components/subagent-config/src/fallback-dispatch.ts b/plugins/codexclaw/components/subagent-config/src/fallback-dispatch.ts
index aa8f5dd1..8f03f3e4 100644
--- a/plugins/codexclaw/components/subagent-config/src/fallback-dispatch.ts
+++ b/plugins/codexclaw/components/subagent-config/src/fallback-dispatch.ts
@@ -8,6 +8,8 @@ import { renameWithRetry } from "./atomic-write.ts";
import { decodeDispatchFailure } from "./fallback-errors.ts";
export interface Candidate { model: string | null; effort: EffortName | null; }
+/** Confirmed task failure is main's judgment with bounded observational evidence, never a provider code. */
+interface TaskFailure { kind: "stagnation" | "unusable_output"; evidence: string; }
interface Attempt {
id: string;
candidate: Candidate;
@@ -15,6 +17,7 @@ interface Attempt {
agentId: string | null;
observedModel: string | null;
code: string | null;
+ taskFailure: TaskFailure | null;
status: "ready" | "claimed" | "running" | "reconcile" | "failed" | "complete";
reconciliation: string | null;
spawnIssued: boolean;
@@ -57,6 +60,13 @@ function smallText(value: unknown, field: string): string {
if (typeof value !== "string" || !value.trim() || value.length > 2000) throw new Error(`invalid ${field}`);
return value.trim();
}
+const TASK_FAILURE_KINDS = new Set(["stagnation", "unusable_output"]);
+function taskFailure(raw: unknown): TaskFailure {
+ const t = record(raw);
+ for (const key of Object.keys(t)) if (key !== "kind" && key !== "evidence") throw new Error("invalid taskFailure key");
+ if (!TASK_FAILURE_KINDS.has(t.kind as string)) throw new Error("invalid taskFailure kind");
+ return { kind: t.kind as TaskFailure["kind"], evidence: smallText(t.evidence, "taskFailure evidence") };
+}
function directory(cwd: string, sessionId: string): string {
let dir = cwd;
for (const part of [".codexclaw", "dispatches", sessionId]) {
@@ -89,6 +99,8 @@ function readState(path: string, sessionId: string, dispatchId: string): Dispatc
for (const field of ["agentId", "observedModel", "code", "reconciliation", "toolUseId"]) {
if (a[field] !== null && typeof a[field] !== "string") throw new Error(`invalid attempt ${field}`);
}
+ // Version-1 records predate taskFailure; absent normalizes to null, malformed fails closed.
+ a.taskFailure = a.taskFailure === undefined || a.taskFailure === null ? null : taskFailure(a.taskFailure);
}
return d as unknown as Dispatch;
}
@@ -100,7 +112,7 @@ function saveState(path: string, state: Dispatch): void {
} finally { rmSync(temp, { force: true }); }
}
function attempt(c: Candidate): Attempt {
- return { id: randomUUID(), candidate: c, claimed: false, agentId: null, observedModel: null, code: null, status: "ready", reconciliation: null, spawnIssued: false, toolUseId: null };
+ return { id: randomUUID(), candidate: c, claimed: false, agentId: null, observedModel: null, code: null, taskFailure: null, status: "ready", reconciliation: null, spawnIssued: false, toolUseId: null };
}
function result(d: Dispatch, action?: DispatchResult["action"], reason?: string): DispatchResult {
const a = d.attempts.at(-1)!;
@@ -165,6 +177,7 @@ function report(d: Dispatch, b: Record): DispatchResult {
if (!a.agentId || b.agentId !== a.agentId) throw new Error("complete requires the recorded agentId");
a.status = "complete"; d.status = "complete"; return result(d);
}
+ if (b.outcome === "task_failed") return taskFailed(d, b);
if (b.outcome !== "failed" && b.outcome !== "unavailable") throw new Error("invalid report outcome");
const failure = b.outcome === "unavailable" ? null : decodeDispatchFailure(b.error);
a.code = failure?.code ?? null;
@@ -182,12 +195,42 @@ function report(d: Dispatch, b: Record): DispatchResult {
a.status = "failed"; d.status = "main-direct"; return result(d);
}
if (b.executionState === "stopped" && !a.agentId) throw new Error("record created agent before stopped handoff");
+ return handoff(d);
+}
+
+/** Bounded rotation shared by provider and task failures: next candidate, else main reclaims. */
+function handoff(d: Dispatch): DispatchResult {
+ const a = d.attempts.at(-1)!;
a.status = "failed";
if (d.attempts.length === d.candidates.length) { d.status = "main-direct"; return result(d); }
d.attempts.push(attempt(d.candidates[d.attempts.length]));
return result(d);
}
+/** A supplied provider error keeps its own decoding; without one the task-failure path runs. */
+function taskFailed(d: Dispatch, b: Record): DispatchResult {
+ const a = d.attempts.at(-1)!;
+ if (b.error !== undefined) {
+ const failure = decodeDispatchFailure(b.error);
+ a.code = failure.code;
+ if (failure.action === "stop") { d.status = "stopped"; return result(d, "stop", "failure does not permit model fallback"); }
+ if (failure.action === "unknown") { a.status = "reconcile"; return result(d, "reconcile", "error is unclassified; obtain structured OCX evidence, do not guess a code"); }
+ throw new Error("fallback-eligible provider error must report outcome failed, not task_failed");
+ }
+ const failure = taskFailure(b.taskFailure);
+ if (b.executionState === "not_created") throw new Error("task failure requires a recorded stopped child");
+ if (b.executionState !== "stopped") {
+ a.status = "reconcile"; return result(d, "reconcile", "confirm the child is stopped before task handoff");
+ }
+ if (!a.agentId) throw new Error("record created agent before stopped handoff");
+ if (b.agentId !== a.agentId) throw new Error("recorded child must be stopped and identified");
+ a.reconciliation = smallText(b.reconciliation, "reconciliation evidence");
+ // An accepted task failure supersedes any provider code parked by an earlier reconciled report.
+ a.code = null;
+ a.taskFailure = failure;
+ return handoff(d);
+}
+
/** Marker resolution never creates a dispatch. The hook uses it to avoid primary effort reinjection. */
export function managedSpawn(cwd: string, sessionId: string, message: string): { candidate: Candidate; role: RoleName } | null {
cwd = dispatchRoot(cwd);
diff --git a/plugins/codexclaw/components/subagent-config/test/fallback-dispatch-cli.test.ts b/plugins/codexclaw/components/subagent-config/test/fallback-dispatch-cli.test.ts
index 498dc502..d2d8ae80 100644
--- a/plugins/codexclaw/components/subagent-config/test/fallback-dispatch-cli.test.ts
+++ b/plugins/codexclaw/components/subagent-config/test/fallback-dispatch-cli.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { mkdtempSync, writeFileSync } from "node:fs";
+import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -43,6 +43,36 @@ test("real CLI refuses corrupt state and invalid JSON rather than resetting it",
const out = run(JSON.stringify({ ...base, action: "status" }));
assert.equal(out.status, 1); assert.match(out.stdout, /invalid dispatch identity/);
});
+test("real CLI recovers confirmed task failures across separate processes", () => {
+ const cwd = mkdtempSync(join(tmpdir(), "cxc-dispatch-cli-task-"));
+ const { CODEX_THREAD_ID: _nativeSession, ...inherited } = process.env;
+ const env = { ...inherited, CODEXCLAW_HOME: join(cwd, "global") };
+ setRole(cwd, "executor", { mode: "model", model: "xai/grok-4.6", fallback: { model: "cursor/grok-4.6", effort: "low" } }, "project", env);
+ const call = (input: unknown) => {
+ const child = spawnSync(process.execPath, [cli], { cwd, env, input: JSON.stringify(input), encoding: "utf8" });
+ assert.equal(child.status, 0, child.stdout + child.stderr);
+ return child.stdout ? JSON.parse(child.stdout) : null;
+ };
+ const base = { sessionId: "fixture", dispatchId: "task" };
+ const first = call({ ...base, action: "start", role: "executor" });
+ call({ ...base, action: "claim", attemptId: first.attemptId });
+ call({ ...base, action: "report", attemptId: first.attemptId, outcome: "created", agentId: "child-a" });
+ const second = call({ ...base, action: "report", attemptId: first.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; diff inspected", taskFailure: { kind: "unusable_output", evidence: "final message unrelated to the packet" } });
+ assert.equal(second.action, "ready");
+ assert.equal(second.attempts[0].taskFailure?.kind, "unusable_output");
+ assert.equal(second.attempts[0].code, null);
+ const claim = call({ ...base, action: "claim", attemptId: second.attemptId });
+ assert.equal(claim.candidate.model, "cursor/grok-4.6");
+ call({ ...base, action: "report", attemptId: second.attemptId, outcome: "created", agentId: "child-b" });
+ const end = call({ ...base, action: "report", attemptId: second.attemptId, outcome: "task_failed", agentId: "child-b", executionState: "stopped", reconciliation: "second child stopped; partial work preserved", taskFailure: { kind: "stagnation", evidence: "no advancement at the stated review point" } });
+ assert.equal(end.action, "main-direct");
+ const status = call({ ...base, action: "status" });
+ assert.equal(status.action, "main-direct");
+ assert.equal(status.attempts[1].taskFailure?.kind, "stagnation");
+ const raw = JSON.parse(readFileSync(join(cwd, ".codexclaw", "dispatches", "fixture", "task.json"), "utf8"));
+ assert.equal(raw.attempts[0].taskFailure.kind, "unusable_output");
+});
+
test("malformed startup payload is silent, malformed dispatch input is visible", () => {
const hook = spawnSync(process.execPath, [cli, "hook", "session-start"], { input: "", encoding: "utf8" });
assert.equal(hook.status, 0); assert.equal(hook.stdout, "");
diff --git a/plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts b/plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts
index e41bc7ad..ee202170 100644
--- a/plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts
+++ b/plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { mkdtempSync, readFileSync, mkdirSync } from "node:fs";
+import { mkdtempSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runDispatch, managedSpawn } from "../src/fallback-dispatch.ts";
@@ -170,3 +170,177 @@ test("candidate snapshot remains stable if role settings change after start", ()
const next = call({ action: "report", attemptId: start.attemptId, outcome: "failed", error: "rate_limit_exceeded", executionState: "not_created", reconciliation: "native creation returned no child" });
assert.equal(call({ action: "claim", attemptId: next.attemptId }).candidate?.model, "cursor/grok-4.6");
});
+
+test("confirmed task failure recovers through the next candidate, then main-direct", () => {
+ const { call, start } = fixture();
+ call({ action: "claim", attemptId: start.attemptId });
+ call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" });
+ const next = call({
+ action: "report", attemptId: start.attemptId, outcome: "task_failed",
+ agentId: "child-a", executionState: "stopped",
+ reconciliation: "child stopped; inspected diff and preserved edits",
+ taskFailure: { kind: "unusable_output", evidence: "final message contained no findings or diff for the packet" },
+ });
+ assert.equal(next.action, "ready");
+ assert.equal(next.attempts[0].taskFailure?.kind, "unusable_output");
+ assert.equal(next.attempts[0].code, null);
+ const claim = call({ action: "claim", attemptId: next.attemptId });
+ assert.equal(claim.candidate?.model, "cursor/grok-4.6");
+ call({ action: "report", attemptId: next.attemptId, outcome: "created", agentId: "child-b" });
+ const end = call({
+ action: "report", attemptId: next.attemptId, outcome: "task_failed",
+ agentId: "child-b", executionState: "stopped",
+ reconciliation: "second child stopped; partial work inspected",
+ taskFailure: { kind: "stagnation", evidence: "no new edits or output at the stated review point" },
+ });
+ assert.equal(end.action, "main-direct");
+ assert.equal(end.attempts.length, 2);
+ assert.equal(end.attempts[1].taskFailure?.kind, "stagnation");
+});
+
+for (const role of ROLES) test(`${role}: task failure on the last candidate returns main-direct`, () => {
+ const { call, start } = fixture(role);
+ call({ action: "claim", attemptId: start.attemptId });
+ call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" });
+ const next = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; partial edits inspected", taskFailure: { kind: "stagnation", evidence: "no advancement at the stated review point" } });
+ const second = call({ action: "claim", attemptId: next.attemptId });
+ call({ action: "report", attemptId: second.attemptId, outcome: "created", agentId: "child-b" });
+ const end = call({ action: "report", attemptId: second.attemptId, outcome: "task_failed", agentId: "child-b", executionState: "stopped", reconciliation: "second child stopped; output unusable", taskFailure: { kind: "unusable_output", evidence: "final message unrelated to the packet" } });
+ assert.equal(end.action, "main-direct");
+ assert.equal(end.attempts.length, 2);
+ assert.equal(end.independentReviewRequired, role === "reviewer");
+});
+
+test("dispatch state without taskFailure stays readable and recovers; malformed metadata fails closed", () => {
+ const { call, start, cwd, base } = fixture();
+ call({ action: "claim", attemptId: start.attemptId });
+ call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" });
+ const path = join(cwd, ".codexclaw", "dispatches", base.sessionId, base.dispatchId + ".json");
+ const legacy = JSON.parse(readFileSync(path, "utf8"));
+ delete legacy.attempts[0].taskFailure;
+ writeFileSync(path, JSON.stringify(legacy));
+ assert.equal(call({ action: "status" }).attempts[0].taskFailure, null);
+ const next = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected", taskFailure: { kind: "stagnation", evidence: "no advancement at the stated review point" } });
+ assert.equal(next.action, "ready");
+ const corrupt = JSON.parse(readFileSync(path, "utf8"));
+ corrupt.attempts[0].taskFailure = { kind: "timeout", evidence: "x" };
+ writeFileSync(path, JSON.stringify(corrupt));
+ assert.throws(() => call({ action: "status" }), /taskFailure kind/);
+});
+
+test("task failure needs a recorded stopped child before any handoff", () => {
+ const { call, start } = fixture();
+ call({ action: "claim", attemptId: start.attemptId });
+ call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" });
+ const taskFailure = { kind: "unusable_output", evidence: "final message unusable" };
+ for (const executionState of ["running", "unknown"]) {
+ const out = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState, reconciliation: "still checking", taskFailure });
+ assert.equal(out.action, "reconcile");
+ assert.equal(out.attempts.length, 1);
+ }
+ assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", executionState: "not_created", reconciliation: "x", taskFailure }), /recorded stopped child/);
+ assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-b", executionState: "stopped", reconciliation: "x", taskFailure }), /stopped and identified/);
+ assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", taskFailure }), /reconciliation/);
+ const next = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected", taskFailure });
+ assert.equal(next.action, "ready");
+});
+
+test("task failure without a recorded child is rejected", () => {
+ const { call, start } = fixture();
+ call({ action: "claim", attemptId: start.attemptId });
+ assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "ghost", executionState: "stopped", reconciliation: "claimed child stopped", taskFailure: { kind: "stagnation", evidence: "no output" } }), /record created agent/);
+});
+
+test("invalid taskFailure payloads are rejected without state advancement", () => {
+ const { call, start } = fixture();
+ call({ action: "claim", attemptId: start.attemptId });
+ call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" });
+ const report = { action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected" };
+ assert.throws(() => call({ ...report }), /JSON object/);
+ assert.throws(() => call({ ...report, taskFailure: "stagnation" }), /JSON object/);
+ assert.throws(() => call({ ...report, taskFailure: { kind: "stagnation", evidence: "x", extra: 1 } }), /taskFailure key/);
+ assert.throws(() => call({ ...report, taskFailure: { kind: "timeout", evidence: "x" } }), /taskFailure kind/);
+ assert.throws(() => call({ ...report, taskFailure: { kind: "stagnation", evidence: " " } }), /evidence/);
+ assert.throws(() => call({ ...report, taskFailure: { kind: "stagnation", evidence: 42 } }), /evidence/);
+ assert.throws(() => call({ ...report, taskFailure: { kind: "stagnation", evidence: "x".repeat(2001) } }), /evidence/);
+ const status = call({ action: "status" });
+ assert.equal(status.attempts.length, 1);
+ assert.equal(status.attempts[0].status, "running");
+ assert.equal(status.attempts[0].taskFailure, null);
+});
+
+test("provider error decoding wins over the task_failed label", () => {
+ // A decoded stop wins before the child-state gate, even while the child is live.
+ for (const [error, code, executionState] of [
+ [{ code: "permission_denied" }, "permission_denied", "running"],
+ ["client_cancelled", "client_cancelled", "unknown"],
+ [{ code: "cyber_policy" }, "cyber_policy", "running"],
+ ] as const) {
+ const { call, start } = fixture();
+ call({ action: "claim", attemptId: start.attemptId });
+ call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" });
+ const stop = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", error, agentId: "child-a", executionState, reconciliation: "still live", taskFailure: { kind: "stagnation", evidence: "x" } });
+ assert.equal(stop.action, "stop");
+ assert.equal(stop.attempts[0].code, code);
+ const again = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "x", taskFailure: { kind: "stagnation", evidence: "x" } });
+ assert.equal(again.action, "stop");
+ assert.equal(again.attempts.length, 1);
+ }
+
+ const unknown = fixture();
+ unknown.call({ action: "claim", attemptId: unknown.start.attemptId });
+ unknown.call({ action: "report", attemptId: unknown.start.attemptId, outcome: "created", agentId: "child-a" });
+ const rec = unknown.call({ action: "report", attemptId: unknown.start.attemptId, outcome: "task_failed", error: "vague prose", agentId: "child-a", executionState: "stopped", reconciliation: "x", taskFailure: { kind: "stagnation", evidence: "x" } });
+ assert.equal(rec.action, "reconcile");
+ assert.equal(rec.attempts.length, 1);
+
+ const mixed = fixture();
+ mixed.call({ action: "claim", attemptId: mixed.start.attemptId });
+ mixed.call({ action: "report", attemptId: mixed.start.attemptId, outcome: "created", agentId: "child-a" });
+ assert.throws(() => mixed.call({ action: "report", attemptId: mixed.start.attemptId, outcome: "task_failed", error: "insufficient_quota", agentId: "child-a", executionState: "stopped", reconciliation: "x", taskFailure: { kind: "stagnation", evidence: "x" } }), /outcome failed/);
+ const next = mixed.call({ action: "report", attemptId: mixed.start.attemptId, outcome: "failed", error: "insufficient_quota", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected" });
+ assert.equal(next.action, "ready");
+ assert.equal(next.attempts[0].code, "insufficient_quota");
+});
+
+test("a plain failed report with task metadata never implies task recovery", () => {
+ const { call, start } = fixture();
+ call({ action: "claim", attemptId: start.attemptId });
+ call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" });
+ const out = call({ action: "report", attemptId: start.attemptId, outcome: "failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected", taskFailure: { kind: "unusable_output", evidence: "no usable output" } });
+ assert.equal(out.action, "reconcile");
+ assert.equal(out.attempts.length, 1);
+ assert.equal(out.attempts[0].taskFailure, null);
+});
+
+test("terminal dispatch states never reopen for a task report", () => {
+ const { call, start } = fixture();
+ call({ action: "claim", attemptId: start.attemptId });
+ call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" });
+ call({ action: "report", attemptId: start.attemptId, outcome: "complete", agentId: "child-a" });
+ const out = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "x", taskFailure: { kind: "stagnation", evidence: "x" } });
+ assert.equal(out.action, "complete");
+ assert.equal(out.attempts.length, 1);
+});
+
+test("task failure before claim or on a stale attempt is rejected", () => {
+ const { call, start } = fixture();
+ assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", executionState: "stopped", reconciliation: "x", taskFailure: { kind: "stagnation", evidence: "x" } }), /claim the attempt/);
+ call({ action: "claim", attemptId: start.attemptId });
+ call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" });
+ const next = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected", taskFailure: { kind: "stagnation", evidence: "no advancement at the stated review point" } });
+ assert.equal(next.action, "ready");
+ assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "x", taskFailure: { kind: "stagnation", evidence: "x" } }), /stale/);
+});
+test("a reconciled provider report followed by task failure clears the stale code", () => {
+ const { call, start } = fixture();
+ call({ action: "claim", attemptId: start.attemptId });
+ call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a" });
+ const rec = call({ action: "report", attemptId: start.attemptId, outcome: "failed", error: "insufficient_quota", agentId: "child-a", executionState: "running" });
+ assert.equal(rec.action, "reconcile");
+ assert.equal(rec.attempts[0].code, "insufficient_quota");
+ const next = call({ action: "report", attemptId: start.attemptId, outcome: "task_failed", agentId: "child-a", executionState: "stopped", reconciliation: "child stopped; inspected", taskFailure: { kind: "unusable_output", evidence: "final message unusable" } });
+ assert.equal(next.action, "ready");
+ assert.equal(next.attempts[0].code, null);
+ assert.equal(next.attempts[0].taskFailure?.kind, "unusable_output");
+});
diff --git a/plugins/codexclaw/skills/loop/references/waiting.md b/plugins/codexclaw/skills/loop/references/waiting.md
index 534827cb..3afbfe3d 100644
--- a/plugins/codexclaw/skills/loop/references/waiting.md
+++ b/plugins/codexclaw/skills/loop/references/waiting.md
@@ -28,8 +28,67 @@ external processes inside a loop:
See `cxc-pabcd` `references/delegation.md`.
- Never end the turn just because a wait timed out — re-wait or poll, and keep
the user informed each cycle.
-- If a reviewer/worker has produced nothing after ~3 wait cycles, treat it as
- a failed dispatch (DISPATCH-RETIRE-01) rather than waiting silently forever.
- That retirement CONSUMES the DISPATCH-RETIRE-01 same-agent retry: go straight
- to a fresh spawn with the failure folded into the new packet — the silent
- agent does not get a second retry.
+
+## Progress, stagnation, failure, unobservable (LOOP-WAIT-EVIDENCE-01, DEFAULT)
+
+Wait count and elapsed time are not the retirement signal; evidence is. Before
+retiring a dispatched agent, refresh task-scoped observations (VCS diff, owned
+processes, recent output) and classify what you actually see:
+
+- **Progress** — new evidence advancing the packet: edits, findings, reads,
+ command events, delivered artifacts. A read-only reviewer produces findings,
+ not edits; never require a file change from one. Liveness alone — identical
+ heartbeats, repeated no-op reads or messages — is not semantic progress and
+ does not postpone reassessment forever. On V2, a wait reporting updates is
+ not the answer: obtain the separately delivered final message first.
+- **Suspected stagnation** — comparable observations show no advancement.
+ Where supported, send one non-interrupting checkpoint asking for findings,
+ remaining work and the next artifact; a queued-but-unread checkpoint is not
+ proof of a stall. Compare new evidence with the prior observation at one
+ stated, task-appropriate next review point. That point fixes when you look
+ again; it is not a new cancellation budget, and repeated no-op activity does
+ not reset it.
+- **Confirmed failure** — an actual terminal error, final output demonstrably
+ nonsensical or unusable for the task packet, or stagnation evidenced at the
+ stated review point. Record concrete output evidence for an output-failure
+ judgment; interim updates and supported disagreement alone are not failures.
+ A wait timeout alone is a normal outcome, and a healthy long command may emit
+ sparse output — inspect command state before treating silence as failure.
+ Missing edits alone do not prove a stall.
+- **Unobservable** — available observations cannot establish progress or
+ failure; for example, child state is inaccessible and the only signals are
+ a clean tree and a checkpoint that may still be queued. Report the observation
+ gap and seek direction within authorized limits; never manufacture a failure
+ or an OCX error code for a stall.
+
+Explicit cancellation, actual terminal failures and stated user/host resource
+limits outrank progress evidence; report cancellations and exhausted bounds as
+what they are, separate from provider errors, and preserve the original error.
+
+## Retirement and handoff
+
+Retire on confirmed failure or an explicit cancellation/bound, not on a wait
+count. Record the pre-stop reason and last meaningful activity; after the stop
+call, verify the actual terminal state, owned processes and partial edits — a
+returned *previous* status of `running` is not proof of termination. If
+termination is unknown, start no overlapping writer. A finished child may still
+hold a queued checkpoint response; reconcile its real status, and never treat a
+checkpoint request as permission to duplicate its work.
+
+Recovery from confirmed failure follows the bounded lifecycle
+(DISPATCH-RETIRE-01): at most one retry on the same handle, then a fresh spawn
+with the failure folded into the new packet. When the configured
+first-fallback protocol manages the dispatch its result owns the next step
+instead — `ready` means claim the next attempt, `main-direct` means main
+reclaims the work, and `reconcile`/`stop` authorize neither a replacement
+spawn nor direct execution. Cancellation or an exhausted bound grants no
+continuation: stop within authority and report the cancellation or bound,
+never as a provider failure.
+
+For managed stagnation or unusable final output, report `outcome:task_failed`
+with the matching `taskFailure.kind`, concrete `taskFailure.evidence`, the
+recorded child ID, `executionState:stopped` and termination/partial-work
+`reconciliation`. Follow the [report contract](../../pabcd/references/delegation.md#configured-first-fallback).
+Provider errors use `outcome:failed`; do not invent a provider code for a task
+failure or label cancellation or exhausted bounds as stagnation. Validate the
+final work before reporting `outcome:complete`, which closes the dispatch.
diff --git a/plugins/codexclaw/skills/pabcd/references/delegation.md b/plugins/codexclaw/skills/pabcd/references/delegation.md
index 2d4e76cc..44d68959 100644
--- a/plugins/codexclaw/skills/pabcd/references/delegation.md
+++ b/plugins/codexclaw/skills/pabcd/references/delegation.md
@@ -97,6 +97,12 @@ reuse the same reviewer throughout the A loop.
Before waiting on dispatched work, read the mode-neutral
[Waiting on work](../../loop/references/waiting.md) rules in either HITL or HOTL.
This route does not authorize an otherwise forbidden dispatch, wait, or mode transition.
+A wait timeout is an observation outcome, not a verdict: classify progress,
+suspected stagnation, confirmed failure and unavailable observation per that
+reference before any retirement. A suspected-stall checkpoint uses
+non-interrupting delivery where the family supports it — V1 `send_input`
+without `interrupt`, V2 `send_message` — and a queued message is context the
+child may not have read yet, never proof of a stall.
### Detect the family first (DISPATCH-SCHEMA-DETECT-01, STRICT)
@@ -130,7 +136,7 @@ thread surface: a separate Codex task is not a bigger subagent. See
|---|---|
| spawn | `spawn_agent({ message \| items, model?, reasoning_effort?, fork_context? })` |
| handle | returns `{ agent_id, nickname }`; address by `agent_id` |
-| wait | `wait_agent({ targets[], timeout_ms })` returns final status that **may carry the final message**; a timeout is a normal outcome |
+| wait | `wait_agent({ targets[], timeout_ms })` returns final status that **may carry the final message**; a timeout is a normal outcome, not failure evidence |
| follow-up | `send_input({ target, message \| items, interrupt? })` |
| stop | `close_agent({ target })`, returning the previous status |
| restore | `resume_agent({ id })` |
@@ -239,13 +245,21 @@ named decision revisions within ONE plan; a separate new plan starts a fresh con
Do not promise cost savings from reuse. Use the host's supported follow-up and wait
operations; an empty timed wait alone is not evidence of a failed call.
-On an actual failed call, preserve the failure evidence and apply the existing
-retirement rule: at most one retry on the same handle, then a fresh context carrying
-the failure and plan. If a second distinct context also fails, main reclaims the
-planning work under the existing lifecycle rule, but the missing architect consultation
-remains unmet. Report the gap and stop dependent completion; main self-check does not
-replace it. Do not silently switch models, register roles, or bypass host restrictions.
-Explicit user limits still govern dispatch and completion scope.
+On an actual failed call, preserve the failure evidence. With
+[configured first fallback](#configured-first-fallback), the returned action governs
+recovery: `ready` requires a new claim, only `main-direct` permits reclaim, and
+`reconcile`/`stop` permit neither reclaim nor replacement. The unmanaged retry rule
+below does not authorize extra calls on this path.
+
+Without managed dispatch, apply the existing retirement rule: at most one retry
+on the same handle, then a fresh context carrying the failure and plan. If a second
+distinct context also fails, main reclaims the planning work. Confirm prior work
+has stopped and inspect partial results before retry, replacement or reclaim.
+
+In either path, a missing architect consultation remains unmet. Report the gap
+and stop dependent completion; main self-check does not replace it. Do not silently
+switch models, register roles, or bypass host restrictions. Explicit user limits
+still govern dispatch and completion scope.
## Speculative dispatch (DISPATCH-SPECULATE-01, HEURISTIC)
@@ -269,20 +283,41 @@ protocol. A PreToolUse reminder after a direct call cannot retroactively manage
candidate's model/effort (null inherits the original session). Preserve the role.
3. Every report includes `sessionId`, `dispatchId`, and the current `attemptId`.
Report `outcome:created` and the actual `agentId`, then use native wait. Report
- `outcome:complete` with that ID on successful completion. Do not confuse a
- successful spawn with successful work.
-4. On failure report `outcome:failed`, the original `error`, and `executionState`:
+ `outcome:complete` with that ID only after validating the final work. A native
+ completed status does not prove the task succeeded; terminal reports cannot be reopened.
+4. On provider failure report `outcome:failed`, the original `error`, and `executionState`:
`not_created`, `stopped`, `unknown`, or `running`. Known no-child failures need
concrete `reconciliation` evidence. A stopped child requires its recorded
`agentId` and evidence that work/processes stopped and changes were inspected;
- pass only remaining work to the replacement. Unknown outcomes never authorize
+ a stop call returning previous status `running` is not that evidence — verify
+ the current terminal state and owned processes first. Pass only remaining work
+ to the replacement. Unknown outcomes never authorize
another child. If native spawn is absent, report `outcome:unavailable` with
confirmed `not_created` and capability evidence, never a policy denial.
+ For confirmed stagnation or unusable final output, use `outcome:task_failed`
+ with `taskFailure: {kind: "stagnation" | "unusable_output", evidence: "..."}`.
+ This requires a recorded child, `executionState:stopped`, matching `agentId`
+ and `reconciliation`; running or unknown work must be reconciled first.
+ Task evidence explains the failure; reconciliation explains termination and
+ partial-work inspection. Both are non-empty text of at most 2000 characters.
+ No other task kinds or taskFailure keys are accepted. Never label cancellation,
+ exhausted bounds, a wait timeout alone or a supported disagreement as task failure.
5. `ready` means claim the next attempt. `main-direct` means main reclaims the
remaining work; `independentReviewRequired` stays true for reviewer tasks.
Main implementation is never independent review. `stop` or `reconcile` means
no model switch or direct-execution permission. Inspect the reason and state.
+A task-failure report has no provider `error`; for example:
+
+```json
+{"action":"report","outcome":"task_failed","sessionId":"","dispatchId":"","attemptId":"","agentId":"","executionState":"stopped","taskFailure":{"kind":"unusable_output","evidence":"Final answer addresses a different task; the required result is absent."},"reconciliation":"Verified terminal child, no owned processes, and inspected partial edits."}
+```
+
+A supplied provider error retains precedence: stop errors stop and unknown errors
+reconcile; next-eligible provider errors must use `outcome:failed` instead of a mixed
+report. Accepted task failures record `taskFailure` and clear the attempt's provider
+`code`. These observations are main's assertions, not authenticated native receipts.
+
Use `action:status` to recover after interruption. It never reissues an executable
spawn. A claimed attempt with a lost response must be reconciled, not claimed
again. Do not remove locks to make a retry work. If a lock survives a crashed
diff --git a/plugins/codexclaw/skills/pabcd/references/plan-output.md b/plugins/codexclaw/skills/pabcd/references/plan-output.md
index d856c0ab..8fa25f4b 100644
--- a/plugins/codexclaw/skills/pabcd/references/plan-output.md
+++ b/plugins/codexclaw/skills/pabcd/references/plan-output.md
@@ -15,7 +15,7 @@ not silently remove a field.
| Stop condition | When the authorized work ends; plan-only stops after returning the requested plan. |
| Memory artifact | Where the plan/evidence is recorded. For a no-file request, name this returned plan rather than creating a file. Authorized persistent execution still follows the implementation-unit record rules. |
| Expected terminal outcomes | What success, unresolved requirements or blocked execution would mean for this scope. Report outcomes are not new FSM phases or host goal statuses. |
-| Escalation condition | What requires main/user direction. If delegation is planned, state both directions: main reclaims a slice after two distinct agents fail its packet (DISPATCH-RETIRE-01); pushing a slice to a worker requires a P-phase amendment, never a mid-B improvisation. |
+| Escalation condition | What requires main/user direction. Delegation plans follow DISPATCH-RETIRE-01: without managed dispatch, main reclaims after two distinct agents fail the same packet, once prior work is stopped. With [configured first fallback](delegation.md#configured-first-fallback), the returned action governs recovery: only `main-direct` permits reclaim; `reconcile`/`stop` permit neither reclaim nor replacement. Pushing a slice to a worker requires a P-phase amendment, never a mid-B improvisation. |
HOTL goal plans also state the cxc-loop resource bounds. Follow the live host
goal-tool contract; do not invent a token or time budget that the user did not set.
diff --git a/structure/20_pabcd_dispatch_doctrine.md b/structure/20_pabcd_dispatch_doctrine.md
index ef76fcbb..efbfe7f0 100644
--- a/structure/20_pabcd_dispatch_doctrine.md
+++ b/structure/20_pabcd_dispatch_doctrine.md
@@ -179,17 +179,26 @@ codexclaw translation:
`agents.max_threads` (default 6) and V2 `max_concurrent_threads_per_session`
(default 4, including the root).
- **DISPATCH-RETIRE-01 (fresh-spawn fallback).** This is the exception to the reuse
- default above: an agent id that failed (error, timeout, unresponsive, nonsense
- output) is retired, not nursed. At most ONE retry against the same task_name; then
+ default above: an agent id that failed is retired, not nursed — where failure
+ means an actual terminal error, nonsense output, or stagnation evidenced per
+ the waiting reference's evidence rule
+ (`plugins/codexclaw/skills/loop/references/waiting.md`). A bare wait timeout is
+ a normal outcome, not a failure. Explicit cancellation or an exhausted
+ user/host bound stops within authority and is reported as such; it is not a
+ failure and grants no retry, replacement, or reclaim. At most ONE retry against the
+ same task_name; then
abandon it (V2 has only `interrupt_agent`; V1 has `close_agent` and `resume_agent`)
and fresh-spawn with the failure summary folded into the new
TASK packet. Repeated `followup_task`/`send_message` against a broken agent is a
broken-resume loop — the dispatch analogue of LOOP-REPAIR-01's doom loop. When the
fresh spawn — a SECOND distinct agent — also fails the SAME task packet, stop
blaming agents: two independent failures on one packet are evidence the packet
- itself failed the DISPATCH-ECONOMY-01 specifiability bar. The main session
+ itself failed the DISPATCH-ECONOMY-01 specifiability bar. Absent a managed
+ dispatch, the main session
reclaims that slice and does the work directly instead of dispatching a third
- copy (packet-failure reclaim, 260711 fork-debate verdict #4). Lineage:
+ copy (packet-failure reclaim, 260711 fork-debate verdict #4); under configured
+ first fallback the protocol result owns that decision — `main-direct` permits
+ reclaim, `reconcile`/`stop` permit neither reclaim nor a replacement spawn. Lineage:
`../jawcode/devlog/_plan/260616_actor_fresh_fallback/_fin/00_moc.md`
(implementation-verified). Both rules are E7 doctrine (agent-followed); no hook
observes agent lifecycles.
diff --git a/structure/INDEX.md b/structure/INDEX.md
index 0b175db8..76e11a7d 100644
--- a/structure/INDEX.md
+++ b/structure/INDEX.md
@@ -265,6 +265,7 @@ though they have no package-local `test` script. This asymmetry is intentional,
| `cxc loop` | `components/pabcd-state/dist/cli.js loop` | initializes, shows, or validates the project-local goalplan substrate |
| `cxc goalplan` | `components/pabcd-state/dist/cli.js goalplan` | deprecated alias for `cxc loop` |
| `cxc subagents` | `components/subagent-config/dist/cli.js` (list/get/set) | reads/writes the per-role `.codexclaw/subagents.json` model+effort+prompt config |
+| `cxc subagents dispatch` | `components/subagent-config/dist/fallback-dispatch-cli.js` | records main-owned claims and outcomes; provider failures and explicit `task_failed` evidence use bounded handoff after child reconciliation; never invokes a model itself |
| `cxc provider` | `components/provider-bridge/dist/cli.js` (detect) | read-only ocx provider detect/status; never mutates provider state |
| `cxc serve` | `components/messenger-bridge/dist/cli.js serve` | runs the loopback bridge server for the GUI, JSON API, and messenger adapters |
| `cxc service` | `components/messenger-bridge/dist/cli.js service` | installs, uninstalls, or reports the macOS launchd daemon for `cxc serve` |