diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e1b48e3..fe4ccfe 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "pashbel-estafeta", - "description": "Resumable multi-session projects: checkpoints, phased plans with recorded checks, handovers and safe parallel sessions.", + "description": "Resumable multi-session projects: checkpoints, phased plans with recorded checks, handovers, safe parallel sessions and cross-provider delegation.", "owner": { "name": "pashbel" }, @@ -8,7 +8,7 @@ { "name": "estafeta", "source": "./packs/session-workflow", - "description": "Resumable multi-session projects: checkpoints, phased plans with recorded checks, handovers and safe parallel sessions." + "description": "Resumable multi-session projects: checkpoints, phased plans with recorded checks, handovers, safe parallel sessions and cross-provider delegation." } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f2b4683 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python: ["3.9", "3.13"] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Pack validation + run: python scripts/workflow.py check + - name: Runtime tests + run: python scripts/test_session.py + - name: Installer tests + run: python scripts/test_workflow.py + - name: Golden path integration tests (stub worker) + run: python scripts/test_relay.py diff --git a/HANDOVER.md b/HANDOVER.md index 6034549..0534dff 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -2,26 +2,74 @@ ## Current work -The October 15, 2026 synchronization task preserves the existing favicon studies -under assets/favicon and adds tracked project context and cross-computer resume -instructions in docs/CONTINUING.md. Spark remains the recommended concept in -assets/favicon/README.md; no product feature or new release is included. +estafeta 3.1.0 adds cross-provider work (plan: +docs/plans/multi-provider.md). + +- **Skill `agent-relay`.** Lets an orchestrator consult, cross-review with or + delegate a phase to another provider, and coordinate peer agents. It covers + T3 host tasks, Codex/Claude/Gemini CLIs and same-provider subagents. +- **Runtime command `delegate`.** Keeps a ledger beside the state, so schema 2 + is unchanged. CLI workers run as supervised processes with a timeout. The + runtime checks a writer's claims against recorded checks and the observed + workspace. +- **Integrated.** Existing skills, docs and the 3.1.0 manifests know about + the skill. A CI workflow was added. +- **Testing approach (user request).** Golden path tests check a short + sequence of agent interactions, with an expected result at each step: + - every change, in CI: a deterministic stub worker + (`scripts/test_relay.py`); + - before a release or a provider/model change: live runs checked with + `--live-verify`. ## Next action -On another computer, update a clean checkout from origin/main using -docs/CONTINUING.md, read PROJECT.md, and use session-start. Continue with the -user's next project task. Do not infer that other computers have updated just -because the source was pushed. +Merge the 3.1.0 pull request after its CI run passes. Optional follow-ups: + +- a third Codex review round; +- live `codex exec` and `gemini -p` runs where those CLIs are installed; +- the benchmark follow-up in docs/BENCHMARK-ROADMAP.md. ## Validation -On October 15, 2026, `python3 scripts/workflow.py check` passed for Estafeta -3.0.0 and its five portable skills. All 57 session runtime tests and all 19 -installer tests passed. Favicon SVG/JSON files parsed, PNG/ICO headers and the -font license link were checked. No new browser visual review was performed. +On October 15, 2026, on macOS with Python 3.9.6: + +- runtime 70/70, installer 21/21, golden 5/5 scenarios (79 steps), pack check + and the Claude plugin validators passed; +- four deliberate regressions were caught by golden steps; +- live, all passing `--live-verify`: + - Codex gpt-6.1-sol through T3: a delegated write and two cross-review + rounds (12, then 5 findings, all fixed with tests); + - `claude -p` as a CLI worker: consult on Haiku 5.5, write on Sonnet 5.5 + under the OS sandbox. + +Details are in packs/session-workflow/VALIDATION.md. ## Boundaries -The local session runtime is ignored by Git. No remote-computer checkout or -provider installation has been verified by this task. +Not verified: + +- live `codex exec` and `gemini -p` CLI transports; +- the Gemini/Antigravity route; +- native Linux and Windows. + +The round 2 fixes were tested locally but not re-reviewed by Codex. The +fixtures in /private/tmp/relay-live and /private/tmp/relay-cli are disposable. + + +## Session facts (generated) + +State revision 109; HEAD 8559853e8949b0ef51d5ffb9c2351158fba23ff2. + +| Phase | State | Owner | Latest checks | +| --- | --- | --- | --- | +| P1 | DONE | - | unit-session: pass (executed, inputs match); golden-relay: pass (executed, inputs match); pack-check: pass (executed, inputs match) | +| P2 | DONE | - | pack-check: pass (executed, inputs match); unit-workflow: pass (executed, inputs match) | +| P3 | DONE | - | pack-check: pass (executed, inputs match); unit-workflow: pass (executed, inputs match); unit-session: pass (executed, inputs match); golden-relay: pass (executed, inputs match) | +| P4 | DONE | - | live-relay: pass (executed, inputs match); unit-session: pass (executed, inputs match); golden-relay: pass (executed, inputs match) | +| P5 | DONE | - | live-cli: pass (executed, inputs match); live-review-2: pass (executed, inputs match); golden-relay: pass (executed, inputs match); pack-check: pass (executed, inputs match) | + +Active sessions: none. +Ended sessions: 5; latest s-xprov-1015 (next: Watch CI of the agent-relay-3.1.0 pull request and merge it when green. Optional: Codex review round 3, live codex exec / gemini -p runs where installed, benchmark follow-up.). + +Generated facts do not establish acceptance, explain intent or prove delivery. + diff --git a/PROJECT.md b/PROJECT.md index fc7076b..1c66f96 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -8,7 +8,7 @@ Do not publish credentials, local runtime state, or personal logs. ## Documentation map -- Plan: none for the current repository synchronization task. +- Plan: docs/plans/multi-provider.md (multi-provider relay). - Handover: HANDOVER.md. - Installation: docs/INSTALL.md; usage: docs/USAGE.md. - Maintenance and checks: docs/MAINTAINING.md. @@ -16,8 +16,7 @@ Do not publish credentials, local runtime state, or personal logs. ## Current focus -- Preserve the favicon studies and make the current source and continuation context available through origin/main. -- No new product feature is selected. Continue only with the next requested task. +- Multi-provider relay (3.1.0): skill `agent-relay` and runtime `delegate`; see docs/plans/multi-provider.md. - On another computer, follow docs/CONTINUING.md, then use session-start. ## Run and verify @@ -28,4 +27,5 @@ Python 3.9+ and Git are required. python3 scripts/workflow.py check python3 scripts/test_session.py python3 scripts/test_workflow.py +python3 scripts/test_relay.py ``` diff --git a/README.md b/README.md index a152779..67bf7cd 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Then ask any agent: "Use session-guide. Explain how this works." | --- | --- | | Use session-start | Opening a chat | | Use plan-and-orchestrate | Work longer than one step | +| Use agent-relay | Ask, review with or delegate to another AI provider | | Use session-handover | At a milestone | | Use session-end | When you stop | diff --git a/docs/BENCHMARK-ROADMAP.md b/docs/BENCHMARK-ROADMAP.md index d9e650e..8234156 100644 --- a/docs/BENCHMARK-ROADMAP.md +++ b/docs/BENCHMARK-ROADMAP.md @@ -84,3 +84,18 @@ alone cannot establish it. The runtime earns its cost only if workflow beats lit on recovery, ownership safety or integration at equal passed requirements, or matches lite at lower cost. If lite matches workflow, the conventions rather than the runtime carry the value. + +## Cross-provider relay (follow-up) + +The golden path tests in `scripts/test_relay.py` catch regressions in the +relay mechanics; they do not show that cross-provider work pays off. A later +benchmark should compare, on the frozen scenarios above: + +- single-provider work with self-review; +- the same work with an agent-relay cross-review by another provider; +- a delegated write phase by another provider. + +Measure defects found by independent acceptance tests, review findings that +were confirmed or rejected, extra tokens and wall time, and how often a +writer's claims disagreed with recorded facts. Record provider and model for +each arm, since a result for one pair does not carry over to another. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 751b84b..a73898d 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -42,8 +42,9 @@ language. same command once. Never edit host permissions or settings to get past the block. If it is blocked again, report the command for the user to run. 6. **Verify.** - - `status` prints five MATCH lines per destination: session-start, - plan-and-orchestrate, session-handover, session-end and session-guide. + - `status` prints six MATCH lines per destination: session-start, + plan-and-orchestrate, session-handover, session-end, session-guide and + agent-relay. - There is no RETIRED-PRESENT line. - No other copy shadows these skills. 7. **Finish.** Reload skills if that is possible without ending the chat. Do @@ -57,7 +58,7 @@ language. ## Portable skills: any supported provider -This route installs five standalone skills with the same runtime and instructions. +This route installs six standalone skills with the same runtime and instructions. It also works from an extracted full source ZIP. ```sh @@ -88,7 +89,7 @@ python3 scripts/workflow.py install --dest /path/to/custom/profile/skills | `agents` | `~/.agents/skills` | The installer respects configured provider homes and detects existing Claude -and Hermes profiles. It changes only these five skills. A changed directory is +and Hermes profiles. It changes only these six skills. A changed directory is backed up intact outside the discovery root before its replacement. Updates are atomic per skill; rerunning the installer converges after a partial failure. `status` compares complete directory hashes. Reload skills or start a new host @@ -130,7 +131,7 @@ codex plugin list --marketplace pashbel-estafeta --json If Git access is configured differently for the host, add the authenticated local clone with `codex plugin marketplace add .` instead. Open the desktop plugin browser to install from that marketplace when the installed client uses the UI -for installation. Start a new session and verify the five skills are available. +for installation. Start a new session and verify the six skills are available. See [OpenAI packaging documentation](https://developers.openai.com/plugins/build/plugins). ## Claude Code plugin diff --git a/docs/MAINTAINING.md b/docs/MAINTAINING.md index 5daf80b..5c3e35e 100644 --- a/docs/MAINTAINING.md +++ b/docs/MAINTAINING.md @@ -13,8 +13,31 @@ python3 scripts/workflow.py sync-contract python3 scripts/workflow.py check python3 scripts/test_session.py python3 scripts/test_workflow.py +python3 scripts/test_relay.py ``` +CI (`.github/workflows/ci.yml`) runs the same checks on Linux and macOS for +every push and pull request. + +## Golden paths + +`scripts/test_relay.py` plays the scenarios in +`scripts/golden/relay-scenarios.json`. Each scenario is a sequence of +orchestrator and worker interactions with an expected result at every step, +and the first failed step fails the scenario. Keep the set small and +representative. Add a scenario only for a new interaction pattern, and add a +failing step when you fix a delegation bug. + +- **Every change and CI:** a deterministic stub worker + (`scripts/golden/stub_worker.py`) stands in for the LLM. It also plays a + hung worker, a garbled reply and a writer that claims checks it never ran. +- **Before a release, or when a provider, model or adapter changes:** run a + scenario live with agent-relay against a disposable fixture. Then run + `python3 scripts/test_relay.py --live-verify SCENARIO --root FIXTURE --map + orch= --map write-1=`, which checks the recorded state + read-only. Record the provider, model, date and result in VALIDATION.md. + Live runs cost money and need the provider's login, so CI does not run them. + Change `VERSION`, every skill's metadata version, the common contract and the three plugin manifests together. Validation rejects version drift. Provider marketplaces point to the same `packs/session-workflow` directory. Native plugin @@ -42,7 +65,7 @@ python3 scripts/workflow.py bundle --plugin-only --output /path/to/estafeta-plug ``` The full archive contains sources, docs, catalogs and the installer. The plugin -archive contains the manifests, README, cover and five self-contained skills. Both get SHA-256 +archive contains the manifests, README, cover and six self-contained skills. Both get SHA-256 sidecars. Credentials, project runtime state and Git history are excluded. The repository is public. Before publishing new benchmark evidence or logs, replace local paths (for example with `$HOME` or `$OUTPUTS`) and remove personal diff --git a/docs/USAGE.md b/docs/USAGE.md index 535a774..8935cef 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -28,6 +28,7 @@ A small isolated edit does not need it. | Plan only | Use plan-and-orchestrate. Plan this change without implementing it yet. | | Execute | Use plan-and-orchestrate. Complete the agreed phases and verify acceptance. | | Economize | Use plan-and-orchestrate with minimal reads and output. | +| Another provider | Use agent-relay. Have Codex review this diff. | | Handover | Use session-handover. Update the handover from actual changes and checks. | | End | Use session-end. Save the next action for another device. | @@ -86,6 +87,33 @@ transfer it together with its evidence. Claims depend on cooperation: they do not stop direct edits that bypass the runtime. +## Several AI providers + +`agent-relay` lets one chat, the orchestrator, work with agents of other +providers. Ask for them by name; each run of another provider costs money, and +the agent needs your request before it starts one. + +| Mode | Example | What comes back | +| --- | --- | --- | +| Consult | Use agent-relay. Ask Gemini which caching option fits here. | An answer, marked as coming from that model | +| Review | Use agent-relay. Have Codex review the diff of P2. | APPROVE or CHANGES_REQUIRED with findings | +| Write | Use agent-relay. Delegate P3 to Claude in its own worktree. | A tested change on a `relay/P3` branch | +| Peers | Open Codex and Claude on the same project, both "in parallel". | Each claims its own phases | + +- **Transports.** The agent prefers the host's own child tasks (for example + T3 Code) and otherwise runs the provider's CLI as a supervised process with a + timeout. It never uses flags that switch off the worker's sandbox. +- **Records.** Each delegation is kept under + `.agent-state/session-workflow/delegations/`: provider, model, packet and + result. `session-start` shows open ones; a later chat collects or cancels + them. +- **Trust.** A worker's report is a claim. For a write, the orchestrator + compares it with the runtime's records, reads the diff, commits and merges it + itself, and reruns the required checks before DONE. +- **Models differ.** A provider and model pair that worked once is not + validated forever. Start a new pair with a consult or review before giving + it a write. + ## Deterministic runtime Skills call their bundled `scripts/session.py`; its source never needs to enter diff --git a/docs/plans/multi-provider.md b/docs/plans/multi-provider.md new file mode 100644 index 0000000..8b85604 --- /dev/null +++ b/docs/plans/multi-provider.md @@ -0,0 +1,137 @@ +# Multi-provider relay plan + +- Context: [PROJECT.md](../../PROJECT.md) +- Goal and acceptance: one project can be worked on by several AI providers at + once. An orchestrator in any provider can use another provider as a subagent: + it can consult it, request a review or delegate a phase as a writer. Every + delegation is recorded, resumable without native agent IDs, and judged by the + orchestrator against fresh checks. +- Scope and non-goals: one new portable skill `agent-relay` with per-provider + adapters, a provider-neutral `delegate` command in the bundled runtime, and + integration into the existing skills and docs. Not in scope: storing + credentials, installing provider CLIs, a long-running broker or daemon, + pairwise provider skills, or automatic merging without the integrator. +- Execution: authorized by the user on 2026-10-15, including one or two paid + child tasks through T3 (Codex as worker and/or reviewer). No commit or push + without a separate request. +- Constraints: Python 3.9+ standard library only; state schema 2 stays + readable by estafeta 3.0.0 runtimes; safety-bypass flags of provider CLIs are + never used by default; follow docs/MAINTAINING.md for versioning. +- Base: main @ 8559853e8949b0ef51d5ffb9c2351158fba23ff2, with the uncommitted + generated facts block in HANDOVER.md from the previous session. + +## Decisions + +| ID | Question and recommendation | Answer or open impact | Date | +| --- | --- | --- | --- | +| D1 | One generic skill with adapters, pairwise "X talk to Y" skills, or instructions only? Recommended: one skill. | One skill `agent-relay` + adapter references + runtime support. | 2026-10-15 | +| D2 | Transports? Recommended: headless CLI as the portable baseline plus host-native orchestration where available. | Both. Locally only `claude` is on PATH; T3 exposes Codex and Gemini (Antigravity) as child tasks. | 2026-10-15 | +| D3 | Collaboration modes for v1? | All four: consult, review, write (delegated phase), peer sessions. | 2026-10-15 | +| D4 | Where to record delegations? | A ledger under the state directory (`delegations/`), not in `state.json`. Schema 2 is unchanged, so a 3.0.0 worker on another provider can still join as a parallel session. CLI workers reuse the supervised `runtime` machinery, so `end` stops them. | 2026-10-15 | +| D5 | How does a result come back regardless of transport? | The worker ends its final answer with one `RELAY-RESULT` block; `delegate collect` extracts it from the CLI log or from a file holding the host tool's reply. | 2026-10-15 | +| D6 | Version | 3.1.0: additive skill and command, no schema change. | 2026-10-15 | +| D7 | How to test a sequence of agent interactions? | Golden path tests: a few representative scenarios in `scripts/golden/relay-scenarios.json`, each a sequence of steps with an expected result per step; any failed step fails the whole scenario. Cheap mode replaces the LLM with a deterministic stub worker and runs with the integration tests on every change (`scripts/test_relay.py`). Live mode runs the same scenario with a real provider at release time and verifies the same step expectations from the recorded state. | 2026-10-15 | + +| D8 | How to catch regressions when providers or models change? | Every delegation records provider and model. Deterministic golden paths run in CI (new GitHub Actions workflow: `check`, unit tests, stub golden paths) on every push and pull request. Live golden paths need provider credentials and cost money, so they run manually per provider × model before a release; VALIDATION.md records which pairs passed and when. Measuring the value of cross-provider work (cost, review catch rate) belongs in the benchmark roadmap, not in v1. | 2026-10-15 | + +## Phases + +Runtime facts (state, owner, checks) appear in the handover's generated block. +All phases run sequentially in this checkout with one integrator: they share +the runtime source, skill copies and manifests. P4 uses cross-provider child +tasks only for verification and review. + +### P1: Relay runtime + +- Scope and anchors: `shared/runtime/session.py` (`command_parser`, + `launch_runtime`, `end`, `brief`, `dispatch`), `shared/runtime.md`, + `shared/quickstart.md`, `scripts/test_session.py`; synced copies in every skill. +- Acceptance: `delegate providers|start|status|collect|cancel` work for the + `cli` transport (supervised process, log, exit) and the `host` transport + (recorded host reference only); write mode requires an existing phase and a + worker session ID; results are parsed and hashed; `end` reports open + delegations and stops CLI workers it owns; state schema stays 2. +- Golden paths (D7): consult, review, delegated write in a linked worktree with + integration, and an interrupted worker that is cancelled. Each involves + sessions, claims, a supervised worker, recorded checks, the ledger and + collect, and states the expected result of every step. +- Checks and review: `unit-session`, `golden-relay`, `pack-check`. + +### P2: agent-relay skill + +- Scope and anchors: `skills/agent-relay/` (SKILL.md, agents/openai.yaml, + provider and host adapters, packet and result templates), `skills.list`, + `scripts/workflow.py`, `scripts/test_workflow.py`. +- Acceptance: the skill covers the four modes, chooses a transport, builds a + packet, never uses bypass flags by default, and states authorization rules; + it installs and validates like the other skills. +- Checks and review: `pack-check`, `unit-workflow`. + +### P3: Integration and release docs + +- Scope and anchors: delegation.md in plan-and-orchestrate, session-end and + session-guide wording, README files, docs/USAGE.md, docs/INSTALL.md, + docs/MAINTAINING.md, VALIDATION.md, manifests and VERSION, PROJECT.md, + `.github/workflows/ci.yml` (D8), docs/BENCHMARK-ROADMAP.md follow-up. +- Acceptance: users can find and invoke cross-provider work from the README and + the guide; version 3.1.0 is consistent everywhere; CI runs the deterministic + suites including the stub golden paths. +- Checks and review: `pack-check`, `unit-workflow`, `unit-session`. + +### P4: Live cross-provider proof + +- Scope and anchors: a disposable fixture project outside the repository; + evidence summary in this plan and VALIDATION.md. +- Acceptance: the live golden path passes: a Codex child task launched through + T3 completes the delegated-write scenario in the fixture following an + `agent-relay` packet, and `scripts/test_relay.py --live-verify` confirms + every step expectation from the recorded state. A Codex review of this change + returns a verdict and its findings are resolved or recorded. +- Checks and review: `live-relay` (live-verify run), re-run of unit and golden checks. + +## Results + +- P4 live golden path: Codex gpt-6.1-sol through T3 passed the write scenario + in /private/tmp/relay-live and the review scenario on this change; both + pass `--live-verify`. See packs/session-workflow/VALIDATION.md. +- The live write exposed a recording race: the worker began before the + orchestrator recorded the task. It is fixed and has a golden scenario. +- Codex review `review-codex-1` returned CHANGES_REQUIRED with 12 findings. + All are fixed with tests: + - check processes outlived a timed-out worker; + - an unknown runtime was treated as stopped; + - results could be extracted from tool output; + - lexical scope checks allowed traversal; + - ledger transfer was not ordered after the state save; + - adoption left the process with the old session; + - required-check provenance was not checked; + - `collect --from` worked while a worker ran; + - a malformed blocked result crashed `collect`; + - the Claude writer had no sandbox; + - Gemini ran without `--model`; + - `--live-verify` could pass without checks. + +- P5 (user request 2026-10-15): live `claude -p` CLI transport passed + consult-cli (Haiku 5.5) and write-worktree (Sonnet 5.5 under the OS sandbox, + $0.14). The packet template now runs checks inline after `--`. +- Codex review round 2 (`review-codex-2`) confirmed 10 of 12 round 1 fixes and + returned CHANGES_REQUIRED with 5 reproduced findings. All are fixed with + tests: + - a check ignoring SIGTERM outlived the timeout; nested and supervisor grace + periods now differ; + - non-final JSON events with `result`/`response` were accepted; event types + are now required; + - a writer in the orchestrator checkout was not inspected; changes are now + compared with a baseline; + - stale required-check evidence was accepted; the DONE gate is now applied + in the worker workspace; + - duplicate JSON keys were resolved silently. + +## Follow-ups and accepted limitations + +- Real `codex exec`, `claude -p` and `gemini` CLI runs are not yet verified. + Codex and Gemini CLIs are not installed on this machine, and a `claude -p` + run was not authorized. The adapters cite official documentation. +- A second Codex review round on the fix delta would need another paid run + and was not started. +- CI runs only after a push. Linux and Windows have not been run natively. diff --git a/packs/session-workflow/.claude-plugin/plugin.json b/packs/session-workflow/.claude-plugin/plugin.json index 1f7396a..37368eb 100644 --- a/packs/session-workflow/.claude-plugin/plugin.json +++ b/packs/session-workflow/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "estafeta", - "version": "3.0.0", - "description": "Resumable multi-session projects: checkpoints, phased plans with recorded checks, handovers and safe parallel sessions.", + "version": "3.1.0", + "description": "Resumable multi-session projects: checkpoints, phased plans with recorded checks, handovers, safe parallel sessions and cross-provider delegation.", "author": { "name": "pashbel" }, diff --git a/packs/session-workflow/.codex-plugin/plugin.json b/packs/session-workflow/.codex-plugin/plugin.json index ab97124..725f510 100644 --- a/packs/session-workflow/.codex-plugin/plugin.json +++ b/packs/session-workflow/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "estafeta", - "version": "3.0.0", - "description": "Resumable multi-session projects: checkpoints, phased plans with recorded checks, handovers and safe parallel sessions.", + "version": "3.1.0", + "description": "Resumable multi-session projects: checkpoints, phased plans with recorded checks, handovers, safe parallel sessions and cross-provider delegation.", "author": { "name": "pashbel" }, @@ -11,7 +11,7 @@ "interface": { "displayName": "estafeta", "shortDescription": "Resumable development across sessions and providers", - "longDescription": "Resumable multi-session projects: checkpoints, phased plans with recorded checks, handovers and safe parallel sessions.", + "longDescription": "Resumable multi-session projects: checkpoints, phased plans with recorded checks, handovers, safe parallel sessions and cross-provider delegation.", "developerName": "pashbel", "category": "Productivity", "capabilities": [ @@ -22,7 +22,8 @@ "defaultPrompt": [ "Use session-guide to learn how estafeta works.", "Use session-start to resume this project.", - "Use plan-and-orchestrate to execute the authorized plan." + "Use plan-and-orchestrate to execute the authorized plan.", + "Use agent-relay to have another AI provider review this change." ] } } diff --git a/packs/session-workflow/README.md b/packs/session-workflow/README.md index 6c8474a..4d72089 100644 --- a/packs/session-workflow/README.md +++ b/packs/session-workflow/README.md @@ -1,6 +1,6 @@ - + -# ![estafeta 3.0.0](assets/headings/estafeta-300.svg) +# ![estafeta 3.1.0](assets/headings/estafeta-310.svg) ![estafeta — Pass the context & Keep building.](assets/cover.webp) @@ -9,7 +9,9 @@ Four skills act on one durable project record: `session-start → plan-and-orchestrate → session-handover → session-end` `session-guide` explains all of this to a new user in a few lines and shows -where the project stands, without changing anything. Handover updates can +where the project stands, without changing anything. `agent-relay` brings in +other AI providers: it consults one, gets a cross-review, or delegates a phase +to it. Handover updates can happen at any milestone; they do not end the session. Ending a session checkpoints unfinished work and never marks it complete. @@ -27,6 +29,29 @@ Use the same plain request with any provider; slash or dollar syntax is optional | Execute | Use plan-and-orchestrate. Execute the ready phases of this plan. | | Record a milestone | Use session-handover. Record the current changes and evidence. | | End / hand off | Use session-end. Save context to continue on another device. | +| Ask another provider | Use agent-relay. Ask Codex whether this migration is safe. | +| Cross-review | Use agent-relay. Have Claude review the diff of this phase. | +| Delegate a phase | Use agent-relay. Delegate phase P2 to Codex in its own worktree. | + + + +## ![Several providers](assets/headings/several-providers.svg) + +One chat is the orchestrator and the only integrator. It reaches other providers +through the host's own child tasks, such as T3 Code's `delegate_task`, or +through their CLIs (`codex exec`, `claude -p`, `gemini -p`), which run as +supervised processes. + +- Every delegation is recorded with its provider, model, packet and result, so + a later chat can resume it without the host's task IDs. +- A writer works in its own worktree under its own parallel session, claims + its phase and records its checks. +- The orchestrator compares the writer's claims with the recorded facts, + commits and merges the change, and reruns the checks before DONE. +- Agents the user opens in parallel work as peers on disjoint phases. + +The [adapters](skills/agent-relay/references/adapters.md) list the exact commands +and the flags that are never used by default. @@ -44,6 +69,24 @@ The runtime keeps execution state (phases, owners, checks, checkpoints) in [contract](shared/workflow-contract.md) holds all shared rules, and the [quickstart](shared/quickstart.md) holds every command an ordinary session needs. + + +## ![Changes in 3.1.0](assets/headings/changes-in-310.svg) + +- New skill `agent-relay`: consult, cross-review and delegated writes across + providers, plus rules for peer agents. +- New runtime command `delegate` (`providers`, `start`, `status`, `collect`, + `cancel`). A CLI worker runs as a supervised runtime with a timeout. A host + task is recorded by reference. Results come back as one `RELAY-RESULT` block, + and a writer's claims are checked against recorded facts. +- `end` stops the session's CLI workers and lists open delegations. `begin` and + `status` show them, and a later session adopts them. +- State schema 2 is unchanged: records live in `delegations/` beside the state, + so a 3.0.0 runtime can still join as a worker. +- Golden path integration tests (`scripts/test_relay.py`) play representative + sequences of interactions with a stub worker on every change, and a live run + is checked with `--live-verify`. CI runs them with the other suites. + ## ![Changes in 3.0.0](assets/headings/changes-in-300.svg) @@ -93,7 +136,10 @@ once. and is untested. - Instructions are identical across providers, but model behaviour is not. Check the first real session on each new provider before treating its - behaviour as validated. + behaviour as validated. The same applies to every new provider and model + pair used through agent-relay. +- The Codex and Gemini CLI adapters follow official documentation. Only the + transports listed in the [validation record](VALIDATION.md) have run live. diff --git a/packs/session-workflow/VALIDATION.md b/packs/session-workflow/VALIDATION.md index f6a5008..a1d88df 100644 --- a/packs/session-workflow/VALIDATION.md +++ b/packs/session-workflow/VALIDATION.md @@ -1,4 +1,57 @@ -# Validation - estafeta 3.0.0 +# Validation - estafeta 3.1.0 + +## Cross-provider relay, 3.1.0 (2026-10-15) + +macOS, Python 3.9.6, orchestrator Claude Opus 5.5 in T3 Code. + +**Deterministic suites.** These run on every change and in CI. + +| Suite | Result | +| --- | --- | +| runtime (`test_session.py`) | 70/70 | +| installer (`test_workflow.py`) | 21/21 | +| golden paths (`test_relay.py`, stub worker) | 5/5 scenarios, 79 steps | +| `workflow.py check` (6 skills) | passed | +| `claude plugin validate`, plugin and marketplace | passed | + +Four deliberate runtime regressions were each caught by a golden step: + +- false check claims no longer detected; +- no supervisor timeout; +- a worker's check not stopped with its worker; +- equal grace periods for the supervisor and nested check runners. + +**Live golden paths.** Host transport: T3 Code `delegate_task`, provider +instance `codex`. + +| Scenario | Model | Result | +| --- | --- | --- | +| write-worktree, disposable fixture | gpt-6.1-sol, medium | PASS. The worker followed the packet: it began its parallel session, claimed P1, ran the required check through the runtime, moved to VERIFY, ended, and returned a valid block. `collect` found no discrepancies. The integrator reviewed and merged the change, reran the check and accepted P1. `--live-verify` passed 2/2 steps. | +| review-host, this change, round 1 | gpt-6.1-sol, high | PASS (`--live-verify` 1/1). The verdict was CHANGES_REQUIRED with 12 findings, 5 of them high. All 12 were fixed, and each fix has a unit test or golden step. | +| review-host, fix delta, round 2 | gpt-6.1-sol, high | PASS (`--live-verify` 1/1). 10 of 12 fixes were confirmed. The verdict was CHANGES_REQUIRED with 5 reproduced findings, 3 of them high, about nested kill grace, non-final JSON events, a same-checkout writer, stale check evidence and duplicate JSON keys. All 5 were fixed with tests; the grace fix was mutation-tested. | + +**Live CLI transport.** `claude -p` 2.1.300 ran as a supervised `delegate` +worker, started from inside a Claude Code session. + +| Scenario | Model | Result | +| --- | --- | --- | +| consult-cli | claude-haiku-5-5 | PASS (`--live-verify` 1/1). The worker read the code and answered correctly. `collect` took the block from the JSON `result` field. | +| write-worktree, disposable fixture | claude-sonnet-5-5 | PASS (`--live-verify` 2/2). 10 turns, $0.14, OS sandbox with `failIfUnavailable`. No discrepancies; the integrator merged the change, reran the check and accepted the phase. | + +The sandbox denied the worker's file tool a write to the state directory, as +intended: only Bash may write there. The packet template now runs checks +inline after `--`, so a worker needs no file outside its worktree. + +The first live write exposed a real ordering race: the host worker began and +claimed its phase before the orchestrator recorded the task. The runtime now +accepts that case, and the `write-host-fast-worker` scenario covers it. + +**Not verified:** + +- live `codex exec` and `gemini -p` CLI transports (not installed here) and + the Gemini/Antigravity host route; +- the first CI run on GitHub; +- native Linux and Windows. ## estafeta rename (2026-10-13) diff --git a/packs/session-workflow/VERSION b/packs/session-workflow/VERSION index 4a36342..fd2a018 100644 --- a/packs/session-workflow/VERSION +++ b/packs/session-workflow/VERSION @@ -1 +1 @@ -3.0.0 +3.1.0 diff --git a/packs/session-workflow/assets/headings/changes-in-310.svg b/packs/session-workflow/assets/headings/changes-in-310.svg new file mode 100644 index 0000000..f20794f --- /dev/null +++ b/packs/session-workflow/assets/headings/changes-in-310.svg @@ -0,0 +1,9 @@ + + Changes in 3.1.0 + + + + diff --git a/packs/session-workflow/assets/headings/estafeta-300.svg b/packs/session-workflow/assets/headings/estafeta-310.svg similarity index 75% rename from packs/session-workflow/assets/headings/estafeta-300.svg rename to packs/session-workflow/assets/headings/estafeta-310.svg index f1aa34c..8fca9fe 100644 --- a/packs/session-workflow/assets/headings/estafeta-300.svg +++ b/packs/session-workflow/assets/headings/estafeta-310.svg @@ -1,9 +1,9 @@ - estafeta 3.0.0 + estafeta 3.1.0 - + diff --git a/packs/session-workflow/assets/headings/several-providers.svg b/packs/session-workflow/assets/headings/several-providers.svg new file mode 100644 index 0000000..c00a3a7 --- /dev/null +++ b/packs/session-workflow/assets/headings/several-providers.svg @@ -0,0 +1,9 @@ + + Several providers + + + + diff --git a/packs/session-workflow/plugin.json b/packs/session-workflow/plugin.json index 072b20c..836fe99 100644 --- a/packs/session-workflow/plugin.json +++ b/packs/session-workflow/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "estafeta", - "version": "3.0.0", - "description": "Resumable multi-session projects: checkpoints, phased plans with recorded checks, handovers and safe parallel sessions.", + "version": "3.1.0", + "description": "Resumable multi-session projects: checkpoints, phased plans with recorded checks, handovers, safe parallel sessions and cross-provider delegation.", "author": { "name": "pashbel" }, diff --git a/packs/session-workflow/shared/runtime.md b/packs/session-workflow/shared/runtime.md index ac4a73f..0b95d32 100644 --- a/packs/session-workflow/shared/runtime.md +++ b/packs/session-workflow/shared/runtime.md @@ -112,6 +112,62 @@ another machine reports `unknown` or `foreign`; the runtime never kills a recorded PID. On Windows the supervisor ends the process tree through `taskkill /T` while the child is alive. +## Delegations + +| Command | Purpose | +| --- | --- | +| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate start --session ID --name N --provider P --mode consult\|review\|write --packet F [--model M] [--phase P --worker-session W] [--workspace DIR] [--timeout S] -- CMD...` | Record a delegation and run a CLI worker as a supervised runtime `relay-N` | +| `delegate start ... --host-ref REF` (no command) | Record a host-native child task, such as a T3 task ID | +| `delegate status [--session ID] [--name N]` | Read-only: records, worker runtime, writer facts and result | +| `delegate collect --session ID --name N [--from FILE] [--confirm-stopped]` | Parse the worker's final RELAY-RESULT block from `--from`, `reply.txt` or the CLI worker's stdout | +| `delegate cancel --session ID --name N [--confirm-stopped]` | Stop a CLI worker, or close a host task you already cancelled | + +- Records live in `delegations//` under the state directory: the packet, + `record.json` and `result.json` with SHA-256 hashes. `state.json` and its + schema do not change, so a 3.0 runtime can still take part as a worker. +- In the worker command, `{packet}`, `{packet_text}`, `{reply_file}`, + `{workspace}` and `{state_dir}` are replaced. The worker runs in `--workspace` + without a shell or stdin. Its stdout and stderr go to separate logs. + `--timeout` stops its process group after a 10-second grace period. Within + it, a `check run` started by the worker stops its own check group (1-second + grace, then SIGKILL) and records the run as `abandoned`. +- A writer needs an unclaimed phase that is not DONE and a new worker session + ID. A given `--workspace` must share this coordinator (this checkout or a + linked worktree); a host task may omit it when the host creates the + worktree. The worker begins that session with `--parallel`, claims the phase + itself and does not commit: the integrator owns Git history. +- `collect` takes the last block of the final answer. That is plain text, or + one of: a Claude event of type `result`, Gemini's untyped `response`, or a + Codex `agent_message` item. Blocks in other events, such as tool results, + are ignored. Duplicate JSON keys make a result `malformed`. A missing or invalid block leaves the record + `malformed`, and it can be collected again. While a CLI worker runs, + `collect` refuses, even with `--from`. +- For a writer, `discrepancies` lists: + - claimed passing checks the worker did not record as passing; + - required checks whose latest run is not the worker's, or whose evidence + would not pass the DONE gate in the worker's workspace (fingerprint and + log); + - reported files outside the phase paths (traversal and absolute paths + included); + - changes the worker's workspace shows outside the phase paths, or did not + report. These are compared with a baseline taken when the delegation was + recorded, so earlier changes in a shared checkout do not count; + - a worker session that never began. + + Claims alone prove nothing. +- A writer recorded after its host task already began and claimed the phase + is accepted when that session is parallel and began after the orchestrator. +- `cancel`, `collect` and `end` treat a CLI worker as stopped only when its + receipt says `exited`. For `unknown` or `foreign`, confirm that it stopped + and pass `--confirm-stopped`; `end` leaves such records open. +- `end` stops the session's CLI workers like other runtimes and cancels their + records. It reports host tasks and kept workers as `open_delegations`. + `begin` and `status` list them. A session that takes over by solo `begin` + inherits them once its state is saved. Any later session adopts the record + of an ended orchestrator, together with its kept CLI process, on `collect` + or `cancel`. + ## Handover output `handover [--output FILE]` and `end --handover-output FILE` replace only the diff --git a/packs/session-workflow/shared/runtime/session.py b/packs/session-workflow/shared/runtime/session.py index 00f5079..849f465 100644 --- a/packs/session-workflow/shared/runtime/session.py +++ b/packs/session-workflow/shared/runtime/session.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """Deterministic session facts and state. Python 3.9+, standard library only. -No model/API calls, no Git writes, no project migration and no kills outside a -process group this runtime supervises. Run --help or a subcommand --help for +No model/API calls of its own (a delegated worker is a command the caller +supplies), no Git writes, no project migration and no kills outside a process +group this runtime supervises. Run --help or a subcommand --help for the JSON CLI. See references/runtime.md. """ from __future__ import annotations @@ -16,11 +17,14 @@ import os from pathlib import Path import platform +import posixpath import re +import shutil import signal import subprocess import sys import tempfile +import threading import time import uuid @@ -777,6 +781,10 @@ def brief(state, store, session_id=None, live=None): "live_workspace": compact_snapshot(live) if live is not None else None} if actor and actor["status"] != "active": result["session_status"] = "ended: " + actor["ended_reason"] + delegations = relay_open(store) + if delegations: + result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], + "status": r["status"], "orchestrator": r["session_id"]} for r in delegations[:20]] warnings = snapshot_warnings(live) if warnings: result["warnings"] = warnings @@ -896,6 +904,8 @@ def begin(store, args): "Inspect the plan and choose a ready phase" new_checkpoint(store, state, session_id, next_action, workspace=live) store.save(state, "begin:" + session_id) + for item in superseded: + relay_transfer(store, item["id"], session_id) result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded @@ -978,6 +988,10 @@ def cell(value): "| Phase | State | Owner | Latest checks |", "| --- | --- | --- | --- |", *rows, "", "Active sessions: " + ("; ".join(f"{cell(sid)} (next: {cell(s['checkpoint'].get('next_action', ''))})" for sid, s in active) or "none") + "."] + delegations = relay_open(store) + if delegations: + lines.append("Open delegations: " + "; ".join(f"{cell(r['name'])} ({cell(r['provider'])} {r['mode']}, " + f"{r['status']})" for r in delegations[:10]) + ".") if ended: sid, s = max(ended, key=lambda item: item[1]["ended_at"]) lines.append(f"Ended sessions: {len(ended)}; latest {cell(sid)} " @@ -1021,7 +1035,7 @@ def group_alive(pgid): return True -def terminate_group(child): +def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": if child.poll() is None: @@ -1031,7 +1045,7 @@ def terminate_group(child): os.killpg(child.pid, signal.SIGTERM) except ProcessLookupError: return - deadline = time.monotonic() + 4 + deadline = time.monotonic() + grace while time.monotonic() < deadline: child.poll() if not group_alive(child.pid): @@ -1047,6 +1061,27 @@ def group_kwargs(): return {"start_new_session": True} if os.name != "nt" else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} +class Interrupted(Exception): + pass + + +@contextmanager +def interrupt_guard(): + """Turn SIGTERM/SIGHUP into Interrupted while a check runs, so its separate process group is not orphaned.""" + names = [n for n in ("SIGTERM", "SIGHUP") if hasattr(signal, n)] + if threading.current_thread() is not threading.main_thread(): + yield + return + def handler(signum, frame): + raise Interrupted() + previous = {n: signal.signal(getattr(signal, n), handler) for n in names} + try: + yield + finally: + for n, h in previous.items(): + signal.signal(getattr(signal, n), h) + + def run_check(store, state, p, args, run_id, command, before): log = store.directory / "checks" / (run_id + ".log") log.parent.mkdir(parents=True, exist_ok=True) @@ -1056,12 +1091,20 @@ def run_check(store, state, p, args, run_id, command, before): child = subprocess.Popen(command, cwd=store.root, stdout=output, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: - code = child.wait(timeout=args.timeout) + with interrupt_guard(): + code = child.wait(timeout=args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) child.wait() code, outcome = None, "timeout" + except Interrupted: + # The caller was stopped (for example a timed-out delegated worker): stop the check's own group too, + # with a short grace so this finishes well inside the supervisor's longer relay grace period. + terminate_group(child, grace=1.0) + child.wait() + output.write(b"\n[session-workflow] check stopped because its caller was terminated\n") + code, outcome = None, "abandoned" except OSError as exc: output.write(str(exc).encode("utf-8", "replace")) code, outcome = None, "error" @@ -1104,7 +1147,7 @@ def stop_runtime(store, record): return status directory = relative_path(store.directory, record["directory"]) atomic_write(directory / "stop.json", canonical({"run_id": record["run_id"]}) + "\n") - deadline = time.monotonic() + 10 + deadline = time.monotonic() + 15 while time.monotonic() < deadline: status = runtime_view(store, record) if status["status"] != "running": @@ -1126,10 +1169,14 @@ def supervise(spec_path): directory = spec_path.parent receipt_path = directory / "receipt.json" try: - with open(directory / "output.log", "ab") as output: - child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, stderr=subprocess.STDOUT, + # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + with open(directory / "output.log", "ab") as output, \ + open(directory / spec.get("stderr", "output.log"), "ab") as errors: + child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, + stderr=errors if spec.get("stderr") else subprocess.STDOUT, stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 + started, limit, timed_out = time.monotonic(), spec.get("timeout"), False # Descendants keep the group alive after the leader exits; supervise until all are gone. while child.poll() is None or group_alive(child.pid): if time.monotonic() - beat >= HEARTBEAT_SECONDS: @@ -1138,33 +1185,45 @@ def supervise(spec_path): "leader_exited": child.returncode is not None}) + "\n", durable=False) beat = time.monotonic() if stop.exists() and read_json(stop).get("run_id") == spec["run_id"]: - terminate_group(child) + terminate_group(child, spec.get("grace", 4.0)) + break + if limit and time.monotonic() - started > limit: + terminate_group(child, spec.get("grace", 4.0)) + timed_out = True break time.sleep(0.2) child.poll() receipt = {"run_id": spec["run_id"], "status": "exited", "exit_code": child.returncode, "scope": "supervised process group" if os.name != "nt" else "supervised process tree"} + if timed_out: + receipt["timed_out"] = True except Exception as exc: receipt = {"run_id": spec["run_id"], "status": "unknown", "reason": str(exc)[:500]} atomic_write(receipt_path, canonical(receipt) + "\n") -def launch_runtime(store, state, args, command): - key = args.session + "/" + args.name +def launch_runtime(store, state, session_id, name, command, keep=False, cwd=None, timeout=None, run_id=None, + split_stderr=False): + key = session_id + "/" + name if key in state["processes"]: raise Problem("runtime", "Runtime name already recorded; inspect it before creating a new name") - run_id = uuid.uuid4().hex + run_id = run_id or uuid.uuid4().hex directory = store.directory / "runtime" / run_id directory.mkdir(parents=True) - spec = {"run_id": run_id, "argv": command, "root": str(store.root)} + spec = {"run_id": run_id, "argv": command, "root": str(cwd or store.root)} + if timeout: + spec["timeout"] = timeout + if split_stderr: + # A delegated worker: its nested check runners need time to stop their own groups first. + spec.update(stderr="stderr.log", grace=10.0) spec_path = directory / "spec.json" atomic_write(spec_path, canonical(spec) + "\n") - record = {"run_id": run_id, "session_id": args.session, "machine": store.machine, + record = {"run_id": run_id, "session_id": session_id, "machine": store.machine, "root": str(store.root), "directory": directory.relative_to(store.directory).as_posix(), - "keep": args.keep} + "keep": keep} # Persist identity before launch; a crash cannot produce an unrecorded child. state["processes"][key] = record - store.save(state, "runtime-prepared:" + args.name) + store.save(state, "runtime-prepared:" + name) SUPERVISORS[run_id] = subprocess.Popen( [sys.executable, str(Path(__file__).resolve()), "_supervise", str(spec_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **group_kwargs()) @@ -1172,9 +1231,488 @@ def launch_runtime(store, state, args, command): while time.monotonic() < deadline: status = runtime_view(store, record) if status["status"] != "unknown": - return {"name": args.name, **status} + return {"name": name, **status} time.sleep(0.05) - return {"name": args.name, **runtime_view(store, record)} + return {"name": name, **runtime_view(store, record)} + + +# --- Delegation ledger ------------------------------------------------------- +# One record per delegated task under /delegations//. It lives outside +# state.json so schema 2 is unchanged and a 3.0 runtime can still join as a worker. +RELAY_OPEN, RELAY_CLOSE = "<<>>" +RELAY_OPEN_STATES = ("running", "malformed") +RELAY_OUTCOMES = ("completed", "blocked", "failed") +PROVIDER_CLIS = ("claude", "codex", "gemini") +MAX_RELAY_TEXT = 4 * 1024 * 1024 + + +def relay_dir(store, name=""): + base = store.directory / "delegations" + return base / name if name else base + + +def relay_load(store, name): + path = relay_dir(store, name) / "record.json" + if not path.is_file(): + raise Problem("delegation", "Unknown delegation name; list them with delegate status") + return read_json(path) + + +def relay_save(store, record): + atomic_write(relay_dir(store, record["name"]) / "record.json", canonical(record) + "\n") + + +def relay_records(store): + base = relay_dir(store) + records = [] + if base.is_dir(): + for directory in sorted(base.iterdir()): + try: + records.append(read_json(directory / "record.json")) + except (Problem, OSError): + continue + return records + + +def relay_owned(store, state, args): + """The caller's record. A record whose orchestrator has ended is adopted together with its CLI process.""" + record = relay_load(store, require(args.name, "--name")) + owner, adopted = record["session_id"], False + if owner != args.session: + if state["sessions"].get(owner, {}).get("status") == "active": + raise Problem("ownership", f"Delegation {record['name']} belongs to active session {owner}", 3) + record["adopted_from"], record["session_id"] = owner, args.session + key, process = relay_process(state, record) + if process and process["session_id"] != args.session and \ + state["sessions"].get(process["session_id"], {}).get("status") != "active": + new_key = args.session + "/" + key.split("/", 1)[1] + if new_key in state["processes"]: + raise Problem("runtime", "This session already has a runtime with that name", 3) + process["session_id"] = args.session + state["processes"][new_key] = state["processes"].pop(key) + adopted = True + return record, adopted + + +def relay_transfer(store, retired, successor): + """Called after the superseding begin was saved; a crash before it leaves adoption as the fallback.""" + for record in relay_records(store): + if record["session_id"] == retired and record["status"] in RELAY_OPEN_STATES: + record["session_id"] = successor + relay_save(store, record) + + +def relay_process(state, record): + for key, process in state["processes"].items(): + if record.get("run_id") and process["run_id"] == record["run_id"]: + return key, process + return None, None + + +def relay_runtime(store, state, record): + _, process = relay_process(state, record) + return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + + +def relay_providers(): + found = [] + for name in PROVIDER_CLIS: + path = shutil.which(name) + item = {"provider": name, "cli": path or "", "version": ""} + if path: + try: + done = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=15, + stdin=subprocess.DEVNULL) + item["version"] = (done.stdout or done.stderr).strip().splitlines()[0][:200] if \ + (done.stdout or done.stderr).strip() else "" + except (OSError, subprocess.TimeoutExpired): + item["version"] = "unknown" + found.append(item) + return {"cli": found, "note": "Host-native subagents (for example T3 delegate_task) are discovered by the agent, " + "not by this runtime"} + + +def relay_start(store, state, args): + name = require(args.name, "--name") + validate(name, "id") + validate(require(args.provider, "--provider"), "id") + mode = require(args.mode, "--mode") + cli = args.inline is not None or bool(args.argv_file) + if relay_dir(store, name).exists(): + raise Problem("delegation", "Delegation name already used; choose a new name", 3) + packet = Path(require(args.packet, "--packet")).expanduser() + text = packet.read_text(encoding="utf-8") + if not text.strip(): + raise Problem("arguments", "The packet is empty") + workspace = Path(args.workspace).expanduser().resolve() if args.workspace else store.root + if not workspace.is_dir(): + raise Problem("workspace", "The worker workspace does not exist") + if mode == "write": + worker = require(args.worker_session, "--worker-session") + validate(worker, "id") + p = phase(state, require(args.phase, "--phase")) + if p["state"] == "DONE": + raise Problem("transition", f"Phase {p['id']} is DONE; reopen it before delegating", 3) + # A host task can start, and its worker begin, before the orchestrator records it. + started = state["sessions"].get(worker) + if p["owner"] and p["owner"] != worker: + raise Problem("ownership", f"Phase {p['id']} is claimed by {p['owner']}; the worker must claim it. " + "Release it first with a transition to TODO", 3) + if worker == args.session or started and (cli or not started["parallel"] or + started["started_at"] < record_floor(state, args.session)): + raise Problem("session_exists", "The worker session ID is already used; choose a new one", 3) + if Store(workspace).directory != store.directory: + raise Problem("workspace", "A writer's workspace must share this coordinator: this checkout or a " + "linked Git worktree of it", 3) + elif args.phase: + phase(state, args.phase) + if not cli and args.timeout: + raise Problem("arguments", "--timeout applies only to a CLI worker command") + if args.timeout is not None and not 0 < args.timeout <= 86400: + raise Problem("arguments", "Timeout must be greater than 0 and at most 86400 seconds") + directory = relay_dir(store, name) + command = [] + if cli: + values = {"{packet}": str(directory / "packet.md"), "{packet_text}": text, + "{reply_file}": str(directory / "reply.txt"), "{workspace}": str(workspace), + "{state_dir}": str(store.directory)} + for item in command_of(args): + for token, value in values.items(): + item = item.replace(token, value) + command.append(item) + try: + validate(command, "argv") + except Problem: + raise Problem("arguments", "Worker command is too long; pass {packet} (a path) instead of {packet_text}") + directory.mkdir(parents=True) + atomic_write(directory / "packet.md", text) + record = {"name": name, "session_id": args.session, "machine": store.machine, "provider": args.provider, + "model": args.model or "", "mode": mode, "transport": "cli" if cli else "host", + "phase": args.phase or "", "worker_session": args.worker_session or "", + "workspace": str(workspace) if cli or args.workspace else "", "host_ref": args.host_ref or "", + "run_id": uuid.uuid4().hex if cli else "", + "packet": (directory / "packet.md").relative_to(store.directory).as_posix(), + "packet_sha256": file_digest(directory / "packet.md"), "status": "running", + "started_at": now(), "finished_at": ""} + if mode == "write" and state["sessions"].get(record["worker_session"]): + record["recorded_after_worker_began"] = True + if mode == "write" and record["workspace"]: + # Changes already present are not the worker's; collect compares against this baseline. + baseline = relay_changes(workspace, store.directory) + if baseline is not None: + atomic_write(directory / "baseline.json", canonical(baseline) + "\n") + relay_save(store, record) + result = {"name": name, "transport": record["transport"], "packet": str(directory / "packet.md"), + "reply_file": str(directory / "reply.txt")} + if cli: + result["runtime"] = launch_runtime(store, state, args.session, "relay-" + name, command, keep=args.keep, + cwd=workspace, timeout=args.timeout, run_id=record["run_id"], + split_stderr=True) + return result + + +def record_floor(state, session_id): + """A worker that began before its orchestrator did cannot belong to this delegation.""" + return state["sessions"][session_id]["started_at"] + + +def relay_latest_checks(state, record): + latest = {} + for check in sorted(state["checks"].values(), key=lambda item: item["sequence"]): + if check["phase"] == record["phase"] and check["session_id"] == record["worker_session"]: + latest[check["name"]] = check + return latest + + +def relay_worker_facts(state, record): + latest = {name: c["result"] if c["provenance"] == "executed" else c["result"] + " (" + c["provenance"] + ")" + for name, c in relay_latest_checks(state, record).items()} + worker = state["sessions"].get(record["worker_session"]) + try: + p = phase(state, record["phase"]) + except Problem: + p = {"state": "missing", "owner": ""} + return {"session": worker["status"] + (": " + worker["ended_reason"] if worker["ended_reason"] else "") + if worker else "not started", "root": worker["root"] if worker else "", "phase_state": p["state"], + "phase_owner": p["owner"], "recorded_checks": latest} + + +def relay_view(store, state, record): + view = {key: record[key] for key in ("name", "session_id", "provider", "model", "mode", "transport", "phase", + "worker_session", "host_ref", "status", "started_at", "finished_at")} + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + view["runtime"] = {key: runtime[key] for key in ("status", "exit_code", "timed_out", "reason") if key in runtime} + if record["status"] == "running" and runtime["status"] == "exited": + view["status"] = "exited" + if record["mode"] == "write": + view["worker"] = relay_worker_facts(state, record) + if record.get("result"): + view["result"] = record["result"] + for key in ("error", "discrepancies", "reason", "adopted_from", "recorded_after_worker_began"): + if record.get(key): + view[key] = record[key] + return view + + +def relay_final_texts(value): + """Final-answer fields of known agent JSON output; tool and progress events are ignored.""" + if not isinstance(value, dict): + return [] + kind = value.get("type") + if kind == "result" and isinstance(value.get("result"), str): # Claude Code json / stream-json result + return [value["result"]] + if kind is None and isinstance(value.get("response"), str): # Gemini CLI json, which has no event type + return [value["response"]] + item = value.get("item") + if value.get("type") == "item.completed" and isinstance(item, dict) and item.get("type") == "agent_message" \ + and isinstance(item.get("text"), str): # Codex exec --json + return [item["text"]] + return [] + + +def relay_blocks(text): + found, start = [], text.find(RELAY_OPEN) + while start >= 0: + end = text.find(RELAY_CLOSE, start) + if end < 0: + break + body = text[start + len(RELAY_OPEN):end].strip() + if body.startswith("```"): + body = body.split("\n", 1)[1] if "\n" in body else "" + body = body.rsplit("```", 1)[0] + found.append(body.strip()) + start = text.find(RELAY_OPEN, end) + return found + + +def relay_extract(text): + """The last RELAY-RESULT block in the final answer: plain text, or a known JSON final-answer field.""" + stripped = text.strip() + if stripped.startswith("{"): + try: + whole = json.loads(stripped) + except ValueError: + whole = None + if whole is not None: + blocks = [b for t in relay_final_texts(whole) for b in relay_blocks(t)] + return blocks[-1] if blocks else None + candidates, raw, inside = [], [], False + for line in text.splitlines(): + decoded = None + if not inside and line.lstrip().startswith("{"): + try: + decoded = json.loads(line) + except ValueError: + decoded = None + if isinstance(decoded, dict): + candidates += relay_blocks("\n".join(raw)) + raw = [] + for final in relay_final_texts(decoded): + candidates += relay_blocks(final) + continue + raw.append(line) + # Lines inside an open block are its body, even when they look like JSON events. + opened = line.rfind(RELAY_OPEN) + if opened >= 0: + inside = RELAY_CLOSE not in line[opened:] + elif inside and RELAY_CLOSE in line: + inside = False + candidates += relay_blocks("\n".join(raw)) + return candidates[-1] if candidates else None + + +def relay_problems(record, result): + if not isinstance(result, dict): + return ["The result must be a JSON object"] + problems = [] + if result.get("outcome") not in RELAY_OUTCOMES: + problems.append("outcome must be one of " + ", ".join(RELAY_OUTCOMES)) + if not isinstance(result.get("summary"), str) or not result["summary"].strip(): + problems.append("summary is required") + if "checks" in result and (not isinstance(result["checks"], list) or not all( + isinstance(c, dict) and isinstance(c.get("name"), str) for c in result["checks"])): + problems.append("checks must be [{name, result}]") + for field in ("changed_files", "findings", "gaps"): + if field in result and not isinstance(result[field], list): + problems.append(field + " must be a list") + if "changed_files" in result and isinstance(result["changed_files"], list) and \ + not all(isinstance(f, str) for f in result["changed_files"]): + problems.append("changed_files must list paths") + if result.get("outcome") == "completed": + if record["mode"] == "consult" and not isinstance(result.get("answer"), str): + problems.append("a consult result needs answer") + if record["mode"] == "review": + if result.get("verdict") not in ("APPROVE", "CHANGES_REQUIRED"): + problems.append("a review needs verdict APPROVE or CHANGES_REQUIRED") + if not isinstance(result.get("findings"), list): + problems.append("a review needs a findings list") + if record["mode"] == "write": + for field in ("changed_files", "checks"): + if field not in result: + problems.append("a write result needs " + field) + return problems + + +def relay_in_scope(name, allowed): + """One-directional containment after normalization; traversal and absolute paths are never in scope.""" + raw = name.replace("\\", "/") + if raw.startswith("/") or re.match(r"^[A-Za-z]:", raw) or ".." in raw.split("/"): + return False + clean = posixpath.normpath(raw).casefold() + for scope in allowed: + scope = posixpath.normpath(scope.replace("\\", "/")).casefold() + if scope in ("*", ".") or clean == scope or clean.startswith(scope + "/"): + return True + return False + + +def relay_changes(root, state_dir): + """Changed and untracked paths of a workspace with content facts, or None when they cannot be read.""" + try: + snap = snapshot(root, state_dir) + if snap.get("limited"): + return None + names = [c["path"] if isinstance(c, dict) else c for c in snap["changes"]] + return file_facts(root, names, state_prefix(root, state_dir)) + except Problem: + return None + + +def relay_discrepancies(store, state, record, result): + """Compare a writer's claims with what the runtime recorded and the workspace shows; claims prove nothing.""" + facts = relay_worker_facts(state, record) + found = [] + if result.get("worker_session", record["worker_session"]) != record["worker_session"]: + found.append("Result names another worker session") + if facts["session"] == "not started": + found.append("The worker session never began in this coordinator") + latest = relay_latest_checks(state, record) + for check in result.get("checks") or []: + recorded = latest.get(check["name"]) + if check.get("result") == "pass" and (not recorded or recorded["result"] != "pass"): + found.append(f"Check {check['name']} is claimed to pass but the worker recorded " + f"{recorded['result'] if recorded else 'no run'}") + try: + p = phase(state, record["phase"]) + except Problem: + p = {"id": "", "paths": [], "required_checks": []} + root = Path(facts["root"]) if facts["root"] else None + if result.get("outcome") == "completed": + overall = {} + for c in sorted((c for c in state["checks"].values() if c["phase"] == p["id"]), key=lambda c: c["sequence"]): + overall[c["name"]] = c + for name in p["required_checks"]: + if name not in latest or overall[name]["id"] != latest[name]["id"]: + found.append(f"Required check {name} has no latest run by the worker") + if root and root.is_dir() and p["id"]: + # The same gate DONE uses, judged in the worker's workspace: fresh fingerprint and intact log. + found += ["Worker evidence would not satisfy DONE: " + gap + for gap in verification_gaps(Store(root, store.directory), state, p)] + claimed = [f for f in result.get("changed_files") or []] + outside = [f for f in claimed if not relay_in_scope(f, p["paths"])] + if outside: + found.append("Reported files outside the phase paths: " + ", ".join(outside[:10])) + if root and root.is_dir(): + now = relay_changes(root, store.directory) + baseline_path = relay_dir(store, record["name"]) / "baseline.json" + baseline = read_json(baseline_path) if baseline_path.is_file() and str(root) == record["workspace"] else None + if now is None: + found.append("Could not inspect the worker workspace") + elif baseline is None and root == store.root: + found.append("The worker used the orchestrator checkout without a recorded baseline; its changes " + "could not be told apart from others") + else: + base = baseline or {} + observed = sorted(f for f in set(now) | set(base) if now.get(f) != base.get(f)) + stray = [f for f in observed if not relay_in_scope(f, p["paths"])] + if stray: + found.append("Workspace changes outside the phase paths: " + ", ".join(stray[:10])) + unreported = [f for f in observed if f not in claimed and f not in stray] + if unreported: + found.append("Changed but not reported: " + ", ".join(unreported[:10])) + return found + + +def relay_collect(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + directory = relay_dir(store, record["name"]) + sources = [directory / "reply.txt"] + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + if runtime["status"] == "running": + raise Problem("delegation_running", "The worker is still running; wait for it or cancel it", 3) + if runtime["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {runtime['status']}: " + f"{runtime.get('reason', '')}. Confirm the worker stopped, then pass --confirm-stopped", 3) + _, process = relay_process(state, record) + if process: + sources.append(relay_path(store, process["directory"]) / "output.log") + if args.from_file: + sources = [Path(args.from_file).expanduser()] + body, used = None, "" + for source in sources: + if source.is_file(): + with open(source, "rb") as handle: + handle.seek(max(0, source.stat().st_size - MAX_RELAY_TEXT)) + body = relay_extract(handle.read().decode("utf-8", "replace")) + if body is not None: + used = str(source) + break + if body is None: + record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + relay_save(store, record) + return relay_view(store, state, record), adopted + try: + result = json.loads(body, object_pairs_hook=object_pairs) + problems = relay_problems(record, result) + except (ValueError, Problem) as exc: + result, problems = None, ["The block is not valid, unambiguous JSON: " + str(exc)[:200]] + if problems: + record.update(status="malformed", error="; ".join(problems)) + relay_save(store, record) + return relay_view(store, state, record), adopted + discrepancies = relay_discrepancies(store, state, record, result) if record["mode"] == "write" else [] + atomic_write(directory / "result.json", canonical(result) + "\n") + record.update(status="returned", finished_at=now(), result=result, source=used, + result_sha256=file_digest(directory / "result.json"), error="", discrepancies=discrepancies) + relay_save(store, record) + return relay_view(store, state, record), adopted + + +def relay_cancel(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + if record["transport"] == "cli": + _, process = relay_process(state, record) + stopped = stop_runtime(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + if stopped["status"] == "running": + raise Problem("delegation_running", "The worker did not stop; inspect it before retrying", 3) + if stopped["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {stopped['status']}: {stopped.get('reason', '')}. " + "Stop it yourself, then pass --confirm-stopped", 3) + elif not args.confirm_stopped: + raise Problem("arguments", "Cancel the host task first (for example with the host's cancel tool), then " + "pass --confirm-stopped") + record.update(status="cancelled", finished_at=now(), reason=args.reason or "cancelled by orchestrator") + relay_save(store, record) + view = relay_view(store, state, record) + if record["mode"] == "write" and state["sessions"].get(record["worker_session"], {}).get("status") == "active": + view["next"] = (f"Worker session {record['worker_session']} is still active; release it with release " + "--confirm-stopped once you have confirmed its agent stopped") + return view, adopted + + +def relay_path(store, relative): + return relative_path(store.directory, relative) + + +def relay_open(store, session_id=None): + return [r for r in relay_records(store) if r["status"] in RELAY_OPEN_STATES and + (session_id is None or r["session_id"] == session_id)] def running_checks(state, session_id, pid=None): @@ -1258,6 +1796,26 @@ def command_parser(): p.add_argument("--name", required=True) p.add_argument("--argv-file", help="JSON argv array instead of a command after --") p.add_argument("--keep", action="store_true") + p = sub.add_parser("delegate", help="Record and supervise work delegated to another agent; worker command after --") + p.add_argument("action", choices=("providers", "start", "status", "collect", "cancel")) + p.add_argument("--session") + p.add_argument("--expect-revision", type=int) + p.add_argument("--name", help="Unique delegation ID") + p.add_argument("--provider", help="Worker provider ID, for example codex, claude or gemini") + p.add_argument("--model", default="") + p.add_argument("--mode", choices=("consult", "review", "write")) + p.add_argument("--phase", help="Required for write: the phase the worker claims") + p.add_argument("--worker-session", help="Required for write: the new session ID the worker begins") + p.add_argument("--workspace", help="Worker checkout; a writer needs this checkout or a linked worktree") + p.add_argument("--packet", help="Instruction packet file given to the worker") + p.add_argument("--host-ref", help="Host task reference when no CLI command is given") + p.add_argument("--argv-file", help="JSON argv array instead of a command after --") + p.add_argument("--timeout", type=float, help="CLI worker: stop its process group after this many seconds") + p.add_argument("--keep", action="store_true", help="CLI worker: keep running when this session ends") + p.add_argument("--from", dest="from_file", help="collect: file holding the worker's final reply") + p.add_argument("--confirm-stopped", action="store_true", + help="cancel/collect: you verified the worker stopped (host task, or a CLI worker in unknown state)") + p.add_argument("--reason", default="") return parser @@ -1440,7 +1998,21 @@ def end(store, state, args): for name, record in state["processes"].items(): if record["session_id"] == args.session: processes[name] = runtime_view(store, record) if args.keep_runtimes or record["keep"] else stop_runtime(store, record) - return {"session_id": args.session, "processes": processes} + result = {"session_id": args.session, "processes": processes} + still_open = [] + for record in relay_open(store, args.session): + key, _ = relay_process(state, record) + stopped = processes.get(key) + if record["transport"] == "cli" and stopped and stopped["status"] == "exited" and record["status"] == "running": + record.update(status="cancelled", finished_at=now(), reason="orchestrator session ended") + relay_save(store, record) + else: + still_open.append(record["name"]) + if still_open: + result["open_delegations"] = still_open + result["warning"] = ("Delegations stay open: a later session adopts them with delegate status, collect " + "or cancel. Cancel host tasks you no longer need.") + return result def check_command(store, state, args): @@ -1489,9 +2061,14 @@ def mutate(store, state, args): return {"checkpoint": state["sessions"][args.session]["checkpoint"]["id"]}, "checkpoint:" + args.session if command == "end": return end(store, state, args), None + if command == "delegate": + if args.action == "start": + return relay_start(store, state, args), None + result, adopted = (relay_collect if args.action == "collect" else relay_cancel)(store, state, args) + return result, "delegate-adopt:" + args.session if adopted else None if command == "runtime": if args.action == "launch": - return launch_runtime(store, state, args, command_of(args)), None + return launch_runtime(store, state, args.session, args.name, command_of(args), args.keep), None record = state["processes"].get(args.session + "/" + args.name) if not record: raise Problem("runtime", "No runtime with this name belongs to this session") @@ -1556,6 +2133,10 @@ def read_only(store, args): "complete" if state["phases"] and all(p["state"] == "DONE" for p in state["phases"]) else "decision" return state, {"action": action, "phase": choices[0] if choices else None, "stale_done_evidence": stale, "not_done": [p["id"] for p in state["phases"] if p["state"] != "DONE"]} + if args.command == "delegate": + records = [relay_load(store, args.name)] if args.name else relay_records(store) + return state, {"delegations": [relay_view(store, state, r) for r in records + if args.name or not args.session or r["session_id"] == args.session]} record = state["processes"].get(args.session + "/" + args.name) if not record: raise Problem("runtime", "Unknown session runtime") @@ -1564,13 +2145,18 @@ def read_only(store, args): def dispatch(store, args): command = args.command - accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" + accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" \ + or command == "delegate" and args.action == "start" if args.inline is not None and not accepts_command: - raise Problem("arguments", "Only 'check run' and 'runtime launch' accept a command after --") + raise Problem("arguments", "Only 'check run', 'runtime launch' and 'delegate start' accept a command after --") + if command == "delegate" and args.action == "providers": + return None, relay_providers() + if command == "delegate" and args.action != "status": + require(args.session, "--session") if command == "snapshot": result = store.observe() return None, result if args.full else compact_snapshot(result) - if command in ("status", "next") or command == "runtime" and args.action == "status": + if command in ("status", "next") or command in ("runtime", "delegate") and args.action == "status": return read_only(store, args) if command == "check" and args.action == "run": return recorded_check(store, args) diff --git a/packs/session-workflow/shared/workflow-contract.md b/packs/session-workflow/shared/workflow-contract.md index a635b44..9eb04ba 100644 --- a/packs/session-workflow/shared/workflow-contract.md +++ b/packs/session-workflow/shared/workflow-contract.md @@ -1,4 +1,4 @@ -# estafeta contract - 3.0.0 +# estafeta contract - 3.1.0 Load once per chat. Commands: [quickstart](quickstart.md). Rare options: [runtime](runtime.md). diff --git a/packs/session-workflow/skills.list b/packs/session-workflow/skills.list index 0b89529..ce69185 100644 --- a/packs/session-workflow/skills.list +++ b/packs/session-workflow/skills.list @@ -3,3 +3,4 @@ session-start session-end session-handover session-guide +agent-relay diff --git a/packs/session-workflow/skills/agent-relay/SKILL.md b/packs/session-workflow/skills/agent-relay/SKILL.md new file mode 100644 index 0000000..c2cfe72 --- /dev/null +++ b/packs/session-workflow/skills/agent-relay/SKILL.md @@ -0,0 +1,104 @@ +--- +name: agent-relay +description: >- + Work on one estafeta project with several AI providers: ask another agent a + question, get a cross-review, delegate a phase to a worker of another + provider (Claude Code, Codex, Gemini, or host-native subagents such as T3 + Code tasks), or coordinate peer agents the user opened. Use for "ask Codex", + "have Claude review this", "delegate this phase to Gemini", "second + opinion", «спроси Codex», «пусть Claude проверит», «отдай фазу другому + агенту». Every delegation is recorded and judged against fresh checks. +license: Apache-2.0 +metadata: + workflow-pack: session-workflow + workflow-version: "3.1.0" +--- + +# Agent relay + +Read the [contract](references/workflow-contract.md) and +[quickstart](references/quickstart.md) once per chat; delegation commands are +in [runtime](references/runtime.md), section Delegations. If this chat has no +session yet, run session-start first. You are the orchestrator and the only +integrator: workers never change the plan, handover or Git history. + +## Modes + +| Mode | Use for | Worker may write | Returns | +| --- | --- | --- | --- | +| `consult` | Question, design option, second opinion | Nothing | `answer` | +| `review` | Exact diff or revision | Nothing | `verdict`, `findings` | +| `write` | One planned phase | Only that phase's paths, in its own workspace | changed files, recorded checks | +| peer | Agents the user opened in the same project | Their own claimed phases | Their own sessions | + +## Delegate + +1. **Authorize.** Running another provider is a paid background run. Proceed + only when the user asked for that provider or mode in this conversation. + Never put secrets in a packet. +2. **Pick the transport** (details and exact commands: [adapters](references/adapters.md)): + 1. a host-native cross-provider task, if this host offers one; + 2. otherwise the provider's CLI (`delegate providers` lists them); + 3. a same-provider subagent, when only another model is needed. + + Without any of these, do the work yourself and say the review is self-review. + Never use a flag that bypasses the worker's sandbox or approvals. +3. **Prepare a write.** The phase must be unclaimed, not DONE and + disjoint from other live claims. Give the worker its own linked worktree + on a `relay/` branch from the current commit, unless the host + creates the worktree itself. Uncommitted changes do not reach a new + worktree. +4. **Write the packet** from the [template](templates/packet.md) into the + state directory's `inputs//`. A packet is self-contained: the + worker may not have estafeta installed, so give it the absolute runtime + path, IDs, allowed paths, required checks with exact argv, and stop + conditions. Later rounds send only the changes. +5. **Start and record.** + - CLI: `delegate start --session ID --name N --provider P --model M --mode + MODE --packet F [--phase P --worker-session W --workspace DIR] + --timeout S -- `. + - Host: launch the host task with the packet text, then record it at once + with `delegate start ... --host-ref `. A fast writer may already + have begun and claimed its phase; the runtime accepts that. If recording + fails for another reason, cancel the host task. +6. **Wait without busy polling.** Host tasks notify; for a CLI worker, check + `delegate status --name N` at sensible intervals and continue other + unclaimed work meanwhile. +7. **Collect.** Run `delegate collect --name N`. For a host task, save the + reply text to the `reply_file` that `start` returned, or pass `--from FILE`. + If the result is `malformed`, ask the same worker to resend only the + RELAY-RESULT block, then collect again. +8. **Judge.** A worker's words are claims. + - consult: weigh the answer like any source and say where it came from. + - review: fix or record every finding. Let the same reviewer check the fix + delta when possible. A verdict is not a passed test. + - write: `discrepancies` must be empty. Read the diff in the worker + workspace and commit it on the relay branch yourself. Merge it, claim the + phase (VERIFY), then rerun the required checks in the integrated checkout. + Only then go to DONE. +9. **Clean up.** Cancel tasks you no longer need. After a successful merge, + remove the worker worktree. `end` stops your CLI workers and lists open + delegations; a later session adopts them through `delegate status`, + `collect` or `cancel`. + +## Peer agents + +When the user runs agents of several providers on the project at once, each +starts with session-start in parallel mode and claims phases with disjoint +paths, ideally in its own worktree. Name one integrator for the plan, handover +and Git history. The others report changed files, checks and gaps to the +integrator instead of editing shared records. Claims need cooperation: a +provider without estafeta must be given the same rules in its prompt. + +## Worker side + +If you received a relay packet, follow it exactly. Use the runtime path it +names, begin only the session it names, stay inside its paths, never commit, +push or change shared records, and end with exactly one RELAY-RESULT block. + +## Model and provider changes + +The ledger records provider and model for every delegation. Results from one +model do not carry over to another. The first time you use a provider and +model pair, start with consult or review rather than write, and judge the +first results especially carefully. diff --git a/packs/session-workflow/skills/agent-relay/agents/openai.yaml b/packs/session-workflow/skills/agent-relay/agents/openai.yaml new file mode 100644 index 0000000..082fee3 --- /dev/null +++ b/packs/session-workflow/skills/agent-relay/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Agent Relay" + short_description: "Consult, review or delegate work to another AI provider" + default_prompt: "Use $agent-relay to delegate this to another provider and judge its result against fresh checks." diff --git a/packs/session-workflow/skills/agent-relay/references/adapters.md b/packs/session-workflow/skills/agent-relay/references/adapters.md new file mode 100644 index 0000000..e2c5d90 --- /dev/null +++ b/packs/session-workflow/skills/agent-relay/references/adapters.md @@ -0,0 +1,129 @@ +# Relay adapters + +Load only the section for the transport you use. CLI flags change between +releases: when a command is refused, read that CLI's `--help` and adjust it +without loosening the sandbox. "Docs" marks commands taken from official +documentation that estafeta's own tests have not run yet. + +## Never by default + +These flags remove the worker's sandbox or approvals. Use one only when the +user explicitly asks for it for this delegation. + +| Provider | Forbidden | +| --- | --- | +| Codex | `--yolo`, `--dangerously-bypass-approvals-and-sandbox`, `--sandbox danger-full-access`, `--ignore-rules` | +| Claude Code | `--dangerously-skip-permissions`, `--allow-dangerously-skip-permissions`, `--permission-mode bypassPermissions` | +| Gemini CLI | `--approval-mode yolo`, `-y`/`--yolo`, `--skip-trust` | + +## Host-native tasks + +Prefer these when the host offers them: the host owns authentication, +visibility and cancellation. + +- **T3 Code.** `orchestrator_capabilities` lists provider instances, models and + whether `canRunCrossProviderChildTask` is true. Launch with `delegate_task`: + the packet text as `task`, `target` = `{providerInstanceId, model}`, + `mode: "async"`, and the delegation name as `clientRequestId`. For write, + add `isolation: "worktree"`. T3 creates the worktree from the last local + commit, so record the delegation without `--workspace`. The runtime learns + the worker's root when the worker begins its session. Record the returned + `taskId` as `--host-ref t3:`. Use `task_status` and `task_cancel` + to follow and stop it. For a new review round, start a new `delegate_task` + with the earlier findings and replies, under a new delegation name. +- **Same-provider subagents** (Claude Code Agent tool, Codex subagents): use + them when only another model or a fresh context is needed. Record them as + host tasks with the subagent's ID as `--host-ref`. + +## CLI workers + +Run through `delegate start ... -- `. The runtime replaces +`{packet_text}`, `{packet}`, `{reply_file}`, `{workspace}` and `{state_dir}`. +The worker gets no shell and no stdin, and its working directory is +`--workspace`. Always pass `--timeout`. Pass the same model to the CLI that you +record with `--model`; the runtime cannot see which model actually ran. + +`collect` reads `{reply_file}` first, then the worker's stdout. Stderr is kept +apart in `stderr.log` because it carries progress and tool output. In JSON +output, only final-answer fields count: Claude's `result`, Gemini's +`response`, and Codex's `agent_message` items. A block quoted inside tool +output is ignored. + +A writer must write the shared state directory to record its session, claims +and checks. Grant exactly that directory (`{state_dir}`) in addition to its +workspace. Do not grant the repository's `.git`: the integrator commits. + +### Codex (`codex exec`), docs + +| Mode | argv after `--` | +| --- | --- | +| consult, review | `codex exec --sandbox read-only --cd {workspace} --output-last-message {reply_file} -m MODEL {packet_text}` | +| write | `codex exec --sandbox workspace-write --add-dir {state_dir} --cd {workspace} --output-last-message {reply_file} -m MODEL {packet_text}` | + +- Reasoning effort: `-c model_reasoning_effort="high"`. +- Codex needs a Git checkout and an existing `codex login` or `CODEX_API_KEY`. +- It reads AGENTS.md and finds skills in `.agents/skills` and `~/.agents/skills`. + +### Claude Code (`claude -p`), verified live + +| Mode | argv after `--` | +| --- | --- | +| consult, review | `claude -p {packet_text} --permission-mode dontAsk --tools Read,Grep,Glob --output-format json --model MODEL --max-budget-usd N` | +| write | `claude -p {packet_text} --permission-mode acceptEdits --settings SANDBOX --output-format json --model MODEL --max-budget-usd N` | + +SANDBOX is one argv item: + +```json +{"sandbox": {"enabled": true, "failIfUnavailable": true, "allowUnsandboxedCommands": false, "filesystem": {"allowWrite": ["{state_dir}"]}}} +``` + +- **The OS sandbox is the boundary.** It confines Bash and every process Bash + starts, and sandboxed commands run without prompts. + - Writes are allowed only to the workspace, the temp directory and + `{state_dir}`. A linked worktree also gets its shared `.git`. + - Permission rules such as `Bash(python3:*)` are not a filesystem boundary, + so do not use them for writers. + - File tools follow `acceptEdits` and can write only inside the workspace. +- **When the sandbox fails.** `failIfUnavailable` makes the worker exit on + native Windows or without the sandbox dependencies; prefer a host task + there. +- **What `collect` can see.** It reports changes outside the phase paths + inside the worktree, but not writes elsewhere. +- **Placement.** Put `{packet_text}` right after `-p`: `--tools` and + `--add-dir` take several values and would swallow a trailing prompt. +- **Cost and denials.** The JSON reply has `result`, `session_id`, `usage`, + `total_cost_usd` and `permission_denials`. `collect` finds the block in + `result`. +- **Avoid `--bare` and `-w`.** `--bare` turns off CLAUDE.md and skills; with + `-w` Claude would make its own worktree. +- **Nesting.** A worker started from inside a Claude Code session ran without + changes in 2.1.300. +- Do not use `--bare`: it turns off CLAUDE.md and skills. +- Do not use `-w`: the runtime already chose the workspace. +- The JSON reply has `result`, `session_id`, `usage` and `total_cost_usd`; + `collect` finds the block inside `result`. + +### Gemini CLI (`gemini -p`), docs + +| Mode | argv after `--` | +| --- | --- | +| consult, review | `gemini -m MODEL -p {packet_text} --output-format json --approval-mode plan` | +| write | `gemini -m MODEL -p {packet_text} --output-format json --approval-mode auto_edit --include-directories {state_dir}` | + +- Since June 2026, personal Google accounts use Antigravity instead. Gemini + CLI keeps working with paid Gemini API, Vertex or enterprise keys. +- Headless shell approval and folder trust for a fresh worktree are + unverified. Prefer a host-native task (for example T3's Antigravity + provider) for Gemini writers. +- Exit codes: 1 means an error, 42 bad input, 53 the turn limit. + +## Resuming a worker + +A follow-up round, such as "resend only the block" or a fix delta, goes to the +same worker session when the transport supports it: + +- `codex exec resume ...`; +- `claude -p --resume ...`; +- a new T3 task carrying the earlier exchange. + +Record every round as a new delegation name. diff --git a/packs/session-workflow/skills/agent-relay/references/quickstart.md b/packs/session-workflow/skills/agent-relay/references/quickstart.md new file mode 100644 index 0000000..1eb628a --- /dev/null +++ b/packs/session-workflow/skills/agent-relay/references/quickstart.md @@ -0,0 +1,69 @@ +# estafeta commands + +Run `python3 SCRIPT --root ROOT ` (`py -3` on Windows). SCRIPT is this +skill's `scripts/session.py`; ROOT is the project's Git root. Each call prints +one JSON object: check `ok`, read `result` and `warnings`, keep `revision`. +Never read the runtime source or state files. + +| Step | Command | +| --- | --- | +| Start | `begin`; add `--parallel` when the user says, in any language, that another live agent works in this checkout. A checkout already in parallel mode is joined automatically (see `warnings`) | +| Plan, claim, checkpoint | `batch --session ID --expect-revision R --input -` with JSON on stdin | +| Verify | `check run --session ID --phase P1 --name unit --timeout 120 -- npm test` | +| Manual or UI evidence | `check record --session ID --phase P1 --name ui --result pass --evidence "..."` | +| Milestone facts | `handover --output HANDOVER.md` | +| Close | `end --session ID --next-action "..." --handover-output HANDOVER.md` | +| Read | `status --session ID`, `next --session ID`, `snapshot` | +| Preview server | `runtime launch --session ID --name preview -- npm run dev`; `runtime stop --session ID --name preview` | + +A batch has up to 20 steps, applied all or none. Plan steps upsert whole phases +and keep omitted ones: + +```json +{"steps":[ + {"command":"plan","phases":[{"id":"P1","title":"Filters","acceptance":"Filters combine and persist in the URL", + "paths":["src","tests"],"required_checks":["unit"]}]}, + {"command":"transition","phase":"P1","to":"IN_PROGRESS"}, + {"command":"checkpoint","next_action":"Implement P1"}]} +``` + +To finish a phase, batch these steps: + +1. `transition` to VERIFY. +2. `transition` to DONE with `acceptance_note`, saying what you compared with + the acceptance. +3. `checkpoint`. + +- Phase fields are `id`, `title` and `acceptance`. Optional fields: + - `depends_on`; + - `priority` (lower runs first); + - `paths`, default `"*"`: the checkout without the narrative documents; + - `exclude`: generated outputs inside `paths`; + - `required_checks`. +- `plan`, `batch` and `release` need `--expect-revision`; for other commands it + is an optional guard. +- A check command after `--` runs without a shell. `--argv-file FILE` takes a + JSON array instead. Open the log only on failure. Reported evidence cannot + replace a required executed check. +- `end` does all of the following: + - saves the checkpoint and releases your claims; + - renders the facts block once, keeping the prose around it; + - stops your runtimes (`--keep-runtimes` keeps them). +- Stopping a runtime ends its whole process group. + +## Recovery + +| Error or field | Action | +| --- | --- | +| `verification` | Close each listed gap: run missing checks, re-run stale ones, put generated outputs in `exclude` or .gitignore | +| `ownership` | A live session owns that phase or those paths: take unclaimed work, never edit that scope | +| `dependency` | Finish the listed dependencies first | +| Transition `warnings` | A dependency's evidence went stale after later edits: continue, re-run its checks before delivery | +| `session_alive` | That session is running a check now: wait, or `begin --parallel` for intended parallel work | +| `session_ended` | Another chat took over this checkout, or the user released this session: stop editing and tell the user. If both chats should work, `begin --parallel` with disjoint paths | +| `session_foreign` | Your session was registered on another machine or path: `begin` a new session here | +| `stale_revision` | `status`, reconcile, retry with the new revision | +| `busy` | The lock was held for 10 seconds: retry once, then report | +| `handover_after_end` | The session already ended: fix the file, rerun only `handover --output` | +| Stopped session holds claims | After the user confirms it is gone: `release --session OLD --expect-revision R --confirm-stopped --reason "..." --by ID` | +| Interrupted check stays `running` | After verifying it stopped: `check abandon --session ID --id CHECK --confirm-stopped --evidence "..."` | diff --git a/packs/session-workflow/skills/agent-relay/references/runtime.md b/packs/session-workflow/skills/agent-relay/references/runtime.md new file mode 100644 index 0000000..0b95d32 --- /dev/null +++ b/packs/session-workflow/skills/agent-relay/references/runtime.md @@ -0,0 +1,185 @@ +# Runtime reference + +Cold reference for options that [quickstart](quickstart.md) does not cover. +The bundled [CLI](../scripts/session.py) needs Python 3.9+ and the standard +library. It makes no model or network calls and never writes to Git. Its +[schema](../scripts/session.schema.json) defines inputs and state. Run it from +this skill's directory; do not load its source into the prompt. + +## State + +- Location: `.agent-state/session-workflow/` in the main Git checkout. Linked + worktrees share it; projects without Git use the project directory; + `--state-dir` selects another directory explicitly. +- The runtime adds a `.gitignore` there, so logs and machine facts stay out of + commits. It skips this when the project already tracks files in that + directory. +- Writes are serialized by an OS lock, waiting up to 10 seconds. Each state + write is atomic and keeps `state.previous.json`. +- Schema 1 state from 2.x is upgraded on the first write; the original is kept + as `state.v1.json`. Its operation ledger is dropped and unresolved operation + IDs are reported once. Newer or broken schemas are refused untouched. +- History is bounded: the last 500 events and the last 10 runs per phase and + check name; older logs are deleted. + +## Sessions + +| Command | Purpose | +| --- | --- | +| `begin [--session ID] [--parallel] [--next-action TEXT]` | Initialize if needed and register a new session; omit `--session` to get a unique ID | +| `checkpoint --session ID --next-action TEXT [--note TEXT]` | Save a resume point | +| `end --session ID --next-action TEXT [--handover-output FILE] [--keep-runtimes]` | Final checkpoint, release claims, render facts, stop own runtimes | +| `release --session OLD --expect-revision R --confirm-stopped --reason TEXT [--by ID]` | End a stopped session that cannot end itself | +| `status [--session ID] [--full]` | Compact state, other sessions, drift and warnings | +| `next --session ID` | Ready phase, stale DONE evidence, unfinished phases | +| `snapshot [--full]` | Workspace facts; creates no state | +| `init [--context F] [--plan F] [--handover F]` | Create state with document pointers; `begin` does this too | + +Identity is a hashed hardware machine ID plus the checkout path. +`SESSION_WORKFLOW_MACHINE_ID` overrides it for containers. A session acts only +from its own machine and path; elsewhere it gets `session_foreign`. + +Solo `begin`, the default, ends earlier sessions recorded in this checkout, or +in a checkout path that no longer exists on this machine. It moves their claims +and runtimes to the new session. A session that is still running a check blocks +this with `session_alive`. + +`begin --parallel` puts the checkout in parallel mode, which marks every +active session there as parallel. While any parallel session is active in a +checkout, a plain `begin` there joins as parallel, supersedes nobody and +returns a warning. Parallel sessions and sessions on other machines are never +superseded. They appear in `other_active_sessions` and leave only through `end` +or a confirmed `release`. When all of them are gone, solo takeover resumes. +Time never expires a session. + +## Phases + +| Command | Purpose | +| --- | --- | +| `plan --session ID --expect-revision R --input F [--merge]` | Import or upsert phase definitions | +| `batch --session ID --expect-revision R --input F` | Up to 20 atomic plan/transition/checkpoint steps | +| `transition --session ID --phase P --to STATE [--reason T] [--acceptance-note T]` | Claim, release or move a phase | + +Phase fields: `id`, `title`, `acceptance` (required), and `depends_on`, +`priority`, `paths` (default `*`), `exclude` and `required_checks`. + +- Paths are literal relative files or directories. Globs and `..` are rejected. +- Plans cannot delete phases (defer them), inject DONE or rewrite another owner's + phase. A DONE phase must be reopened before its contract changes. +- One owner per phase. Within one checkout, overlapping paths cannot be + claimed by two sessions. Separate worktrees edit isolated copies. Claims do + not block direct edits: they rely on cooperating agents. +- Reaching IN_PROGRESS, VERIFY or DONE requires every dependency to be DONE. + Stale dependency evidence produces `warnings`, not a block. +- Reopening a DONE phase returns completed dependents to VERIFY. + +## Evidence + +| Command | Purpose | +| --- | --- | +| `check run --session ID --phase P --name N [--timeout S] -- CMD...` | Execute and record a check (or `--argv-file F`) | +| `check record --session ID --phase P --name N --result pass\|fail --evidence T` | Record manual evidence, labelled reported | +| `check abandon --session ID --id CHECK --confirm-stopped --evidence T` | Close an interrupted check whose command stopped | + +- A check runs without a shell in its own process group. A timeout stops the + whole group. +- The phase fingerprint covers file contents in scope, Git blob IDs after clean + filters, the acceptance text, paths, `exclude` and required checks. Commits + alone do not change it. With `*`, the context, plan and handover documents + are excluded. +- `stable_inputs` is false when the check changed files in scope while it ran. + `changed_during_check` lists them. Such a run cannot satisfy DONE; add those + outputs to `exclude` or .gitignore and run the check again. +- DONE requires `--acceptance-note` and, for every required check, a latest + executed passing run with stable inputs, a matching fingerprint and an intact + log. Without required checks, any fresh passing run qualifies, including a + reported one. +- Ignored files are outside fingerprints; declare and check such inputs + separately. + +## Runtimes + +| Command | Purpose | +| --- | --- | +| `runtime launch --session ID --name N [--keep] -- CMD...` | Start a supervised process group | +| `runtime status --session ID --name N` | Read its receipt | +| `runtime stop --session ID --name N` | Stop the group, also for an ended session's kept runtime | + +A detached supervisor owns the group through a live handle and writes a +heartbeat receipt. Stopping signals the whole group, so descendants such as +`npm -> node -> vite` stop too. A missing receipt, an expired heartbeat or +another machine reports `unknown` or `foreign`; the runtime never kills a +recorded PID. On Windows the supervisor ends the process tree through +`taskkill /T` while the child is alive. + +## Delegations + +| Command | Purpose | +| --- | --- | +| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate start --session ID --name N --provider P --mode consult\|review\|write --packet F [--model M] [--phase P --worker-session W] [--workspace DIR] [--timeout S] -- CMD...` | Record a delegation and run a CLI worker as a supervised runtime `relay-N` | +| `delegate start ... --host-ref REF` (no command) | Record a host-native child task, such as a T3 task ID | +| `delegate status [--session ID] [--name N]` | Read-only: records, worker runtime, writer facts and result | +| `delegate collect --session ID --name N [--from FILE] [--confirm-stopped]` | Parse the worker's final RELAY-RESULT block from `--from`, `reply.txt` or the CLI worker's stdout | +| `delegate cancel --session ID --name N [--confirm-stopped]` | Stop a CLI worker, or close a host task you already cancelled | + +- Records live in `delegations//` under the state directory: the packet, + `record.json` and `result.json` with SHA-256 hashes. `state.json` and its + schema do not change, so a 3.0 runtime can still take part as a worker. +- In the worker command, `{packet}`, `{packet_text}`, `{reply_file}`, + `{workspace}` and `{state_dir}` are replaced. The worker runs in `--workspace` + without a shell or stdin. Its stdout and stderr go to separate logs. + `--timeout` stops its process group after a 10-second grace period. Within + it, a `check run` started by the worker stops its own check group (1-second + grace, then SIGKILL) and records the run as `abandoned`. +- A writer needs an unclaimed phase that is not DONE and a new worker session + ID. A given `--workspace` must share this coordinator (this checkout or a + linked worktree); a host task may omit it when the host creates the + worktree. The worker begins that session with `--parallel`, claims the phase + itself and does not commit: the integrator owns Git history. +- `collect` takes the last block of the final answer. That is plain text, or + one of: a Claude event of type `result`, Gemini's untyped `response`, or a + Codex `agent_message` item. Blocks in other events, such as tool results, + are ignored. Duplicate JSON keys make a result `malformed`. A missing or invalid block leaves the record + `malformed`, and it can be collected again. While a CLI worker runs, + `collect` refuses, even with `--from`. +- For a writer, `discrepancies` lists: + - claimed passing checks the worker did not record as passing; + - required checks whose latest run is not the worker's, or whose evidence + would not pass the DONE gate in the worker's workspace (fingerprint and + log); + - reported files outside the phase paths (traversal and absolute paths + included); + - changes the worker's workspace shows outside the phase paths, or did not + report. These are compared with a baseline taken when the delegation was + recorded, so earlier changes in a shared checkout do not count; + - a worker session that never began. + + Claims alone prove nothing. +- A writer recorded after its host task already began and claimed the phase + is accepted when that session is parallel and began after the orchestrator. +- `cancel`, `collect` and `end` treat a CLI worker as stopped only when its + receipt says `exited`. For `unknown` or `foreign`, confirm that it stopped + and pass `--confirm-stopped`; `end` leaves such records open. +- `end` stops the session's CLI workers like other runtimes and cancels their + records. It reports host tasks and kept workers as `open_delegations`. + `begin` and `status` list them. A session that takes over by solo `begin` + inherits them once its state is saved. Any later session adopts the record + of an ended orchestrator, together with its kept CLI process, on `collect` + or `cancel`. + +## Handover output + +`handover [--output FILE]` and `end --handover-output FILE` replace only the +block between `` and +``. Missing markers append one block; +duplicate markers fail without changes. The block lists phases, latest checks, +active sessions and the latest ended session, not the full history. If +rendering fails after `end`, the error `handover_after_end` means the session +has already ended: retry only `handover`. + +## Output and exit codes + +Output is UTF-8 JSON with `ok`, `revision`, `state_directory`, `result` and +optional `warnings`, or `error.code` and `error.message`. Exit codes: 0 ok, +2 invalid input, 3 conflict or gate, 4 busy, 6 environment or I/O. diff --git a/packs/session-workflow/skills/agent-relay/references/workflow-contract.md b/packs/session-workflow/skills/agent-relay/references/workflow-contract.md new file mode 100644 index 0000000..9eb04ba --- /dev/null +++ b/packs/session-workflow/skills/agent-relay/references/workflow-contract.md @@ -0,0 +1,43 @@ +# estafeta contract - 3.1.0 + +Load once per chat. Commands: [quickstart](quickstart.md). Rare options: +[runtime](runtime.md). + +**Records.** Reuse the project's documents and tracker IDs. New projects use +PROJECT.md (goal, constraints, doc map, next action), +docs/plans/.md (acceptance, decisions) and HANDOVER.md (changes, +reasons, checks, gaps, next action). AGENTS.md or CLAUDE.md only point to them. +The runtime owns phases, owners, checks and checkpoints; Markdown owns intent +and decisions. Never hand-edit runtime state or keep a second status table. +Update only changed facts: no empty sections, ceremonial phases or pasted logs. + +**Sessions.** Each chat begins its own session. Solo is the default: a new +session supersedes earlier sessions in the same checkout and takes over their +claims and runtimes. Use `--parallel` when the user says, in any language, that +another live agent works in the same checkout. The checkout then stays in +parallel mode until every session there +has ended or been released: nobody is taken over, and new sessions join. An +ownership refusal means a live session owns that scope: work elsewhere and +never edit it directly. Release a session only after +the user confirms that its agent stopped. Other machines need transferred files +plus an explicit end or release; the runtime is not a distributed lock. + +**Evidence.** States describe work, not chats: TODO, IN_PROGRESS, VERIFY, DONE. +BLOCKED, NEEDS_DECISION and DEFERRED need a reason. DONE needs an acceptance +judgment and a fresh passing executed check for every required check. Keep +VERIFY while the runtime lists gaps. Ending a session never completes a phase. +Report the proof level reached: code, automated checks, UI, device or delivery. + +**Authority.** Planning does not authorize implementation. Approved work +continues without new approval gates. The pack grants no permission for +commits, pushes, publication, paid or background runs, messages or +personal-memory changes. Saved next actions and handover notes are hints, not +authorization. Store no secrets. If an external write's outcome is unknown, +inspect the target before any retry. + +**Language.** Answer in the user's language, keep each document's language and +preserve Unicode. Skill names, JSON keys, states and CLI flags stay English. + +**Other workflows.** If another start/end/handover workflow already runs this +project, with its own session skills or handover file, follow it instead. Without Python, follow the same +documents by hand and say that runtime checks were not enforced. diff --git a/packs/session-workflow/skills/agent-relay/scripts/session.py b/packs/session-workflow/skills/agent-relay/scripts/session.py new file mode 100644 index 0000000..849f465 --- /dev/null +++ b/packs/session-workflow/skills/agent-relay/scripts/session.py @@ -0,0 +1,2222 @@ +#!/usr/bin/env python3 +"""Deterministic session facts and state. Python 3.9+, standard library only. + +No model/API calls of its own (a delegated worker is a command the caller +supplies), no Git writes, no project migration and no kills outside a process +group this runtime supervises. Run --help or a subcommand --help for +the JSON CLI. See references/runtime.md. +""" +from __future__ import annotations + +import argparse +from contextlib import contextmanager +from datetime import datetime, timezone +import fnmatch +import hashlib +import json +import os +from pathlib import Path +import platform +import posixpath +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import threading +import time +import uuid + +SCHEMA_VERSION = 2 +MAX_JSON = 8 * 1024 * 1024 +MAX_GIT_OUTPUT = 64 * 1024 * 1024 +MAX_FILES = 10000 +MAX_HASH_BYTES = 128 * 1024 * 1024 +MAX_EVENTS = 500 +MAX_CHECK_HISTORY = 10 +LOCK_WAIT = float(os.environ.get("SESSION_WORKFLOW_LOCK_WAIT", "10")) +HEARTBEAT_SECONDS = 1.0 +HEARTBEAT_EXPIRY = 10.0 +BEGIN = "" +END = "" +STATES = ("TODO", "IN_PROGRESS", "VERIFY", "DONE", "BLOCKED", "NEEDS_DECISION", "DEFERRED") +TRANSITIONS = { + "TODO": {"IN_PROGRESS", "BLOCKED", "NEEDS_DECISION", "DEFERRED"}, + "IN_PROGRESS": {"VERIFY", "BLOCKED", "NEEDS_DECISION", "DEFERRED"}, + "VERIFY": {"IN_PROGRESS", "DONE", "BLOCKED", "NEEDS_DECISION", "DEFERRED"}, + "DONE": {"IN_PROGRESS"}, + "BLOCKED": {"TODO", "IN_PROGRESS", "NEEDS_DECISION", "DEFERRED"}, + "NEEDS_DECISION": {"TODO", "IN_PROGRESS", "BLOCKED", "DEFERRED"}, + "DEFERRED": {"TODO"}, +} +# Dependency, cache and build output skipped when a project has no Git index. +SKIP_NAMES = frozenset({ + ".git", ".hg", ".svn", ".agent-state", "node_modules", ".venv", "venv", "__pycache__", + ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".next", ".nuxt", ".svelte-kit", + ".cache", ".parcel-cache", ".turbo", ".gradle", ".idea", "dist", "build", "coverage", + "target", ".DS_Store"}) +LOCK_ERRORS = (OSError,) if os.name == "nt" else (BlockingIOError,) +SUPERVISORS = {} +_SCHEMAS = [] + + +class Problem(Exception): + def __init__(self, code, message, exit_code=2): + super().__init__(message) + self.code, self.exit_code = code, exit_code + + +def now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def canonical(value): + return json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":"), allow_nan=False) + + +def digest(value): + return hashlib.sha256(canonical(value).encode()).hexdigest() + + +def emit(value): + # Readable UTF-8 keeps non-English facts compact for the model reading them. + stream = sys.stdout + if hasattr(stream, "reconfigure"): + try: + stream.reconfigure(encoding="utf-8", errors="backslashreplace") + except (OSError, ValueError): + pass + print(json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False), file=stream) + + +def object_pairs(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Problem("invalid_json", f"Duplicate JSON key: {key}") + result[key] = value + return result + + +def read_json(path): + if str(path) == "-": + raw = sys.stdin.read(MAX_JSON + 1) + else: + p = Path(path) + if p.stat().st_size > MAX_JSON: + raise Problem("too_large", f"JSON exceeds {MAX_JSON} bytes: {p}") + raw = p.read_text(encoding="utf-8") + if len(raw.encode("utf-8")) > MAX_JSON: + raise Problem("too_large", "JSON input exceeds limit") + try: + return json.loads(raw, object_pairs_hook=object_pairs, + parse_constant=lambda x: (_ for _ in ()).throw(ValueError(x))) + except (ValueError, UnicodeError) as exc: + raise Problem("invalid_json", str(exc)) from exc + + +def validate(value, definition): + """Validate the documented schema subset; reject malformed inputs before effects.""" + if not _SCHEMAS: + _SCHEMAS.append(read_json(Path(__file__).with_name("session.schema.json"))) + schemas = _SCHEMAS[0] + + def visit(item, rule, path): + if "$ref" in rule: + return visit(item, schemas["$defs"][rule["$ref"].split("/")[-1]], path) + types = {"object": dict, "array": list, "string": str, "integer": int, + "number": (int, float), "boolean": bool, "null": type(None)} + typ = rule.get("type") + if typ and (not isinstance(item, types[typ]) or + typ in ("integer", "number") and isinstance(item, bool)): + raise Problem("schema", f"{path}: expected {typ}") + if "enum" in rule and item not in rule["enum"] or "const" in rule and item != rule["const"]: + raise Problem("schema", f"{path}: unsupported value") + if isinstance(item, str): + if len(item) < rule.get("minLength", 0) or len(item) > rule.get("maxLength", 100000): + raise Problem("schema", f"{path}: invalid string length") + if "pattern" in rule and not re.fullmatch(rule["pattern"], item): + raise Problem("schema", f"{path}: invalid format") + if typ in ("integer", "number"): + if item < rule.get("minimum", -float("inf")) or item > rule.get("maximum", float("inf")): + raise Problem("schema", f"{path}: outside allowed range") + if isinstance(item, dict): + missing = set(rule.get("required", [])) - item.keys() + if missing: + raise Problem("schema", f"{path}: missing {sorted(missing)}") + props = rule.get("properties", {}) + extra = rule.get("additionalProperties", True) + for key, val in item.items(): + if key in props: + visit(val, props[key], path + "." + key) + elif extra is False: + raise Problem("schema", f"{path}: unknown field {key}") + elif isinstance(extra, dict): + visit(val, extra, path + "." + key) + if isinstance(item, list): + if not rule.get("minItems", 0) <= len(item) <= rule.get("maxItems", 10000): + raise Problem("schema", f"{path}: invalid list length") + for i, val in enumerate(item): + visit(val, rule.get("items", {}), f"{path}[{i}]") + visit(value, schemas["$defs"][definition], definition) + + +def relative_path(root, value): + p = Path(value) + if p.is_absolute() or ".." in p.parts or not p.parts: + raise Problem("path", f"Expected a project-relative path: {value}") + target = root / p + # Refuse symlink components, including the last component; never follow them for writes. + current = root + for part in p.parts: + current = current / part + if current.is_symlink(): + raise Problem("path", f"Symlink path is not a runtime destination: {value}") + if root not in target.resolve().parents: + raise Problem("path", f"Path escapes project: {value}") + return target + + +def atomic_write(path, text, durable=True): + path.parent.mkdir(parents=True, exist_ok=True) + old_mode = path.stat().st_mode & 0o777 if path.exists() else None + fd, name = tempfile.mkstemp(prefix=".session-write-", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: + f.write(text) + if durable: + f.flush() + os.fsync(f.fileno()) + if old_mode is not None: + os.chmod(name, old_mode) + os.replace(name, path) + finally: + if os.path.exists(name): + os.unlink(name) + + +@contextmanager +def file_lock(path, wait=None): + """Wait briefly for a local OS lock; the OS releases it after a crash.""" + path.parent.mkdir(parents=True, exist_ok=True) + deadline = time.monotonic() + (LOCK_WAIT if wait is None else wait) + f = open(path, "a+b") + locked = False + try: + while not locked: + try: + if os.name == "nt": + import msvcrt + if f.seek(0, 2) == 0: + f.write(b"0"); f.flush() + f.seek(0) + msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB) + locked = True + except LOCK_ERRORS as exc: + if time.monotonic() >= deadline: + raise Problem("busy", "Another runtime writer holds the local lock; retry shortly", 4) from exc + time.sleep(0.05) + yield + finally: + if locked and os.name == "nt": + import msvcrt + f.seek(0); msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1) + f.close() + + +def platform_machine_id(): + if sys.platform.startswith("linux"): + for name in ("/etc/machine-id", "/var/lib/dbus/machine-id"): + try: + value = Path(name).read_text(encoding="utf-8").strip() + except OSError: + continue + if value: + return value + elif sys.platform == "darwin": + try: + out = subprocess.run(["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"], capture_output=True, + text=True, timeout=5).stdout + except (OSError, subprocess.SubprocessError): + out = "" + match = re.search(r'"IOPlatformUUID" = "([^"]+)"', out) + if match: + return match.group(1) + elif os.name == "nt": + try: + import winreg + access = winreg.KEY_READ | getattr(winreg, "KEY_WOW64_64KEY", 0) + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography", 0, access) as key: + return str(winreg.QueryValueEx(key, "MachineGuid")[0]) + except OSError: + pass + return None + + +def machine_id(): + """Pseudonymous, stable machine identity: hostnames change with networks, hardware IDs do not.""" + raw = os.environ.get("SESSION_WORKFLOW_MACHINE_ID") or platform_machine_id() or "host:" + platform.node() + return "m-" + hashlib.sha256(("session-workflow/" + raw).encode("utf-8")).hexdigest()[:16] + + +def git(root, *args, optional=False, limit=MAX_JSON, stdin=None): + try: + result = subprocess.run(["git", "-C", str(root), *args], input=stdin, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=60, + env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"}) + except FileNotFoundError: + if optional: + return None + raise Problem("environment", "Git is unavailable", 6) + except subprocess.TimeoutExpired: + if optional: + return None + raise Problem("git", "Git did not answer within 60 seconds", 6) + if result.returncode: + if optional: + return None + raise Problem("git", result.stderr.decode("utf-8", "replace")[:1000], 6) + if len(result.stdout) > limit: + raise Problem("too_large", "Git output exceeds the bounded snapshot limit", 6) + return result.stdout + + +def state_prefix(root, state_dir): + return state_dir.relative_to(root).as_posix() if root in state_dir.parents else None + + +def under(name, prefix): + return prefix is not None and (name == prefix or name.startswith(prefix.rstrip("/") + "/")) + + +def ignore_patterns(root): + """Simple patterns from a root .gitignore, for projects that have no Git index yet.""" + try: + lines = (root / ".gitignore").read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return [] + return [line.strip().strip("/") for line in lines + if line.strip() and not line.strip().startswith(("#", "!")) and line.strip().strip("/")] + + +def skipped(relative, name, patterns): + if name in SKIP_NAMES: + return True + return any(fnmatch.fnmatch(relative if "/" in pattern else name, pattern) for pattern in patterns) + + +def walk_files(root, start, state_dir, patterns): + names = [] + for directory, dirs, files in os.walk(start, followlinks=False): + base = Path(directory) + kept = [] + for d in sorted(dirs): + full = base / d + if full != state_dir and not full.is_symlink() and not skipped(full.relative_to(root).as_posix(), d, patterns): + kept.append(d) + dirs[:] = kept + names.extend(rel for rel in ((base / f).relative_to(root).as_posix() for f in files) + if not skipped(rel, rel.rsplit("/", 1)[-1], patterns)) + if len(names) > MAX_FILES: + raise Problem("too_large", f"More than {MAX_FILES} files outside dependency/build directories; " + "run 'git init' with a .gitignore or narrow the phase paths", 6) + return names + + +def file_facts(root, names, prefix): + items, total = {}, 0 + for name in sorted(set(names)): + if under(name, prefix): + continue + if len(items) >= MAX_FILES: + raise Problem("too_large", f"More than {MAX_FILES} files to fingerprint", 6) + p = root / name + # Hash symlinks as links, without traversing targets. + if p.is_symlink(): + items[name] = {"link": os.readlink(p)} + elif not p.exists(): + items[name] = {"missing": True} + elif p.is_file(): + before = p.stat() + total += before.st_size + if total > MAX_HASH_BYTES: + raise Problem("too_large", "Changed files exceed the hash budget", 6) + content = p.read_bytes() + after = p.stat() + if (before.st_mtime_ns, before.st_size) != (after.st_mtime_ns, after.st_size): + raise Problem("workspace_changed", f"File changed during snapshot: {name}", 3) + items[name] = {"sha256": hashlib.sha256(content).hexdigest(), + "executable": bool(after.st_mode & 0o111)} + else: + # Submodules and nested repositories are recorded, not traversed. + items[name] = {"directory": True} + return items + + +def file_digest(path): + h = hashlib.sha256() + with open(path, "rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def parse_status(raw, prefix): + chunks, changes, index = raw.split(b"\0"), [], 0 + while index < len(chunks) and chunks[index]: + entry = chunks[index] + code, name = entry[:2].decode(), os.fsdecode(entry[3:]) + index += 1 + if not under(name, prefix): + changes.append({"status": code, "path": name}) + if "R" in code or "C" in code: + old = os.fsdecode(chunks[index]); index += 1 + if not under(old, prefix): + changes.append({"status": "source", "path": old}) + return changes + + +def compact_snapshot(value): + # Full inventories remain in active checkpoints and explicit --full reads. + total = value.get("total_paths", len(value["changes"])) + return {**value, "changes": value["changes"][:5], "total_paths": total, "truncated": total > 5} + + +def snapshot(root, state_dir): + # Agents and dev servers write while we read; retry a moving workspace briefly. + for attempt in range(3): + try: + return snapshot_once(root, state_dir) + except Problem as exc: + if exc.code != "workspace_changed" or attempt == 2: + raise + time.sleep(0.2) + + +def snapshot_once(root, state_dir): + prefix = state_prefix(root, state_dir) + top = git(root, "rev-parse", "--show-toplevel", optional=True) + if top is None: + try: + files = file_facts(root, walk_files(root, root, state_dir, ignore_patterns(root)), prefix) + except Problem as exc: + if exc.code != "too_large": + raise + return {"kind": "files", "head": None, "branch": None, "changes": [], "fingerprint": None, + "limited": True, "coverage": "not fingerprinted: " + str(exc)} + return {"kind": "files", "head": None, "branch": None, "changes": sorted(files), + "fingerprint": digest(files), + "coverage": "regular files; dependency/build directories and simple .gitignore patterns skipped"} + if Path(os.fsdecode(top).strip()).resolve() != root: + raise Problem("workspace", "Use --root at the Git repository root", 6) + head_raw = git(root, "rev-parse", "--verify", "HEAD", optional=True) + head = head_raw.decode().strip() if head_raw else None + branch_raw = git(root, "symbolic-ref", "--short", "HEAD", optional=True) + branch = branch_raw.decode().strip() if branch_raw else None + status = git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all", limit=MAX_GIT_OUTPUT) + changes = parse_status(status, prefix) + staged = git(root, "diff", "--cached", "--raw", "-z", "--no-renames", "--no-abbrev", + optional=True, limit=MAX_GIT_OUTPUT) + if staged is None: + staged = git(root, "ls-files", "--stage", "-z", limit=MAX_GIT_OUTPUT) + basis = {"head": head, "status": hashlib.sha256(status).hexdigest(), + "staged": hashlib.sha256(staged).hexdigest()} + try: + if len(changes) > MAX_FILES: + raise Problem("too_large", f"{len(changes)} changed or untracked paths", 6) + files = file_facts(root, [x["path"] for x in changes], prefix) + except Problem as exc: + if exc.code != "too_large": + raise + return {"kind": "git", "head": head, "branch": branch, "changes": changes[:20], + "total_paths": len(changes), "fingerprint": digest(basis), "limited": True, + "coverage": "status only, contents not hashed: " + str(exc)} + if status != git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all", limit=MAX_GIT_OUTPUT): + raise Problem("workspace_changed", "Git status changed during snapshot; retry the read", 3) + return {"kind": "git", "head": head, "branch": branch, "changes": changes, + "fingerprint": digest({**basis, "files": files}), + "coverage": "Git index and nonignored changed/untracked files"} + + +def snapshot_warnings(live): + if not live or not live.get("limited"): + return [] + if live["kind"] == "git": + return [f"{live.get('total_paths')} changed or untracked paths: add generated or dependency " + "directories (for example node_modules/) to .gitignore; drift detection is reduced"] + return ["Workspace too large to fingerprint without Git: run 'git init' with a .gitignore; " + "drift detection is off"] + + +def blob_hashes(root, names): + """Git blob IDs after clean filters, so unchanged content matches its index entry.""" + if not names: + return [] + if any("\n" in n or "\r" in n for n in names): + result = [] + for name in names: + content = (root / name).read_bytes() + result.append(hashlib.sha1(b"blob %d\0" % len(content) + content).hexdigest()) + return result + out = git(root, "hash-object", "--stdin-paths", stdin=b"\n".join(os.fsencode(n) for n in names) + b"\n", + limit=MAX_GIT_OUTPUT).decode().split() + if len(out) != len(names): + raise Problem("git", "Unexpected git hash-object output", 6) + return out + + +def scope_inventory(store, state, p): + """Content identities of non-ignored files in a phase scope. Commits alone do not change it.""" + root, prefix = store.root, state_prefix(store.root, store.directory) + whole = "*" in p["paths"] + skip = list(p.get("exclude", [])) + if whole: + # Narrative documents change at every milestone; they are not product inputs. + skip += [value for value in state["documents"].values() if value] + if store.is_git: + spec = [] if whole else ["--", *p["paths"]] + inventory, changed = {}, set() + listed = git(root, "--literal-pathspecs", "ls-files", "-s", "-z", *spec, limit=MAX_GIT_OUTPUT) + for entry in listed.split(b"\0"): + if entry: + meta, raw_name = entry.split(b"\t", 1) + mode, sha, stage = meta.decode().split() + name = os.fsdecode(raw_name) + if stage == "0": + inventory[name] = mode + ":" + sha + else: + changed.add(name) + status = git(root, "--literal-pathspecs", "status", "--porcelain=v1", "-z", "--untracked-files=all", + *spec, limit=MAX_GIT_OUTPUT) + changed.update(x["path"] for x in parse_status(status, prefix)) + hashable = [] + for name in sorted(changed): + target = root / name + if target.is_symlink(): + inventory[name] = "link:" + os.readlink(target) + elif target.is_file(): + hashable.append(name) + elif not target.exists(): + inventory.pop(name, None) + else: + inventory[name] = "directory" + if len(hashable) > MAX_FILES: + raise Problem("too_large", f"{len(hashable)} changed or untracked files in the phase scope; add " + "generated directories to .gitignore or narrow the phase paths", 6) + for name, sha in zip(hashable, blob_hashes(root, hashable)): + inventory[name] = ("100755" if os.access(root / name, os.X_OK) else "100644") + ":" + sha + else: + patterns, names = ignore_patterns(root), [] + for value in (["*"] if whole else p["paths"]): + target = root if value == "*" else relative_path(root, value) + if target.is_dir() and not target.is_symlink(): + names += walk_files(root, target, store.directory, patterns) + elif target.exists() or target.is_symlink(): + names.append(target.relative_to(root).as_posix()) + inventory = file_facts(root, names, prefix) + return {name: value for name, value in inventory.items() + if not under(name, prefix) and not any(under(name, s) for s in skip)} + + +def phase_fingerprint(store, state, p, inventory=None): + if inventory is None: + inventory = scope_inventory(store, state, p) + return digest({"inputs": inventory, "paths": p["paths"], "exclude": p.get("exclude", []), + "acceptance": p["acceptance"], "required_checks": p["required_checks"]}) + + +def check_plan(phases): + by_id = {p["id"]: p for p in phases} + if len(by_id) != len(phases): + raise Problem("plan", "Duplicate phase ID") + pending = {pid: set(p["depends_on"]) for pid, p in by_id.items()} + for pid, deps in pending.items(): + if deps - by_id.keys(): + raise Problem("plan", f"Unknown dependencies in {pid}: {sorted(deps - by_id.keys())}") + while pending: + available = {pid for pid, deps in pending.items() if not deps} + if not available: + raise Problem("plan", "Dependency cycle") + pending = {pid: deps - available for pid, deps in pending.items() if pid not in available} + for p in phases: + for value in p["paths"] + p.get("exclude", []): + path = Path(value) + if value != "*" and (path.is_absolute() or ".." in path.parts or + any(c in value for c in "*?[]") or not path.parts): + raise Problem("plan", "Phase paths must be relative file/directory names, or '*'") + if "*" in p.get("exclude", []): + raise Problem("plan", "exclude lists generated files or directories, not '*'") + + +def phase(state, pid): + try: + return next(p for p in state["phases"] if p["id"] == pid) + except StopIteration: + raise Problem("phase", "Unknown phase: " + pid) + + +def paths_overlap(left, right): + for a in left: + for b in right: + a, b = a.rstrip("/").casefold(), b.rstrip("/").casefold() + if a == "*" or b == "*" or a == b or a.startswith(b + "/") or b.startswith(a + "/"): + return True + return False + + +def claim_conflicts(state, candidate, session_id): + actor = state["sessions"][session_id] + conflicts = [] + for p in state["phases"]: + owner = p["owner"] + if owner and owner != session_id: + other = state["sessions"][owner] + same_checkout = actor["machine"] == other["machine"] and actor["root"] == other["root"] + if p["id"] == candidate["id"] or same_checkout and paths_overlap(candidate["paths"], p["paths"]): + conflicts.append({"phase": p["id"], "owner": owner, "paths": p["paths"]}) + return conflicts + + +def dependency_ids(state, p): + seen, stack = [], list(p["depends_on"]) + while stack: + pid = stack.pop() + if pid not in seen: + seen.append(pid) + stack.extend(phase(state, pid)["depends_on"]) + return seen + + +def ready(state, session_id=None): + done = {p["id"] for p in state["phases"] if p["state"] == "DONE"} + candidates = [p for p in state["phases"] if p["state"] in ("TODO", "IN_PROGRESS", "VERIFY") + and set(dependency_ids(state, p)) <= done + and (session_id is None or not claim_conflicts(state, p, session_id))] + return sorted(candidates, key=lambda p: ({"IN_PROGRESS": 0, "VERIFY": 1, "TODO": 2}[p["state"]], + p["priority"], p["id"])) + + +def prune_checks(state): + # Keep recent history per check name; older logs are superseded evidence. + groups = {} + for check in state["checks"].values(): + if check["result"] != "running": + groups.setdefault((check["phase"], check["name"]), []).append(check) + removed = [] + for records in groups.values(): + records.sort(key=lambda c: c["sequence"]) + for old in records[:-MAX_CHECK_HISTORY]: + del state["checks"][old["id"]] + if old["provenance"] == "executed": + removed.append(old["evidence"]) + return removed + + +def migrate_v1(state, store): + """One-way upgrade from schema 1. The original file is kept as state.v1.json on the next save.""" + here = platform.node() + + def machine_of(host): + return store.machine if host == here else "legacy-" + hashlib.sha256(host.encode("utf-8")).hexdigest()[:12] + try: + for s in state["sessions"].values(): + s["machine"] = machine_of(s["host"]) + s["parallel"] = False + s["ended_reason"] = "" if s["status"] == "active" else "ended" + for p in state["phases"]: + p.setdefault("exclude", []) + for record in state["processes"].values(): + record["machine"] = machine_of(record.pop("host")) + dropped = sorted(op["id"] for op in state.pop("operations").values() + if op["status"] in ("pending", "outcome-unknown", "prepared", "known-not-applied")) + state.pop("source_key") + state["events"] = state["events"][-MAX_EVENTS:] + except (KeyError, TypeError, AttributeError) as exc: + raise Problem("state", f"Cannot upgrade schema 1 state ({exc}); it was not modified") + state["schema_version"] = SCHEMA_VERSION + note = "Upgraded state schema 1 to 2; the operation ledger was removed" + if dropped: + note += "; unresolved operations to check manually: " + ", ".join(dropped) + return state, note + + +class Store: + def __init__(self, root, directory=None): + self.root = Path(root).expanduser().resolve() + if not self.root.is_dir(): + raise Problem("workspace", "Workspace does not exist", 6) + self.machine = machine_id() + common = git(self.root, "rev-parse", "--git-common-dir", optional=True) + self.is_git = common is not None + self.main = self.root + if directory is not None: + path = Path(directory).expanduser() + self.directory = path.resolve() if path.is_absolute() else relative_path(self.root, directory) + else: + # Git lists the main checkout first; linked worktrees use its one coordinator. + worktrees = git(self.root, "worktree", "list", "--porcelain", "-z", optional=True) if common else None + if worktrees: + self.main = Path(os.fsdecode(worktrees.split(b"\0")[0][9:])).resolve() + self.directory = relative_path(self.main, ".agent-state/session-workflow") + self.path = self.directory / "state.json" + self.migration = None + + def load(self): + if not self.path.exists(): + raise Problem("not_initialized", "No session state yet; run begin first") + if self.path.is_symlink(): + raise Problem("path", "State must not be a symlink") + state = read_json(self.path) + if not isinstance(state, dict) or state.get("schema_version") not in (1, SCHEMA_VERSION): + raise Problem("version", "Unsupported state schema; it was not modified") + if state["schema_version"] == 1: + state, self.migration = migrate_v1(state, self) + validate(state, "state") + check_plan(state["phases"]) + for p in state["phases"]: + if p["owner"] and (p["owner"] not in state["sessions"] or + state["sessions"][p["owner"]]["status"] != "active"): + raise Problem("state", "Phase owner must identify an active session") + return state + + def save(self, state, event): + state["revision"] += 1 + state["updated_at"] = now() + if self.migration: + state["events"].append({"revision": state["revision"], "at": state["updated_at"], "event": self.migration}) + state["events"].append({"revision": state["revision"], "at": state["updated_at"], "event": event}) + state["events"] = state["events"][-MAX_EVENTS:] + stale_logs = prune_checks(state) + validate(state, "state") + text = json.dumps(state, ensure_ascii=True, sort_keys=True, indent=2, allow_nan=False) + "\n" + if len(text.encode()) > MAX_JSON: + raise Problem("too_large", "State is full; defer or remove finished phases before extending the plan") + if self.path.exists(): + previous = self.path.read_text(encoding="utf-8") + if self.migration and not (self.directory / "state.v1.json").exists(): + atomic_write(self.directory / "state.v1.json", previous) + atomic_write(self.directory / "state.previous.json", previous) + atomic_write(self.path, text) + self.migration = None + for name in stale_logs: + try: + (self.directory / name).unlink() + except OSError: + pass + ensure_ignored(self) + + def expect(self, state, revision, required=False): + if revision is None: + if required: + raise Problem("arguments", "--expect-revision is required for this command") + return + if state["revision"] != revision: + raise Problem("stale_revision", f"Expected revision {revision}; current is {state['revision']}. Read status and reconcile.", 3) + + def observe(self): + return snapshot(self.root, self.directory) + + +def ensure_ignored(store): + """Keep runtime logs and machine facts out of commits unless the project already versions them.""" + marker = store.directory / ".gitignore" + if marker.exists() or store.main not in store.directory.parents: + return + if store.is_git: + tracked = git(store.main, "ls-files", "-z", "--", store.directory.relative_to(store.main).as_posix(), + optional=True) + if tracked is None or tracked: + return + atomic_write(marker, "# estafeta runtime state: local logs and machine facts.\n*\n") + + +def location(s, store): + if s["machine"] != store.machine: + return "other machine" + if s["root"] == str(store.root): + return "this checkout" + return "other checkout" if Path(s["root"]).exists() else "missing checkout" + + +def session(state, store, session_id, require_active=True): + actor = state["sessions"].get(session_id) + if not actor: + raise Problem("session", "Unknown session ID; begin a new session") + if require_active and actor["status"] != "active": + raise Problem("session_ended", f"Session {session_id} has ended ({actor['ended_reason']}). If another chat " + "took over, stop editing and tell the user; to keep working alongside it, begin a new " + "session with --parallel.", 3) + if actor["machine"] != store.machine or actor["root"] != str(store.root): + raise Problem("session_foreign", "This session belongs to another machine or checkout path; begin a new " + "session here and release the old one once the user confirms it stopped", 3) + return actor + + +def brief(state, store, session_id=None, live=None): + actor = state["sessions"].get(session_id) or {} + checkpoint = actor.get("checkpoint") or {} + others, ended = [], 0 + for sid, s in sorted(state["sessions"].items()): + if sid == session_id: + continue + if s["status"] != "active": + ended += 1 + continue + others.append({"id": sid, "where": location(s, store), "parallel": s["parallel"], + "claims": [p["id"] for p in state["phases"] if p["owner"] == sid], + "next_action": s["checkpoint"].get("next_action", "")}) + recorded = (checkpoint.get("workspace") or {}).get("fingerprint") + observed = (live or {}).get("fingerprint") + result = {"project_id": state["project_id"], "revision": state["revision"], "session_id": session_id, + "next_action": checkpoint.get("next_action", ""), "checkpoint": checkpoint.get("id"), + "documents": state["documents"], "phase_count": len(state["phases"]), + "phases": [{"id": p["id"], "state": p["state"], "owner": p["owner"]} for p in state["phases"][:50]], + "ready": [p["id"] for p in ready(state, session_id if actor.get("status") == "active" else None)[:20]], + "other_active_sessions": others, "ended_sessions": ended, + "workspace_changed": None if recorded is None or observed is None else recorded != observed, + "live_workspace": compact_snapshot(live) if live is not None else None} + if actor and actor["status"] != "active": + result["session_status"] = "ended: " + actor["ended_reason"] + delegations = relay_open(store) + if delegations: + result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], + "status": r["status"], "orchestrator": r["session_id"]} for r in delegations[:20]] + warnings = snapshot_warnings(live) + if warnings: + result["warnings"] = warnings + return result + + +def initial_state(store, args): + for value in (args.context, args.plan, args.handover): + if value: + relative_path(store.root, value) + return {"schema_version": SCHEMA_VERSION, "project_id": uuid.uuid4().hex, + "revision": 0, "created_at": now(), "updated_at": now(), + "documents": {"context": args.context, "plan": args.plan, "handover": args.handover}, + "sessions": {}, "phases": [], "checks": {}, "processes": {}, "events": []} + + +def new_checkpoint(store, state, session_id, next_action, note="", workspace=None): + state["sessions"][session_id]["checkpoint"] = { + "id": f"{session_id}-{state['revision'] + 1}", "at": now(), "next_action": next_action, "note": note, + "workspace": workspace if workspace is not None else store.observe()} + + +def compact_checkpoint(s): + # Ended sessions keep their resume point and fingerprint, not a full path inventory. + workspace = s["checkpoint"].get("workspace") + if workspace and len(workspace.get("changes", [])) > 5: + s["checkpoint"]["workspace"] = compact_snapshot(workspace) + + +def live_checks(store, state, session_id): + """A held runner lock proves that a session's check is still executing.""" + alive = [] + for check in state["checks"].values(): + if check["session_id"] == session_id and check["result"] == "running": + try: + with file_lock(store.directory / "checks" / (check["id"] + ".lock"), wait=0): + pass + except Problem: + alive.append(check["id"]) + return alive + + +def retire(store, state, session_id, reason, successor=None): + s = state["sessions"][session_id] + s.update(status="ended", ended_at=now(), ended_reason=reason) + compact_checkpoint(s) + transferred, released, runtimes = [], [], [] + for p in state["phases"]: + if p["owner"] == session_id: + p["owner"] = "" + if successor and not claim_conflicts(state, p, successor): + p["owner"] = successor + transferred.append(p["id"]) + else: + released.append(p["id"]) + for check in state["checks"].values(): + if check["session_id"] == session_id and check["result"] == "running": + check["result"] = "abandoned" + check["evidence"] += "; " + reason + if successor: + for key, record in list(state["processes"].items()): + name = key.split("/", 1)[1] + if record["session_id"] == session_id and record["machine"] == store.machine \ + and successor + "/" + name not in state["processes"]: + record["session_id"] = successor + state["processes"][successor + "/" + name] = state["processes"].pop(key) + runtimes.append(name) + return {"transferred": transferred, "released": released, "runtimes": runtimes} + + +def prior_checkpoint(state, store): + """The most relevant previous resume point: this checkout, then a moved one, then other machines.""" + tiers = {"this checkout": 0, "missing checkout": 1, "other machine": 2, "other checkout": 3} + best = None + for sid, s in state["sessions"].items(): + if s["checkpoint"]: + key = (-tiers[location(s, store)], s["checkpoint"].get("at", "")) + if best is None or key > best[0]: + best = (key, sid, s) + return best + + +def begin(store, args): + with file_lock(store.directory / ".lock"): + state = store.load() if store.path.exists() else initial_state(store, args) + session_id = args.session or "s-" + uuid.uuid4().hex[:8] + validate(session_id, "id") + if session_id in state["sessions"]: + raise Problem("session_exists", "Session ID already used; omit --session to get a new unique ID", 3) + live = store.observe() + prior = prior_checkpoint(state, store) + here = sorted(sid for sid, s in state["sessions"].items() + if s["status"] == "active" and location(s, store) == "this checkout") + # One --parallel puts the checkout in parallel mode until every session here has ended or + # been released: nobody here is taken over, and new sessions join instead of superseding. + parallel_here = [sid for sid in here if state["sessions"][sid]["parallel"]] + joined = bool(parallel_here) and not args.parallel + if args.parallel: + for sid in here: + state["sessions"][sid]["parallel"] = True + state["sessions"][session_id] = {"status": "active", "machine": store.machine, "host": platform.node(), + "root": str(store.root), "started_at": now(), "ended_at": "", "ended_reason": "", + "parallel": args.parallel or joined, "checkpoint": {}} + superseded = [] + if not args.parallel and not joined: + # Solo mode: one writer per checkout, so earlier non-parallel chats here have stopped. + for sid, s in sorted(state["sessions"].items()): + if sid == session_id or s["status"] != "active" or s["parallel"] or \ + location(s, store) not in ("this checkout", "missing checkout"): + continue + alive = live_checks(store, state, sid) + if alive: + raise Problem("session_alive", f"Session {sid} is running check {alive[0]} right now. Wait " + "for it, or begin with --parallel to work alongside it.", 3) + superseded.append({"id": sid, **retire(store, state, sid, "superseded by " + session_id, session_id)}) + next_action = args.next_action or (prior[2]["checkpoint"].get("next_action", "") if prior else "") or \ + "Inspect the plan and choose a ready phase" + new_checkpoint(store, state, session_id, next_action, workspace=live) + store.save(state, "begin:" + session_id) + for item in superseded: + relay_transfer(store, item["id"], session_id) + result = brief(state, store, session_id, live) + if superseded: + result["superseded"] = superseded + if joined: + result["warnings"] = result.get("warnings", []) + [ + "Parallel work is in progress in this checkout (" + ", ".join(parallel_here) + "); this session " + "joined it and took over nothing. Claim paths no one else claims; release a session only after " + "the user confirms its chat stopped."] + if prior: + _, sid, s = prior + recorded = (s["checkpoint"].get("workspace") or {}).get("fingerprint") + result["prior_checkpoint"] = {"id": s["checkpoint"].get("id"), "session": sid, "where": location(s, store), + "next_action": s["checkpoint"].get("next_action", ""), "note": s["checkpoint"].get("note", ""), + "workspace_changed": None if recorded is None or live.get("fingerprint") is None + else recorded != live["fingerprint"]} + return state, result + + +def verification_gaps(store, state, p): + """Explain why fresh evidence does not cover a phase; an empty list satisfies the DONE gate.""" + try: + fingerprint = phase_fingerprint(store, state, p) + except Problem as exc: + return ["phase inputs cannot be fingerprinted: " + str(exc)] + latest = {} + for c in sorted((c for c in state["checks"].values() if c["phase"] == p["id"]), key=lambda c: c["sequence"]): + latest[c["name"]] = c + + def gap(c): + if c["result"] != "pass": + return "latest result is " + c["result"] + if not c["stable_inputs"]: + changed = ", ".join(c.get("changed_during_check") or []) or "unknown files" + return ("the check modified files in the phase scope while running (" + changed + "); list generated " + "outputs in the phase 'exclude' or .gitignore, then run it again") + if c["fingerprint"] != fingerprint: + return "phase files or contract changed after this check; run it again" + if c["provenance"] == "executed": + log = store.directory / c["evidence"] + if not log.is_file() or file_digest(log) != c["evidence_sha256"]: + return "evidence log is missing or was modified" + return None + gaps = [] + for name in p["required_checks"]: + c = latest.get(name) + if c is None: + gaps.append(name + ": not run yet") + elif c["provenance"] != "executed": + gaps.append(name + ": needs an executed check; reported evidence cannot replace it") + elif gap(c): + gaps.append(name + ": " + gap(c)) + if not p["required_checks"] and not any(gap(c) is None for c in latest.values()): + gaps.append("no fresh passing check; use 'check run', or 'check record' for manual evidence") + return gaps + + +def facts_markdown(store, state, live): + def cell(value): + return str(value).replace("|", "\\|").replace("\r", " ").replace("\n", " ").replace("<", "<") + latest = {} + for check in sorted(state["checks"].values(), key=lambda item: item["sequence"]): + latest[(check["phase"], check["name"])] = check + rows = [] + for p in state["phases"]: + items = [] + try: + fingerprint = phase_fingerprint(store, state, p) if any(k[0] == p["id"] for k in latest) else None + except Problem: + fingerprint = None + for (pid, name), check in latest.items(): + if pid == p["id"]: + fresh = "inputs match" if check["fingerprint"] == fingerprint else \ + "inputs unknown" if fingerprint is None else "inputs changed" + items.append(f"{name}: {check['result']} ({check['provenance']}, {fresh})") + rows.append(f"| {cell(p['id'])} | {p['state']} | {cell(p['owner'] or '-')} | {cell('; '.join(items) or '-')} |") + active = [(sid, s) for sid, s in sorted(state["sessions"].items()) if s["status"] == "active"] + ended = [(sid, s) for sid, s in state["sessions"].items() if s["status"] != "active"] + lines = [BEGIN, "## Session facts (generated)", "", + f"State revision {state['revision']}; HEAD {cell(live.get('head') or 'non-Git/unborn')}.", "", + "| Phase | State | Owner | Latest checks |", "| --- | --- | --- | --- |", *rows, "", + "Active sessions: " + ("; ".join(f"{cell(sid)} (next: {cell(s['checkpoint'].get('next_action', ''))})" + for sid, s in active) or "none") + "."] + delegations = relay_open(store) + if delegations: + lines.append("Open delegations: " + "; ".join(f"{cell(r['name'])} ({cell(r['provider'])} {r['mode']}, " + f"{r['status']})" for r in delegations[:10]) + ".") + if ended: + sid, s = max(ended, key=lambda item: item[1]["ended_at"]) + lines.append(f"Ended sessions: {len(ended)}; latest {cell(sid)} " + f"(next: {cell(s['checkpoint'].get('next_action', ''))}).") + lines += ["", "Generated facts do not establish acceptance, explain intent or prove delivery.", END] + return "\n".join(lines) + "\n" + + +def render_target(store, output): + target = relative_path(store.root, output) + if target == store.path or target.name in ("state.previous.json", "state.v1.json", ".lock"): + raise Problem("path", "Cannot render over runtime state") + text = target.read_text(encoding="utf-8") if target.exists() else "" + if BEGIN in text or END in text: + if text.count(BEGIN) != 1 or text.count(END) != 1 or text.index(BEGIN) > text.index(END): + raise Problem("markers", "Ambiguous generated markers; preserve file and fix the boundary") + return target, text + + +def render(store, state, output): + target, text = render_target(store, output) + block = facts_markdown(store, state, store.observe()) + if BEGIN in text or END in text: + updated = text[:text.index(BEGIN)] + block.rstrip("\n") + text[text.index(END) + len(END):] + else: + updated = text + ("\n\n" if text and not text.endswith("\n\n") else "") + block + if updated != text: + atomic_write(target, updated) + return {"output": output, "changed": updated != text} + + +def group_alive(pgid): + if os.name == "nt": + return False + try: + os.killpg(pgid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + +def terminate_group(child, grace=4.0): + """Stop the direct child and every descendant in the process group it leads.""" + if os.name == "nt": + if child.poll() is None: + subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) + return + try: + os.killpg(child.pid, signal.SIGTERM) + except ProcessLookupError: + return + deadline = time.monotonic() + grace + while time.monotonic() < deadline: + child.poll() + if not group_alive(child.pid): + return + time.sleep(0.1) + try: + os.killpg(child.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def group_kwargs(): + return {"start_new_session": True} if os.name != "nt" else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} + + +class Interrupted(Exception): + pass + + +@contextmanager +def interrupt_guard(): + """Turn SIGTERM/SIGHUP into Interrupted while a check runs, so its separate process group is not orphaned.""" + names = [n for n in ("SIGTERM", "SIGHUP") if hasattr(signal, n)] + if threading.current_thread() is not threading.main_thread(): + yield + return + def handler(signum, frame): + raise Interrupted() + previous = {n: signal.signal(getattr(signal, n), handler) for n in names} + try: + yield + finally: + for n, h in previous.items(): + signal.signal(getattr(signal, n), h) + + +def run_check(store, state, p, args, run_id, command, before): + log = store.directory / "checks" / (run_id + ".log") + log.parent.mkdir(parents=True, exist_ok=True) + started = time.monotonic() + with open(log, "wb") as output: + try: + child = subprocess.Popen(command, cwd=store.root, stdout=output, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) + try: + with interrupt_guard(): + code = child.wait(timeout=args.timeout) + outcome = "pass" if code == 0 else "fail" + except subprocess.TimeoutExpired: + terminate_group(child) + child.wait() + code, outcome = None, "timeout" + except Interrupted: + # The caller was stopped (for example a timed-out delegated worker): stop the check's own group too, + # with a short grace so this finishes well inside the supervisor's longer relay grace period. + terminate_group(child, grace=1.0) + child.wait() + output.write(b"\n[session-workflow] check stopped because its caller was terminated\n") + code, outcome = None, "abandoned" + except OSError as exc: + output.write(str(exc).encode("utf-8", "replace")) + code, outcome = None, "error" + after = scope_inventory(store, state, p) + changed = sorted(name for name in set(before) | set(after) if before.get(name) != after.get(name)) + return {"id": run_id, "phase": p["id"], "name": args.name, "result": outcome, + "provenance": "executed", "at": now(), "fingerprint": phase_fingerprint(store, state, p, after), + "stable_inputs": not changed, "changed_during_check": changed[:10], "exit_code": code, + "elapsed_seconds": round(time.monotonic() - started, 3), + "session_id": args.session, "root": str(store.root), "sequence": 0, + "evidence": log.relative_to(store.directory).as_posix(), "evidence_sha256": file_digest(log)} + + +def runtime_view(store, record): + handle = SUPERVISORS.get(record["run_id"]) + if handle is not None and handle.poll() is not None: + del SUPERVISORS[record["run_id"]] + if record["machine"] != store.machine: + return {"status": "foreign", "reason": "Launched on another machine; no remote process action"} + path = relative_path(store.directory, record["directory"]) / "receipt.json" + if not path.is_file(): + return {"status": "unknown", "reason": "No supervisor receipt"} + try: + value = read_json(path) + valid = isinstance(value, dict) and value.get("status") in ("running", "exited", "unknown") + if not valid or value["status"] == "running" and not isinstance(value.get("heartbeat"), (int, float)): + raise ValueError("Invalid supervisor receipt") + except (Problem, OSError, ValueError): + return {"status": "unknown", "reason": "Invalid or unreadable supervisor receipt; no PID fallback"} + if value.get("run_id") != record["run_id"]: + return {"status": "unknown", "reason": "Supervisor identity mismatch"} + if value["status"] == "running" and time.time() - value.get("heartbeat", 0) > HEARTBEAT_EXPIRY: + return {"status": "unknown", "reason": "Supervisor heartbeat expired; never fall back to PID kill"} + return value + + +def stop_runtime(store, record): + status = runtime_view(store, record) + if status["status"] != "running": + return status + directory = relative_path(store.directory, record["directory"]) + atomic_write(directory / "stop.json", canonical({"run_id": record["run_id"]}) + "\n") + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + status = runtime_view(store, record) + if status["status"] != "running": + handle = SUPERVISORS.pop(record["run_id"], None) + if handle is not None: + try: + handle.wait(timeout=1) + except subprocess.TimeoutExpired: + SUPERVISORS[record["run_id"]] = handle + return status + time.sleep(0.1) + return {"status": "unknown", "reason": "Stop receipt unavailable; process was not killed by PID"} + + +def supervise(spec_path): + """Private supervisor: owns one process group through a live Popen handle; accepts no PID.""" + spec_path = Path(spec_path).resolve() + spec, child = read_json(spec_path), None + directory = spec_path.parent + receipt_path = directory / "receipt.json" + try: + # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + with open(directory / "output.log", "ab") as output, \ + open(directory / spec.get("stderr", "output.log"), "ab") as errors: + child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, + stderr=errors if spec.get("stderr") else subprocess.STDOUT, + stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) + stop, beat = directory / "stop.json", 0.0 + started, limit, timed_out = time.monotonic(), spec.get("timeout"), False + # Descendants keep the group alive after the leader exits; supervise until all are gone. + while child.poll() is None or group_alive(child.pid): + if time.monotonic() - beat >= HEARTBEAT_SECONDS: + atomic_write(receipt_path, canonical({"run_id": spec["run_id"], "status": "running", + "heartbeat": time.time(), "pid": child.pid, + "leader_exited": child.returncode is not None}) + "\n", durable=False) + beat = time.monotonic() + if stop.exists() and read_json(stop).get("run_id") == spec["run_id"]: + terminate_group(child, spec.get("grace", 4.0)) + break + if limit and time.monotonic() - started > limit: + terminate_group(child, spec.get("grace", 4.0)) + timed_out = True + break + time.sleep(0.2) + child.poll() + receipt = {"run_id": spec["run_id"], "status": "exited", "exit_code": child.returncode, + "scope": "supervised process group" if os.name != "nt" else "supervised process tree"} + if timed_out: + receipt["timed_out"] = True + except Exception as exc: + receipt = {"run_id": spec["run_id"], "status": "unknown", "reason": str(exc)[:500]} + atomic_write(receipt_path, canonical(receipt) + "\n") + + +def launch_runtime(store, state, session_id, name, command, keep=False, cwd=None, timeout=None, run_id=None, + split_stderr=False): + key = session_id + "/" + name + if key in state["processes"]: + raise Problem("runtime", "Runtime name already recorded; inspect it before creating a new name") + run_id = run_id or uuid.uuid4().hex + directory = store.directory / "runtime" / run_id + directory.mkdir(parents=True) + spec = {"run_id": run_id, "argv": command, "root": str(cwd or store.root)} + if timeout: + spec["timeout"] = timeout + if split_stderr: + # A delegated worker: its nested check runners need time to stop their own groups first. + spec.update(stderr="stderr.log", grace=10.0) + spec_path = directory / "spec.json" + atomic_write(spec_path, canonical(spec) + "\n") + record = {"run_id": run_id, "session_id": session_id, "machine": store.machine, + "root": str(store.root), "directory": directory.relative_to(store.directory).as_posix(), + "keep": keep} + # Persist identity before launch; a crash cannot produce an unrecorded child. + state["processes"][key] = record + store.save(state, "runtime-prepared:" + name) + SUPERVISORS[run_id] = subprocess.Popen( + [sys.executable, str(Path(__file__).resolve()), "_supervise", str(spec_path)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **group_kwargs()) + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + status = runtime_view(store, record) + if status["status"] != "unknown": + return {"name": name, **status} + time.sleep(0.05) + return {"name": name, **runtime_view(store, record)} + + +# --- Delegation ledger ------------------------------------------------------- +# One record per delegated task under /delegations//. It lives outside +# state.json so schema 2 is unchanged and a 3.0 runtime can still join as a worker. +RELAY_OPEN, RELAY_CLOSE = "<<>>" +RELAY_OPEN_STATES = ("running", "malformed") +RELAY_OUTCOMES = ("completed", "blocked", "failed") +PROVIDER_CLIS = ("claude", "codex", "gemini") +MAX_RELAY_TEXT = 4 * 1024 * 1024 + + +def relay_dir(store, name=""): + base = store.directory / "delegations" + return base / name if name else base + + +def relay_load(store, name): + path = relay_dir(store, name) / "record.json" + if not path.is_file(): + raise Problem("delegation", "Unknown delegation name; list them with delegate status") + return read_json(path) + + +def relay_save(store, record): + atomic_write(relay_dir(store, record["name"]) / "record.json", canonical(record) + "\n") + + +def relay_records(store): + base = relay_dir(store) + records = [] + if base.is_dir(): + for directory in sorted(base.iterdir()): + try: + records.append(read_json(directory / "record.json")) + except (Problem, OSError): + continue + return records + + +def relay_owned(store, state, args): + """The caller's record. A record whose orchestrator has ended is adopted together with its CLI process.""" + record = relay_load(store, require(args.name, "--name")) + owner, adopted = record["session_id"], False + if owner != args.session: + if state["sessions"].get(owner, {}).get("status") == "active": + raise Problem("ownership", f"Delegation {record['name']} belongs to active session {owner}", 3) + record["adopted_from"], record["session_id"] = owner, args.session + key, process = relay_process(state, record) + if process and process["session_id"] != args.session and \ + state["sessions"].get(process["session_id"], {}).get("status") != "active": + new_key = args.session + "/" + key.split("/", 1)[1] + if new_key in state["processes"]: + raise Problem("runtime", "This session already has a runtime with that name", 3) + process["session_id"] = args.session + state["processes"][new_key] = state["processes"].pop(key) + adopted = True + return record, adopted + + +def relay_transfer(store, retired, successor): + """Called after the superseding begin was saved; a crash before it leaves adoption as the fallback.""" + for record in relay_records(store): + if record["session_id"] == retired and record["status"] in RELAY_OPEN_STATES: + record["session_id"] = successor + relay_save(store, record) + + +def relay_process(state, record): + for key, process in state["processes"].items(): + if record.get("run_id") and process["run_id"] == record["run_id"]: + return key, process + return None, None + + +def relay_runtime(store, state, record): + _, process = relay_process(state, record) + return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + + +def relay_providers(): + found = [] + for name in PROVIDER_CLIS: + path = shutil.which(name) + item = {"provider": name, "cli": path or "", "version": ""} + if path: + try: + done = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=15, + stdin=subprocess.DEVNULL) + item["version"] = (done.stdout or done.stderr).strip().splitlines()[0][:200] if \ + (done.stdout or done.stderr).strip() else "" + except (OSError, subprocess.TimeoutExpired): + item["version"] = "unknown" + found.append(item) + return {"cli": found, "note": "Host-native subagents (for example T3 delegate_task) are discovered by the agent, " + "not by this runtime"} + + +def relay_start(store, state, args): + name = require(args.name, "--name") + validate(name, "id") + validate(require(args.provider, "--provider"), "id") + mode = require(args.mode, "--mode") + cli = args.inline is not None or bool(args.argv_file) + if relay_dir(store, name).exists(): + raise Problem("delegation", "Delegation name already used; choose a new name", 3) + packet = Path(require(args.packet, "--packet")).expanduser() + text = packet.read_text(encoding="utf-8") + if not text.strip(): + raise Problem("arguments", "The packet is empty") + workspace = Path(args.workspace).expanduser().resolve() if args.workspace else store.root + if not workspace.is_dir(): + raise Problem("workspace", "The worker workspace does not exist") + if mode == "write": + worker = require(args.worker_session, "--worker-session") + validate(worker, "id") + p = phase(state, require(args.phase, "--phase")) + if p["state"] == "DONE": + raise Problem("transition", f"Phase {p['id']} is DONE; reopen it before delegating", 3) + # A host task can start, and its worker begin, before the orchestrator records it. + started = state["sessions"].get(worker) + if p["owner"] and p["owner"] != worker: + raise Problem("ownership", f"Phase {p['id']} is claimed by {p['owner']}; the worker must claim it. " + "Release it first with a transition to TODO", 3) + if worker == args.session or started and (cli or not started["parallel"] or + started["started_at"] < record_floor(state, args.session)): + raise Problem("session_exists", "The worker session ID is already used; choose a new one", 3) + if Store(workspace).directory != store.directory: + raise Problem("workspace", "A writer's workspace must share this coordinator: this checkout or a " + "linked Git worktree of it", 3) + elif args.phase: + phase(state, args.phase) + if not cli and args.timeout: + raise Problem("arguments", "--timeout applies only to a CLI worker command") + if args.timeout is not None and not 0 < args.timeout <= 86400: + raise Problem("arguments", "Timeout must be greater than 0 and at most 86400 seconds") + directory = relay_dir(store, name) + command = [] + if cli: + values = {"{packet}": str(directory / "packet.md"), "{packet_text}": text, + "{reply_file}": str(directory / "reply.txt"), "{workspace}": str(workspace), + "{state_dir}": str(store.directory)} + for item in command_of(args): + for token, value in values.items(): + item = item.replace(token, value) + command.append(item) + try: + validate(command, "argv") + except Problem: + raise Problem("arguments", "Worker command is too long; pass {packet} (a path) instead of {packet_text}") + directory.mkdir(parents=True) + atomic_write(directory / "packet.md", text) + record = {"name": name, "session_id": args.session, "machine": store.machine, "provider": args.provider, + "model": args.model or "", "mode": mode, "transport": "cli" if cli else "host", + "phase": args.phase or "", "worker_session": args.worker_session or "", + "workspace": str(workspace) if cli or args.workspace else "", "host_ref": args.host_ref or "", + "run_id": uuid.uuid4().hex if cli else "", + "packet": (directory / "packet.md").relative_to(store.directory).as_posix(), + "packet_sha256": file_digest(directory / "packet.md"), "status": "running", + "started_at": now(), "finished_at": ""} + if mode == "write" and state["sessions"].get(record["worker_session"]): + record["recorded_after_worker_began"] = True + if mode == "write" and record["workspace"]: + # Changes already present are not the worker's; collect compares against this baseline. + baseline = relay_changes(workspace, store.directory) + if baseline is not None: + atomic_write(directory / "baseline.json", canonical(baseline) + "\n") + relay_save(store, record) + result = {"name": name, "transport": record["transport"], "packet": str(directory / "packet.md"), + "reply_file": str(directory / "reply.txt")} + if cli: + result["runtime"] = launch_runtime(store, state, args.session, "relay-" + name, command, keep=args.keep, + cwd=workspace, timeout=args.timeout, run_id=record["run_id"], + split_stderr=True) + return result + + +def record_floor(state, session_id): + """A worker that began before its orchestrator did cannot belong to this delegation.""" + return state["sessions"][session_id]["started_at"] + + +def relay_latest_checks(state, record): + latest = {} + for check in sorted(state["checks"].values(), key=lambda item: item["sequence"]): + if check["phase"] == record["phase"] and check["session_id"] == record["worker_session"]: + latest[check["name"]] = check + return latest + + +def relay_worker_facts(state, record): + latest = {name: c["result"] if c["provenance"] == "executed" else c["result"] + " (" + c["provenance"] + ")" + for name, c in relay_latest_checks(state, record).items()} + worker = state["sessions"].get(record["worker_session"]) + try: + p = phase(state, record["phase"]) + except Problem: + p = {"state": "missing", "owner": ""} + return {"session": worker["status"] + (": " + worker["ended_reason"] if worker["ended_reason"] else "") + if worker else "not started", "root": worker["root"] if worker else "", "phase_state": p["state"], + "phase_owner": p["owner"], "recorded_checks": latest} + + +def relay_view(store, state, record): + view = {key: record[key] for key in ("name", "session_id", "provider", "model", "mode", "transport", "phase", + "worker_session", "host_ref", "status", "started_at", "finished_at")} + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + view["runtime"] = {key: runtime[key] for key in ("status", "exit_code", "timed_out", "reason") if key in runtime} + if record["status"] == "running" and runtime["status"] == "exited": + view["status"] = "exited" + if record["mode"] == "write": + view["worker"] = relay_worker_facts(state, record) + if record.get("result"): + view["result"] = record["result"] + for key in ("error", "discrepancies", "reason", "adopted_from", "recorded_after_worker_began"): + if record.get(key): + view[key] = record[key] + return view + + +def relay_final_texts(value): + """Final-answer fields of known agent JSON output; tool and progress events are ignored.""" + if not isinstance(value, dict): + return [] + kind = value.get("type") + if kind == "result" and isinstance(value.get("result"), str): # Claude Code json / stream-json result + return [value["result"]] + if kind is None and isinstance(value.get("response"), str): # Gemini CLI json, which has no event type + return [value["response"]] + item = value.get("item") + if value.get("type") == "item.completed" and isinstance(item, dict) and item.get("type") == "agent_message" \ + and isinstance(item.get("text"), str): # Codex exec --json + return [item["text"]] + return [] + + +def relay_blocks(text): + found, start = [], text.find(RELAY_OPEN) + while start >= 0: + end = text.find(RELAY_CLOSE, start) + if end < 0: + break + body = text[start + len(RELAY_OPEN):end].strip() + if body.startswith("```"): + body = body.split("\n", 1)[1] if "\n" in body else "" + body = body.rsplit("```", 1)[0] + found.append(body.strip()) + start = text.find(RELAY_OPEN, end) + return found + + +def relay_extract(text): + """The last RELAY-RESULT block in the final answer: plain text, or a known JSON final-answer field.""" + stripped = text.strip() + if stripped.startswith("{"): + try: + whole = json.loads(stripped) + except ValueError: + whole = None + if whole is not None: + blocks = [b for t in relay_final_texts(whole) for b in relay_blocks(t)] + return blocks[-1] if blocks else None + candidates, raw, inside = [], [], False + for line in text.splitlines(): + decoded = None + if not inside and line.lstrip().startswith("{"): + try: + decoded = json.loads(line) + except ValueError: + decoded = None + if isinstance(decoded, dict): + candidates += relay_blocks("\n".join(raw)) + raw = [] + for final in relay_final_texts(decoded): + candidates += relay_blocks(final) + continue + raw.append(line) + # Lines inside an open block are its body, even when they look like JSON events. + opened = line.rfind(RELAY_OPEN) + if opened >= 0: + inside = RELAY_CLOSE not in line[opened:] + elif inside and RELAY_CLOSE in line: + inside = False + candidates += relay_blocks("\n".join(raw)) + return candidates[-1] if candidates else None + + +def relay_problems(record, result): + if not isinstance(result, dict): + return ["The result must be a JSON object"] + problems = [] + if result.get("outcome") not in RELAY_OUTCOMES: + problems.append("outcome must be one of " + ", ".join(RELAY_OUTCOMES)) + if not isinstance(result.get("summary"), str) or not result["summary"].strip(): + problems.append("summary is required") + if "checks" in result and (not isinstance(result["checks"], list) or not all( + isinstance(c, dict) and isinstance(c.get("name"), str) for c in result["checks"])): + problems.append("checks must be [{name, result}]") + for field in ("changed_files", "findings", "gaps"): + if field in result and not isinstance(result[field], list): + problems.append(field + " must be a list") + if "changed_files" in result and isinstance(result["changed_files"], list) and \ + not all(isinstance(f, str) for f in result["changed_files"]): + problems.append("changed_files must list paths") + if result.get("outcome") == "completed": + if record["mode"] == "consult" and not isinstance(result.get("answer"), str): + problems.append("a consult result needs answer") + if record["mode"] == "review": + if result.get("verdict") not in ("APPROVE", "CHANGES_REQUIRED"): + problems.append("a review needs verdict APPROVE or CHANGES_REQUIRED") + if not isinstance(result.get("findings"), list): + problems.append("a review needs a findings list") + if record["mode"] == "write": + for field in ("changed_files", "checks"): + if field not in result: + problems.append("a write result needs " + field) + return problems + + +def relay_in_scope(name, allowed): + """One-directional containment after normalization; traversal and absolute paths are never in scope.""" + raw = name.replace("\\", "/") + if raw.startswith("/") or re.match(r"^[A-Za-z]:", raw) or ".." in raw.split("/"): + return False + clean = posixpath.normpath(raw).casefold() + for scope in allowed: + scope = posixpath.normpath(scope.replace("\\", "/")).casefold() + if scope in ("*", ".") or clean == scope or clean.startswith(scope + "/"): + return True + return False + + +def relay_changes(root, state_dir): + """Changed and untracked paths of a workspace with content facts, or None when they cannot be read.""" + try: + snap = snapshot(root, state_dir) + if snap.get("limited"): + return None + names = [c["path"] if isinstance(c, dict) else c for c in snap["changes"]] + return file_facts(root, names, state_prefix(root, state_dir)) + except Problem: + return None + + +def relay_discrepancies(store, state, record, result): + """Compare a writer's claims with what the runtime recorded and the workspace shows; claims prove nothing.""" + facts = relay_worker_facts(state, record) + found = [] + if result.get("worker_session", record["worker_session"]) != record["worker_session"]: + found.append("Result names another worker session") + if facts["session"] == "not started": + found.append("The worker session never began in this coordinator") + latest = relay_latest_checks(state, record) + for check in result.get("checks") or []: + recorded = latest.get(check["name"]) + if check.get("result") == "pass" and (not recorded or recorded["result"] != "pass"): + found.append(f"Check {check['name']} is claimed to pass but the worker recorded " + f"{recorded['result'] if recorded else 'no run'}") + try: + p = phase(state, record["phase"]) + except Problem: + p = {"id": "", "paths": [], "required_checks": []} + root = Path(facts["root"]) if facts["root"] else None + if result.get("outcome") == "completed": + overall = {} + for c in sorted((c for c in state["checks"].values() if c["phase"] == p["id"]), key=lambda c: c["sequence"]): + overall[c["name"]] = c + for name in p["required_checks"]: + if name not in latest or overall[name]["id"] != latest[name]["id"]: + found.append(f"Required check {name} has no latest run by the worker") + if root and root.is_dir() and p["id"]: + # The same gate DONE uses, judged in the worker's workspace: fresh fingerprint and intact log. + found += ["Worker evidence would not satisfy DONE: " + gap + for gap in verification_gaps(Store(root, store.directory), state, p)] + claimed = [f for f in result.get("changed_files") or []] + outside = [f for f in claimed if not relay_in_scope(f, p["paths"])] + if outside: + found.append("Reported files outside the phase paths: " + ", ".join(outside[:10])) + if root and root.is_dir(): + now = relay_changes(root, store.directory) + baseline_path = relay_dir(store, record["name"]) / "baseline.json" + baseline = read_json(baseline_path) if baseline_path.is_file() and str(root) == record["workspace"] else None + if now is None: + found.append("Could not inspect the worker workspace") + elif baseline is None and root == store.root: + found.append("The worker used the orchestrator checkout without a recorded baseline; its changes " + "could not be told apart from others") + else: + base = baseline or {} + observed = sorted(f for f in set(now) | set(base) if now.get(f) != base.get(f)) + stray = [f for f in observed if not relay_in_scope(f, p["paths"])] + if stray: + found.append("Workspace changes outside the phase paths: " + ", ".join(stray[:10])) + unreported = [f for f in observed if f not in claimed and f not in stray] + if unreported: + found.append("Changed but not reported: " + ", ".join(unreported[:10])) + return found + + +def relay_collect(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + directory = relay_dir(store, record["name"]) + sources = [directory / "reply.txt"] + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + if runtime["status"] == "running": + raise Problem("delegation_running", "The worker is still running; wait for it or cancel it", 3) + if runtime["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {runtime['status']}: " + f"{runtime.get('reason', '')}. Confirm the worker stopped, then pass --confirm-stopped", 3) + _, process = relay_process(state, record) + if process: + sources.append(relay_path(store, process["directory"]) / "output.log") + if args.from_file: + sources = [Path(args.from_file).expanduser()] + body, used = None, "" + for source in sources: + if source.is_file(): + with open(source, "rb") as handle: + handle.seek(max(0, source.stat().st_size - MAX_RELAY_TEXT)) + body = relay_extract(handle.read().decode("utf-8", "replace")) + if body is not None: + used = str(source) + break + if body is None: + record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + relay_save(store, record) + return relay_view(store, state, record), adopted + try: + result = json.loads(body, object_pairs_hook=object_pairs) + problems = relay_problems(record, result) + except (ValueError, Problem) as exc: + result, problems = None, ["The block is not valid, unambiguous JSON: " + str(exc)[:200]] + if problems: + record.update(status="malformed", error="; ".join(problems)) + relay_save(store, record) + return relay_view(store, state, record), adopted + discrepancies = relay_discrepancies(store, state, record, result) if record["mode"] == "write" else [] + atomic_write(directory / "result.json", canonical(result) + "\n") + record.update(status="returned", finished_at=now(), result=result, source=used, + result_sha256=file_digest(directory / "result.json"), error="", discrepancies=discrepancies) + relay_save(store, record) + return relay_view(store, state, record), adopted + + +def relay_cancel(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + if record["transport"] == "cli": + _, process = relay_process(state, record) + stopped = stop_runtime(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + if stopped["status"] == "running": + raise Problem("delegation_running", "The worker did not stop; inspect it before retrying", 3) + if stopped["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {stopped['status']}: {stopped.get('reason', '')}. " + "Stop it yourself, then pass --confirm-stopped", 3) + elif not args.confirm_stopped: + raise Problem("arguments", "Cancel the host task first (for example with the host's cancel tool), then " + "pass --confirm-stopped") + record.update(status="cancelled", finished_at=now(), reason=args.reason or "cancelled by orchestrator") + relay_save(store, record) + view = relay_view(store, state, record) + if record["mode"] == "write" and state["sessions"].get(record["worker_session"], {}).get("status") == "active": + view["next"] = (f"Worker session {record['worker_session']} is still active; release it with release " + "--confirm-stopped once you have confirmed its agent stopped") + return view, adopted + + +def relay_path(store, relative): + return relative_path(store.directory, relative) + + +def relay_open(store, session_id=None): + return [r for r in relay_records(store) if r["status"] in RELAY_OPEN_STATES and + (session_id is None or r["session_id"] == session_id)] + + +def running_checks(state, session_id, pid=None): + return [c for c in state["checks"].values() if c["result"] == "running" and + c["session_id"] == session_id and (pid is None or c["phase"] == pid)] + + +def command_parser(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=os.getcwd(), help="Project/worktree root") + parser.add_argument("--state-dir", help="Explicit shared coordinator directory; absolute or project-relative") + sub = parser.add_subparsers(dest="command", required=True) + p = sub.add_parser("snapshot", help="Read workspace facts without creating project state") + p.add_argument("--full", action="store_true", help="Include every observed path; default shows at most 5") + for name in ("init", "begin"): + p = sub.add_parser(name, help="Create state once" if name == "init" else + "Initialize if needed and register a NEW session; supersedes stopped solo sessions here") + p.add_argument("--context", default="PROJECT.md") + p.add_argument("--plan", default="") + p.add_argument("--handover", default="HANDOVER.md") + if name == "begin": + p.add_argument("--session", help="New unique ID; omit to generate one") + p.add_argument("--parallel", action="store_true", + help="Work alongside other live sessions in this checkout; never supersede them") + p.add_argument("--next-action") + p = sub.add_parser("status", help="Compact shared state and live local facts") + p.add_argument("--session") + p.add_argument("--full", action="store_true", help="Return complete machine state; avoid loading it into prompts by default") + p = sub.add_parser("next", help="Deterministic eligible phase and stale evidence") + p.add_argument("--session", required=True) + for name in ("checkpoint", "end"): + p = sub.add_parser(name) + p.add_argument("--session", required=True) + p.add_argument("--expect-revision", type=int) + p.add_argument("--next-action", required=True) + p.add_argument("--note", default="") + if name == "end": + p.add_argument("--keep-runtimes", action="store_true") + p.add_argument("--handover-output", help="Render one marked factual block after this session ends") + p = sub.add_parser("release", help="End a stopped session that cannot end itself (crashed chat, other machine)") + p.add_argument("--session", required=True, help="The stopped session to release") + p.add_argument("--expect-revision", type=int, required=True) + p.add_argument("--confirm-stopped", action="store_true", help="The user confirmed that session's agent stopped") + p.add_argument("--reason", required=True) + p.add_argument("--by", help="Your active session ID, recorded for audit") + p = sub.add_parser("plan", help="Import declarative phase definitions; preserve runtime states") + p.add_argument("--input", required=True, help="JSON plan file, or - for stdin") + p.add_argument("--session", required=True) + p.add_argument("--expect-revision", type=int, required=True) + p.add_argument("--merge", action="store_true", help="Upsert supplied definitions without resending recorded phases") + p = sub.add_parser("batch", help="One atomic revision-checked plan/transition/checkpoint transaction; no subprocesses") + p.add_argument("--input", required=True, help="JSON {steps:[...]}; stdin is -") + p.add_argument("--session", required=True) + p.add_argument("--expect-revision", type=int, required=True) + p = sub.add_parser("transition") + p.add_argument("--session", required=True) + p.add_argument("--expect-revision", type=int) + p.add_argument("--phase", required=True) + p.add_argument("--to", choices=STATES, required=True) + p.add_argument("--reason", default="") + p.add_argument("--acceptance-note", default="") + p = sub.add_parser("handover", help="Render only the marked factual block, preserving surrounding prose") + p.add_argument("--output", help="Default: handover.md in the shared state directory") + p.add_argument("--expect-revision", type=int) + p = sub.add_parser("check", help="Record executed checks or explicitly reported evidence; command after --") + p.add_argument("action", choices=("run", "record", "abandon")) + p.add_argument("--session", required=True) + p.add_argument("--expect-revision", type=int) + p.add_argument("--phase") + p.add_argument("--name") + p.add_argument("--argv-file", help="JSON argv array instead of a command after --; shell=False either way") + p.add_argument("--timeout", type=float, default=30) + p.add_argument("--evidence") + p.add_argument("--result", choices=("pass", "fail")) + p.add_argument("--id") + p.add_argument("--confirm-stopped", action="store_true", help="For abandon: caller verified the interrupted command is no longer running") + p = sub.add_parser("runtime", help="Own only process groups launched by this runtime; command after --") + p.add_argument("action", choices=("launch", "status", "stop")) + p.add_argument("--session", required=True) + p.add_argument("--expect-revision", type=int) + p.add_argument("--name", required=True) + p.add_argument("--argv-file", help="JSON argv array instead of a command after --") + p.add_argument("--keep", action="store_true") + p = sub.add_parser("delegate", help="Record and supervise work delegated to another agent; worker command after --") + p.add_argument("action", choices=("providers", "start", "status", "collect", "cancel")) + p.add_argument("--session") + p.add_argument("--expect-revision", type=int) + p.add_argument("--name", help="Unique delegation ID") + p.add_argument("--provider", help="Worker provider ID, for example codex, claude or gemini") + p.add_argument("--model", default="") + p.add_argument("--mode", choices=("consult", "review", "write")) + p.add_argument("--phase", help="Required for write: the phase the worker claims") + p.add_argument("--worker-session", help="Required for write: the new session ID the worker begins") + p.add_argument("--workspace", help="Worker checkout; a writer needs this checkout or a linked worktree") + p.add_argument("--packet", help="Instruction packet file given to the worker") + p.add_argument("--host-ref", help="Host task reference when no CLI command is given") + p.add_argument("--argv-file", help="JSON argv array instead of a command after --") + p.add_argument("--timeout", type=float, help="CLI worker: stop its process group after this many seconds") + p.add_argument("--keep", action="store_true", help="CLI worker: keep running when this session ends") + p.add_argument("--from", dest="from_file", help="collect: file holding the worker's final reply") + p.add_argument("--confirm-stopped", action="store_true", + help="cancel/collect: you verified the worker stopped (host task, or a CLI worker in unknown state)") + p.add_argument("--reason", default="") + return parser + + +def require(value, label): + if value is None or isinstance(value, str) and not value.strip(): + raise Problem("arguments", label + " is required") + return value + + +def command_of(args): + if args.inline is not None and args.argv_file: + raise Problem("arguments", "Give the command after -- or with --argv-file, not both") + command = args.inline if args.inline is not None else read_json(require(args.argv_file, "A command after -- (or --argv-file)")) + validate(command, "argv") + return command + + +def apply_plan(state, args): + data = read_json(args.input) + return apply_plan_data(state, data, args.session, getattr(args, "merge", False)) + + +def apply_plan_data(state, data, session_id, merge=False): + validate(data, "plan") + definitions = [] + for item in data["phases"]: + definitions.append({"depends_on": [], "priority": 0, "paths": ["*"], "required_checks": [], "exclude": [], **item}) + for field in ("paths", "exclude"): + definitions[-1][field] = [Path(x).as_posix() for x in definitions[-1][field]] + if len({p["id"] for p in definitions}) != len(definitions): + raise Problem("plan", "Duplicate phase IDs") + old = {p["id"]: p for p in state["phases"]} + if merge: + fields = ("id", "title", "acceptance", "depends_on", "priority", "paths", "required_checks", "exclude") + by_id = {p["id"]: {k: p[k] for k in fields} for p in state["phases"]} + by_id.update({p["id"]: p for p in definitions}) + definitions = list(by_id.values()) + check_plan(definitions) + if set(old) - {p["id"] for p in definitions}: + raise Problem("plan", "Do not remove recorded phases; explicitly defer them") + result = [] + for item in definitions: + prior = old.get(item["id"]) + metadata = {k: prior[k] for k in item} if prior else None + if prior and metadata != item: + if prior["owner"] not in ("", session_id): + raise Problem("ownership", "Another session owns phase " + item["id"], 3) + if prior["state"] == "DONE" or running_checks(state, session_id, item["id"]): + raise Problem("plan", "Reopen a completed phase / finish active checks before changing its contract") + result.append({**item, "state": prior["state"] if prior else "TODO", + "owner": prior["owner"] if prior else "", + "reason": prior["reason"] if prior else "", "acceptance_note": prior["acceptance_note"] if prior else ""}) + proposed = {**state, "phases": result} + for item in result: + if item["owner"] and claim_conflicts(proposed, item, item["owner"]): + raise Problem("ownership", "Plan change would overlap another session's active claim", 3) + state["phases"] = result + return {"phases": len(result)} + + +def batch(store, state, args): + data = read_json(args.input) + validate(data, "batch") + fields = {"plan": {"command", "phases"}, + "transition": {"command", "phase", "to", "reason", "acceptance_note"}, + "checkpoint": {"command", "next_action", "note"}} + required = {"plan": {"phases"}, "transition": {"phase", "to"}, "checkpoint": {"next_action"}} + for step in data["steps"]: + command = step["command"] + if set(step) - fields[command] or required[command] - set(step): + raise Problem("schema", "Invalid fields for batch " + command) + results, live = [], None + for step in data["steps"]: + command = step["command"] + if command == "plan": + result = apply_plan_data(state, {"phases": step["phases"]}, args.session, merge=True) + elif command == "transition": + params = argparse.Namespace(session=args.session, phase=step["phase"], to=step["to"], + reason=step.get("reason", ""), acceptance_note=step.get("acceptance_note", "")) + result = transition(store, state, params) + else: + require(step["next_action"], "next_action") + live = live if live is not None else store.observe() + new_checkpoint(store, state, args.session, step["next_action"], step.get("note", ""), live) + result = {"checkpoint": state["sessions"][args.session]["checkpoint"]["id"]} + results.append({"command": command, **result}) + return {"steps": results} + + +def transition(store, state, args): + p = phase(state, args.phase) + target = args.to + if p["owner"] not in ("", args.session): + raise Problem("ownership", f"Phase {p['id']} belongs to session {p['owner']}; work on unclaimed phases, " + "or release that session after the user confirms it stopped", 3) + claiming = target == p["state"] and p["owner"] == "" and target in ("IN_PROGRESS", "VERIFY") + if target == p["state"] and not claiming: + return {"phase": p["id"], "state": p["state"], "unchanged": True} + if not claiming and target not in TRANSITIONS[p["state"]]: + raise Problem("transition", f"Invalid transition {p['state']} -> {target}") + if running_checks(state, args.session, p["id"]): + raise Problem("check_running", "Finish or explicitly abandon the in-flight check first", 3) + warnings = [] + if target in ("IN_PROGRESS", "VERIFY", "DONE"): + pending = [d for d in dependency_ids(state, p) if phase(state, d)["state"] != "DONE"] + if pending: + raise Problem("dependency", "Dependencies are not DONE: " + ", ".join(sorted(pending)), 3) + conflicts = claim_conflicts(state, p, args.session) + if conflicts: + raise Problem("ownership", "Conflicting phase/file claim: " + canonical(conflicts) + "; choose " + "unclaimed work, or release a session the user confirmed stopped", 3) + if target in ("BLOCKED", "NEEDS_DECISION", "DEFERRED") or p["state"] in ("DONE", "BLOCKED", "NEEDS_DECISION", "DEFERRED"): + require(args.reason, "--reason") + if target == "DONE": + require(args.acceptance_note, "--acceptance-note (an explicit acceptance judgment)") + gaps = verification_gaps(store, state, p) + if gaps: + raise Problem("verification", "Cannot mark DONE: " + "; ".join(gaps), 3) + p["acceptance_note"] = args.acceptance_note + if target in ("VERIFY", "DONE"): + # Later phases legitimately edit shared files; stale dependency evidence is a warning, not a lock. + warnings = [f"Dependency {d} evidence is no longer fresh; re-run its checks before delivery" + for d in p["depends_on"] if verification_gaps(store, state, phase(state, d))] + if p["state"] == "DONE": + p["acceptance_note"] = "" + affected = {p["id"]} + while True: + expanded = affected | {item["id"] for item in state["phases"] if set(item["depends_on"]) & affected} + if expanded == affected: + break + affected = expanded + for dependent in state["phases"]: + if dependent["id"] in affected - {p["id"]}: + if dependent["owner"] not in ("", args.session) or running_checks(state, args.session, dependent["id"]): + raise Problem("dependency_owned", "Coordinate with the dependent phase owner before reopening", 3) + if dependent["state"] == "DONE": + dependent.update(state="VERIFY", owner="", acceptance_note="", reason="Dependency reopened: " + p["id"]) + p["state"], p["reason"] = target, args.reason + p["owner"] = args.session if target in ("IN_PROGRESS", "VERIFY") else "" + result = {"phase": p["id"], "state": p["state"], "owner": p["owner"]} + if warnings: + result["warnings"] = warnings + return result + + +def release(store, state, args): + target = state["sessions"].get(args.session) + if not target: + raise Problem("session", "Unknown session ID") + if target["status"] != "active": + raise Problem("session_ended", f"Session {args.session} has already ended ({target['ended_reason']})", 3) + if not args.confirm_stopped: + raise Problem("arguments", "Confirm with the user that this session's agent has stopped, then pass --confirm-stopped") + reason = require(args.reason, "--reason") + if args.by: + session(state, store, args.by) + alive = live_checks(store, state, args.session) if target["machine"] == store.machine else [] + if alive: + raise Problem("session_alive", f"Session {args.session} is running check {alive[0]} right now; it has not stopped", 3) + label = "released" + (" by " + args.by if args.by else "") + ": " + reason + return {"session_id": args.session, **retire(store, state, args.session, label)} + + +def end(store, state, args): + if running_checks(state, args.session): + raise Problem("check_running", "This session has an in-flight check; reconcile it before ending", 3) + if args.handover_output: + render_target(store, args.handover_output) + if claim_conflicts(state, {"id": "", "paths": [args.handover_output]}, args.session): + raise Problem("ownership", "Another session owns the requested handover output", 3) + new_checkpoint(store, state, args.session, args.next_action, args.note) + for p in state["phases"]: + if p["owner"] == args.session: + p["owner"] = "" + s = state["sessions"][args.session] + s.update(status="ended", ended_at=now(), ended_reason="ended") + compact_checkpoint(s) + store.save(state, "end-checkpoint:" + args.session) + processes = {} + for name, record in state["processes"].items(): + if record["session_id"] == args.session: + processes[name] = runtime_view(store, record) if args.keep_runtimes or record["keep"] else stop_runtime(store, record) + result = {"session_id": args.session, "processes": processes} + still_open = [] + for record in relay_open(store, args.session): + key, _ = relay_process(state, record) + stopped = processes.get(key) + if record["transport"] == "cli" and stopped and stopped["status"] == "exited" and record["status"] == "running": + record.update(status="cancelled", finished_at=now(), reason="orchestrator session ended") + relay_save(store, record) + else: + still_open.append(record["name"]) + if still_open: + result["open_delegations"] = still_open + result["warning"] = ("Delegations stay open: a later session adopts them with delegate status, collect " + "or cancel. Cancel host tasks you no longer need.") + return result + + +def check_command(store, state, args): + if args.action == "abandon": + check = state["checks"].get(require(args.id, "--id")) + if not check or check["session_id"] != args.session or check["result"] != "running": + raise Problem("check", "Only this session's unresolved check can be abandoned") + if not args.confirm_stopped: + raise Problem("check", "Verify the interrupted command has stopped, then use --confirm-stopped") + try: + with file_lock(store.directory / "checks" / (args.id + ".lock"), wait=0): + pass + except Problem: + raise Problem("check_running", "This check is still executing; it cannot be abandoned", 3) + check["result"] = "abandoned" + check["evidence"] += "; " + require(args.evidence, "--evidence explaining interruption") + return check + p = phase(state, require(args.phase, "--phase")) + if p["owner"] != args.session: + raise Problem("ownership", "Only the phase owner can record evidence; claim the phase first", 3) + rid = uuid.uuid4().hex + record = {"id": rid, "phase": p["id"], "name": require(args.name, "--name"), + "result": require(args.result, "--result"), "provenance": "reported", "at": now(), + "fingerprint": phase_fingerprint(store, state, p), "stable_inputs": True, "changed_during_check": [], + "evidence": require(args.evidence, "--evidence"), "session_id": args.session, + "root": str(store.root), "exit_code": None, "elapsed_seconds": 0, + "sequence": state["revision"] + 1, "evidence_sha256": ""} + state["checks"][rid] = record + return record + + +def mutate(store, state, args): + """Apply one locked mutation; returns (result, event) where event None means already saved/unchanged.""" + command = args.command + if command == "release": + return release(store, state, args), "release:" + args.session + session(state, store, args.session, require_active=not (command == "runtime" and args.action == "stop")) + if command == "plan": + return apply_plan(state, args), "plan:" + args.session + if command == "batch": + return batch(store, state, args), "batch:" + args.session + if command == "transition": + return transition(store, state, args), "transition:" + args.session + if command == "checkpoint": + new_checkpoint(store, state, args.session, args.next_action, args.note) + return {"checkpoint": state["sessions"][args.session]["checkpoint"]["id"]}, "checkpoint:" + args.session + if command == "end": + return end(store, state, args), None + if command == "delegate": + if args.action == "start": + return relay_start(store, state, args), None + result, adopted = (relay_collect if args.action == "collect" else relay_cancel)(store, state, args) + return result, "delegate-adopt:" + args.session if adopted else None + if command == "runtime": + if args.action == "launch": + return launch_runtime(store, state, args.session, args.name, command_of(args), args.keep), None + record = state["processes"].get(args.session + "/" + args.name) + if not record: + raise Problem("runtime", "No runtime with this name belongs to this session") + return stop_runtime(store, record), None + if command == "check": + return check_command(store, state, args), "check-" + args.action + ":" + args.session + raise Problem("command", "Unsupported command") + + +def recorded_check(store, args): + require(args.name, "--name"); require(args.phase, "--phase") + command = command_of(args) + if not 0 < args.timeout <= 3600: + raise Problem("arguments", "Timeout must be greater than 0 and at most 3600 seconds") + run_id = uuid.uuid4().hex + with file_lock(store.directory / "checks" / (run_id + ".lock"), wait=0): + with file_lock(store.directory / ".lock"): + state = store.load(); store.expect(state, args.expect_revision) + session(state, store, args.session) + p = phase(state, args.phase) + if p["owner"] != args.session or p["state"] not in ("IN_PROGRESS", "VERIFY"): + raise Problem("ownership", "Only the session that owns an IN_PROGRESS or VERIFY phase can run its " + "check; claim the phase first", 3) + if running_checks(state, args.session, p["id"]): + raise Problem("check_running", "A check is already in flight for this phase", 3) + before = scope_inventory(store, state, p) + state["checks"][run_id] = {"id": run_id, "phase": args.phase, "name": args.name, + "result": "running", "provenance": "executed", "at": now(), + "fingerprint": phase_fingerprint(store, state, p, before), "stable_inputs": False, + "changed_during_check": [], "evidence": "checks/" + run_id + ".log", "session_id": args.session, + "root": str(store.root), "exit_code": None, "elapsed_seconds": 0, + "sequence": state["revision"] + 1, "evidence_sha256": ""} + store.save(state, "check-start:" + run_id) + # Long work happens outside the coordinator lock; other disjoint sessions can progress. + result = run_check(store, state, p, args, run_id, command, before) + with file_lock(store.directory / ".lock"): + current = store.load() + saved = current["checks"].get(run_id) + if not saved or saved["result"] != "running": + raise Problem("check_conflict", "Check was abandoned or changed; its completion was not promoted", 3) + result["sequence"] = current["revision"] + 1 + current["checks"][run_id] = result + store.save(current, "check-result:" + run_id) + shown = dict(result) + if not shown["stable_inputs"]: + shown["warning"] = ("The check changed files in the phase scope; this run cannot satisfy DONE. List " + "generated outputs in the phase 'exclude' or .gitignore and run it again.") + return current, shown + + +def read_only(store, args): + state = store.load() + if args.command == "status": + if args.session and args.session not in state["sessions"]: + raise Problem("session", "Unknown session ID") + return state, state if args.full else brief(state, store, args.session, store.observe()) + if args.command == "next": + session(state, store, args.session) + choices = ready(state, args.session) + stale = [p["id"] for p in state["phases"] if p["state"] == "DONE" and verification_gaps(store, state, p)] + action = "continue" if choices else "recheck" if stale else \ + "complete" if state["phases"] and all(p["state"] == "DONE" for p in state["phases"]) else "decision" + return state, {"action": action, "phase": choices[0] if choices else None, "stale_done_evidence": stale, + "not_done": [p["id"] for p in state["phases"] if p["state"] != "DONE"]} + if args.command == "delegate": + records = [relay_load(store, args.name)] if args.name else relay_records(store) + return state, {"delegations": [relay_view(store, state, r) for r in records + if args.name or not args.session or r["session_id"] == args.session]} + record = state["processes"].get(args.session + "/" + args.name) + if not record: + raise Problem("runtime", "Unknown session runtime") + return state, runtime_view(store, record) + + +def dispatch(store, args): + command = args.command + accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" \ + or command == "delegate" and args.action == "start" + if args.inline is not None and not accepts_command: + raise Problem("arguments", "Only 'check run', 'runtime launch' and 'delegate start' accept a command after --") + if command == "delegate" and args.action == "providers": + return None, relay_providers() + if command == "delegate" and args.action != "status": + require(args.session, "--session") + if command == "snapshot": + result = store.observe() + return None, result if args.full else compact_snapshot(result) + if command in ("status", "next") or command in ("runtime", "delegate") and args.action == "status": + return read_only(store, args) + if command == "check" and args.action == "run": + return recorded_check(store, args) + if command == "begin": + return begin(store, args) + with file_lock(store.directory / ".lock"): + if command == "init": + if store.path.exists(): + return store.load(), {"existing": True} + state = initial_state(store, args) + store.save(state, "init") + return state, {"created": True} + state = store.load() + store.expect(state, args.expect_revision, required=command in ("plan", "batch", "release")) + if command == "handover": + if args.output: + return state, render(store, state, args.output) + target = store.directory / "handover.md" + content = facts_markdown(store, state, store.observe()) + changed = not target.exists() or target.read_text(encoding="utf-8") != content + if changed: + atomic_write(target, content) + return state, {"output": str(target), "changed": changed} + result, event = mutate(store, state, args) + if event: + store.save(state, event) + if command == "end" and args.handover_output: + try: + result["handover"] = render(store, state, args.handover_output) + except (Problem, OSError, UnicodeError) as exc: + raise Problem("handover_after_end", f"Session ended at revision {state['revision']}; handover failed: {exc}. Reconcile and retry only handover.", 6) + return state, result + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if argv and argv[0] == "_supervise": + supervise(argv[1]); return 0 + inline = None + if "--" in argv: + index = argv.index("--") + argv, inline = argv[:index], argv[index + 1:] + args = command_parser().parse_args(argv) + args.inline = inline + try: + store = Store(args.root, args.state_dir) + state, result = dispatch(store, args) + output = {"ok": True, "schema_version": SCHEMA_VERSION, "state_directory": str(store.directory), + "revision": state["revision"] if state else None, "result": result} + if store.migration: + output["warnings"] = [store.migration] + emit(output) + return 0 + except Problem as exc: + emit({"ok": False, "error": {"code": exc.code, "message": str(exc)}}) + return exc.exit_code + except (OSError, UnicodeError, subprocess.TimeoutExpired) as exc: + emit({"ok": False, "error": {"code": "io", "message": str(exc)}}) + return 6 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packs/session-workflow/skills/agent-relay/scripts/session.schema.json b/packs/session-workflow/skills/agent-relay/scripts/session.schema.json new file mode 100644 index 0000000..d0199d1 --- /dev/null +++ b/packs/session-workflow/skills/agent-relay/scripts/session.schema.json @@ -0,0 +1,560 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "estafeta runtime interfaces", + "description": "The bundled validator implements the keywords used here; no third-party packages are required. State schema 2; schema 1 is upgraded once with a state.v1.json copy.", + "$comment": "Machine state is authoritative only for runtime-owned facts; decisions and acceptance remain externally judged.", + "$defs": { + "id": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "argv": { + "type": "array", + "items": { + "type": "string", + "maxLength": 16000 + }, + "minItems": 1, + "maxItems": 200 + }, + "phase_input": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "title": { + "type": "string", + "maxLength": 1000 + }, + "acceptance": { + "type": "string", + "minLength": 1, + "maxLength": 12000 + }, + "depends_on": { + "type": "array", + "items": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "minItems": 0, + "maxItems": 2000 + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "minItems": 1, + "maxItems": 2000 + }, + "required_checks": { + "type": "array", + "items": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "minItems": 0, + "maxItems": 2000 + }, + "exclude": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "minItems": 0, + "maxItems": 2000 + } + }, + "required": [ + "id", + "title", + "acceptance" + ], + "additionalProperties": false + }, + "phase_state": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "title": { + "type": "string", + "maxLength": 1000 + }, + "acceptance": { + "type": "string", + "minLength": 1, + "maxLength": 12000 + }, + "depends_on": { + "type": "array", + "items": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "minItems": 0, + "maxItems": 2000 + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "paths": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "minItems": 1, + "maxItems": 2000 + }, + "required_checks": { + "type": "array", + "items": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "minItems": 0, + "maxItems": 2000 + }, + "state": { + "enum": [ + "TODO", + "IN_PROGRESS", + "VERIFY", + "DONE", + "BLOCKED", + "NEEDS_DECISION", + "DEFERRED" + ] + }, + "owner": { + "type": "string", + "maxLength": 1000 + }, + "reason": { + "type": "string", + "maxLength": 12000 + }, + "acceptance_note": { + "type": "string", + "maxLength": 12000 + }, + "exclude": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "minItems": 0, + "maxItems": 2000 + } + }, + "required": [ + "id", + "title", + "acceptance", + "depends_on", + "priority", + "paths", + "required_checks", + "state", + "owner", + "reason", + "acceptance_note", + "exclude" + ], + "additionalProperties": false + }, + "plan": { + "type": "object", + "properties": { + "phases": { + "type": "array", + "items": { + "$ref": "#/$defs/phase_input" + }, + "minItems": 1, + "maxItems": 2000 + } + }, + "required": [ + "phases" + ], + "additionalProperties": false + }, + "check": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "phase": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "name": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "result": { + "enum": [ + "running", + "pass", + "fail", + "timeout", + "error", + "abandoned" + ] + }, + "provenance": { + "enum": [ + "executed", + "reported" + ] + }, + "at": { + "type": "string", + "maxLength": 1000 + }, + "fingerprint": { + "type": "string", + "maxLength": 1000 + }, + "stable_inputs": { + "type": "boolean" + }, + "evidence": { + "type": "string", + "maxLength": 12000 + }, + "session_id": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "root": { + "type": "string", + "maxLength": 1000 + }, + "exit_code": {}, + "elapsed_seconds": { + "type": "number", + "minimum": 0 + }, + "sequence": { + "type": "integer", + "minimum": 0 + }, + "evidence_sha256": { + "type": "string", + "maxLength": 64 + }, + "changed_during_check": { + "type": "array", + "items": { + "type": "string", + "maxLength": 4096 + }, + "minItems": 0, + "maxItems": 10 + } + }, + "required": [ + "id", + "phase", + "name", + "result", + "provenance", + "at", + "fingerprint", + "stable_inputs", + "evidence", + "session_id", + "root", + "exit_code", + "elapsed_seconds", + "sequence", + "evidence_sha256" + ], + "additionalProperties": false + }, + "session": { + "type": "object", + "properties": { + "status": { + "enum": [ + "active", + "ended" + ] + }, + "machine": { + "type": "string", + "maxLength": 1000 + }, + "host": { + "type": "string", + "maxLength": 1000 + }, + "root": { + "type": "string", + "maxLength": 1000 + }, + "started_at": { + "type": "string", + "maxLength": 1000 + }, + "ended_at": { + "type": "string", + "maxLength": 1000 + }, + "ended_reason": { + "type": "string", + "maxLength": 13000 + }, + "parallel": { + "type": "boolean" + }, + "checkpoint": { + "type": "object" + } + }, + "required": [ + "status", + "machine", + "host", + "root", + "started_at", + "ended_at", + "ended_reason", + "parallel", + "checkpoint" + ], + "additionalProperties": false + }, + "process": { + "type": "object", + "properties": { + "run_id": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "session_id": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "machine": { + "type": "string", + "maxLength": 1000 + }, + "root": { + "type": "string", + "maxLength": 1000 + }, + "directory": { + "type": "string", + "maxLength": 1000 + }, + "keep": { + "type": "boolean" + } + }, + "required": [ + "run_id", + "session_id", + "machine", + "root", + "directory", + "keep" + ], + "additionalProperties": false + }, + "state": { + "type": "object", + "properties": { + "schema_version": { + "type": "integer", + "const": 2 + }, + "project_id": { + "type": "string", + "pattern": "[A-Za-z0-9][A-Za-z0-9_.-]{0,79}" + }, + "revision": { + "type": "integer", + "minimum": 0 + }, + "created_at": { + "type": "string", + "maxLength": 1000 + }, + "updated_at": { + "type": "string", + "maxLength": 1000 + }, + "documents": { + "type": "object", + "properties": { + "context": { + "type": "string", + "maxLength": 1000 + }, + "plan": { + "type": "string", + "maxLength": 1000 + }, + "handover": { + "type": "string", + "maxLength": 1000 + } + }, + "required": [ + "context", + "plan", + "handover" + ], + "additionalProperties": false + }, + "sessions": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/session" + } + }, + "phases": { + "type": "array", + "items": { + "$ref": "#/$defs/phase_state" + }, + "minItems": 0, + "maxItems": 2000 + }, + "checks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/check" + } + }, + "processes": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/process" + } + }, + "events": { + "type": "array", + "items": { + "type": "object" + }, + "minItems": 0, + "maxItems": 1000 + } + }, + "required": [ + "schema_version", + "project_id", + "revision", + "created_at", + "updated_at", + "documents", + "sessions", + "phases", + "checks", + "processes", + "events" + ], + "additionalProperties": false + }, + "batch_step": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": [ + "plan", + "transition", + "checkpoint" + ] + }, + "phases": { + "type": "array", + "items": { + "$ref": "#/$defs/phase_input" + }, + "minItems": 1, + "maxItems": 2000 + }, + "phase": { + "$ref": "#/$defs/id" + }, + "to": { + "type": "string", + "enum": [ + "TODO", + "IN_PROGRESS", + "VERIFY", + "DONE", + "BLOCKED", + "NEEDS_DECISION", + "DEFERRED" + ] + }, + "reason": { + "type": "string", + "maxLength": 12000 + }, + "acceptance_note": { + "type": "string", + "maxLength": 12000 + }, + "next_action": { + "type": "string", + "minLength": 1, + "maxLength": 12000 + }, + "note": { + "type": "string", + "maxLength": 12000 + } + }, + "required": [ + "command" + ], + "additionalProperties": false + }, + "batch": { + "type": "object", + "properties": { + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/batch_step" + }, + "minItems": 1, + "maxItems": 20 + } + }, + "required": [ + "steps" + ], + "additionalProperties": false + } + } +} diff --git a/packs/session-workflow/skills/agent-relay/templates/packet.md b/packs/session-workflow/skills/agent-relay/templates/packet.md new file mode 100644 index 0000000..a795bdd --- /dev/null +++ b/packs/session-workflow/skills/agent-relay/templates/packet.md @@ -0,0 +1,69 @@ +# Relay packet: + +```relay-packet +{ + "version": 1, + "name": "", + "mode": "consult | review | write", + "orchestrator": "", + "workspace": "", + "runtime": ["python3", ""], + "phase": "", + "worker_session": "", + "allowed_paths": [""], + "required_checks": {"": ["", "..."]}, + "revision": "" +} +``` + +You are a worker for another agent, which orchestrates this project. Delete the +sections that do not apply to this mode before sending. + +## Task + + + +## Context + + + +## Rules + +- Work only in the workspace above. Do not commit, push, install, publish or + change PROJECT.md, plans, HANDOVER.md or other shared records. +- consult and review: read only. Change no files. +- write: change only `allowed_paths`. Record your work with the runtime above; + `--root` is always the workspace: + 1. `begin --parallel --session ` + 2. `transition --session --expect-revision R --phase --to IN_PROGRESS` + 3. make the change + 4. for each required check: `check run --session --expect-revision R --phase --name --timeout 300 -- ` + 5. `transition ... --to VERIFY`, then `end --session --expect-revision R --next-action "Orchestrator integrates "` + + Use the `revision` each call returns. An `ownership` refusal means stop and + report it as blocked. +- Stop and report `blocked` instead of guessing when access, a decision or a + dependency is missing. + +## Reply + +End your final message with exactly one block, valid JSON inside: + +```text +<<>> +``` + +Keep only the fields for your mode. Report checks exactly as recorded: the +orchestrator compares them with the runtime state. diff --git a/packs/session-workflow/skills/plan-and-orchestrate/SKILL.md b/packs/session-workflow/skills/plan-and-orchestrate/SKILL.md index b6d92e8..cc9d75b 100644 --- a/packs/session-workflow/skills/plan-and-orchestrate/SKILL.md +++ b/packs/session-workflow/skills/plan-and-orchestrate/SKILL.md @@ -10,14 +10,15 @@ description: >- license: Apache-2.0 metadata: workflow-pack: session-workflow - workflow-version: "3.0.0" + workflow-version: "3.1.0" --- # Plan and orchestrate Read the [contract](references/workflow-contract.md) and [quickstart](references/quickstart.md) once per chat. For parallel agents, also -read [delegation](references/delegation.md). If this chat has no session yet, +read [delegation](references/delegation.md); for other AI providers, use the +agent-relay skill. If this chat has no session yet, run session-start first. 1. **Frame** the outcome, acceptance, constraints and non-goals. Reuse an diff --git a/packs/session-workflow/skills/plan-and-orchestrate/references/delegation.md b/packs/session-workflow/skills/plan-and-orchestrate/references/delegation.md index 6ae1f99..a216445 100644 --- a/packs/session-workflow/skills/plan-and-orchestrate/references/delegation.md +++ b/packs/session-workflow/skills/plan-and-orchestrate/references/delegation.md @@ -2,7 +2,9 @@ Use native subagents or parallel chats only when the host offers them and the user authorized them. No team size is required; sequential work with disclosed -self-review is always valid. +self-review is always valid. To consult, review with or delegate to an agent of +another provider, use the agent-relay skill: it records each delegation and +checks a writer's claims against the runtime. - **One integrator** owns the plan, context, handover, integration and Git history. Writers never change those shared records. diff --git a/packs/session-workflow/skills/plan-and-orchestrate/references/runtime.md b/packs/session-workflow/skills/plan-and-orchestrate/references/runtime.md index ac4a73f..0b95d32 100644 --- a/packs/session-workflow/skills/plan-and-orchestrate/references/runtime.md +++ b/packs/session-workflow/skills/plan-and-orchestrate/references/runtime.md @@ -112,6 +112,62 @@ another machine reports `unknown` or `foreign`; the runtime never kills a recorded PID. On Windows the supervisor ends the process tree through `taskkill /T` while the child is alive. +## Delegations + +| Command | Purpose | +| --- | --- | +| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate start --session ID --name N --provider P --mode consult\|review\|write --packet F [--model M] [--phase P --worker-session W] [--workspace DIR] [--timeout S] -- CMD...` | Record a delegation and run a CLI worker as a supervised runtime `relay-N` | +| `delegate start ... --host-ref REF` (no command) | Record a host-native child task, such as a T3 task ID | +| `delegate status [--session ID] [--name N]` | Read-only: records, worker runtime, writer facts and result | +| `delegate collect --session ID --name N [--from FILE] [--confirm-stopped]` | Parse the worker's final RELAY-RESULT block from `--from`, `reply.txt` or the CLI worker's stdout | +| `delegate cancel --session ID --name N [--confirm-stopped]` | Stop a CLI worker, or close a host task you already cancelled | + +- Records live in `delegations//` under the state directory: the packet, + `record.json` and `result.json` with SHA-256 hashes. `state.json` and its + schema do not change, so a 3.0 runtime can still take part as a worker. +- In the worker command, `{packet}`, `{packet_text}`, `{reply_file}`, + `{workspace}` and `{state_dir}` are replaced. The worker runs in `--workspace` + without a shell or stdin. Its stdout and stderr go to separate logs. + `--timeout` stops its process group after a 10-second grace period. Within + it, a `check run` started by the worker stops its own check group (1-second + grace, then SIGKILL) and records the run as `abandoned`. +- A writer needs an unclaimed phase that is not DONE and a new worker session + ID. A given `--workspace` must share this coordinator (this checkout or a + linked worktree); a host task may omit it when the host creates the + worktree. The worker begins that session with `--parallel`, claims the phase + itself and does not commit: the integrator owns Git history. +- `collect` takes the last block of the final answer. That is plain text, or + one of: a Claude event of type `result`, Gemini's untyped `response`, or a + Codex `agent_message` item. Blocks in other events, such as tool results, + are ignored. Duplicate JSON keys make a result `malformed`. A missing or invalid block leaves the record + `malformed`, and it can be collected again. While a CLI worker runs, + `collect` refuses, even with `--from`. +- For a writer, `discrepancies` lists: + - claimed passing checks the worker did not record as passing; + - required checks whose latest run is not the worker's, or whose evidence + would not pass the DONE gate in the worker's workspace (fingerprint and + log); + - reported files outside the phase paths (traversal and absolute paths + included); + - changes the worker's workspace shows outside the phase paths, or did not + report. These are compared with a baseline taken when the delegation was + recorded, so earlier changes in a shared checkout do not count; + - a worker session that never began. + + Claims alone prove nothing. +- A writer recorded after its host task already began and claimed the phase + is accepted when that session is parallel and began after the orchestrator. +- `cancel`, `collect` and `end` treat a CLI worker as stopped only when its + receipt says `exited`. For `unknown` or `foreign`, confirm that it stopped + and pass `--confirm-stopped`; `end` leaves such records open. +- `end` stops the session's CLI workers like other runtimes and cancels their + records. It reports host tasks and kept workers as `open_delegations`. + `begin` and `status` list them. A session that takes over by solo `begin` + inherits them once its state is saved. Any later session adopts the record + of an ended orchestrator, together with its kept CLI process, on `collect` + or `cancel`. + ## Handover output `handover [--output FILE]` and `end --handover-output FILE` replace only the diff --git a/packs/session-workflow/skills/plan-and-orchestrate/references/workflow-contract.md b/packs/session-workflow/skills/plan-and-orchestrate/references/workflow-contract.md index a635b44..9eb04ba 100644 --- a/packs/session-workflow/skills/plan-and-orchestrate/references/workflow-contract.md +++ b/packs/session-workflow/skills/plan-and-orchestrate/references/workflow-contract.md @@ -1,4 +1,4 @@ -# estafeta contract - 3.0.0 +# estafeta contract - 3.1.0 Load once per chat. Commands: [quickstart](quickstart.md). Rare options: [runtime](runtime.md). diff --git a/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py b/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py index 00f5079..849f465 100644 --- a/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py +++ b/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """Deterministic session facts and state. Python 3.9+, standard library only. -No model/API calls, no Git writes, no project migration and no kills outside a -process group this runtime supervises. Run --help or a subcommand --help for +No model/API calls of its own (a delegated worker is a command the caller +supplies), no Git writes, no project migration and no kills outside a process +group this runtime supervises. Run --help or a subcommand --help for the JSON CLI. See references/runtime.md. """ from __future__ import annotations @@ -16,11 +17,14 @@ import os from pathlib import Path import platform +import posixpath import re +import shutil import signal import subprocess import sys import tempfile +import threading import time import uuid @@ -777,6 +781,10 @@ def brief(state, store, session_id=None, live=None): "live_workspace": compact_snapshot(live) if live is not None else None} if actor and actor["status"] != "active": result["session_status"] = "ended: " + actor["ended_reason"] + delegations = relay_open(store) + if delegations: + result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], + "status": r["status"], "orchestrator": r["session_id"]} for r in delegations[:20]] warnings = snapshot_warnings(live) if warnings: result["warnings"] = warnings @@ -896,6 +904,8 @@ def begin(store, args): "Inspect the plan and choose a ready phase" new_checkpoint(store, state, session_id, next_action, workspace=live) store.save(state, "begin:" + session_id) + for item in superseded: + relay_transfer(store, item["id"], session_id) result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded @@ -978,6 +988,10 @@ def cell(value): "| Phase | State | Owner | Latest checks |", "| --- | --- | --- | --- |", *rows, "", "Active sessions: " + ("; ".join(f"{cell(sid)} (next: {cell(s['checkpoint'].get('next_action', ''))})" for sid, s in active) or "none") + "."] + delegations = relay_open(store) + if delegations: + lines.append("Open delegations: " + "; ".join(f"{cell(r['name'])} ({cell(r['provider'])} {r['mode']}, " + f"{r['status']})" for r in delegations[:10]) + ".") if ended: sid, s = max(ended, key=lambda item: item[1]["ended_at"]) lines.append(f"Ended sessions: {len(ended)}; latest {cell(sid)} " @@ -1021,7 +1035,7 @@ def group_alive(pgid): return True -def terminate_group(child): +def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": if child.poll() is None: @@ -1031,7 +1045,7 @@ def terminate_group(child): os.killpg(child.pid, signal.SIGTERM) except ProcessLookupError: return - deadline = time.monotonic() + 4 + deadline = time.monotonic() + grace while time.monotonic() < deadline: child.poll() if not group_alive(child.pid): @@ -1047,6 +1061,27 @@ def group_kwargs(): return {"start_new_session": True} if os.name != "nt" else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} +class Interrupted(Exception): + pass + + +@contextmanager +def interrupt_guard(): + """Turn SIGTERM/SIGHUP into Interrupted while a check runs, so its separate process group is not orphaned.""" + names = [n for n in ("SIGTERM", "SIGHUP") if hasattr(signal, n)] + if threading.current_thread() is not threading.main_thread(): + yield + return + def handler(signum, frame): + raise Interrupted() + previous = {n: signal.signal(getattr(signal, n), handler) for n in names} + try: + yield + finally: + for n, h in previous.items(): + signal.signal(getattr(signal, n), h) + + def run_check(store, state, p, args, run_id, command, before): log = store.directory / "checks" / (run_id + ".log") log.parent.mkdir(parents=True, exist_ok=True) @@ -1056,12 +1091,20 @@ def run_check(store, state, p, args, run_id, command, before): child = subprocess.Popen(command, cwd=store.root, stdout=output, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: - code = child.wait(timeout=args.timeout) + with interrupt_guard(): + code = child.wait(timeout=args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) child.wait() code, outcome = None, "timeout" + except Interrupted: + # The caller was stopped (for example a timed-out delegated worker): stop the check's own group too, + # with a short grace so this finishes well inside the supervisor's longer relay grace period. + terminate_group(child, grace=1.0) + child.wait() + output.write(b"\n[session-workflow] check stopped because its caller was terminated\n") + code, outcome = None, "abandoned" except OSError as exc: output.write(str(exc).encode("utf-8", "replace")) code, outcome = None, "error" @@ -1104,7 +1147,7 @@ def stop_runtime(store, record): return status directory = relative_path(store.directory, record["directory"]) atomic_write(directory / "stop.json", canonical({"run_id": record["run_id"]}) + "\n") - deadline = time.monotonic() + 10 + deadline = time.monotonic() + 15 while time.monotonic() < deadline: status = runtime_view(store, record) if status["status"] != "running": @@ -1126,10 +1169,14 @@ def supervise(spec_path): directory = spec_path.parent receipt_path = directory / "receipt.json" try: - with open(directory / "output.log", "ab") as output: - child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, stderr=subprocess.STDOUT, + # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + with open(directory / "output.log", "ab") as output, \ + open(directory / spec.get("stderr", "output.log"), "ab") as errors: + child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, + stderr=errors if spec.get("stderr") else subprocess.STDOUT, stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 + started, limit, timed_out = time.monotonic(), spec.get("timeout"), False # Descendants keep the group alive after the leader exits; supervise until all are gone. while child.poll() is None or group_alive(child.pid): if time.monotonic() - beat >= HEARTBEAT_SECONDS: @@ -1138,33 +1185,45 @@ def supervise(spec_path): "leader_exited": child.returncode is not None}) + "\n", durable=False) beat = time.monotonic() if stop.exists() and read_json(stop).get("run_id") == spec["run_id"]: - terminate_group(child) + terminate_group(child, spec.get("grace", 4.0)) + break + if limit and time.monotonic() - started > limit: + terminate_group(child, spec.get("grace", 4.0)) + timed_out = True break time.sleep(0.2) child.poll() receipt = {"run_id": spec["run_id"], "status": "exited", "exit_code": child.returncode, "scope": "supervised process group" if os.name != "nt" else "supervised process tree"} + if timed_out: + receipt["timed_out"] = True except Exception as exc: receipt = {"run_id": spec["run_id"], "status": "unknown", "reason": str(exc)[:500]} atomic_write(receipt_path, canonical(receipt) + "\n") -def launch_runtime(store, state, args, command): - key = args.session + "/" + args.name +def launch_runtime(store, state, session_id, name, command, keep=False, cwd=None, timeout=None, run_id=None, + split_stderr=False): + key = session_id + "/" + name if key in state["processes"]: raise Problem("runtime", "Runtime name already recorded; inspect it before creating a new name") - run_id = uuid.uuid4().hex + run_id = run_id or uuid.uuid4().hex directory = store.directory / "runtime" / run_id directory.mkdir(parents=True) - spec = {"run_id": run_id, "argv": command, "root": str(store.root)} + spec = {"run_id": run_id, "argv": command, "root": str(cwd or store.root)} + if timeout: + spec["timeout"] = timeout + if split_stderr: + # A delegated worker: its nested check runners need time to stop their own groups first. + spec.update(stderr="stderr.log", grace=10.0) spec_path = directory / "spec.json" atomic_write(spec_path, canonical(spec) + "\n") - record = {"run_id": run_id, "session_id": args.session, "machine": store.machine, + record = {"run_id": run_id, "session_id": session_id, "machine": store.machine, "root": str(store.root), "directory": directory.relative_to(store.directory).as_posix(), - "keep": args.keep} + "keep": keep} # Persist identity before launch; a crash cannot produce an unrecorded child. state["processes"][key] = record - store.save(state, "runtime-prepared:" + args.name) + store.save(state, "runtime-prepared:" + name) SUPERVISORS[run_id] = subprocess.Popen( [sys.executable, str(Path(__file__).resolve()), "_supervise", str(spec_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **group_kwargs()) @@ -1172,9 +1231,488 @@ def launch_runtime(store, state, args, command): while time.monotonic() < deadline: status = runtime_view(store, record) if status["status"] != "unknown": - return {"name": args.name, **status} + return {"name": name, **status} time.sleep(0.05) - return {"name": args.name, **runtime_view(store, record)} + return {"name": name, **runtime_view(store, record)} + + +# --- Delegation ledger ------------------------------------------------------- +# One record per delegated task under /delegations//. It lives outside +# state.json so schema 2 is unchanged and a 3.0 runtime can still join as a worker. +RELAY_OPEN, RELAY_CLOSE = "<<>>" +RELAY_OPEN_STATES = ("running", "malformed") +RELAY_OUTCOMES = ("completed", "blocked", "failed") +PROVIDER_CLIS = ("claude", "codex", "gemini") +MAX_RELAY_TEXT = 4 * 1024 * 1024 + + +def relay_dir(store, name=""): + base = store.directory / "delegations" + return base / name if name else base + + +def relay_load(store, name): + path = relay_dir(store, name) / "record.json" + if not path.is_file(): + raise Problem("delegation", "Unknown delegation name; list them with delegate status") + return read_json(path) + + +def relay_save(store, record): + atomic_write(relay_dir(store, record["name"]) / "record.json", canonical(record) + "\n") + + +def relay_records(store): + base = relay_dir(store) + records = [] + if base.is_dir(): + for directory in sorted(base.iterdir()): + try: + records.append(read_json(directory / "record.json")) + except (Problem, OSError): + continue + return records + + +def relay_owned(store, state, args): + """The caller's record. A record whose orchestrator has ended is adopted together with its CLI process.""" + record = relay_load(store, require(args.name, "--name")) + owner, adopted = record["session_id"], False + if owner != args.session: + if state["sessions"].get(owner, {}).get("status") == "active": + raise Problem("ownership", f"Delegation {record['name']} belongs to active session {owner}", 3) + record["adopted_from"], record["session_id"] = owner, args.session + key, process = relay_process(state, record) + if process and process["session_id"] != args.session and \ + state["sessions"].get(process["session_id"], {}).get("status") != "active": + new_key = args.session + "/" + key.split("/", 1)[1] + if new_key in state["processes"]: + raise Problem("runtime", "This session already has a runtime with that name", 3) + process["session_id"] = args.session + state["processes"][new_key] = state["processes"].pop(key) + adopted = True + return record, adopted + + +def relay_transfer(store, retired, successor): + """Called after the superseding begin was saved; a crash before it leaves adoption as the fallback.""" + for record in relay_records(store): + if record["session_id"] == retired and record["status"] in RELAY_OPEN_STATES: + record["session_id"] = successor + relay_save(store, record) + + +def relay_process(state, record): + for key, process in state["processes"].items(): + if record.get("run_id") and process["run_id"] == record["run_id"]: + return key, process + return None, None + + +def relay_runtime(store, state, record): + _, process = relay_process(state, record) + return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + + +def relay_providers(): + found = [] + for name in PROVIDER_CLIS: + path = shutil.which(name) + item = {"provider": name, "cli": path or "", "version": ""} + if path: + try: + done = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=15, + stdin=subprocess.DEVNULL) + item["version"] = (done.stdout or done.stderr).strip().splitlines()[0][:200] if \ + (done.stdout or done.stderr).strip() else "" + except (OSError, subprocess.TimeoutExpired): + item["version"] = "unknown" + found.append(item) + return {"cli": found, "note": "Host-native subagents (for example T3 delegate_task) are discovered by the agent, " + "not by this runtime"} + + +def relay_start(store, state, args): + name = require(args.name, "--name") + validate(name, "id") + validate(require(args.provider, "--provider"), "id") + mode = require(args.mode, "--mode") + cli = args.inline is not None or bool(args.argv_file) + if relay_dir(store, name).exists(): + raise Problem("delegation", "Delegation name already used; choose a new name", 3) + packet = Path(require(args.packet, "--packet")).expanduser() + text = packet.read_text(encoding="utf-8") + if not text.strip(): + raise Problem("arguments", "The packet is empty") + workspace = Path(args.workspace).expanduser().resolve() if args.workspace else store.root + if not workspace.is_dir(): + raise Problem("workspace", "The worker workspace does not exist") + if mode == "write": + worker = require(args.worker_session, "--worker-session") + validate(worker, "id") + p = phase(state, require(args.phase, "--phase")) + if p["state"] == "DONE": + raise Problem("transition", f"Phase {p['id']} is DONE; reopen it before delegating", 3) + # A host task can start, and its worker begin, before the orchestrator records it. + started = state["sessions"].get(worker) + if p["owner"] and p["owner"] != worker: + raise Problem("ownership", f"Phase {p['id']} is claimed by {p['owner']}; the worker must claim it. " + "Release it first with a transition to TODO", 3) + if worker == args.session or started and (cli or not started["parallel"] or + started["started_at"] < record_floor(state, args.session)): + raise Problem("session_exists", "The worker session ID is already used; choose a new one", 3) + if Store(workspace).directory != store.directory: + raise Problem("workspace", "A writer's workspace must share this coordinator: this checkout or a " + "linked Git worktree of it", 3) + elif args.phase: + phase(state, args.phase) + if not cli and args.timeout: + raise Problem("arguments", "--timeout applies only to a CLI worker command") + if args.timeout is not None and not 0 < args.timeout <= 86400: + raise Problem("arguments", "Timeout must be greater than 0 and at most 86400 seconds") + directory = relay_dir(store, name) + command = [] + if cli: + values = {"{packet}": str(directory / "packet.md"), "{packet_text}": text, + "{reply_file}": str(directory / "reply.txt"), "{workspace}": str(workspace), + "{state_dir}": str(store.directory)} + for item in command_of(args): + for token, value in values.items(): + item = item.replace(token, value) + command.append(item) + try: + validate(command, "argv") + except Problem: + raise Problem("arguments", "Worker command is too long; pass {packet} (a path) instead of {packet_text}") + directory.mkdir(parents=True) + atomic_write(directory / "packet.md", text) + record = {"name": name, "session_id": args.session, "machine": store.machine, "provider": args.provider, + "model": args.model or "", "mode": mode, "transport": "cli" if cli else "host", + "phase": args.phase or "", "worker_session": args.worker_session or "", + "workspace": str(workspace) if cli or args.workspace else "", "host_ref": args.host_ref or "", + "run_id": uuid.uuid4().hex if cli else "", + "packet": (directory / "packet.md").relative_to(store.directory).as_posix(), + "packet_sha256": file_digest(directory / "packet.md"), "status": "running", + "started_at": now(), "finished_at": ""} + if mode == "write" and state["sessions"].get(record["worker_session"]): + record["recorded_after_worker_began"] = True + if mode == "write" and record["workspace"]: + # Changes already present are not the worker's; collect compares against this baseline. + baseline = relay_changes(workspace, store.directory) + if baseline is not None: + atomic_write(directory / "baseline.json", canonical(baseline) + "\n") + relay_save(store, record) + result = {"name": name, "transport": record["transport"], "packet": str(directory / "packet.md"), + "reply_file": str(directory / "reply.txt")} + if cli: + result["runtime"] = launch_runtime(store, state, args.session, "relay-" + name, command, keep=args.keep, + cwd=workspace, timeout=args.timeout, run_id=record["run_id"], + split_stderr=True) + return result + + +def record_floor(state, session_id): + """A worker that began before its orchestrator did cannot belong to this delegation.""" + return state["sessions"][session_id]["started_at"] + + +def relay_latest_checks(state, record): + latest = {} + for check in sorted(state["checks"].values(), key=lambda item: item["sequence"]): + if check["phase"] == record["phase"] and check["session_id"] == record["worker_session"]: + latest[check["name"]] = check + return latest + + +def relay_worker_facts(state, record): + latest = {name: c["result"] if c["provenance"] == "executed" else c["result"] + " (" + c["provenance"] + ")" + for name, c in relay_latest_checks(state, record).items()} + worker = state["sessions"].get(record["worker_session"]) + try: + p = phase(state, record["phase"]) + except Problem: + p = {"state": "missing", "owner": ""} + return {"session": worker["status"] + (": " + worker["ended_reason"] if worker["ended_reason"] else "") + if worker else "not started", "root": worker["root"] if worker else "", "phase_state": p["state"], + "phase_owner": p["owner"], "recorded_checks": latest} + + +def relay_view(store, state, record): + view = {key: record[key] for key in ("name", "session_id", "provider", "model", "mode", "transport", "phase", + "worker_session", "host_ref", "status", "started_at", "finished_at")} + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + view["runtime"] = {key: runtime[key] for key in ("status", "exit_code", "timed_out", "reason") if key in runtime} + if record["status"] == "running" and runtime["status"] == "exited": + view["status"] = "exited" + if record["mode"] == "write": + view["worker"] = relay_worker_facts(state, record) + if record.get("result"): + view["result"] = record["result"] + for key in ("error", "discrepancies", "reason", "adopted_from", "recorded_after_worker_began"): + if record.get(key): + view[key] = record[key] + return view + + +def relay_final_texts(value): + """Final-answer fields of known agent JSON output; tool and progress events are ignored.""" + if not isinstance(value, dict): + return [] + kind = value.get("type") + if kind == "result" and isinstance(value.get("result"), str): # Claude Code json / stream-json result + return [value["result"]] + if kind is None and isinstance(value.get("response"), str): # Gemini CLI json, which has no event type + return [value["response"]] + item = value.get("item") + if value.get("type") == "item.completed" and isinstance(item, dict) and item.get("type") == "agent_message" \ + and isinstance(item.get("text"), str): # Codex exec --json + return [item["text"]] + return [] + + +def relay_blocks(text): + found, start = [], text.find(RELAY_OPEN) + while start >= 0: + end = text.find(RELAY_CLOSE, start) + if end < 0: + break + body = text[start + len(RELAY_OPEN):end].strip() + if body.startswith("```"): + body = body.split("\n", 1)[1] if "\n" in body else "" + body = body.rsplit("```", 1)[0] + found.append(body.strip()) + start = text.find(RELAY_OPEN, end) + return found + + +def relay_extract(text): + """The last RELAY-RESULT block in the final answer: plain text, or a known JSON final-answer field.""" + stripped = text.strip() + if stripped.startswith("{"): + try: + whole = json.loads(stripped) + except ValueError: + whole = None + if whole is not None: + blocks = [b for t in relay_final_texts(whole) for b in relay_blocks(t)] + return blocks[-1] if blocks else None + candidates, raw, inside = [], [], False + for line in text.splitlines(): + decoded = None + if not inside and line.lstrip().startswith("{"): + try: + decoded = json.loads(line) + except ValueError: + decoded = None + if isinstance(decoded, dict): + candidates += relay_blocks("\n".join(raw)) + raw = [] + for final in relay_final_texts(decoded): + candidates += relay_blocks(final) + continue + raw.append(line) + # Lines inside an open block are its body, even when they look like JSON events. + opened = line.rfind(RELAY_OPEN) + if opened >= 0: + inside = RELAY_CLOSE not in line[opened:] + elif inside and RELAY_CLOSE in line: + inside = False + candidates += relay_blocks("\n".join(raw)) + return candidates[-1] if candidates else None + + +def relay_problems(record, result): + if not isinstance(result, dict): + return ["The result must be a JSON object"] + problems = [] + if result.get("outcome") not in RELAY_OUTCOMES: + problems.append("outcome must be one of " + ", ".join(RELAY_OUTCOMES)) + if not isinstance(result.get("summary"), str) or not result["summary"].strip(): + problems.append("summary is required") + if "checks" in result and (not isinstance(result["checks"], list) or not all( + isinstance(c, dict) and isinstance(c.get("name"), str) for c in result["checks"])): + problems.append("checks must be [{name, result}]") + for field in ("changed_files", "findings", "gaps"): + if field in result and not isinstance(result[field], list): + problems.append(field + " must be a list") + if "changed_files" in result and isinstance(result["changed_files"], list) and \ + not all(isinstance(f, str) for f in result["changed_files"]): + problems.append("changed_files must list paths") + if result.get("outcome") == "completed": + if record["mode"] == "consult" and not isinstance(result.get("answer"), str): + problems.append("a consult result needs answer") + if record["mode"] == "review": + if result.get("verdict") not in ("APPROVE", "CHANGES_REQUIRED"): + problems.append("a review needs verdict APPROVE or CHANGES_REQUIRED") + if not isinstance(result.get("findings"), list): + problems.append("a review needs a findings list") + if record["mode"] == "write": + for field in ("changed_files", "checks"): + if field not in result: + problems.append("a write result needs " + field) + return problems + + +def relay_in_scope(name, allowed): + """One-directional containment after normalization; traversal and absolute paths are never in scope.""" + raw = name.replace("\\", "/") + if raw.startswith("/") or re.match(r"^[A-Za-z]:", raw) or ".." in raw.split("/"): + return False + clean = posixpath.normpath(raw).casefold() + for scope in allowed: + scope = posixpath.normpath(scope.replace("\\", "/")).casefold() + if scope in ("*", ".") or clean == scope or clean.startswith(scope + "/"): + return True + return False + + +def relay_changes(root, state_dir): + """Changed and untracked paths of a workspace with content facts, or None when they cannot be read.""" + try: + snap = snapshot(root, state_dir) + if snap.get("limited"): + return None + names = [c["path"] if isinstance(c, dict) else c for c in snap["changes"]] + return file_facts(root, names, state_prefix(root, state_dir)) + except Problem: + return None + + +def relay_discrepancies(store, state, record, result): + """Compare a writer's claims with what the runtime recorded and the workspace shows; claims prove nothing.""" + facts = relay_worker_facts(state, record) + found = [] + if result.get("worker_session", record["worker_session"]) != record["worker_session"]: + found.append("Result names another worker session") + if facts["session"] == "not started": + found.append("The worker session never began in this coordinator") + latest = relay_latest_checks(state, record) + for check in result.get("checks") or []: + recorded = latest.get(check["name"]) + if check.get("result") == "pass" and (not recorded or recorded["result"] != "pass"): + found.append(f"Check {check['name']} is claimed to pass but the worker recorded " + f"{recorded['result'] if recorded else 'no run'}") + try: + p = phase(state, record["phase"]) + except Problem: + p = {"id": "", "paths": [], "required_checks": []} + root = Path(facts["root"]) if facts["root"] else None + if result.get("outcome") == "completed": + overall = {} + for c in sorted((c for c in state["checks"].values() if c["phase"] == p["id"]), key=lambda c: c["sequence"]): + overall[c["name"]] = c + for name in p["required_checks"]: + if name not in latest or overall[name]["id"] != latest[name]["id"]: + found.append(f"Required check {name} has no latest run by the worker") + if root and root.is_dir() and p["id"]: + # The same gate DONE uses, judged in the worker's workspace: fresh fingerprint and intact log. + found += ["Worker evidence would not satisfy DONE: " + gap + for gap in verification_gaps(Store(root, store.directory), state, p)] + claimed = [f for f in result.get("changed_files") or []] + outside = [f for f in claimed if not relay_in_scope(f, p["paths"])] + if outside: + found.append("Reported files outside the phase paths: " + ", ".join(outside[:10])) + if root and root.is_dir(): + now = relay_changes(root, store.directory) + baseline_path = relay_dir(store, record["name"]) / "baseline.json" + baseline = read_json(baseline_path) if baseline_path.is_file() and str(root) == record["workspace"] else None + if now is None: + found.append("Could not inspect the worker workspace") + elif baseline is None and root == store.root: + found.append("The worker used the orchestrator checkout without a recorded baseline; its changes " + "could not be told apart from others") + else: + base = baseline or {} + observed = sorted(f for f in set(now) | set(base) if now.get(f) != base.get(f)) + stray = [f for f in observed if not relay_in_scope(f, p["paths"])] + if stray: + found.append("Workspace changes outside the phase paths: " + ", ".join(stray[:10])) + unreported = [f for f in observed if f not in claimed and f not in stray] + if unreported: + found.append("Changed but not reported: " + ", ".join(unreported[:10])) + return found + + +def relay_collect(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + directory = relay_dir(store, record["name"]) + sources = [directory / "reply.txt"] + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + if runtime["status"] == "running": + raise Problem("delegation_running", "The worker is still running; wait for it or cancel it", 3) + if runtime["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {runtime['status']}: " + f"{runtime.get('reason', '')}. Confirm the worker stopped, then pass --confirm-stopped", 3) + _, process = relay_process(state, record) + if process: + sources.append(relay_path(store, process["directory"]) / "output.log") + if args.from_file: + sources = [Path(args.from_file).expanduser()] + body, used = None, "" + for source in sources: + if source.is_file(): + with open(source, "rb") as handle: + handle.seek(max(0, source.stat().st_size - MAX_RELAY_TEXT)) + body = relay_extract(handle.read().decode("utf-8", "replace")) + if body is not None: + used = str(source) + break + if body is None: + record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + relay_save(store, record) + return relay_view(store, state, record), adopted + try: + result = json.loads(body, object_pairs_hook=object_pairs) + problems = relay_problems(record, result) + except (ValueError, Problem) as exc: + result, problems = None, ["The block is not valid, unambiguous JSON: " + str(exc)[:200]] + if problems: + record.update(status="malformed", error="; ".join(problems)) + relay_save(store, record) + return relay_view(store, state, record), adopted + discrepancies = relay_discrepancies(store, state, record, result) if record["mode"] == "write" else [] + atomic_write(directory / "result.json", canonical(result) + "\n") + record.update(status="returned", finished_at=now(), result=result, source=used, + result_sha256=file_digest(directory / "result.json"), error="", discrepancies=discrepancies) + relay_save(store, record) + return relay_view(store, state, record), adopted + + +def relay_cancel(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + if record["transport"] == "cli": + _, process = relay_process(state, record) + stopped = stop_runtime(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + if stopped["status"] == "running": + raise Problem("delegation_running", "The worker did not stop; inspect it before retrying", 3) + if stopped["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {stopped['status']}: {stopped.get('reason', '')}. " + "Stop it yourself, then pass --confirm-stopped", 3) + elif not args.confirm_stopped: + raise Problem("arguments", "Cancel the host task first (for example with the host's cancel tool), then " + "pass --confirm-stopped") + record.update(status="cancelled", finished_at=now(), reason=args.reason or "cancelled by orchestrator") + relay_save(store, record) + view = relay_view(store, state, record) + if record["mode"] == "write" and state["sessions"].get(record["worker_session"], {}).get("status") == "active": + view["next"] = (f"Worker session {record['worker_session']} is still active; release it with release " + "--confirm-stopped once you have confirmed its agent stopped") + return view, adopted + + +def relay_path(store, relative): + return relative_path(store.directory, relative) + + +def relay_open(store, session_id=None): + return [r for r in relay_records(store) if r["status"] in RELAY_OPEN_STATES and + (session_id is None or r["session_id"] == session_id)] def running_checks(state, session_id, pid=None): @@ -1258,6 +1796,26 @@ def command_parser(): p.add_argument("--name", required=True) p.add_argument("--argv-file", help="JSON argv array instead of a command after --") p.add_argument("--keep", action="store_true") + p = sub.add_parser("delegate", help="Record and supervise work delegated to another agent; worker command after --") + p.add_argument("action", choices=("providers", "start", "status", "collect", "cancel")) + p.add_argument("--session") + p.add_argument("--expect-revision", type=int) + p.add_argument("--name", help="Unique delegation ID") + p.add_argument("--provider", help="Worker provider ID, for example codex, claude or gemini") + p.add_argument("--model", default="") + p.add_argument("--mode", choices=("consult", "review", "write")) + p.add_argument("--phase", help="Required for write: the phase the worker claims") + p.add_argument("--worker-session", help="Required for write: the new session ID the worker begins") + p.add_argument("--workspace", help="Worker checkout; a writer needs this checkout or a linked worktree") + p.add_argument("--packet", help="Instruction packet file given to the worker") + p.add_argument("--host-ref", help="Host task reference when no CLI command is given") + p.add_argument("--argv-file", help="JSON argv array instead of a command after --") + p.add_argument("--timeout", type=float, help="CLI worker: stop its process group after this many seconds") + p.add_argument("--keep", action="store_true", help="CLI worker: keep running when this session ends") + p.add_argument("--from", dest="from_file", help="collect: file holding the worker's final reply") + p.add_argument("--confirm-stopped", action="store_true", + help="cancel/collect: you verified the worker stopped (host task, or a CLI worker in unknown state)") + p.add_argument("--reason", default="") return parser @@ -1440,7 +1998,21 @@ def end(store, state, args): for name, record in state["processes"].items(): if record["session_id"] == args.session: processes[name] = runtime_view(store, record) if args.keep_runtimes or record["keep"] else stop_runtime(store, record) - return {"session_id": args.session, "processes": processes} + result = {"session_id": args.session, "processes": processes} + still_open = [] + for record in relay_open(store, args.session): + key, _ = relay_process(state, record) + stopped = processes.get(key) + if record["transport"] == "cli" and stopped and stopped["status"] == "exited" and record["status"] == "running": + record.update(status="cancelled", finished_at=now(), reason="orchestrator session ended") + relay_save(store, record) + else: + still_open.append(record["name"]) + if still_open: + result["open_delegations"] = still_open + result["warning"] = ("Delegations stay open: a later session adopts them with delegate status, collect " + "or cancel. Cancel host tasks you no longer need.") + return result def check_command(store, state, args): @@ -1489,9 +2061,14 @@ def mutate(store, state, args): return {"checkpoint": state["sessions"][args.session]["checkpoint"]["id"]}, "checkpoint:" + args.session if command == "end": return end(store, state, args), None + if command == "delegate": + if args.action == "start": + return relay_start(store, state, args), None + result, adopted = (relay_collect if args.action == "collect" else relay_cancel)(store, state, args) + return result, "delegate-adopt:" + args.session if adopted else None if command == "runtime": if args.action == "launch": - return launch_runtime(store, state, args, command_of(args)), None + return launch_runtime(store, state, args.session, args.name, command_of(args), args.keep), None record = state["processes"].get(args.session + "/" + args.name) if not record: raise Problem("runtime", "No runtime with this name belongs to this session") @@ -1556,6 +2133,10 @@ def read_only(store, args): "complete" if state["phases"] and all(p["state"] == "DONE" for p in state["phases"]) else "decision" return state, {"action": action, "phase": choices[0] if choices else None, "stale_done_evidence": stale, "not_done": [p["id"] for p in state["phases"] if p["state"] != "DONE"]} + if args.command == "delegate": + records = [relay_load(store, args.name)] if args.name else relay_records(store) + return state, {"delegations": [relay_view(store, state, r) for r in records + if args.name or not args.session or r["session_id"] == args.session]} record = state["processes"].get(args.session + "/" + args.name) if not record: raise Problem("runtime", "Unknown session runtime") @@ -1564,13 +2145,18 @@ def read_only(store, args): def dispatch(store, args): command = args.command - accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" + accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" \ + or command == "delegate" and args.action == "start" if args.inline is not None and not accepts_command: - raise Problem("arguments", "Only 'check run' and 'runtime launch' accept a command after --") + raise Problem("arguments", "Only 'check run', 'runtime launch' and 'delegate start' accept a command after --") + if command == "delegate" and args.action == "providers": + return None, relay_providers() + if command == "delegate" and args.action != "status": + require(args.session, "--session") if command == "snapshot": result = store.observe() return None, result if args.full else compact_snapshot(result) - if command in ("status", "next") or command == "runtime" and args.action == "status": + if command in ("status", "next") or command in ("runtime", "delegate") and args.action == "status": return read_only(store, args) if command == "check" and args.action == "run": return recorded_check(store, args) diff --git a/packs/session-workflow/skills/session-end/SKILL.md b/packs/session-workflow/skills/session-end/SKILL.md index 0606365..4df4d1c 100644 --- a/packs/session-workflow/skills/session-end/SKILL.md +++ b/packs/session-workflow/skills/session-end/SKILL.md @@ -10,7 +10,7 @@ description: >- license: Apache-2.0 metadata: workflow-pack: session-workflow - workflow-version: "3.0.0" + workflow-version: "3.1.0" --- # Session end @@ -20,6 +20,9 @@ Read the [contract](references/workflow-contract.md) and 1. **Settle evidence.** Let running checks finish, or abandon them after verifying they stopped. Unfinished phases stay IN_PROGRESS or VERIFY. + Collect finished delegations and cancel unneeded ones (agent-relay). `end` + stops your CLI workers and lists the rest as `open_delegations`: report + them. 2. **Update the narrative:** the changed next action in PROJECT.md and the handover delta (session-handover steps 2-4), not whole documents. 3. **End once:** `end --session ID --next-action "" diff --git a/packs/session-workflow/skills/session-end/references/runtime.md b/packs/session-workflow/skills/session-end/references/runtime.md index ac4a73f..0b95d32 100644 --- a/packs/session-workflow/skills/session-end/references/runtime.md +++ b/packs/session-workflow/skills/session-end/references/runtime.md @@ -112,6 +112,62 @@ another machine reports `unknown` or `foreign`; the runtime never kills a recorded PID. On Windows the supervisor ends the process tree through `taskkill /T` while the child is alive. +## Delegations + +| Command | Purpose | +| --- | --- | +| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate start --session ID --name N --provider P --mode consult\|review\|write --packet F [--model M] [--phase P --worker-session W] [--workspace DIR] [--timeout S] -- CMD...` | Record a delegation and run a CLI worker as a supervised runtime `relay-N` | +| `delegate start ... --host-ref REF` (no command) | Record a host-native child task, such as a T3 task ID | +| `delegate status [--session ID] [--name N]` | Read-only: records, worker runtime, writer facts and result | +| `delegate collect --session ID --name N [--from FILE] [--confirm-stopped]` | Parse the worker's final RELAY-RESULT block from `--from`, `reply.txt` or the CLI worker's stdout | +| `delegate cancel --session ID --name N [--confirm-stopped]` | Stop a CLI worker, or close a host task you already cancelled | + +- Records live in `delegations//` under the state directory: the packet, + `record.json` and `result.json` with SHA-256 hashes. `state.json` and its + schema do not change, so a 3.0 runtime can still take part as a worker. +- In the worker command, `{packet}`, `{packet_text}`, `{reply_file}`, + `{workspace}` and `{state_dir}` are replaced. The worker runs in `--workspace` + without a shell or stdin. Its stdout and stderr go to separate logs. + `--timeout` stops its process group after a 10-second grace period. Within + it, a `check run` started by the worker stops its own check group (1-second + grace, then SIGKILL) and records the run as `abandoned`. +- A writer needs an unclaimed phase that is not DONE and a new worker session + ID. A given `--workspace` must share this coordinator (this checkout or a + linked worktree); a host task may omit it when the host creates the + worktree. The worker begins that session with `--parallel`, claims the phase + itself and does not commit: the integrator owns Git history. +- `collect` takes the last block of the final answer. That is plain text, or + one of: a Claude event of type `result`, Gemini's untyped `response`, or a + Codex `agent_message` item. Blocks in other events, such as tool results, + are ignored. Duplicate JSON keys make a result `malformed`. A missing or invalid block leaves the record + `malformed`, and it can be collected again. While a CLI worker runs, + `collect` refuses, even with `--from`. +- For a writer, `discrepancies` lists: + - claimed passing checks the worker did not record as passing; + - required checks whose latest run is not the worker's, or whose evidence + would not pass the DONE gate in the worker's workspace (fingerprint and + log); + - reported files outside the phase paths (traversal and absolute paths + included); + - changes the worker's workspace shows outside the phase paths, or did not + report. These are compared with a baseline taken when the delegation was + recorded, so earlier changes in a shared checkout do not count; + - a worker session that never began. + + Claims alone prove nothing. +- A writer recorded after its host task already began and claimed the phase + is accepted when that session is parallel and began after the orchestrator. +- `cancel`, `collect` and `end` treat a CLI worker as stopped only when its + receipt says `exited`. For `unknown` or `foreign`, confirm that it stopped + and pass `--confirm-stopped`; `end` leaves such records open. +- `end` stops the session's CLI workers like other runtimes and cancels their + records. It reports host tasks and kept workers as `open_delegations`. + `begin` and `status` list them. A session that takes over by solo `begin` + inherits them once its state is saved. Any later session adopts the record + of an ended orchestrator, together with its kept CLI process, on `collect` + or `cancel`. + ## Handover output `handover [--output FILE]` and `end --handover-output FILE` replace only the diff --git a/packs/session-workflow/skills/session-end/references/workflow-contract.md b/packs/session-workflow/skills/session-end/references/workflow-contract.md index a635b44..9eb04ba 100644 --- a/packs/session-workflow/skills/session-end/references/workflow-contract.md +++ b/packs/session-workflow/skills/session-end/references/workflow-contract.md @@ -1,4 +1,4 @@ -# estafeta contract - 3.0.0 +# estafeta contract - 3.1.0 Load once per chat. Commands: [quickstart](quickstart.md). Rare options: [runtime](runtime.md). diff --git a/packs/session-workflow/skills/session-end/scripts/session.py b/packs/session-workflow/skills/session-end/scripts/session.py index 00f5079..849f465 100644 --- a/packs/session-workflow/skills/session-end/scripts/session.py +++ b/packs/session-workflow/skills/session-end/scripts/session.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """Deterministic session facts and state. Python 3.9+, standard library only. -No model/API calls, no Git writes, no project migration and no kills outside a -process group this runtime supervises. Run --help or a subcommand --help for +No model/API calls of its own (a delegated worker is a command the caller +supplies), no Git writes, no project migration and no kills outside a process +group this runtime supervises. Run --help or a subcommand --help for the JSON CLI. See references/runtime.md. """ from __future__ import annotations @@ -16,11 +17,14 @@ import os from pathlib import Path import platform +import posixpath import re +import shutil import signal import subprocess import sys import tempfile +import threading import time import uuid @@ -777,6 +781,10 @@ def brief(state, store, session_id=None, live=None): "live_workspace": compact_snapshot(live) if live is not None else None} if actor and actor["status"] != "active": result["session_status"] = "ended: " + actor["ended_reason"] + delegations = relay_open(store) + if delegations: + result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], + "status": r["status"], "orchestrator": r["session_id"]} for r in delegations[:20]] warnings = snapshot_warnings(live) if warnings: result["warnings"] = warnings @@ -896,6 +904,8 @@ def begin(store, args): "Inspect the plan and choose a ready phase" new_checkpoint(store, state, session_id, next_action, workspace=live) store.save(state, "begin:" + session_id) + for item in superseded: + relay_transfer(store, item["id"], session_id) result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded @@ -978,6 +988,10 @@ def cell(value): "| Phase | State | Owner | Latest checks |", "| --- | --- | --- | --- |", *rows, "", "Active sessions: " + ("; ".join(f"{cell(sid)} (next: {cell(s['checkpoint'].get('next_action', ''))})" for sid, s in active) or "none") + "."] + delegations = relay_open(store) + if delegations: + lines.append("Open delegations: " + "; ".join(f"{cell(r['name'])} ({cell(r['provider'])} {r['mode']}, " + f"{r['status']})" for r in delegations[:10]) + ".") if ended: sid, s = max(ended, key=lambda item: item[1]["ended_at"]) lines.append(f"Ended sessions: {len(ended)}; latest {cell(sid)} " @@ -1021,7 +1035,7 @@ def group_alive(pgid): return True -def terminate_group(child): +def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": if child.poll() is None: @@ -1031,7 +1045,7 @@ def terminate_group(child): os.killpg(child.pid, signal.SIGTERM) except ProcessLookupError: return - deadline = time.monotonic() + 4 + deadline = time.monotonic() + grace while time.monotonic() < deadline: child.poll() if not group_alive(child.pid): @@ -1047,6 +1061,27 @@ def group_kwargs(): return {"start_new_session": True} if os.name != "nt" else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} +class Interrupted(Exception): + pass + + +@contextmanager +def interrupt_guard(): + """Turn SIGTERM/SIGHUP into Interrupted while a check runs, so its separate process group is not orphaned.""" + names = [n for n in ("SIGTERM", "SIGHUP") if hasattr(signal, n)] + if threading.current_thread() is not threading.main_thread(): + yield + return + def handler(signum, frame): + raise Interrupted() + previous = {n: signal.signal(getattr(signal, n), handler) for n in names} + try: + yield + finally: + for n, h in previous.items(): + signal.signal(getattr(signal, n), h) + + def run_check(store, state, p, args, run_id, command, before): log = store.directory / "checks" / (run_id + ".log") log.parent.mkdir(parents=True, exist_ok=True) @@ -1056,12 +1091,20 @@ def run_check(store, state, p, args, run_id, command, before): child = subprocess.Popen(command, cwd=store.root, stdout=output, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: - code = child.wait(timeout=args.timeout) + with interrupt_guard(): + code = child.wait(timeout=args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) child.wait() code, outcome = None, "timeout" + except Interrupted: + # The caller was stopped (for example a timed-out delegated worker): stop the check's own group too, + # with a short grace so this finishes well inside the supervisor's longer relay grace period. + terminate_group(child, grace=1.0) + child.wait() + output.write(b"\n[session-workflow] check stopped because its caller was terminated\n") + code, outcome = None, "abandoned" except OSError as exc: output.write(str(exc).encode("utf-8", "replace")) code, outcome = None, "error" @@ -1104,7 +1147,7 @@ def stop_runtime(store, record): return status directory = relative_path(store.directory, record["directory"]) atomic_write(directory / "stop.json", canonical({"run_id": record["run_id"]}) + "\n") - deadline = time.monotonic() + 10 + deadline = time.monotonic() + 15 while time.monotonic() < deadline: status = runtime_view(store, record) if status["status"] != "running": @@ -1126,10 +1169,14 @@ def supervise(spec_path): directory = spec_path.parent receipt_path = directory / "receipt.json" try: - with open(directory / "output.log", "ab") as output: - child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, stderr=subprocess.STDOUT, + # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + with open(directory / "output.log", "ab") as output, \ + open(directory / spec.get("stderr", "output.log"), "ab") as errors: + child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, + stderr=errors if spec.get("stderr") else subprocess.STDOUT, stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 + started, limit, timed_out = time.monotonic(), spec.get("timeout"), False # Descendants keep the group alive after the leader exits; supervise until all are gone. while child.poll() is None or group_alive(child.pid): if time.monotonic() - beat >= HEARTBEAT_SECONDS: @@ -1138,33 +1185,45 @@ def supervise(spec_path): "leader_exited": child.returncode is not None}) + "\n", durable=False) beat = time.monotonic() if stop.exists() and read_json(stop).get("run_id") == spec["run_id"]: - terminate_group(child) + terminate_group(child, spec.get("grace", 4.0)) + break + if limit and time.monotonic() - started > limit: + terminate_group(child, spec.get("grace", 4.0)) + timed_out = True break time.sleep(0.2) child.poll() receipt = {"run_id": spec["run_id"], "status": "exited", "exit_code": child.returncode, "scope": "supervised process group" if os.name != "nt" else "supervised process tree"} + if timed_out: + receipt["timed_out"] = True except Exception as exc: receipt = {"run_id": spec["run_id"], "status": "unknown", "reason": str(exc)[:500]} atomic_write(receipt_path, canonical(receipt) + "\n") -def launch_runtime(store, state, args, command): - key = args.session + "/" + args.name +def launch_runtime(store, state, session_id, name, command, keep=False, cwd=None, timeout=None, run_id=None, + split_stderr=False): + key = session_id + "/" + name if key in state["processes"]: raise Problem("runtime", "Runtime name already recorded; inspect it before creating a new name") - run_id = uuid.uuid4().hex + run_id = run_id or uuid.uuid4().hex directory = store.directory / "runtime" / run_id directory.mkdir(parents=True) - spec = {"run_id": run_id, "argv": command, "root": str(store.root)} + spec = {"run_id": run_id, "argv": command, "root": str(cwd or store.root)} + if timeout: + spec["timeout"] = timeout + if split_stderr: + # A delegated worker: its nested check runners need time to stop their own groups first. + spec.update(stderr="stderr.log", grace=10.0) spec_path = directory / "spec.json" atomic_write(spec_path, canonical(spec) + "\n") - record = {"run_id": run_id, "session_id": args.session, "machine": store.machine, + record = {"run_id": run_id, "session_id": session_id, "machine": store.machine, "root": str(store.root), "directory": directory.relative_to(store.directory).as_posix(), - "keep": args.keep} + "keep": keep} # Persist identity before launch; a crash cannot produce an unrecorded child. state["processes"][key] = record - store.save(state, "runtime-prepared:" + args.name) + store.save(state, "runtime-prepared:" + name) SUPERVISORS[run_id] = subprocess.Popen( [sys.executable, str(Path(__file__).resolve()), "_supervise", str(spec_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **group_kwargs()) @@ -1172,9 +1231,488 @@ def launch_runtime(store, state, args, command): while time.monotonic() < deadline: status = runtime_view(store, record) if status["status"] != "unknown": - return {"name": args.name, **status} + return {"name": name, **status} time.sleep(0.05) - return {"name": args.name, **runtime_view(store, record)} + return {"name": name, **runtime_view(store, record)} + + +# --- Delegation ledger ------------------------------------------------------- +# One record per delegated task under /delegations//. It lives outside +# state.json so schema 2 is unchanged and a 3.0 runtime can still join as a worker. +RELAY_OPEN, RELAY_CLOSE = "<<>>" +RELAY_OPEN_STATES = ("running", "malformed") +RELAY_OUTCOMES = ("completed", "blocked", "failed") +PROVIDER_CLIS = ("claude", "codex", "gemini") +MAX_RELAY_TEXT = 4 * 1024 * 1024 + + +def relay_dir(store, name=""): + base = store.directory / "delegations" + return base / name if name else base + + +def relay_load(store, name): + path = relay_dir(store, name) / "record.json" + if not path.is_file(): + raise Problem("delegation", "Unknown delegation name; list them with delegate status") + return read_json(path) + + +def relay_save(store, record): + atomic_write(relay_dir(store, record["name"]) / "record.json", canonical(record) + "\n") + + +def relay_records(store): + base = relay_dir(store) + records = [] + if base.is_dir(): + for directory in sorted(base.iterdir()): + try: + records.append(read_json(directory / "record.json")) + except (Problem, OSError): + continue + return records + + +def relay_owned(store, state, args): + """The caller's record. A record whose orchestrator has ended is adopted together with its CLI process.""" + record = relay_load(store, require(args.name, "--name")) + owner, adopted = record["session_id"], False + if owner != args.session: + if state["sessions"].get(owner, {}).get("status") == "active": + raise Problem("ownership", f"Delegation {record['name']} belongs to active session {owner}", 3) + record["adopted_from"], record["session_id"] = owner, args.session + key, process = relay_process(state, record) + if process and process["session_id"] != args.session and \ + state["sessions"].get(process["session_id"], {}).get("status") != "active": + new_key = args.session + "/" + key.split("/", 1)[1] + if new_key in state["processes"]: + raise Problem("runtime", "This session already has a runtime with that name", 3) + process["session_id"] = args.session + state["processes"][new_key] = state["processes"].pop(key) + adopted = True + return record, adopted + + +def relay_transfer(store, retired, successor): + """Called after the superseding begin was saved; a crash before it leaves adoption as the fallback.""" + for record in relay_records(store): + if record["session_id"] == retired and record["status"] in RELAY_OPEN_STATES: + record["session_id"] = successor + relay_save(store, record) + + +def relay_process(state, record): + for key, process in state["processes"].items(): + if record.get("run_id") and process["run_id"] == record["run_id"]: + return key, process + return None, None + + +def relay_runtime(store, state, record): + _, process = relay_process(state, record) + return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + + +def relay_providers(): + found = [] + for name in PROVIDER_CLIS: + path = shutil.which(name) + item = {"provider": name, "cli": path or "", "version": ""} + if path: + try: + done = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=15, + stdin=subprocess.DEVNULL) + item["version"] = (done.stdout or done.stderr).strip().splitlines()[0][:200] if \ + (done.stdout or done.stderr).strip() else "" + except (OSError, subprocess.TimeoutExpired): + item["version"] = "unknown" + found.append(item) + return {"cli": found, "note": "Host-native subagents (for example T3 delegate_task) are discovered by the agent, " + "not by this runtime"} + + +def relay_start(store, state, args): + name = require(args.name, "--name") + validate(name, "id") + validate(require(args.provider, "--provider"), "id") + mode = require(args.mode, "--mode") + cli = args.inline is not None or bool(args.argv_file) + if relay_dir(store, name).exists(): + raise Problem("delegation", "Delegation name already used; choose a new name", 3) + packet = Path(require(args.packet, "--packet")).expanduser() + text = packet.read_text(encoding="utf-8") + if not text.strip(): + raise Problem("arguments", "The packet is empty") + workspace = Path(args.workspace).expanduser().resolve() if args.workspace else store.root + if not workspace.is_dir(): + raise Problem("workspace", "The worker workspace does not exist") + if mode == "write": + worker = require(args.worker_session, "--worker-session") + validate(worker, "id") + p = phase(state, require(args.phase, "--phase")) + if p["state"] == "DONE": + raise Problem("transition", f"Phase {p['id']} is DONE; reopen it before delegating", 3) + # A host task can start, and its worker begin, before the orchestrator records it. + started = state["sessions"].get(worker) + if p["owner"] and p["owner"] != worker: + raise Problem("ownership", f"Phase {p['id']} is claimed by {p['owner']}; the worker must claim it. " + "Release it first with a transition to TODO", 3) + if worker == args.session or started and (cli or not started["parallel"] or + started["started_at"] < record_floor(state, args.session)): + raise Problem("session_exists", "The worker session ID is already used; choose a new one", 3) + if Store(workspace).directory != store.directory: + raise Problem("workspace", "A writer's workspace must share this coordinator: this checkout or a " + "linked Git worktree of it", 3) + elif args.phase: + phase(state, args.phase) + if not cli and args.timeout: + raise Problem("arguments", "--timeout applies only to a CLI worker command") + if args.timeout is not None and not 0 < args.timeout <= 86400: + raise Problem("arguments", "Timeout must be greater than 0 and at most 86400 seconds") + directory = relay_dir(store, name) + command = [] + if cli: + values = {"{packet}": str(directory / "packet.md"), "{packet_text}": text, + "{reply_file}": str(directory / "reply.txt"), "{workspace}": str(workspace), + "{state_dir}": str(store.directory)} + for item in command_of(args): + for token, value in values.items(): + item = item.replace(token, value) + command.append(item) + try: + validate(command, "argv") + except Problem: + raise Problem("arguments", "Worker command is too long; pass {packet} (a path) instead of {packet_text}") + directory.mkdir(parents=True) + atomic_write(directory / "packet.md", text) + record = {"name": name, "session_id": args.session, "machine": store.machine, "provider": args.provider, + "model": args.model or "", "mode": mode, "transport": "cli" if cli else "host", + "phase": args.phase or "", "worker_session": args.worker_session or "", + "workspace": str(workspace) if cli or args.workspace else "", "host_ref": args.host_ref or "", + "run_id": uuid.uuid4().hex if cli else "", + "packet": (directory / "packet.md").relative_to(store.directory).as_posix(), + "packet_sha256": file_digest(directory / "packet.md"), "status": "running", + "started_at": now(), "finished_at": ""} + if mode == "write" and state["sessions"].get(record["worker_session"]): + record["recorded_after_worker_began"] = True + if mode == "write" and record["workspace"]: + # Changes already present are not the worker's; collect compares against this baseline. + baseline = relay_changes(workspace, store.directory) + if baseline is not None: + atomic_write(directory / "baseline.json", canonical(baseline) + "\n") + relay_save(store, record) + result = {"name": name, "transport": record["transport"], "packet": str(directory / "packet.md"), + "reply_file": str(directory / "reply.txt")} + if cli: + result["runtime"] = launch_runtime(store, state, args.session, "relay-" + name, command, keep=args.keep, + cwd=workspace, timeout=args.timeout, run_id=record["run_id"], + split_stderr=True) + return result + + +def record_floor(state, session_id): + """A worker that began before its orchestrator did cannot belong to this delegation.""" + return state["sessions"][session_id]["started_at"] + + +def relay_latest_checks(state, record): + latest = {} + for check in sorted(state["checks"].values(), key=lambda item: item["sequence"]): + if check["phase"] == record["phase"] and check["session_id"] == record["worker_session"]: + latest[check["name"]] = check + return latest + + +def relay_worker_facts(state, record): + latest = {name: c["result"] if c["provenance"] == "executed" else c["result"] + " (" + c["provenance"] + ")" + for name, c in relay_latest_checks(state, record).items()} + worker = state["sessions"].get(record["worker_session"]) + try: + p = phase(state, record["phase"]) + except Problem: + p = {"state": "missing", "owner": ""} + return {"session": worker["status"] + (": " + worker["ended_reason"] if worker["ended_reason"] else "") + if worker else "not started", "root": worker["root"] if worker else "", "phase_state": p["state"], + "phase_owner": p["owner"], "recorded_checks": latest} + + +def relay_view(store, state, record): + view = {key: record[key] for key in ("name", "session_id", "provider", "model", "mode", "transport", "phase", + "worker_session", "host_ref", "status", "started_at", "finished_at")} + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + view["runtime"] = {key: runtime[key] for key in ("status", "exit_code", "timed_out", "reason") if key in runtime} + if record["status"] == "running" and runtime["status"] == "exited": + view["status"] = "exited" + if record["mode"] == "write": + view["worker"] = relay_worker_facts(state, record) + if record.get("result"): + view["result"] = record["result"] + for key in ("error", "discrepancies", "reason", "adopted_from", "recorded_after_worker_began"): + if record.get(key): + view[key] = record[key] + return view + + +def relay_final_texts(value): + """Final-answer fields of known agent JSON output; tool and progress events are ignored.""" + if not isinstance(value, dict): + return [] + kind = value.get("type") + if kind == "result" and isinstance(value.get("result"), str): # Claude Code json / stream-json result + return [value["result"]] + if kind is None and isinstance(value.get("response"), str): # Gemini CLI json, which has no event type + return [value["response"]] + item = value.get("item") + if value.get("type") == "item.completed" and isinstance(item, dict) and item.get("type") == "agent_message" \ + and isinstance(item.get("text"), str): # Codex exec --json + return [item["text"]] + return [] + + +def relay_blocks(text): + found, start = [], text.find(RELAY_OPEN) + while start >= 0: + end = text.find(RELAY_CLOSE, start) + if end < 0: + break + body = text[start + len(RELAY_OPEN):end].strip() + if body.startswith("```"): + body = body.split("\n", 1)[1] if "\n" in body else "" + body = body.rsplit("```", 1)[0] + found.append(body.strip()) + start = text.find(RELAY_OPEN, end) + return found + + +def relay_extract(text): + """The last RELAY-RESULT block in the final answer: plain text, or a known JSON final-answer field.""" + stripped = text.strip() + if stripped.startswith("{"): + try: + whole = json.loads(stripped) + except ValueError: + whole = None + if whole is not None: + blocks = [b for t in relay_final_texts(whole) for b in relay_blocks(t)] + return blocks[-1] if blocks else None + candidates, raw, inside = [], [], False + for line in text.splitlines(): + decoded = None + if not inside and line.lstrip().startswith("{"): + try: + decoded = json.loads(line) + except ValueError: + decoded = None + if isinstance(decoded, dict): + candidates += relay_blocks("\n".join(raw)) + raw = [] + for final in relay_final_texts(decoded): + candidates += relay_blocks(final) + continue + raw.append(line) + # Lines inside an open block are its body, even when they look like JSON events. + opened = line.rfind(RELAY_OPEN) + if opened >= 0: + inside = RELAY_CLOSE not in line[opened:] + elif inside and RELAY_CLOSE in line: + inside = False + candidates += relay_blocks("\n".join(raw)) + return candidates[-1] if candidates else None + + +def relay_problems(record, result): + if not isinstance(result, dict): + return ["The result must be a JSON object"] + problems = [] + if result.get("outcome") not in RELAY_OUTCOMES: + problems.append("outcome must be one of " + ", ".join(RELAY_OUTCOMES)) + if not isinstance(result.get("summary"), str) or not result["summary"].strip(): + problems.append("summary is required") + if "checks" in result and (not isinstance(result["checks"], list) or not all( + isinstance(c, dict) and isinstance(c.get("name"), str) for c in result["checks"])): + problems.append("checks must be [{name, result}]") + for field in ("changed_files", "findings", "gaps"): + if field in result and not isinstance(result[field], list): + problems.append(field + " must be a list") + if "changed_files" in result and isinstance(result["changed_files"], list) and \ + not all(isinstance(f, str) for f in result["changed_files"]): + problems.append("changed_files must list paths") + if result.get("outcome") == "completed": + if record["mode"] == "consult" and not isinstance(result.get("answer"), str): + problems.append("a consult result needs answer") + if record["mode"] == "review": + if result.get("verdict") not in ("APPROVE", "CHANGES_REQUIRED"): + problems.append("a review needs verdict APPROVE or CHANGES_REQUIRED") + if not isinstance(result.get("findings"), list): + problems.append("a review needs a findings list") + if record["mode"] == "write": + for field in ("changed_files", "checks"): + if field not in result: + problems.append("a write result needs " + field) + return problems + + +def relay_in_scope(name, allowed): + """One-directional containment after normalization; traversal and absolute paths are never in scope.""" + raw = name.replace("\\", "/") + if raw.startswith("/") or re.match(r"^[A-Za-z]:", raw) or ".." in raw.split("/"): + return False + clean = posixpath.normpath(raw).casefold() + for scope in allowed: + scope = posixpath.normpath(scope.replace("\\", "/")).casefold() + if scope in ("*", ".") or clean == scope or clean.startswith(scope + "/"): + return True + return False + + +def relay_changes(root, state_dir): + """Changed and untracked paths of a workspace with content facts, or None when they cannot be read.""" + try: + snap = snapshot(root, state_dir) + if snap.get("limited"): + return None + names = [c["path"] if isinstance(c, dict) else c for c in snap["changes"]] + return file_facts(root, names, state_prefix(root, state_dir)) + except Problem: + return None + + +def relay_discrepancies(store, state, record, result): + """Compare a writer's claims with what the runtime recorded and the workspace shows; claims prove nothing.""" + facts = relay_worker_facts(state, record) + found = [] + if result.get("worker_session", record["worker_session"]) != record["worker_session"]: + found.append("Result names another worker session") + if facts["session"] == "not started": + found.append("The worker session never began in this coordinator") + latest = relay_latest_checks(state, record) + for check in result.get("checks") or []: + recorded = latest.get(check["name"]) + if check.get("result") == "pass" and (not recorded or recorded["result"] != "pass"): + found.append(f"Check {check['name']} is claimed to pass but the worker recorded " + f"{recorded['result'] if recorded else 'no run'}") + try: + p = phase(state, record["phase"]) + except Problem: + p = {"id": "", "paths": [], "required_checks": []} + root = Path(facts["root"]) if facts["root"] else None + if result.get("outcome") == "completed": + overall = {} + for c in sorted((c for c in state["checks"].values() if c["phase"] == p["id"]), key=lambda c: c["sequence"]): + overall[c["name"]] = c + for name in p["required_checks"]: + if name not in latest or overall[name]["id"] != latest[name]["id"]: + found.append(f"Required check {name} has no latest run by the worker") + if root and root.is_dir() and p["id"]: + # The same gate DONE uses, judged in the worker's workspace: fresh fingerprint and intact log. + found += ["Worker evidence would not satisfy DONE: " + gap + for gap in verification_gaps(Store(root, store.directory), state, p)] + claimed = [f for f in result.get("changed_files") or []] + outside = [f for f in claimed if not relay_in_scope(f, p["paths"])] + if outside: + found.append("Reported files outside the phase paths: " + ", ".join(outside[:10])) + if root and root.is_dir(): + now = relay_changes(root, store.directory) + baseline_path = relay_dir(store, record["name"]) / "baseline.json" + baseline = read_json(baseline_path) if baseline_path.is_file() and str(root) == record["workspace"] else None + if now is None: + found.append("Could not inspect the worker workspace") + elif baseline is None and root == store.root: + found.append("The worker used the orchestrator checkout without a recorded baseline; its changes " + "could not be told apart from others") + else: + base = baseline or {} + observed = sorted(f for f in set(now) | set(base) if now.get(f) != base.get(f)) + stray = [f for f in observed if not relay_in_scope(f, p["paths"])] + if stray: + found.append("Workspace changes outside the phase paths: " + ", ".join(stray[:10])) + unreported = [f for f in observed if f not in claimed and f not in stray] + if unreported: + found.append("Changed but not reported: " + ", ".join(unreported[:10])) + return found + + +def relay_collect(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + directory = relay_dir(store, record["name"]) + sources = [directory / "reply.txt"] + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + if runtime["status"] == "running": + raise Problem("delegation_running", "The worker is still running; wait for it or cancel it", 3) + if runtime["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {runtime['status']}: " + f"{runtime.get('reason', '')}. Confirm the worker stopped, then pass --confirm-stopped", 3) + _, process = relay_process(state, record) + if process: + sources.append(relay_path(store, process["directory"]) / "output.log") + if args.from_file: + sources = [Path(args.from_file).expanduser()] + body, used = None, "" + for source in sources: + if source.is_file(): + with open(source, "rb") as handle: + handle.seek(max(0, source.stat().st_size - MAX_RELAY_TEXT)) + body = relay_extract(handle.read().decode("utf-8", "replace")) + if body is not None: + used = str(source) + break + if body is None: + record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + relay_save(store, record) + return relay_view(store, state, record), adopted + try: + result = json.loads(body, object_pairs_hook=object_pairs) + problems = relay_problems(record, result) + except (ValueError, Problem) as exc: + result, problems = None, ["The block is not valid, unambiguous JSON: " + str(exc)[:200]] + if problems: + record.update(status="malformed", error="; ".join(problems)) + relay_save(store, record) + return relay_view(store, state, record), adopted + discrepancies = relay_discrepancies(store, state, record, result) if record["mode"] == "write" else [] + atomic_write(directory / "result.json", canonical(result) + "\n") + record.update(status="returned", finished_at=now(), result=result, source=used, + result_sha256=file_digest(directory / "result.json"), error="", discrepancies=discrepancies) + relay_save(store, record) + return relay_view(store, state, record), adopted + + +def relay_cancel(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + if record["transport"] == "cli": + _, process = relay_process(state, record) + stopped = stop_runtime(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + if stopped["status"] == "running": + raise Problem("delegation_running", "The worker did not stop; inspect it before retrying", 3) + if stopped["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {stopped['status']}: {stopped.get('reason', '')}. " + "Stop it yourself, then pass --confirm-stopped", 3) + elif not args.confirm_stopped: + raise Problem("arguments", "Cancel the host task first (for example with the host's cancel tool), then " + "pass --confirm-stopped") + record.update(status="cancelled", finished_at=now(), reason=args.reason or "cancelled by orchestrator") + relay_save(store, record) + view = relay_view(store, state, record) + if record["mode"] == "write" and state["sessions"].get(record["worker_session"], {}).get("status") == "active": + view["next"] = (f"Worker session {record['worker_session']} is still active; release it with release " + "--confirm-stopped once you have confirmed its agent stopped") + return view, adopted + + +def relay_path(store, relative): + return relative_path(store.directory, relative) + + +def relay_open(store, session_id=None): + return [r for r in relay_records(store) if r["status"] in RELAY_OPEN_STATES and + (session_id is None or r["session_id"] == session_id)] def running_checks(state, session_id, pid=None): @@ -1258,6 +1796,26 @@ def command_parser(): p.add_argument("--name", required=True) p.add_argument("--argv-file", help="JSON argv array instead of a command after --") p.add_argument("--keep", action="store_true") + p = sub.add_parser("delegate", help="Record and supervise work delegated to another agent; worker command after --") + p.add_argument("action", choices=("providers", "start", "status", "collect", "cancel")) + p.add_argument("--session") + p.add_argument("--expect-revision", type=int) + p.add_argument("--name", help="Unique delegation ID") + p.add_argument("--provider", help="Worker provider ID, for example codex, claude or gemini") + p.add_argument("--model", default="") + p.add_argument("--mode", choices=("consult", "review", "write")) + p.add_argument("--phase", help="Required for write: the phase the worker claims") + p.add_argument("--worker-session", help="Required for write: the new session ID the worker begins") + p.add_argument("--workspace", help="Worker checkout; a writer needs this checkout or a linked worktree") + p.add_argument("--packet", help="Instruction packet file given to the worker") + p.add_argument("--host-ref", help="Host task reference when no CLI command is given") + p.add_argument("--argv-file", help="JSON argv array instead of a command after --") + p.add_argument("--timeout", type=float, help="CLI worker: stop its process group after this many seconds") + p.add_argument("--keep", action="store_true", help="CLI worker: keep running when this session ends") + p.add_argument("--from", dest="from_file", help="collect: file holding the worker's final reply") + p.add_argument("--confirm-stopped", action="store_true", + help="cancel/collect: you verified the worker stopped (host task, or a CLI worker in unknown state)") + p.add_argument("--reason", default="") return parser @@ -1440,7 +1998,21 @@ def end(store, state, args): for name, record in state["processes"].items(): if record["session_id"] == args.session: processes[name] = runtime_view(store, record) if args.keep_runtimes or record["keep"] else stop_runtime(store, record) - return {"session_id": args.session, "processes": processes} + result = {"session_id": args.session, "processes": processes} + still_open = [] + for record in relay_open(store, args.session): + key, _ = relay_process(state, record) + stopped = processes.get(key) + if record["transport"] == "cli" and stopped and stopped["status"] == "exited" and record["status"] == "running": + record.update(status="cancelled", finished_at=now(), reason="orchestrator session ended") + relay_save(store, record) + else: + still_open.append(record["name"]) + if still_open: + result["open_delegations"] = still_open + result["warning"] = ("Delegations stay open: a later session adopts them with delegate status, collect " + "or cancel. Cancel host tasks you no longer need.") + return result def check_command(store, state, args): @@ -1489,9 +2061,14 @@ def mutate(store, state, args): return {"checkpoint": state["sessions"][args.session]["checkpoint"]["id"]}, "checkpoint:" + args.session if command == "end": return end(store, state, args), None + if command == "delegate": + if args.action == "start": + return relay_start(store, state, args), None + result, adopted = (relay_collect if args.action == "collect" else relay_cancel)(store, state, args) + return result, "delegate-adopt:" + args.session if adopted else None if command == "runtime": if args.action == "launch": - return launch_runtime(store, state, args, command_of(args)), None + return launch_runtime(store, state, args.session, args.name, command_of(args), args.keep), None record = state["processes"].get(args.session + "/" + args.name) if not record: raise Problem("runtime", "No runtime with this name belongs to this session") @@ -1556,6 +2133,10 @@ def read_only(store, args): "complete" if state["phases"] and all(p["state"] == "DONE" for p in state["phases"]) else "decision" return state, {"action": action, "phase": choices[0] if choices else None, "stale_done_evidence": stale, "not_done": [p["id"] for p in state["phases"] if p["state"] != "DONE"]} + if args.command == "delegate": + records = [relay_load(store, args.name)] if args.name else relay_records(store) + return state, {"delegations": [relay_view(store, state, r) for r in records + if args.name or not args.session or r["session_id"] == args.session]} record = state["processes"].get(args.session + "/" + args.name) if not record: raise Problem("runtime", "Unknown session runtime") @@ -1564,13 +2145,18 @@ def read_only(store, args): def dispatch(store, args): command = args.command - accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" + accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" \ + or command == "delegate" and args.action == "start" if args.inline is not None and not accepts_command: - raise Problem("arguments", "Only 'check run' and 'runtime launch' accept a command after --") + raise Problem("arguments", "Only 'check run', 'runtime launch' and 'delegate start' accept a command after --") + if command == "delegate" and args.action == "providers": + return None, relay_providers() + if command == "delegate" and args.action != "status": + require(args.session, "--session") if command == "snapshot": result = store.observe() return None, result if args.full else compact_snapshot(result) - if command in ("status", "next") or command == "runtime" and args.action == "status": + if command in ("status", "next") or command in ("runtime", "delegate") and args.action == "status": return read_only(store, args) if command == "check" and args.action == "run": return recorded_check(store, args) diff --git a/packs/session-workflow/skills/session-guide/SKILL.md b/packs/session-workflow/skills/session-guide/SKILL.md index 846ed12..32eb991 100644 --- a/packs/session-workflow/skills/session-guide/SKILL.md +++ b/packs/session-workflow/skills/session-guide/SKILL.md @@ -9,7 +9,7 @@ description: >- license: Apache-2.0 metadata: workflow-pack: session-workflow - workflow-version: "3.0.0" + workflow-version: "3.1.0" --- # Session guide @@ -47,6 +47,7 @@ another device continues from those files instead of from chat history. | Work longer than one step | "Use plan-and-orchestrate" | Splits it into phases with acceptance criteria, executes them, runs checks | | At a milestone | "Use session-handover" | Writes what changed and why, what is verified and what is left | | When stopping | "Use session-end" | Saves the exact next step and stops the servers it started | +| Another AI should help | "Use agent-relay. Ask Codex / have Claude review / delegate P2" | Runs that provider as a recorded subagent and checks what it reports | **Files.** - PROJECT.md holds the goal and next step, docs/plans/ the plan, and @@ -59,13 +60,16 @@ another device continues from those files instead of from chat history. - It marks a phase DONE only after a check passed. Otherwise the phase stays VERIFY, with the gap named. -**What it never does without you.** Commit, push, deploy, paid runs or -deleting data. A plan alone does not authorize implementation. +**What it never does without you.** Commit, push, deploy, paid runs +(including other AI providers) or deleting data. A plan alone does not authorize implementation. **When you are needed.** - **Several agents in one folder:** tell one of them they work in parallel. The folder then stays in parallel mode until all of them end, and none takes - over another. + over another. This works across providers too: Codex, Claude and Gemini share + the same records. +- **Another provider as a helper:** name the provider. Each run costs money, + so the agent asks first. - **The agent asks whether a chat is closed:** answer, and it releases that chat's unfinished work. - **A phase stays VERIFY:** something was not checked, for example the UI. diff --git a/packs/session-workflow/skills/session-guide/references/runtime.md b/packs/session-workflow/skills/session-guide/references/runtime.md index ac4a73f..0b95d32 100644 --- a/packs/session-workflow/skills/session-guide/references/runtime.md +++ b/packs/session-workflow/skills/session-guide/references/runtime.md @@ -112,6 +112,62 @@ another machine reports `unknown` or `foreign`; the runtime never kills a recorded PID. On Windows the supervisor ends the process tree through `taskkill /T` while the child is alive. +## Delegations + +| Command | Purpose | +| --- | --- | +| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate start --session ID --name N --provider P --mode consult\|review\|write --packet F [--model M] [--phase P --worker-session W] [--workspace DIR] [--timeout S] -- CMD...` | Record a delegation and run a CLI worker as a supervised runtime `relay-N` | +| `delegate start ... --host-ref REF` (no command) | Record a host-native child task, such as a T3 task ID | +| `delegate status [--session ID] [--name N]` | Read-only: records, worker runtime, writer facts and result | +| `delegate collect --session ID --name N [--from FILE] [--confirm-stopped]` | Parse the worker's final RELAY-RESULT block from `--from`, `reply.txt` or the CLI worker's stdout | +| `delegate cancel --session ID --name N [--confirm-stopped]` | Stop a CLI worker, or close a host task you already cancelled | + +- Records live in `delegations//` under the state directory: the packet, + `record.json` and `result.json` with SHA-256 hashes. `state.json` and its + schema do not change, so a 3.0 runtime can still take part as a worker. +- In the worker command, `{packet}`, `{packet_text}`, `{reply_file}`, + `{workspace}` and `{state_dir}` are replaced. The worker runs in `--workspace` + without a shell or stdin. Its stdout and stderr go to separate logs. + `--timeout` stops its process group after a 10-second grace period. Within + it, a `check run` started by the worker stops its own check group (1-second + grace, then SIGKILL) and records the run as `abandoned`. +- A writer needs an unclaimed phase that is not DONE and a new worker session + ID. A given `--workspace` must share this coordinator (this checkout or a + linked worktree); a host task may omit it when the host creates the + worktree. The worker begins that session with `--parallel`, claims the phase + itself and does not commit: the integrator owns Git history. +- `collect` takes the last block of the final answer. That is plain text, or + one of: a Claude event of type `result`, Gemini's untyped `response`, or a + Codex `agent_message` item. Blocks in other events, such as tool results, + are ignored. Duplicate JSON keys make a result `malformed`. A missing or invalid block leaves the record + `malformed`, and it can be collected again. While a CLI worker runs, + `collect` refuses, even with `--from`. +- For a writer, `discrepancies` lists: + - claimed passing checks the worker did not record as passing; + - required checks whose latest run is not the worker's, or whose evidence + would not pass the DONE gate in the worker's workspace (fingerprint and + log); + - reported files outside the phase paths (traversal and absolute paths + included); + - changes the worker's workspace shows outside the phase paths, or did not + report. These are compared with a baseline taken when the delegation was + recorded, so earlier changes in a shared checkout do not count; + - a worker session that never began. + + Claims alone prove nothing. +- A writer recorded after its host task already began and claimed the phase + is accepted when that session is parallel and began after the orchestrator. +- `cancel`, `collect` and `end` treat a CLI worker as stopped only when its + receipt says `exited`. For `unknown` or `foreign`, confirm that it stopped + and pass `--confirm-stopped`; `end` leaves such records open. +- `end` stops the session's CLI workers like other runtimes and cancels their + records. It reports host tasks and kept workers as `open_delegations`. + `begin` and `status` list them. A session that takes over by solo `begin` + inherits them once its state is saved. Any later session adopts the record + of an ended orchestrator, together with its kept CLI process, on `collect` + or `cancel`. + ## Handover output `handover [--output FILE]` and `end --handover-output FILE` replace only the diff --git a/packs/session-workflow/skills/session-guide/references/workflow-contract.md b/packs/session-workflow/skills/session-guide/references/workflow-contract.md index a635b44..9eb04ba 100644 --- a/packs/session-workflow/skills/session-guide/references/workflow-contract.md +++ b/packs/session-workflow/skills/session-guide/references/workflow-contract.md @@ -1,4 +1,4 @@ -# estafeta contract - 3.0.0 +# estafeta contract - 3.1.0 Load once per chat. Commands: [quickstart](quickstart.md). Rare options: [runtime](runtime.md). diff --git a/packs/session-workflow/skills/session-guide/scripts/session.py b/packs/session-workflow/skills/session-guide/scripts/session.py index 00f5079..849f465 100644 --- a/packs/session-workflow/skills/session-guide/scripts/session.py +++ b/packs/session-workflow/skills/session-guide/scripts/session.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """Deterministic session facts and state. Python 3.9+, standard library only. -No model/API calls, no Git writes, no project migration and no kills outside a -process group this runtime supervises. Run --help or a subcommand --help for +No model/API calls of its own (a delegated worker is a command the caller +supplies), no Git writes, no project migration and no kills outside a process +group this runtime supervises. Run --help or a subcommand --help for the JSON CLI. See references/runtime.md. """ from __future__ import annotations @@ -16,11 +17,14 @@ import os from pathlib import Path import platform +import posixpath import re +import shutil import signal import subprocess import sys import tempfile +import threading import time import uuid @@ -777,6 +781,10 @@ def brief(state, store, session_id=None, live=None): "live_workspace": compact_snapshot(live) if live is not None else None} if actor and actor["status"] != "active": result["session_status"] = "ended: " + actor["ended_reason"] + delegations = relay_open(store) + if delegations: + result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], + "status": r["status"], "orchestrator": r["session_id"]} for r in delegations[:20]] warnings = snapshot_warnings(live) if warnings: result["warnings"] = warnings @@ -896,6 +904,8 @@ def begin(store, args): "Inspect the plan and choose a ready phase" new_checkpoint(store, state, session_id, next_action, workspace=live) store.save(state, "begin:" + session_id) + for item in superseded: + relay_transfer(store, item["id"], session_id) result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded @@ -978,6 +988,10 @@ def cell(value): "| Phase | State | Owner | Latest checks |", "| --- | --- | --- | --- |", *rows, "", "Active sessions: " + ("; ".join(f"{cell(sid)} (next: {cell(s['checkpoint'].get('next_action', ''))})" for sid, s in active) or "none") + "."] + delegations = relay_open(store) + if delegations: + lines.append("Open delegations: " + "; ".join(f"{cell(r['name'])} ({cell(r['provider'])} {r['mode']}, " + f"{r['status']})" for r in delegations[:10]) + ".") if ended: sid, s = max(ended, key=lambda item: item[1]["ended_at"]) lines.append(f"Ended sessions: {len(ended)}; latest {cell(sid)} " @@ -1021,7 +1035,7 @@ def group_alive(pgid): return True -def terminate_group(child): +def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": if child.poll() is None: @@ -1031,7 +1045,7 @@ def terminate_group(child): os.killpg(child.pid, signal.SIGTERM) except ProcessLookupError: return - deadline = time.monotonic() + 4 + deadline = time.monotonic() + grace while time.monotonic() < deadline: child.poll() if not group_alive(child.pid): @@ -1047,6 +1061,27 @@ def group_kwargs(): return {"start_new_session": True} if os.name != "nt" else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} +class Interrupted(Exception): + pass + + +@contextmanager +def interrupt_guard(): + """Turn SIGTERM/SIGHUP into Interrupted while a check runs, so its separate process group is not orphaned.""" + names = [n for n in ("SIGTERM", "SIGHUP") if hasattr(signal, n)] + if threading.current_thread() is not threading.main_thread(): + yield + return + def handler(signum, frame): + raise Interrupted() + previous = {n: signal.signal(getattr(signal, n), handler) for n in names} + try: + yield + finally: + for n, h in previous.items(): + signal.signal(getattr(signal, n), h) + + def run_check(store, state, p, args, run_id, command, before): log = store.directory / "checks" / (run_id + ".log") log.parent.mkdir(parents=True, exist_ok=True) @@ -1056,12 +1091,20 @@ def run_check(store, state, p, args, run_id, command, before): child = subprocess.Popen(command, cwd=store.root, stdout=output, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: - code = child.wait(timeout=args.timeout) + with interrupt_guard(): + code = child.wait(timeout=args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) child.wait() code, outcome = None, "timeout" + except Interrupted: + # The caller was stopped (for example a timed-out delegated worker): stop the check's own group too, + # with a short grace so this finishes well inside the supervisor's longer relay grace period. + terminate_group(child, grace=1.0) + child.wait() + output.write(b"\n[session-workflow] check stopped because its caller was terminated\n") + code, outcome = None, "abandoned" except OSError as exc: output.write(str(exc).encode("utf-8", "replace")) code, outcome = None, "error" @@ -1104,7 +1147,7 @@ def stop_runtime(store, record): return status directory = relative_path(store.directory, record["directory"]) atomic_write(directory / "stop.json", canonical({"run_id": record["run_id"]}) + "\n") - deadline = time.monotonic() + 10 + deadline = time.monotonic() + 15 while time.monotonic() < deadline: status = runtime_view(store, record) if status["status"] != "running": @@ -1126,10 +1169,14 @@ def supervise(spec_path): directory = spec_path.parent receipt_path = directory / "receipt.json" try: - with open(directory / "output.log", "ab") as output: - child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, stderr=subprocess.STDOUT, + # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + with open(directory / "output.log", "ab") as output, \ + open(directory / spec.get("stderr", "output.log"), "ab") as errors: + child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, + stderr=errors if spec.get("stderr") else subprocess.STDOUT, stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 + started, limit, timed_out = time.monotonic(), spec.get("timeout"), False # Descendants keep the group alive after the leader exits; supervise until all are gone. while child.poll() is None or group_alive(child.pid): if time.monotonic() - beat >= HEARTBEAT_SECONDS: @@ -1138,33 +1185,45 @@ def supervise(spec_path): "leader_exited": child.returncode is not None}) + "\n", durable=False) beat = time.monotonic() if stop.exists() and read_json(stop).get("run_id") == spec["run_id"]: - terminate_group(child) + terminate_group(child, spec.get("grace", 4.0)) + break + if limit and time.monotonic() - started > limit: + terminate_group(child, spec.get("grace", 4.0)) + timed_out = True break time.sleep(0.2) child.poll() receipt = {"run_id": spec["run_id"], "status": "exited", "exit_code": child.returncode, "scope": "supervised process group" if os.name != "nt" else "supervised process tree"} + if timed_out: + receipt["timed_out"] = True except Exception as exc: receipt = {"run_id": spec["run_id"], "status": "unknown", "reason": str(exc)[:500]} atomic_write(receipt_path, canonical(receipt) + "\n") -def launch_runtime(store, state, args, command): - key = args.session + "/" + args.name +def launch_runtime(store, state, session_id, name, command, keep=False, cwd=None, timeout=None, run_id=None, + split_stderr=False): + key = session_id + "/" + name if key in state["processes"]: raise Problem("runtime", "Runtime name already recorded; inspect it before creating a new name") - run_id = uuid.uuid4().hex + run_id = run_id or uuid.uuid4().hex directory = store.directory / "runtime" / run_id directory.mkdir(parents=True) - spec = {"run_id": run_id, "argv": command, "root": str(store.root)} + spec = {"run_id": run_id, "argv": command, "root": str(cwd or store.root)} + if timeout: + spec["timeout"] = timeout + if split_stderr: + # A delegated worker: its nested check runners need time to stop their own groups first. + spec.update(stderr="stderr.log", grace=10.0) spec_path = directory / "spec.json" atomic_write(spec_path, canonical(spec) + "\n") - record = {"run_id": run_id, "session_id": args.session, "machine": store.machine, + record = {"run_id": run_id, "session_id": session_id, "machine": store.machine, "root": str(store.root), "directory": directory.relative_to(store.directory).as_posix(), - "keep": args.keep} + "keep": keep} # Persist identity before launch; a crash cannot produce an unrecorded child. state["processes"][key] = record - store.save(state, "runtime-prepared:" + args.name) + store.save(state, "runtime-prepared:" + name) SUPERVISORS[run_id] = subprocess.Popen( [sys.executable, str(Path(__file__).resolve()), "_supervise", str(spec_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **group_kwargs()) @@ -1172,9 +1231,488 @@ def launch_runtime(store, state, args, command): while time.monotonic() < deadline: status = runtime_view(store, record) if status["status"] != "unknown": - return {"name": args.name, **status} + return {"name": name, **status} time.sleep(0.05) - return {"name": args.name, **runtime_view(store, record)} + return {"name": name, **runtime_view(store, record)} + + +# --- Delegation ledger ------------------------------------------------------- +# One record per delegated task under /delegations//. It lives outside +# state.json so schema 2 is unchanged and a 3.0 runtime can still join as a worker. +RELAY_OPEN, RELAY_CLOSE = "<<>>" +RELAY_OPEN_STATES = ("running", "malformed") +RELAY_OUTCOMES = ("completed", "blocked", "failed") +PROVIDER_CLIS = ("claude", "codex", "gemini") +MAX_RELAY_TEXT = 4 * 1024 * 1024 + + +def relay_dir(store, name=""): + base = store.directory / "delegations" + return base / name if name else base + + +def relay_load(store, name): + path = relay_dir(store, name) / "record.json" + if not path.is_file(): + raise Problem("delegation", "Unknown delegation name; list them with delegate status") + return read_json(path) + + +def relay_save(store, record): + atomic_write(relay_dir(store, record["name"]) / "record.json", canonical(record) + "\n") + + +def relay_records(store): + base = relay_dir(store) + records = [] + if base.is_dir(): + for directory in sorted(base.iterdir()): + try: + records.append(read_json(directory / "record.json")) + except (Problem, OSError): + continue + return records + + +def relay_owned(store, state, args): + """The caller's record. A record whose orchestrator has ended is adopted together with its CLI process.""" + record = relay_load(store, require(args.name, "--name")) + owner, adopted = record["session_id"], False + if owner != args.session: + if state["sessions"].get(owner, {}).get("status") == "active": + raise Problem("ownership", f"Delegation {record['name']} belongs to active session {owner}", 3) + record["adopted_from"], record["session_id"] = owner, args.session + key, process = relay_process(state, record) + if process and process["session_id"] != args.session and \ + state["sessions"].get(process["session_id"], {}).get("status") != "active": + new_key = args.session + "/" + key.split("/", 1)[1] + if new_key in state["processes"]: + raise Problem("runtime", "This session already has a runtime with that name", 3) + process["session_id"] = args.session + state["processes"][new_key] = state["processes"].pop(key) + adopted = True + return record, adopted + + +def relay_transfer(store, retired, successor): + """Called after the superseding begin was saved; a crash before it leaves adoption as the fallback.""" + for record in relay_records(store): + if record["session_id"] == retired and record["status"] in RELAY_OPEN_STATES: + record["session_id"] = successor + relay_save(store, record) + + +def relay_process(state, record): + for key, process in state["processes"].items(): + if record.get("run_id") and process["run_id"] == record["run_id"]: + return key, process + return None, None + + +def relay_runtime(store, state, record): + _, process = relay_process(state, record) + return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + + +def relay_providers(): + found = [] + for name in PROVIDER_CLIS: + path = shutil.which(name) + item = {"provider": name, "cli": path or "", "version": ""} + if path: + try: + done = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=15, + stdin=subprocess.DEVNULL) + item["version"] = (done.stdout or done.stderr).strip().splitlines()[0][:200] if \ + (done.stdout or done.stderr).strip() else "" + except (OSError, subprocess.TimeoutExpired): + item["version"] = "unknown" + found.append(item) + return {"cli": found, "note": "Host-native subagents (for example T3 delegate_task) are discovered by the agent, " + "not by this runtime"} + + +def relay_start(store, state, args): + name = require(args.name, "--name") + validate(name, "id") + validate(require(args.provider, "--provider"), "id") + mode = require(args.mode, "--mode") + cli = args.inline is not None or bool(args.argv_file) + if relay_dir(store, name).exists(): + raise Problem("delegation", "Delegation name already used; choose a new name", 3) + packet = Path(require(args.packet, "--packet")).expanduser() + text = packet.read_text(encoding="utf-8") + if not text.strip(): + raise Problem("arguments", "The packet is empty") + workspace = Path(args.workspace).expanduser().resolve() if args.workspace else store.root + if not workspace.is_dir(): + raise Problem("workspace", "The worker workspace does not exist") + if mode == "write": + worker = require(args.worker_session, "--worker-session") + validate(worker, "id") + p = phase(state, require(args.phase, "--phase")) + if p["state"] == "DONE": + raise Problem("transition", f"Phase {p['id']} is DONE; reopen it before delegating", 3) + # A host task can start, and its worker begin, before the orchestrator records it. + started = state["sessions"].get(worker) + if p["owner"] and p["owner"] != worker: + raise Problem("ownership", f"Phase {p['id']} is claimed by {p['owner']}; the worker must claim it. " + "Release it first with a transition to TODO", 3) + if worker == args.session or started and (cli or not started["parallel"] or + started["started_at"] < record_floor(state, args.session)): + raise Problem("session_exists", "The worker session ID is already used; choose a new one", 3) + if Store(workspace).directory != store.directory: + raise Problem("workspace", "A writer's workspace must share this coordinator: this checkout or a " + "linked Git worktree of it", 3) + elif args.phase: + phase(state, args.phase) + if not cli and args.timeout: + raise Problem("arguments", "--timeout applies only to a CLI worker command") + if args.timeout is not None and not 0 < args.timeout <= 86400: + raise Problem("arguments", "Timeout must be greater than 0 and at most 86400 seconds") + directory = relay_dir(store, name) + command = [] + if cli: + values = {"{packet}": str(directory / "packet.md"), "{packet_text}": text, + "{reply_file}": str(directory / "reply.txt"), "{workspace}": str(workspace), + "{state_dir}": str(store.directory)} + for item in command_of(args): + for token, value in values.items(): + item = item.replace(token, value) + command.append(item) + try: + validate(command, "argv") + except Problem: + raise Problem("arguments", "Worker command is too long; pass {packet} (a path) instead of {packet_text}") + directory.mkdir(parents=True) + atomic_write(directory / "packet.md", text) + record = {"name": name, "session_id": args.session, "machine": store.machine, "provider": args.provider, + "model": args.model or "", "mode": mode, "transport": "cli" if cli else "host", + "phase": args.phase or "", "worker_session": args.worker_session or "", + "workspace": str(workspace) if cli or args.workspace else "", "host_ref": args.host_ref or "", + "run_id": uuid.uuid4().hex if cli else "", + "packet": (directory / "packet.md").relative_to(store.directory).as_posix(), + "packet_sha256": file_digest(directory / "packet.md"), "status": "running", + "started_at": now(), "finished_at": ""} + if mode == "write" and state["sessions"].get(record["worker_session"]): + record["recorded_after_worker_began"] = True + if mode == "write" and record["workspace"]: + # Changes already present are not the worker's; collect compares against this baseline. + baseline = relay_changes(workspace, store.directory) + if baseline is not None: + atomic_write(directory / "baseline.json", canonical(baseline) + "\n") + relay_save(store, record) + result = {"name": name, "transport": record["transport"], "packet": str(directory / "packet.md"), + "reply_file": str(directory / "reply.txt")} + if cli: + result["runtime"] = launch_runtime(store, state, args.session, "relay-" + name, command, keep=args.keep, + cwd=workspace, timeout=args.timeout, run_id=record["run_id"], + split_stderr=True) + return result + + +def record_floor(state, session_id): + """A worker that began before its orchestrator did cannot belong to this delegation.""" + return state["sessions"][session_id]["started_at"] + + +def relay_latest_checks(state, record): + latest = {} + for check in sorted(state["checks"].values(), key=lambda item: item["sequence"]): + if check["phase"] == record["phase"] and check["session_id"] == record["worker_session"]: + latest[check["name"]] = check + return latest + + +def relay_worker_facts(state, record): + latest = {name: c["result"] if c["provenance"] == "executed" else c["result"] + " (" + c["provenance"] + ")" + for name, c in relay_latest_checks(state, record).items()} + worker = state["sessions"].get(record["worker_session"]) + try: + p = phase(state, record["phase"]) + except Problem: + p = {"state": "missing", "owner": ""} + return {"session": worker["status"] + (": " + worker["ended_reason"] if worker["ended_reason"] else "") + if worker else "not started", "root": worker["root"] if worker else "", "phase_state": p["state"], + "phase_owner": p["owner"], "recorded_checks": latest} + + +def relay_view(store, state, record): + view = {key: record[key] for key in ("name", "session_id", "provider", "model", "mode", "transport", "phase", + "worker_session", "host_ref", "status", "started_at", "finished_at")} + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + view["runtime"] = {key: runtime[key] for key in ("status", "exit_code", "timed_out", "reason") if key in runtime} + if record["status"] == "running" and runtime["status"] == "exited": + view["status"] = "exited" + if record["mode"] == "write": + view["worker"] = relay_worker_facts(state, record) + if record.get("result"): + view["result"] = record["result"] + for key in ("error", "discrepancies", "reason", "adopted_from", "recorded_after_worker_began"): + if record.get(key): + view[key] = record[key] + return view + + +def relay_final_texts(value): + """Final-answer fields of known agent JSON output; tool and progress events are ignored.""" + if not isinstance(value, dict): + return [] + kind = value.get("type") + if kind == "result" and isinstance(value.get("result"), str): # Claude Code json / stream-json result + return [value["result"]] + if kind is None and isinstance(value.get("response"), str): # Gemini CLI json, which has no event type + return [value["response"]] + item = value.get("item") + if value.get("type") == "item.completed" and isinstance(item, dict) and item.get("type") == "agent_message" \ + and isinstance(item.get("text"), str): # Codex exec --json + return [item["text"]] + return [] + + +def relay_blocks(text): + found, start = [], text.find(RELAY_OPEN) + while start >= 0: + end = text.find(RELAY_CLOSE, start) + if end < 0: + break + body = text[start + len(RELAY_OPEN):end].strip() + if body.startswith("```"): + body = body.split("\n", 1)[1] if "\n" in body else "" + body = body.rsplit("```", 1)[0] + found.append(body.strip()) + start = text.find(RELAY_OPEN, end) + return found + + +def relay_extract(text): + """The last RELAY-RESULT block in the final answer: plain text, or a known JSON final-answer field.""" + stripped = text.strip() + if stripped.startswith("{"): + try: + whole = json.loads(stripped) + except ValueError: + whole = None + if whole is not None: + blocks = [b for t in relay_final_texts(whole) for b in relay_blocks(t)] + return blocks[-1] if blocks else None + candidates, raw, inside = [], [], False + for line in text.splitlines(): + decoded = None + if not inside and line.lstrip().startswith("{"): + try: + decoded = json.loads(line) + except ValueError: + decoded = None + if isinstance(decoded, dict): + candidates += relay_blocks("\n".join(raw)) + raw = [] + for final in relay_final_texts(decoded): + candidates += relay_blocks(final) + continue + raw.append(line) + # Lines inside an open block are its body, even when they look like JSON events. + opened = line.rfind(RELAY_OPEN) + if opened >= 0: + inside = RELAY_CLOSE not in line[opened:] + elif inside and RELAY_CLOSE in line: + inside = False + candidates += relay_blocks("\n".join(raw)) + return candidates[-1] if candidates else None + + +def relay_problems(record, result): + if not isinstance(result, dict): + return ["The result must be a JSON object"] + problems = [] + if result.get("outcome") not in RELAY_OUTCOMES: + problems.append("outcome must be one of " + ", ".join(RELAY_OUTCOMES)) + if not isinstance(result.get("summary"), str) or not result["summary"].strip(): + problems.append("summary is required") + if "checks" in result and (not isinstance(result["checks"], list) or not all( + isinstance(c, dict) and isinstance(c.get("name"), str) for c in result["checks"])): + problems.append("checks must be [{name, result}]") + for field in ("changed_files", "findings", "gaps"): + if field in result and not isinstance(result[field], list): + problems.append(field + " must be a list") + if "changed_files" in result and isinstance(result["changed_files"], list) and \ + not all(isinstance(f, str) for f in result["changed_files"]): + problems.append("changed_files must list paths") + if result.get("outcome") == "completed": + if record["mode"] == "consult" and not isinstance(result.get("answer"), str): + problems.append("a consult result needs answer") + if record["mode"] == "review": + if result.get("verdict") not in ("APPROVE", "CHANGES_REQUIRED"): + problems.append("a review needs verdict APPROVE or CHANGES_REQUIRED") + if not isinstance(result.get("findings"), list): + problems.append("a review needs a findings list") + if record["mode"] == "write": + for field in ("changed_files", "checks"): + if field not in result: + problems.append("a write result needs " + field) + return problems + + +def relay_in_scope(name, allowed): + """One-directional containment after normalization; traversal and absolute paths are never in scope.""" + raw = name.replace("\\", "/") + if raw.startswith("/") or re.match(r"^[A-Za-z]:", raw) or ".." in raw.split("/"): + return False + clean = posixpath.normpath(raw).casefold() + for scope in allowed: + scope = posixpath.normpath(scope.replace("\\", "/")).casefold() + if scope in ("*", ".") or clean == scope or clean.startswith(scope + "/"): + return True + return False + + +def relay_changes(root, state_dir): + """Changed and untracked paths of a workspace with content facts, or None when they cannot be read.""" + try: + snap = snapshot(root, state_dir) + if snap.get("limited"): + return None + names = [c["path"] if isinstance(c, dict) else c for c in snap["changes"]] + return file_facts(root, names, state_prefix(root, state_dir)) + except Problem: + return None + + +def relay_discrepancies(store, state, record, result): + """Compare a writer's claims with what the runtime recorded and the workspace shows; claims prove nothing.""" + facts = relay_worker_facts(state, record) + found = [] + if result.get("worker_session", record["worker_session"]) != record["worker_session"]: + found.append("Result names another worker session") + if facts["session"] == "not started": + found.append("The worker session never began in this coordinator") + latest = relay_latest_checks(state, record) + for check in result.get("checks") or []: + recorded = latest.get(check["name"]) + if check.get("result") == "pass" and (not recorded or recorded["result"] != "pass"): + found.append(f"Check {check['name']} is claimed to pass but the worker recorded " + f"{recorded['result'] if recorded else 'no run'}") + try: + p = phase(state, record["phase"]) + except Problem: + p = {"id": "", "paths": [], "required_checks": []} + root = Path(facts["root"]) if facts["root"] else None + if result.get("outcome") == "completed": + overall = {} + for c in sorted((c for c in state["checks"].values() if c["phase"] == p["id"]), key=lambda c: c["sequence"]): + overall[c["name"]] = c + for name in p["required_checks"]: + if name not in latest or overall[name]["id"] != latest[name]["id"]: + found.append(f"Required check {name} has no latest run by the worker") + if root and root.is_dir() and p["id"]: + # The same gate DONE uses, judged in the worker's workspace: fresh fingerprint and intact log. + found += ["Worker evidence would not satisfy DONE: " + gap + for gap in verification_gaps(Store(root, store.directory), state, p)] + claimed = [f for f in result.get("changed_files") or []] + outside = [f for f in claimed if not relay_in_scope(f, p["paths"])] + if outside: + found.append("Reported files outside the phase paths: " + ", ".join(outside[:10])) + if root and root.is_dir(): + now = relay_changes(root, store.directory) + baseline_path = relay_dir(store, record["name"]) / "baseline.json" + baseline = read_json(baseline_path) if baseline_path.is_file() and str(root) == record["workspace"] else None + if now is None: + found.append("Could not inspect the worker workspace") + elif baseline is None and root == store.root: + found.append("The worker used the orchestrator checkout without a recorded baseline; its changes " + "could not be told apart from others") + else: + base = baseline or {} + observed = sorted(f for f in set(now) | set(base) if now.get(f) != base.get(f)) + stray = [f for f in observed if not relay_in_scope(f, p["paths"])] + if stray: + found.append("Workspace changes outside the phase paths: " + ", ".join(stray[:10])) + unreported = [f for f in observed if f not in claimed and f not in stray] + if unreported: + found.append("Changed but not reported: " + ", ".join(unreported[:10])) + return found + + +def relay_collect(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + directory = relay_dir(store, record["name"]) + sources = [directory / "reply.txt"] + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + if runtime["status"] == "running": + raise Problem("delegation_running", "The worker is still running; wait for it or cancel it", 3) + if runtime["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {runtime['status']}: " + f"{runtime.get('reason', '')}. Confirm the worker stopped, then pass --confirm-stopped", 3) + _, process = relay_process(state, record) + if process: + sources.append(relay_path(store, process["directory"]) / "output.log") + if args.from_file: + sources = [Path(args.from_file).expanduser()] + body, used = None, "" + for source in sources: + if source.is_file(): + with open(source, "rb") as handle: + handle.seek(max(0, source.stat().st_size - MAX_RELAY_TEXT)) + body = relay_extract(handle.read().decode("utf-8", "replace")) + if body is not None: + used = str(source) + break + if body is None: + record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + relay_save(store, record) + return relay_view(store, state, record), adopted + try: + result = json.loads(body, object_pairs_hook=object_pairs) + problems = relay_problems(record, result) + except (ValueError, Problem) as exc: + result, problems = None, ["The block is not valid, unambiguous JSON: " + str(exc)[:200]] + if problems: + record.update(status="malformed", error="; ".join(problems)) + relay_save(store, record) + return relay_view(store, state, record), adopted + discrepancies = relay_discrepancies(store, state, record, result) if record["mode"] == "write" else [] + atomic_write(directory / "result.json", canonical(result) + "\n") + record.update(status="returned", finished_at=now(), result=result, source=used, + result_sha256=file_digest(directory / "result.json"), error="", discrepancies=discrepancies) + relay_save(store, record) + return relay_view(store, state, record), adopted + + +def relay_cancel(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + if record["transport"] == "cli": + _, process = relay_process(state, record) + stopped = stop_runtime(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + if stopped["status"] == "running": + raise Problem("delegation_running", "The worker did not stop; inspect it before retrying", 3) + if stopped["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {stopped['status']}: {stopped.get('reason', '')}. " + "Stop it yourself, then pass --confirm-stopped", 3) + elif not args.confirm_stopped: + raise Problem("arguments", "Cancel the host task first (for example with the host's cancel tool), then " + "pass --confirm-stopped") + record.update(status="cancelled", finished_at=now(), reason=args.reason or "cancelled by orchestrator") + relay_save(store, record) + view = relay_view(store, state, record) + if record["mode"] == "write" and state["sessions"].get(record["worker_session"], {}).get("status") == "active": + view["next"] = (f"Worker session {record['worker_session']} is still active; release it with release " + "--confirm-stopped once you have confirmed its agent stopped") + return view, adopted + + +def relay_path(store, relative): + return relative_path(store.directory, relative) + + +def relay_open(store, session_id=None): + return [r for r in relay_records(store) if r["status"] in RELAY_OPEN_STATES and + (session_id is None or r["session_id"] == session_id)] def running_checks(state, session_id, pid=None): @@ -1258,6 +1796,26 @@ def command_parser(): p.add_argument("--name", required=True) p.add_argument("--argv-file", help="JSON argv array instead of a command after --") p.add_argument("--keep", action="store_true") + p = sub.add_parser("delegate", help="Record and supervise work delegated to another agent; worker command after --") + p.add_argument("action", choices=("providers", "start", "status", "collect", "cancel")) + p.add_argument("--session") + p.add_argument("--expect-revision", type=int) + p.add_argument("--name", help="Unique delegation ID") + p.add_argument("--provider", help="Worker provider ID, for example codex, claude or gemini") + p.add_argument("--model", default="") + p.add_argument("--mode", choices=("consult", "review", "write")) + p.add_argument("--phase", help="Required for write: the phase the worker claims") + p.add_argument("--worker-session", help="Required for write: the new session ID the worker begins") + p.add_argument("--workspace", help="Worker checkout; a writer needs this checkout or a linked worktree") + p.add_argument("--packet", help="Instruction packet file given to the worker") + p.add_argument("--host-ref", help="Host task reference when no CLI command is given") + p.add_argument("--argv-file", help="JSON argv array instead of a command after --") + p.add_argument("--timeout", type=float, help="CLI worker: stop its process group after this many seconds") + p.add_argument("--keep", action="store_true", help="CLI worker: keep running when this session ends") + p.add_argument("--from", dest="from_file", help="collect: file holding the worker's final reply") + p.add_argument("--confirm-stopped", action="store_true", + help="cancel/collect: you verified the worker stopped (host task, or a CLI worker in unknown state)") + p.add_argument("--reason", default="") return parser @@ -1440,7 +1998,21 @@ def end(store, state, args): for name, record in state["processes"].items(): if record["session_id"] == args.session: processes[name] = runtime_view(store, record) if args.keep_runtimes or record["keep"] else stop_runtime(store, record) - return {"session_id": args.session, "processes": processes} + result = {"session_id": args.session, "processes": processes} + still_open = [] + for record in relay_open(store, args.session): + key, _ = relay_process(state, record) + stopped = processes.get(key) + if record["transport"] == "cli" and stopped and stopped["status"] == "exited" and record["status"] == "running": + record.update(status="cancelled", finished_at=now(), reason="orchestrator session ended") + relay_save(store, record) + else: + still_open.append(record["name"]) + if still_open: + result["open_delegations"] = still_open + result["warning"] = ("Delegations stay open: a later session adopts them with delegate status, collect " + "or cancel. Cancel host tasks you no longer need.") + return result def check_command(store, state, args): @@ -1489,9 +2061,14 @@ def mutate(store, state, args): return {"checkpoint": state["sessions"][args.session]["checkpoint"]["id"]}, "checkpoint:" + args.session if command == "end": return end(store, state, args), None + if command == "delegate": + if args.action == "start": + return relay_start(store, state, args), None + result, adopted = (relay_collect if args.action == "collect" else relay_cancel)(store, state, args) + return result, "delegate-adopt:" + args.session if adopted else None if command == "runtime": if args.action == "launch": - return launch_runtime(store, state, args, command_of(args)), None + return launch_runtime(store, state, args.session, args.name, command_of(args), args.keep), None record = state["processes"].get(args.session + "/" + args.name) if not record: raise Problem("runtime", "No runtime with this name belongs to this session") @@ -1556,6 +2133,10 @@ def read_only(store, args): "complete" if state["phases"] and all(p["state"] == "DONE" for p in state["phases"]) else "decision" return state, {"action": action, "phase": choices[0] if choices else None, "stale_done_evidence": stale, "not_done": [p["id"] for p in state["phases"] if p["state"] != "DONE"]} + if args.command == "delegate": + records = [relay_load(store, args.name)] if args.name else relay_records(store) + return state, {"delegations": [relay_view(store, state, r) for r in records + if args.name or not args.session or r["session_id"] == args.session]} record = state["processes"].get(args.session + "/" + args.name) if not record: raise Problem("runtime", "Unknown session runtime") @@ -1564,13 +2145,18 @@ def read_only(store, args): def dispatch(store, args): command = args.command - accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" + accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" \ + or command == "delegate" and args.action == "start" if args.inline is not None and not accepts_command: - raise Problem("arguments", "Only 'check run' and 'runtime launch' accept a command after --") + raise Problem("arguments", "Only 'check run', 'runtime launch' and 'delegate start' accept a command after --") + if command == "delegate" and args.action == "providers": + return None, relay_providers() + if command == "delegate" and args.action != "status": + require(args.session, "--session") if command == "snapshot": result = store.observe() return None, result if args.full else compact_snapshot(result) - if command in ("status", "next") or command == "runtime" and args.action == "status": + if command in ("status", "next") or command in ("runtime", "delegate") and args.action == "status": return read_only(store, args) if command == "check" and args.action == "run": return recorded_check(store, args) diff --git a/packs/session-workflow/skills/session-handover/SKILL.md b/packs/session-workflow/skills/session-handover/SKILL.md index fdc272e..0a4f316 100644 --- a/packs/session-workflow/skills/session-handover/SKILL.md +++ b/packs/session-workflow/skills/session-handover/SKILL.md @@ -10,7 +10,7 @@ description: >- license: Apache-2.0 metadata: workflow-pack: session-workflow - workflow-version: "3.0.0" + workflow-version: "3.1.0" --- # Session handover diff --git a/packs/session-workflow/skills/session-handover/references/runtime.md b/packs/session-workflow/skills/session-handover/references/runtime.md index ac4a73f..0b95d32 100644 --- a/packs/session-workflow/skills/session-handover/references/runtime.md +++ b/packs/session-workflow/skills/session-handover/references/runtime.md @@ -112,6 +112,62 @@ another machine reports `unknown` or `foreign`; the runtime never kills a recorded PID. On Windows the supervisor ends the process tree through `taskkill /T` while the child is alive. +## Delegations + +| Command | Purpose | +| --- | --- | +| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate start --session ID --name N --provider P --mode consult\|review\|write --packet F [--model M] [--phase P --worker-session W] [--workspace DIR] [--timeout S] -- CMD...` | Record a delegation and run a CLI worker as a supervised runtime `relay-N` | +| `delegate start ... --host-ref REF` (no command) | Record a host-native child task, such as a T3 task ID | +| `delegate status [--session ID] [--name N]` | Read-only: records, worker runtime, writer facts and result | +| `delegate collect --session ID --name N [--from FILE] [--confirm-stopped]` | Parse the worker's final RELAY-RESULT block from `--from`, `reply.txt` or the CLI worker's stdout | +| `delegate cancel --session ID --name N [--confirm-stopped]` | Stop a CLI worker, or close a host task you already cancelled | + +- Records live in `delegations//` under the state directory: the packet, + `record.json` and `result.json` with SHA-256 hashes. `state.json` and its + schema do not change, so a 3.0 runtime can still take part as a worker. +- In the worker command, `{packet}`, `{packet_text}`, `{reply_file}`, + `{workspace}` and `{state_dir}` are replaced. The worker runs in `--workspace` + without a shell or stdin. Its stdout and stderr go to separate logs. + `--timeout` stops its process group after a 10-second grace period. Within + it, a `check run` started by the worker stops its own check group (1-second + grace, then SIGKILL) and records the run as `abandoned`. +- A writer needs an unclaimed phase that is not DONE and a new worker session + ID. A given `--workspace` must share this coordinator (this checkout or a + linked worktree); a host task may omit it when the host creates the + worktree. The worker begins that session with `--parallel`, claims the phase + itself and does not commit: the integrator owns Git history. +- `collect` takes the last block of the final answer. That is plain text, or + one of: a Claude event of type `result`, Gemini's untyped `response`, or a + Codex `agent_message` item. Blocks in other events, such as tool results, + are ignored. Duplicate JSON keys make a result `malformed`. A missing or invalid block leaves the record + `malformed`, and it can be collected again. While a CLI worker runs, + `collect` refuses, even with `--from`. +- For a writer, `discrepancies` lists: + - claimed passing checks the worker did not record as passing; + - required checks whose latest run is not the worker's, or whose evidence + would not pass the DONE gate in the worker's workspace (fingerprint and + log); + - reported files outside the phase paths (traversal and absolute paths + included); + - changes the worker's workspace shows outside the phase paths, or did not + report. These are compared with a baseline taken when the delegation was + recorded, so earlier changes in a shared checkout do not count; + - a worker session that never began. + + Claims alone prove nothing. +- A writer recorded after its host task already began and claimed the phase + is accepted when that session is parallel and began after the orchestrator. +- `cancel`, `collect` and `end` treat a CLI worker as stopped only when its + receipt says `exited`. For `unknown` or `foreign`, confirm that it stopped + and pass `--confirm-stopped`; `end` leaves such records open. +- `end` stops the session's CLI workers like other runtimes and cancels their + records. It reports host tasks and kept workers as `open_delegations`. + `begin` and `status` list them. A session that takes over by solo `begin` + inherits them once its state is saved. Any later session adopts the record + of an ended orchestrator, together with its kept CLI process, on `collect` + or `cancel`. + ## Handover output `handover [--output FILE]` and `end --handover-output FILE` replace only the diff --git a/packs/session-workflow/skills/session-handover/references/workflow-contract.md b/packs/session-workflow/skills/session-handover/references/workflow-contract.md index a635b44..9eb04ba 100644 --- a/packs/session-workflow/skills/session-handover/references/workflow-contract.md +++ b/packs/session-workflow/skills/session-handover/references/workflow-contract.md @@ -1,4 +1,4 @@ -# estafeta contract - 3.0.0 +# estafeta contract - 3.1.0 Load once per chat. Commands: [quickstart](quickstart.md). Rare options: [runtime](runtime.md). diff --git a/packs/session-workflow/skills/session-handover/scripts/session.py b/packs/session-workflow/skills/session-handover/scripts/session.py index 00f5079..849f465 100644 --- a/packs/session-workflow/skills/session-handover/scripts/session.py +++ b/packs/session-workflow/skills/session-handover/scripts/session.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """Deterministic session facts and state. Python 3.9+, standard library only. -No model/API calls, no Git writes, no project migration and no kills outside a -process group this runtime supervises. Run --help or a subcommand --help for +No model/API calls of its own (a delegated worker is a command the caller +supplies), no Git writes, no project migration and no kills outside a process +group this runtime supervises. Run --help or a subcommand --help for the JSON CLI. See references/runtime.md. """ from __future__ import annotations @@ -16,11 +17,14 @@ import os from pathlib import Path import platform +import posixpath import re +import shutil import signal import subprocess import sys import tempfile +import threading import time import uuid @@ -777,6 +781,10 @@ def brief(state, store, session_id=None, live=None): "live_workspace": compact_snapshot(live) if live is not None else None} if actor and actor["status"] != "active": result["session_status"] = "ended: " + actor["ended_reason"] + delegations = relay_open(store) + if delegations: + result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], + "status": r["status"], "orchestrator": r["session_id"]} for r in delegations[:20]] warnings = snapshot_warnings(live) if warnings: result["warnings"] = warnings @@ -896,6 +904,8 @@ def begin(store, args): "Inspect the plan and choose a ready phase" new_checkpoint(store, state, session_id, next_action, workspace=live) store.save(state, "begin:" + session_id) + for item in superseded: + relay_transfer(store, item["id"], session_id) result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded @@ -978,6 +988,10 @@ def cell(value): "| Phase | State | Owner | Latest checks |", "| --- | --- | --- | --- |", *rows, "", "Active sessions: " + ("; ".join(f"{cell(sid)} (next: {cell(s['checkpoint'].get('next_action', ''))})" for sid, s in active) or "none") + "."] + delegations = relay_open(store) + if delegations: + lines.append("Open delegations: " + "; ".join(f"{cell(r['name'])} ({cell(r['provider'])} {r['mode']}, " + f"{r['status']})" for r in delegations[:10]) + ".") if ended: sid, s = max(ended, key=lambda item: item[1]["ended_at"]) lines.append(f"Ended sessions: {len(ended)}; latest {cell(sid)} " @@ -1021,7 +1035,7 @@ def group_alive(pgid): return True -def terminate_group(child): +def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": if child.poll() is None: @@ -1031,7 +1045,7 @@ def terminate_group(child): os.killpg(child.pid, signal.SIGTERM) except ProcessLookupError: return - deadline = time.monotonic() + 4 + deadline = time.monotonic() + grace while time.monotonic() < deadline: child.poll() if not group_alive(child.pid): @@ -1047,6 +1061,27 @@ def group_kwargs(): return {"start_new_session": True} if os.name != "nt" else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} +class Interrupted(Exception): + pass + + +@contextmanager +def interrupt_guard(): + """Turn SIGTERM/SIGHUP into Interrupted while a check runs, so its separate process group is not orphaned.""" + names = [n for n in ("SIGTERM", "SIGHUP") if hasattr(signal, n)] + if threading.current_thread() is not threading.main_thread(): + yield + return + def handler(signum, frame): + raise Interrupted() + previous = {n: signal.signal(getattr(signal, n), handler) for n in names} + try: + yield + finally: + for n, h in previous.items(): + signal.signal(getattr(signal, n), h) + + def run_check(store, state, p, args, run_id, command, before): log = store.directory / "checks" / (run_id + ".log") log.parent.mkdir(parents=True, exist_ok=True) @@ -1056,12 +1091,20 @@ def run_check(store, state, p, args, run_id, command, before): child = subprocess.Popen(command, cwd=store.root, stdout=output, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: - code = child.wait(timeout=args.timeout) + with interrupt_guard(): + code = child.wait(timeout=args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) child.wait() code, outcome = None, "timeout" + except Interrupted: + # The caller was stopped (for example a timed-out delegated worker): stop the check's own group too, + # with a short grace so this finishes well inside the supervisor's longer relay grace period. + terminate_group(child, grace=1.0) + child.wait() + output.write(b"\n[session-workflow] check stopped because its caller was terminated\n") + code, outcome = None, "abandoned" except OSError as exc: output.write(str(exc).encode("utf-8", "replace")) code, outcome = None, "error" @@ -1104,7 +1147,7 @@ def stop_runtime(store, record): return status directory = relative_path(store.directory, record["directory"]) atomic_write(directory / "stop.json", canonical({"run_id": record["run_id"]}) + "\n") - deadline = time.monotonic() + 10 + deadline = time.monotonic() + 15 while time.monotonic() < deadline: status = runtime_view(store, record) if status["status"] != "running": @@ -1126,10 +1169,14 @@ def supervise(spec_path): directory = spec_path.parent receipt_path = directory / "receipt.json" try: - with open(directory / "output.log", "ab") as output: - child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, stderr=subprocess.STDOUT, + # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + with open(directory / "output.log", "ab") as output, \ + open(directory / spec.get("stderr", "output.log"), "ab") as errors: + child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, + stderr=errors if spec.get("stderr") else subprocess.STDOUT, stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 + started, limit, timed_out = time.monotonic(), spec.get("timeout"), False # Descendants keep the group alive after the leader exits; supervise until all are gone. while child.poll() is None or group_alive(child.pid): if time.monotonic() - beat >= HEARTBEAT_SECONDS: @@ -1138,33 +1185,45 @@ def supervise(spec_path): "leader_exited": child.returncode is not None}) + "\n", durable=False) beat = time.monotonic() if stop.exists() and read_json(stop).get("run_id") == spec["run_id"]: - terminate_group(child) + terminate_group(child, spec.get("grace", 4.0)) + break + if limit and time.monotonic() - started > limit: + terminate_group(child, spec.get("grace", 4.0)) + timed_out = True break time.sleep(0.2) child.poll() receipt = {"run_id": spec["run_id"], "status": "exited", "exit_code": child.returncode, "scope": "supervised process group" if os.name != "nt" else "supervised process tree"} + if timed_out: + receipt["timed_out"] = True except Exception as exc: receipt = {"run_id": spec["run_id"], "status": "unknown", "reason": str(exc)[:500]} atomic_write(receipt_path, canonical(receipt) + "\n") -def launch_runtime(store, state, args, command): - key = args.session + "/" + args.name +def launch_runtime(store, state, session_id, name, command, keep=False, cwd=None, timeout=None, run_id=None, + split_stderr=False): + key = session_id + "/" + name if key in state["processes"]: raise Problem("runtime", "Runtime name already recorded; inspect it before creating a new name") - run_id = uuid.uuid4().hex + run_id = run_id or uuid.uuid4().hex directory = store.directory / "runtime" / run_id directory.mkdir(parents=True) - spec = {"run_id": run_id, "argv": command, "root": str(store.root)} + spec = {"run_id": run_id, "argv": command, "root": str(cwd or store.root)} + if timeout: + spec["timeout"] = timeout + if split_stderr: + # A delegated worker: its nested check runners need time to stop their own groups first. + spec.update(stderr="stderr.log", grace=10.0) spec_path = directory / "spec.json" atomic_write(spec_path, canonical(spec) + "\n") - record = {"run_id": run_id, "session_id": args.session, "machine": store.machine, + record = {"run_id": run_id, "session_id": session_id, "machine": store.machine, "root": str(store.root), "directory": directory.relative_to(store.directory).as_posix(), - "keep": args.keep} + "keep": keep} # Persist identity before launch; a crash cannot produce an unrecorded child. state["processes"][key] = record - store.save(state, "runtime-prepared:" + args.name) + store.save(state, "runtime-prepared:" + name) SUPERVISORS[run_id] = subprocess.Popen( [sys.executable, str(Path(__file__).resolve()), "_supervise", str(spec_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **group_kwargs()) @@ -1172,9 +1231,488 @@ def launch_runtime(store, state, args, command): while time.monotonic() < deadline: status = runtime_view(store, record) if status["status"] != "unknown": - return {"name": args.name, **status} + return {"name": name, **status} time.sleep(0.05) - return {"name": args.name, **runtime_view(store, record)} + return {"name": name, **runtime_view(store, record)} + + +# --- Delegation ledger ------------------------------------------------------- +# One record per delegated task under /delegations//. It lives outside +# state.json so schema 2 is unchanged and a 3.0 runtime can still join as a worker. +RELAY_OPEN, RELAY_CLOSE = "<<>>" +RELAY_OPEN_STATES = ("running", "malformed") +RELAY_OUTCOMES = ("completed", "blocked", "failed") +PROVIDER_CLIS = ("claude", "codex", "gemini") +MAX_RELAY_TEXT = 4 * 1024 * 1024 + + +def relay_dir(store, name=""): + base = store.directory / "delegations" + return base / name if name else base + + +def relay_load(store, name): + path = relay_dir(store, name) / "record.json" + if not path.is_file(): + raise Problem("delegation", "Unknown delegation name; list them with delegate status") + return read_json(path) + + +def relay_save(store, record): + atomic_write(relay_dir(store, record["name"]) / "record.json", canonical(record) + "\n") + + +def relay_records(store): + base = relay_dir(store) + records = [] + if base.is_dir(): + for directory in sorted(base.iterdir()): + try: + records.append(read_json(directory / "record.json")) + except (Problem, OSError): + continue + return records + + +def relay_owned(store, state, args): + """The caller's record. A record whose orchestrator has ended is adopted together with its CLI process.""" + record = relay_load(store, require(args.name, "--name")) + owner, adopted = record["session_id"], False + if owner != args.session: + if state["sessions"].get(owner, {}).get("status") == "active": + raise Problem("ownership", f"Delegation {record['name']} belongs to active session {owner}", 3) + record["adopted_from"], record["session_id"] = owner, args.session + key, process = relay_process(state, record) + if process and process["session_id"] != args.session and \ + state["sessions"].get(process["session_id"], {}).get("status") != "active": + new_key = args.session + "/" + key.split("/", 1)[1] + if new_key in state["processes"]: + raise Problem("runtime", "This session already has a runtime with that name", 3) + process["session_id"] = args.session + state["processes"][new_key] = state["processes"].pop(key) + adopted = True + return record, adopted + + +def relay_transfer(store, retired, successor): + """Called after the superseding begin was saved; a crash before it leaves adoption as the fallback.""" + for record in relay_records(store): + if record["session_id"] == retired and record["status"] in RELAY_OPEN_STATES: + record["session_id"] = successor + relay_save(store, record) + + +def relay_process(state, record): + for key, process in state["processes"].items(): + if record.get("run_id") and process["run_id"] == record["run_id"]: + return key, process + return None, None + + +def relay_runtime(store, state, record): + _, process = relay_process(state, record) + return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + + +def relay_providers(): + found = [] + for name in PROVIDER_CLIS: + path = shutil.which(name) + item = {"provider": name, "cli": path or "", "version": ""} + if path: + try: + done = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=15, + stdin=subprocess.DEVNULL) + item["version"] = (done.stdout or done.stderr).strip().splitlines()[0][:200] if \ + (done.stdout or done.stderr).strip() else "" + except (OSError, subprocess.TimeoutExpired): + item["version"] = "unknown" + found.append(item) + return {"cli": found, "note": "Host-native subagents (for example T3 delegate_task) are discovered by the agent, " + "not by this runtime"} + + +def relay_start(store, state, args): + name = require(args.name, "--name") + validate(name, "id") + validate(require(args.provider, "--provider"), "id") + mode = require(args.mode, "--mode") + cli = args.inline is not None or bool(args.argv_file) + if relay_dir(store, name).exists(): + raise Problem("delegation", "Delegation name already used; choose a new name", 3) + packet = Path(require(args.packet, "--packet")).expanduser() + text = packet.read_text(encoding="utf-8") + if not text.strip(): + raise Problem("arguments", "The packet is empty") + workspace = Path(args.workspace).expanduser().resolve() if args.workspace else store.root + if not workspace.is_dir(): + raise Problem("workspace", "The worker workspace does not exist") + if mode == "write": + worker = require(args.worker_session, "--worker-session") + validate(worker, "id") + p = phase(state, require(args.phase, "--phase")) + if p["state"] == "DONE": + raise Problem("transition", f"Phase {p['id']} is DONE; reopen it before delegating", 3) + # A host task can start, and its worker begin, before the orchestrator records it. + started = state["sessions"].get(worker) + if p["owner"] and p["owner"] != worker: + raise Problem("ownership", f"Phase {p['id']} is claimed by {p['owner']}; the worker must claim it. " + "Release it first with a transition to TODO", 3) + if worker == args.session or started and (cli or not started["parallel"] or + started["started_at"] < record_floor(state, args.session)): + raise Problem("session_exists", "The worker session ID is already used; choose a new one", 3) + if Store(workspace).directory != store.directory: + raise Problem("workspace", "A writer's workspace must share this coordinator: this checkout or a " + "linked Git worktree of it", 3) + elif args.phase: + phase(state, args.phase) + if not cli and args.timeout: + raise Problem("arguments", "--timeout applies only to a CLI worker command") + if args.timeout is not None and not 0 < args.timeout <= 86400: + raise Problem("arguments", "Timeout must be greater than 0 and at most 86400 seconds") + directory = relay_dir(store, name) + command = [] + if cli: + values = {"{packet}": str(directory / "packet.md"), "{packet_text}": text, + "{reply_file}": str(directory / "reply.txt"), "{workspace}": str(workspace), + "{state_dir}": str(store.directory)} + for item in command_of(args): + for token, value in values.items(): + item = item.replace(token, value) + command.append(item) + try: + validate(command, "argv") + except Problem: + raise Problem("arguments", "Worker command is too long; pass {packet} (a path) instead of {packet_text}") + directory.mkdir(parents=True) + atomic_write(directory / "packet.md", text) + record = {"name": name, "session_id": args.session, "machine": store.machine, "provider": args.provider, + "model": args.model or "", "mode": mode, "transport": "cli" if cli else "host", + "phase": args.phase or "", "worker_session": args.worker_session or "", + "workspace": str(workspace) if cli or args.workspace else "", "host_ref": args.host_ref or "", + "run_id": uuid.uuid4().hex if cli else "", + "packet": (directory / "packet.md").relative_to(store.directory).as_posix(), + "packet_sha256": file_digest(directory / "packet.md"), "status": "running", + "started_at": now(), "finished_at": ""} + if mode == "write" and state["sessions"].get(record["worker_session"]): + record["recorded_after_worker_began"] = True + if mode == "write" and record["workspace"]: + # Changes already present are not the worker's; collect compares against this baseline. + baseline = relay_changes(workspace, store.directory) + if baseline is not None: + atomic_write(directory / "baseline.json", canonical(baseline) + "\n") + relay_save(store, record) + result = {"name": name, "transport": record["transport"], "packet": str(directory / "packet.md"), + "reply_file": str(directory / "reply.txt")} + if cli: + result["runtime"] = launch_runtime(store, state, args.session, "relay-" + name, command, keep=args.keep, + cwd=workspace, timeout=args.timeout, run_id=record["run_id"], + split_stderr=True) + return result + + +def record_floor(state, session_id): + """A worker that began before its orchestrator did cannot belong to this delegation.""" + return state["sessions"][session_id]["started_at"] + + +def relay_latest_checks(state, record): + latest = {} + for check in sorted(state["checks"].values(), key=lambda item: item["sequence"]): + if check["phase"] == record["phase"] and check["session_id"] == record["worker_session"]: + latest[check["name"]] = check + return latest + + +def relay_worker_facts(state, record): + latest = {name: c["result"] if c["provenance"] == "executed" else c["result"] + " (" + c["provenance"] + ")" + for name, c in relay_latest_checks(state, record).items()} + worker = state["sessions"].get(record["worker_session"]) + try: + p = phase(state, record["phase"]) + except Problem: + p = {"state": "missing", "owner": ""} + return {"session": worker["status"] + (": " + worker["ended_reason"] if worker["ended_reason"] else "") + if worker else "not started", "root": worker["root"] if worker else "", "phase_state": p["state"], + "phase_owner": p["owner"], "recorded_checks": latest} + + +def relay_view(store, state, record): + view = {key: record[key] for key in ("name", "session_id", "provider", "model", "mode", "transport", "phase", + "worker_session", "host_ref", "status", "started_at", "finished_at")} + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + view["runtime"] = {key: runtime[key] for key in ("status", "exit_code", "timed_out", "reason") if key in runtime} + if record["status"] == "running" and runtime["status"] == "exited": + view["status"] = "exited" + if record["mode"] == "write": + view["worker"] = relay_worker_facts(state, record) + if record.get("result"): + view["result"] = record["result"] + for key in ("error", "discrepancies", "reason", "adopted_from", "recorded_after_worker_began"): + if record.get(key): + view[key] = record[key] + return view + + +def relay_final_texts(value): + """Final-answer fields of known agent JSON output; tool and progress events are ignored.""" + if not isinstance(value, dict): + return [] + kind = value.get("type") + if kind == "result" and isinstance(value.get("result"), str): # Claude Code json / stream-json result + return [value["result"]] + if kind is None and isinstance(value.get("response"), str): # Gemini CLI json, which has no event type + return [value["response"]] + item = value.get("item") + if value.get("type") == "item.completed" and isinstance(item, dict) and item.get("type") == "agent_message" \ + and isinstance(item.get("text"), str): # Codex exec --json + return [item["text"]] + return [] + + +def relay_blocks(text): + found, start = [], text.find(RELAY_OPEN) + while start >= 0: + end = text.find(RELAY_CLOSE, start) + if end < 0: + break + body = text[start + len(RELAY_OPEN):end].strip() + if body.startswith("```"): + body = body.split("\n", 1)[1] if "\n" in body else "" + body = body.rsplit("```", 1)[0] + found.append(body.strip()) + start = text.find(RELAY_OPEN, end) + return found + + +def relay_extract(text): + """The last RELAY-RESULT block in the final answer: plain text, or a known JSON final-answer field.""" + stripped = text.strip() + if stripped.startswith("{"): + try: + whole = json.loads(stripped) + except ValueError: + whole = None + if whole is not None: + blocks = [b for t in relay_final_texts(whole) for b in relay_blocks(t)] + return blocks[-1] if blocks else None + candidates, raw, inside = [], [], False + for line in text.splitlines(): + decoded = None + if not inside and line.lstrip().startswith("{"): + try: + decoded = json.loads(line) + except ValueError: + decoded = None + if isinstance(decoded, dict): + candidates += relay_blocks("\n".join(raw)) + raw = [] + for final in relay_final_texts(decoded): + candidates += relay_blocks(final) + continue + raw.append(line) + # Lines inside an open block are its body, even when they look like JSON events. + opened = line.rfind(RELAY_OPEN) + if opened >= 0: + inside = RELAY_CLOSE not in line[opened:] + elif inside and RELAY_CLOSE in line: + inside = False + candidates += relay_blocks("\n".join(raw)) + return candidates[-1] if candidates else None + + +def relay_problems(record, result): + if not isinstance(result, dict): + return ["The result must be a JSON object"] + problems = [] + if result.get("outcome") not in RELAY_OUTCOMES: + problems.append("outcome must be one of " + ", ".join(RELAY_OUTCOMES)) + if not isinstance(result.get("summary"), str) or not result["summary"].strip(): + problems.append("summary is required") + if "checks" in result and (not isinstance(result["checks"], list) or not all( + isinstance(c, dict) and isinstance(c.get("name"), str) for c in result["checks"])): + problems.append("checks must be [{name, result}]") + for field in ("changed_files", "findings", "gaps"): + if field in result and not isinstance(result[field], list): + problems.append(field + " must be a list") + if "changed_files" in result and isinstance(result["changed_files"], list) and \ + not all(isinstance(f, str) for f in result["changed_files"]): + problems.append("changed_files must list paths") + if result.get("outcome") == "completed": + if record["mode"] == "consult" and not isinstance(result.get("answer"), str): + problems.append("a consult result needs answer") + if record["mode"] == "review": + if result.get("verdict") not in ("APPROVE", "CHANGES_REQUIRED"): + problems.append("a review needs verdict APPROVE or CHANGES_REQUIRED") + if not isinstance(result.get("findings"), list): + problems.append("a review needs a findings list") + if record["mode"] == "write": + for field in ("changed_files", "checks"): + if field not in result: + problems.append("a write result needs " + field) + return problems + + +def relay_in_scope(name, allowed): + """One-directional containment after normalization; traversal and absolute paths are never in scope.""" + raw = name.replace("\\", "/") + if raw.startswith("/") or re.match(r"^[A-Za-z]:", raw) or ".." in raw.split("/"): + return False + clean = posixpath.normpath(raw).casefold() + for scope in allowed: + scope = posixpath.normpath(scope.replace("\\", "/")).casefold() + if scope in ("*", ".") or clean == scope or clean.startswith(scope + "/"): + return True + return False + + +def relay_changes(root, state_dir): + """Changed and untracked paths of a workspace with content facts, or None when they cannot be read.""" + try: + snap = snapshot(root, state_dir) + if snap.get("limited"): + return None + names = [c["path"] if isinstance(c, dict) else c for c in snap["changes"]] + return file_facts(root, names, state_prefix(root, state_dir)) + except Problem: + return None + + +def relay_discrepancies(store, state, record, result): + """Compare a writer's claims with what the runtime recorded and the workspace shows; claims prove nothing.""" + facts = relay_worker_facts(state, record) + found = [] + if result.get("worker_session", record["worker_session"]) != record["worker_session"]: + found.append("Result names another worker session") + if facts["session"] == "not started": + found.append("The worker session never began in this coordinator") + latest = relay_latest_checks(state, record) + for check in result.get("checks") or []: + recorded = latest.get(check["name"]) + if check.get("result") == "pass" and (not recorded or recorded["result"] != "pass"): + found.append(f"Check {check['name']} is claimed to pass but the worker recorded " + f"{recorded['result'] if recorded else 'no run'}") + try: + p = phase(state, record["phase"]) + except Problem: + p = {"id": "", "paths": [], "required_checks": []} + root = Path(facts["root"]) if facts["root"] else None + if result.get("outcome") == "completed": + overall = {} + for c in sorted((c for c in state["checks"].values() if c["phase"] == p["id"]), key=lambda c: c["sequence"]): + overall[c["name"]] = c + for name in p["required_checks"]: + if name not in latest or overall[name]["id"] != latest[name]["id"]: + found.append(f"Required check {name} has no latest run by the worker") + if root and root.is_dir() and p["id"]: + # The same gate DONE uses, judged in the worker's workspace: fresh fingerprint and intact log. + found += ["Worker evidence would not satisfy DONE: " + gap + for gap in verification_gaps(Store(root, store.directory), state, p)] + claimed = [f for f in result.get("changed_files") or []] + outside = [f for f in claimed if not relay_in_scope(f, p["paths"])] + if outside: + found.append("Reported files outside the phase paths: " + ", ".join(outside[:10])) + if root and root.is_dir(): + now = relay_changes(root, store.directory) + baseline_path = relay_dir(store, record["name"]) / "baseline.json" + baseline = read_json(baseline_path) if baseline_path.is_file() and str(root) == record["workspace"] else None + if now is None: + found.append("Could not inspect the worker workspace") + elif baseline is None and root == store.root: + found.append("The worker used the orchestrator checkout without a recorded baseline; its changes " + "could not be told apart from others") + else: + base = baseline or {} + observed = sorted(f for f in set(now) | set(base) if now.get(f) != base.get(f)) + stray = [f for f in observed if not relay_in_scope(f, p["paths"])] + if stray: + found.append("Workspace changes outside the phase paths: " + ", ".join(stray[:10])) + unreported = [f for f in observed if f not in claimed and f not in stray] + if unreported: + found.append("Changed but not reported: " + ", ".join(unreported[:10])) + return found + + +def relay_collect(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + directory = relay_dir(store, record["name"]) + sources = [directory / "reply.txt"] + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + if runtime["status"] == "running": + raise Problem("delegation_running", "The worker is still running; wait for it or cancel it", 3) + if runtime["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {runtime['status']}: " + f"{runtime.get('reason', '')}. Confirm the worker stopped, then pass --confirm-stopped", 3) + _, process = relay_process(state, record) + if process: + sources.append(relay_path(store, process["directory"]) / "output.log") + if args.from_file: + sources = [Path(args.from_file).expanduser()] + body, used = None, "" + for source in sources: + if source.is_file(): + with open(source, "rb") as handle: + handle.seek(max(0, source.stat().st_size - MAX_RELAY_TEXT)) + body = relay_extract(handle.read().decode("utf-8", "replace")) + if body is not None: + used = str(source) + break + if body is None: + record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + relay_save(store, record) + return relay_view(store, state, record), adopted + try: + result = json.loads(body, object_pairs_hook=object_pairs) + problems = relay_problems(record, result) + except (ValueError, Problem) as exc: + result, problems = None, ["The block is not valid, unambiguous JSON: " + str(exc)[:200]] + if problems: + record.update(status="malformed", error="; ".join(problems)) + relay_save(store, record) + return relay_view(store, state, record), adopted + discrepancies = relay_discrepancies(store, state, record, result) if record["mode"] == "write" else [] + atomic_write(directory / "result.json", canonical(result) + "\n") + record.update(status="returned", finished_at=now(), result=result, source=used, + result_sha256=file_digest(directory / "result.json"), error="", discrepancies=discrepancies) + relay_save(store, record) + return relay_view(store, state, record), adopted + + +def relay_cancel(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + if record["transport"] == "cli": + _, process = relay_process(state, record) + stopped = stop_runtime(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + if stopped["status"] == "running": + raise Problem("delegation_running", "The worker did not stop; inspect it before retrying", 3) + if stopped["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {stopped['status']}: {stopped.get('reason', '')}. " + "Stop it yourself, then pass --confirm-stopped", 3) + elif not args.confirm_stopped: + raise Problem("arguments", "Cancel the host task first (for example with the host's cancel tool), then " + "pass --confirm-stopped") + record.update(status="cancelled", finished_at=now(), reason=args.reason or "cancelled by orchestrator") + relay_save(store, record) + view = relay_view(store, state, record) + if record["mode"] == "write" and state["sessions"].get(record["worker_session"], {}).get("status") == "active": + view["next"] = (f"Worker session {record['worker_session']} is still active; release it with release " + "--confirm-stopped once you have confirmed its agent stopped") + return view, adopted + + +def relay_path(store, relative): + return relative_path(store.directory, relative) + + +def relay_open(store, session_id=None): + return [r for r in relay_records(store) if r["status"] in RELAY_OPEN_STATES and + (session_id is None or r["session_id"] == session_id)] def running_checks(state, session_id, pid=None): @@ -1258,6 +1796,26 @@ def command_parser(): p.add_argument("--name", required=True) p.add_argument("--argv-file", help="JSON argv array instead of a command after --") p.add_argument("--keep", action="store_true") + p = sub.add_parser("delegate", help="Record and supervise work delegated to another agent; worker command after --") + p.add_argument("action", choices=("providers", "start", "status", "collect", "cancel")) + p.add_argument("--session") + p.add_argument("--expect-revision", type=int) + p.add_argument("--name", help="Unique delegation ID") + p.add_argument("--provider", help="Worker provider ID, for example codex, claude or gemini") + p.add_argument("--model", default="") + p.add_argument("--mode", choices=("consult", "review", "write")) + p.add_argument("--phase", help="Required for write: the phase the worker claims") + p.add_argument("--worker-session", help="Required for write: the new session ID the worker begins") + p.add_argument("--workspace", help="Worker checkout; a writer needs this checkout or a linked worktree") + p.add_argument("--packet", help="Instruction packet file given to the worker") + p.add_argument("--host-ref", help="Host task reference when no CLI command is given") + p.add_argument("--argv-file", help="JSON argv array instead of a command after --") + p.add_argument("--timeout", type=float, help="CLI worker: stop its process group after this many seconds") + p.add_argument("--keep", action="store_true", help="CLI worker: keep running when this session ends") + p.add_argument("--from", dest="from_file", help="collect: file holding the worker's final reply") + p.add_argument("--confirm-stopped", action="store_true", + help="cancel/collect: you verified the worker stopped (host task, or a CLI worker in unknown state)") + p.add_argument("--reason", default="") return parser @@ -1440,7 +1998,21 @@ def end(store, state, args): for name, record in state["processes"].items(): if record["session_id"] == args.session: processes[name] = runtime_view(store, record) if args.keep_runtimes or record["keep"] else stop_runtime(store, record) - return {"session_id": args.session, "processes": processes} + result = {"session_id": args.session, "processes": processes} + still_open = [] + for record in relay_open(store, args.session): + key, _ = relay_process(state, record) + stopped = processes.get(key) + if record["transport"] == "cli" and stopped and stopped["status"] == "exited" and record["status"] == "running": + record.update(status="cancelled", finished_at=now(), reason="orchestrator session ended") + relay_save(store, record) + else: + still_open.append(record["name"]) + if still_open: + result["open_delegations"] = still_open + result["warning"] = ("Delegations stay open: a later session adopts them with delegate status, collect " + "or cancel. Cancel host tasks you no longer need.") + return result def check_command(store, state, args): @@ -1489,9 +2061,14 @@ def mutate(store, state, args): return {"checkpoint": state["sessions"][args.session]["checkpoint"]["id"]}, "checkpoint:" + args.session if command == "end": return end(store, state, args), None + if command == "delegate": + if args.action == "start": + return relay_start(store, state, args), None + result, adopted = (relay_collect if args.action == "collect" else relay_cancel)(store, state, args) + return result, "delegate-adopt:" + args.session if adopted else None if command == "runtime": if args.action == "launch": - return launch_runtime(store, state, args, command_of(args)), None + return launch_runtime(store, state, args.session, args.name, command_of(args), args.keep), None record = state["processes"].get(args.session + "/" + args.name) if not record: raise Problem("runtime", "No runtime with this name belongs to this session") @@ -1556,6 +2133,10 @@ def read_only(store, args): "complete" if state["phases"] and all(p["state"] == "DONE" for p in state["phases"]) else "decision" return state, {"action": action, "phase": choices[0] if choices else None, "stale_done_evidence": stale, "not_done": [p["id"] for p in state["phases"] if p["state"] != "DONE"]} + if args.command == "delegate": + records = [relay_load(store, args.name)] if args.name else relay_records(store) + return state, {"delegations": [relay_view(store, state, r) for r in records + if args.name or not args.session or r["session_id"] == args.session]} record = state["processes"].get(args.session + "/" + args.name) if not record: raise Problem("runtime", "Unknown session runtime") @@ -1564,13 +2145,18 @@ def read_only(store, args): def dispatch(store, args): command = args.command - accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" + accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" \ + or command == "delegate" and args.action == "start" if args.inline is not None and not accepts_command: - raise Problem("arguments", "Only 'check run' and 'runtime launch' accept a command after --") + raise Problem("arguments", "Only 'check run', 'runtime launch' and 'delegate start' accept a command after --") + if command == "delegate" and args.action == "providers": + return None, relay_providers() + if command == "delegate" and args.action != "status": + require(args.session, "--session") if command == "snapshot": result = store.observe() return None, result if args.full else compact_snapshot(result) - if command in ("status", "next") or command == "runtime" and args.action == "status": + if command in ("status", "next") or command in ("runtime", "delegate") and args.action == "status": return read_only(store, args) if command == "check" and args.action == "run": return recorded_check(store, args) diff --git a/packs/session-workflow/skills/session-start/SKILL.md b/packs/session-workflow/skills/session-start/SKILL.md index bb20d5d..852b402 100644 --- a/packs/session-workflow/skills/session-start/SKILL.md +++ b/packs/session-workflow/skills/session-start/SKILL.md @@ -10,7 +10,7 @@ description: >- license: Apache-2.0 metadata: workflow-pack: session-workflow - workflow-version: "3.0.0" + workflow-version: "3.1.0" --- # Session start @@ -28,7 +28,9 @@ Read the [contract](references/workflow-contract.md) and 2. **Read the resume point** in the result: - `next_action`, `prior_checkpoint`, `documents` and `warnings`; - `superseded`: earlier chats here, whose claims are now yours; - - `other_active_sessions`. + - `other_active_sessions`; + - `open_delegations`: work handed to other agents. Collect or cancel it + with agent-relay before relying on the phases involved. 3. **Reconcile** the context, plan and handover named in `documents` (or the project's doc map) with `git status` and recent changes. If `prior_checkpoint.workspace_changed` is true, inspect what changed before diff --git a/packs/session-workflow/skills/session-start/references/runtime.md b/packs/session-workflow/skills/session-start/references/runtime.md index ac4a73f..0b95d32 100644 --- a/packs/session-workflow/skills/session-start/references/runtime.md +++ b/packs/session-workflow/skills/session-start/references/runtime.md @@ -112,6 +112,62 @@ another machine reports `unknown` or `foreign`; the runtime never kills a recorded PID. On Windows the supervisor ends the process tree through `taskkill /T` while the child is alive. +## Delegations + +| Command | Purpose | +| --- | --- | +| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate start --session ID --name N --provider P --mode consult\|review\|write --packet F [--model M] [--phase P --worker-session W] [--workspace DIR] [--timeout S] -- CMD...` | Record a delegation and run a CLI worker as a supervised runtime `relay-N` | +| `delegate start ... --host-ref REF` (no command) | Record a host-native child task, such as a T3 task ID | +| `delegate status [--session ID] [--name N]` | Read-only: records, worker runtime, writer facts and result | +| `delegate collect --session ID --name N [--from FILE] [--confirm-stopped]` | Parse the worker's final RELAY-RESULT block from `--from`, `reply.txt` or the CLI worker's stdout | +| `delegate cancel --session ID --name N [--confirm-stopped]` | Stop a CLI worker, or close a host task you already cancelled | + +- Records live in `delegations//` under the state directory: the packet, + `record.json` and `result.json` with SHA-256 hashes. `state.json` and its + schema do not change, so a 3.0 runtime can still take part as a worker. +- In the worker command, `{packet}`, `{packet_text}`, `{reply_file}`, + `{workspace}` and `{state_dir}` are replaced. The worker runs in `--workspace` + without a shell or stdin. Its stdout and stderr go to separate logs. + `--timeout` stops its process group after a 10-second grace period. Within + it, a `check run` started by the worker stops its own check group (1-second + grace, then SIGKILL) and records the run as `abandoned`. +- A writer needs an unclaimed phase that is not DONE and a new worker session + ID. A given `--workspace` must share this coordinator (this checkout or a + linked worktree); a host task may omit it when the host creates the + worktree. The worker begins that session with `--parallel`, claims the phase + itself and does not commit: the integrator owns Git history. +- `collect` takes the last block of the final answer. That is plain text, or + one of: a Claude event of type `result`, Gemini's untyped `response`, or a + Codex `agent_message` item. Blocks in other events, such as tool results, + are ignored. Duplicate JSON keys make a result `malformed`. A missing or invalid block leaves the record + `malformed`, and it can be collected again. While a CLI worker runs, + `collect` refuses, even with `--from`. +- For a writer, `discrepancies` lists: + - claimed passing checks the worker did not record as passing; + - required checks whose latest run is not the worker's, or whose evidence + would not pass the DONE gate in the worker's workspace (fingerprint and + log); + - reported files outside the phase paths (traversal and absolute paths + included); + - changes the worker's workspace shows outside the phase paths, or did not + report. These are compared with a baseline taken when the delegation was + recorded, so earlier changes in a shared checkout do not count; + - a worker session that never began. + + Claims alone prove nothing. +- A writer recorded after its host task already began and claimed the phase + is accepted when that session is parallel and began after the orchestrator. +- `cancel`, `collect` and `end` treat a CLI worker as stopped only when its + receipt says `exited`. For `unknown` or `foreign`, confirm that it stopped + and pass `--confirm-stopped`; `end` leaves such records open. +- `end` stops the session's CLI workers like other runtimes and cancels their + records. It reports host tasks and kept workers as `open_delegations`. + `begin` and `status` list them. A session that takes over by solo `begin` + inherits them once its state is saved. Any later session adopts the record + of an ended orchestrator, together with its kept CLI process, on `collect` + or `cancel`. + ## Handover output `handover [--output FILE]` and `end --handover-output FILE` replace only the diff --git a/packs/session-workflow/skills/session-start/references/workflow-contract.md b/packs/session-workflow/skills/session-start/references/workflow-contract.md index a635b44..9eb04ba 100644 --- a/packs/session-workflow/skills/session-start/references/workflow-contract.md +++ b/packs/session-workflow/skills/session-start/references/workflow-contract.md @@ -1,4 +1,4 @@ -# estafeta contract - 3.0.0 +# estafeta contract - 3.1.0 Load once per chat. Commands: [quickstart](quickstart.md). Rare options: [runtime](runtime.md). diff --git a/packs/session-workflow/skills/session-start/scripts/session.py b/packs/session-workflow/skills/session-start/scripts/session.py index 00f5079..849f465 100644 --- a/packs/session-workflow/skills/session-start/scripts/session.py +++ b/packs/session-workflow/skills/session-start/scripts/session.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """Deterministic session facts and state. Python 3.9+, standard library only. -No model/API calls, no Git writes, no project migration and no kills outside a -process group this runtime supervises. Run --help or a subcommand --help for +No model/API calls of its own (a delegated worker is a command the caller +supplies), no Git writes, no project migration and no kills outside a process +group this runtime supervises. Run --help or a subcommand --help for the JSON CLI. See references/runtime.md. """ from __future__ import annotations @@ -16,11 +17,14 @@ import os from pathlib import Path import platform +import posixpath import re +import shutil import signal import subprocess import sys import tempfile +import threading import time import uuid @@ -777,6 +781,10 @@ def brief(state, store, session_id=None, live=None): "live_workspace": compact_snapshot(live) if live is not None else None} if actor and actor["status"] != "active": result["session_status"] = "ended: " + actor["ended_reason"] + delegations = relay_open(store) + if delegations: + result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], + "status": r["status"], "orchestrator": r["session_id"]} for r in delegations[:20]] warnings = snapshot_warnings(live) if warnings: result["warnings"] = warnings @@ -896,6 +904,8 @@ def begin(store, args): "Inspect the plan and choose a ready phase" new_checkpoint(store, state, session_id, next_action, workspace=live) store.save(state, "begin:" + session_id) + for item in superseded: + relay_transfer(store, item["id"], session_id) result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded @@ -978,6 +988,10 @@ def cell(value): "| Phase | State | Owner | Latest checks |", "| --- | --- | --- | --- |", *rows, "", "Active sessions: " + ("; ".join(f"{cell(sid)} (next: {cell(s['checkpoint'].get('next_action', ''))})" for sid, s in active) or "none") + "."] + delegations = relay_open(store) + if delegations: + lines.append("Open delegations: " + "; ".join(f"{cell(r['name'])} ({cell(r['provider'])} {r['mode']}, " + f"{r['status']})" for r in delegations[:10]) + ".") if ended: sid, s = max(ended, key=lambda item: item[1]["ended_at"]) lines.append(f"Ended sessions: {len(ended)}; latest {cell(sid)} " @@ -1021,7 +1035,7 @@ def group_alive(pgid): return True -def terminate_group(child): +def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": if child.poll() is None: @@ -1031,7 +1045,7 @@ def terminate_group(child): os.killpg(child.pid, signal.SIGTERM) except ProcessLookupError: return - deadline = time.monotonic() + 4 + deadline = time.monotonic() + grace while time.monotonic() < deadline: child.poll() if not group_alive(child.pid): @@ -1047,6 +1061,27 @@ def group_kwargs(): return {"start_new_session": True} if os.name != "nt" else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} +class Interrupted(Exception): + pass + + +@contextmanager +def interrupt_guard(): + """Turn SIGTERM/SIGHUP into Interrupted while a check runs, so its separate process group is not orphaned.""" + names = [n for n in ("SIGTERM", "SIGHUP") if hasattr(signal, n)] + if threading.current_thread() is not threading.main_thread(): + yield + return + def handler(signum, frame): + raise Interrupted() + previous = {n: signal.signal(getattr(signal, n), handler) for n in names} + try: + yield + finally: + for n, h in previous.items(): + signal.signal(getattr(signal, n), h) + + def run_check(store, state, p, args, run_id, command, before): log = store.directory / "checks" / (run_id + ".log") log.parent.mkdir(parents=True, exist_ok=True) @@ -1056,12 +1091,20 @@ def run_check(store, state, p, args, run_id, command, before): child = subprocess.Popen(command, cwd=store.root, stdout=output, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: - code = child.wait(timeout=args.timeout) + with interrupt_guard(): + code = child.wait(timeout=args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) child.wait() code, outcome = None, "timeout" + except Interrupted: + # The caller was stopped (for example a timed-out delegated worker): stop the check's own group too, + # with a short grace so this finishes well inside the supervisor's longer relay grace period. + terminate_group(child, grace=1.0) + child.wait() + output.write(b"\n[session-workflow] check stopped because its caller was terminated\n") + code, outcome = None, "abandoned" except OSError as exc: output.write(str(exc).encode("utf-8", "replace")) code, outcome = None, "error" @@ -1104,7 +1147,7 @@ def stop_runtime(store, record): return status directory = relative_path(store.directory, record["directory"]) atomic_write(directory / "stop.json", canonical({"run_id": record["run_id"]}) + "\n") - deadline = time.monotonic() + 10 + deadline = time.monotonic() + 15 while time.monotonic() < deadline: status = runtime_view(store, record) if status["status"] != "running": @@ -1126,10 +1169,14 @@ def supervise(spec_path): directory = spec_path.parent receipt_path = directory / "receipt.json" try: - with open(directory / "output.log", "ab") as output: - child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, stderr=subprocess.STDOUT, + # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + with open(directory / "output.log", "ab") as output, \ + open(directory / spec.get("stderr", "output.log"), "ab") as errors: + child = subprocess.Popen(spec["argv"], cwd=spec["root"], stdout=output, + stderr=errors if spec.get("stderr") else subprocess.STDOUT, stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 + started, limit, timed_out = time.monotonic(), spec.get("timeout"), False # Descendants keep the group alive after the leader exits; supervise until all are gone. while child.poll() is None or group_alive(child.pid): if time.monotonic() - beat >= HEARTBEAT_SECONDS: @@ -1138,33 +1185,45 @@ def supervise(spec_path): "leader_exited": child.returncode is not None}) + "\n", durable=False) beat = time.monotonic() if stop.exists() and read_json(stop).get("run_id") == spec["run_id"]: - terminate_group(child) + terminate_group(child, spec.get("grace", 4.0)) + break + if limit and time.monotonic() - started > limit: + terminate_group(child, spec.get("grace", 4.0)) + timed_out = True break time.sleep(0.2) child.poll() receipt = {"run_id": spec["run_id"], "status": "exited", "exit_code": child.returncode, "scope": "supervised process group" if os.name != "nt" else "supervised process tree"} + if timed_out: + receipt["timed_out"] = True except Exception as exc: receipt = {"run_id": spec["run_id"], "status": "unknown", "reason": str(exc)[:500]} atomic_write(receipt_path, canonical(receipt) + "\n") -def launch_runtime(store, state, args, command): - key = args.session + "/" + args.name +def launch_runtime(store, state, session_id, name, command, keep=False, cwd=None, timeout=None, run_id=None, + split_stderr=False): + key = session_id + "/" + name if key in state["processes"]: raise Problem("runtime", "Runtime name already recorded; inspect it before creating a new name") - run_id = uuid.uuid4().hex + run_id = run_id or uuid.uuid4().hex directory = store.directory / "runtime" / run_id directory.mkdir(parents=True) - spec = {"run_id": run_id, "argv": command, "root": str(store.root)} + spec = {"run_id": run_id, "argv": command, "root": str(cwd or store.root)} + if timeout: + spec["timeout"] = timeout + if split_stderr: + # A delegated worker: its nested check runners need time to stop their own groups first. + spec.update(stderr="stderr.log", grace=10.0) spec_path = directory / "spec.json" atomic_write(spec_path, canonical(spec) + "\n") - record = {"run_id": run_id, "session_id": args.session, "machine": store.machine, + record = {"run_id": run_id, "session_id": session_id, "machine": store.machine, "root": str(store.root), "directory": directory.relative_to(store.directory).as_posix(), - "keep": args.keep} + "keep": keep} # Persist identity before launch; a crash cannot produce an unrecorded child. state["processes"][key] = record - store.save(state, "runtime-prepared:" + args.name) + store.save(state, "runtime-prepared:" + name) SUPERVISORS[run_id] = subprocess.Popen( [sys.executable, str(Path(__file__).resolve()), "_supervise", str(spec_path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **group_kwargs()) @@ -1172,9 +1231,488 @@ def launch_runtime(store, state, args, command): while time.monotonic() < deadline: status = runtime_view(store, record) if status["status"] != "unknown": - return {"name": args.name, **status} + return {"name": name, **status} time.sleep(0.05) - return {"name": args.name, **runtime_view(store, record)} + return {"name": name, **runtime_view(store, record)} + + +# --- Delegation ledger ------------------------------------------------------- +# One record per delegated task under /delegations//. It lives outside +# state.json so schema 2 is unchanged and a 3.0 runtime can still join as a worker. +RELAY_OPEN, RELAY_CLOSE = "<<>>" +RELAY_OPEN_STATES = ("running", "malformed") +RELAY_OUTCOMES = ("completed", "blocked", "failed") +PROVIDER_CLIS = ("claude", "codex", "gemini") +MAX_RELAY_TEXT = 4 * 1024 * 1024 + + +def relay_dir(store, name=""): + base = store.directory / "delegations" + return base / name if name else base + + +def relay_load(store, name): + path = relay_dir(store, name) / "record.json" + if not path.is_file(): + raise Problem("delegation", "Unknown delegation name; list them with delegate status") + return read_json(path) + + +def relay_save(store, record): + atomic_write(relay_dir(store, record["name"]) / "record.json", canonical(record) + "\n") + + +def relay_records(store): + base = relay_dir(store) + records = [] + if base.is_dir(): + for directory in sorted(base.iterdir()): + try: + records.append(read_json(directory / "record.json")) + except (Problem, OSError): + continue + return records + + +def relay_owned(store, state, args): + """The caller's record. A record whose orchestrator has ended is adopted together with its CLI process.""" + record = relay_load(store, require(args.name, "--name")) + owner, adopted = record["session_id"], False + if owner != args.session: + if state["sessions"].get(owner, {}).get("status") == "active": + raise Problem("ownership", f"Delegation {record['name']} belongs to active session {owner}", 3) + record["adopted_from"], record["session_id"] = owner, args.session + key, process = relay_process(state, record) + if process and process["session_id"] != args.session and \ + state["sessions"].get(process["session_id"], {}).get("status") != "active": + new_key = args.session + "/" + key.split("/", 1)[1] + if new_key in state["processes"]: + raise Problem("runtime", "This session already has a runtime with that name", 3) + process["session_id"] = args.session + state["processes"][new_key] = state["processes"].pop(key) + adopted = True + return record, adopted + + +def relay_transfer(store, retired, successor): + """Called after the superseding begin was saved; a crash before it leaves adoption as the fallback.""" + for record in relay_records(store): + if record["session_id"] == retired and record["status"] in RELAY_OPEN_STATES: + record["session_id"] = successor + relay_save(store, record) + + +def relay_process(state, record): + for key, process in state["processes"].items(): + if record.get("run_id") and process["run_id"] == record["run_id"]: + return key, process + return None, None + + +def relay_runtime(store, state, record): + _, process = relay_process(state, record) + return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + + +def relay_providers(): + found = [] + for name in PROVIDER_CLIS: + path = shutil.which(name) + item = {"provider": name, "cli": path or "", "version": ""} + if path: + try: + done = subprocess.run([path, "--version"], capture_output=True, text=True, timeout=15, + stdin=subprocess.DEVNULL) + item["version"] = (done.stdout or done.stderr).strip().splitlines()[0][:200] if \ + (done.stdout or done.stderr).strip() else "" + except (OSError, subprocess.TimeoutExpired): + item["version"] = "unknown" + found.append(item) + return {"cli": found, "note": "Host-native subagents (for example T3 delegate_task) are discovered by the agent, " + "not by this runtime"} + + +def relay_start(store, state, args): + name = require(args.name, "--name") + validate(name, "id") + validate(require(args.provider, "--provider"), "id") + mode = require(args.mode, "--mode") + cli = args.inline is not None or bool(args.argv_file) + if relay_dir(store, name).exists(): + raise Problem("delegation", "Delegation name already used; choose a new name", 3) + packet = Path(require(args.packet, "--packet")).expanduser() + text = packet.read_text(encoding="utf-8") + if not text.strip(): + raise Problem("arguments", "The packet is empty") + workspace = Path(args.workspace).expanduser().resolve() if args.workspace else store.root + if not workspace.is_dir(): + raise Problem("workspace", "The worker workspace does not exist") + if mode == "write": + worker = require(args.worker_session, "--worker-session") + validate(worker, "id") + p = phase(state, require(args.phase, "--phase")) + if p["state"] == "DONE": + raise Problem("transition", f"Phase {p['id']} is DONE; reopen it before delegating", 3) + # A host task can start, and its worker begin, before the orchestrator records it. + started = state["sessions"].get(worker) + if p["owner"] and p["owner"] != worker: + raise Problem("ownership", f"Phase {p['id']} is claimed by {p['owner']}; the worker must claim it. " + "Release it first with a transition to TODO", 3) + if worker == args.session or started and (cli or not started["parallel"] or + started["started_at"] < record_floor(state, args.session)): + raise Problem("session_exists", "The worker session ID is already used; choose a new one", 3) + if Store(workspace).directory != store.directory: + raise Problem("workspace", "A writer's workspace must share this coordinator: this checkout or a " + "linked Git worktree of it", 3) + elif args.phase: + phase(state, args.phase) + if not cli and args.timeout: + raise Problem("arguments", "--timeout applies only to a CLI worker command") + if args.timeout is not None and not 0 < args.timeout <= 86400: + raise Problem("arguments", "Timeout must be greater than 0 and at most 86400 seconds") + directory = relay_dir(store, name) + command = [] + if cli: + values = {"{packet}": str(directory / "packet.md"), "{packet_text}": text, + "{reply_file}": str(directory / "reply.txt"), "{workspace}": str(workspace), + "{state_dir}": str(store.directory)} + for item in command_of(args): + for token, value in values.items(): + item = item.replace(token, value) + command.append(item) + try: + validate(command, "argv") + except Problem: + raise Problem("arguments", "Worker command is too long; pass {packet} (a path) instead of {packet_text}") + directory.mkdir(parents=True) + atomic_write(directory / "packet.md", text) + record = {"name": name, "session_id": args.session, "machine": store.machine, "provider": args.provider, + "model": args.model or "", "mode": mode, "transport": "cli" if cli else "host", + "phase": args.phase or "", "worker_session": args.worker_session or "", + "workspace": str(workspace) if cli or args.workspace else "", "host_ref": args.host_ref or "", + "run_id": uuid.uuid4().hex if cli else "", + "packet": (directory / "packet.md").relative_to(store.directory).as_posix(), + "packet_sha256": file_digest(directory / "packet.md"), "status": "running", + "started_at": now(), "finished_at": ""} + if mode == "write" and state["sessions"].get(record["worker_session"]): + record["recorded_after_worker_began"] = True + if mode == "write" and record["workspace"]: + # Changes already present are not the worker's; collect compares against this baseline. + baseline = relay_changes(workspace, store.directory) + if baseline is not None: + atomic_write(directory / "baseline.json", canonical(baseline) + "\n") + relay_save(store, record) + result = {"name": name, "transport": record["transport"], "packet": str(directory / "packet.md"), + "reply_file": str(directory / "reply.txt")} + if cli: + result["runtime"] = launch_runtime(store, state, args.session, "relay-" + name, command, keep=args.keep, + cwd=workspace, timeout=args.timeout, run_id=record["run_id"], + split_stderr=True) + return result + + +def record_floor(state, session_id): + """A worker that began before its orchestrator did cannot belong to this delegation.""" + return state["sessions"][session_id]["started_at"] + + +def relay_latest_checks(state, record): + latest = {} + for check in sorted(state["checks"].values(), key=lambda item: item["sequence"]): + if check["phase"] == record["phase"] and check["session_id"] == record["worker_session"]: + latest[check["name"]] = check + return latest + + +def relay_worker_facts(state, record): + latest = {name: c["result"] if c["provenance"] == "executed" else c["result"] + " (" + c["provenance"] + ")" + for name, c in relay_latest_checks(state, record).items()} + worker = state["sessions"].get(record["worker_session"]) + try: + p = phase(state, record["phase"]) + except Problem: + p = {"state": "missing", "owner": ""} + return {"session": worker["status"] + (": " + worker["ended_reason"] if worker["ended_reason"] else "") + if worker else "not started", "root": worker["root"] if worker else "", "phase_state": p["state"], + "phase_owner": p["owner"], "recorded_checks": latest} + + +def relay_view(store, state, record): + view = {key: record[key] for key in ("name", "session_id", "provider", "model", "mode", "transport", "phase", + "worker_session", "host_ref", "status", "started_at", "finished_at")} + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + view["runtime"] = {key: runtime[key] for key in ("status", "exit_code", "timed_out", "reason") if key in runtime} + if record["status"] == "running" and runtime["status"] == "exited": + view["status"] = "exited" + if record["mode"] == "write": + view["worker"] = relay_worker_facts(state, record) + if record.get("result"): + view["result"] = record["result"] + for key in ("error", "discrepancies", "reason", "adopted_from", "recorded_after_worker_began"): + if record.get(key): + view[key] = record[key] + return view + + +def relay_final_texts(value): + """Final-answer fields of known agent JSON output; tool and progress events are ignored.""" + if not isinstance(value, dict): + return [] + kind = value.get("type") + if kind == "result" and isinstance(value.get("result"), str): # Claude Code json / stream-json result + return [value["result"]] + if kind is None and isinstance(value.get("response"), str): # Gemini CLI json, which has no event type + return [value["response"]] + item = value.get("item") + if value.get("type") == "item.completed" and isinstance(item, dict) and item.get("type") == "agent_message" \ + and isinstance(item.get("text"), str): # Codex exec --json + return [item["text"]] + return [] + + +def relay_blocks(text): + found, start = [], text.find(RELAY_OPEN) + while start >= 0: + end = text.find(RELAY_CLOSE, start) + if end < 0: + break + body = text[start + len(RELAY_OPEN):end].strip() + if body.startswith("```"): + body = body.split("\n", 1)[1] if "\n" in body else "" + body = body.rsplit("```", 1)[0] + found.append(body.strip()) + start = text.find(RELAY_OPEN, end) + return found + + +def relay_extract(text): + """The last RELAY-RESULT block in the final answer: plain text, or a known JSON final-answer field.""" + stripped = text.strip() + if stripped.startswith("{"): + try: + whole = json.loads(stripped) + except ValueError: + whole = None + if whole is not None: + blocks = [b for t in relay_final_texts(whole) for b in relay_blocks(t)] + return blocks[-1] if blocks else None + candidates, raw, inside = [], [], False + for line in text.splitlines(): + decoded = None + if not inside and line.lstrip().startswith("{"): + try: + decoded = json.loads(line) + except ValueError: + decoded = None + if isinstance(decoded, dict): + candidates += relay_blocks("\n".join(raw)) + raw = [] + for final in relay_final_texts(decoded): + candidates += relay_blocks(final) + continue + raw.append(line) + # Lines inside an open block are its body, even when they look like JSON events. + opened = line.rfind(RELAY_OPEN) + if opened >= 0: + inside = RELAY_CLOSE not in line[opened:] + elif inside and RELAY_CLOSE in line: + inside = False + candidates += relay_blocks("\n".join(raw)) + return candidates[-1] if candidates else None + + +def relay_problems(record, result): + if not isinstance(result, dict): + return ["The result must be a JSON object"] + problems = [] + if result.get("outcome") not in RELAY_OUTCOMES: + problems.append("outcome must be one of " + ", ".join(RELAY_OUTCOMES)) + if not isinstance(result.get("summary"), str) or not result["summary"].strip(): + problems.append("summary is required") + if "checks" in result and (not isinstance(result["checks"], list) or not all( + isinstance(c, dict) and isinstance(c.get("name"), str) for c in result["checks"])): + problems.append("checks must be [{name, result}]") + for field in ("changed_files", "findings", "gaps"): + if field in result and not isinstance(result[field], list): + problems.append(field + " must be a list") + if "changed_files" in result and isinstance(result["changed_files"], list) and \ + not all(isinstance(f, str) for f in result["changed_files"]): + problems.append("changed_files must list paths") + if result.get("outcome") == "completed": + if record["mode"] == "consult" and not isinstance(result.get("answer"), str): + problems.append("a consult result needs answer") + if record["mode"] == "review": + if result.get("verdict") not in ("APPROVE", "CHANGES_REQUIRED"): + problems.append("a review needs verdict APPROVE or CHANGES_REQUIRED") + if not isinstance(result.get("findings"), list): + problems.append("a review needs a findings list") + if record["mode"] == "write": + for field in ("changed_files", "checks"): + if field not in result: + problems.append("a write result needs " + field) + return problems + + +def relay_in_scope(name, allowed): + """One-directional containment after normalization; traversal and absolute paths are never in scope.""" + raw = name.replace("\\", "/") + if raw.startswith("/") or re.match(r"^[A-Za-z]:", raw) or ".." in raw.split("/"): + return False + clean = posixpath.normpath(raw).casefold() + for scope in allowed: + scope = posixpath.normpath(scope.replace("\\", "/")).casefold() + if scope in ("*", ".") or clean == scope or clean.startswith(scope + "/"): + return True + return False + + +def relay_changes(root, state_dir): + """Changed and untracked paths of a workspace with content facts, or None when they cannot be read.""" + try: + snap = snapshot(root, state_dir) + if snap.get("limited"): + return None + names = [c["path"] if isinstance(c, dict) else c for c in snap["changes"]] + return file_facts(root, names, state_prefix(root, state_dir)) + except Problem: + return None + + +def relay_discrepancies(store, state, record, result): + """Compare a writer's claims with what the runtime recorded and the workspace shows; claims prove nothing.""" + facts = relay_worker_facts(state, record) + found = [] + if result.get("worker_session", record["worker_session"]) != record["worker_session"]: + found.append("Result names another worker session") + if facts["session"] == "not started": + found.append("The worker session never began in this coordinator") + latest = relay_latest_checks(state, record) + for check in result.get("checks") or []: + recorded = latest.get(check["name"]) + if check.get("result") == "pass" and (not recorded or recorded["result"] != "pass"): + found.append(f"Check {check['name']} is claimed to pass but the worker recorded " + f"{recorded['result'] if recorded else 'no run'}") + try: + p = phase(state, record["phase"]) + except Problem: + p = {"id": "", "paths": [], "required_checks": []} + root = Path(facts["root"]) if facts["root"] else None + if result.get("outcome") == "completed": + overall = {} + for c in sorted((c for c in state["checks"].values() if c["phase"] == p["id"]), key=lambda c: c["sequence"]): + overall[c["name"]] = c + for name in p["required_checks"]: + if name not in latest or overall[name]["id"] != latest[name]["id"]: + found.append(f"Required check {name} has no latest run by the worker") + if root and root.is_dir() and p["id"]: + # The same gate DONE uses, judged in the worker's workspace: fresh fingerprint and intact log. + found += ["Worker evidence would not satisfy DONE: " + gap + for gap in verification_gaps(Store(root, store.directory), state, p)] + claimed = [f for f in result.get("changed_files") or []] + outside = [f for f in claimed if not relay_in_scope(f, p["paths"])] + if outside: + found.append("Reported files outside the phase paths: " + ", ".join(outside[:10])) + if root and root.is_dir(): + now = relay_changes(root, store.directory) + baseline_path = relay_dir(store, record["name"]) / "baseline.json" + baseline = read_json(baseline_path) if baseline_path.is_file() and str(root) == record["workspace"] else None + if now is None: + found.append("Could not inspect the worker workspace") + elif baseline is None and root == store.root: + found.append("The worker used the orchestrator checkout without a recorded baseline; its changes " + "could not be told apart from others") + else: + base = baseline or {} + observed = sorted(f for f in set(now) | set(base) if now.get(f) != base.get(f)) + stray = [f for f in observed if not relay_in_scope(f, p["paths"])] + if stray: + found.append("Workspace changes outside the phase paths: " + ", ".join(stray[:10])) + unreported = [f for f in observed if f not in claimed and f not in stray] + if unreported: + found.append("Changed but not reported: " + ", ".join(unreported[:10])) + return found + + +def relay_collect(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + directory = relay_dir(store, record["name"]) + sources = [directory / "reply.txt"] + if record["transport"] == "cli": + runtime = relay_runtime(store, state, record) + if runtime["status"] == "running": + raise Problem("delegation_running", "The worker is still running; wait for it or cancel it", 3) + if runtime["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {runtime['status']}: " + f"{runtime.get('reason', '')}. Confirm the worker stopped, then pass --confirm-stopped", 3) + _, process = relay_process(state, record) + if process: + sources.append(relay_path(store, process["directory"]) / "output.log") + if args.from_file: + sources = [Path(args.from_file).expanduser()] + body, used = None, "" + for source in sources: + if source.is_file(): + with open(source, "rb") as handle: + handle.seek(max(0, source.stat().st_size - MAX_RELAY_TEXT)) + body = relay_extract(handle.read().decode("utf-8", "replace")) + if body is not None: + used = str(source) + break + if body is None: + record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + relay_save(store, record) + return relay_view(store, state, record), adopted + try: + result = json.loads(body, object_pairs_hook=object_pairs) + problems = relay_problems(record, result) + except (ValueError, Problem) as exc: + result, problems = None, ["The block is not valid, unambiguous JSON: " + str(exc)[:200]] + if problems: + record.update(status="malformed", error="; ".join(problems)) + relay_save(store, record) + return relay_view(store, state, record), adopted + discrepancies = relay_discrepancies(store, state, record, result) if record["mode"] == "write" else [] + atomic_write(directory / "result.json", canonical(result) + "\n") + record.update(status="returned", finished_at=now(), result=result, source=used, + result_sha256=file_digest(directory / "result.json"), error="", discrepancies=discrepancies) + relay_save(store, record) + return relay_view(store, state, record), adopted + + +def relay_cancel(store, state, args): + record, adopted = relay_owned(store, state, args) + if record["status"] not in RELAY_OPEN_STATES: + raise Problem("delegation", f"Delegation {record['name']} is already {record['status']}", 3) + if record["transport"] == "cli": + _, process = relay_process(state, record) + stopped = stop_runtime(store, process) if process else {"status": "unknown", "reason": "No runtime record"} + if stopped["status"] == "running": + raise Problem("delegation_running", "The worker did not stop; inspect it before retrying", 3) + if stopped["status"] != "exited" and not args.confirm_stopped: + raise Problem("delegation_unknown", f"Worker runtime is {stopped['status']}: {stopped.get('reason', '')}. " + "Stop it yourself, then pass --confirm-stopped", 3) + elif not args.confirm_stopped: + raise Problem("arguments", "Cancel the host task first (for example with the host's cancel tool), then " + "pass --confirm-stopped") + record.update(status="cancelled", finished_at=now(), reason=args.reason or "cancelled by orchestrator") + relay_save(store, record) + view = relay_view(store, state, record) + if record["mode"] == "write" and state["sessions"].get(record["worker_session"], {}).get("status") == "active": + view["next"] = (f"Worker session {record['worker_session']} is still active; release it with release " + "--confirm-stopped once you have confirmed its agent stopped") + return view, adopted + + +def relay_path(store, relative): + return relative_path(store.directory, relative) + + +def relay_open(store, session_id=None): + return [r for r in relay_records(store) if r["status"] in RELAY_OPEN_STATES and + (session_id is None or r["session_id"] == session_id)] def running_checks(state, session_id, pid=None): @@ -1258,6 +1796,26 @@ def command_parser(): p.add_argument("--name", required=True) p.add_argument("--argv-file", help="JSON argv array instead of a command after --") p.add_argument("--keep", action="store_true") + p = sub.add_parser("delegate", help="Record and supervise work delegated to another agent; worker command after --") + p.add_argument("action", choices=("providers", "start", "status", "collect", "cancel")) + p.add_argument("--session") + p.add_argument("--expect-revision", type=int) + p.add_argument("--name", help="Unique delegation ID") + p.add_argument("--provider", help="Worker provider ID, for example codex, claude or gemini") + p.add_argument("--model", default="") + p.add_argument("--mode", choices=("consult", "review", "write")) + p.add_argument("--phase", help="Required for write: the phase the worker claims") + p.add_argument("--worker-session", help="Required for write: the new session ID the worker begins") + p.add_argument("--workspace", help="Worker checkout; a writer needs this checkout or a linked worktree") + p.add_argument("--packet", help="Instruction packet file given to the worker") + p.add_argument("--host-ref", help="Host task reference when no CLI command is given") + p.add_argument("--argv-file", help="JSON argv array instead of a command after --") + p.add_argument("--timeout", type=float, help="CLI worker: stop its process group after this many seconds") + p.add_argument("--keep", action="store_true", help="CLI worker: keep running when this session ends") + p.add_argument("--from", dest="from_file", help="collect: file holding the worker's final reply") + p.add_argument("--confirm-stopped", action="store_true", + help="cancel/collect: you verified the worker stopped (host task, or a CLI worker in unknown state)") + p.add_argument("--reason", default="") return parser @@ -1440,7 +1998,21 @@ def end(store, state, args): for name, record in state["processes"].items(): if record["session_id"] == args.session: processes[name] = runtime_view(store, record) if args.keep_runtimes or record["keep"] else stop_runtime(store, record) - return {"session_id": args.session, "processes": processes} + result = {"session_id": args.session, "processes": processes} + still_open = [] + for record in relay_open(store, args.session): + key, _ = relay_process(state, record) + stopped = processes.get(key) + if record["transport"] == "cli" and stopped and stopped["status"] == "exited" and record["status"] == "running": + record.update(status="cancelled", finished_at=now(), reason="orchestrator session ended") + relay_save(store, record) + else: + still_open.append(record["name"]) + if still_open: + result["open_delegations"] = still_open + result["warning"] = ("Delegations stay open: a later session adopts them with delegate status, collect " + "or cancel. Cancel host tasks you no longer need.") + return result def check_command(store, state, args): @@ -1489,9 +2061,14 @@ def mutate(store, state, args): return {"checkpoint": state["sessions"][args.session]["checkpoint"]["id"]}, "checkpoint:" + args.session if command == "end": return end(store, state, args), None + if command == "delegate": + if args.action == "start": + return relay_start(store, state, args), None + result, adopted = (relay_collect if args.action == "collect" else relay_cancel)(store, state, args) + return result, "delegate-adopt:" + args.session if adopted else None if command == "runtime": if args.action == "launch": - return launch_runtime(store, state, args, command_of(args)), None + return launch_runtime(store, state, args.session, args.name, command_of(args), args.keep), None record = state["processes"].get(args.session + "/" + args.name) if not record: raise Problem("runtime", "No runtime with this name belongs to this session") @@ -1556,6 +2133,10 @@ def read_only(store, args): "complete" if state["phases"] and all(p["state"] == "DONE" for p in state["phases"]) else "decision" return state, {"action": action, "phase": choices[0] if choices else None, "stale_done_evidence": stale, "not_done": [p["id"] for p in state["phases"] if p["state"] != "DONE"]} + if args.command == "delegate": + records = [relay_load(store, args.name)] if args.name else relay_records(store) + return state, {"delegations": [relay_view(store, state, r) for r in records + if args.name or not args.session or r["session_id"] == args.session]} record = state["processes"].get(args.session + "/" + args.name) if not record: raise Problem("runtime", "Unknown session runtime") @@ -1564,13 +2145,18 @@ def read_only(store, args): def dispatch(store, args): command = args.command - accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" + accepts_command = command == "check" and args.action == "run" or command == "runtime" and args.action == "launch" \ + or command == "delegate" and args.action == "start" if args.inline is not None and not accepts_command: - raise Problem("arguments", "Only 'check run' and 'runtime launch' accept a command after --") + raise Problem("arguments", "Only 'check run', 'runtime launch' and 'delegate start' accept a command after --") + if command == "delegate" and args.action == "providers": + return None, relay_providers() + if command == "delegate" and args.action != "status": + require(args.session, "--session") if command == "snapshot": result = store.observe() return None, result if args.full else compact_snapshot(result) - if command in ("status", "next") or command == "runtime" and args.action == "status": + if command in ("status", "next") or command in ("runtime", "delegate") and args.action == "status": return read_only(store, args) if command == "check" and args.action == "run": return recorded_check(store, args) diff --git a/scripts/golden/relay-scenarios.json b/scripts/golden/relay-scenarios.json new file mode 100644 index 0000000..a7b172c --- /dev/null +++ b/scripts/golden/relay-scenarios.json @@ -0,0 +1,1743 @@ +{ + "description": "Golden paths for agent-relay. Each scenario is a sequence of orchestrator and worker interactions with the expected result of every step; one failed step fails the scenario. The stub worker stands in for an LLM; steps marked verify also run against a live provider run with --live-verify.", + "scenarios": [ + { + "id": "consult-cli", + "description": "Orchestrator asks another provider a read-only question through a supervised CLI worker.", + "steps": [ + { + "name": "orchestrator begins", + "do": "runtime", + "args": [ + "begin", + "--session", + "orch" + ], + "expect": [ + { + "path": "ok", + "equals": true + }, + { + "path": "result.session_id", + "equals": "orch" + } + ] + }, + { + "name": "providers are listed without state changes", + "do": "runtime", + "args": [ + "delegate", + "providers" + ], + "expect": [ + { + "path": "ok", + "equals": true + }, + { + "path": "result.cli", + "nonempty": true + } + ] + }, + { + "name": "packet is written", + "do": "packet", + "file": "consult.md", + "header": { + "mode": "consult", + "name": "ask-1", + "orchestrator": "orch", + "workspace": "{root}" + }, + "body": "Question: which file holds the greeting?" + }, + { + "name": "consult starts as a supervised CLI worker", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "ask-1", + "--provider", + "stub", + "--model", + "stub-1", + "--mode", + "consult", + "--packet", + "{packets}/consult.md", + "--timeout", + "60", + "--", + "{python}", + "{stub}", + "{packet}", + "--answer", + "a.txt" + ], + "expect": [ + { + "path": "ok", + "equals": true + }, + { + "path": "result.transport", + "equals": "cli" + } + ] + }, + { + "name": "worker exits on its own", + "do": "wait", + "delegation": "ask-1", + "expect": [ + { + "path": "status", + "equals": "exited" + }, + { + "path": "runtime.exit_code", + "equals": 0 + } + ] + }, + { + "name": "status shows the delegation as open before collect", + "do": "runtime", + "args": [ + "status", + "--session", + "orch" + ], + "expect": [ + { + "path": "result.open_delegations.0.name", + "equals": "ask-1" + } + ] + }, + { + "name": "collect parses the reply from the worker log", + "do": "runtime", + "args": [ + "delegate", + "collect", + "--session", + "orch", + "--name", + "ask-1" + ], + "expect": [ + { + "path": "result.status", + "equals": "returned" + }, + { + "path": "result.result.answer", + "equals": "a.txt" + }, + { + "path": "result.model", + "equals": "stub-1" + } + ] + }, + { + "name": "recorded delegation meets the golden expectations", + "do": "runtime", + "args": [ + "delegate", + "status", + "--name", + "ask-1" + ], + "verify": true, + "expect": [ + { + "path": "result.delegations.0.status", + "equals": "returned" + }, + { + "path": "result.delegations.0.result.outcome", + "equals": "completed" + }, + { + "path": "result.delegations.0.result.answer", + "nonempty": true + } + ] + }, + { + "name": "a second collect is refused", + "do": "runtime", + "args": [ + "delegate", + "collect", + "--session", + "orch", + "--name", + "ask-1" + ], + "expect": [ + { + "path": "ok", + "equals": false + }, + { + "path": "error.code", + "equals": "delegation" + } + ] + }, + { + "name": "orchestrator ends with nothing open", + "do": "runtime", + "args": [ + "end", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--next-action", + "done" + ], + "expect": [ + { + "path": "ok", + "equals": true + }, + { + "path": "result.open_delegations", + "absent": true + } + ] + } + ] + }, + { + "id": "review-host", + "description": "Cross-review through a host-native child task (for example T3 delegate_task); the host returns text that the orchestrator saves and collects.", + "steps": [ + { + "name": "orchestrator begins", + "do": "runtime", + "args": [ + "begin", + "--session", + "orch" + ], + "expect": [ + { + "path": "ok", + "equals": true + } + ] + }, + { + "name": "review packet is written", + "do": "packet", + "file": "review.md", + "header": { + "mode": "review", + "name": "review-1", + "orchestrator": "orch", + "workspace": "{root}", + "revision": "HEAD" + }, + "body": "Review the diff of HEAD for correctness." + }, + { + "name": "host task is recorded without a process", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "review-1", + "--provider", + "codex", + "--model", + "gpt-test", + "--mode", + "review", + "--packet", + "{packets}/review.md", + "--host-ref", + "t3:task-42" + ], + "expect": [ + { + "path": "result.transport", + "equals": "host" + }, + { + "path": "result.runtime", + "absent": true + } + ] + }, + { + "name": "status lists it as running", + "do": "runtime", + "args": [ + "delegate", + "status", + "--session", + "orch" + ], + "expect": [ + { + "path": "result.delegations.0.status", + "equals": "running" + }, + { + "path": "result.delegations.0.host_ref", + "equals": "t3:task-42" + } + ] + }, + { + "name": "collect without a reply leaves it open as malformed", + "do": "runtime", + "args": [ + "delegate", + "collect", + "--session", + "orch", + "--name", + "review-1" + ], + "expect": [ + { + "path": "result.status", + "equals": "malformed" + } + ] + }, + { + "name": "host reply arrives wrapped in JSON output", + "do": "reply", + "file": "review-reply.json", + "json_wrap": true, + "result": { + "outcome": "completed", + "summary": "One issue", + "verdict": "CHANGES_REQUIRED", + "findings": [ + { + "location": "a.txt:1", + "scenario": "empty input", + "impact": "crash" + } + ] + } + }, + { + "name": "collect from the saved reply", + "do": "runtime", + "args": [ + "delegate", + "collect", + "--session", + "orch", + "--name", + "review-1", + "--from", + "{packets}/review-reply.json" + ], + "expect": [ + { + "path": "result.status", + "equals": "returned" + }, + { + "path": "result.result.verdict", + "in": [ + "APPROVE", + "CHANGES_REQUIRED" + ] + }, + { + "path": "result.result.findings", + "nonempty": true + } + ] + }, + { + "name": "recorded delegation meets the golden expectations", + "do": "runtime", + "args": [ + "delegate", + "status", + "--name", + "review-1" + ], + "verify": true, + "expect": [ + { + "path": "result.delegations.0.status", + "equals": "returned" + }, + { + "path": "result.delegations.0.result.verdict", + "in": [ + "APPROVE", + "CHANGES_REQUIRED" + ] + }, + { + "path": "result.delegations.0.result.findings", + "exists": true + } + ] + } + ] + }, + { + "id": "write-worktree", + "description": "A worker of another provider implements one phase in a linked worktree; the orchestrator verifies its claims against recorded facts, integrates and closes the phase with fresh checks.", + "steps": [ + { + "name": "orchestrator begins", + "do": "runtime", + "args": [ + "begin", + "--session", + "orch" + ], + "expect": [ + { + "path": "ok", + "equals": true + } + ] + }, + { + "name": "plan records the phase", + "do": "runtime", + "args": [ + "plan", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--input", + "{packets}/plan.json" + ], + "files": { + "plan.json": { + "phases": [ + { + "id": "P1", + "title": "Greeting", + "acceptance": "greeting.txt says hello", + "paths": [ + "greeting.txt" + ], + "required_checks": [ + "unit" + ] + } + ] + } + }, + "expect": [ + { + "path": "ok", + "equals": true + } + ] + }, + { + "name": "orchestrator claims P1 by mistake", + "do": "runtime", + "args": [ + "transition", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--phase", + "P1", + "--to", + "IN_PROGRESS" + ], + "expect": [ + { + "path": "result.owner", + "equals": "orch" + } + ] + }, + { + "name": "worktree for the worker", + "do": "git", + "args": [ + "worktree", + "add", + "-q", + "-b", + "relay/P1", + "{wt}" + ] + }, + { + "name": "write packet is written", + "do": "packet", + "file": "write.md", + "header": { + "mode": "write", + "name": "write-1", + "orchestrator": "orch", + "phase": "P1", + "worker_session": "worker-1", + "workspace": "{wt}", + "allowed_paths": [ + "greeting.txt" + ], + "required_checks": { + "unit": [ + "{python}", + "-c", + "import sys; sys.exit(open('greeting.txt').read().strip() != 'hello')" + ] + } + }, + "body": "Create greeting.txt containing hello." + }, + { + "name": "delegating a phase the orchestrator still holds is refused", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "write-1", + "--provider", + "stub", + "--mode", + "write", + "--phase", + "P1", + "--worker-session", + "worker-1", + "--workspace", + "{wt}", + "--packet", + "{packets}/write.md", + "--", + "{python}", + "{stub}", + "{packet}" + ], + "expect": [ + { + "path": "ok", + "equals": false + }, + { + "path": "error.code", + "equals": "ownership" + } + ] + }, + { + "name": "orchestrator releases P1", + "do": "runtime", + "args": [ + "transition", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--phase", + "P1", + "--to", + "BLOCKED", + "--reason", + "delegated" + ], + "expect": [ + { + "path": "result.owner", + "equals": "" + } + ] + }, + { + "name": "P1 returns to TODO unclaimed", + "do": "runtime", + "args": [ + "transition", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--phase", + "P1", + "--to", + "TODO", + "--reason", + "delegated" + ], + "expect": [ + { + "path": "result.state", + "equals": "TODO" + } + ] + }, + { + "name": "write worker starts in the worktree", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--name", + "write-1", + "--provider", + "stub", + "--mode", + "write", + "--phase", + "P1", + "--worker-session", + "worker-1", + "--workspace", + "{wt}", + "--packet", + "{packets}/write.md", + "--timeout", + "120", + "--", + "{python}", + "{stub}", + "{packet}", + "--edit", + "greeting.txt=hello" + ], + "expect": [ + { + "path": "ok", + "equals": true + }, + { + "path": "result.runtime.name", + "equals": "relay-write-1" + } + ] + }, + { + "name": "worker exits", + "do": "wait", + "delegation": "write-1", + "expect": [ + { + "path": "status", + "equals": "exited" + }, + { + "path": "runtime.exit_code", + "equals": 0 + } + ] + }, + { + "name": "collect matches claims with recorded facts", + "do": "runtime", + "args": [ + "delegate", + "collect", + "--session", + "orch", + "--name", + "write-1" + ], + "expect": [ + { + "path": "result.status", + "equals": "returned" + }, + { + "path": "result.discrepancies", + "empty": true + }, + { + "path": "result.worker.session", + "contains": "ended" + }, + { + "path": "result.worker.phase_state", + "equals": "VERIFY" + }, + { + "path": "result.worker.phase_owner", + "equals": "" + }, + { + "path": "result.worker.recorded_checks.unit", + "equals": "pass" + }, + { + "path": "result.worker.root", + "contains": "worker wt" + } + ] + }, + { + "name": "integrator stages the worker's change", + "do": "git", + "cwd": "wt", + "args": [ + "add", + "greeting.txt" + ] + }, + { + "name": "integrator reviews and commits the worker's change on its branch", + "do": "git", + "cwd": "wt", + "args": [ + "-c", + "user.name=Integrator", + "-c", + "user.email=integrator@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qam", + "P1 by worker-1", + "--allow-empty" + ] + }, + { + "name": "integrator merges the worker branch", + "do": "git", + "args": [ + "merge", + "-q", + "--ff-only", + "relay/P1" + ] + }, + { + "name": "orchestrator claims P1 for verification", + "do": "runtime", + "args": [ + "transition", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--phase", + "P1", + "--to", + "VERIFY" + ], + "expect": [ + { + "path": "result.owner", + "equals": "orch" + } + ] + }, + { + "name": "fresh check in the integrated checkout", + "do": "runtime", + "args": [ + "check", + "run", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--phase", + "P1", + "--name", + "unit", + "--", + "{python}", + "-c", + "import sys; sys.exit(open('greeting.txt').read().strip() != 'hello')" + ], + "expect": [ + { + "path": "result.result", + "equals": "pass" + } + ] + }, + { + "name": "P1 is accepted", + "do": "runtime", + "args": [ + "transition", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--phase", + "P1", + "--to", + "DONE", + "--acceptance-note", + "greeting.txt says hello after integration" + ], + "expect": [ + { + "path": "result.state", + "equals": "DONE" + } + ] + }, + { + "name": "orchestrator ends cleanly", + "do": "runtime", + "args": [ + "end", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--next-action", + "done" + ], + "expect": [ + { + "path": "result.open_delegations", + "absent": true + } + ] + }, + { + "name": "recorded delegation meets the golden expectations", + "do": "runtime", + "args": [ + "delegate", + "status", + "--name", + "write-1" + ], + "verify": true, + "expect": [ + { + "path": "result.delegations.0.status", + "equals": "returned" + }, + { + "path": "result.delegations.0.result.outcome", + "equals": "completed" + }, + { + "path": "result.delegations.0.discrepancies", + "absent": true + }, + { + "path": "result.delegations.0.worker.session", + "contains": "ended" + }, + { + "path": "result.delegations.0.worker.recorded_checks", + "nonempty": true + } + ] + }, + { + "name": "the phase is accepted after integration", + "do": "runtime", + "args": [ + "status" + ], + "verify": true, + "expect": [ + { + "path": "result.phases.0.id", + "equals": "P1" + }, + { + "path": "result.phases.0.state", + "equals": "DONE" + } + ] + } + ] + }, + { + "id": "write-host-fast-worker", + "description": "A host-native writer (observed live with Codex through T3) begins and claims its phase before the orchestrator records the task; the late record is accepted, while an unrelated session reusing the ID is not.", + "steps": [ + { + "name": "orchestrator begins", + "do": "runtime", + "args": [ + "begin", + "--session", + "orch" + ] + }, + { + "name": "plan records the phase", + "do": "runtime", + "args": [ + "plan", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--input", + "{packets}/plan.json" + ], + "files": { + "plan.json": { + "phases": [ + { + "id": "P1", + "title": "Greeting", + "acceptance": "greeting.txt says hello", + "paths": [ + "greeting.txt" + ], + "required_checks": [ + "unit" + ] + } + ] + } + } + }, + { + "name": "worktree for the worker", + "do": "git", + "args": [ + "worktree", + "add", + "-q", + "-b", + "relay/P1", + "{wt}" + ] + }, + { + "name": "write packet", + "do": "packet", + "file": "write.md", + "header": { + "mode": "write", + "name": "fast-1", + "orchestrator": "orch", + "phase": "P1", + "worker_session": "worker-fast", + "workspace": "{wt}", + "allowed_paths": [ + "greeting.txt" + ], + "required_checks": { + "unit": [ + "{python}", + "-c", + "pass" + ] + } + }, + "body": "Create greeting.txt." + }, + { + "name": "the host worker begins first", + "do": "runtime", + "root": "wt", + "args": [ + "begin", + "--parallel", + "--session", + "worker-fast" + ], + "expect": [ + { + "path": "ok", + "equals": true + } + ] + }, + { + "name": "and claims the phase", + "do": "runtime", + "root": "wt", + "args": [ + "transition", + "--session", + "worker-fast", + "--expect-revision", + "{rev}", + "--phase", + "P1", + "--to", + "IN_PROGRESS" + ], + "expect": [ + { + "path": "result.owner", + "equals": "worker-fast" + } + ] + }, + { + "name": "the orchestrator records the task afterwards", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "fast-1", + "--provider", + "codex", + "--mode", + "write", + "--phase", + "P1", + "--worker-session", + "worker-fast", + "--workspace", + "{wt}", + "--packet", + "{packets}/write.md", + "--host-ref", + "t3:fast-1" + ], + "expect": [ + { + "path": "ok", + "equals": true + } + ] + }, + { + "name": "status links the running worker", + "do": "runtime", + "args": [ + "delegate", + "status", + "--name", + "fast-1" + ], + "expect": [ + { + "path": "result.delegations.0.worker.session", + "equals": "active" + }, + { + "path": "result.delegations.0.worker.phase_owner", + "equals": "worker-fast" + } + ] + }, + { + "name": "a CLI delegation cannot reuse a live session ID", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "fast-2", + "--provider", + "stub", + "--mode", + "write", + "--phase", + "P1", + "--worker-session", + "worker-fast", + "--workspace", + "{wt}", + "--packet", + "{packets}/write.md", + "--", + "{python}", + "{stub}", + "{packet}" + ], + "expect": [ + { + "path": "ok", + "equals": false + }, + { + "path": "error.code", + "equals": "session_exists" + } + ] + }, + { + "name": "another worker cannot take the claimed phase", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "fast-3", + "--provider", + "codex", + "--mode", + "write", + "--phase", + "P1", + "--worker-session", + "worker-other", + "--packet", + "{packets}/write.md", + "--host-ref", + "t3:x" + ], + "expect": [ + { + "path": "ok", + "equals": false + }, + { + "path": "error.code", + "equals": "ownership" + } + ] + } + ] + }, + { + "id": "failures", + "description": "The orchestrator catches a hung worker, a garbled reply, a writer that claims checks it never ran, and survives its own session ending with work still open.", + "steps": [ + { + "name": "orchestrator begins", + "do": "runtime", + "args": [ + "begin", + "--session", + "orch" + ] + }, + { + "name": "plan records the phase", + "do": "runtime", + "args": [ + "plan", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--input", + "{packets}/plan.json" + ], + "files": { + "plan.json": { + "phases": [ + { + "id": "P1", + "title": "Greeting", + "acceptance": "greeting.txt says hello", + "paths": [ + "greeting.txt" + ], + "required_checks": [ + "unit" + ] + }, + { + "id": "P2", + "title": "Slow", + "acceptance": "slow.txt exists", + "paths": [ + "slow.txt" + ], + "required_checks": [ + "unit" + ] + } + ] + } + } + }, + { + "name": "consult packet", + "do": "packet", + "file": "consult.md", + "header": { + "mode": "consult", + "name": "hang-1", + "orchestrator": "orch", + "workspace": "{root}" + }, + "body": "Anything." + }, + { + "name": "a hanging worker is started with a timeout", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--name", + "hang-1", + "--provider", + "stub", + "--mode", + "consult", + "--packet", + "{packets}/consult.md", + "--timeout", + "5", + "--", + "{python}", + "{stub}", + "{packet}", + "--behavior", + "hang" + ] + }, + { + "name": "a reply for it arrives early", + "do": "reply", + "file": "early.txt", + "result": { + "outcome": "completed", + "summary": "early", + "answer": "x" + } + }, + { + "name": "collect --from is refused while the worker still runs", + "do": "runtime", + "args": [ + "delegate", + "collect", + "--session", + "orch", + "--name", + "hang-1", + "--from", + "{packets}/early.txt" + ], + "expect": [ + { + "path": "ok", + "equals": false + }, + { + "path": "error.code", + "equals": "delegation_running" + } + ] + }, + { + "name": "the supervisor stops it at the timeout", + "do": "wait", + "delegation": "hang-1", + "expect": [ + { + "path": "status", + "equals": "exited" + }, + { + "path": "runtime.timed_out", + "equals": true + } + ] + }, + { + "name": "collect finds no result", + "do": "runtime", + "args": [ + "delegate", + "collect", + "--session", + "orch", + "--name", + "hang-1" + ], + "expect": [ + { + "path": "result.status", + "equals": "malformed" + } + ] + }, + { + "name": "cancel closes it", + "do": "runtime", + "args": [ + "delegate", + "cancel", + "--session", + "orch", + "--name", + "hang-1" + ], + "expect": [ + { + "path": "result.status", + "equals": "cancelled" + } + ] + }, + { + "name": "a garbling worker is started", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--name", + "garble-1", + "--provider", + "stub", + "--mode", + "consult", + "--packet", + "{packets}/consult.md", + "--", + "{python}", + "{stub}", + "{packet}", + "--behavior", + "garbage" + ] + }, + { + "name": "it exits", + "do": "wait", + "delegation": "garble-1", + "expect": [ + { + "path": "status", + "equals": "exited" + } + ] + }, + { + "name": "its reply is reported as malformed, not accepted", + "do": "runtime", + "args": [ + "delegate", + "collect", + "--session", + "orch", + "--name", + "garble-1" + ], + "expect": [ + { + "path": "result.status", + "equals": "malformed" + }, + { + "path": "result.error", + "contains": "RELAY-RESULT" + } + ] + }, + { + "name": "a corrected reply can still be collected", + "do": "reply", + "file": "garble-reply.txt", + "result": { + "outcome": "completed", + "summary": "Second try", + "answer": "fine" + } + }, + { + "name": "collect the corrected reply", + "do": "runtime", + "args": [ + "delegate", + "collect", + "--session", + "orch", + "--name", + "garble-1", + "--from", + "{packets}/garble-reply.txt" + ], + "expect": [ + { + "path": "result.status", + "equals": "returned" + } + ] + }, + { + "name": "worktree for the lying worker", + "do": "git", + "args": [ + "worktree", + "add", + "-q", + "-b", + "relay/lie", + "{wt}" + ] + }, + { + "name": "write packet", + "do": "packet", + "file": "write.md", + "header": { + "mode": "write", + "name": "lie-1", + "orchestrator": "orch", + "phase": "P1", + "worker_session": "worker-lie", + "workspace": "{wt}", + "allowed_paths": [ + "greeting.txt" + ], + "required_checks": { + "unit": [ + "{python}", + "-c", + "pass" + ] + } + }, + "body": "Create greeting.txt." + }, + { + "name": "a writer that skips its checks is started", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--name", + "lie-1", + "--provider", + "stub", + "--mode", + "write", + "--phase", + "P1", + "--worker-session", + "worker-lie", + "--workspace", + "{wt}", + "--packet", + "{packets}/write.md", + "--", + "{python}", + "{stub}", + "{packet}", + "--behavior", + "lie", + "--edit", + "greeting.txt=hello", + "--edit", + "other.txt=surprise" + ] + }, + { + "name": "it exits", + "do": "wait", + "delegation": "lie-1", + "expect": [ + { + "path": "status", + "equals": "exited" + } + ] + }, + { + "name": "collect exposes the unrecorded checks and the unreported out-of-scope file", + "do": "runtime", + "args": [ + "delegate", + "collect", + "--session", + "orch", + "--name", + "lie-1" + ], + "expect": [ + { + "path": "result.status", + "equals": "returned" + }, + { + "path": "result.discrepancies.0", + "contains": "claimed to pass" + }, + { + "path": "result.discrepancies", + "contains": "Required check unit" + }, + { + "path": "result.discrepancies", + "contains": "Workspace changes outside the phase paths: other.txt" + } + ] + }, + { + "name": "slow writer packet (its check ignores SIGTERM)", + "do": "packet", + "file": "slow.md", + "header": { + "mode": "write", + "name": "slow-1", + "orchestrator": "orch", + "phase": "P2", + "worker_session": "worker-slow", + "workspace": "{root}", + "allowed_paths": [ + "slow.txt" + ], + "required_checks": { + "unit": [ + "{python}", + "-c", + "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600) # relay-golden-slow" + ] + } + }, + "body": "Create slow.txt." + }, + { + "name": "a writer whose check outlives the relay timeout is started", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--name", + "slow-1", + "--provider", + "stub", + "--mode", + "write", + "--phase", + "P2", + "--worker-session", + "worker-slow", + "--packet", + "{packets}/slow.md", + "--timeout", + "6", + "--", + "{python}", + "{stub}", + "{packet}", + "--edit", + "slow.txt=slow" + ], + "expect": [ + { + "path": "ok", + "equals": true + } + ] + }, + { + "name": "the supervisor stops it at the timeout", + "do": "wait", + "delegation": "slow-1", + "timeout": 60, + "expect": [ + { + "path": "status", + "equals": "exited" + }, + { + "path": "runtime.timed_out", + "equals": true + }, + { + "path": "worker.recorded_checks.unit", + "equals": "abandoned" + } + ] + }, + { + "name": "its check process did not survive", + "do": "no_process", + "pattern": "relay-golden-slow" + }, + { + "name": "cancel closes it and names the worker session to release", + "do": "runtime", + "args": [ + "delegate", + "cancel", + "--session", + "orch", + "--name", + "slow-1" + ], + "expect": [ + { + "path": "result.status", + "equals": "cancelled" + }, + { + "path": "result.next", + "contains": "worker-slow" + } + ] + }, + { + "name": "the user confirmed the worker stopped; release it", + "do": "runtime", + "args": [ + "release", + "--session", + "worker-slow", + "--expect-revision", + "{rev}", + "--confirm-stopped", + "--reason", + "relay timeout", + "--by", + "orch" + ], + "expect": [ + { + "path": "ok", + "equals": true + } + ] + }, + { + "name": "review packet", + "do": "packet", + "file": "review.md", + "header": { + "mode": "review", + "name": "late-1", + "orchestrator": "orch", + "workspace": "{root}" + }, + "body": "Review." + }, + { + "name": "a host review is still running", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "late-1", + "--provider", + "codex", + "--mode", + "review", + "--packet", + "{packets}/review.md", + "--host-ref", + "t3:task-7" + ] + }, + { + "name": "ending the orchestrator reports it as open", + "do": "runtime", + "args": [ + "end", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--next-action", + "collect late-1" + ], + "expect": [ + { + "path": "result.open_delegations.0", + "equals": "late-1" + } + ] + }, + { + "name": "a new orchestrator sees it on begin", + "do": "runtime", + "args": [ + "begin", + "--session", + "orch-2" + ], + "expect": [ + { + "path": "result.open_delegations.0.name", + "equals": "late-1" + } + ] + }, + { + "name": "host reply", + "do": "reply", + "file": "late-reply.txt", + "result": { + "outcome": "completed", + "summary": "Fine", + "verdict": "APPROVE", + "findings": [] + } + }, + { + "name": "the new orchestrator adopts and collects it", + "do": "runtime", + "args": [ + "delegate", + "collect", + "--session", + "orch-2", + "--name", + "late-1", + "--from", + "{packets}/late-reply.txt" + ], + "expect": [ + { + "path": "result.status", + "equals": "returned" + }, + { + "path": "result.session_id", + "equals": "orch-2" + }, + { + "path": "result.adopted_from", + "equals": "orch" + } + ] + } + ] + } + ] +} diff --git a/scripts/golden/stub_worker.py b/scripts/golden/stub_worker.py new file mode 100644 index 0000000..ad5527f --- /dev/null +++ b/scripts/golden/stub_worker.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Deterministic stand-in for an LLM worker in golden path tests. + +It reads an agent-relay packet, performs the steps the packet asks a real agent +to perform, and ends with a RELAY-RESULT block. Behaviors simulate the failure +modes the orchestrator must catch: a hung worker, a garbled reply and a worker +that claims checks it never ran. +""" +import argparse +import json +from pathlib import Path +import subprocess +import sys +import time + + +def packet_header(path): + text = Path(path).read_text(encoding="utf-8") + start = text.index("```relay-packet") + len("```relay-packet") + return json.loads(text[start:text.index("```", start)]) + + +def runtime(packet, *args): + done = subprocess.run([*packet["runtime"], "--root", packet["workspace"], *args], + capture_output=True, text=True) + value = json.loads(done.stdout) + if not value["ok"]: + raise SystemExit("runtime refused: " + done.stdout) + return value + + +def reply(result): + print("Worker finished; result follows.") + print("<<>>") + + +def write(packet, args): + sid, pid = packet["worker_session"], packet["phase"] + rev = runtime(packet, "begin", "--parallel", "--session", sid)["revision"] + rev = runtime(packet, "transition", "--session", sid, "--expect-revision", str(rev), + "--phase", pid, "--to", "IN_PROGRESS")["revision"] + changed = [] + for item in args.edit: + name, text = item.split("=", 1) + target = Path(packet["workspace"]) / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text + "\n", encoding="utf-8") + changed.append(name) + reported = changed[:1] if args.behavior == "lie" else changed + checks = [] + for name, argv in packet["required_checks"].items(): + if args.behavior == "lie": + checks.append({"name": name, "result": "pass"}) + continue + done = runtime(packet, "check", "run", "--session", sid, "--expect-revision", str(rev), + "--phase", pid, "--name", name, "--timeout", "900", "--", *argv) + rev = done["revision"] + checks.append({"name": name, "result": done["result"]["result"]}) + rev = runtime(packet, "transition", "--session", sid, "--expect-revision", str(rev), + "--phase", pid, "--to", "VERIFY")["revision"] + runtime(packet, "end", "--session", sid, "--expect-revision", str(rev), + "--next-action", "Orchestrator integrates " + pid) + reply({"outcome": "completed", "summary": "Edited " + ", ".join(reported), "worker_session": sid, + "phase": pid, "changed_files": reported, "checks": checks, "gaps": []}) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("packet") + parser.add_argument("--behavior", choices=("normal", "hang", "garbage", "lie"), default="normal") + parser.add_argument("--answer", default="") + parser.add_argument("--verdict", default="APPROVE") + parser.add_argument("--finding", action="append", default=[]) + parser.add_argument("--edit", action="append", default=[], help="path=text inside the workspace") + args = parser.parse_args() + packet = packet_header(args.packet) + if args.behavior == "hang": + time.sleep(600) + if args.behavior == "garbage": + print("I think the task is done.") + return 0 + if packet["mode"] == "consult": + reply({"outcome": "completed", "summary": "Answered", "answer": args.answer}) + elif packet["mode"] == "review": + reply({"outcome": "completed", "summary": "Reviewed", "verdict": args.verdict, + "findings": [{"location": f, "scenario": "stub", "impact": "stub"} for f in args.finding]}) + else: + write(packet, args) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_relay.py b/scripts/test_relay.py new file mode 100644 index 0000000..8fb8955 --- /dev/null +++ b/scripts/test_relay.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Golden path integration tests for agent-relay delegation. + +Each scenario in golden/relay-scenarios.json is a sequence of orchestrator and +worker interactions with an expected result at every step. The first failed +step fails the whole scenario. By default a deterministic stub stands in for +the LLM worker in a disposable Git repository, so the suite is cheap enough to +run on every change and in CI. + +--live-verify SCENARIO runs only the read-only steps marked "verify" against a +project where a real provider performed that scenario, with --map replacing the +scenario's session and delegation IDs by the live ones. +""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time + +HERE = Path(__file__).resolve().parent +SOURCE = HERE.parent / "packs/session-workflow/shared/runtime/session.py" +SCENARIOS = HERE / "golden/relay-scenarios.json" +STUB = HERE / "golden/stub_worker.py" + + +class StepFailed(Exception): + pass + + +def lookup(value, path): + for part in path.split("."): + if isinstance(value, list) and part.isdigit() and int(part) < len(value): + value = value[int(part)] + elif isinstance(value, dict) and part in value: + value = value[part] + else: + return KeyError + return value + + +def judge(value, expectations): + for rule in expectations: + actual = lookup(value, rule["path"]) + missing = actual is KeyError + if "equals" in rule and (missing or actual != rule["equals"]): + raise StepFailed(f"{rule['path']} = {actual!r}, expected {rule['equals']!r}") + if "in" in rule and (missing or actual not in rule["in"]): + raise StepFailed(f"{rule['path']} = {actual!r}, expected one of {rule['in']!r}") + if "contains" in rule and (missing or rule["contains"] not in str(actual)): + raise StepFailed(f"{rule['path']} = {actual!r}, expected to contain {rule['contains']!r}") + if rule.get("exists") and missing: + raise StepFailed(f"{rule['path']} is missing") + if rule.get("absent") and not missing: + raise StepFailed(f"{rule['path']} = {actual!r}, expected absent") + if rule.get("nonempty") and (missing or not actual): + raise StepFailed(f"{rule['path']} is empty or missing") + if rule.get("empty") and not missing and actual: + raise StepFailed(f"{rule['path']} = {actual!r}, expected empty") + + +class Run: + def __init__(self, base, root, mapping=None): + self.root, self.wt, self.packets = root, base / "worker wt", base / "packets" + self.packets.mkdir(exist_ok=True) + self.rev, self.mapping = None, mapping or {} + + def fill(self, value): + if isinstance(value, list): + return [self.fill(item) for item in value] + if isinstance(value, dict): + return {key: self.fill(item) for key, item in value.items()} + if not isinstance(value, str): + return value + value = self.mapping.get(value, value) + for token, real in (("{root}", self.root), ("{wt}", self.wt), ("{packets}", self.packets), + ("{python}", sys.executable), ("{stub}", STUB), ("{rev}", self.rev)): + value = value.replace(token, str(real)) + return value + + def runtime(self, args, where=None): + root = self.wt if where == "wt" else self.root + done = subprocess.run([sys.executable, str(SOURCE), "--root", str(root), *self.fill(args)], + capture_output=True, text=True, timeout=300) + try: + output = json.loads(done.stdout) + except ValueError: + raise StepFailed(f"runtime printed no JSON (exit {done.returncode}): {done.stderr[-500:]}") + if output.get("revision") is not None: + self.rev = output["revision"] + return output + + def step(self, step): + for name, data in step.get("files", {}).items(): + (self.packets / name).write_text(json.dumps(self.fill(data)), encoding="utf-8") + action = step["do"] + if action == "runtime": + return self.runtime(step["args"], step.get("root")) + if action == "git": + where = self.wt if step.get("cwd") == "wt" else self.root + done = subprocess.run(["git", "-C", str(where), *self.fill(step["args"])], capture_output=True, text=True) + if done.returncode: + raise StepFailed("git failed: " + done.stderr[-500:]) + return {"ok": True} + if action == "packet": + header = {**self.fill(step["header"]), "version": 1, "runtime": [sys.executable, str(SOURCE)]} + text = (f"# Relay packet\n\n```relay-packet\n{json.dumps(header, indent=2)}\n```\n\n" + f"{self.fill(step['body'])}\n") + (self.packets / step["file"]).write_text(text, encoding="utf-8") + return {"ok": True} + if action == "reply": + text = f"Done.\n<<>>\n" + if step.get("json_wrap"): + text = json.dumps({"type": "result", "result": text, "session_id": "host-session"}) + "\n" + (self.packets / step["file"]).write_text(text, encoding="utf-8") + return {"ok": True} + if action == "wait": + deadline = time.monotonic() + step.get("timeout", 120) + while time.monotonic() < deadline: + view = self.runtime(["delegate", "status", "--name", step["delegation"]]) + if not view["ok"]: + raise StepFailed("status failed: " + json.dumps(view["error"])) + current = view["result"]["delegations"][0] + if current["status"] != "running": + return current + time.sleep(0.2) + raise StepFailed("delegation still running at the wait timeout") + if action == "no_process": + deadline = time.monotonic() + step.get("timeout", 10) + while True: # a stopped process may need a moment to be reaped + done = subprocess.run(["pgrep", "-f", step["pattern"]], capture_output=True, text=True) + if not done.stdout.strip() or time.monotonic() > deadline: + break + time.sleep(0.2) + if done.stdout.strip(): + ps = subprocess.run(["ps", "-o", "pid=,ppid=,pgid=,command=", "-p", ",".join(done.stdout.split())], + capture_output=True, text=True) + raise StepFailed("process still running: " + (ps.stdout.strip() or done.stdout.strip())) + return {"ok": True} + raise StepFailed("unknown action " + action) + + +def fixture(base): + root = base / "project" + root.mkdir() + for args in (["init", "-q"], ): + subprocess.run(["git", "-C", str(root), *args], check=True) + (root / "a.txt").write_text("a\n") + (root / ".gitignore").write_text(".agent-state/\n") + subprocess.run(["git", "-C", str(root), "add", "."], check=True) + subprocess.run(["git", "-C", str(root), "-c", "user.name=Golden", "-c", "user.email=golden@example.invalid", + "-c", "commit.gpgsign=false", "commit", "-qm", "fixture"], check=True) + return root + + +def play(scenario, run, only_verify=False): + """Run steps in order; the first failure fails the scenario and skips the rest.""" + steps = [s for s in scenario["steps"] if s.get("verify") or not only_verify] + for number, step in enumerate(steps, 1): + try: + result = run.step(step) + judge(result, step.get("expect", [{"path": "ok", "equals": True}])) + except (StepFailed, subprocess.TimeoutExpired) as exc: + print(f" FAIL step {number}/{len(steps)}: {step['name']}\n {exc}") + if len(steps) > number: + print(f" skipped {len(steps) - number} later steps") + return False + print(f" ok step {number}/{len(steps)}: {step['name']}") + return True + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--scenario", action="append", help="Run only these scenario IDs") + parser.add_argument("--live-verify", metavar="SCENARIO", help="Check a live run's recorded state, read-only") + parser.add_argument("--root", help="Project root for --live-verify") + parser.add_argument("--map", action="append", default=[], metavar="SCENARIO_ID=LIVE_ID") + args = parser.parse_args(argv) + scenarios = json.loads(SCENARIOS.read_text(encoding="utf-8"))["scenarios"] + if args.live_verify: + if not args.root: + parser.error("--live-verify needs --root") + scenario = next((s for s in scenarios if s["id"] == args.live_verify), None) + if scenario is None: + parser.error("unknown scenario " + args.live_verify) + if not any(step.get("verify") for step in scenario["steps"]): + parser.error(f"scenario {scenario['id']} has no verify steps; a live run cannot be checked with it") + mapping = dict(item.split("=", 1) for item in args.map) + print(f"live verify {scenario['id']}: {scenario['description']}") + with tempfile.TemporaryDirectory(prefix="relay-live-") as temp: + passed = play(scenario, Run(Path(temp), Path(args.root).resolve(), mapping), only_verify=True) + print("PASS" if passed else "FAIL") + return 0 if passed else 1 + chosen = [s for s in scenarios if not args.scenario or s["id"] in args.scenario] + env = {"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull, "GIT_CONFIG_NOSYSTEM": "1", + "SESSION_WORKFLOW_MACHINE_ID": "golden-relay"} + saved = {key: os.environ.get(key) for key in env} + os.environ.update(env) + failed = [] + try: + for scenario in chosen: + print(f"scenario {scenario['id']}: {scenario['description']}") + with tempfile.TemporaryDirectory(prefix="relay-golden-") as temp: + base = Path(temp).resolve() + run = Run(base, fixture(base)) + try: + if not play(scenario, run): + failed.append(scenario["id"]) + finally: + # Leave nothing running: cancel open CLI workers. + for record in (run.runtime(["delegate", "status"]).get("result") or {}).get("delegations", []): + if record["status"] == "running" and record["transport"] == "cli": + run.runtime(["delegate", "cancel", "--session", record["session_id"], "--name", record["name"]]) + finally: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + print(f"{len(chosen) - len(failed)}/{len(chosen)} golden scenarios passed" + + (": failed " + ", ".join(failed) if failed else "")) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_session.py b/scripts/test_session.py index 6922d21..959811e 100644 --- a/scripts/test_session.py +++ b/scripts/test_session.py @@ -102,6 +102,179 @@ def batch_call(self, steps, owner="one", **kwargs): return self.call("batch", "--session", owner, "--input", self.payload("batch.json", {"steps": steps}), **kwargs) + # Delegation result parsing + + def test_relay_result_is_found_raw_fenced_or_inside_json_output(self): + block = '<<>>' + self.assertEqual(r.relay_extract("noise\n" + block), '{"outcome": "completed"}') + fenced = '<<>>' + self.assertEqual(json.loads(r.relay_extract(fenced)), {"a": 1}) + wrapped = json.dumps({"type": "result", "result": "text\n" + block}) + self.assertEqual(r.relay_extract('{"type": "progress"}\n' + wrapped), '{"outcome": "completed"}') + older = block.replace("completed", "blocked") + self.assertIn("completed", r.relay_extract(older + "\n" + block)) + self.assertIsNone(r.relay_extract("<<>>') + view = self.call("delegate", "collect", "--session", "one", "--name", "w", "--from", str(reply))["result"] + self.assertEqual(view["status"], "malformed") + self.assertFalse((r.relay_dir(self.store, "w") / "result.json").exists()) + + def test_relay_unknown_worker_is_not_closed_without_confirmation(self): + self.setup_sessions() + packet = self.base / "packet.md" + packet.write_text("Ask\n") + self.call("delegate", "start", "--session", "one", "--name", "q", "--provider", "stub", "--mode", "consult", + "--packet", str(packet), "--", sys.executable, "-c", "import time; time.sleep(30)") + record = r.relay_load(self.store, "q") + state = self.state() + key, process = r.relay_process(state, record) + r.stop_runtime(self.store, process) + (r.relay_path(self.store, process["directory"]) / "receipt.json").unlink() + self.assertEqual(self.call("delegate", "cancel", "--session", "one", "--name", "q", code=3)["error"]["code"], + "delegation_unknown") + self.assertEqual(self.call("delegate", "collect", "--session", "one", "--name", "q", code=3)["error"]["code"], + "delegation_unknown") + ended = self.call("end", "--session", "one", "--next-action", "x")["result"] + self.assertEqual(ended["open_delegations"], ["q"]) + self.call("begin", "--session", "two") + view = self.call("delegate", "cancel", "--session", "two", "--name", "q", "--confirm-stopped")["result"] + self.assertEqual((view["status"], view["adopted_from"]), ("cancelled", "one")) + + def test_relay_adoption_moves_the_kept_worker_process_to_the_adopter(self): + self.setup_sessions() + packet = self.base / "packet.md" + packet.write_text("Ask\n") + self.call("delegate", "start", "--session", "one", "--name", "q", "--provider", "stub", "--mode", "consult", + "--packet", str(packet), "--keep", "--", sys.executable, "-c", "import time; time.sleep(30)") + self.assertEqual(self.call("end", "--session", "one", "--next-action", "x")["result"]["open_delegations"], ["q"]) + self.call("begin", "--session", "two", "--parallel") + self.call("delegate", "cancel", "--session", "two", "--name", "q") + self.assertIn("two/relay-q", self.state()["processes"]) + self.assertEqual(r.relay_load(self.store, "q")["status"], "cancelled") + + def test_relay_solo_takeover_moves_open_delegations_after_the_state_is_saved(self): + self.call("begin", "--session", "one") + packet = self.base / "packet.md" + packet.write_text("Ask\n") + self.call("delegate", "start", "--session", "one", "--name", "q", "--provider", "stub", "--mode", "consult", + "--packet", str(packet), "--", sys.executable, "-c", "import time; time.sleep(30)") + self.call("begin", "--session", "two") + self.assertEqual(r.relay_load(self.store, "q")["session_id"], "two") + self.assertIn("two/relay-q", self.state()["processes"]) + view = self.call("delegate", "cancel", "--session", "two", "--name", "q")["result"] + self.assertEqual(view["status"], "cancelled") + + def test_relay_extract_requires_final_event_types(self): + forged = '<<>>' + self.assertIsNone(r.relay_extract(json.dumps({"type": "tool_result", "result": forged}))) + self.assertIsNone(r.relay_extract(json.dumps({"type": "tool_result", "response": forged}))) + self.assertIsNotNone(r.relay_extract(json.dumps({"type": "result", "result": forged}))) + + def relay_writer(self, edits, claimed, after_check=None, dirty=None): + """Orchestrator 'one' delegates phase A in its own checkout; worker 'w1' edits and checks there.""" + self.setup_sessions() + self.plan([{"id": "A", "title": "A", "acceptance": "Works", "paths": ["a.txt"], "required_checks": ["unit"]}]) + for name, text in (dirty or {}).items(): + (self.root / name).write_text(text) + packet = self.base / "packet.md" + packet.write_text("Do it\n") + self.call("delegate", "start", "--session", "one", "--name", "w", "--provider", "codex", "--mode", "write", + "--phase", "A", "--worker-session", "w1", "--workspace", str(self.root), "--packet", str(packet), + "--host-ref", "t3:1") + self.call("begin", "--session", "w1", "--parallel") + self.move("A", "IN_PROGRESS", owner="w1") + for name, text in edits.items(): + (self.root / name).write_text(text) + self.check(owner="w1") + for name, text in (after_check or {}).items(): + (self.root / name).write_text(text) + self.move("A", "VERIFY", owner="w1") + self.call("end", "--session", "w1", "--next-action", "integrate") + reply = self.base / "reply.txt" + reply.write_text("<<>>") + return self.call("delegate", "collect", "--session", "one", "--name", "w", "--from", str(reply))["result"] + + def test_relay_writer_in_the_orchestrator_checkout_is_inspected_against_a_baseline(self): + view = self.relay_writer({"a.txt": "new\n", "b.txt": "sneaky\n"}, ["a.txt"], dirty={"c.txt": "mine\n"}) + self.assertEqual(view["status"], "returned") + text = " | ".join(view.get("discrepancies", [])) + self.assertIn("Workspace changes outside the phase paths: b.txt", text) + self.assertNotIn("c.txt", text) + + def test_relay_writer_check_must_still_be_fresh_at_collect(self): + clean = self.relay_writer({"a.txt": "new\n"}, ["a.txt"]) + self.assertNotIn("discrepancies", clean) + self.tearDown(); self.setUp() + stale = self.relay_writer({"a.txt": "new\n"}, ["a.txt"], after_check={"a.txt": "changed later\n"}) + self.assertIn("Worker evidence would not satisfy DONE", " | ".join(stale["discrepancies"])) + + def test_relay_duplicate_keys_make_the_result_malformed(self): + self.setup_sessions() + packet = self.base / "packet.md" + packet.write_text("Review\n") + self.call("delegate", "start", "--session", "one", "--name", "rv", "--provider", "codex", "--mode", "review", + "--packet", str(packet), "--host-ref", "t3:2") + reply = self.base / "reply.txt" + reply.write_text('<<>>') + view = self.call("delegate", "collect", "--session", "one", "--name", "rv", "--from", str(reply))["result"] + self.assertEqual(view["status"], "malformed") + self.assertIn("Duplicate JSON key", view["error"]) + # Snapshots and startup def test_snapshot_is_read_only_and_tracks_dirty_and_staged_content(self): diff --git a/scripts/test_workflow.py b/scripts/test_workflow.py index 05cbb01..9c23b9c 100644 --- a/scripts/test_workflow.py +++ b/scripts/test_workflow.py @@ -262,6 +262,32 @@ def test_link_mode_is_idempotent_and_copy_migration_keeps_source(self): self.assertFalse((self.target / "session-start").is_symlink()) self.assertTrue((w.PACK / "skills/session-start/SKILL.md").is_file()) + def test_relay_packet_template_matches_runtime_and_golden_worker(self): + base = w.PACK / "skills/agent-relay" + text = (base / "templates/packet.md").read_text(encoding="utf-8") + spec = importlib.util.spec_from_file_location("relay_runtime", base / "scripts/session.py") + runtime = importlib.util.module_from_spec(spec) + spec.loader.exec_module(runtime) + self.assertIn(runtime.RELAY_OPEN, text) + self.assertIn(runtime.RELAY_CLOSE, text) + for outcome in runtime.RELAY_OUTCOMES: + self.assertIn(outcome, text) + header = text.split("```relay-packet", 1)[1].split("```", 1)[0] + stub = (Path(__file__).with_name("golden") / "stub_worker.py").read_text(encoding="utf-8") + for key in ("mode", "workspace", "runtime", "phase", "worker_session", "required_checks"): + self.assertIn(f'"{key}"', header) + self.assertIn(f'packet["{key}"]', stub) + adapters = (base / "references/adapters.md").read_text(encoding="utf-8") + for token in ("{packet_text}", "{reply_file}", "{workspace}", "{state_dir}"): + self.assertIn(token, adapters) + + def test_live_verify_refuses_a_scenario_without_verify_steps(self): + spec = importlib.util.spec_from_file_location("relay_golden", Path(__file__).with_name("test_relay.py")) + golden = importlib.util.module_from_spec(spec) + spec.loader.exec_module(golden) + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + golden.main(["--live-verify", "failures", "--root", str(self.base)]) + if __name__ == "__main__": unittest.main(verbosity=2)