diff --git a/README.ko.md b/README.ko.md index 72c16d45..be14a72c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -13,9 +13,9 @@

CI - 2,793 tests passing + 2,821 tests passing 29 skills - 24 hooks + 25 hooks Documentation MIT

@@ -28,7 +28,7 @@ codexclaw는 Codex 런타임을 체계적인 개발 환경으로 바꾼다. 별 ## 주요 기능 -**Dev Skill Family** — 표준 부모 스킬(`dev`)이 관리하는 12개 작업 영역별 라우터(`dev-architecture`, `dev-backend`, `dev-frontend`, `dev-testing`, `dev-security`, `dev-debugging`, `dev-data`, `dev-devops`, `dev-code-reviewer`, `dev-scaffolding`, `dev-visualizer`, `dev-uiux-design`)로 구성된다. 모든 라우터는 부모 스킬의 규칙 등급, 검증 게이트, 안전 규칙을 물려받는다. 고유 규칙 ID는 155개다. +**Dev Skill Family** — 표준 부모 스킬(`dev`)이 관리하는 12개 작업 영역별 라우터(`dev-architecture`, `dev-backend`, `dev-frontend`, `dev-testing`, `dev-security`, `dev-debugging`, `dev-data`, `dev-devops`, `dev-code-reviewer`, `dev-scaffolding`, `dev-diagram-viewer`, `dev-uiux-design`)로 구성된다. 모든 라우터는 부모 스킬의 규칙 등급, 검증 게이트, 안전 규칙을 물려받는다. 고유 규칙 ID는 155개다. **PABCD Workflow** — Plan / Audit / Build / Check / Done을 증명 기반 전환 게이트가 있는 파일 기반 FSM으로 구현했다. `cxc orchestrate` 명령으로 단계를 진행하며, 각 전환에는 구조화된 근거가 붙는다. 영속적인 goalplan 원장이 여러 사이클에 걸쳐 작업 단계, 성공 기준, 수집한 증거를 추적한다. @@ -212,7 +212,7 @@ _PATH 수준 `cxc` 진입점(`bin/codexclaw.mjs` + `cli/` 워크스페이스)은 | DevOps / infra | `dev-devops` | `dev-security` for credentials | | Scaffolding | `dev-scaffolding` | `dev-architecture` for boundaries | | Code review | `dev-code-reviewer` | `dev-security` + `dev-testing` | -| Diagrams | `dev-visualizer` | — | +| Diagrams | `dev-diagram-viewer` | — | 각 라우터는 필요할 때만 불러오는 자체 모듈형 참고 자료를 갖추고 있으며, 부모 스킬의 검증 게이트, 규칙 등급, 안전 규칙을 물려받는다. diff --git a/README.md b/README.md index 7acf90cb..7b2c8639 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@

CI - 2,793 tests passing + 2,821 tests passing 29 skills - 24 hooks + 25 hooks Documentation MIT

@@ -28,7 +28,7 @@ codexclaw turns the Codex runtime into a disciplined development environment. It ## Features -**Dev Skill Family** — 12 surface-specific routers (`dev-architecture`, `dev-backend`, `dev-frontend`, `dev-testing`, `dev-security`, `dev-debugging`, `dev-data`, `dev-devops`, `dev-code-reviewer`, `dev-scaffolding`, `dev-visualizer`, `dev-uiux-design`) governed by a canonical parent (`dev`). Every router inherits the parent's rule classes, verification gate, and safety rules. 155 unique rule IDs across the family. +**Dev Skill Family** — 12 surface-specific routers (`dev-architecture`, `dev-backend`, `dev-frontend`, `dev-testing`, `dev-security`, `dev-debugging`, `dev-data`, `dev-devops`, `dev-code-reviewer`, `dev-scaffolding`, `dev-diagram-viewer`, `dev-uiux-design`) governed by a canonical parent (`dev`). Every router inherits the parent's rule classes, verification gate, and safety rules. 155 unique rule IDs across the family. **PABCD Workflow** — Plan / Audit / Build / Check / Done, implemented as a file-backed FSM with attestation-gated transitions. Phases advance through `cxc orchestrate` commands; each transition carries structured evidence. A durable goalplan ledger tracks work phases, success criteria, and captured proof across multiple cycles. @@ -222,7 +222,7 @@ Every coding task is classified (C0-C5) before process depth is chosen. The pare | DevOps / infra | `dev-devops` | `dev-security` for credentials | | Scaffolding | `dev-scaffolding` | `dev-architecture` for boundaries | | Code review | `dev-code-reviewer` | `dev-security` + `dev-testing` | -| Diagrams, visual documents, HTML/SVG reports and PDF composition | `dev-visualizer` | Available document-format owner for export | +| Diagrams, visual documents, HTML/SVG reports and PDF composition | `dev-diagram-viewer` | Available document-format owner for export | Each router carries its own modular references (loaded on demand, never preloaded) and inherits the parent's verification gate, rule classes, and safety rules. diff --git a/README.zh.md b/README.zh.md index 7ea8c542..bc86d6ca 100644 --- a/README.zh.md +++ b/README.zh.md @@ -13,9 +13,9 @@

CI - 2,793 tests passing + 2,821 tests passing 29 skills - 24 hooks + 25 hooks Documentation MIT

@@ -28,7 +28,7 @@ codexclaw 将 Codex 运行时转变为规范化的开发环境。它不提供独 ## 功能 -**Dev Skill Family** — 由规范父级 `dev` 统一管理的 12 个特定领域路由器(`dev-architecture`、`dev-backend`、`dev-frontend`、`dev-testing`、`dev-security`、`dev-debugging`、`dev-data`、`dev-devops`、`dev-code-reviewer`、`dev-scaffolding`、`dev-visualizer`、`dev-uiux-design`)。所有路由器都继承父级的规则分类、验证门和安全规则。共包含 155 个唯一规则 ID。 +**Dev Skill Family** — 由规范父级 `dev` 统一管理的 12 个特定领域路由器(`dev-architecture`、`dev-backend`、`dev-frontend`、`dev-testing`、`dev-security`、`dev-debugging`、`dev-data`、`dev-devops`、`dev-code-reviewer`、`dev-scaffolding`、`dev-diagram-viewer`、`dev-uiux-design`)。所有路由器都继承父级的规则分类、验证门和安全规则。共包含 155 个唯一规则 ID。 **PABCD Workflow** — Plan / Audit / Build / Check / Done,基于文件驱动的 FSM 实现,并通过证明材料控制阶段转换。各阶段通过 `cxc orchestrate` 命令推进,每次转换都携带结构化证据。持久化 goalplan 账本可跨多个周期跟踪工作阶段、成功标准和已收集的证据。 @@ -211,7 +211,7 @@ _PATH 级 `cxc` 入口(`bin/codexclaw.mjs` + `cli/` 工作区)位于仓库 | DevOps / infra | `dev-devops` | `dev-security` for credentials | | Scaffolding | `dev-scaffolding` | `dev-architecture` for boundaries | | Code review | `dev-code-reviewer` | `dev-security` + `dev-testing` | -| Diagrams | `dev-visualizer` | — | +| Diagrams | `dev-diagram-viewer` | — | 每个路由器都有独立的模块化参考资料,仅在需要时加载,不会预加载;同时继承父级的验证门、规则分类和安全规则。 diff --git a/bin/codexclaw.mjs b/bin/codexclaw.mjs index 96e67c34..58597ae8 100755 --- a/bin/codexclaw.mjs +++ b/bin/codexclaw.mjs @@ -218,7 +218,9 @@ function runPabcdState(args) { /** Delegate to the compiled subagent-config CLI. argv: ["subagents", ...rest]. */ function runSubagents(args) { - const res = spawnSync(process.execPath, [subagentConfigCli, ...args], { stdio: "inherit" }); + const dispatch = args[1] === "dispatch"; + const entry = dispatch ? subagentConfigCli.replace(/cli\.js$/, "fallback-dispatch-cli.js") : subagentConfigCli; + const res = spawnSync(process.execPath, [entry, ...(dispatch ? args.slice(2) : args)], { stdio: "inherit" }); return typeof res.status === "number" ? res.status : 1; } diff --git a/devlog/_fin/260909_subagent_first_fallback/000_plan.md b/devlog/_fin/260909_subagent_first_fallback/000_plan.md new file mode 100644 index 00000000..8eb9793e --- /dev/null +++ b/devlog/_fin/260909_subagent_first_fallback/000_plan.md @@ -0,0 +1,24 @@ +# Per-role first fallback + +All three subagent roles gain an optional first fallback from the existing catalog. Exhaustion returns work to the main agent; independent review remains outstanding. Existing global/project role inheritance and primary selections remain intact. + +Loop: satisfy-spec, C3 feature with careful persistent-dispatch boundary validation. Trigger: Jun requested implementation with cxc-loop. Goal: configurable, executable primary -> fallback -> main-direct decision chain. Non-goals: round robin, OCX settings changes, installs, paid probes, push/merge/deploy. Stop: all criteria verified and cycle closed. Evidence: this unit and session goalplan. Outcome: DONE only with passing checks; missing host delivery is reported as a limitation, not automatic execution proof. Escalation: main reclaims failed delegated slices; scope changes amend this plan first. No user token/time cap; bounded local commands only. + +One integrated PABCD cycle covers this feature. Main owns dispatch lifecycle and integration; executor owns configuration and GUI with disjoint files after audit. Existing module homes are reused: subagent-config/src, subagent-config/test, gui/src/pages, gui/test. No AGENTS.md or POLICY.md was present in this checkout. Baseline is local origin/dev 6e97e73, adopted in the current managed worktree; no remote mutation. + +Verification: `CODEXCLAW_HOME=/tmp/cxc-first-fallback/empty-home node --test --test-concurrency=1 'plugins/codexclaw/components/subagent-config/test/*.test.ts'` reads the exact module tests: baseline 228 passed, exit 0. Unisolated invocation had 11 global-config contamination failures. Build via package script compiles src recursively into dist and checks manifest. GUI interaction will be rendered after implementation; it has not yet been verified. + +See 010_implementation.md for field chain and failure activation cases. Architecture/SoT updates: docs-site/src/content/docs/guides/subagents.md and skills/pabcd/references/delegation.md. + +## Progress +A: Inspector returned GO-WITH-FIXES (4 blockers). All accepted: parent SessionStart affordance, canonical native-string decoder with unknown-error stop, OCX rewrite limits, and exact model duplicate semantics. B started with persisted A>B near-pass attestation. Main owns dispatch code and executor owns settings/UI. Initial dispatch tests: 4 pass, 5 failures awaiting the independent store fallback implementation; not a final verification result. GUI baseline tsc exited 0. Offline npm ci used the existing lockfile and completed without changing dependencies. + +B ownership adjustment: executor implemented the store contract; remaining CLI/MCP/GUI work is reclaimed by main to remove the serial integration wait. Executor was asked to stop outside store and return current edits before main touches its previous write scope. This is a handoff, not evidence of agent failure. Main dispatch tests reached 14/14 and targeted strict tsc passed after the store became available. Code reviewer Auditor returned PASS for the dispatch scope; its nonblocking startup/null-tool-ID/root notes were folded into code/tests. + +C review repair: Critic found the new SessionStart hook missing from generated inventory, README badges/prose and one hardcoded hook-count assertion. Accepted: the initial 304-test selection omitted inventory/gate/hook-e2e tests, so its green receipt did not cover the added manifest entry's full publication chain. No conflict with feature logic; regenerate inventory from the manifest, update the independent expected hook count, and broaden verification to these three suites. This is the first repair of this finding. Also clarify report attemptId and conservative stale-lock recovery in delegation docs. Screenshots and API/CLI evidence at 93c02c6 remain valid for unchanged feature code. + +Inventory repair verification exposed two more assumptions in the same publication chain: hook-e2e expects the repository's `hook ` entrypoint convention, and its inventory negative fixture used the old 23-count badge as its replacement target. Accepted both: use the conventional hook command (and test that exact CLI argv), and make the negative fixture corrupt any numeric hook badge to zero before checking restoration from the inventory. Repair subset now passes 54/54. Runtime fallback selection, UI and provider behavior are unchanged by this repair. + +## D — local completion + +The integrated cycle closed to IDLE after final code commit `83b06c4`. All recorded criteria are met: per-role fallback settings and scopes, managed dispatch, synthetic failure activation, browser/CLI QA, and packaging consistency. Final independent review: PASS. No implementation work remains in this local scope. Installed plugin/settings, live provider failure behavior, publication and deployment were not changed or claimed. See `011_verification.md` for the evidence layers and limitations. diff --git a/devlog/_fin/260909_subagent_first_fallback/001_boundary_evidence.md b/devlog/_fin/260909_subagent_first_fallback/001_boundary_evidence.md new file mode 100644 index 00000000..d2e256f4 --- /dev/null +++ b/devlog/_fin/260909_subagent_first_fallback/001_boundary_evidence.md @@ -0,0 +1,9 @@ +# Local boundary evidence + +- Catalog /home/jun/.codex/opencodex-catalog.json contains xai/grok-4.6 and cursor/grok-4.6. No provider request made. +- OCX src/lib/errors.ts:180 classifies structured error codes; upstream-retry.ts owns provider retries. +- OCX src/codex/subagent-model-fallback.ts:535,617,657 already resolves global/per-primary fallback and rewrites thread_spawn requests before provider routing. CXC will not mutate that configuration. Requested candidate is not proof of actual route; preserve actual model as unknown absent observed metadata. +- CXC components/pabcd-state/src/hook.ts:1889 documents incomplete/truncated PostToolUse error visibility. No verified PostToolUseFailure surface is available. +- CXC spawn-attach-hook.ts:926 returns an updated input envelope; it does not invoke native tools. +- Native host metadata exposes spawn_agent returning agent_id, and wait_agent returning errored:string or completed:string|null. No structured provider error field is promised. Parse only a complete JSON error envelope, never guess a code from arbitrary prose; unknown errors return reconcile/stop rather than blindly rotate. +- Scout independently inspected OCX and CXC source and recommended managed start/report with native main-owned calls. Runtime role observed in child turn_context: gpt-5.6-luna high. Reviewer route observed: anthropic/claude-opus-5 xhigh. Exact actual downstream route remains unverified. diff --git a/devlog/_fin/260909_subagent_first_fallback/010_implementation.md b/devlog/_fin/260909_subagent_first_fallback/010_implementation.md new file mode 100644 index 00000000..d46d3363 --- /dev/null +++ b/devlog/_fin/260909_subagent_first_fallback/010_implementation.md @@ -0,0 +1,36 @@ +# Integrated implementation + +Depends on current global/project settings and native main-owned spawn/wait tools. The plugin does not own native tool invocation. Keep candidate decisions deterministic in CXC; main invokes native tools using returned payloads and reports outcomes. A pre-spawn notice connects configured fallback users to this managed path. Do not claim a hook can re-spawn or that error codes always survive the host. + +## Configuration chain (executor lane) +MODIFY components/subagent-config/src/store.ts: RoleConfig adds optional fallback `{model:string, effort:EffortName|null}` or null, normalized to null for old configs; invalid writes rejected. Reject duplicate primary/fallback model. Preserve whole-role inheritance, null effort and prompt. Final policy fixed main-direct for this initial version, avoiding unnecessary configuration. +MODIFY src/cli.ts: --fallback-model, --fallback-effort, --clear-fallback; merged validation and existing scopes. +MODIFY src/settings-api.ts and src/mcp.ts: round-trip fallback, expose nested schema and use store validation. +MODIFY gui/src/api.ts, pages/Subagents.tsx: optional fallback type; same model catalog and effort picker, clear fallback; preserve source/saving/trust behavior. Empty means no fallback, not main model. Label final main-direct behavior, independent review exception. +MODIFY corresponding store/CLI/settings/MCP/GUI tests for all roles, old JSON, invalid inputs, scope, independent efforts, saving and clearing. + +## Dispatch chain (main lane) +NEW subagent-config/src/fallback-dispatch.ts: persisted per-dispatch candidate snapshot, attempt IDs and bounded transitions. Start returns primary payload; record created agent; report terminal failure with structured code -> fallback payload once; exhaustion -> main-direct. Unknown state -> reconcile; permissions/policy/cancellation -> stop. Agent-started failures require terminal retirement + workspace reconciliation before retry or main-direct. No guessed string error classifier; accept OCX structured code envelope at the boundary, preserve unknown codes. No same-provider retry: OCX owns it. Records keep chosen route and failure code, not raw prompts or secret error bodies. +NEW src/fallback-dispatch-cli.ts: explicit JSON stdin start/record/report/status protocol, path-safe dispatch ID and host-owned storage root, atomic exclusive claim prevents concurrent duplicate transitions. CLI callable from parent agent; do not add native schema fields or bypass host restrictions. +MODIFY spawn-attach-hook.ts: notice when fallback configured, directing parent to managed CLI protocol; avoid injecting instructions only into child's prompt as sole integration. Exact hook envelope capability to be checked before implementing. +MODIFY CLI bin router only if needed for `cxc subagents dispatch`; avoid collision with executor cli.ts ownership by standalone dispatch routing in bin/codexclaw.mjs. +MODIFY delegation.md and subagents guide: native caller sequence, raw failure code availability limits, full-history forks not eligible, explicit model override honored, review not waived, commands/example. +NEW tests/fallback-dispatch.test.ts and CLI tests: primary failure then fallback, second failure main-direct, all three roles, permission stop, unknown outcome reconciliation, mid-task cleanup, duplicate reports/concurrent transitions, config snapshot and restart, malformed input and traversal. + +Structural choice: co-locate dispatch policy/state with subagent-config; do not import OCX filesystem source or create a provider proxy. Rejected hook-only retry because PreToolUse never observes native outcome. Public boundary: local CLI JSON and additive config. Bypass: E7 main-followed invocation; native direct spawn remains possible. State transitions are deterministic within managed path, not universal enforcement. Final enforcement layer: none for callers bypassing managed dispatch. No cross-session cooldown in this version: OCX owns provider cooldown; no duplicated quota engine. + +Activation tests must assert actual candidate IDs, exactly two attempts maximum, and no executable payload while state ambiguous or cleanup unconfirmed. A returned candidate is selected intent, not proof of actual runtime model. Record observed agent ID/model separately when supplied; unknown actual model stays unknown. Main-direct after policy denial must never be offered. Existing no-fallback spawn behavior stays compatible. + +## Concrete CLI wire +`cxc subagents dispatch` reads one JSON object from stdin. Start `{action:"start",sessionId,dispatchId,role}` returns `{action:"spawn",dispatchId,attemptId,candidate:{model,effort},independentReviewRequired}`. Main supplies its original task/skills and fresh-context native spawn args. `claim` consumes an attempt before native spawn; repeat claim cannot authorize another call. `report` requires dispatchId/sessionId/attemptId, outcome `created|complete|failed`, agentId where created, observedModel optional, and error `{code}` or full JSON envelope for failure. Failure also requires executionState `not_created|stopped|unknown|running`; started/stopped work requires nonempty reconciliation evidence before handoff. `status` never reissues a claimed spawn. Unknown errors and unresolved execution return `reconcile` without a candidate. Duplicate/stale report returns no new spawn authorization. Fresh start cannot overwrite an existing dispatch. Exhaustion returns `main-direct` for ordinary tasks and retains `independentReviewRequired:true` for reviewer. Explicit host `spawn_unavailable` terminates to main-direct only after known no child. + +Start/claim/report/status is an E7 caller protocol with deterministic filesystem transitions. Filesystem records have schema version and per-attempt claimed flag; invalid/corrupt state is an error, never a fresh dispatch. A crash after claim is deliberately ambiguous; caller must reconcile before any replacement. Persist candidate snapshot, agent identity and code/reconciliation evidence; never derive observedModel from candidate. No statement that provider/host payload text authenticates claims from the main caller. + +Managed attempts carry a message marker bound to session+dispatch+attempt. The spawn hook resolves this record (claimed attempt only) and supplies candidate model/effort exactly; null effort must not accidentally inherit the role's primary override. Native parent effort inheritance is retained when the candidate effort is null. Do not emit unsupported native fields. A managed marker is not an authorization token for permissions. The hook's role routing must use the recorded logical role, not keyword inference, for reviewer/explorer correctness. Native explicit model overrides outside the managed protocol remain caller-owned. + +## A synthesis (four blockers accepted) +B1: Add a SessionStart hook in hooks/session-start-announcing-subagent-fallback.json registered in .codex-plugin/plugin.json, implemented by fallback-dispatch-cli.ts. It reads effective role settings and emits parent additionalContext before the first spawn. PreToolUse uses supported additionalContext only as a late reminder; never claim it manages an already-started first call. +B2: Native wait returns errored:string. The decoder accepts complete JSON error envelopes and anchored OCX code strings (e.g. `insufficient_quota`), plus canonical transport messages `Cursor rate limit exceeded...`, `You've hit your usage limit...`, `You have exceeded your current quota...`, and `Rate limit reached...`. These narrow compatibility prefixes preserve OCX meanings; no broad arbitrary keyword classifier. A structured policy/permission error always stops. Unknown/prose-only failures outside this finite adapter return reconcile/stop and expose the original error as unclassified, never fabricate a code. Document this limitation and test both canonical quota prose and unknown prose. No claim that every possible host error preserves a code or is auto-recoverable. +B3: Two attempts bounds CXC-issued native calls only. OCX may rewrite either requested candidate through its own configured fallback chain; actual model stays null without observed evidence. Add a test with observedModel different from candidate, keep lower-layer retries untouched. Role-keying, per-candidate effort and main-direct exhaustion justify the CXC layer; an OCX combo would not own native spawn or failed task handoff. +B4: Duplicate validation only applies to exact model ID equality with mode=model. Default-mode primary identity is unknown; cross-provider aliases remain distinct. Do not guess them equivalent. +Nonblocking: bin interception precedes runSubagents passthrough; delegation path is plugins/codexclaw/skills/pabcd/references/delegation.md. Remove invented spawn_unavailable provider code; represent unavailable native tool as explicit host capability outcome with no-child evidence, separate from provider error decoding. D1 denial/permission reason must remain stop. diff --git a/devlog/_fin/260909_subagent_first_fallback/011_verification.md b/devlog/_fin/260909_subagent_first_fallback/011_verification.md new file mode 100644 index 00000000..4e51f614 --- /dev/null +++ b/devlog/_fin/260909_subagent_first_fallback/011_verification.md @@ -0,0 +1,19 @@ +# Verification and delivery + +Code checkpoints: `93c02c6` (feature), `83b06c4` (hook inventory and entrypoint repair). This final documentation archive does not change implementation files. + +- Build: `npm run build`, 166 runtime files compiled; layout validated, exit 0. +- GUI: workspace Vite build and strict GUI tsc, exit 0. Targeted strict NodeNext tsc on dispatch, CLI, MCP and spawn hook, exit 0. +- Final relevant suites: 355 tests, 355 pass, 0 fail, 0 skipped at `83b06c4`. Includes subagent-config, all GUI tests, dist freshness, manifest policy, packaging, CLI usage, inventory, gate and hook-e2e. This is not the whole-repository suite. +- Final independent reviewer: PASS; separately ran 58 repair-focused tests, all passed. +- Real local browser/API: all three roles save and retain independent fallback effort; duplicate model rejection preserves values; project clearing and global inheritance restoration pass. Screenshots inspected at 1440, 768, 390 and 320px; no horizontal overflow or page errors. Existing controls and focus styling reused. +- Real compiled CLI with synthetic outcome reports: xai/grok-4.6 -> cursor/grok-4.6 -> main-direct. No native/provider inference calls were made by this probe. Unit tests also exercise ambiguous creation, stopped-child reconciliation, marker replay, no-tool-ID hosts, restart and config snapshots. +- Main and child models were used for ordinary implementation/review delegation; no additional paid-provider failure/quota probes were performed. + +Evidence is under `.codexclaw/evidence/01a08476-5f10-75c1-bc04-81ab5318553f/`: test-receipt.json binds the final code; qa-receipt.json and qa/ hold browser images, actions, CLI trace and teardown. GUI/CLI observations at `93c02c6` remain applicable because the followup changes only inventory, hook entrypoint argv, its tests and documentation. The new hook argv itself was re-tested at `83b06c4`. + +Environment diagnosis: first unisolated component run saw user global settings (11 failures); isolated CODEXCLAW_HOME removed that contamination. Extended GUI tests then saw a pre-existing `/tmp/.git` (four fixture failures); TMPDIR=/var/tmp/cxc-first-fallback-tests removed that unrelated ancestor without deleting it or changing tests. Final tests used both isolated paths. + +Owned Vite PID 340728 / terminal 90158 was terminated; port 17944 has no listener. Browser contexts and CLI child processes exited. Only local fixture/evidence files and workspace dependencies remain. + +The managed protocol is main-followed: CXC makes deterministic candidate/attempt decisions while the main invokes native spawn/wait. Direct unmanaged calls are not automatically retried. Unknown host error prose remains unclassified, and requested models are not proof of OCX's actual downstream route. Independent-review requirements survive main-direct exhaustion. Installed plugin and global model settings were not modified; no push, PR, merge or release occurred. diff --git a/devlog/_fin/260909_subagent_first_fallback/012_isolated_native_verification.md b/devlog/_fin/260909_subagent_first_fallback/012_isolated_native_verification.md new file mode 100644 index 00000000..f9a32ab6 --- /dev/null +++ b/devlog/_fin/260909_subagent_first_fallback/012_isolated_native_verification.md @@ -0,0 +1,49 @@ +# Isolated native verification — fallback acceptance failed + +Tested implementation: `701c17d`, Codex CLI `0.153.4`, 2026-09-09. + +This is the pre-fix failure record. See `013_native_error_fix.md` for the subsequent repair and verification. + +The earlier synthetic CLI checks did not establish native error compatibility. This run found that quota and server errors are rewritten by Codex before `wait_agent` returns. The current decoder does not recognize these strings, so every tested eligible failure stops at `reconcile` instead of selecting the fallback. The feature is not ready to claim working native fallback. + +## Results + +Each row ran for executor, explorer and reviewer using the role's persisted dispatch record. + +| Injected provider response | Native wait error/result | Current dispatch result | +| --- | --- | --- | +| Successful response | `CHILD_OK` | `complete`, passes | +| HTTP 429, `insufficient_quota` | `exceeded retry limit, last status: 429 Too Many Requests` | `reconcile`, fails fallback acceptance | +| SSE `response.failed`, `insufficient_quota`, generic message | `Quota exceeded. Check your plan and billing details.` | `reconcile`, fails fallback acceptance | +| Same SSE code with canonical usage-limit message | Same quota text | `reconcile`, fails fallback acceptance | +| SSE `response.failed`, `rate_limit_exceeded` | `rate limit exceeded: Cursor rate limit exceeded: fixture exhausted` | `reconcile`, fails fallback acceptance | +| HTTP 500, `upstream_server_error` | `We're currently experiencing high demand, which may cause temporary errors.` | `reconcile`, fails fallback acceptance | +| HTTP 403, `permission_denied` | `unexpected status 403 Forbidden: Fixture provider rejected, url: ...` | `reconcile`, passes conservative no-fallback criterion; does not establish explicit `stop` classification | + +21 cases: 6 acceptance passes, 15 acceptance failures. No case reached the configured `cursor/grok-4.6` fallback or `main-direct`. This is an acceptance failure even though the fixture processes exited normally. + +All 21 cases observed SessionStart guidance in native model input, claimed-marker consumption with `spawnIssued: true`, and outgoing native child requests for `xai/grok-4.6` at `high`. The fixture omitted model and effort from spawn arguments, so those values came from the real PreToolUse hook. Reviewer dispatches retained `independentReviewRequired: true`. Native wait and close ran before failure reports; no child was allowed to perform file edits. + +Independent full repository suite: `env TMPDIR=/var/tmp/cxc-fallback-native-701c17d/tmp npm test`, exit 0, 2,721 tests total, 2,650 passed, 0 failed, 71 skipped. This does not override the failed native acceptance cases. + +## Isolation and evidence boundary + +Fixtures, CODEX_HOME, CODEXCLAW_HOME, TMPDIR and project state live under `/var/tmp/cxc-fallback-native-701c17d`. The native binary was launched directly with a minimal environment and no credentials. Its provider URL was an ephemeral loopback HTTP server. The operator's OCX service and installed plugin/settings were not modified or used for inference. + +The two production hook commands and matchers were loaded as isolated user hooks, with native-reported hashes explicitly trusted in that isolated config. `hooks/list` confirmed both trusted. This verifies those hook entrypoints in the real host, not a full plugin-marketplace installation. The fixture reused model catalog metadata with direct tools and v1 enabled; code-mode execution and native custom-role behavior were not covered. Logical CXC role selection came from the managed dispatch, not native `agent_type`. + +The main's protocol actions were scripted by the loopback fixture using real compiled CLI subprocesses and actual native session IDs. Native spawn/wait/close and their returned errors were real. This does not test whether an unscripted main model follows the protocol, downstream OCX routing/retries, paid provider behavior, mid-task edit recovery, or actual main-agent completion. + +Local artifacts: + +- `/var/tmp/cxc-fallback-native-701c17d/acceptance.json`: all acceptance verdicts. +- `/var/tmp/cxc-fallback-native-701c17d/-/`: outgoing requests, native stdout/stderr, dispatch input/output trace and final result. +- `/var/tmp/cxc-fallback-native-701c17d/full-test.log`: complete repository test output. +- `/var/tmp/cxc-fallback-native-701c17d/matrix.py`: native fixture; run with `FIXTURE_ROLE` and `FIXTURE_CASE` environment variables. It depends on the catalog and isolated hook trust setup captured alongside it. +- `/var/tmp/cxc-fallback-native-701c17d/hooks-list.json`: final trusted hook metadata. + +All fixture servers and subprocesses exited; only evidence and isolated state remain. Production code was not changed by this verification. + +## Required follow-up + +`plugins/codexclaw/components/subagent-config/src/fallback-errors.ts:9` must account for verified native transport transformations, with narrow matching and retained permission/unknown-error negatives. `fallback-dispatch.ts:173` currently returns `reconcile` for these unclassified strings. Add regression coverage from the captured native outputs, then repeat native acceptance to prove primary -> fallback -> main-direct, including fallback effort and reviewer independence. Do not treat the prior passing synthetic tests as proof of that transition. diff --git a/devlog/_fin/260909_subagent_first_fallback/013_native_error_fix.md b/devlog/_fin/260909_subagent_first_fallback/013_native_error_fix.md new file mode 100644 index 00000000..10afdbfa --- /dev/null +++ b/devlog/_fin/260909_subagent_first_fallback/013_native_error_fix.md @@ -0,0 +1,23 @@ +# Native error compatibility repair + +Codex rewrites provider error codes into native wait messages. The decoder now recognizes the exact observed quota, HTTP 429 retry-limit and high-demand messages, plus the native `rate limit exceeded: ` prefix. These select the existing next-candidate behavior; they do not add provider retries or change child reconciliation requirements. + +Structured permission codes still take precedence. Quoted task content, similar words, modified exact-message suffixes and HTTP 403 prose do not become eligible for another model. Unknown execution state still requires reconciliation. HTTP 403 remains conservative `reconcile`, not an explicitly decoded `stop`. + +Implementation: `plugins/codexclaw/components/subagent-config/src/fallback-errors.ts`, with its tracked compiled output. Four regression tests were added to the existing dispatch suite. The captured native messages failed before the repair; all 18 dispatch tests passed after it. Build compiled 166 files and validated layout; strict TypeScript checking of the decoder passed. + +Native verification repeats the isolated setup and limitations documented in `012_isolated_native_verification.md`. The same native binary, real spawn/wait/close, trusted hook commands and compiled dispatch CLI are used. Only provider responses and the main's protocol choices are scripted. The tests verify the `main-direct` decision; they do not prove an unscripted main model's subsequent task execution or a live OCX/provider account. + +The expanded matrix runs eight scenarios for each of executor, explorer and reviewer: primary success, generic SSE quota, canonical-message SSE quota, SSE rate limit, HTTP 429, HTTP 500, HTTP 403, and fallback success. Assertions check the final action, exact attempt count, observed outbound model/effort, hook issuance and reviewer independence. Expected routing is `xai/grok-4.6`/high -> `cursor/grok-4.6`/low, then `main-direct` if the fallback also fails. Primary success and HTTP 403 must never issue a second attempt; fallback success must end `complete` after two attempts. + +Final result: native matrix 24 passed, 0 failed. Full repository rerun (`env TMPDIR=/var/tmp/cxc-fallback-native-701c17d/tmp npm test`) exited 0: 2,725 total, 2,654 passed, 0 failed, 71 skipped. All fixture servers and native subprocesses exited. Build/typecheck and `git diff --check` passed. These results cover the final source and compiled changes in this repair. + +Evidence directory: `/var/tmp/cxc-fallback-native-701c17d/`. + +- `regression-red.log` / `regression-green.log`: before/after regression output. +- `fix-build.log`: production build. +- `run-fixed.py`: matrix runner with assertions; `matrix.py`: native fixture. +- `fixed/acceptance.json`: expanded matrix verdicts; each scenario directory contains requests, native events, dispatch traces and results. +- `fix-full-test.log`: full repository test rerun after the repair. + +Original failing evidence remains outside `fixed/`. No operator settings, installed plugin or remote Git state were changed. diff --git a/devlog/_fin/260909_subagent_first_fallback/014_ui_desktop.png b/devlog/_fin/260909_subagent_first_fallback/014_ui_desktop.png new file mode 100644 index 00000000..bd13d24a Binary files /dev/null and b/devlog/_fin/260909_subagent_first_fallback/014_ui_desktop.png differ diff --git a/devlog/_fin/260909_subagent_first_fallback/015_upstream_integration.md b/devlog/_fin/260909_subagent_first_fallback/015_upstream_integration.md new file mode 100644 index 00000000..809abff6 --- /dev/null +++ b/devlog/_fin/260909_subagent_first_fallback/015_upstream_integration.md @@ -0,0 +1,9 @@ +# Upstream integration for PR #116 + +Merged upstream dev `3f9d22e59beda246f23649e7b9a221e8ea632254` into the publication branch. The repository fetch refspec only tracks main; fetching dev initially updated FETCH_HEAD without updating origin/dev. An explicit dev-to-origin/dev fetch corrected that stale comparison. + +Resolved the hook-count conflict to 25 (upstream memory-write hook plus the new fallback notice) and retained the inventory test's count-independent drift fixture. Regenerated inventory and README counts. Fallback source, compiled runtime and GUI files are byte-identical to the previously verified publication head `2e728edc`; the native 24-case evidence remains applicable to those artifacts, with the same isolated-hook limitations. + +After integration: full suite 2,821 total, 2,750 passed, zero failures, 71 skipped; build compiled 166 files successfully. Published test counts were regenerated from that observed total, and the inventory suite, count checker, repository gate and diff check passed. Logs: `/var/tmp/cxc-fallback-native-701c17d/pr-full-test.log`, `pr-build.log`, `pr-inventory-test.log`. + +Publication uses `thisisjun786/codexclaw:codex/subagent-first-fallback` targeting `lidge-jun/codexclaw:dev`. Original local commits remain on `codex/subagent-first-fallback`; publication history uses the GitHub noreply email after GH007 rejected the first push. No history was force-pushed. diff --git a/docs-site/src/content/docs/guides/subagents.md b/docs-site/src/content/docs/guides/subagents.md index 56a21eba..41bf4ebd 100644 --- a/docs-site/src/content/docs/guides/subagents.md +++ b/docs-site/src/content/docs/guides/subagents.md @@ -54,3 +54,50 @@ reliable native V2 channels are the leaf guard and omitted configured `model`/`reasoning_effort` injection for non-full-history spawns. It never invents role baselines or inferred surface skills. Role config, resolver, and spawn-wrapper are all shipped (L9). + +## First fallback + +Each role can keep one optional fallback model with its own reasoning effort. +Select both models from the existing catalog: for example `xai/grok-4.6` followed +by `cursor/grok-4.6`. The IDs distinguish the provider routes. This is ordered +failover, not round robin: a healthy primary keeps receiving work. + +Global and project settings use the same whole-role inheritance as primary +settings. Old configurations have no fallback. Removing a project override also +restores the global fallback. A null fallback effort inherits the original +session's effort, not the primary role's effort. + +```sh +cxc subagents set executor --fallback-model cursor/grok-4.6 --fallback-effort high +cxc subagents set executor --clear-fallback +``` + +The same flags apply to explorer and reviewer. When both attempts fail, the main +agent takes over remaining work. An independent review requirement remains +outstanding; main-agent work does not satisfy it. + +### Execution and evidence + +CXC's managed dispatch protocol selects candidates and records attempts. The main +agent still calls native spawn/wait tools. SessionStart supplies the protocol when +fallbacks are configured; the delegation skill documents the sequence. Existing +direct native calls remain possible and are not automatically retried by a hook. +Settings alone do not establish that a native host delivered every failure code. + +`cxc subagents dispatch` accepts one JSON object on stdin. Begin with +`{"action":"start","sessionId":"","dispatchId":"","role":"executor"}`. +Claim the returned attempt with `action:claim` and `attemptId`. Only `action:spawn` +authorizes one call; include its marker at the start of the native task message. +Report creation/completion or failure, and inspect `action:status` after a lost +response. Records live under `.codexclaw/dispatches//.json`. + +Quota/model availability failures can select the fallback. Policy, permission, +authentication and cancellation failures stop. Ambiguous creation or ongoing work +must be reconciled before replacement; stopped executors require a change review +and cleanup evidence. Unknown error prose does not trigger blind rotation. + +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 +stays unknown. No quota balance, even split, or universal native-hook delivery is +implied by configuring a fallback. diff --git a/plugins/codexclaw/.codex-plugin/plugin.json b/plugins/codexclaw/.codex-plugin/plugin.json index 2e383be6..5a12c451 100644 --- a/plugins/codexclaw/.codex-plugin/plugin.json +++ b/plugins/codexclaw/.codex-plugin/plugin.json @@ -33,6 +33,7 @@ "./hooks/subagent-stop-verifying-evidence.json", "./hooks/subagent-stop-observing-review.json", "./hooks/pre-tool-use-attaching-skills.json", + "./hooks/session-start-announcing-subagent-fallback.json", "./hooks/post-compact-resetting-reinject-cursor.json", "./hooks/pre-tool-use-linting-apply-patch.json", "./hooks/post-tool-use-tracking-render-observations.json", diff --git a/plugins/codexclaw/components/subagent-config/dist/cli.js b/plugins/codexclaw/components/subagent-config/dist/cli.js index d5b696f8..6dcca70f 100644 --- a/plugins/codexclaw/components/subagent-config/dist/cli.js +++ b/plugins/codexclaw/components/subagent-config/dist/cli.js @@ -13,7 +13,7 @@ * subagents set --mode default|model [--model ] [--effort |--clear-effort] * [--prompt |--clear-prompt] */ -import { readConfig, setRole, resetRole, projectConfigTrustToken, ROLES, EFFORTS, } from "./store.js"; +import { readConfig, setRole, resetRole, projectConfigTrustToken, ROLES, EFFORTS, } from "./store.js"; import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -32,7 +32,7 @@ function isRole(v ) { /** Pure structural parse of the `subagents` argv (excluding the leading verb). */ export function parseSubagentsArgs(argv ) { // Scope is an explicit trailing selector, so prompt/model values stay literal. - if (argv.at(-1) === "--global" && !["--prompt", "--model"].includes(argv.at(-2) ?? "")) { + if (argv.at(-1) === "--global" && !["--prompt", "--model", "--fallback-model"].includes(argv.at(-2) ?? "")) { return { ...parseProjectArgs(argv.slice(0, -1)), scope: "global" }; } return parseProjectArgs(argv); @@ -56,7 +56,9 @@ function parseProjectArgs(argv ) { if (sub === "set") { if (!isRole(argv[1])) return { action: "set", error: `unknown role '${argv[1] ?? ""}' (expected ${ROLES.join("|")})` }; const role = argv[1]; - const patch = {}; + const patch = {}; + let clearFallback = false; + let setFallback = false; for (let i = 2; i < argv.length; i++) { const a = argv[i]; if (a === "--mode") { @@ -65,6 +67,16 @@ function parseProjectArgs(argv ) { patch.mode = v; } else if (a === "--model") { patch.model = argv[++i] ?? ""; + } else if (a === "--fallback-model") { + const model = argv[++i]; + if (!model?.trim()) return { action: "set", role, error: "--fallback-model requires a model id" }; + patch.fallback = { ...patch.fallback, model }; setFallback = true; + } else if (a === "--fallback-effort") { + const value = argv[++i]; + if (value !== "inherit" && !(EFFORTS ).includes(value ?? "")) return { action: "set", role, error: "invalid --fallback-effort" }; + patch.fallback = { ...patch.fallback, effort: value === "inherit" ? null : value }; setFallback = true; + } else if (a === "--clear-fallback") { + clearFallback = true; patch.fallback = null; } else if (a === "--effort") { const v = argv[++i]; if (!(EFFORTS ).includes(v ?? "")) { @@ -81,8 +93,9 @@ function parseProjectArgs(argv ) { return { action: "set", role, error: `unknown flag '${a}'` }; } } + if (clearFallback && setFallback) return { action: "set", role, error: "--clear-fallback cannot be combined with fallback settings" }; if (Object.keys(patch).length === 0) { - return { action: "set", role, error: "set requires at least one of --mode/--model/--effort/--clear-effort/--prompt/--clear-prompt" }; + return { action: "set", role, error: "set requires at least one of --mode/--model/--effort/--clear-effort/--prompt/--clear-prompt/--fallback-model/--fallback-effort/--clear-fallback" }; } return { action: "set", role, patch }; } @@ -96,6 +109,8 @@ const HELP = [ " subagents list all role configs", " subagents get show one role config", " subagents set --mode default|model [--model ] [--effort |--clear-effort] [--prompt |--clear-prompt]", + " --fallback-model [--fallback-effort low|medium|high|xhigh|inherit] | --clear-fallback", + " subagents dispatch main-owned fallback protocol; JSON stdin (start/claim/report/status)", " subagents reset remove the role override and inherit the next scope", " Append --global to list/get/set/reset to manage user defaults", " subagents trust-token print an export bound to this repo and exact config", diff --git a/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch-cli.js b/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch-cli.js new file mode 100644 index 00000000..599ba48b --- /dev/null +++ b/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch-cli.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { readSync, realpathSync } from "node:fs"; +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 created with the returned agentId, then use native wait. Report complete with that agentId, or failed with 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 function sessionFallbackNotice(cwd ) { + const roles = readConfig(cwd).roles; + const active = ROLES.filter(role => roles[role].fallback); + if (!active.length) return ""; + return JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: `[codexclaw] First fallback configured for ${active.join(", ")}. ${DISPATCH_GUIDANCE}` } }) + "\n"; +} +function main() { + const sessionStart = process.argv[2] === "session-start" || (process.argv[2] === "hook" && process.argv[3] === "session-start"); + try { + const buffer = Buffer.alloc(64 * 1024 + 1); + let size = 0; + for (;;) { + const count = readSync(0, buffer, size, buffer.length - size, null); + if (!count) break; + size += count; + if (size === buffer.length) throw new Error("dispatch input exceeds 64 KiB"); + } + const raw = buffer.subarray(0, size).toString("utf8"); + if (sessionStart) { + const payload = JSON.parse(raw) ; + if (typeof payload.agent_id === "string" && payload.agent_id) return; + process.stdout.write(sessionFallbackNotice(typeof payload.cwd === "string" ? payload.cwd : process.cwd())); + return; + } + process.stdout.write(JSON.stringify(runDispatch(process.cwd(), JSON.parse(raw))) + "\n"); + } catch (error) { + if (sessionStart) return; + process.stdout.write(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }) + "\n"); + process.exitCode = 1; + } +} +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch.js b/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch.js new file mode 100644 index 00000000..3a26c444 --- /dev/null +++ b/plugins/codexclaw/components/subagent-config/dist/fallback-dispatch.js @@ -0,0 +1,222 @@ +/** Main-owned native spawn protocol. This module selects attempts; it never calls a provider. */ +import { randomUUID } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { EFFORTS, readConfig, ROLES, } from "./store.js"; +import { renameWithRetry } from "./atomic-write.js"; +import { decodeDispatchFailure } from "./fallback-errors.js"; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +const ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,95}$/; +const MAX_INPUT = 64 * 1024; +function dispatchRoot(cwd ) { + const git = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 1500 }); + return realpathSync(git.status === 0 ? git.stdout.trim() : cwd); +} +function record(value ) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected a JSON object"); + return value ; +} +function id(value , field ) { + if (typeof value !== "string" || !ID.test(value)) throw new Error(`invalid ${field}`); + return value; +} +function smallText(value , field ) { + if (typeof value !== "string" || !value.trim() || value.length > 2000) throw new Error(`invalid ${field}`); + return value.trim(); +} +function directory(cwd , sessionId ) { + let dir = cwd; + for (const part of [".codexclaw", "dispatches", sessionId]) { + dir = join(dir, part); + if (existsSync(dir)) { + if (!lstatSync(dir).isDirectory() || lstatSync(dir).isSymbolicLink()) throw new Error("dispatch directory must not be a symlink"); + } else mkdirSync(dir, { mode: 0o700 }); + } + return dir; +} +function candidate(raw ) { + const c = record(raw); + if (c.model !== null && (typeof c.model !== "string" || !c.model.trim())) throw new Error("invalid stored model"); + if (c.effort !== null && !(EFFORTS ).includes(c.effort)) throw new Error("invalid stored effort"); + return { model: c.model , effort: c.effort }; +} +function readState(path , sessionId , dispatchId ) { + if (lstatSync(path).isSymbolicLink()) throw new Error("dispatch state must not be a symlink"); + const d = record(JSON.parse(readFileSync(path, "utf8"))); + if (d.version !== 1 || d.sessionId !== sessionId || d.id !== dispatchId || !ROLES.includes(d.role )) throw new Error("invalid dispatch identity"); + if (!Array.isArray(d.candidates) || d.candidates.length < 1 || d.candidates.length > 2) throw new Error("invalid candidates"); + d.candidates.forEach(candidate); + if (!Array.isArray(d.attempts) || d.attempts.length < 1 || d.attempts.length > d.candidates.length) throw new Error("invalid attempts"); + if (!["active", "stopped", "complete", "main-direct"].includes(String(d.status))) throw new Error("invalid dispatch status"); + for (const [i, raw] of d.attempts.entries()) { + const a = record(raw); id(a.id, "attemptId"); candidate(a.candidate); + if (candidate(a.candidate).model !== candidate(d.candidates[i]).model || candidate(a.candidate).effort !== candidate(d.candidates[i]).effort || typeof a.claimed !== "boolean") throw new Error("invalid attempt candidate"); + if (typeof a.spawnIssued !== "boolean") throw new Error("invalid spawn issuance"); + if (!["ready", "claimed", "running", "reconcile", "failed", "complete"].includes(String(a.status))) throw new Error("invalid attempt status"); + for (const field of ["agentId", "observedModel", "code", "reconciliation", "toolUseId"]) { + if (a[field] !== null && typeof a[field] !== "string") throw new Error(`invalid attempt ${field}`); + } + } + return d ; +} +function saveState(path , state ) { + const temp = `${path}.${randomUUID()}.tmp`; + try { + writeFileSync(temp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600, flag: "wx" }); + renameWithRetry(temp, path); + } 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 }; +} +function result(d , action , reason ) { + const a = d.attempts.at(-1) ; + const inferred = d.status === "active" ? (a.status === "ready" ? "ready" : a.status === "running" ? "wait" : "reconcile") : d.status === "stopped" ? "stop" : d.status; + return { action: action ?? inferred, dispatchId: d.id, attemptId: a.id, + independentReviewRequired: d.role === "reviewer", attempts: d.attempts, + ...(reason ? { reason } : {}) }; +} + +/** Explicit caller reports are evidence from the main agent, not authenticated provider receipts. */ +export function runDispatch(cwd , input , env = process.env) { + cwd = dispatchRoot(cwd); + const b = record(input); + if (JSON.stringify(b).length > MAX_INPUT) throw new Error("dispatch input exceeds 64 KiB"); + const sessionId = id(b.sessionId, "sessionId"); + if (env.CODEX_THREAD_ID && env.CODEX_THREAD_ID !== sessionId) throw new Error("sessionId must match the native main session"); + const dispatchId = id(b.dispatchId, "dispatchId"); + const dir = directory(cwd, sessionId); + const path = join(dir, `${dispatchId}.json`); + const lock = `${path}.lock`; + // Never steal a stale lock: an interrupted writer requires inspection, not a second spawn. + mkdirSync(lock, { mode: 0o700 }); + try { + if (b.action === "start") { + if (existsSync(path)) throw new Error("dispatch already exists; use status, never replay start"); + if (!ROLES.includes(b.role )) throw new Error("invalid role"); + const role = b.role ; + const cfg = readConfig(cwd, "project", env).roles[role]; + const candidates = [{ model: cfg.mode === "model" ? cfg.model : null, effort: cfg.effort }]; + if (cfg.fallback && cfg.fallback.model !== candidates[0].model) candidates.push({ ...cfg.fallback }); + const d = { version: 1, sessionId, id: dispatchId, role, candidates, attempts: [attempt(candidates[0])], status: "active" }; + saveState(path, d); + return result(d); + } + const d = readState(path, sessionId, dispatchId); + if (b.action === "status") return result(d); + const a = d.attempts.at(-1) ; + if (b.attemptId !== a.id) throw new Error("stale or missing attemptId; inspect status"); + if (d.status !== "active") return result(d); + if (b.action === "claim") { + if (a.claimed || a.status !== "ready") return result(d, "reconcile", "attempt already claimed; do not spawn again"); + a.claimed = true; a.status = "claimed"; saveState(path, d); + return { ...result(d, "spawn"), candidate: a.candidate, marker: `[CXC-DISPATCH:${dispatchId}:${a.id}]` }; + } + if (b.action !== "report") throw new Error("action must be start, claim, report or status"); + if (!a.claimed) throw new Error("claim the attempt before reporting an outcome"); + const response = report(d, b); + saveState(path, d); + return response; + } finally { rmSync(lock, { recursive: true }); } +} +function report(d , b ) { + const a = d.attempts.at(-1) ; + if (b.outcome === "created") { + const agentId = id(b.agentId, "agentId"); + if (a.agentId !== null && a.agentId !== agentId) throw new Error("agentId changed"); + a.agentId = agentId; a.status = "running"; + if (b.observedModel !== undefined) a.observedModel = smallText(b.observedModel, "observedModel"); + return result(d, "wait"); + } + if (b.outcome === "complete") { + 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 !== "failed" && b.outcome !== "unavailable") throw new Error("invalid report outcome"); + const failure = b.outcome === "unavailable" ? null : decodeDispatchFailure(b.error); + a.code = failure?.code ?? null; + // Never turn denial/cancellation/unknown prose into either another model or main execution. + 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"); } + if (b.executionState !== "not_created" && b.executionState !== "stopped") { + a.status = "reconcile"; return result(d, "reconcile", "confirm whether a child exists and stop it before handoff"); + } + if (a.agentId && (b.executionState !== "stopped" || b.agentId !== a.agentId)) throw new Error("recorded child must be stopped and identified"); + // Even pre-spawn failure needs a concrete observation, not timeout-as-proof. + a.reconciliation = smallText(b.reconciliation, "reconciliation evidence"); + if (b.outcome === "unavailable") { + if (a.agentId || b.executionState !== "not_created") throw new Error("unavailable requires confirmed no child"); + 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"); + 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); +} + +/** Marker resolution never creates a dispatch. The hook uses it to avoid primary effort reinjection. */ +export function managedSpawn(cwd , sessionId , message ) { + cwd = dispatchRoot(cwd); + const match = /^\[CXC-DISPATCH:([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+)\](?:\r?\n|$)/m.exec(message); + if (!match) return null; + id(sessionId, "sessionId"); id(match[1], "dispatchId"); id(match[2], "attemptId"); + const path = join(cwd, ".codexclaw", "dispatches", sessionId, `${match[1]}.json`); + const d = readState(path, sessionId, match[1]); + const a = d.attempts.at(-1) ; + if (a.id !== match[2] || !a.claimed || a.status !== "claimed" || d.status !== "active") throw new Error("managed spawn attempt is not claimed or no longer current"); + return { candidate: a.candidate, role: d.role }; +} + +/** Consume native issuance under the same dispatch lock; repeated hook delivery needs the same host tool id. */ +export function issueManagedSpawn(cwd , sessionId , message , toolUseId ) { + cwd = dispatchRoot(cwd); + const match = /^\[CXC-DISPATCH:([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+)\](?:\r?\n|$)/m.exec(message); + if (!match) return null; + const resolved = managedSpawn(cwd, sessionId, message); + const dir = directory(cwd, sessionId); + const path = join(dir, `${match[1]}.json`); + const lock = `${path}.lock`; + mkdirSync(lock, { mode: 0o700 }); + try { + const d = readState(path, sessionId, match[1]); + const a = d.attempts.at(-1) ; + if (a.id !== match[2] || a.status !== "claimed" || !a.claimed || d.status !== "active") throw new Error("managed attempt changed before issuance"); + if (a.spawnIssued && (!toolUseId || a.toolUseId !== toolUseId)) throw new Error("attempt already issued to another native call; reconcile before retry"); + a.spawnIssued = true; a.toolUseId = toolUseId; saveState(path, d); + return resolved; + } finally { rmSync(lock, { recursive: true }); } +} diff --git a/plugins/codexclaw/components/subagent-config/dist/fallback-errors.js b/plugins/codexclaw/components/subagent-config/dist/fallback-errors.js new file mode 100644 index 00000000..2e131150 --- /dev/null +++ b/plugins/codexclaw/components/subagent-config/dist/fallback-errors.js @@ -0,0 +1,48 @@ +/** Decode OCX errors at the native wait string boundary; this is not a retry engine. */ +const NEXT_CODES = new Set([ + "insufficient_quota", "rate_limit_exceeded", "upstream_server_error", + "model_not_found", "unsupported_model", "unsupported_reasoning_effort", + "input_admission_refused", +]); + + +export function decodeDispatchFailure(value ) { + if (typeof value === "string") { + const text = value.trim(); + try { return decodeDispatchFailure(JSON.parse(text)); } catch { /* native prose surface */ } + // Codex 0.153.4 rewrites HTTP/SSE errors before wait_agent exposes them. + // Match observed host templates, not quota/server keywords in arbitrary prose. + if (text === "Quota exceeded. Check your plan and billing details.") { + return codeDecision("insufficient_quota"); + } + if (text === "exceeded retry limit, last status: 429 Too Many Requests" || text.startsWith("rate limit exceeded: ")) { + return codeDecision("rate_limit_exceeded"); + } + if (text === "We're currently experiencing high demand, which may cause temporary errors.") { + return codeDecision("upstream_server_error"); + } + // Only complete code tokens and canonical transport prefixes, never arbitrary keywords. + if (/^[a-z][a-z0-9_]*$/.test(text)) return codeDecision(text); + if (/^(?:Cursor rate limit exceeded|Rate limit reached)(?:[\s.:]|$)/i.test(text)) { + return codeDecision("rate_limit_exceeded"); + } + if (/^(?:You've hit your usage limit|You have exceeded your current quota)(?:[\s.:]|$)/i.test(text)) { + return codeDecision("insufficient_quota"); + } + return { code: null, action: "unknown" }; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return { code: null, action: "unknown" }; + const record = value ; + // Only known envelope paths. Do not search arbitrary nested task output for error codes. + if (typeof record.code === "string") return codeDecision(record.code); + if (record.error !== undefined) return decodeDispatchFailure(record.error); + if (record.last_error !== undefined) return decodeDispatchFailure(record.last_error); + if (record.response && typeof record.response === "object") { + return decodeDispatchFailure((record.response ).error); + } + return { code: null, action: "unknown" }; +} +function codeDecision(code ) { + // All other codes, including policy, auth, cancellation and context overflow, stop. + return { code, action: NEXT_CODES.has(code) ? "next" : "stop" }; +} diff --git a/plugins/codexclaw/components/subagent-config/dist/mcp.js b/plugins/codexclaw/components/subagent-config/dist/mcp.js index 3369fb17..f140ba29 100644 --- a/plugins/codexclaw/components/subagent-config/dist/mcp.js +++ b/plugins/codexclaw/components/subagent-config/dist/mcp.js @@ -48,6 +48,12 @@ const TOOLS = [ mode: { type: "string", enum: ["default", "model"] }, model: { type: ["string", "null"] }, effort: { type: ["string", "null"], enum: [...EFFORTS, null] }, + fallback: { + type: ["object", "null"], + description: "Optional first fallback. Null clears; omitted nested effort inherits existing fallback effort or session effort.", + properties: { model: { type: "string", minLength: 1 }, effort: { type: ["string", "null"], enum: [...EFFORTS, null] } }, + additionalProperties: false, + }, promptOverride: { type: ["string", "null"] }, }, required: ["role"], diff --git a/plugins/codexclaw/components/subagent-config/dist/settings-api.js b/plugins/codexclaw/components/subagent-config/dist/settings-api.js index 004cd64d..21908815 100644 --- a/plugins/codexclaw/components/subagent-config/dist/settings-api.js +++ b/plugins/codexclaw/components/subagent-config/dist/settings-api.js @@ -13,7 +13,7 @@ export function updateSettings(cwd , body ) { const scope = configScope(b.scope); if (b.inherit !== undefined && typeof b.inherit !== 'boolean') throw new Error('inherit must be a boolean'); const patch = {}; - for (const key of ['mode', 'model', 'effort', 'promptOverride']) { + for (const key of ['mode', 'model', 'effort', 'promptOverride', 'fallback']) { if (b[key] !== undefined) patch[key] = b[key]; } if (b.inherit === true) { diff --git a/plugins/codexclaw/components/subagent-config/dist/spawn-attach-hook.js b/plugins/codexclaw/components/subagent-config/dist/spawn-attach-hook.js index 4edc37ca..ddd9acb8 100644 --- a/plugins/codexclaw/components/subagent-config/dist/spawn-attach-hook.js +++ b/plugins/codexclaw/components/subagent-config/dist/spawn-attach-hook.js @@ -40,7 +40,9 @@ import { createHash, randomBytes } from "node:crypto"; import { tmpdir } from "node:os"; import { basename, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { resolveSpawnConfig, } from "./store.js"; +import { readConfig, resolveSpawnConfig, } from "./store.js"; +import { managedSpawn, issueManagedSpawn } from "./fallback-dispatch.js"; +import { DISPATCH_GUIDANCE } from "./fallback-dispatch-cli.js"; import { checkFinalGatePrereqs } from "./final-gate-guard.js"; function isRecord(v ) { @@ -438,7 +440,8 @@ const REVIEW_KEYWORDS = [ * on hook-path dispatches. */ export function inferRole(agentType , message ) { - if (agentType === "worker") return "executor"; + if (agentType === "worker" || agentType === "executor") return "executor"; + if (agentType === "reviewer") return "reviewer"; const m = (message ?? "").toLowerCase(); return REVIEW_KEYWORDS.some((k) => m.includes(k)) ? "reviewer" : "explorer"; } @@ -778,13 +781,23 @@ export function runSpawnAttachHook(raw ) { // Only rewrite a real message; never invent one (schema shape stays untouched). const message = toolInput.message; if (typeof message !== "string" || message.trim().length === 0) return ""; + const cwd = typeof obj.cwd === "string" && obj.cwd.length > 0 ? obj.cwd : process.cwd(); + const dispatchScan = scanInlineSkillBlocks(message).scanSource; + let managed = null; + if (/^\[CXC-DISPATCH:/m.test(dispatchScan)) { + try { + if (isFullHistoryFork(toolInput)) return denyEnvelope("managed fallback requires a fresh context"); + managed = managedSpawn(cwd, typeof obj.session_id === "string" ? obj.session_id : "", dispatchScan); + if (!managed) return denyEnvelope("invalid managed dispatch marker"); + } catch (error) { return denyEnvelope(`managed dispatch: ${error instanceof Error ? error.message : String(error)}`); } + } const recursionRequested = !spawnedBySubagent && message.includes(SUBSPAWN_TOKEN); const mintedGrant = recursionRequested ? mintRecursionGrant(obj) : null; const controlledMessage = stripControlMarkers(message); const skillsDir = runtimeSkillsDir(); const normalizedMessage = skillsDir ? normalizeSkillMentions(controlledMessage, skillsDir) : controlledMessage; - const role = inferRole(toolInput.agent_type, normalizedMessage); + const role = managed?.role ?? inferRole(toolInput.agent_type, normalizedMessage); // Skill delivery: inline the recognized cxc SKILL.md bodies (atomic overflow // rule inside). @@ -856,7 +869,6 @@ export function runSpawnAttachHook(raw ) { // FULL-HISTORY FORK GUARD (model/effort only): codex-rs hard-rejects // model/reasoning_effort overrides on full-history forks, so those two fields // are skipped there. promptOverride is not subject to this guard. - const cwd = typeof obj.cwd === "string" && obj.cwd.length > 0 ? obj.cwd : process.cwd(); const resolution = resolveSpawnConfig(cwd, role); if (resolution.trustWarning) { evidenceExemptMessage = `[CXC-CONFIG-IGNORED] ${resolution.trustWarning}\n\n${evidenceExemptMessage}`; @@ -867,7 +879,7 @@ export function runSpawnAttachHook(raw ) { const injectedPrompt = typeof resolution.promptOverride === "string" && resolution.promptOverride.trim().length > 0 ? resolution.promptOverride.trim() : null; - if (!isFullHistoryFork(toolInput)) { + if (!managed && !isFullHistoryFork(toolInput)) { const callerModel = toolInput.model; const callerPickedModel = typeof callerModel === "string" && callerModel.trim().length > 0; const callerEffort = toolInput.reasoning_effort; @@ -894,7 +906,7 @@ export function runSpawnAttachHook(raw ) { // Guard was empty (existing guard already present in message, or no guard // needed). Find the existing guard marker and insert after the guard block; // if no marker is found, prepend to the whole message. - const markerIdx = evidenceExemptMessage.indexOf(surfaceMarker); + const markerIdx = evidenceExemptMessage.indexOf(v2Spawn ? LEAF_GUARD_MARKER : SCOPE_GUARD_MARKER); if (markerIdx !== -1) { // Find the end of the existing guard block (first double newline after marker). const blockEnd = evidenceExemptMessage.indexOf("\n\n", markerIdx); @@ -921,17 +933,29 @@ export function runSpawnAttachHook(raw ) { ); if (!gateCheck.ok) return denyEnvelope(gateCheck.reason ?? "final gate prerequisites are missing"); - if (!messageChanged && injectedModel === null && injectedEffort === null) return ""; + const fallbackNotice = !managed && readConfig(cwd).roles[role].fallback + ? `[codexclaw] This direct spawn is not managed by first-fallback tracking. For subsequent tasks: ${DISPATCH_GUIDANCE}` : null; + if (!managed && !fallbackNotice && !messageChanged && injectedModel === null && injectedEffort === null) return ""; // Full replacement: echo every original key; change only message/model/effort. const updatedInput = { ...toolInput, message: evidenceExemptMessage }; if (injectedModel !== null) updatedInput.model = injectedModel; if (injectedEffort !== null) updatedInput.reasoning_effort = injectedEffort; + if (managed) { + try { + issueManagedSpawn(cwd, typeof obj.session_id === "string" ? obj.session_id : "", dispatchScan, typeof obj.tool_use_id === "string" ? obj.tool_use_id : null); + } catch (error) { return denyEnvelope(`managed dispatch: ${error instanceof Error ? error.message : String(error)}`); } + if (managed.candidate.model === null) delete updatedInput.model; + else updatedInput.model = managed.candidate.model; + if (managed.candidate.effort === null) delete updatedInput.reasoning_effort; + else updatedInput.reasoning_effort = managed.candidate.effort; + } return `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput, + ...(fallbackNotice ? { additionalContext: fallbackNotice } : {}), }, })}\n`; } catch { diff --git a/plugins/codexclaw/components/subagent-config/dist/store.js b/plugins/codexclaw/components/subagent-config/dist/store.js index fb86623d..2d458955 100644 --- a/plugins/codexclaw/components/subagent-config/dist/store.js +++ b/plugins/codexclaw/components/subagent-config/dist/store.js @@ -48,8 +48,24 @@ export const EFFORTS = ["low", "medium", "high", "xhigh"] ; + + + + + + + +/** Patch shape: fallback may be a partial nested update or null to clear. */ + + + + + + + + export function defaultRole() { - return { mode: "default", model: null, effort: null, promptOverride: null }; + return { mode: "default", model: null, effort: null, promptOverride: null, fallback: null }; } export function defaultConfig() { @@ -70,9 +86,29 @@ function reconstructRole(raw ) { // effort: only a known wire value survives; anything else -> null (inherit). const effort = (EFFORTS ).includes(r.effort ) ? (r.effort ) : null; const promptOverride = typeof r.promptOverride === "string" ? r.promptOverride : null; + const fallback = reconstructFallback(r.fallback); // A "model" mode with no valid model is invalid -> fall back to default (fail safe). - if (mode === "model" && model === null) return { mode: "default", model: null, effort, promptOverride }; - return { mode, model, effort, promptOverride }; + if (mode === "model" && model === null) return { mode: "default", model: null, effort, promptOverride, fallback }; + return { mode, model, effort, promptOverride, fallback }; +} + +/** Missing or malformed fallback becomes null; invalid nested effort becomes inherit. */ +function reconstructFallback(raw ) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const f = raw ; + if (typeof f.model !== "string" || f.model.trim().length === 0) return null; + const effort = (EFFORTS ).includes(f.effort ) ? (f.effort ) : null; + return { model: f.model, effort }; +} + +function mergeFallback(current , patch ) { + if (patch === undefined) return current; + if (patch === null) return null; + const model = typeof patch.model === "string" ? patch.model : (current?.model ?? ""); + const effort = Object.prototype.hasOwnProperty.call(patch, "effort") + ? (patch.effort === undefined ? null : patch.effort) + : (current?.effort ?? null); + return { model, effort }; } @@ -168,7 +204,7 @@ export function readConfig(cwd , scope = "project", env } /** Validate a role patch, returning an error message or null. */ -export function validateRolePatch(patch ) { +export function validateRolePatch(patch ) { if (patch.mode !== undefined && patch.mode !== "default" && patch.mode !== "model") { return `invalid mode "${String(patch.mode)}" (must be "default" or "model")`; } @@ -185,6 +221,30 @@ export function validateRolePatch(patch ) { if (patch.promptOverride !== undefined && patch.promptOverride !== null && typeof patch.promptOverride !== "string") { return "promptOverride must be a string or null"; } + if (patch.fallback !== undefined && patch.fallback !== null) { + if (typeof patch.fallback !== "object" || Array.isArray(patch.fallback)) { + return "fallback must be an object or null"; + } + if (patch.fallback.model !== undefined && !(typeof patch.fallback.model === "string" && patch.fallback.model.trim().length > 0)) { + return "fallback requires a non-empty model id"; + } + if ( + patch.fallback.effort !== undefined && + patch.fallback.effort !== null && + !(EFFORTS ).includes(patch.fallback.effort ) + ) { + return `invalid fallback effort "${String(patch.fallback.effort)}" (must be one of ${EFFORTS.join("/")} or null)`; + } + } + if ( + patch.mode === "model" && + typeof patch.model === "string" && + patch.fallback && + typeof patch.fallback.model === "string" && + patch.fallback.model === patch.model + ) { + return "fallback model must differ from the primary model"; + } return null; } @@ -206,15 +266,22 @@ export function writeConfig(cwd , config ) { } /** Merge only the selected role; missing roles continue to inherit dynamically. */ -export function setRole(cwd , role , patch , scope = "project", env = process.env) { +export function setRole(cwd , role , patch , scope = "project", env = process.env) { if (!ROLES.includes(role)) throw new Error(`unknown role "${role}"`); const path = scopedPath(cwd, scope, env); const raw = scope === "global" ? readGlobalRaw(env, true) : readRaw(path, true); const current = Object.hasOwn(raw.roles, role) ? reconstructRole(raw.roles[role]) : readConfig(cwd, scope, env).roles[role]; - const next = { ...current, ...patch }; + if (patch.fallback !== undefined && patch.fallback !== null && (typeof patch.fallback !== "object" || Array.isArray(patch.fallback))) { + throw new Error("fallback must be an object or null"); + } + const fallbackError = validateRolePatch({ fallback: patch.fallback }); + if (fallbackError) throw new Error(fallbackError); + const { fallback: fallbackPatch, ...rest } = patch; + const next = { ...current, ...rest, fallback: mergeFallback(current.fallback, fallbackPatch) }; const err = validateRolePatch(next); if (err) throw new Error(err); if (next.mode === "default") next.model = null; + if (next.fallback) next.fallback = { model: next.fallback.model, effort: next.fallback.effort }; raw.roles[role] = { ...(typeof raw.roles[role] === "object" && raw.roles[role] !== null ? raw.roles[role] : {}), ...next }; writeRaw(path, raw); return readConfig(cwd, scope, env); diff --git a/plugins/codexclaw/components/subagent-config/src/cli.ts b/plugins/codexclaw/components/subagent-config/src/cli.ts index 3caebd2f..11a89c3c 100644 --- a/plugins/codexclaw/components/subagent-config/src/cli.ts +++ b/plugins/codexclaw/components/subagent-config/src/cli.ts @@ -13,7 +13,7 @@ * subagents set --mode default|model [--model ] [--effort |--clear-effort] * [--prompt |--clear-prompt] */ -import { readConfig, setRole, resetRole, type ConfigScope, projectConfigTrustToken, ROLES, EFFORTS, type RoleName, type RoleConfig, type EffortName } from "./store.ts"; +import { readConfig, setRole, resetRole, type ConfigScope, projectConfigTrustToken, ROLES, EFFORTS, type RoleName, type RolePatch, type EffortName } from "./store.ts"; import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -21,7 +21,7 @@ export interface ParsedSubagentsArgs { action: "list" | "get" | "set" | "reset" | "trust-token" | "help"; role?: RoleName; scope?: ConfigScope; - patch?: Partial; + patch?: RolePatch; error?: string; } @@ -32,7 +32,7 @@ function isRole(v: string | undefined): v is RoleName { /** Pure structural parse of the `subagents` argv (excluding the leading verb). */ export function parseSubagentsArgs(argv: string[]): ParsedSubagentsArgs { // Scope is an explicit trailing selector, so prompt/model values stay literal. - if (argv.at(-1) === "--global" && !["--prompt", "--model"].includes(argv.at(-2) ?? "")) { + if (argv.at(-1) === "--global" && !["--prompt", "--model", "--fallback-model"].includes(argv.at(-2) ?? "")) { return { ...parseProjectArgs(argv.slice(0, -1)), scope: "global" }; } return parseProjectArgs(argv); @@ -56,7 +56,9 @@ function parseProjectArgs(argv: string[]): ParsedSubagentsArgs { if (sub === "set") { if (!isRole(argv[1])) return { action: "set", error: `unknown role '${argv[1] ?? ""}' (expected ${ROLES.join("|")})` }; const role = argv[1]; - const patch: Partial = {}; + const patch: RolePatch = {}; + let clearFallback = false; + let setFallback = false; for (let i = 2; i < argv.length; i++) { const a = argv[i]; if (a === "--mode") { @@ -65,6 +67,16 @@ function parseProjectArgs(argv: string[]): ParsedSubagentsArgs { patch.mode = v; } else if (a === "--model") { patch.model = argv[++i] ?? ""; + } else if (a === "--fallback-model") { + const model = argv[++i]; + if (!model?.trim()) return { action: "set", role, error: "--fallback-model requires a model id" }; + patch.fallback = { ...patch.fallback, model }; setFallback = true; + } else if (a === "--fallback-effort") { + const value = argv[++i]; + if (value !== "inherit" && !(EFFORTS as readonly string[]).includes(value ?? "")) return { action: "set", role, error: "invalid --fallback-effort" }; + patch.fallback = { ...patch.fallback, effort: value === "inherit" ? null : value as EffortName }; setFallback = true; + } else if (a === "--clear-fallback") { + clearFallback = true; patch.fallback = null; } else if (a === "--effort") { const v = argv[++i]; if (!(EFFORTS as readonly string[]).includes(v ?? "")) { @@ -81,8 +93,9 @@ function parseProjectArgs(argv: string[]): ParsedSubagentsArgs { return { action: "set", role, error: `unknown flag '${a}'` }; } } + if (clearFallback && setFallback) return { action: "set", role, error: "--clear-fallback cannot be combined with fallback settings" }; if (Object.keys(patch).length === 0) { - return { action: "set", role, error: "set requires at least one of --mode/--model/--effort/--clear-effort/--prompt/--clear-prompt" }; + return { action: "set", role, error: "set requires at least one of --mode/--model/--effort/--clear-effort/--prompt/--clear-prompt/--fallback-model/--fallback-effort/--clear-fallback" }; } return { action: "set", role, patch }; } @@ -96,6 +109,8 @@ const HELP = [ " subagents list all role configs", " subagents get show one role config", " subagents set --mode default|model [--model ] [--effort |--clear-effort] [--prompt |--clear-prompt]", + " --fallback-model [--fallback-effort low|medium|high|xhigh|inherit] | --clear-fallback", + " subagents dispatch main-owned fallback protocol; JSON stdin (start/claim/report/status)", " subagents reset remove the role override and inherit the next scope", " Append --global to list/get/set/reset to manage user defaults", " subagents trust-token print an export bound to this repo and exact config", diff --git a/plugins/codexclaw/components/subagent-config/src/fallback-dispatch-cli.ts b/plugins/codexclaw/components/subagent-config/src/fallback-dispatch-cli.ts new file mode 100644 index 00000000..d711aba5 --- /dev/null +++ b/plugins/codexclaw/components/subagent-config/src/fallback-dispatch-cli.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { readSync, realpathSync } from "node:fs"; +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 created with the returned agentId, then use native wait. Report complete with that agentId, or failed with 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 function sessionFallbackNotice(cwd: string): string { + const roles = readConfig(cwd).roles; + const active = ROLES.filter(role => roles[role].fallback); + if (!active.length) return ""; + return JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: `[codexclaw] First fallback configured for ${active.join(", ")}. ${DISPATCH_GUIDANCE}` } }) + "\n"; +} +function main(): void { + const sessionStart = process.argv[2] === "session-start" || (process.argv[2] === "hook" && process.argv[3] === "session-start"); + try { + const buffer = Buffer.alloc(64 * 1024 + 1); + let size = 0; + for (;;) { + const count = readSync(0, buffer, size, buffer.length - size, null); + if (!count) break; + size += count; + if (size === buffer.length) throw new Error("dispatch input exceeds 64 KiB"); + } + const raw = buffer.subarray(0, size).toString("utf8"); + if (sessionStart) { + const payload = JSON.parse(raw) as { cwd?: unknown; agent_id?: unknown }; + if (typeof payload.agent_id === "string" && payload.agent_id) return; + process.stdout.write(sessionFallbackNotice(typeof payload.cwd === "string" ? payload.cwd : process.cwd())); + return; + } + process.stdout.write(JSON.stringify(runDispatch(process.cwd(), JSON.parse(raw))) + "\n"); + } catch (error) { + if (sessionStart) return; + process.stdout.write(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }) + "\n"); + process.exitCode = 1; + } +} +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/plugins/codexclaw/components/subagent-config/src/fallback-dispatch.ts b/plugins/codexclaw/components/subagent-config/src/fallback-dispatch.ts new file mode 100644 index 00000000..aa8f5dd1 --- /dev/null +++ b/plugins/codexclaw/components/subagent-config/src/fallback-dispatch.ts @@ -0,0 +1,222 @@ +/** Main-owned native spawn protocol. This module selects attempts; it never calls a provider. */ +import { randomUUID } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { EFFORTS, readConfig, ROLES, type EffortName, type RoleName } from "./store.ts"; +import { renameWithRetry } from "./atomic-write.ts"; +import { decodeDispatchFailure } from "./fallback-errors.ts"; + +export interface Candidate { model: string | null; effort: EffortName | null; } +interface Attempt { + id: string; + candidate: Candidate; + claimed: boolean; + agentId: string | null; + observedModel: string | null; + code: string | null; + status: "ready" | "claimed" | "running" | "reconcile" | "failed" | "complete"; + reconciliation: string | null; + spawnIssued: boolean; + toolUseId: string | null; +} +interface Dispatch { + version: 1; + sessionId: string; + id: string; + role: RoleName; + candidates: Candidate[]; + attempts: Attempt[]; + status: "active" | "stopped" | "complete" | "main-direct"; +} +export interface DispatchResult { + action: "ready" | "spawn" | "wait" | "reconcile" | "stop" | "complete" | "main-direct"; + dispatchId: string; + attemptId: string; + independentReviewRequired: boolean; + candidate?: Candidate; + marker?: string; + reason?: string; + attempts: ReadonlyArray; +} +const ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,95}$/; +const MAX_INPUT = 64 * 1024; +function dispatchRoot(cwd: string): string { + const git = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", timeout: 1500 }); + return realpathSync(git.status === 0 ? git.stdout.trim() : cwd); +} +function record(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected a JSON object"); + return value as Record; +} +function id(value: unknown, field: string): string { + if (typeof value !== "string" || !ID.test(value)) throw new Error(`invalid ${field}`); + return value; +} +function smallText(value: unknown, field: string): string { + if (typeof value !== "string" || !value.trim() || value.length > 2000) throw new Error(`invalid ${field}`); + return value.trim(); +} +function directory(cwd: string, sessionId: string): string { + let dir = cwd; + for (const part of [".codexclaw", "dispatches", sessionId]) { + dir = join(dir, part); + if (existsSync(dir)) { + if (!lstatSync(dir).isDirectory() || lstatSync(dir).isSymbolicLink()) throw new Error("dispatch directory must not be a symlink"); + } else mkdirSync(dir, { mode: 0o700 }); + } + return dir; +} +function candidate(raw: unknown): Candidate { + const c = record(raw); + if (c.model !== null && (typeof c.model !== "string" || !c.model.trim())) throw new Error("invalid stored model"); + if (c.effort !== null && !(EFFORTS as readonly unknown[]).includes(c.effort)) throw new Error("invalid stored effort"); + return { model: c.model as string | null, effort: c.effort as EffortName | null }; +} +function readState(path: string, sessionId: string, dispatchId: string): Dispatch { + if (lstatSync(path).isSymbolicLink()) throw new Error("dispatch state must not be a symlink"); + const d = record(JSON.parse(readFileSync(path, "utf8"))); + if (d.version !== 1 || d.sessionId !== sessionId || d.id !== dispatchId || !ROLES.includes(d.role as RoleName)) throw new Error("invalid dispatch identity"); + if (!Array.isArray(d.candidates) || d.candidates.length < 1 || d.candidates.length > 2) throw new Error("invalid candidates"); + d.candidates.forEach(candidate); + if (!Array.isArray(d.attempts) || d.attempts.length < 1 || d.attempts.length > d.candidates.length) throw new Error("invalid attempts"); + if (!["active", "stopped", "complete", "main-direct"].includes(String(d.status))) throw new Error("invalid dispatch status"); + for (const [i, raw] of d.attempts.entries()) { + const a = record(raw); id(a.id, "attemptId"); candidate(a.candidate); + if (candidate(a.candidate).model !== candidate(d.candidates[i]).model || candidate(a.candidate).effort !== candidate(d.candidates[i]).effort || typeof a.claimed !== "boolean") throw new Error("invalid attempt candidate"); + if (typeof a.spawnIssued !== "boolean") throw new Error("invalid spawn issuance"); + if (!["ready", "claimed", "running", "reconcile", "failed", "complete"].includes(String(a.status))) throw new Error("invalid attempt status"); + for (const field of ["agentId", "observedModel", "code", "reconciliation", "toolUseId"]) { + if (a[field] !== null && typeof a[field] !== "string") throw new Error(`invalid attempt ${field}`); + } + } + return d as unknown as Dispatch; +} +function saveState(path: string, state: Dispatch): void { + const temp = `${path}.${randomUUID()}.tmp`; + try { + writeFileSync(temp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600, flag: "wx" }); + renameWithRetry(temp, path); + } 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 }; +} +function result(d: Dispatch, action?: DispatchResult["action"], reason?: string): DispatchResult { + const a = d.attempts.at(-1)!; + const inferred = d.status === "active" ? (a.status === "ready" ? "ready" : a.status === "running" ? "wait" : "reconcile") : d.status === "stopped" ? "stop" : d.status; + return { action: action ?? inferred, dispatchId: d.id, attemptId: a.id, + independentReviewRequired: d.role === "reviewer", attempts: d.attempts, + ...(reason ? { reason } : {}) }; +} + +/** Explicit caller reports are evidence from the main agent, not authenticated provider receipts. */ +export function runDispatch(cwd: string, input: unknown, env: NodeJS.ProcessEnv = process.env): DispatchResult { + cwd = dispatchRoot(cwd); + const b = record(input); + if (JSON.stringify(b).length > MAX_INPUT) throw new Error("dispatch input exceeds 64 KiB"); + const sessionId = id(b.sessionId, "sessionId"); + if (env.CODEX_THREAD_ID && env.CODEX_THREAD_ID !== sessionId) throw new Error("sessionId must match the native main session"); + const dispatchId = id(b.dispatchId, "dispatchId"); + const dir = directory(cwd, sessionId); + const path = join(dir, `${dispatchId}.json`); + const lock = `${path}.lock`; + // Never steal a stale lock: an interrupted writer requires inspection, not a second spawn. + mkdirSync(lock, { mode: 0o700 }); + try { + if (b.action === "start") { + if (existsSync(path)) throw new Error("dispatch already exists; use status, never replay start"); + if (!ROLES.includes(b.role as RoleName)) throw new Error("invalid role"); + const role = b.role as RoleName; + const cfg = readConfig(cwd, "project", env).roles[role]; + const candidates: Candidate[] = [{ model: cfg.mode === "model" ? cfg.model : null, effort: cfg.effort }]; + if (cfg.fallback && cfg.fallback.model !== candidates[0].model) candidates.push({ ...cfg.fallback }); + const d: Dispatch = { version: 1, sessionId, id: dispatchId, role, candidates, attempts: [attempt(candidates[0])], status: "active" }; + saveState(path, d); + return result(d); + } + const d = readState(path, sessionId, dispatchId); + if (b.action === "status") return result(d); + const a = d.attempts.at(-1)!; + if (b.attemptId !== a.id) throw new Error("stale or missing attemptId; inspect status"); + if (d.status !== "active") return result(d); + if (b.action === "claim") { + if (a.claimed || a.status !== "ready") return result(d, "reconcile", "attempt already claimed; do not spawn again"); + a.claimed = true; a.status = "claimed"; saveState(path, d); + return { ...result(d, "spawn"), candidate: a.candidate, marker: `[CXC-DISPATCH:${dispatchId}:${a.id}]` }; + } + if (b.action !== "report") throw new Error("action must be start, claim, report or status"); + if (!a.claimed) throw new Error("claim the attempt before reporting an outcome"); + const response = report(d, b); + saveState(path, d); + return response; + } finally { rmSync(lock, { recursive: true }); } +} +function report(d: Dispatch, b: Record): DispatchResult { + const a = d.attempts.at(-1)!; + if (b.outcome === "created") { + const agentId = id(b.agentId, "agentId"); + if (a.agentId !== null && a.agentId !== agentId) throw new Error("agentId changed"); + a.agentId = agentId; a.status = "running"; + if (b.observedModel !== undefined) a.observedModel = smallText(b.observedModel, "observedModel"); + return result(d, "wait"); + } + if (b.outcome === "complete") { + 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 !== "failed" && b.outcome !== "unavailable") throw new Error("invalid report outcome"); + const failure = b.outcome === "unavailable" ? null : decodeDispatchFailure(b.error); + a.code = failure?.code ?? null; + // Never turn denial/cancellation/unknown prose into either another model or main execution. + 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"); } + if (b.executionState !== "not_created" && b.executionState !== "stopped") { + a.status = "reconcile"; return result(d, "reconcile", "confirm whether a child exists and stop it before handoff"); + } + if (a.agentId && (b.executionState !== "stopped" || b.agentId !== a.agentId)) throw new Error("recorded child must be stopped and identified"); + // Even pre-spawn failure needs a concrete observation, not timeout-as-proof. + a.reconciliation = smallText(b.reconciliation, "reconciliation evidence"); + if (b.outcome === "unavailable") { + if (a.agentId || b.executionState !== "not_created") throw new Error("unavailable requires confirmed no child"); + 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"); + 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); +} + +/** 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); + const match = /^\[CXC-DISPATCH:([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+)\](?:\r?\n|$)/m.exec(message); + if (!match) return null; + id(sessionId, "sessionId"); id(match[1], "dispatchId"); id(match[2], "attemptId"); + const path = join(cwd, ".codexclaw", "dispatches", sessionId, `${match[1]}.json`); + const d = readState(path, sessionId, match[1]); + const a = d.attempts.at(-1)!; + if (a.id !== match[2] || !a.claimed || a.status !== "claimed" || d.status !== "active") throw new Error("managed spawn attempt is not claimed or no longer current"); + return { candidate: a.candidate, role: d.role }; +} + +/** Consume native issuance under the same dispatch lock; repeated hook delivery needs the same host tool id. */ +export function issueManagedSpawn(cwd: string, sessionId: string, message: string, toolUseId: string | null): { candidate: Candidate; role: RoleName } | null { + cwd = dispatchRoot(cwd); + const match = /^\[CXC-DISPATCH:([a-zA-Z0-9_-]+):([a-zA-Z0-9_-]+)\](?:\r?\n|$)/m.exec(message); + if (!match) return null; + const resolved = managedSpawn(cwd, sessionId, message); + const dir = directory(cwd, sessionId); + const path = join(dir, `${match[1]}.json`); + const lock = `${path}.lock`; + mkdirSync(lock, { mode: 0o700 }); + try { + const d = readState(path, sessionId, match[1]); + const a = d.attempts.at(-1)!; + if (a.id !== match[2] || a.status !== "claimed" || !a.claimed || d.status !== "active") throw new Error("managed attempt changed before issuance"); + if (a.spawnIssued && (!toolUseId || a.toolUseId !== toolUseId)) throw new Error("attempt already issued to another native call; reconcile before retry"); + a.spawnIssued = true; a.toolUseId = toolUseId; saveState(path, d); + return resolved; + } finally { rmSync(lock, { recursive: true }); } +} diff --git a/plugins/codexclaw/components/subagent-config/src/fallback-errors.ts b/plugins/codexclaw/components/subagent-config/src/fallback-errors.ts new file mode 100644 index 00000000..46780e4f --- /dev/null +++ b/plugins/codexclaw/components/subagent-config/src/fallback-errors.ts @@ -0,0 +1,48 @@ +/** Decode OCX errors at the native wait string boundary; this is not a retry engine. */ +const NEXT_CODES = new Set([ + "insufficient_quota", "rate_limit_exceeded", "upstream_server_error", + "model_not_found", "unsupported_model", "unsupported_reasoning_effort", + "input_admission_refused", +]); +export interface FailureDecision { code: string | null; action: "next" | "stop" | "unknown"; } + +export function decodeDispatchFailure(value: unknown): FailureDecision { + if (typeof value === "string") { + const text = value.trim(); + try { return decodeDispatchFailure(JSON.parse(text)); } catch { /* native prose surface */ } + // Codex 0.153.4 rewrites HTTP/SSE errors before wait_agent exposes them. + // Match observed host templates, not quota/server keywords in arbitrary prose. + if (text === "Quota exceeded. Check your plan and billing details.") { + return codeDecision("insufficient_quota"); + } + if (text === "exceeded retry limit, last status: 429 Too Many Requests" || text.startsWith("rate limit exceeded: ")) { + return codeDecision("rate_limit_exceeded"); + } + if (text === "We're currently experiencing high demand, which may cause temporary errors.") { + return codeDecision("upstream_server_error"); + } + // Only complete code tokens and canonical transport prefixes, never arbitrary keywords. + if (/^[a-z][a-z0-9_]*$/.test(text)) return codeDecision(text); + if (/^(?:Cursor rate limit exceeded|Rate limit reached)(?:[\s.:]|$)/i.test(text)) { + return codeDecision("rate_limit_exceeded"); + } + if (/^(?:You've hit your usage limit|You have exceeded your current quota)(?:[\s.:]|$)/i.test(text)) { + return codeDecision("insufficient_quota"); + } + return { code: null, action: "unknown" }; + } + if (!value || typeof value !== "object" || Array.isArray(value)) return { code: null, action: "unknown" }; + const record = value as Record; + // Only known envelope paths. Do not search arbitrary nested task output for error codes. + if (typeof record.code === "string") return codeDecision(record.code); + if (record.error !== undefined) return decodeDispatchFailure(record.error); + if (record.last_error !== undefined) return decodeDispatchFailure(record.last_error); + if (record.response && typeof record.response === "object") { + return decodeDispatchFailure((record.response as Record).error); + } + return { code: null, action: "unknown" }; +} +function codeDecision(code: string): FailureDecision { + // All other codes, including policy, auth, cancellation and context overflow, stop. + return { code, action: NEXT_CODES.has(code) ? "next" : "stop" }; +} diff --git a/plugins/codexclaw/components/subagent-config/src/mcp.ts b/plugins/codexclaw/components/subagent-config/src/mcp.ts index b44a3985..3ca9ac17 100644 --- a/plugins/codexclaw/components/subagent-config/src/mcp.ts +++ b/plugins/codexclaw/components/subagent-config/src/mcp.ts @@ -48,6 +48,12 @@ const TOOLS = [ mode: { type: "string", enum: ["default", "model"] }, model: { type: ["string", "null"] }, effort: { type: ["string", "null"], enum: [...EFFORTS, null] }, + fallback: { + type: ["object", "null"], + description: "Optional first fallback. Null clears; omitted nested effort inherits existing fallback effort or session effort.", + properties: { model: { type: "string", minLength: 1 }, effort: { type: ["string", "null"], enum: [...EFFORTS, null] } }, + additionalProperties: false, + }, promptOverride: { type: ["string", "null"] }, }, required: ["role"], diff --git a/plugins/codexclaw/components/subagent-config/src/settings-api.ts b/plugins/codexclaw/components/subagent-config/src/settings-api.ts index 3229eed4..20e2888f 100644 --- a/plugins/codexclaw/components/subagent-config/src/settings-api.ts +++ b/plugins/codexclaw/components/subagent-config/src/settings-api.ts @@ -13,7 +13,7 @@ export function updateSettings(cwd: string, body: unknown): SubagentSettings { const scope = configScope(b.scope); if (b.inherit !== undefined && typeof b.inherit !== 'boolean') throw new Error('inherit must be a boolean'); const patch: Record = {}; - for (const key of ['mode', 'model', 'effort', 'promptOverride']) { + for (const key of ['mode', 'model', 'effort', 'promptOverride', 'fallback']) { if (b[key] !== undefined) patch[key] = b[key]; } if (b.inherit === true) { diff --git a/plugins/codexclaw/components/subagent-config/src/spawn-attach-hook.ts b/plugins/codexclaw/components/subagent-config/src/spawn-attach-hook.ts index d7adc38e..191cd0bd 100644 --- a/plugins/codexclaw/components/subagent-config/src/spawn-attach-hook.ts +++ b/plugins/codexclaw/components/subagent-config/src/spawn-attach-hook.ts @@ -40,7 +40,9 @@ import { createHash, randomBytes } from "node:crypto"; import { tmpdir } from "node:os"; import { basename, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { resolveSpawnConfig, type RoleName } from "./store.ts"; +import { readConfig, resolveSpawnConfig, type RoleName } from "./store.ts"; +import { managedSpawn, issueManagedSpawn } from "./fallback-dispatch.ts"; +import { DISPATCH_GUIDANCE } from "./fallback-dispatch-cli.ts"; import { checkFinalGatePrereqs } from "./final-gate-guard.ts"; function isRecord(v: unknown): v is Record { @@ -438,7 +440,8 @@ const REVIEW_KEYWORDS = [ * on hook-path dispatches. */ export function inferRole(agentType: unknown, message: string): RoleName { - if (agentType === "worker") return "executor"; + if (agentType === "worker" || agentType === "executor") return "executor"; + if (agentType === "reviewer") return "reviewer"; const m = (message ?? "").toLowerCase(); return REVIEW_KEYWORDS.some((k) => m.includes(k)) ? "reviewer" : "explorer"; } @@ -778,13 +781,23 @@ export function runSpawnAttachHook(raw: string): string { // Only rewrite a real message; never invent one (schema shape stays untouched). const message = toolInput.message; if (typeof message !== "string" || message.trim().length === 0) return ""; + const cwd = typeof obj.cwd === "string" && obj.cwd.length > 0 ? obj.cwd : process.cwd(); + const dispatchScan = scanInlineSkillBlocks(message).scanSource; + let managed: ReturnType = null; + if (/^\[CXC-DISPATCH:/m.test(dispatchScan)) { + try { + if (isFullHistoryFork(toolInput)) return denyEnvelope("managed fallback requires a fresh context"); + managed = managedSpawn(cwd, typeof obj.session_id === "string" ? obj.session_id : "", dispatchScan); + if (!managed) return denyEnvelope("invalid managed dispatch marker"); + } catch (error) { return denyEnvelope(`managed dispatch: ${error instanceof Error ? error.message : String(error)}`); } + } const recursionRequested = !spawnedBySubagent && message.includes(SUBSPAWN_TOKEN); const mintedGrant = recursionRequested ? mintRecursionGrant(obj) : null; const controlledMessage = stripControlMarkers(message); const skillsDir = runtimeSkillsDir(); const normalizedMessage = skillsDir ? normalizeSkillMentions(controlledMessage, skillsDir) : controlledMessage; - const role = inferRole(toolInput.agent_type, normalizedMessage); + const role = managed?.role ?? inferRole(toolInput.agent_type, normalizedMessage); // Skill delivery: inline the recognized cxc SKILL.md bodies (atomic overflow // rule inside). @@ -856,7 +869,6 @@ export function runSpawnAttachHook(raw: string): string { // FULL-HISTORY FORK GUARD (model/effort only): codex-rs hard-rejects // model/reasoning_effort overrides on full-history forks, so those two fields // are skipped there. promptOverride is not subject to this guard. - const cwd = typeof obj.cwd === "string" && obj.cwd.length > 0 ? obj.cwd : process.cwd(); const resolution = resolveSpawnConfig(cwd, role); if (resolution.trustWarning) { evidenceExemptMessage = `[CXC-CONFIG-IGNORED] ${resolution.trustWarning}\n\n${evidenceExemptMessage}`; @@ -867,7 +879,7 @@ export function runSpawnAttachHook(raw: string): string { const injectedPrompt = typeof resolution.promptOverride === "string" && resolution.promptOverride.trim().length > 0 ? resolution.promptOverride.trim() : null; - if (!isFullHistoryFork(toolInput)) { + if (!managed && !isFullHistoryFork(toolInput)) { const callerModel = toolInput.model; const callerPickedModel = typeof callerModel === "string" && callerModel.trim().length > 0; const callerEffort = toolInput.reasoning_effort; @@ -894,7 +906,7 @@ export function runSpawnAttachHook(raw: string): string { // Guard was empty (existing guard already present in message, or no guard // needed). Find the existing guard marker and insert after the guard block; // if no marker is found, prepend to the whole message. - const markerIdx = evidenceExemptMessage.indexOf(surfaceMarker); + const markerIdx = evidenceExemptMessage.indexOf(v2Spawn ? LEAF_GUARD_MARKER : SCOPE_GUARD_MARKER); if (markerIdx !== -1) { // Find the end of the existing guard block (first double newline after marker). const blockEnd = evidenceExemptMessage.indexOf("\n\n", markerIdx); @@ -921,17 +933,29 @@ export function runSpawnAttachHook(raw: string): string { ); if (!gateCheck.ok) return denyEnvelope(gateCheck.reason ?? "final gate prerequisites are missing"); - if (!messageChanged && injectedModel === null && injectedEffort === null) return ""; + const fallbackNotice = !managed && readConfig(cwd).roles[role].fallback + ? `[codexclaw] This direct spawn is not managed by first-fallback tracking. For subsequent tasks: ${DISPATCH_GUIDANCE}` : null; + if (!managed && !fallbackNotice && !messageChanged && injectedModel === null && injectedEffort === null) return ""; // Full replacement: echo every original key; change only message/model/effort. const updatedInput: Record = { ...toolInput, message: evidenceExemptMessage }; if (injectedModel !== null) updatedInput.model = injectedModel; if (injectedEffort !== null) updatedInput.reasoning_effort = injectedEffort; + if (managed) { + try { + issueManagedSpawn(cwd, typeof obj.session_id === "string" ? obj.session_id : "", dispatchScan, typeof obj.tool_use_id === "string" ? obj.tool_use_id : null); + } catch (error) { return denyEnvelope(`managed dispatch: ${error instanceof Error ? error.message : String(error)}`); } + if (managed.candidate.model === null) delete updatedInput.model; + else updatedInput.model = managed.candidate.model; + if (managed.candidate.effort === null) delete updatedInput.reasoning_effort; + else updatedInput.reasoning_effort = managed.candidate.effort; + } return `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput, + ...(fallbackNotice ? { additionalContext: fallbackNotice } : {}), }, })}\n`; } catch { diff --git a/plugins/codexclaw/components/subagent-config/src/store.ts b/plugins/codexclaw/components/subagent-config/src/store.ts index 1debd0e4..aac642c4 100644 --- a/plugins/codexclaw/components/subagent-config/src/store.ts +++ b/plugins/codexclaw/components/subagent-config/src/store.ts @@ -42,14 +42,30 @@ export interface RoleConfig { effort: EffortName | null; /** role prompt-segment override; null means "no override" (never fabricated). */ promptOverride: string | null; + /** + * Optional first fallback after the primary candidate. null = no fallback. + * Independent of primary effort. Duplicate model IDs are rejected only when + * mode is "model" (default-mode primary identity is unknown). + */ + fallback: RoleFallback | null; } +export interface RoleFallback { + model: string; + effort: EffortName | null; +} + +/** Patch shape: fallback may be a partial nested update or null to clear. */ +export type RolePatch = Partial> & { + fallback?: Partial | null; +}; + export interface SubagentsConfig { roles: Record; } export function defaultRole(): RoleConfig { - return { mode: "default", model: null, effort: null, promptOverride: null }; + return { mode: "default", model: null, effort: null, promptOverride: null, fallback: null }; } export function defaultConfig(): SubagentsConfig { @@ -70,9 +86,29 @@ function reconstructRole(raw: unknown): RoleConfig { // effort: only a known wire value survives; anything else -> null (inherit). const effort = (EFFORTS as readonly string[]).includes(r.effort as string) ? (r.effort as EffortName) : null; const promptOverride = typeof r.promptOverride === "string" ? r.promptOverride : null; + const fallback = reconstructFallback(r.fallback); // A "model" mode with no valid model is invalid -> fall back to default (fail safe). - if (mode === "model" && model === null) return { mode: "default", model: null, effort, promptOverride }; - return { mode, model, effort, promptOverride }; + if (mode === "model" && model === null) return { mode: "default", model: null, effort, promptOverride, fallback }; + return { mode, model, effort, promptOverride, fallback }; +} + +/** Missing or malformed fallback becomes null; invalid nested effort becomes inherit. */ +function reconstructFallback(raw: unknown): RoleFallback | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const f = raw as Record; + if (typeof f.model !== "string" || f.model.trim().length === 0) return null; + const effort = (EFFORTS as readonly string[]).includes(f.effort as string) ? (f.effort as EffortName) : null; + return { model: f.model, effort }; +} + +function mergeFallback(current: RoleFallback | null, patch: Partial | null | undefined): RoleFallback | null { + if (patch === undefined) return current; + if (patch === null) return null; + const model = typeof patch.model === "string" ? patch.model : (current?.model ?? ""); + const effort = Object.prototype.hasOwnProperty.call(patch, "effort") + ? (patch.effort === undefined ? null : patch.effort) + : (current?.effort ?? null); + return { model, effort }; } export type ConfigScope = "project" | "global"; @@ -168,7 +204,7 @@ export function readConfig(cwd: string, scope: ConfigScope = "project", env: Nod } /** Validate a role patch, returning an error message or null. */ -export function validateRolePatch(patch: Partial): string | null { +export function validateRolePatch(patch: RolePatch): string | null { if (patch.mode !== undefined && patch.mode !== "default" && patch.mode !== "model") { return `invalid mode "${String(patch.mode)}" (must be "default" or "model")`; } @@ -185,6 +221,30 @@ export function validateRolePatch(patch: Partial): string | null { if (patch.promptOverride !== undefined && patch.promptOverride !== null && typeof patch.promptOverride !== "string") { return "promptOverride must be a string or null"; } + if (patch.fallback !== undefined && patch.fallback !== null) { + if (typeof patch.fallback !== "object" || Array.isArray(patch.fallback)) { + return "fallback must be an object or null"; + } + if (patch.fallback.model !== undefined && !(typeof patch.fallback.model === "string" && patch.fallback.model.trim().length > 0)) { + return "fallback requires a non-empty model id"; + } + if ( + patch.fallback.effort !== undefined && + patch.fallback.effort !== null && + !(EFFORTS as readonly string[]).includes(patch.fallback.effort as string) + ) { + return `invalid fallback effort "${String(patch.fallback.effort)}" (must be one of ${EFFORTS.join("/")} or null)`; + } + } + if ( + patch.mode === "model" && + typeof patch.model === "string" && + patch.fallback && + typeof patch.fallback.model === "string" && + patch.fallback.model === patch.model + ) { + return "fallback model must differ from the primary model"; + } return null; } @@ -206,15 +266,22 @@ export function writeConfig(cwd: string, config: SubagentsConfig): void { } /** Merge only the selected role; missing roles continue to inherit dynamically. */ -export function setRole(cwd: string, role: RoleName, patch: Partial, scope: ConfigScope = "project", env: NodeJS.ProcessEnv = process.env): SubagentsConfig { +export function setRole(cwd: string, role: RoleName, patch: RolePatch, scope: ConfigScope = "project", env: NodeJS.ProcessEnv = process.env): SubagentsConfig { if (!ROLES.includes(role)) throw new Error(`unknown role "${role}"`); const path = scopedPath(cwd, scope, env); const raw = scope === "global" ? readGlobalRaw(env, true) : readRaw(path, true); const current = Object.hasOwn(raw.roles, role) ? reconstructRole(raw.roles[role]) : readConfig(cwd, scope, env).roles[role]; - const next: RoleConfig = { ...current, ...patch }; + if (patch.fallback !== undefined && patch.fallback !== null && (typeof patch.fallback !== "object" || Array.isArray(patch.fallback))) { + throw new Error("fallback must be an object or null"); + } + const fallbackError = validateRolePatch({ fallback: patch.fallback }); + if (fallbackError) throw new Error(fallbackError); + const { fallback: fallbackPatch, ...rest } = patch; + const next: RoleConfig = { ...current, ...rest, fallback: mergeFallback(current.fallback, fallbackPatch) }; const err = validateRolePatch(next); if (err) throw new Error(err); if (next.mode === "default") next.model = null; + if (next.fallback) next.fallback = { model: next.fallback.model, effort: next.fallback.effort }; raw.roles[role] = { ...(typeof raw.roles[role] === "object" && raw.roles[role] !== null ? raw.roles[role] as Record : {}), ...next }; writeRaw(path, raw); return readConfig(cwd, scope, env); diff --git a/plugins/codexclaw/components/subagent-config/test/fallback-config.test.ts b/plugins/codexclaw/components/subagent-config/test/fallback-config.test.ts new file mode 100644 index 00000000..48d321cc --- /dev/null +++ b/plugins/codexclaw/components/subagent-config/test/fallback-config.test.ts @@ -0,0 +1,54 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ROLES, readConfig, setRole, resetRole, defaultRole } from "../src/store.ts"; +import { parseSubagentsArgs, runSubagents } from "../src/cli.ts"; +import { updateSettings } from "../src/settings-api.ts"; + +for (const role of ROLES) test(`${role}: fallback stores independently, follows scopes and clears`, () => { + const cwd = mkdtempSync(join(tmpdir(), "cxc-fallback-config-")); + const env = { CODEXCLAW_HOME: join(cwd, "global") }; + setRole(cwd, role, { mode: "model", model: "primary/a", effort: "high", fallback: { model: "secondary/b", effort: "low" } }, "global", env); + assert.deepEqual(readConfig(cwd, "project", env).roles[role].fallback, { model: "secondary/b", effort: "low" }); + setRole(cwd, role, { fallback: { effort: null } }, "project", env); + assert.equal(readConfig(cwd, "project", env).roles[role].effort, "high"); + assert.deepEqual(readConfig(cwd, "project", env).roles[role].fallback, { model: "secondary/b", effort: null }); + setRole(cwd, role, { fallback: null }, "project", env); + assert.equal(readConfig(cwd, "project", env).roles[role].fallback, null); + resetRole(cwd, role, "project", env); + assert.equal(readConfig(cwd, "project", env).roles[role].fallback?.effort, "low"); +}); +test("invalid fallback updates preserve file bytes; exact provider IDs stay distinct", () => { + const cwd = mkdtempSync(join(tmpdir(), "cxc-fallback-invalid-")); + const env = { CODEXCLAW_HOME: join(cwd, "global") }; + setRole(cwd, "executor", { mode: "model", model: "xai/grok-4.6", fallback: { model: "cursor/grok-4.6", effort: null } }, "project", env); + const path = join(cwd, ".codexclaw/subagents.json"); const before = readFileSync(path, "utf8"); + for (const fallback of [{ model: "xai/grok-4.6" }, { model: " " }, { model: 42 }, { effort: "bogus" }, []]) { + assert.throws(() => setRole(cwd, "executor", { fallback } as never, "project", env)); + assert.equal(readFileSync(path, "utf8"), before); + } + setRole(cwd, "executor", { mode: "default", fallback: { model: "xai/grok-4.6" } }, "project", env); + assert.equal(readConfig(cwd, "project", env).roles.executor.fallback?.model, "xai/grok-4.6"); +}); +test("legacy JSON has no fallback, and effort-only creation without model is rejected", () => { + const cwd = mkdtempSync(join(tmpdir(), "cxc-fallback-old-")); const env = { CODEXCLAW_HOME: join(cwd, "global") }; + setRole(cwd, "executor", { mode: "default" }, "project", env); + writeFileSync(join(cwd, ".codexclaw/subagents.json"), JSON.stringify({ roles: { executor: { mode: "model", model: "old/model", effort: "high" } } })); + assert.equal(readConfig(cwd, "project", env).roles.executor.fallback, null); + assert.equal(defaultRole().fallback, null); + assert.throws(() => setRole(cwd, "executor", { fallback: { effort: "low" } }, "project", env), /model/); +}); +test("CLI and settings API roundtrip fallback and reject conflicting clear", () => { + const cwd = mkdtempSync(join(tmpdir(), "cxc-fallback-cli-config-")); + const parsed = parseSubagentsArgs(["set", "executor", "--fallback-model", "cursor/grok-4.6", "--fallback-effort", "low"]); + assert.equal(runSubagents(parsed, cwd).code, 0); + const updated = updateSettings(cwd, { role: "executor", fallback: { effort: null } }); + assert.deepEqual(updated.roles.executor.fallback, { model: "cursor/grok-4.6", effort: null }); + assert.ok(parseSubagentsArgs(["set", "executor", "--clear-fallback", "--fallback-model", "x"]).error); + assert.ok(parseSubagentsArgs(["set", "executor", "--fallback-model", "x", "--clear-fallback"]).error); + assert.equal(parseSubagentsArgs(["set", "executor", "--fallback-model", "--global"]).scope, undefined); + assert.equal(runSubagents(parseSubagentsArgs(["set", "executor", "--clear-fallback"]), cwd).code, 0); + assert.equal(readConfig(cwd).roles.executor.fallback, null); +}); 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 new file mode 100644 index 00000000..498dc502 --- /dev/null +++ b/plugins/codexclaw/components/subagent-config/test/fallback-dispatch-cli.test.ts @@ -0,0 +1,51 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { setRole } from "../src/store.ts"; +const cli = resolve(dirname(fileURLToPath(import.meta.url)), "../src/fallback-dispatch-cli.ts"); + +test("real CLI persists route, survives separate processes, and emits startup protocol", () => { + const cwd = mkdtempSync(join(tmpdir(), "cxc-dispatch-cli-")); + 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, args: string[] = []) => { + const child = spawnSync(process.execPath, [cli, ...args], { 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; + }; + assert.match(call({ cwd }, ["hook", "session-start"]).hookSpecificOutput.additionalContext, /executor/); + assert.equal(call({ cwd, agent_id: "child" }, ["hook", "session-start"]), null); + const base = { sessionId: "fixture", dispatchId: "one" }; + const first = call({ ...base, action: "start", role: "executor" }); + const claim = call({ ...base, action: "claim", attemptId: first.attemptId }); + assert.equal(claim.action, "spawn"); assert.equal(claim.candidate.model, "xai/grok-4.6"); + const second = call({ ...base, action: "report", attemptId: first.attemptId, outcome: "failed", error: "insufficient_quota", executionState: "not_created", reconciliation: "native tool rejected before creation" }); + assert.equal(second.action, "ready"); + const next = call({ ...base, action: "claim", attemptId: second.attemptId }); + assert.equal(next.candidate.model, "cursor/grok-4.6"); + call({ ...base, action: "report", attemptId: next.attemptId, outcome: "created", agentId: "native-child" }); + assert.equal(call({ ...base, action: "report", attemptId: next.attemptId, outcome: "complete", agentId: "native-child" }).action, "complete"); + assert.equal(call({ ...base, action: "status" }).action, "complete"); +}); +test("real CLI refuses corrupt state and invalid JSON rather than resetting it", () => { + const cwd = mkdtempSync(join(tmpdir(), "cxc-dispatch-cli-invalid-")); + const { CODEX_THREAD_ID: _nativeSession, ...env } = process.env; + const base = { sessionId: "fixture", dispatchId: "one" }; + const run = (input: string) => spawnSync(process.execPath, [cli], { cwd, env, input, encoding: "utf8" }); + assert.equal(run("{").status, 1); + assert.equal(run(JSON.stringify({ ...base, action: "start", role: "executor" })).status, 0); + writeFileSync(join(cwd, ".codexclaw/dispatches/fixture/one.json"), "{}"); + const out = run(JSON.stringify({ ...base, action: "status" })); + assert.equal(out.status, 1); assert.match(out.stdout, /invalid dispatch identity/); +}); +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, ""); + const command = spawnSync(process.execPath, [cli], { input: "", encoding: "utf8" }); + assert.equal(command.status, 1); assert.ok(JSON.parse(command.stdout).error); +}); diff --git a/plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts b/plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts new file mode 100644 index 00000000..e41bc7ad --- /dev/null +++ b/plugins/codexclaw/components/subagent-config/test/fallback-dispatch.test.ts @@ -0,0 +1,172 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runDispatch, managedSpawn } from "../src/fallback-dispatch.ts"; +import { decodeDispatchFailure } from "../src/fallback-errors.ts"; +import { setRole, ROLES } from "../src/store.ts"; +import { runSpawnAttachHook } from "../src/spawn-attach-hook.ts"; + +function fixture(role: typeof ROLES[number] = "executor") { + const cwd = mkdtempSync(join(tmpdir(), "cxc-fallback-")); + const env = { CODEXCLAW_HOME: join(cwd, "global") }; + setRole(cwd, role, { mode: "model", model: "xai/grok-4.6", effort: "high", fallback: { model: "cursor/grok-4.6", effort: null } }, "project", env); + const base = { sessionId: "session-test", dispatchId: "task-test" }; + const call = (input: Record) => runDispatch(cwd, { ...base, ...input }, env); + const start = call({ action: "start", role }); + return { cwd, base, call, start }; +} + +// Captured from real Codex 0.153.4 wait_agent results against a loopback provider. +const nativeFailures = [ + ["exceeded retry limit, last status: 429 Too Many Requests", "rate_limit_exceeded"], + ["Quota exceeded. Check your plan and billing details.", "insufficient_quota"], + ["rate limit exceeded: Cursor rate limit exceeded: fixture exhausted", "rate_limit_exceeded"], + ["We're currently experiencing high demand, which may cause temporary errors.", "upstream_server_error"], +] as const; + +test("native wait errors retain fallback eligibility after Codex rewrites provider codes", () => { + for (const [message, code] of nativeFailures) { + assert.deepEqual(decodeDispatchFailure(message), { code, action: "next" }); + assert.equal(decodeDispatchFailure({ error: { code: "permission_denied", message } }).action, "stop"); + assert.equal(decodeDispatchFailure(`Task output: ${message}`).action, "unknown"); + } + for (const message of [ + "exceeded retry limit, last status: 403 Forbidden", + "unexpected status 403 Forbidden: Quota exceeded. Check your plan and billing details.", + "Quota exceeded. Check your plan and billing details. Permission denied.", + "We're currently experiencing high demand, which may cause temporary errors. Permission denied.", + "rate limit exceededness", + ]) assert.equal(decodeDispatchFailure(message).action, "unknown"); +}); + +for (const role of ROLES) test(`${role}: native errors select fallback only after child reconciliation`, () => { + for (const [error] of nativeFailures) { + const { call, start } = fixture(role); + call({ action: "claim", attemptId: start.attemptId }); + call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "native-first" }); + const report = { action: "report", attemptId: start.attemptId, outcome: "failed", error, agentId: "native-first" }; + assert.equal(call({ ...report, executionState: "unknown" }).action, "reconcile"); + const next = call({ ...report, executionState: "stopped", reconciliation: "native wait terminal; close completed; inspected workspace" }); + assert.equal(next.action, "ready"); + const claim = call({ action: "claim", attemptId: next.attemptId }); + assert.equal(claim.candidate?.model, "cursor/grok-4.6"); + const end = call({ action: "report", attemptId: next.attemptId, outcome: "failed", error, executionState: "not_created", reconciliation: "native creation rejected without child" }); + assert.equal(end.action, "main-direct"); + assert.equal(end.independentReviewRequired, role === "reviewer"); + } +}); + +test("unknown and policy failures never offer fallback or main-direct", () => { + for (const error of ["some vague failure", { error: { code: "permission_denied" } }, { code: "cyber_policy" }, "client_cancelled"]) { + const { call, start } = fixture(); + call({ action: "claim", attemptId: start.attemptId }); + const out = call({ action: "report", attemptId: start.attemptId, outcome: "failed", error, executionState: "not_created", reconciliation: "native spawn returned denial; no child created" }); + assert.ok(["stop", "reconcile"].includes(out.action)); assert.equal(out.candidate, undefined); + assert.equal(out.attempts.length, 1); + } +}); +test("timeout/unknown execution and duplicate claims never authorize another spawn", () => { + const { call, start } = fixture(); + const claim = call({ action: "claim", attemptId: start.attemptId }); assert.equal(claim.action, "spawn"); + assert.equal(call({ action: "claim", attemptId: start.attemptId }).action, "reconcile"); + const out = call({ action: "report", attemptId: start.attemptId, outcome: "failed", error: { code: "insufficient_quota" }, executionState: "unknown" }); + assert.equal(out.action, "reconcile"); assert.equal(out.candidate, undefined); + assert.equal(call({ action: "status" }).action, "reconcile"); + assert.throws(() => call({ action: "start", role: "executor" }), /already exists/); +}); +test("mid-task failure needs stopped child identity and reconciliation", () => { + const { call, start } = fixture(); + call({ action: "claim", attemptId: start.attemptId }); + call({ action: "report", attemptId: start.attemptId, outcome: "created", agentId: "child-a", observedModel: "ocx-rewritten/model" }); + const failure = { action: "report", attemptId: start.attemptId, outcome: "failed", error: { code: "upstream_server_error" } }; + assert.equal(call({ ...failure, executionState: "running" }).action, "reconcile"); + assert.throws(() => call({ ...failure, executionState: "not_created", reconciliation: "guess" }), /must be stopped/); + assert.throws(() => call({ ...failure, executionState: "stopped", agentId: "child-a" }), /reconciliation/); + const out = call({ ...failure, executionState: "stopped", agentId: "child-a", reconciliation: "child stopped; inspected diff and preserved edits; remaining task prepared" }); + assert.equal(out.action, "ready"); assert.equal(out.attempts[0].observedModel, "ocx-rewritten/model"); + assert.equal(out.attempts[0].candidate.model, "xai/grok-4.6"); +}); +for (const role of ROLES) test(`${role}: primary -> first fallback -> main-direct, restart-safe`, () => { + const { call, start, cwd, base } = fixture(role); + const first = call({ action: "claim", attemptId: start.attemptId }); + assert.equal(first.candidate?.model, "xai/grok-4.6"); + const next = call({ action: "report", attemptId: first.attemptId, outcome: "failed", error: "Cursor rate limit exceeded: exhausted", executionState: "not_created", reconciliation: "native creation failed with explicit no-child result" }); + assert.equal(next.action, "ready"); + const second = call({ action: "claim", attemptId: next.attemptId }); + assert.deepEqual(second.candidate, { model: "cursor/grok-4.6", effort: null }); + assert.equal(managedSpawn(cwd, base.sessionId, second.marker + "\nTASK")?.role, role); + assert.throws(() => call({ action: "report", attemptId: first.attemptId, outcome: "failed" }), /stale/); + const end = call({ action: "report", attemptId: second.attemptId, outcome: "failed", error: '{"error":{"code":"insufficient_quota"}}', executionState: "not_created", reconciliation: "second native creation explicitly failed without child" }); + assert.equal(end.action, "main-direct"); assert.equal(end.attempts.length, 2); + assert.equal(end.independentReviewRequired, role === "reviewer"); + assert.equal(end.attempts[0].observedModel, null); + assert.equal(call({ action: "status" }).action, "main-direct"); + const raw = readFileSync(join(cwd, ".codexclaw", "dispatches", base.sessionId, base.dispatchId + ".json"), "utf8"); + assert.equal(JSON.parse(raw).attempts.length, 2); +}); +test("managed hook preserves fallback null effort and logical reviewer role", () => { + const { call, start, cwd, base } = fixture("reviewer"); + call({ action: "claim", attemptId: start.attemptId }); + const next = call({ action: "report", attemptId: start.attemptId, outcome: "failed", error: "insufficient_quota", executionState: "not_created", reconciliation: "no child created" }); + const claim = call({ action: "claim", attemptId: next.attemptId }); + const output = JSON.parse(runSpawnAttachHook(JSON.stringify({ hook_event_name: "PreToolUse", cwd, session_id: base.sessionId, tool_name: "spawn_agent", tool_input: { agent_type: "explorer", model: "wrong-primary", reasoning_effort: "high", message: claim.marker + "\nInvestigate the file" } }))); + assert.equal(output.hookSpecificOutput.updatedInput.model, "cursor/grok-4.6"); + assert.equal(output.hookSpecificOutput.updatedInput.reasoning_effort, undefined); + const fork = JSON.parse(runSpawnAttachHook(JSON.stringify({ hook_event_name: "PreToolUse", cwd, session_id: base.sessionId, tool_name: "spawn_agent", tool_input: { fork_context: true, message: claim.marker + "\nTASK" } }))); + assert.equal(fork.hookSpecificOutput.permissionDecision, "deny"); +}); +test("invalid IDs, foreign sessions and concurrent lock fail closed", () => { + const { call, start, cwd, base } = fixture(); + assert.throws(() => call({ action: "status", dispatchId: "../escape" }), /invalid/); + assert.throws(() => runDispatch(cwd, { ...base, action: "status" }, { CODEX_THREAD_ID: "other" }), /native main session/); + mkdirSync(join(cwd, ".codexclaw", "dispatches", base.sessionId, base.dispatchId + ".json.lock")); + assert.throws(() => call({ action: "claim", attemptId: start.attemptId }), /EEXIST/); +}); +test("error decoder honors envelope code and does not mine quoted task content", () => { + assert.equal(decodeDispatchFailure({ error: { code: "permission_denied", message: "Cursor rate limit exceeded" } }).action, "stop"); + assert.equal(decodeDispatchFailure("The task says insufficient_quota").action, "unknown"); + assert.equal(decodeDispatchFailure({ content: { error: { code: "insufficient_quota" } } }).action, "unknown"); + assert.equal(decodeDispatchFailure("You've hit your usage limit. Try later").code, "insufficient_quota"); +}); +test("managed marker cannot authorize two different native calls; same hook id is idempotent", () => { + const { call, start, cwd, base } = fixture(); + const claim = call({ action: "claim", attemptId: start.attemptId }); + const payload = { hook_event_name: "PreToolUse", cwd, session_id: base.sessionId, tool_use_id: "native-call-1", tool_name: "spawn_agent", tool_input: { agent_type: "executor", message: claim.marker + "\nTASK: implement" } }; + const first = JSON.parse(runSpawnAttachHook(JSON.stringify(payload))).hookSpecificOutput; + assert.equal(first.permissionDecision, "allow"); + const repeated = JSON.parse(runSpawnAttachHook(JSON.stringify({ ...payload, tool_input: first.updatedInput }))).hookSpecificOutput; + assert.equal(repeated.permissionDecision, "allow"); + const duplicate = JSON.parse(runSpawnAttachHook(JSON.stringify({ ...payload, tool_use_id: "native-call-2" }))).hookSpecificOutput; + assert.equal(duplicate.permissionDecision, "deny"); +}); +test("direct caller overrides remain intact even with role fallback enabled", () => { + const { cwd, base } = fixture(); + const out = JSON.parse(runSpawnAttachHook(JSON.stringify({ hook_event_name: "PreToolUse", cwd, session_id: base.sessionId, tool_name: "spawn_agent", tool_input: { agent_type: "executor", model: "explicit/other", reasoning_effort: "low", message: "Implement file" } }))).hookSpecificOutput; + assert.equal(out.updatedInput.model, "explicit/other"); + assert.equal(out.updatedInput.reasoning_effort, "low"); + assert.match(out.additionalContext, /not managed/); +}); +test("hosts without tool-use IDs cannot replay an issued marker", () => { + const { call, start, cwd, base } = fixture(); + const claim = call({ action: "claim", attemptId: start.attemptId }); + const payload = { hook_event_name: "PreToolUse", cwd, session_id: base.sessionId, tool_name: "spawn_agent", tool_input: { agent_type: "executor", message: claim.marker + "\nTASK: implement" } }; + assert.equal(JSON.parse(runSpawnAttachHook(JSON.stringify(payload))).hookSpecificOutput.permissionDecision, "allow"); + assert.equal(JSON.parse(runSpawnAttachHook(JSON.stringify(payload))).hookSpecificOutput.permissionDecision, "deny"); +}); +test("unavailable native tool returns main-direct only with no-child evidence", () => { + const { call, start } = fixture(); + call({ action: "claim", attemptId: start.attemptId }); + assert.equal(call({ action: "report", attemptId: start.attemptId, outcome: "unavailable", executionState: "unknown" }).action, "reconcile"); + assert.throws(() => call({ action: "report", attemptId: start.attemptId, outcome: "unavailable", executionState: "not_created" }), /reconciliation/); + const out = call({ action: "report", attemptId: start.attemptId, outcome: "unavailable", executionState: "not_created", reconciliation: "native tool catalog has no spawn tool, no call was made" }); + assert.equal(out.action, "main-direct"); assert.equal(out.attempts.length, 1); +}); +test("candidate snapshot remains stable if role settings change after start", () => { + const { call, start, cwd } = fixture(); + setRole(cwd, "executor", { fallback: { model: "different/provider", effort: "low" } }); + call({ action: "claim", attemptId: start.attemptId }); + 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"); +}); diff --git a/plugins/codexclaw/components/subagent-config/test/mcp.test.ts b/plugins/codexclaw/components/subagent-config/test/mcp.test.ts index 4eb57a58..ffcbbc44 100644 --- a/plugins/codexclaw/components/subagent-config/test/mcp.test.ts +++ b/plugins/codexclaw/components/subagent-config/test/mcp.test.ts @@ -117,3 +117,18 @@ test("MCP: subagents_set effort roundtrips; invalid effort is isError", async () if (bad.length === 0) return; assert.equal(bad[0].result.isError, true); }); + +test("MCP: first fallback roundtrips for every role and rejects invalid nested effort", async () => { + const cwd = mkdtempSync(join(tmpdir(), "cxc-mcp-fallback-")); + for (const role of ["explorer", "reviewer", "executor"]) { + const replies = await collect(cwd, [ + { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "subagents_set", arguments: { role, fallback: { model: "cursor/grok-4.6", effort: "low" } } } }, + { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "subagents_get", arguments: {} } }, + ], 2); + assert.equal(replies.length, 2, "built MCP server is required for fallback verification"); + const result = JSON.parse(replies.find(r => r.id === 2).result.content[0].text); + assert.deepEqual(result.roles[role].fallback, { model: "cursor/grok-4.6", effort: "low" }); + } + const rejected = await collect(cwd, [{ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "subagents_set", arguments: { role: "executor", fallback: { effort: "invalid" } } } }], 1); + assert.equal(rejected[0].result.isError, true); +}); diff --git a/plugins/codexclaw/components/subagent-config/test/spawn-attach-hook.test.ts b/plugins/codexclaw/components/subagent-config/test/spawn-attach-hook.test.ts index 1657d80f..2bbdd60b 100644 --- a/plugins/codexclaw/components/subagent-config/test/spawn-attach-hook.test.ts +++ b/plugins/codexclaw/components/subagent-config/test/spawn-attach-hook.test.ts @@ -775,6 +775,10 @@ test("oversized spawn hook stdin is denied before JSON parsing", () => { test("inferRole: worker -> executor; review keywords -> reviewer; default explorer", () => { assert.equal(inferRole("worker", "review this"), "executor"); + // Managed fallback dispatches spawn with explicit role names; the direct path + // honours them too, so "executor"/"reviewer" never fall back to keyword scans. + assert.equal(inferRole("executor", "map the codebase"), "executor"); + assert.equal(inferRole("reviewer", "map the codebase"), "reviewer"); assert.equal(inferRole("explorer", "audit the plan for blockers"), "reviewer"); assert.equal(inferRole("explorer", "코드 검증 부탁"), "reviewer"); assert.equal(inferRole("explorer", "map the codebase"), "explorer"); diff --git a/plugins/codexclaw/gui/src/api.ts b/plugins/codexclaw/gui/src/api.ts index 3aa26a21..55a4ed4f 100644 --- a/plugins/codexclaw/gui/src/api.ts +++ b/plugins/codexclaw/gui/src/api.ts @@ -19,6 +19,7 @@ export interface RoleConfig { /** reasoning-effort override; null = inherit the parent session's effort. */ effort: EffortName | null; promptOverride: string | null; + fallback?: { model: string; effort: EffortName | null } | null; } export type SubagentScope = "project" | "global"; diff --git a/plugins/codexclaw/gui/src/components/EffortSelect.tsx b/plugins/codexclaw/gui/src/components/EffortSelect.tsx index 6561adfa..1ff99cf7 100644 --- a/plugins/codexclaw/gui/src/components/EffortSelect.tsx +++ b/plugins/codexclaw/gui/src/components/EffortSelect.tsx @@ -3,6 +3,7 @@ import { effortExcluded } from "../effort-support.ts"; interface Props { value: EffortName | null; + label?: string; disabled: boolean; supported?: readonly string[] | null; onChange: (effort: EffortName | null) => void; @@ -17,7 +18,7 @@ interface Props { * source did not advertise a ladder. Only an array may disable an option — folding * `null` into `[]` would grey out every effort for a model whose ladder OCX does not * report, which is a live state on real rosters. */ -export function EffortSelect({ value, disabled, onChange, supported }: Props) { +export function EffortSelect({ value, disabled, onChange, supported, label = "reasoning effort" }: Props) { return ( { const selected = e.target.value; if (selected === "global") onInherit?.(); else onChange(selected === "main" ? null : selected.slice(6)); }} - aria-label="model" + aria-label={label} > - + {onInherit ? : null} {value && !entries.some(e => e.id === value) ? : null} {entries.map((e) => ( diff --git a/plugins/codexclaw/gui/src/pages/Subagents.tsx b/plugins/codexclaw/gui/src/pages/Subagents.tsx index 3574bf17..773455f0 100644 --- a/plugins/codexclaw/gui/src/pages/Subagents.tsx +++ b/plugins/codexclaw/gui/src/pages/Subagents.tsx @@ -71,6 +71,9 @@ export function SubagentsPage({ provider, scope = "project" }: { provider: Provi setError("This model does not support the saved effort. Select session effort first, then choose the model. If using global settings, choose Main model first to customize this role."); return; } } + if (patch.fallback?.effort && effortExcluded(catalog.find(entry => entry.id === patch.fallback?.model)?.reasoningEfforts, patch.fallback.effort)) { + setError("The fallback model does not support this effort. Select session effort first."); return; + } saving.current = true; setSavingRole(role); setError(null); const current = generation.current; const result = await setSubagentRole(role, patch, config, scope); @@ -124,6 +127,12 @@ export function SubagentsPage({ provider, scope = "project" }: { provider: Provi void save(role, { inherit: true }) : undefined} value={effectiveModel} disabled={savingRole !== null || ignored} entries={catalog} onChange={model => void save(role, { mode: model ? "model" : "default", model })} /> void save(role, { effort })} /> +
+ First fallback + void save(role, { fallback: model ? { model, effort: r.fallback?.effort ?? null } : null })} /> + entry.id === r.fallback?.model)?.reasoningEfforts} disabled={savingRole !== null || ignored || inherited || !r.fallback} onChange={effort => r.fallback && void save(role, { fallback: { ...r.fallback, effort } })} /> +
+

After attempts fail, the main agent takes over remaining work.{role === "reviewer" ? " Independent review is still required." : ""}

{unsupported ?

Saved effort {r.effort} is not advertised by this model. Select session effort or another supported level.

: null}