diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2b4683..c1b32c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: [main] pull_request: permissions: @@ -12,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] python: ["3.9", "3.13"] runs-on: ${{ matrix.os }} timeout-minutes: 20 @@ -24,8 +25,11 @@ jobs: - name: Pack validation run: python scripts/workflow.py check - name: Runtime tests + if: success() || failure() run: python scripts/test_session.py - name: Installer tests + if: success() || failure() run: python scripts/test_workflow.py - name: Golden path integration tests (stub worker) + if: success() || failure() run: python scripts/test_relay.py diff --git a/HANDOVER.md b/HANDOVER.md index 0534dff..8da3f81 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -2,74 +2,99 @@ ## Current work -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`. +3.1.0 (`agent-relay` and runtime `delegate`) is merged to main through PR #3. + +Release 3.2.0 is in progress on branch `relay-hardening-3.1.1` (draft PR #5 to +main; plan docs/plans/relay-hardening.md). Done so far: + +- **Windows.** Supported and verified in CI. Fixed: index file modes in + fingerprints, job-object supervision, and a file-based stop request for + nested checks. +- **Takeover guard.** A solo session-start no longer takes over a live thread + in the same folder. This was observed live with a parallel Codex thread in + T3. +- **Provider policy.** Projects get an "AI providers" table that session-start + asks for on first use and `delegate` enforces. This project allows every + available provider except the Claude (Pricefx) work account. +- **Live checks.** These passed: + - `codex exec` as a worker; + - Antigravity through T3; + - orchestrators in Claude Code and in Codex, each with the other's CLI and + with their own native subagents; + - the Gemini CLI failure mode without an API key. +- **Drivers.** `scripts/golden/live_cli.py` and `live_orchestrator.py` replay + the live golden paths. + +PR #4 was merged only into its stale base branch `agent-relay-3.1.0`, so its +commits are not in main. PR #5 carries them. ## Next action -Merge the 3.1.0 pull request after its CI run passes. Optional follow-ups: +The work moves to another computer. There, follow docs/CONTINUING.md with +branch `relay-hardening-3.1.1` instead of `main`, and use session-start. The +runtime state (phases, checks, the delegation ledger) stays on this computer, +so import the open phases R4 and R5 from the plan. -- 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. +Then fix the 11 findings of Codex review round 3. They are listed in +docs/plans/relay-hardening.md, "Status at pause". Start with the +high-severity ones: + +1. the policy parser; +2. the Windows job assignment race; +3. nested stop chaining; +4. the late-recorded host writer baseline. + +Add a failing test for each, then rerun the suites and the affected live +paths. Have Codex re-review the fix delta. Then release 3.2.0 (R5) and mark PR +#5 ready. ## Validation 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. +- runtime 76/76, installer 21/21 and golden 6/6 scenarios passed, and + `workflow.py check` passed. +- PR #5 CI was green on Linux, macOS and Windows at 29fb17e, the last code + change; this closed R1 on October 16. +- Live evidence for each transport and host is in + packs/session-workflow/VALIDATION.md. ## Boundaries -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. +- **Unverified:** + - a live `gemini -p` answer (no API key); + - Codex orchestrating without network access (not run); + - native Windows beyond GitHub CI. +- **Machine-local:** the runtime state and the live-run fixtures under the + temp directory stay on the first computer. VALIDATION.md keeps the live + evidence. +- **Installs:** the global estafeta skills in the provider profiles are outdated + (2.4.1 or missing). Update them with `python3 scripts/workflow.py install + --all-detected` when convenient. ## Session facts (generated) -State revision 109; HEAD 8559853e8949b0ef51d5ffb9c2351158fba23ff2. +State revision 162; HEAD 29fb17eaebe23d48326a73ee436093ce841035ab. | 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) | +| P1 | DONE | - | unit-session: pass (executed, inputs changed); golden-relay: pass (executed, inputs changed); pack-check: pass (executed, inputs changed) | +| P2 | DONE | - | pack-check: pass (executed, inputs changed); unit-workflow: pass (executed, inputs changed) | +| P3 | DONE | - | pack-check: pass (executed, inputs changed); unit-workflow: pass (executed, inputs changed); unit-session: pass (executed, inputs changed); golden-relay: pass (executed, inputs changed) | +| P4 | DONE | - | live-relay: pass (executed, inputs changed); unit-session: pass (executed, inputs changed); golden-relay: pass (executed, inputs changed) | +| P5 | DONE | - | live-cli: pass (executed, inputs changed); live-review-2: pass (executed, inputs changed); golden-relay: pass (executed, inputs changed); pack-check: pass (executed, inputs changed) | +| R1 | DONE | - | unit-session: pass (executed, inputs match); unit-workflow: pass (executed, inputs match); golden-relay: pass (executed, inputs match); pack-check: pass (executed, inputs match); ci-windows: pass (executed, inputs match) | +| R2 | DONE | - | live-codex-cli: pass (executed, inputs match) | +| R3 | DONE | - | live-antigravity: pass (executed, inputs match); gemini-cli-failure: pass (executed, inputs match) | +| R4 | TODO | - | - | +| R5 | TODO | - | - | +| R6 | DONE | - | unit-session: pass (executed, inputs changed); golden-relay: pass (executed, inputs changed); pack-check: pass (executed, inputs changed) | +| R7 | DONE | - | unit-session: pass (executed, inputs match); golden-relay: pass (executed, inputs match) | +| R8 | DONE | - | live-orchestrators: 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.). +Ended sessions: 10; latest s-transfer-1016 (next: On another computer: follow docs/CONTINUING.md with branch relay-hardening-3.1.1, use session-start, import open phases R4 and R5 from docs/plans/relay-hardening.md, then fix the 11 Codex review round 3 findings (high severity first, each with a failing test), have Codex re-review the fix delta, release 3.2.0 (R5) and mark PR #5 ready). Generated facts do not establish acceptance, explain intent or prove delivery. diff --git a/PROJECT.md b/PROJECT.md index 1c66f96..0f09e47 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -8,7 +8,7 @@ Do not publish credentials, local runtime state, or personal logs. ## Documentation map -- Plan: docs/plans/multi-provider.md (multi-provider relay). +- Plan: docs/plans/relay-hardening.md (3.1.1); previous: docs/plans/multi-provider.md. - Handover: HANDOVER.md. - Installation: docs/INSTALL.md; usage: docs/USAGE.md. - Maintenance and checks: docs/MAINTAINING.md. @@ -16,8 +16,32 @@ Do not publish credentials, local runtime state, or personal logs. ## Current focus -- 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. +- 3.1.0 (multi-provider relay) is merged to main. Release 3.2.0 is in progress + on branch `relay-hardening-3.1.1`, draft PR #5 to main (plan: + docs/plans/relay-hardening.md). PR #4 was merged only into the stale base + branch `agent-relay-3.1.0`, not into main. +- Paused 2026-10-15. Next: fix the 11 findings of Codex review round 3 (R4). +- On another computer, follow docs/CONTINUING.md with branch + `relay-hardening-3.1.1` instead of `main`, then use session-start. The + runtime state stays on the first computer: import the open phases R4 and R5 + from the plan there. + +## AI providers + +Decided by the user on 2026-10-15: every available provider except the +Claude (Pricefx) work account. Agents ask before changing this table. +Providers not listed (for example Cursor, Grok or OpenCode, currently +disabled in T3) are refused until the user adds them. + + +| Provider | Allowed | Models | Notes | +| --- | --- | --- | --- | +| claude | orchestrate, consult, review, write | any | Personal account: T3 instance `claudeAgent`, Claude Code, `claude -p` | +| claude-pricefx | none | - | Work account (T3 instance `claude-pricefx`): not for this project | +| codex | orchestrate, consult, review, write | any | T3 tasks and `codex exec` (local login) | +| antigravity | orchestrate, consult, review, write | any | T3 tasks; Gemini models without an API key | +| gemini | consult, review, write | any | `gemini -p`; needs a paid API key, none on this host yet | + ## Run and verify diff --git a/docs/USAGE.md b/docs/USAGE.md index 8935cef..d42ef8c 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -66,8 +66,14 @@ transfer it together with its evidence. - **Solo (default):** one writer per checkout. A new chat supersedes the earlier chats in the same checkout and takes over their phase claims and preview - servers. A chat closed without session-end therefore blocks nothing. A chat that - is still running a check is detected as alive, and the takeover is refused. + servers, so a chat closed without session-end blocks nothing for long. + - **No takeover of live chats.** A new chat does not take over a chat that + may still be working: one running a check, one that acted in the last 30 + minutes, or one with open delegations. It joins in parallel and says so. + This matters in hosts such as T3 Code, where several threads share one + folder. + - **Idle threshold.** `SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES` changes the 30 + minutes. - **Parallel:** tell a chat, in any language, that another agent works in the same folder ("work in parallel", «работаем параллельно», «pracujeme paralelně», «並行して作業する»), and it begins with `--parallel`. The agent goes diff --git a/docs/plans/relay-hardening.md b/docs/plans/relay-hardening.md new file mode 100644 index 0000000..64df94a --- /dev/null +++ b/docs/plans/relay-hardening.md @@ -0,0 +1,164 @@ +# Relay hardening and provider policy plan (3.2.0) + +- Context: [PROJECT.md](../../PROJECT.md); previous workstream: + [multi-provider](multi-provider.md). +- Goal and acceptance: close the gaps that 3.1.0 left as "Not verified": + - live `codex exec` and Gemini transports; + - the Gemini/Antigravity route; + - native Linux and Windows; + - a third review round on the round 2 fixes. + + Each gap ends verified, or as a documented limitation with a concrete + reason. +- Scope and non-goals: CI, tests, runtime fixes found by the new evidence, + adapter corrections, validation record and a 3.1.1 release. Not in scope: + new delegation modes or a benchmark run. +- Execution: authorized by the user on 2026-10-15. + - Installing `@openai/codex` and `@google/gemini-cli` globally (npm prefix + `~/.local`). + - Paid runs: live `codex exec`, live Antigravity through T3, and a third + Codex review round, with re-review of its fixes if needed. + - Pushes to a stacked branch with its own PR on top of #3. +- Constraints: same as the 3.1.0 plan. Store no secrets, never use + sandbox-bypass flags, and keep state schema 2. +- Base: `agent-relay-3.1.0` @ 5b3fb8a (PR #3, open). Branch + `relay-hardening-3.1.1`. + +## Decisions + +| ID | Question and recommendation | Answer or open impact | Date | +| --- | --- | --- | --- | +| H1 | Linux | Closed by PR #3 CI: ubuntu-latest with Python 3.9 and 3.13 passed all four suites. Record it; no extra work. | 2026-10-15 | +| H2 | Windows without a Windows machine | Add a windows-latest job to CI and iterate by pushing to this branch. Mark POSIX-only tests as skipped with their reason. Fix real runtime defects. | 2026-10-15 | +| H3 | Gemini route | Verify Antigravity live through T3 host tasks. `gemini -p` gets an install and failure-mode check only: personal Gemini logins were retired, and the user chose no API key. | 2026-10-15 | +| H4 | CI double runs | Trigger `push` only for main; pull requests cover branches. | 2026-10-15 | +| H5 | Scope grew (user, 2026-10-15) | One release, 3.2.0, that adds three things: orchestrator hosts beyond T3, a project provider policy with a first-use question, and a guard against taking over a live session. | 2026-10-15 | +| H6 | Provider policy enforcement | The runtime enforces it. PROJECT.md holds a marked "AI providers" table; `delegate start` refuses until it exists, and refuses providers or modes it does not allow. session-start asks the user on first use. | 2026-10-15 | +| H7 | Live session takeover | Observed: a separate T3 thread's solo session-start superseded the live orchestrator and inherited its claims. Solo `begin` now supersedes only sessions that have been idle for 30 minutes (configurable) and hold no open delegations; otherwise it joins in parallel and warns. | 2026-10-15 | + +## Phases + +### R1: Windows and CI + +- Scope: `.github/workflows/ci.yml`, `scripts/test_*.py`, `scripts/golden/`, + the runtime if Windows exposes defects. +- Acceptance: the windows-latest job passes all suites. Every skipped test + names its POSIX-only reason. Branch pushes no longer run CI twice. +- Checks: `unit-session`, `unit-workflow`, `golden-relay`, `pack-check`, + `ci-windows` (recorded GitHub run). + +### R2: Live Codex CLI + +- Scope: `skills/agent-relay/references/adapters.md`, VALIDATION.md. +- Acceptance: `codex exec` as a supervised `delegate` worker passes + consult-cli and write-worktree with `--live-verify` in disposable fixtures. + The adapter commands match `codex exec --help` 0.162.1. +- Checks: `live-codex-cli`. + +### R3: Gemini and Antigravity + +- Scope: adapters, VALIDATION.md. +- Acceptance: + - Antigravity (gemini-3.8-flash-high) through T3 passes a consult and a + delegated write with `--live-verify`. + - The `gemini -p` adapter matches `gemini --help` 0.64.0. + - Its unauthenticated failure is recorded by `delegate` as an exited worker + and a `malformed` result, never as success. +- Checks: `live-antigravity`, `gemini-cli-failure`. + +### R4: Review round 3 + +- Scope: whatever its findings touch. +- Acceptance: a Codex review of the round 2 fixes and this whole update + (R1-R3, R6-R8) returns APPROVE, or each finding is fixed with a test and the + fix delta is re-reviewed. +- Checks: `live-review-3`, plus the suites. + +### R6: Provider policy and first-use question + +- Scope: runtime (policy parser and `delegate start` gate; `delegate + providers` shows the policy), session-start, agent-relay, session-guide, + the project-context template, tests, golden scenarios and the live driver. +- Acceptance: + - On first use in a project, session-start asks which providers, modes and + models are allowed, offering detected CLIs and host options, and writes + the marked table in PROJECT.md. + - Delegation is refused without it, and for anything it does not allow. +- Checks: `unit-session`, `golden-relay`, `pack-check`. + +### R7: Takeover guard + +- Scope: `begin` in the runtime, the contract, USAGE.md, tests. +- Acceptance: a solo begin joins in parallel, instead of superseding, when an + earlier session in this checkout was active within the idle threshold or + has open delegations. Idle sessions are still taken over. +- Checks: `unit-session`, `golden-relay`. + +### R8: Orchestrator hosts beyond T3 + +- Scope: adapters ("Orchestrator hosts"), a live orchestrator driver, and + VALIDATION.md. +- Acceptance: live runs, each verified with `--live-verify`: + - Codex (`codex exec` with project-local skills) orchestrates a Claude + worker; + - Claude Code (`claude -p`) orchestrates a Codex worker; + - Claude Code delegates to its own native subagent. + + Sandbox requirements per host are documented. +- Checks: `live-orchestrators`. + +### R5: Release 3.2.0 + +- Scope: versions, README limits, VALIDATION.md, handover, PR. +- Acceptance: 3.2.0 is consistent everywhere, and the "Not verified" list + holds only items with a concrete reason. +- Checks: `pack-check`, `unit-workflow`, `unit-session`, `golden-relay`. + +## Status at pause (2026-10-15) + +- **DONE with recorded checks:** + - R1: Windows CI, green on PR #5 at 29fb17e (closed 2026-10-16); + - R2: live `codex exec`; + - R3: Antigravity through T3, Gemini CLI failure mode; + - R6: provider policy and first-use question; + - R7: takeover guard; + - R8: orchestrators in Claude Code and Codex, plus native subagents. +- **R4 is open.** Codex review round 3 (`review-codex-3`, gpt-6.1-sol) + returned CHANGES_REQUIRED. + - Round 2 items: R2-1 and R2-5 are closed. R2-2, R2-3 and R2-4 are + partially open (items 6, 8 and 9 below). + - Each finding below needs a failing test, then a fix: + 1. **High.** The policy parser reads rows inside code blocks and comments, + and a duplicate ID overrides an earlier row (a `claude-pricefx` none row + can become write). Reject duplicates and ignore commented or fenced + rows. + 2. An empty or missing model cell counts as any model. Require an explicit + `any`. + 3. **High.** The Windows job is assigned after the child starts. Put the + supervisor itself in the job before spawning, and fail loudly when + assignment fails. + 4. **High.** A nested delegation B escapes parent A's stop: B has a separate + process group and overwrites the inherited stop-file path. Chain the + stop requests (watch the parent's file too) and stop child supervisors + transitively. + 5. The stop sequence blocks heartbeats for about 13 s, past the 10 s + expiry, so cancel reports unknown during a valid stop. Keep beating + while stopping. + 6. An explicit `type: null` is treated as absent. Require the key to be + absent for a Gemini `response`. + 7. Duplicate keys in JSON/JSONL envelopes are resolved silently. Parse + envelopes with `object_pairs`. + 8. **High.** For a host writer recorded late, its early edits are absorbed + into the baseline. Record the baseline before launching the host task, + or flag late recording without a baseline. + 9. A removed worker worktree skips freshness and log validation silently. + Report it as a discrepancy. + 10. A `runtime-prepared:` event is not attributed in + `last_activity`. Attribute runtime launches to their session. + 11. `live_orchestrator.py` passes even if the orchestrator failed, did not + end its session or left workers running, and it does not check the + requested provider, model or transport. Tighten it. +- **Then:** rerun the suites, the affected golden paths and the live paths + touched (orchestrators, Windows CI). Have Codex re-review the fix delta. + After that, R5 releases 3.2.0 and PR #5 becomes ready for review. + diff --git a/packs/session-workflow/VALIDATION.md b/packs/session-workflow/VALIDATION.md index a1d88df..3696df1 100644 --- a/packs/session-workflow/VALIDATION.md +++ b/packs/session-workflow/VALIDATION.md @@ -1,5 +1,52 @@ # Validation - estafeta 3.1.0 +## Relay hardening, 3.1.1 (2026-10-15) + +This release closes the gaps that 3.1.0 left unverified. + +**Live transports.** All were run with `scripts/golden/live_cli.py` or T3 +host tasks in disposable fixtures, then checked with `test_relay.py +--live-verify`. + +| Transport | Model | consult-cli | write-worktree | +| --- | --- | --- | --- | +| `codex exec` 0.162.1, CLI | gpt-6.1-sol | PASS | PASS: sandboxed writer in a worktree, no discrepancies, integrated recheck passed | +| Antigravity, T3 host task | gemini-3.8-flash-high | PASS | PASS: writer recorded its session and check, no discrepancies, integrated recheck passed | +| `gemini -p` 0.64.0, CLI | none: no API key | failure mode only | not run | + +Without a key, `gemini -p` exits with code 41 and prints its error as JSON on +stderr. `delegate` records the worker as exited and the result as +`malformed`, with that exit code and stderr, never as success. A real Gemini +CLI answer needs a paid API key, which this host does not have; Gemini models +are verified through Antigravity instead. + +**Orchestrators outside T3.** A real orchestrator agent received a plain user +request ("consult X with agent-relay") in a fixture with project-local skills +and an AI providers table. It ran session-start, delegate start, collect and +session-end itself. The run was checked with `--live-verify consult-cli` +(`scripts/golden/live_orchestrator.py`). + +| Orchestrator | Worker | Result | +| --- | --- | --- | +| Claude Code `claude -p`, sonnet-5.5 | Codex via `codex exec` | PASS: 13 turns, $0.19 | +| Codex `codex exec`, gpt-6.1-sol, workspace-write with network | Claude via `claude -p`, haiku-5.5 | PASS | +| Claude Code, sonnet-5.5 | its Agent tool subagent (haiku), recorded as host | PASS: $0.21 | +| Codex, gpt-6.1-sol | its `multi_agent` subagent, recorded as host | PASS: the model was not recorded, so the skill now requires `--model` | + +In the first run, Claude Code used the user's outdated global 2.4.1 +`session-end`, which refused the newer state untouched. The agent recovered +with the project's runtime, and the `version` error now explains this +shadowing. + +Observations from these runs, now in the adapters: + +- **Codex.** The user's `~/.codex/config.toml` defaulted to + `danger-full-access`, so the adapter's explicit `--sandbox` is mandatory. +- **Gemini.** In an untrusted folder it lowers the approval mode, so a headless + writer would be read-only. +- **Antigravity.** Its writer wrapped the result block in a fenced code + block; `collect` accepted it. + ## Cross-provider relay, 3.1.0 (2026-10-15) macOS, Python 3.9.6, orchestrator Claude Opus 5.5 in T3 Code. diff --git a/packs/session-workflow/shared/quickstart.md b/packs/session-workflow/shared/quickstart.md index 1eb628a..c5a9baf 100644 --- a/packs/session-workflow/shared/quickstart.md +++ b/packs/session-workflow/shared/quickstart.md @@ -65,5 +65,7 @@ To finish a phase, batch these steps: | `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` | +| `provider_policy: missing` | First use: ask the user which AI providers this project allows and record the PROJECT.md table (session-start step 5) | +| `providers_unset`, `provider_not_allowed` | Delegation outside the project's AI providers table: ask the user; never edit the table on your own | | 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/shared/runtime.md b/packs/session-workflow/shared/runtime.md index 0b95d32..032e7db 100644 --- a/packs/session-workflow/shared/runtime.md +++ b/packs/session-workflow/shared/runtime.md @@ -41,8 +41,20 @@ 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`. +and runtimes to the new session, and their open delegations once the state is +saved. A session that is still running a check blocks this with +`session_alive`. + +A session in this checkout that acted within +`SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES` (default 30), or that has open +delegations, may belong to a live thread. A solo `begin` then joins in +parallel, takes over nothing and warns. + +Activity is measured from the runtime's own records: + +- the session's begin and checkpoints; +- its events and checks; +- its delegations. `begin --parallel` puts the checkout in parallel mode, which marks every active session there as parallel. While any parallel session is active in a @@ -116,13 +128,22 @@ recorded PID. On Windows the supervisor ends the process tree through | Command | Purpose | | --- | --- | -| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate providers` | Read-only: provider CLIs on PATH, their versions and the project's AI providers table | | `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 | +- **Provider policy.** `delegate start` requires the marked AI providers table + in the context document (PROJECT.md), between + `` and ``. + - Each row gives the provider ID, its allowed modes (`consult`, `review`, + `write`, plus the informational `orchestrate`, or `none`) and its models + (`any` or IDs). + - Without the table the error is `providers_unset`, and `status`/`begin` + report `provider_policy: missing`. A provider, mode or model outside it + gives `provider_not_allowed`. - 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. diff --git a/packs/session-workflow/shared/runtime/session.py b/packs/session-workflow/shared/runtime/session.py index 849f465..fe0c94b 100644 --- a/packs/session-workflow/shared/runtime/session.py +++ b/packs/session-workflow/shared/runtime/session.py @@ -508,7 +508,12 @@ def scope_inventory(store, state, p): 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 + if os.name == "nt": + # Windows reports every file as executable and Git there ignores file modes: keep the index mode. + mode = inventory[name].split(":")[0] if name in inventory else "100644" + else: + mode = "100755" if os.access(root / name, os.X_OK) else "100644" + inventory[name] = mode + ":" + sha else: patterns, names = ignore_patterns(root), [] for value in (["*"] if whole else p["paths"]): @@ -672,7 +677,12 @@ def load(self): 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") + found = state.get("schema_version") if isinstance(state, dict) else None + newer = isinstance(found, int) and found > SCHEMA_VERSION + raise Problem("version", "Unsupported state schema; it was not modified." + ( + f" It was written by a newer estafeta (schema {found}); this copy of the skill is outdated, often " + "an older global install shadowing a newer one. Use the newest installed copy, or update all " + "installs: python3 scripts/workflow.py install --all-detected" if newer else "")) if state["schema_version"] == 1: state, self.migration = migrate_v1(state, self) validate(state, "state") @@ -781,6 +791,11 @@ 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"] + try: + if provider_policy(store, state) is None: + result["provider_policy"] = "missing" + except Problem: + result["provider_policy"] = "invalid" delegations = relay_open(store) if delegations: result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], @@ -867,6 +882,42 @@ def prior_checkpoint(state, store): return best +def parse_time(value): + try: + return datetime.fromisoformat(value).timestamp() + except (TypeError, ValueError): + return 0.0 + + +def last_activity(store, state, session_id): + """Latest moment the runtime saw this session act: begin, checkpoints, its events, checks and delegations.""" + s = state["sessions"][session_id] + times = [parse_time(s["started_at"]), parse_time((s.get("checkpoint") or {}).get("at"))] + runs = {c["id"] for c in state["checks"].values() if c["session_id"] == session_id} + for event in state["events"]: + target = event.get("event", "").split(":", 1)[-1] + if target == session_id or target in runs: + times.append(parse_time(event.get("at"))) + for record in relay_records(store): + if record["session_id"] == session_id: + times += [parse_time(record.get("started_at")), parse_time(record.get("finished_at"))] + return max(times) + + +def takeover_blockers(store, state, candidates): + """Sessions a solo begin must not take over: recently active ones, or those with open delegations.""" + idle = 60 * float(os.environ.get("SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES", "30")) + delegating = {r["session_id"] for r in relay_open(store)} + blockers = [] + for sid in candidates: + quiet = time.time() - last_activity(store, state, sid) + if sid in delegating: + blockers.append(f"{sid} has open delegations") + elif quiet < idle: + blockers.append(f"{sid} was active {int(quiet // 60)} min ago") + return blockers + + def begin(store, args): with file_lock(store.directory / ".lock"): state = store.load() if store.path.exists() else initial_state(store, args) @@ -882,6 +933,14 @@ def begin(store, args): # 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 + # Another thread or agent may still be working here (T3 runs several threads in one folder): + # a solo begin does not take over a recently active session; it joins in parallel instead. + guarded = [] if args.parallel or joined else takeover_blockers( + store, state, [sid for sid in here if live_checks(store, state, sid) == []]) + if guarded: + joined = True + for sid in here: + state["sessions"][sid]["parallel"] = True if args.parallel: for sid in here: state["sessions"][sid]["parallel"] = True @@ -909,7 +968,12 @@ def begin(store, args): result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded - if joined: + if guarded: + result["warnings"] = result.get("warnings", []) + [ + "Another session here may still be working (" + "; ".join(guarded) + "), so this session joined " + "in parallel and took over nothing. Claim paths no one else claims. If that chat has stopped, " + "confirm with the user and release it."] + elif 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 " @@ -1035,10 +1099,68 @@ def group_alive(pgid): return True -def terminate_group(child, grace=4.0): +class WindowsJob: + """A Windows Job Object: the counterpart of a POSIX process group for descendants of one child.""" + + def __init__(self, pid): + import ctypes + from ctypes import wintypes + self.ctypes, self.wintypes = ctypes, wintypes + self.kernel = k = ctypes.WinDLL("kernel32", use_last_error=True) + signatures = { + "CreateJobObjectW": ([wintypes.LPVOID, wintypes.LPCWSTR], wintypes.HANDLE), + "OpenProcess": ([wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE), + "AssignProcessToJobObject": ([wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL), + "QueryInformationJobObject": ([wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD)], wintypes.BOOL), + "TerminateJobObject": ([wintypes.HANDLE, wintypes.UINT], wintypes.BOOL), + "CloseHandle": ([wintypes.HANDLE], wintypes.BOOL), + } + for name, (args, result) in signatures.items(): + getattr(k, name).argtypes, getattr(k, name).restype = args, result + self.handle = self.kernel.CreateJobObjectW(None, None) + process = self.kernel.OpenProcess(0x0101, False, pid) # PROCESS_TERMINATE | PROCESS_SET_QUOTA + try: + if not self.handle or not process or not self.kernel.AssignProcessToJobObject(self.handle, process): + raise OSError(ctypes.get_last_error(), "Cannot assign the process to a job object") + finally: + if process: + self.kernel.CloseHandle(process) + + def active(self): + ctypes, wintypes = self.ctypes, self.wintypes + + class Accounting(ctypes.Structure): + _fields_ = [("user", ctypes.c_int64), ("kernel", ctypes.c_int64), ("period_user", ctypes.c_int64), + ("period_kernel", ctypes.c_int64), ("page_faults", wintypes.DWORD), + ("total", wintypes.DWORD), ("active", wintypes.DWORD), ("terminated", wintypes.DWORD)] + info = Accounting() + if not self.kernel.QueryInformationJobObject(self.handle, 1, ctypes.byref(info), ctypes.sizeof(info), None): + return 0 + return info.active + + def terminate(self): + self.kernel.TerminateJobObject(self.handle, 1) + + +def terminate_group(child, grace=4.0, job=None): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": - if child.poll() is None: + # Ask the group to stop first: CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned. Then force whatever remains: the whole job, or the tree under a live leader. + def alive(): + return child.poll() is None or (job is not None and job.active() > 0) + if alive(): + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + except OSError: + pass + deadline = time.monotonic() + grace + while alive() and time.monotonic() < deadline: + time.sleep(0.1) + if job is not None and job.active() > 0: + job.terminate() + elif child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return try: @@ -1065,10 +1187,30 @@ class Interrupted(Exception): pass +STOP_FILE = "SESSION_WORKFLOW_STOP_FILE" + + +def wait_for_check(child, timeout): + """Wait like Popen.wait, but also stop when the supervisor of an enclosing worker asks for it. + + Signals cannot reach a check started by a worker everywhere (a Windows process without a console gets no + CTRL_BREAK), so the supervisor also writes the file named in SESSION_WORKFLOW_STOP_FILE. + """ + stop_file, deadline = os.environ.get(STOP_FILE), time.monotonic() + timeout + while True: + try: + return child.wait(timeout=min(0.2, max(0.0, deadline - time.monotonic()))) + except subprocess.TimeoutExpired: + if stop_file and os.path.exists(stop_file): + raise Interrupted() + if time.monotonic() >= deadline: + raise + + @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)] + names = [n for n in ("SIGTERM", "SIGHUP", "SIGBREAK") if hasattr(signal, n)] if threading.current_thread() is not threading.main_thread(): yield return @@ -1092,7 +1234,7 @@ def run_check(store, state, p, args, run_id, command, before): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: with interrupt_guard(): - code = child.wait(timeout=args.timeout) + code = wait_for_check(child, args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) @@ -1162,6 +1304,14 @@ def stop_runtime(store, record): return {"status": "unknown", "reason": "Stop receipt unavailable; process was not killed by PID"} +def request_stop(stop_request, running, grace): + """Tell nested check runners to stop their checks before any signal: they record the run as abandoned.""" + atomic_write(stop_request, "stop\n") + deadline = time.monotonic() + min(3.0, grace / 2) + while running() and time.monotonic() < deadline: + time.sleep(0.1) + + def supervise(spec_path): """Private supervisor: owns one process group through a live Popen handle; accepts no PID.""" spec_path = Path(spec_path).resolve() @@ -1170,25 +1320,41 @@ def supervise(spec_path): receipt_path = directory / "receipt.json" try: # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + stop_request = directory / "stop-request" 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()) + stdin=subprocess.DEVNULL, shell=False, + env={**os.environ, STOP_FILE: str(stop_request)}, **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): + job = None + if os.name == "nt": + try: + job = WindowsJob(child.pid) + except OSError: + job = None # Fall back to the leader and taskkill /T + + def running(): + if os.name == "nt": + return child.poll() is None or (job is not None and job.active() > 0) + # Descendants keep the group alive after the leader exits. + return child.poll() is None or group_alive(child.pid) + # Supervise until the leader and all of its descendants are gone. + while running(): 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)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) break if limit and time.monotonic() - started > limit: - terminate_group(child, spec.get("grace", 4.0)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) @@ -1314,6 +1480,50 @@ def relay_runtime(store, state, record): return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} +POLICY_BEGIN, POLICY_END = "", "" +RELAY_MODES = ("consult", "review", "write") + + +def provider_policy(store, state=None): + """The project's AI providers table from the context document, or None when the user has not decided yet.""" + name = (state or {}).get("documents", {}).get("context") or "PROJECT.md" + path = relative_path(store.root, name) + if not path.is_file(): + return None + text = path.read_text(encoding="utf-8") + if POLICY_BEGIN not in text and POLICY_END not in text: + return None + if text.count(POLICY_BEGIN) != 1 or text.count(POLICY_END) != 1 or text.index(POLICY_BEGIN) > text.index(POLICY_END): + raise Problem("providers_invalid", f"Ambiguous AI providers markers in {name}; keep exactly one block", 3) + providers = {} + for line in text[text.index(POLICY_BEGIN) + len(POLICY_BEGIN):text.index(POLICY_END)].splitlines(): + cells = [c.strip().strip("`").strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 2 or not cells[0] or cells[0].lower() == "provider" or set(cells[0]) <= set("-: "): + continue + uses = {u for u in re.split(r"[,;\s]+", cells[1].lower()) if u} + models = [m for m in re.split(r"[,;\s]+", cells[2]) if m] if len(cells) > 2 else [] + providers[cells[0].lower()] = { + "modes": [m for m in RELAY_MODES if m in uses], "orchestrate": "orchestrate" in uses, + "models": [] if not models or models[0].lower() in ("any", "*", "-") else models} + return {"file": name, "providers": providers} + + +def check_policy(store, state, provider, mode, model): + policy = provider_policy(store, state) + if policy is None: + raise Problem("providers_unset", "This project has no AI providers table yet. Ask the user which providers, " + "modes and models this project allows, record them in PROJECT.md (agent-relay, step 1), then " + "retry", 3) + entry = policy["providers"].get(provider.lower()) + if entry is None or mode not in entry["modes"]: + allowed = ", ".join(f"{p}: {'/'.join(e['modes']) or 'none'}" for p, e in sorted(policy["providers"].items())) + raise Problem("provider_not_allowed", f"{policy['file']} does not allow {provider} for {mode} " + f"(allowed: {allowed or 'no providers'}). Ask the user before changing the table", 3) + if entry["models"] and model not in entry["models"]: + raise Problem("provider_not_allowed", f"{policy['file']} allows {provider} only with " + f"{', '.join(entry['models'])}; pass one of them with --model", 3) + + def relay_providers(): found = [] for name in PROVIDER_CLIS: @@ -1340,6 +1550,7 @@ def relay_start(store, state, args): 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) + check_policy(store, state, args.provider, mode, args.model or "") packet = Path(require(args.packet, "--packet")).expanduser() text = packet.read_text(encoding="utf-8") if not text.strip(): @@ -1662,7 +1873,19 @@ def relay_collect(store, state, args): used = str(source) break if body is None: - record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + error = "No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer" + if record["transport"] == "cli" and not args.from_file: + # Diagnose a CLI that failed before answering, such as missing authentication. + runtime = relay_runtime(store, state, record) + _, process = relay_process(state, record) + tail = "" + log = relay_path(store, process["directory"]) / "stderr.log" if process else None + if log and log.is_file(): + with open(log, "rb") as handle: + handle.seek(max(0, log.stat().st_size - 600)) + tail = " ".join(handle.read().decode("utf-8", "replace").split()) + error += f"; worker exit code {runtime.get('exit_code')}" + (f"; stderr ends: {tail}" if tail else "") + record.update(status="malformed", error=error) relay_save(store, record) return relay_view(store, state, record), adopted try: @@ -2150,7 +2373,12 @@ def dispatch(store, args): 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() + found = relay_providers() + try: + found["policy"] = provider_policy(store, store.load() if store.path.exists() else None) + except Problem as exc: + found["policy"] = {"error": str(exc)} + return None, found if command == "delegate" and args.action != "status": require(args.session, "--session") if command == "snapshot": diff --git a/packs/session-workflow/shared/workflow-contract.md b/packs/session-workflow/shared/workflow-contract.md index 9eb04ba..bfbfdb7 100644 --- a/packs/session-workflow/shared/workflow-contract.md +++ b/packs/session-workflow/shared/workflow-contract.md @@ -13,7 +13,9 @@ 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 +claims and runtimes. A session that acted in the last 30 minutes or has open +delegations may belong to a live chat or thread, so the new session joins in +parallel instead and warns. 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 diff --git a/packs/session-workflow/skills/agent-relay/SKILL.md b/packs/session-workflow/skills/agent-relay/SKILL.md index c2cfe72..70a890c 100644 --- a/packs/session-workflow/skills/agent-relay/SKILL.md +++ b/packs/session-workflow/skills/agent-relay/SKILL.md @@ -33,9 +33,15 @@ integrator: workers never change the plan, handover or Git history. ## 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. +1. **Authorize.** + - **The project table.** The AI providers table in PROJECT.md is the + user's standing choice of providers, modes and models for this project, + and the runtime refuses anything outside it. If it is missing, ask the + user and record it first (session-start, step 5). Change it only when + the user asks. + - **Cost.** Each run of another provider still costs money: follow the + user's request for this task and say what you start. + - **Secrets.** 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); @@ -58,7 +64,8 @@ integrator: workers never change the plan, handover or Git history. 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 + with `delegate start ... --host-ref `. Always pass `--model`; + for a native subagent that names no model, record the orchestrator's. 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 diff --git a/packs/session-workflow/skills/agent-relay/references/adapters.md b/packs/session-workflow/skills/agent-relay/references/adapters.md index e2c5d90..457a16b 100644 --- a/packs/session-workflow/skills/agent-relay/references/adapters.md +++ b/packs/session-workflow/skills/agent-relay/references/adapters.md @@ -5,6 +5,46 @@ 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. +## Orchestrator hosts + +The orchestrator can run in any host that has a shell and the estafeta skills. +The cases below were verified live with `scripts/golden/live_orchestrator.py`, +the consult golden path, and `--live-verify`. + +| Orchestrator | Worker | Transport | Verified | +| --- | --- | --- | --- | +| T3 Code (any provider) | Codex, Antigravity (Gemini) | T3 `delegate_task` | consult, review, write | +| Claude Code | Codex | `codex exec` | consult | +| Claude Code | Claude subagent | Agent tool, recorded as host | consult | +| Codex | Claude | `claude -p` | consult | +| Codex | Codex subagent | `multi_agent`, recorded as host | consult | +| any shell host | Claude, Codex | `claude -p`, `codex exec` | consult and write (`live_cli.py`) | + +What each host needs: + +- **Shell commands.** The orchestrator must run the runtime and the worker + CLI. + - **Claude Code:** allow Bash, either through your permission mode or with + `--allowedTools Bash` headless. + - **Codex:** its sandbox applies to everything it starts, including the + supervised worker. A nested CLI needs the network and write access to its + own config directory (`~/.claude` or `CLAUDE_CONFIG_DIR`, `~/.codex`). + The verified setting was `--sandbox workspace-write -c + sandbox_workspace_write.network_access=true` with `--add-dir` for those + directories. Without network, a nested CLI cannot reach its API (not run + live). A Codex orchestrator that cannot get these permissions should use + its native subagents or a host such as T3. +- **Native subagents.** They do not run under the runtime, so record them as + host tasks: `--host-ref` is the subagent's name or path, and `--model` is + the model it ran, or the orchestrator's own when the host does not say. + Save its final message to `reply_file`, then collect. +- **Skill copies.** Several copies can coexist: global per provider, project + `.claude/skills` and `.agents/skills`. An older global copy can shadow a + newer one. Seen live: Claude Code ran a global 2.4.1 `session-end`, which + refused the newer state without changing it. Keep installs current with + `python3 scripts/workflow.py install --all-detected`. The runtime's + `version` error names the cause. + ## Never by default These flags remove the worker's sandbox or approvals. Use one only when the @@ -14,7 +54,15 @@ user explicitly asks for it for this delegation. | --- | --- | | 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` | +| Gemini CLI | `--approval-mode yolo`, `-y`/`--yolo`; `--skip-trust` only as described below | + +## Provider IDs + +`--provider` names what actually runs, and it must match a row of the +project's AI providers table. Use the plain provider ID (`claude`, `codex`, +`gemini`, `antigravity`). When a host has several accounts of one provider and +the project distinguishes them, use the host's instance ID, such as T3's +`claude-pricefx`. ## Host-native tasks @@ -53,16 +101,24 @@ 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 +### Codex (`codex exec`), verified live | 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`. +- **Always pass `--sandbox`.** A user's `~/.codex/config.toml` may default to + `sandbox_mode = "danger-full-access"`, and only the flag overrides it. + `approval_policy = "never"` is fine headless: a command needing escalation + fails instead of waiting. +- **Reasoning effort:** `-c model_reasoning_effort="high"`. +- **Login:** Codex needs a Git checkout and an existing `codex login` or + `CODEX_API_KEY`. The CLI reuses the desktop app's login in `~/.codex`. +- **Instructions and skills:** it reads AGENTS.md and finds skills in + `.agents/skills` and `~/.agents/skills`. +- **Verified with codex-cli 0.162.1 and gpt-6.1-sol:** consult and a sandboxed + write in a linked worktree. `collect` read the block from `{reply_file}`. ### Claude Code (`claude -p`), verified live @@ -110,12 +166,22 @@ SANDBOX is one argv item: | 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. +- **Personal accounts.** Since June 2026, personal Google accounts use + Antigravity instead. Gemini CLI keeps working with paid Gemini API, Vertex + or enterprise keys. +- **Flags.** They match gemini 0.64.0 `--help`. +- **No login.** Without one, the CLI exits with code 41 and prints its error + as JSON on stderr. `collect` records `malformed` with that exit code and + stderr, never success (verified). +- **Untrusted folders.** A fresh worktree is an untrusted folder, and Gemini + then lowers `--approval-mode` to `default`, so a headless writer cannot + edit. Trusting the folder is the user's decision: add the repository to + Gemini's trusted folders once, or pass `--skip-trust` for a worktree the + orchestrator created from a commit it trusts. +- **Prefer Antigravity.** A host-native Antigravity task (for example in T3) + needs no API key, so prefer it for Gemini models. +- **Exit codes:** 1 means an error, 41 no authentication, 42 bad input, 53 the + turn limit. ## Resuming a worker diff --git a/packs/session-workflow/skills/agent-relay/references/quickstart.md b/packs/session-workflow/skills/agent-relay/references/quickstart.md index 1eb628a..c5a9baf 100644 --- a/packs/session-workflow/skills/agent-relay/references/quickstart.md +++ b/packs/session-workflow/skills/agent-relay/references/quickstart.md @@ -65,5 +65,7 @@ To finish a phase, batch these steps: | `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` | +| `provider_policy: missing` | First use: ask the user which AI providers this project allows and record the PROJECT.md table (session-start step 5) | +| `providers_unset`, `provider_not_allowed` | Delegation outside the project's AI providers table: ask the user; never edit the table on your own | | 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 index 0b95d32..032e7db 100644 --- a/packs/session-workflow/skills/agent-relay/references/runtime.md +++ b/packs/session-workflow/skills/agent-relay/references/runtime.md @@ -41,8 +41,20 @@ 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`. +and runtimes to the new session, and their open delegations once the state is +saved. A session that is still running a check blocks this with +`session_alive`. + +A session in this checkout that acted within +`SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES` (default 30), or that has open +delegations, may belong to a live thread. A solo `begin` then joins in +parallel, takes over nothing and warns. + +Activity is measured from the runtime's own records: + +- the session's begin and checkpoints; +- its events and checks; +- its delegations. `begin --parallel` puts the checkout in parallel mode, which marks every active session there as parallel. While any parallel session is active in a @@ -116,13 +128,22 @@ recorded PID. On Windows the supervisor ends the process tree through | Command | Purpose | | --- | --- | -| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate providers` | Read-only: provider CLIs on PATH, their versions and the project's AI providers table | | `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 | +- **Provider policy.** `delegate start` requires the marked AI providers table + in the context document (PROJECT.md), between + `` and ``. + - Each row gives the provider ID, its allowed modes (`consult`, `review`, + `write`, plus the informational `orchestrate`, or `none`) and its models + (`any` or IDs). + - Without the table the error is `providers_unset`, and `status`/`begin` + report `provider_policy: missing`. A provider, mode or model outside it + gives `provider_not_allowed`. - 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. diff --git a/packs/session-workflow/skills/agent-relay/references/workflow-contract.md b/packs/session-workflow/skills/agent-relay/references/workflow-contract.md index 9eb04ba..bfbfdb7 100644 --- a/packs/session-workflow/skills/agent-relay/references/workflow-contract.md +++ b/packs/session-workflow/skills/agent-relay/references/workflow-contract.md @@ -13,7 +13,9 @@ 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 +claims and runtimes. A session that acted in the last 30 minutes or has open +delegations may belong to a live chat or thread, so the new session joins in +parallel instead and warns. 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 diff --git a/packs/session-workflow/skills/agent-relay/scripts/session.py b/packs/session-workflow/skills/agent-relay/scripts/session.py index 849f465..fe0c94b 100644 --- a/packs/session-workflow/skills/agent-relay/scripts/session.py +++ b/packs/session-workflow/skills/agent-relay/scripts/session.py @@ -508,7 +508,12 @@ def scope_inventory(store, state, p): 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 + if os.name == "nt": + # Windows reports every file as executable and Git there ignores file modes: keep the index mode. + mode = inventory[name].split(":")[0] if name in inventory else "100644" + else: + mode = "100755" if os.access(root / name, os.X_OK) else "100644" + inventory[name] = mode + ":" + sha else: patterns, names = ignore_patterns(root), [] for value in (["*"] if whole else p["paths"]): @@ -672,7 +677,12 @@ def load(self): 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") + found = state.get("schema_version") if isinstance(state, dict) else None + newer = isinstance(found, int) and found > SCHEMA_VERSION + raise Problem("version", "Unsupported state schema; it was not modified." + ( + f" It was written by a newer estafeta (schema {found}); this copy of the skill is outdated, often " + "an older global install shadowing a newer one. Use the newest installed copy, or update all " + "installs: python3 scripts/workflow.py install --all-detected" if newer else "")) if state["schema_version"] == 1: state, self.migration = migrate_v1(state, self) validate(state, "state") @@ -781,6 +791,11 @@ 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"] + try: + if provider_policy(store, state) is None: + result["provider_policy"] = "missing" + except Problem: + result["provider_policy"] = "invalid" delegations = relay_open(store) if delegations: result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], @@ -867,6 +882,42 @@ def prior_checkpoint(state, store): return best +def parse_time(value): + try: + return datetime.fromisoformat(value).timestamp() + except (TypeError, ValueError): + return 0.0 + + +def last_activity(store, state, session_id): + """Latest moment the runtime saw this session act: begin, checkpoints, its events, checks and delegations.""" + s = state["sessions"][session_id] + times = [parse_time(s["started_at"]), parse_time((s.get("checkpoint") or {}).get("at"))] + runs = {c["id"] for c in state["checks"].values() if c["session_id"] == session_id} + for event in state["events"]: + target = event.get("event", "").split(":", 1)[-1] + if target == session_id or target in runs: + times.append(parse_time(event.get("at"))) + for record in relay_records(store): + if record["session_id"] == session_id: + times += [parse_time(record.get("started_at")), parse_time(record.get("finished_at"))] + return max(times) + + +def takeover_blockers(store, state, candidates): + """Sessions a solo begin must not take over: recently active ones, or those with open delegations.""" + idle = 60 * float(os.environ.get("SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES", "30")) + delegating = {r["session_id"] for r in relay_open(store)} + blockers = [] + for sid in candidates: + quiet = time.time() - last_activity(store, state, sid) + if sid in delegating: + blockers.append(f"{sid} has open delegations") + elif quiet < idle: + blockers.append(f"{sid} was active {int(quiet // 60)} min ago") + return blockers + + def begin(store, args): with file_lock(store.directory / ".lock"): state = store.load() if store.path.exists() else initial_state(store, args) @@ -882,6 +933,14 @@ def begin(store, args): # 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 + # Another thread or agent may still be working here (T3 runs several threads in one folder): + # a solo begin does not take over a recently active session; it joins in parallel instead. + guarded = [] if args.parallel or joined else takeover_blockers( + store, state, [sid for sid in here if live_checks(store, state, sid) == []]) + if guarded: + joined = True + for sid in here: + state["sessions"][sid]["parallel"] = True if args.parallel: for sid in here: state["sessions"][sid]["parallel"] = True @@ -909,7 +968,12 @@ def begin(store, args): result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded - if joined: + if guarded: + result["warnings"] = result.get("warnings", []) + [ + "Another session here may still be working (" + "; ".join(guarded) + "), so this session joined " + "in parallel and took over nothing. Claim paths no one else claims. If that chat has stopped, " + "confirm with the user and release it."] + elif 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 " @@ -1035,10 +1099,68 @@ def group_alive(pgid): return True -def terminate_group(child, grace=4.0): +class WindowsJob: + """A Windows Job Object: the counterpart of a POSIX process group for descendants of one child.""" + + def __init__(self, pid): + import ctypes + from ctypes import wintypes + self.ctypes, self.wintypes = ctypes, wintypes + self.kernel = k = ctypes.WinDLL("kernel32", use_last_error=True) + signatures = { + "CreateJobObjectW": ([wintypes.LPVOID, wintypes.LPCWSTR], wintypes.HANDLE), + "OpenProcess": ([wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE), + "AssignProcessToJobObject": ([wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL), + "QueryInformationJobObject": ([wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD)], wintypes.BOOL), + "TerminateJobObject": ([wintypes.HANDLE, wintypes.UINT], wintypes.BOOL), + "CloseHandle": ([wintypes.HANDLE], wintypes.BOOL), + } + for name, (args, result) in signatures.items(): + getattr(k, name).argtypes, getattr(k, name).restype = args, result + self.handle = self.kernel.CreateJobObjectW(None, None) + process = self.kernel.OpenProcess(0x0101, False, pid) # PROCESS_TERMINATE | PROCESS_SET_QUOTA + try: + if not self.handle or not process or not self.kernel.AssignProcessToJobObject(self.handle, process): + raise OSError(ctypes.get_last_error(), "Cannot assign the process to a job object") + finally: + if process: + self.kernel.CloseHandle(process) + + def active(self): + ctypes, wintypes = self.ctypes, self.wintypes + + class Accounting(ctypes.Structure): + _fields_ = [("user", ctypes.c_int64), ("kernel", ctypes.c_int64), ("period_user", ctypes.c_int64), + ("period_kernel", ctypes.c_int64), ("page_faults", wintypes.DWORD), + ("total", wintypes.DWORD), ("active", wintypes.DWORD), ("terminated", wintypes.DWORD)] + info = Accounting() + if not self.kernel.QueryInformationJobObject(self.handle, 1, ctypes.byref(info), ctypes.sizeof(info), None): + return 0 + return info.active + + def terminate(self): + self.kernel.TerminateJobObject(self.handle, 1) + + +def terminate_group(child, grace=4.0, job=None): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": - if child.poll() is None: + # Ask the group to stop first: CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned. Then force whatever remains: the whole job, or the tree under a live leader. + def alive(): + return child.poll() is None or (job is not None and job.active() > 0) + if alive(): + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + except OSError: + pass + deadline = time.monotonic() + grace + while alive() and time.monotonic() < deadline: + time.sleep(0.1) + if job is not None and job.active() > 0: + job.terminate() + elif child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return try: @@ -1065,10 +1187,30 @@ class Interrupted(Exception): pass +STOP_FILE = "SESSION_WORKFLOW_STOP_FILE" + + +def wait_for_check(child, timeout): + """Wait like Popen.wait, but also stop when the supervisor of an enclosing worker asks for it. + + Signals cannot reach a check started by a worker everywhere (a Windows process without a console gets no + CTRL_BREAK), so the supervisor also writes the file named in SESSION_WORKFLOW_STOP_FILE. + """ + stop_file, deadline = os.environ.get(STOP_FILE), time.monotonic() + timeout + while True: + try: + return child.wait(timeout=min(0.2, max(0.0, deadline - time.monotonic()))) + except subprocess.TimeoutExpired: + if stop_file and os.path.exists(stop_file): + raise Interrupted() + if time.monotonic() >= deadline: + raise + + @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)] + names = [n for n in ("SIGTERM", "SIGHUP", "SIGBREAK") if hasattr(signal, n)] if threading.current_thread() is not threading.main_thread(): yield return @@ -1092,7 +1234,7 @@ def run_check(store, state, p, args, run_id, command, before): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: with interrupt_guard(): - code = child.wait(timeout=args.timeout) + code = wait_for_check(child, args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) @@ -1162,6 +1304,14 @@ def stop_runtime(store, record): return {"status": "unknown", "reason": "Stop receipt unavailable; process was not killed by PID"} +def request_stop(stop_request, running, grace): + """Tell nested check runners to stop their checks before any signal: they record the run as abandoned.""" + atomic_write(stop_request, "stop\n") + deadline = time.monotonic() + min(3.0, grace / 2) + while running() and time.monotonic() < deadline: + time.sleep(0.1) + + def supervise(spec_path): """Private supervisor: owns one process group through a live Popen handle; accepts no PID.""" spec_path = Path(spec_path).resolve() @@ -1170,25 +1320,41 @@ def supervise(spec_path): receipt_path = directory / "receipt.json" try: # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + stop_request = directory / "stop-request" 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()) + stdin=subprocess.DEVNULL, shell=False, + env={**os.environ, STOP_FILE: str(stop_request)}, **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): + job = None + if os.name == "nt": + try: + job = WindowsJob(child.pid) + except OSError: + job = None # Fall back to the leader and taskkill /T + + def running(): + if os.name == "nt": + return child.poll() is None or (job is not None and job.active() > 0) + # Descendants keep the group alive after the leader exits. + return child.poll() is None or group_alive(child.pid) + # Supervise until the leader and all of its descendants are gone. + while running(): 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)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) break if limit and time.monotonic() - started > limit: - terminate_group(child, spec.get("grace", 4.0)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) @@ -1314,6 +1480,50 @@ def relay_runtime(store, state, record): return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} +POLICY_BEGIN, POLICY_END = "", "" +RELAY_MODES = ("consult", "review", "write") + + +def provider_policy(store, state=None): + """The project's AI providers table from the context document, or None when the user has not decided yet.""" + name = (state or {}).get("documents", {}).get("context") or "PROJECT.md" + path = relative_path(store.root, name) + if not path.is_file(): + return None + text = path.read_text(encoding="utf-8") + if POLICY_BEGIN not in text and POLICY_END not in text: + return None + if text.count(POLICY_BEGIN) != 1 or text.count(POLICY_END) != 1 or text.index(POLICY_BEGIN) > text.index(POLICY_END): + raise Problem("providers_invalid", f"Ambiguous AI providers markers in {name}; keep exactly one block", 3) + providers = {} + for line in text[text.index(POLICY_BEGIN) + len(POLICY_BEGIN):text.index(POLICY_END)].splitlines(): + cells = [c.strip().strip("`").strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 2 or not cells[0] or cells[0].lower() == "provider" or set(cells[0]) <= set("-: "): + continue + uses = {u for u in re.split(r"[,;\s]+", cells[1].lower()) if u} + models = [m for m in re.split(r"[,;\s]+", cells[2]) if m] if len(cells) > 2 else [] + providers[cells[0].lower()] = { + "modes": [m for m in RELAY_MODES if m in uses], "orchestrate": "orchestrate" in uses, + "models": [] if not models or models[0].lower() in ("any", "*", "-") else models} + return {"file": name, "providers": providers} + + +def check_policy(store, state, provider, mode, model): + policy = provider_policy(store, state) + if policy is None: + raise Problem("providers_unset", "This project has no AI providers table yet. Ask the user which providers, " + "modes and models this project allows, record them in PROJECT.md (agent-relay, step 1), then " + "retry", 3) + entry = policy["providers"].get(provider.lower()) + if entry is None or mode not in entry["modes"]: + allowed = ", ".join(f"{p}: {'/'.join(e['modes']) or 'none'}" for p, e in sorted(policy["providers"].items())) + raise Problem("provider_not_allowed", f"{policy['file']} does not allow {provider} for {mode} " + f"(allowed: {allowed or 'no providers'}). Ask the user before changing the table", 3) + if entry["models"] and model not in entry["models"]: + raise Problem("provider_not_allowed", f"{policy['file']} allows {provider} only with " + f"{', '.join(entry['models'])}; pass one of them with --model", 3) + + def relay_providers(): found = [] for name in PROVIDER_CLIS: @@ -1340,6 +1550,7 @@ def relay_start(store, state, args): 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) + check_policy(store, state, args.provider, mode, args.model or "") packet = Path(require(args.packet, "--packet")).expanduser() text = packet.read_text(encoding="utf-8") if not text.strip(): @@ -1662,7 +1873,19 @@ def relay_collect(store, state, args): used = str(source) break if body is None: - record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + error = "No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer" + if record["transport"] == "cli" and not args.from_file: + # Diagnose a CLI that failed before answering, such as missing authentication. + runtime = relay_runtime(store, state, record) + _, process = relay_process(state, record) + tail = "" + log = relay_path(store, process["directory"]) / "stderr.log" if process else None + if log and log.is_file(): + with open(log, "rb") as handle: + handle.seek(max(0, log.stat().st_size - 600)) + tail = " ".join(handle.read().decode("utf-8", "replace").split()) + error += f"; worker exit code {runtime.get('exit_code')}" + (f"; stderr ends: {tail}" if tail else "") + record.update(status="malformed", error=error) relay_save(store, record) return relay_view(store, state, record), adopted try: @@ -2150,7 +2373,12 @@ def dispatch(store, args): 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() + found = relay_providers() + try: + found["policy"] = provider_policy(store, store.load() if store.path.exists() else None) + except Problem as exc: + found["policy"] = {"error": str(exc)} + return None, found if command == "delegate" and args.action != "status": require(args.session, "--session") if command == "snapshot": diff --git a/packs/session-workflow/skills/plan-and-orchestrate/references/quickstart.md b/packs/session-workflow/skills/plan-and-orchestrate/references/quickstart.md index 1eb628a..c5a9baf 100644 --- a/packs/session-workflow/skills/plan-and-orchestrate/references/quickstart.md +++ b/packs/session-workflow/skills/plan-and-orchestrate/references/quickstart.md @@ -65,5 +65,7 @@ To finish a phase, batch these steps: | `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` | +| `provider_policy: missing` | First use: ask the user which AI providers this project allows and record the PROJECT.md table (session-start step 5) | +| `providers_unset`, `provider_not_allowed` | Delegation outside the project's AI providers table: ask the user; never edit the table on your own | | 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/plan-and-orchestrate/references/runtime.md b/packs/session-workflow/skills/plan-and-orchestrate/references/runtime.md index 0b95d32..032e7db 100644 --- a/packs/session-workflow/skills/plan-and-orchestrate/references/runtime.md +++ b/packs/session-workflow/skills/plan-and-orchestrate/references/runtime.md @@ -41,8 +41,20 @@ 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`. +and runtimes to the new session, and their open delegations once the state is +saved. A session that is still running a check blocks this with +`session_alive`. + +A session in this checkout that acted within +`SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES` (default 30), or that has open +delegations, may belong to a live thread. A solo `begin` then joins in +parallel, takes over nothing and warns. + +Activity is measured from the runtime's own records: + +- the session's begin and checkpoints; +- its events and checks; +- its delegations. `begin --parallel` puts the checkout in parallel mode, which marks every active session there as parallel. While any parallel session is active in a @@ -116,13 +128,22 @@ recorded PID. On Windows the supervisor ends the process tree through | Command | Purpose | | --- | --- | -| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate providers` | Read-only: provider CLIs on PATH, their versions and the project's AI providers table | | `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 | +- **Provider policy.** `delegate start` requires the marked AI providers table + in the context document (PROJECT.md), between + `` and ``. + - Each row gives the provider ID, its allowed modes (`consult`, `review`, + `write`, plus the informational `orchestrate`, or `none`) and its models + (`any` or IDs). + - Without the table the error is `providers_unset`, and `status`/`begin` + report `provider_policy: missing`. A provider, mode or model outside it + gives `provider_not_allowed`. - 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. 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 9eb04ba..bfbfdb7 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 @@ -13,7 +13,9 @@ 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 +claims and runtimes. A session that acted in the last 30 minutes or has open +delegations may belong to a live chat or thread, so the new session joins in +parallel instead and warns. 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 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 849f465..fe0c94b 100644 --- a/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py +++ b/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py @@ -508,7 +508,12 @@ def scope_inventory(store, state, p): 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 + if os.name == "nt": + # Windows reports every file as executable and Git there ignores file modes: keep the index mode. + mode = inventory[name].split(":")[0] if name in inventory else "100644" + else: + mode = "100755" if os.access(root / name, os.X_OK) else "100644" + inventory[name] = mode + ":" + sha else: patterns, names = ignore_patterns(root), [] for value in (["*"] if whole else p["paths"]): @@ -672,7 +677,12 @@ def load(self): 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") + found = state.get("schema_version") if isinstance(state, dict) else None + newer = isinstance(found, int) and found > SCHEMA_VERSION + raise Problem("version", "Unsupported state schema; it was not modified." + ( + f" It was written by a newer estafeta (schema {found}); this copy of the skill is outdated, often " + "an older global install shadowing a newer one. Use the newest installed copy, or update all " + "installs: python3 scripts/workflow.py install --all-detected" if newer else "")) if state["schema_version"] == 1: state, self.migration = migrate_v1(state, self) validate(state, "state") @@ -781,6 +791,11 @@ 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"] + try: + if provider_policy(store, state) is None: + result["provider_policy"] = "missing" + except Problem: + result["provider_policy"] = "invalid" delegations = relay_open(store) if delegations: result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], @@ -867,6 +882,42 @@ def prior_checkpoint(state, store): return best +def parse_time(value): + try: + return datetime.fromisoformat(value).timestamp() + except (TypeError, ValueError): + return 0.0 + + +def last_activity(store, state, session_id): + """Latest moment the runtime saw this session act: begin, checkpoints, its events, checks and delegations.""" + s = state["sessions"][session_id] + times = [parse_time(s["started_at"]), parse_time((s.get("checkpoint") or {}).get("at"))] + runs = {c["id"] for c in state["checks"].values() if c["session_id"] == session_id} + for event in state["events"]: + target = event.get("event", "").split(":", 1)[-1] + if target == session_id or target in runs: + times.append(parse_time(event.get("at"))) + for record in relay_records(store): + if record["session_id"] == session_id: + times += [parse_time(record.get("started_at")), parse_time(record.get("finished_at"))] + return max(times) + + +def takeover_blockers(store, state, candidates): + """Sessions a solo begin must not take over: recently active ones, or those with open delegations.""" + idle = 60 * float(os.environ.get("SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES", "30")) + delegating = {r["session_id"] for r in relay_open(store)} + blockers = [] + for sid in candidates: + quiet = time.time() - last_activity(store, state, sid) + if sid in delegating: + blockers.append(f"{sid} has open delegations") + elif quiet < idle: + blockers.append(f"{sid} was active {int(quiet // 60)} min ago") + return blockers + + def begin(store, args): with file_lock(store.directory / ".lock"): state = store.load() if store.path.exists() else initial_state(store, args) @@ -882,6 +933,14 @@ def begin(store, args): # 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 + # Another thread or agent may still be working here (T3 runs several threads in one folder): + # a solo begin does not take over a recently active session; it joins in parallel instead. + guarded = [] if args.parallel or joined else takeover_blockers( + store, state, [sid for sid in here if live_checks(store, state, sid) == []]) + if guarded: + joined = True + for sid in here: + state["sessions"][sid]["parallel"] = True if args.parallel: for sid in here: state["sessions"][sid]["parallel"] = True @@ -909,7 +968,12 @@ def begin(store, args): result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded - if joined: + if guarded: + result["warnings"] = result.get("warnings", []) + [ + "Another session here may still be working (" + "; ".join(guarded) + "), so this session joined " + "in parallel and took over nothing. Claim paths no one else claims. If that chat has stopped, " + "confirm with the user and release it."] + elif 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 " @@ -1035,10 +1099,68 @@ def group_alive(pgid): return True -def terminate_group(child, grace=4.0): +class WindowsJob: + """A Windows Job Object: the counterpart of a POSIX process group for descendants of one child.""" + + def __init__(self, pid): + import ctypes + from ctypes import wintypes + self.ctypes, self.wintypes = ctypes, wintypes + self.kernel = k = ctypes.WinDLL("kernel32", use_last_error=True) + signatures = { + "CreateJobObjectW": ([wintypes.LPVOID, wintypes.LPCWSTR], wintypes.HANDLE), + "OpenProcess": ([wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE), + "AssignProcessToJobObject": ([wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL), + "QueryInformationJobObject": ([wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD)], wintypes.BOOL), + "TerminateJobObject": ([wintypes.HANDLE, wintypes.UINT], wintypes.BOOL), + "CloseHandle": ([wintypes.HANDLE], wintypes.BOOL), + } + for name, (args, result) in signatures.items(): + getattr(k, name).argtypes, getattr(k, name).restype = args, result + self.handle = self.kernel.CreateJobObjectW(None, None) + process = self.kernel.OpenProcess(0x0101, False, pid) # PROCESS_TERMINATE | PROCESS_SET_QUOTA + try: + if not self.handle or not process or not self.kernel.AssignProcessToJobObject(self.handle, process): + raise OSError(ctypes.get_last_error(), "Cannot assign the process to a job object") + finally: + if process: + self.kernel.CloseHandle(process) + + def active(self): + ctypes, wintypes = self.ctypes, self.wintypes + + class Accounting(ctypes.Structure): + _fields_ = [("user", ctypes.c_int64), ("kernel", ctypes.c_int64), ("period_user", ctypes.c_int64), + ("period_kernel", ctypes.c_int64), ("page_faults", wintypes.DWORD), + ("total", wintypes.DWORD), ("active", wintypes.DWORD), ("terminated", wintypes.DWORD)] + info = Accounting() + if not self.kernel.QueryInformationJobObject(self.handle, 1, ctypes.byref(info), ctypes.sizeof(info), None): + return 0 + return info.active + + def terminate(self): + self.kernel.TerminateJobObject(self.handle, 1) + + +def terminate_group(child, grace=4.0, job=None): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": - if child.poll() is None: + # Ask the group to stop first: CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned. Then force whatever remains: the whole job, or the tree under a live leader. + def alive(): + return child.poll() is None or (job is not None and job.active() > 0) + if alive(): + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + except OSError: + pass + deadline = time.monotonic() + grace + while alive() and time.monotonic() < deadline: + time.sleep(0.1) + if job is not None and job.active() > 0: + job.terminate() + elif child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return try: @@ -1065,10 +1187,30 @@ class Interrupted(Exception): pass +STOP_FILE = "SESSION_WORKFLOW_STOP_FILE" + + +def wait_for_check(child, timeout): + """Wait like Popen.wait, but also stop when the supervisor of an enclosing worker asks for it. + + Signals cannot reach a check started by a worker everywhere (a Windows process without a console gets no + CTRL_BREAK), so the supervisor also writes the file named in SESSION_WORKFLOW_STOP_FILE. + """ + stop_file, deadline = os.environ.get(STOP_FILE), time.monotonic() + timeout + while True: + try: + return child.wait(timeout=min(0.2, max(0.0, deadline - time.monotonic()))) + except subprocess.TimeoutExpired: + if stop_file and os.path.exists(stop_file): + raise Interrupted() + if time.monotonic() >= deadline: + raise + + @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)] + names = [n for n in ("SIGTERM", "SIGHUP", "SIGBREAK") if hasattr(signal, n)] if threading.current_thread() is not threading.main_thread(): yield return @@ -1092,7 +1234,7 @@ def run_check(store, state, p, args, run_id, command, before): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: with interrupt_guard(): - code = child.wait(timeout=args.timeout) + code = wait_for_check(child, args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) @@ -1162,6 +1304,14 @@ def stop_runtime(store, record): return {"status": "unknown", "reason": "Stop receipt unavailable; process was not killed by PID"} +def request_stop(stop_request, running, grace): + """Tell nested check runners to stop their checks before any signal: they record the run as abandoned.""" + atomic_write(stop_request, "stop\n") + deadline = time.monotonic() + min(3.0, grace / 2) + while running() and time.monotonic() < deadline: + time.sleep(0.1) + + def supervise(spec_path): """Private supervisor: owns one process group through a live Popen handle; accepts no PID.""" spec_path = Path(spec_path).resolve() @@ -1170,25 +1320,41 @@ def supervise(spec_path): receipt_path = directory / "receipt.json" try: # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + stop_request = directory / "stop-request" 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()) + stdin=subprocess.DEVNULL, shell=False, + env={**os.environ, STOP_FILE: str(stop_request)}, **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): + job = None + if os.name == "nt": + try: + job = WindowsJob(child.pid) + except OSError: + job = None # Fall back to the leader and taskkill /T + + def running(): + if os.name == "nt": + return child.poll() is None or (job is not None and job.active() > 0) + # Descendants keep the group alive after the leader exits. + return child.poll() is None or group_alive(child.pid) + # Supervise until the leader and all of its descendants are gone. + while running(): 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)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) break if limit and time.monotonic() - started > limit: - terminate_group(child, spec.get("grace", 4.0)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) @@ -1314,6 +1480,50 @@ def relay_runtime(store, state, record): return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} +POLICY_BEGIN, POLICY_END = "", "" +RELAY_MODES = ("consult", "review", "write") + + +def provider_policy(store, state=None): + """The project's AI providers table from the context document, or None when the user has not decided yet.""" + name = (state or {}).get("documents", {}).get("context") or "PROJECT.md" + path = relative_path(store.root, name) + if not path.is_file(): + return None + text = path.read_text(encoding="utf-8") + if POLICY_BEGIN not in text and POLICY_END not in text: + return None + if text.count(POLICY_BEGIN) != 1 or text.count(POLICY_END) != 1 or text.index(POLICY_BEGIN) > text.index(POLICY_END): + raise Problem("providers_invalid", f"Ambiguous AI providers markers in {name}; keep exactly one block", 3) + providers = {} + for line in text[text.index(POLICY_BEGIN) + len(POLICY_BEGIN):text.index(POLICY_END)].splitlines(): + cells = [c.strip().strip("`").strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 2 or not cells[0] or cells[0].lower() == "provider" or set(cells[0]) <= set("-: "): + continue + uses = {u for u in re.split(r"[,;\s]+", cells[1].lower()) if u} + models = [m for m in re.split(r"[,;\s]+", cells[2]) if m] if len(cells) > 2 else [] + providers[cells[0].lower()] = { + "modes": [m for m in RELAY_MODES if m in uses], "orchestrate": "orchestrate" in uses, + "models": [] if not models or models[0].lower() in ("any", "*", "-") else models} + return {"file": name, "providers": providers} + + +def check_policy(store, state, provider, mode, model): + policy = provider_policy(store, state) + if policy is None: + raise Problem("providers_unset", "This project has no AI providers table yet. Ask the user which providers, " + "modes and models this project allows, record them in PROJECT.md (agent-relay, step 1), then " + "retry", 3) + entry = policy["providers"].get(provider.lower()) + if entry is None or mode not in entry["modes"]: + allowed = ", ".join(f"{p}: {'/'.join(e['modes']) or 'none'}" for p, e in sorted(policy["providers"].items())) + raise Problem("provider_not_allowed", f"{policy['file']} does not allow {provider} for {mode} " + f"(allowed: {allowed or 'no providers'}). Ask the user before changing the table", 3) + if entry["models"] and model not in entry["models"]: + raise Problem("provider_not_allowed", f"{policy['file']} allows {provider} only with " + f"{', '.join(entry['models'])}; pass one of them with --model", 3) + + def relay_providers(): found = [] for name in PROVIDER_CLIS: @@ -1340,6 +1550,7 @@ def relay_start(store, state, args): 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) + check_policy(store, state, args.provider, mode, args.model or "") packet = Path(require(args.packet, "--packet")).expanduser() text = packet.read_text(encoding="utf-8") if not text.strip(): @@ -1662,7 +1873,19 @@ def relay_collect(store, state, args): used = str(source) break if body is None: - record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + error = "No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer" + if record["transport"] == "cli" and not args.from_file: + # Diagnose a CLI that failed before answering, such as missing authentication. + runtime = relay_runtime(store, state, record) + _, process = relay_process(state, record) + tail = "" + log = relay_path(store, process["directory"]) / "stderr.log" if process else None + if log and log.is_file(): + with open(log, "rb") as handle: + handle.seek(max(0, log.stat().st_size - 600)) + tail = " ".join(handle.read().decode("utf-8", "replace").split()) + error += f"; worker exit code {runtime.get('exit_code')}" + (f"; stderr ends: {tail}" if tail else "") + record.update(status="malformed", error=error) relay_save(store, record) return relay_view(store, state, record), adopted try: @@ -2150,7 +2373,12 @@ def dispatch(store, args): 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() + found = relay_providers() + try: + found["policy"] = provider_policy(store, store.load() if store.path.exists() else None) + except Problem as exc: + found["policy"] = {"error": str(exc)} + return None, found if command == "delegate" and args.action != "status": require(args.session, "--session") if command == "snapshot": diff --git a/packs/session-workflow/skills/session-end/references/quickstart.md b/packs/session-workflow/skills/session-end/references/quickstart.md index 1eb628a..c5a9baf 100644 --- a/packs/session-workflow/skills/session-end/references/quickstart.md +++ b/packs/session-workflow/skills/session-end/references/quickstart.md @@ -65,5 +65,7 @@ To finish a phase, batch these steps: | `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` | +| `provider_policy: missing` | First use: ask the user which AI providers this project allows and record the PROJECT.md table (session-start step 5) | +| `providers_unset`, `provider_not_allowed` | Delegation outside the project's AI providers table: ask the user; never edit the table on your own | | 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/session-end/references/runtime.md b/packs/session-workflow/skills/session-end/references/runtime.md index 0b95d32..032e7db 100644 --- a/packs/session-workflow/skills/session-end/references/runtime.md +++ b/packs/session-workflow/skills/session-end/references/runtime.md @@ -41,8 +41,20 @@ 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`. +and runtimes to the new session, and their open delegations once the state is +saved. A session that is still running a check blocks this with +`session_alive`. + +A session in this checkout that acted within +`SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES` (default 30), or that has open +delegations, may belong to a live thread. A solo `begin` then joins in +parallel, takes over nothing and warns. + +Activity is measured from the runtime's own records: + +- the session's begin and checkpoints; +- its events and checks; +- its delegations. `begin --parallel` puts the checkout in parallel mode, which marks every active session there as parallel. While any parallel session is active in a @@ -116,13 +128,22 @@ recorded PID. On Windows the supervisor ends the process tree through | Command | Purpose | | --- | --- | -| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate providers` | Read-only: provider CLIs on PATH, their versions and the project's AI providers table | | `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 | +- **Provider policy.** `delegate start` requires the marked AI providers table + in the context document (PROJECT.md), between + `` and ``. + - Each row gives the provider ID, its allowed modes (`consult`, `review`, + `write`, plus the informational `orchestrate`, or `none`) and its models + (`any` or IDs). + - Without the table the error is `providers_unset`, and `status`/`begin` + report `provider_policy: missing`. A provider, mode or model outside it + gives `provider_not_allowed`. - 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. 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 9eb04ba..bfbfdb7 100644 --- a/packs/session-workflow/skills/session-end/references/workflow-contract.md +++ b/packs/session-workflow/skills/session-end/references/workflow-contract.md @@ -13,7 +13,9 @@ 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 +claims and runtimes. A session that acted in the last 30 minutes or has open +delegations may belong to a live chat or thread, so the new session joins in +parallel instead and warns. 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 diff --git a/packs/session-workflow/skills/session-end/scripts/session.py b/packs/session-workflow/skills/session-end/scripts/session.py index 849f465..fe0c94b 100644 --- a/packs/session-workflow/skills/session-end/scripts/session.py +++ b/packs/session-workflow/skills/session-end/scripts/session.py @@ -508,7 +508,12 @@ def scope_inventory(store, state, p): 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 + if os.name == "nt": + # Windows reports every file as executable and Git there ignores file modes: keep the index mode. + mode = inventory[name].split(":")[0] if name in inventory else "100644" + else: + mode = "100755" if os.access(root / name, os.X_OK) else "100644" + inventory[name] = mode + ":" + sha else: patterns, names = ignore_patterns(root), [] for value in (["*"] if whole else p["paths"]): @@ -672,7 +677,12 @@ def load(self): 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") + found = state.get("schema_version") if isinstance(state, dict) else None + newer = isinstance(found, int) and found > SCHEMA_VERSION + raise Problem("version", "Unsupported state schema; it was not modified." + ( + f" It was written by a newer estafeta (schema {found}); this copy of the skill is outdated, often " + "an older global install shadowing a newer one. Use the newest installed copy, or update all " + "installs: python3 scripts/workflow.py install --all-detected" if newer else "")) if state["schema_version"] == 1: state, self.migration = migrate_v1(state, self) validate(state, "state") @@ -781,6 +791,11 @@ 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"] + try: + if provider_policy(store, state) is None: + result["provider_policy"] = "missing" + except Problem: + result["provider_policy"] = "invalid" delegations = relay_open(store) if delegations: result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], @@ -867,6 +882,42 @@ def prior_checkpoint(state, store): return best +def parse_time(value): + try: + return datetime.fromisoformat(value).timestamp() + except (TypeError, ValueError): + return 0.0 + + +def last_activity(store, state, session_id): + """Latest moment the runtime saw this session act: begin, checkpoints, its events, checks and delegations.""" + s = state["sessions"][session_id] + times = [parse_time(s["started_at"]), parse_time((s.get("checkpoint") or {}).get("at"))] + runs = {c["id"] for c in state["checks"].values() if c["session_id"] == session_id} + for event in state["events"]: + target = event.get("event", "").split(":", 1)[-1] + if target == session_id or target in runs: + times.append(parse_time(event.get("at"))) + for record in relay_records(store): + if record["session_id"] == session_id: + times += [parse_time(record.get("started_at")), parse_time(record.get("finished_at"))] + return max(times) + + +def takeover_blockers(store, state, candidates): + """Sessions a solo begin must not take over: recently active ones, or those with open delegations.""" + idle = 60 * float(os.environ.get("SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES", "30")) + delegating = {r["session_id"] for r in relay_open(store)} + blockers = [] + for sid in candidates: + quiet = time.time() - last_activity(store, state, sid) + if sid in delegating: + blockers.append(f"{sid} has open delegations") + elif quiet < idle: + blockers.append(f"{sid} was active {int(quiet // 60)} min ago") + return blockers + + def begin(store, args): with file_lock(store.directory / ".lock"): state = store.load() if store.path.exists() else initial_state(store, args) @@ -882,6 +933,14 @@ def begin(store, args): # 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 + # Another thread or agent may still be working here (T3 runs several threads in one folder): + # a solo begin does not take over a recently active session; it joins in parallel instead. + guarded = [] if args.parallel or joined else takeover_blockers( + store, state, [sid for sid in here if live_checks(store, state, sid) == []]) + if guarded: + joined = True + for sid in here: + state["sessions"][sid]["parallel"] = True if args.parallel: for sid in here: state["sessions"][sid]["parallel"] = True @@ -909,7 +968,12 @@ def begin(store, args): result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded - if joined: + if guarded: + result["warnings"] = result.get("warnings", []) + [ + "Another session here may still be working (" + "; ".join(guarded) + "), so this session joined " + "in parallel and took over nothing. Claim paths no one else claims. If that chat has stopped, " + "confirm with the user and release it."] + elif 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 " @@ -1035,10 +1099,68 @@ def group_alive(pgid): return True -def terminate_group(child, grace=4.0): +class WindowsJob: + """A Windows Job Object: the counterpart of a POSIX process group for descendants of one child.""" + + def __init__(self, pid): + import ctypes + from ctypes import wintypes + self.ctypes, self.wintypes = ctypes, wintypes + self.kernel = k = ctypes.WinDLL("kernel32", use_last_error=True) + signatures = { + "CreateJobObjectW": ([wintypes.LPVOID, wintypes.LPCWSTR], wintypes.HANDLE), + "OpenProcess": ([wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE), + "AssignProcessToJobObject": ([wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL), + "QueryInformationJobObject": ([wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD)], wintypes.BOOL), + "TerminateJobObject": ([wintypes.HANDLE, wintypes.UINT], wintypes.BOOL), + "CloseHandle": ([wintypes.HANDLE], wintypes.BOOL), + } + for name, (args, result) in signatures.items(): + getattr(k, name).argtypes, getattr(k, name).restype = args, result + self.handle = self.kernel.CreateJobObjectW(None, None) + process = self.kernel.OpenProcess(0x0101, False, pid) # PROCESS_TERMINATE | PROCESS_SET_QUOTA + try: + if not self.handle or not process or not self.kernel.AssignProcessToJobObject(self.handle, process): + raise OSError(ctypes.get_last_error(), "Cannot assign the process to a job object") + finally: + if process: + self.kernel.CloseHandle(process) + + def active(self): + ctypes, wintypes = self.ctypes, self.wintypes + + class Accounting(ctypes.Structure): + _fields_ = [("user", ctypes.c_int64), ("kernel", ctypes.c_int64), ("period_user", ctypes.c_int64), + ("period_kernel", ctypes.c_int64), ("page_faults", wintypes.DWORD), + ("total", wintypes.DWORD), ("active", wintypes.DWORD), ("terminated", wintypes.DWORD)] + info = Accounting() + if not self.kernel.QueryInformationJobObject(self.handle, 1, ctypes.byref(info), ctypes.sizeof(info), None): + return 0 + return info.active + + def terminate(self): + self.kernel.TerminateJobObject(self.handle, 1) + + +def terminate_group(child, grace=4.0, job=None): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": - if child.poll() is None: + # Ask the group to stop first: CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned. Then force whatever remains: the whole job, or the tree under a live leader. + def alive(): + return child.poll() is None or (job is not None and job.active() > 0) + if alive(): + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + except OSError: + pass + deadline = time.monotonic() + grace + while alive() and time.monotonic() < deadline: + time.sleep(0.1) + if job is not None and job.active() > 0: + job.terminate() + elif child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return try: @@ -1065,10 +1187,30 @@ class Interrupted(Exception): pass +STOP_FILE = "SESSION_WORKFLOW_STOP_FILE" + + +def wait_for_check(child, timeout): + """Wait like Popen.wait, but also stop when the supervisor of an enclosing worker asks for it. + + Signals cannot reach a check started by a worker everywhere (a Windows process without a console gets no + CTRL_BREAK), so the supervisor also writes the file named in SESSION_WORKFLOW_STOP_FILE. + """ + stop_file, deadline = os.environ.get(STOP_FILE), time.monotonic() + timeout + while True: + try: + return child.wait(timeout=min(0.2, max(0.0, deadline - time.monotonic()))) + except subprocess.TimeoutExpired: + if stop_file and os.path.exists(stop_file): + raise Interrupted() + if time.monotonic() >= deadline: + raise + + @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)] + names = [n for n in ("SIGTERM", "SIGHUP", "SIGBREAK") if hasattr(signal, n)] if threading.current_thread() is not threading.main_thread(): yield return @@ -1092,7 +1234,7 @@ def run_check(store, state, p, args, run_id, command, before): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: with interrupt_guard(): - code = child.wait(timeout=args.timeout) + code = wait_for_check(child, args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) @@ -1162,6 +1304,14 @@ def stop_runtime(store, record): return {"status": "unknown", "reason": "Stop receipt unavailable; process was not killed by PID"} +def request_stop(stop_request, running, grace): + """Tell nested check runners to stop their checks before any signal: they record the run as abandoned.""" + atomic_write(stop_request, "stop\n") + deadline = time.monotonic() + min(3.0, grace / 2) + while running() and time.monotonic() < deadline: + time.sleep(0.1) + + def supervise(spec_path): """Private supervisor: owns one process group through a live Popen handle; accepts no PID.""" spec_path = Path(spec_path).resolve() @@ -1170,25 +1320,41 @@ def supervise(spec_path): receipt_path = directory / "receipt.json" try: # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + stop_request = directory / "stop-request" 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()) + stdin=subprocess.DEVNULL, shell=False, + env={**os.environ, STOP_FILE: str(stop_request)}, **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): + job = None + if os.name == "nt": + try: + job = WindowsJob(child.pid) + except OSError: + job = None # Fall back to the leader and taskkill /T + + def running(): + if os.name == "nt": + return child.poll() is None or (job is not None and job.active() > 0) + # Descendants keep the group alive after the leader exits. + return child.poll() is None or group_alive(child.pid) + # Supervise until the leader and all of its descendants are gone. + while running(): 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)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) break if limit and time.monotonic() - started > limit: - terminate_group(child, spec.get("grace", 4.0)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) @@ -1314,6 +1480,50 @@ def relay_runtime(store, state, record): return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} +POLICY_BEGIN, POLICY_END = "", "" +RELAY_MODES = ("consult", "review", "write") + + +def provider_policy(store, state=None): + """The project's AI providers table from the context document, or None when the user has not decided yet.""" + name = (state or {}).get("documents", {}).get("context") or "PROJECT.md" + path = relative_path(store.root, name) + if not path.is_file(): + return None + text = path.read_text(encoding="utf-8") + if POLICY_BEGIN not in text and POLICY_END not in text: + return None + if text.count(POLICY_BEGIN) != 1 or text.count(POLICY_END) != 1 or text.index(POLICY_BEGIN) > text.index(POLICY_END): + raise Problem("providers_invalid", f"Ambiguous AI providers markers in {name}; keep exactly one block", 3) + providers = {} + for line in text[text.index(POLICY_BEGIN) + len(POLICY_BEGIN):text.index(POLICY_END)].splitlines(): + cells = [c.strip().strip("`").strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 2 or not cells[0] or cells[0].lower() == "provider" or set(cells[0]) <= set("-: "): + continue + uses = {u for u in re.split(r"[,;\s]+", cells[1].lower()) if u} + models = [m for m in re.split(r"[,;\s]+", cells[2]) if m] if len(cells) > 2 else [] + providers[cells[0].lower()] = { + "modes": [m for m in RELAY_MODES if m in uses], "orchestrate": "orchestrate" in uses, + "models": [] if not models or models[0].lower() in ("any", "*", "-") else models} + return {"file": name, "providers": providers} + + +def check_policy(store, state, provider, mode, model): + policy = provider_policy(store, state) + if policy is None: + raise Problem("providers_unset", "This project has no AI providers table yet. Ask the user which providers, " + "modes and models this project allows, record them in PROJECT.md (agent-relay, step 1), then " + "retry", 3) + entry = policy["providers"].get(provider.lower()) + if entry is None or mode not in entry["modes"]: + allowed = ", ".join(f"{p}: {'/'.join(e['modes']) or 'none'}" for p, e in sorted(policy["providers"].items())) + raise Problem("provider_not_allowed", f"{policy['file']} does not allow {provider} for {mode} " + f"(allowed: {allowed or 'no providers'}). Ask the user before changing the table", 3) + if entry["models"] and model not in entry["models"]: + raise Problem("provider_not_allowed", f"{policy['file']} allows {provider} only with " + f"{', '.join(entry['models'])}; pass one of them with --model", 3) + + def relay_providers(): found = [] for name in PROVIDER_CLIS: @@ -1340,6 +1550,7 @@ def relay_start(store, state, args): 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) + check_policy(store, state, args.provider, mode, args.model or "") packet = Path(require(args.packet, "--packet")).expanduser() text = packet.read_text(encoding="utf-8") if not text.strip(): @@ -1662,7 +1873,19 @@ def relay_collect(store, state, args): used = str(source) break if body is None: - record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + error = "No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer" + if record["transport"] == "cli" and not args.from_file: + # Diagnose a CLI that failed before answering, such as missing authentication. + runtime = relay_runtime(store, state, record) + _, process = relay_process(state, record) + tail = "" + log = relay_path(store, process["directory"]) / "stderr.log" if process else None + if log and log.is_file(): + with open(log, "rb") as handle: + handle.seek(max(0, log.stat().st_size - 600)) + tail = " ".join(handle.read().decode("utf-8", "replace").split()) + error += f"; worker exit code {runtime.get('exit_code')}" + (f"; stderr ends: {tail}" if tail else "") + record.update(status="malformed", error=error) relay_save(store, record) return relay_view(store, state, record), adopted try: @@ -2150,7 +2373,12 @@ def dispatch(store, args): 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() + found = relay_providers() + try: + found["policy"] = provider_policy(store, store.load() if store.path.exists() else None) + except Problem as exc: + found["policy"] = {"error": str(exc)} + return None, found if command == "delegate" and args.action != "status": require(args.session, "--session") if command == "snapshot": diff --git a/packs/session-workflow/skills/session-guide/SKILL.md b/packs/session-workflow/skills/session-guide/SKILL.md index 32eb991..0ff8eaf 100644 --- a/packs/session-workflow/skills/session-guide/SKILL.md +++ b/packs/session-workflow/skills/session-guide/SKILL.md @@ -52,6 +52,9 @@ another device continues from those files instead of from chat history. **Files.** - PROJECT.md holds the goal and next step, docs/plans/ the plan, and HANDOVER.md what is done and verified. Read and edit them freely. +- PROJECT.md also lists the AI providers this project may use. On first use + the agent asks you once and records your answer; delegation to anything + else is refused. - `.agent-state/` holds machine state. It is never committed or edited by hand. **What the agent does by itself.** diff --git a/packs/session-workflow/skills/session-guide/references/quickstart.md b/packs/session-workflow/skills/session-guide/references/quickstart.md index 1eb628a..c5a9baf 100644 --- a/packs/session-workflow/skills/session-guide/references/quickstart.md +++ b/packs/session-workflow/skills/session-guide/references/quickstart.md @@ -65,5 +65,7 @@ To finish a phase, batch these steps: | `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` | +| `provider_policy: missing` | First use: ask the user which AI providers this project allows and record the PROJECT.md table (session-start step 5) | +| `providers_unset`, `provider_not_allowed` | Delegation outside the project's AI providers table: ask the user; never edit the table on your own | | 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/session-guide/references/runtime.md b/packs/session-workflow/skills/session-guide/references/runtime.md index 0b95d32..032e7db 100644 --- a/packs/session-workflow/skills/session-guide/references/runtime.md +++ b/packs/session-workflow/skills/session-guide/references/runtime.md @@ -41,8 +41,20 @@ 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`. +and runtimes to the new session, and their open delegations once the state is +saved. A session that is still running a check blocks this with +`session_alive`. + +A session in this checkout that acted within +`SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES` (default 30), or that has open +delegations, may belong to a live thread. A solo `begin` then joins in +parallel, takes over nothing and warns. + +Activity is measured from the runtime's own records: + +- the session's begin and checkpoints; +- its events and checks; +- its delegations. `begin --parallel` puts the checkout in parallel mode, which marks every active session there as parallel. While any parallel session is active in a @@ -116,13 +128,22 @@ recorded PID. On Windows the supervisor ends the process tree through | Command | Purpose | | --- | --- | -| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate providers` | Read-only: provider CLIs on PATH, their versions and the project's AI providers table | | `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 | +- **Provider policy.** `delegate start` requires the marked AI providers table + in the context document (PROJECT.md), between + `` and ``. + - Each row gives the provider ID, its allowed modes (`consult`, `review`, + `write`, plus the informational `orchestrate`, or `none`) and its models + (`any` or IDs). + - Without the table the error is `providers_unset`, and `status`/`begin` + report `provider_policy: missing`. A provider, mode or model outside it + gives `provider_not_allowed`. - 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. 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 9eb04ba..bfbfdb7 100644 --- a/packs/session-workflow/skills/session-guide/references/workflow-contract.md +++ b/packs/session-workflow/skills/session-guide/references/workflow-contract.md @@ -13,7 +13,9 @@ 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 +claims and runtimes. A session that acted in the last 30 minutes or has open +delegations may belong to a live chat or thread, so the new session joins in +parallel instead and warns. 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 diff --git a/packs/session-workflow/skills/session-guide/scripts/session.py b/packs/session-workflow/skills/session-guide/scripts/session.py index 849f465..fe0c94b 100644 --- a/packs/session-workflow/skills/session-guide/scripts/session.py +++ b/packs/session-workflow/skills/session-guide/scripts/session.py @@ -508,7 +508,12 @@ def scope_inventory(store, state, p): 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 + if os.name == "nt": + # Windows reports every file as executable and Git there ignores file modes: keep the index mode. + mode = inventory[name].split(":")[0] if name in inventory else "100644" + else: + mode = "100755" if os.access(root / name, os.X_OK) else "100644" + inventory[name] = mode + ":" + sha else: patterns, names = ignore_patterns(root), [] for value in (["*"] if whole else p["paths"]): @@ -672,7 +677,12 @@ def load(self): 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") + found = state.get("schema_version") if isinstance(state, dict) else None + newer = isinstance(found, int) and found > SCHEMA_VERSION + raise Problem("version", "Unsupported state schema; it was not modified." + ( + f" It was written by a newer estafeta (schema {found}); this copy of the skill is outdated, often " + "an older global install shadowing a newer one. Use the newest installed copy, or update all " + "installs: python3 scripts/workflow.py install --all-detected" if newer else "")) if state["schema_version"] == 1: state, self.migration = migrate_v1(state, self) validate(state, "state") @@ -781,6 +791,11 @@ 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"] + try: + if provider_policy(store, state) is None: + result["provider_policy"] = "missing" + except Problem: + result["provider_policy"] = "invalid" delegations = relay_open(store) if delegations: result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], @@ -867,6 +882,42 @@ def prior_checkpoint(state, store): return best +def parse_time(value): + try: + return datetime.fromisoformat(value).timestamp() + except (TypeError, ValueError): + return 0.0 + + +def last_activity(store, state, session_id): + """Latest moment the runtime saw this session act: begin, checkpoints, its events, checks and delegations.""" + s = state["sessions"][session_id] + times = [parse_time(s["started_at"]), parse_time((s.get("checkpoint") or {}).get("at"))] + runs = {c["id"] for c in state["checks"].values() if c["session_id"] == session_id} + for event in state["events"]: + target = event.get("event", "").split(":", 1)[-1] + if target == session_id or target in runs: + times.append(parse_time(event.get("at"))) + for record in relay_records(store): + if record["session_id"] == session_id: + times += [parse_time(record.get("started_at")), parse_time(record.get("finished_at"))] + return max(times) + + +def takeover_blockers(store, state, candidates): + """Sessions a solo begin must not take over: recently active ones, or those with open delegations.""" + idle = 60 * float(os.environ.get("SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES", "30")) + delegating = {r["session_id"] for r in relay_open(store)} + blockers = [] + for sid in candidates: + quiet = time.time() - last_activity(store, state, sid) + if sid in delegating: + blockers.append(f"{sid} has open delegations") + elif quiet < idle: + blockers.append(f"{sid} was active {int(quiet // 60)} min ago") + return blockers + + def begin(store, args): with file_lock(store.directory / ".lock"): state = store.load() if store.path.exists() else initial_state(store, args) @@ -882,6 +933,14 @@ def begin(store, args): # 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 + # Another thread or agent may still be working here (T3 runs several threads in one folder): + # a solo begin does not take over a recently active session; it joins in parallel instead. + guarded = [] if args.parallel or joined else takeover_blockers( + store, state, [sid for sid in here if live_checks(store, state, sid) == []]) + if guarded: + joined = True + for sid in here: + state["sessions"][sid]["parallel"] = True if args.parallel: for sid in here: state["sessions"][sid]["parallel"] = True @@ -909,7 +968,12 @@ def begin(store, args): result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded - if joined: + if guarded: + result["warnings"] = result.get("warnings", []) + [ + "Another session here may still be working (" + "; ".join(guarded) + "), so this session joined " + "in parallel and took over nothing. Claim paths no one else claims. If that chat has stopped, " + "confirm with the user and release it."] + elif 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 " @@ -1035,10 +1099,68 @@ def group_alive(pgid): return True -def terminate_group(child, grace=4.0): +class WindowsJob: + """A Windows Job Object: the counterpart of a POSIX process group for descendants of one child.""" + + def __init__(self, pid): + import ctypes + from ctypes import wintypes + self.ctypes, self.wintypes = ctypes, wintypes + self.kernel = k = ctypes.WinDLL("kernel32", use_last_error=True) + signatures = { + "CreateJobObjectW": ([wintypes.LPVOID, wintypes.LPCWSTR], wintypes.HANDLE), + "OpenProcess": ([wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE), + "AssignProcessToJobObject": ([wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL), + "QueryInformationJobObject": ([wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD)], wintypes.BOOL), + "TerminateJobObject": ([wintypes.HANDLE, wintypes.UINT], wintypes.BOOL), + "CloseHandle": ([wintypes.HANDLE], wintypes.BOOL), + } + for name, (args, result) in signatures.items(): + getattr(k, name).argtypes, getattr(k, name).restype = args, result + self.handle = self.kernel.CreateJobObjectW(None, None) + process = self.kernel.OpenProcess(0x0101, False, pid) # PROCESS_TERMINATE | PROCESS_SET_QUOTA + try: + if not self.handle or not process or not self.kernel.AssignProcessToJobObject(self.handle, process): + raise OSError(ctypes.get_last_error(), "Cannot assign the process to a job object") + finally: + if process: + self.kernel.CloseHandle(process) + + def active(self): + ctypes, wintypes = self.ctypes, self.wintypes + + class Accounting(ctypes.Structure): + _fields_ = [("user", ctypes.c_int64), ("kernel", ctypes.c_int64), ("period_user", ctypes.c_int64), + ("period_kernel", ctypes.c_int64), ("page_faults", wintypes.DWORD), + ("total", wintypes.DWORD), ("active", wintypes.DWORD), ("terminated", wintypes.DWORD)] + info = Accounting() + if not self.kernel.QueryInformationJobObject(self.handle, 1, ctypes.byref(info), ctypes.sizeof(info), None): + return 0 + return info.active + + def terminate(self): + self.kernel.TerminateJobObject(self.handle, 1) + + +def terminate_group(child, grace=4.0, job=None): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": - if child.poll() is None: + # Ask the group to stop first: CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned. Then force whatever remains: the whole job, or the tree under a live leader. + def alive(): + return child.poll() is None or (job is not None and job.active() > 0) + if alive(): + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + except OSError: + pass + deadline = time.monotonic() + grace + while alive() and time.monotonic() < deadline: + time.sleep(0.1) + if job is not None and job.active() > 0: + job.terminate() + elif child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return try: @@ -1065,10 +1187,30 @@ class Interrupted(Exception): pass +STOP_FILE = "SESSION_WORKFLOW_STOP_FILE" + + +def wait_for_check(child, timeout): + """Wait like Popen.wait, but also stop when the supervisor of an enclosing worker asks for it. + + Signals cannot reach a check started by a worker everywhere (a Windows process without a console gets no + CTRL_BREAK), so the supervisor also writes the file named in SESSION_WORKFLOW_STOP_FILE. + """ + stop_file, deadline = os.environ.get(STOP_FILE), time.monotonic() + timeout + while True: + try: + return child.wait(timeout=min(0.2, max(0.0, deadline - time.monotonic()))) + except subprocess.TimeoutExpired: + if stop_file and os.path.exists(stop_file): + raise Interrupted() + if time.monotonic() >= deadline: + raise + + @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)] + names = [n for n in ("SIGTERM", "SIGHUP", "SIGBREAK") if hasattr(signal, n)] if threading.current_thread() is not threading.main_thread(): yield return @@ -1092,7 +1234,7 @@ def run_check(store, state, p, args, run_id, command, before): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: with interrupt_guard(): - code = child.wait(timeout=args.timeout) + code = wait_for_check(child, args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) @@ -1162,6 +1304,14 @@ def stop_runtime(store, record): return {"status": "unknown", "reason": "Stop receipt unavailable; process was not killed by PID"} +def request_stop(stop_request, running, grace): + """Tell nested check runners to stop their checks before any signal: they record the run as abandoned.""" + atomic_write(stop_request, "stop\n") + deadline = time.monotonic() + min(3.0, grace / 2) + while running() and time.monotonic() < deadline: + time.sleep(0.1) + + def supervise(spec_path): """Private supervisor: owns one process group through a live Popen handle; accepts no PID.""" spec_path = Path(spec_path).resolve() @@ -1170,25 +1320,41 @@ def supervise(spec_path): receipt_path = directory / "receipt.json" try: # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + stop_request = directory / "stop-request" 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()) + stdin=subprocess.DEVNULL, shell=False, + env={**os.environ, STOP_FILE: str(stop_request)}, **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): + job = None + if os.name == "nt": + try: + job = WindowsJob(child.pid) + except OSError: + job = None # Fall back to the leader and taskkill /T + + def running(): + if os.name == "nt": + return child.poll() is None or (job is not None and job.active() > 0) + # Descendants keep the group alive after the leader exits. + return child.poll() is None or group_alive(child.pid) + # Supervise until the leader and all of its descendants are gone. + while running(): 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)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) break if limit and time.monotonic() - started > limit: - terminate_group(child, spec.get("grace", 4.0)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) @@ -1314,6 +1480,50 @@ def relay_runtime(store, state, record): return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} +POLICY_BEGIN, POLICY_END = "", "" +RELAY_MODES = ("consult", "review", "write") + + +def provider_policy(store, state=None): + """The project's AI providers table from the context document, or None when the user has not decided yet.""" + name = (state or {}).get("documents", {}).get("context") or "PROJECT.md" + path = relative_path(store.root, name) + if not path.is_file(): + return None + text = path.read_text(encoding="utf-8") + if POLICY_BEGIN not in text and POLICY_END not in text: + return None + if text.count(POLICY_BEGIN) != 1 or text.count(POLICY_END) != 1 or text.index(POLICY_BEGIN) > text.index(POLICY_END): + raise Problem("providers_invalid", f"Ambiguous AI providers markers in {name}; keep exactly one block", 3) + providers = {} + for line in text[text.index(POLICY_BEGIN) + len(POLICY_BEGIN):text.index(POLICY_END)].splitlines(): + cells = [c.strip().strip("`").strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 2 or not cells[0] or cells[0].lower() == "provider" or set(cells[0]) <= set("-: "): + continue + uses = {u for u in re.split(r"[,;\s]+", cells[1].lower()) if u} + models = [m for m in re.split(r"[,;\s]+", cells[2]) if m] if len(cells) > 2 else [] + providers[cells[0].lower()] = { + "modes": [m for m in RELAY_MODES if m in uses], "orchestrate": "orchestrate" in uses, + "models": [] if not models or models[0].lower() in ("any", "*", "-") else models} + return {"file": name, "providers": providers} + + +def check_policy(store, state, provider, mode, model): + policy = provider_policy(store, state) + if policy is None: + raise Problem("providers_unset", "This project has no AI providers table yet. Ask the user which providers, " + "modes and models this project allows, record them in PROJECT.md (agent-relay, step 1), then " + "retry", 3) + entry = policy["providers"].get(provider.lower()) + if entry is None or mode not in entry["modes"]: + allowed = ", ".join(f"{p}: {'/'.join(e['modes']) or 'none'}" for p, e in sorted(policy["providers"].items())) + raise Problem("provider_not_allowed", f"{policy['file']} does not allow {provider} for {mode} " + f"(allowed: {allowed or 'no providers'}). Ask the user before changing the table", 3) + if entry["models"] and model not in entry["models"]: + raise Problem("provider_not_allowed", f"{policy['file']} allows {provider} only with " + f"{', '.join(entry['models'])}; pass one of them with --model", 3) + + def relay_providers(): found = [] for name in PROVIDER_CLIS: @@ -1340,6 +1550,7 @@ def relay_start(store, state, args): 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) + check_policy(store, state, args.provider, mode, args.model or "") packet = Path(require(args.packet, "--packet")).expanduser() text = packet.read_text(encoding="utf-8") if not text.strip(): @@ -1662,7 +1873,19 @@ def relay_collect(store, state, args): used = str(source) break if body is None: - record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + error = "No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer" + if record["transport"] == "cli" and not args.from_file: + # Diagnose a CLI that failed before answering, such as missing authentication. + runtime = relay_runtime(store, state, record) + _, process = relay_process(state, record) + tail = "" + log = relay_path(store, process["directory"]) / "stderr.log" if process else None + if log and log.is_file(): + with open(log, "rb") as handle: + handle.seek(max(0, log.stat().st_size - 600)) + tail = " ".join(handle.read().decode("utf-8", "replace").split()) + error += f"; worker exit code {runtime.get('exit_code')}" + (f"; stderr ends: {tail}" if tail else "") + record.update(status="malformed", error=error) relay_save(store, record) return relay_view(store, state, record), adopted try: @@ -2150,7 +2373,12 @@ def dispatch(store, args): 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() + found = relay_providers() + try: + found["policy"] = provider_policy(store, store.load() if store.path.exists() else None) + except Problem as exc: + found["policy"] = {"error": str(exc)} + return None, found if command == "delegate" and args.action != "status": require(args.session, "--session") if command == "snapshot": diff --git a/packs/session-workflow/skills/session-handover/references/quickstart.md b/packs/session-workflow/skills/session-handover/references/quickstart.md index 1eb628a..c5a9baf 100644 --- a/packs/session-workflow/skills/session-handover/references/quickstart.md +++ b/packs/session-workflow/skills/session-handover/references/quickstart.md @@ -65,5 +65,7 @@ To finish a phase, batch these steps: | `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` | +| `provider_policy: missing` | First use: ask the user which AI providers this project allows and record the PROJECT.md table (session-start step 5) | +| `providers_unset`, `provider_not_allowed` | Delegation outside the project's AI providers table: ask the user; never edit the table on your own | | 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/session-handover/references/runtime.md b/packs/session-workflow/skills/session-handover/references/runtime.md index 0b95d32..032e7db 100644 --- a/packs/session-workflow/skills/session-handover/references/runtime.md +++ b/packs/session-workflow/skills/session-handover/references/runtime.md @@ -41,8 +41,20 @@ 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`. +and runtimes to the new session, and their open delegations once the state is +saved. A session that is still running a check blocks this with +`session_alive`. + +A session in this checkout that acted within +`SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES` (default 30), or that has open +delegations, may belong to a live thread. A solo `begin` then joins in +parallel, takes over nothing and warns. + +Activity is measured from the runtime's own records: + +- the session's begin and checkpoints; +- its events and checks; +- its delegations. `begin --parallel` puts the checkout in parallel mode, which marks every active session there as parallel. While any parallel session is active in a @@ -116,13 +128,22 @@ recorded PID. On Windows the supervisor ends the process tree through | Command | Purpose | | --- | --- | -| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate providers` | Read-only: provider CLIs on PATH, their versions and the project's AI providers table | | `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 | +- **Provider policy.** `delegate start` requires the marked AI providers table + in the context document (PROJECT.md), between + `` and ``. + - Each row gives the provider ID, its allowed modes (`consult`, `review`, + `write`, plus the informational `orchestrate`, or `none`) and its models + (`any` or IDs). + - Without the table the error is `providers_unset`, and `status`/`begin` + report `provider_policy: missing`. A provider, mode or model outside it + gives `provider_not_allowed`. - 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. 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 9eb04ba..bfbfdb7 100644 --- a/packs/session-workflow/skills/session-handover/references/workflow-contract.md +++ b/packs/session-workflow/skills/session-handover/references/workflow-contract.md @@ -13,7 +13,9 @@ 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 +claims and runtimes. A session that acted in the last 30 minutes or has open +delegations may belong to a live chat or thread, so the new session joins in +parallel instead and warns. 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 diff --git a/packs/session-workflow/skills/session-handover/scripts/session.py b/packs/session-workflow/skills/session-handover/scripts/session.py index 849f465..fe0c94b 100644 --- a/packs/session-workflow/skills/session-handover/scripts/session.py +++ b/packs/session-workflow/skills/session-handover/scripts/session.py @@ -508,7 +508,12 @@ def scope_inventory(store, state, p): 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 + if os.name == "nt": + # Windows reports every file as executable and Git there ignores file modes: keep the index mode. + mode = inventory[name].split(":")[0] if name in inventory else "100644" + else: + mode = "100755" if os.access(root / name, os.X_OK) else "100644" + inventory[name] = mode + ":" + sha else: patterns, names = ignore_patterns(root), [] for value in (["*"] if whole else p["paths"]): @@ -672,7 +677,12 @@ def load(self): 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") + found = state.get("schema_version") if isinstance(state, dict) else None + newer = isinstance(found, int) and found > SCHEMA_VERSION + raise Problem("version", "Unsupported state schema; it was not modified." + ( + f" It was written by a newer estafeta (schema {found}); this copy of the skill is outdated, often " + "an older global install shadowing a newer one. Use the newest installed copy, or update all " + "installs: python3 scripts/workflow.py install --all-detected" if newer else "")) if state["schema_version"] == 1: state, self.migration = migrate_v1(state, self) validate(state, "state") @@ -781,6 +791,11 @@ 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"] + try: + if provider_policy(store, state) is None: + result["provider_policy"] = "missing" + except Problem: + result["provider_policy"] = "invalid" delegations = relay_open(store) if delegations: result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], @@ -867,6 +882,42 @@ def prior_checkpoint(state, store): return best +def parse_time(value): + try: + return datetime.fromisoformat(value).timestamp() + except (TypeError, ValueError): + return 0.0 + + +def last_activity(store, state, session_id): + """Latest moment the runtime saw this session act: begin, checkpoints, its events, checks and delegations.""" + s = state["sessions"][session_id] + times = [parse_time(s["started_at"]), parse_time((s.get("checkpoint") or {}).get("at"))] + runs = {c["id"] for c in state["checks"].values() if c["session_id"] == session_id} + for event in state["events"]: + target = event.get("event", "").split(":", 1)[-1] + if target == session_id or target in runs: + times.append(parse_time(event.get("at"))) + for record in relay_records(store): + if record["session_id"] == session_id: + times += [parse_time(record.get("started_at")), parse_time(record.get("finished_at"))] + return max(times) + + +def takeover_blockers(store, state, candidates): + """Sessions a solo begin must not take over: recently active ones, or those with open delegations.""" + idle = 60 * float(os.environ.get("SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES", "30")) + delegating = {r["session_id"] for r in relay_open(store)} + blockers = [] + for sid in candidates: + quiet = time.time() - last_activity(store, state, sid) + if sid in delegating: + blockers.append(f"{sid} has open delegations") + elif quiet < idle: + blockers.append(f"{sid} was active {int(quiet // 60)} min ago") + return blockers + + def begin(store, args): with file_lock(store.directory / ".lock"): state = store.load() if store.path.exists() else initial_state(store, args) @@ -882,6 +933,14 @@ def begin(store, args): # 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 + # Another thread or agent may still be working here (T3 runs several threads in one folder): + # a solo begin does not take over a recently active session; it joins in parallel instead. + guarded = [] if args.parallel or joined else takeover_blockers( + store, state, [sid for sid in here if live_checks(store, state, sid) == []]) + if guarded: + joined = True + for sid in here: + state["sessions"][sid]["parallel"] = True if args.parallel: for sid in here: state["sessions"][sid]["parallel"] = True @@ -909,7 +968,12 @@ def begin(store, args): result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded - if joined: + if guarded: + result["warnings"] = result.get("warnings", []) + [ + "Another session here may still be working (" + "; ".join(guarded) + "), so this session joined " + "in parallel and took over nothing. Claim paths no one else claims. If that chat has stopped, " + "confirm with the user and release it."] + elif 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 " @@ -1035,10 +1099,68 @@ def group_alive(pgid): return True -def terminate_group(child, grace=4.0): +class WindowsJob: + """A Windows Job Object: the counterpart of a POSIX process group for descendants of one child.""" + + def __init__(self, pid): + import ctypes + from ctypes import wintypes + self.ctypes, self.wintypes = ctypes, wintypes + self.kernel = k = ctypes.WinDLL("kernel32", use_last_error=True) + signatures = { + "CreateJobObjectW": ([wintypes.LPVOID, wintypes.LPCWSTR], wintypes.HANDLE), + "OpenProcess": ([wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE), + "AssignProcessToJobObject": ([wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL), + "QueryInformationJobObject": ([wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD)], wintypes.BOOL), + "TerminateJobObject": ([wintypes.HANDLE, wintypes.UINT], wintypes.BOOL), + "CloseHandle": ([wintypes.HANDLE], wintypes.BOOL), + } + for name, (args, result) in signatures.items(): + getattr(k, name).argtypes, getattr(k, name).restype = args, result + self.handle = self.kernel.CreateJobObjectW(None, None) + process = self.kernel.OpenProcess(0x0101, False, pid) # PROCESS_TERMINATE | PROCESS_SET_QUOTA + try: + if not self.handle or not process or not self.kernel.AssignProcessToJobObject(self.handle, process): + raise OSError(ctypes.get_last_error(), "Cannot assign the process to a job object") + finally: + if process: + self.kernel.CloseHandle(process) + + def active(self): + ctypes, wintypes = self.ctypes, self.wintypes + + class Accounting(ctypes.Structure): + _fields_ = [("user", ctypes.c_int64), ("kernel", ctypes.c_int64), ("period_user", ctypes.c_int64), + ("period_kernel", ctypes.c_int64), ("page_faults", wintypes.DWORD), + ("total", wintypes.DWORD), ("active", wintypes.DWORD), ("terminated", wintypes.DWORD)] + info = Accounting() + if not self.kernel.QueryInformationJobObject(self.handle, 1, ctypes.byref(info), ctypes.sizeof(info), None): + return 0 + return info.active + + def terminate(self): + self.kernel.TerminateJobObject(self.handle, 1) + + +def terminate_group(child, grace=4.0, job=None): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": - if child.poll() is None: + # Ask the group to stop first: CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned. Then force whatever remains: the whole job, or the tree under a live leader. + def alive(): + return child.poll() is None or (job is not None and job.active() > 0) + if alive(): + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + except OSError: + pass + deadline = time.monotonic() + grace + while alive() and time.monotonic() < deadline: + time.sleep(0.1) + if job is not None and job.active() > 0: + job.terminate() + elif child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return try: @@ -1065,10 +1187,30 @@ class Interrupted(Exception): pass +STOP_FILE = "SESSION_WORKFLOW_STOP_FILE" + + +def wait_for_check(child, timeout): + """Wait like Popen.wait, but also stop when the supervisor of an enclosing worker asks for it. + + Signals cannot reach a check started by a worker everywhere (a Windows process without a console gets no + CTRL_BREAK), so the supervisor also writes the file named in SESSION_WORKFLOW_STOP_FILE. + """ + stop_file, deadline = os.environ.get(STOP_FILE), time.monotonic() + timeout + while True: + try: + return child.wait(timeout=min(0.2, max(0.0, deadline - time.monotonic()))) + except subprocess.TimeoutExpired: + if stop_file and os.path.exists(stop_file): + raise Interrupted() + if time.monotonic() >= deadline: + raise + + @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)] + names = [n for n in ("SIGTERM", "SIGHUP", "SIGBREAK") if hasattr(signal, n)] if threading.current_thread() is not threading.main_thread(): yield return @@ -1092,7 +1234,7 @@ def run_check(store, state, p, args, run_id, command, before): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: with interrupt_guard(): - code = child.wait(timeout=args.timeout) + code = wait_for_check(child, args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) @@ -1162,6 +1304,14 @@ def stop_runtime(store, record): return {"status": "unknown", "reason": "Stop receipt unavailable; process was not killed by PID"} +def request_stop(stop_request, running, grace): + """Tell nested check runners to stop their checks before any signal: they record the run as abandoned.""" + atomic_write(stop_request, "stop\n") + deadline = time.monotonic() + min(3.0, grace / 2) + while running() and time.monotonic() < deadline: + time.sleep(0.1) + + def supervise(spec_path): """Private supervisor: owns one process group through a live Popen handle; accepts no PID.""" spec_path = Path(spec_path).resolve() @@ -1170,25 +1320,41 @@ def supervise(spec_path): receipt_path = directory / "receipt.json" try: # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + stop_request = directory / "stop-request" 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()) + stdin=subprocess.DEVNULL, shell=False, + env={**os.environ, STOP_FILE: str(stop_request)}, **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): + job = None + if os.name == "nt": + try: + job = WindowsJob(child.pid) + except OSError: + job = None # Fall back to the leader and taskkill /T + + def running(): + if os.name == "nt": + return child.poll() is None or (job is not None and job.active() > 0) + # Descendants keep the group alive after the leader exits. + return child.poll() is None or group_alive(child.pid) + # Supervise until the leader and all of its descendants are gone. + while running(): 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)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) break if limit and time.monotonic() - started > limit: - terminate_group(child, spec.get("grace", 4.0)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) @@ -1314,6 +1480,50 @@ def relay_runtime(store, state, record): return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} +POLICY_BEGIN, POLICY_END = "", "" +RELAY_MODES = ("consult", "review", "write") + + +def provider_policy(store, state=None): + """The project's AI providers table from the context document, or None when the user has not decided yet.""" + name = (state or {}).get("documents", {}).get("context") or "PROJECT.md" + path = relative_path(store.root, name) + if not path.is_file(): + return None + text = path.read_text(encoding="utf-8") + if POLICY_BEGIN not in text and POLICY_END not in text: + return None + if text.count(POLICY_BEGIN) != 1 or text.count(POLICY_END) != 1 or text.index(POLICY_BEGIN) > text.index(POLICY_END): + raise Problem("providers_invalid", f"Ambiguous AI providers markers in {name}; keep exactly one block", 3) + providers = {} + for line in text[text.index(POLICY_BEGIN) + len(POLICY_BEGIN):text.index(POLICY_END)].splitlines(): + cells = [c.strip().strip("`").strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 2 or not cells[0] or cells[0].lower() == "provider" or set(cells[0]) <= set("-: "): + continue + uses = {u for u in re.split(r"[,;\s]+", cells[1].lower()) if u} + models = [m for m in re.split(r"[,;\s]+", cells[2]) if m] if len(cells) > 2 else [] + providers[cells[0].lower()] = { + "modes": [m for m in RELAY_MODES if m in uses], "orchestrate": "orchestrate" in uses, + "models": [] if not models or models[0].lower() in ("any", "*", "-") else models} + return {"file": name, "providers": providers} + + +def check_policy(store, state, provider, mode, model): + policy = provider_policy(store, state) + if policy is None: + raise Problem("providers_unset", "This project has no AI providers table yet. Ask the user which providers, " + "modes and models this project allows, record them in PROJECT.md (agent-relay, step 1), then " + "retry", 3) + entry = policy["providers"].get(provider.lower()) + if entry is None or mode not in entry["modes"]: + allowed = ", ".join(f"{p}: {'/'.join(e['modes']) or 'none'}" for p, e in sorted(policy["providers"].items())) + raise Problem("provider_not_allowed", f"{policy['file']} does not allow {provider} for {mode} " + f"(allowed: {allowed or 'no providers'}). Ask the user before changing the table", 3) + if entry["models"] and model not in entry["models"]: + raise Problem("provider_not_allowed", f"{policy['file']} allows {provider} only with " + f"{', '.join(entry['models'])}; pass one of them with --model", 3) + + def relay_providers(): found = [] for name in PROVIDER_CLIS: @@ -1340,6 +1550,7 @@ def relay_start(store, state, args): 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) + check_policy(store, state, args.provider, mode, args.model or "") packet = Path(require(args.packet, "--packet")).expanduser() text = packet.read_text(encoding="utf-8") if not text.strip(): @@ -1662,7 +1873,19 @@ def relay_collect(store, state, args): used = str(source) break if body is None: - record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + error = "No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer" + if record["transport"] == "cli" and not args.from_file: + # Diagnose a CLI that failed before answering, such as missing authentication. + runtime = relay_runtime(store, state, record) + _, process = relay_process(state, record) + tail = "" + log = relay_path(store, process["directory"]) / "stderr.log" if process else None + if log and log.is_file(): + with open(log, "rb") as handle: + handle.seek(max(0, log.stat().st_size - 600)) + tail = " ".join(handle.read().decode("utf-8", "replace").split()) + error += f"; worker exit code {runtime.get('exit_code')}" + (f"; stderr ends: {tail}" if tail else "") + record.update(status="malformed", error=error) relay_save(store, record) return relay_view(store, state, record), adopted try: @@ -2150,7 +2373,12 @@ def dispatch(store, args): 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() + found = relay_providers() + try: + found["policy"] = provider_policy(store, store.load() if store.path.exists() else None) + except Problem as exc: + found["policy"] = {"error": str(exc)} + return None, found if command == "delegate" and args.action != "status": require(args.session, "--session") if command == "snapshot": diff --git a/packs/session-workflow/skills/session-start/SKILL.md b/packs/session-workflow/skills/session-start/SKILL.md index 852b402..3bb5570 100644 --- a/packs/session-workflow/skills/session-start/SKILL.md +++ b/packs/session-workflow/skills/session-start/SKILL.md @@ -39,11 +39,21 @@ Read the [contract](references/workflow-contract.md) and 4. **Bootstrap only what is missing.** That means PROJECT.md from the [template](templates/project-context.md) plus a two-line pointer in AGENTS.md or CLAUDE.md. -5. **Report briefly:** +5. **Ask about AI providers on first use.** If the result says + `provider_policy: missing`, ask the user once, in one question, which AI + providers this project may use and for what. Offer: + - this agent; + - the CLIs that `delegate providers` finds; + - the host's own subagents or cross-provider tasks. + + "Only this agent" is a valid answer. Record the answer as the AI providers + table in PROJECT.md (template section), keeping its markers. If the result + says `invalid`, fix the markers. Never add a provider without the user. +6. **Report briefly:** - current phase, drift or gaps and next action; - superseded and other active sessions; - `warnings`, for example proposed .gitignore entries. -6. **Continue** the task the user asked for. Without a task, propose the saved +7. **Continue** the task the user asked for. Without a task, propose the saved next action and wait. If another active session holds claims you need, take unclaimed work. Release diff --git a/packs/session-workflow/skills/session-start/references/quickstart.md b/packs/session-workflow/skills/session-start/references/quickstart.md index 1eb628a..c5a9baf 100644 --- a/packs/session-workflow/skills/session-start/references/quickstart.md +++ b/packs/session-workflow/skills/session-start/references/quickstart.md @@ -65,5 +65,7 @@ To finish a phase, batch these steps: | `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` | +| `provider_policy: missing` | First use: ask the user which AI providers this project allows and record the PROJECT.md table (session-start step 5) | +| `providers_unset`, `provider_not_allowed` | Delegation outside the project's AI providers table: ask the user; never edit the table on your own | | 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/session-start/references/runtime.md b/packs/session-workflow/skills/session-start/references/runtime.md index 0b95d32..032e7db 100644 --- a/packs/session-workflow/skills/session-start/references/runtime.md +++ b/packs/session-workflow/skills/session-start/references/runtime.md @@ -41,8 +41,20 @@ 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`. +and runtimes to the new session, and their open delegations once the state is +saved. A session that is still running a check blocks this with +`session_alive`. + +A session in this checkout that acted within +`SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES` (default 30), or that has open +delegations, may belong to a live thread. A solo `begin` then joins in +parallel, takes over nothing and warns. + +Activity is measured from the runtime's own records: + +- the session's begin and checkpoints; +- its events and checks; +- its delegations. `begin --parallel` puts the checkout in parallel mode, which marks every active session there as parallel. While any parallel session is active in a @@ -116,13 +128,22 @@ recorded PID. On Windows the supervisor ends the process tree through | Command | Purpose | | --- | --- | -| `delegate providers` | Read-only: provider CLIs on PATH and their versions | +| `delegate providers` | Read-only: provider CLIs on PATH, their versions and the project's AI providers table | | `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 | +- **Provider policy.** `delegate start` requires the marked AI providers table + in the context document (PROJECT.md), between + `` and ``. + - Each row gives the provider ID, its allowed modes (`consult`, `review`, + `write`, plus the informational `orchestrate`, or `none`) and its models + (`any` or IDs). + - Without the table the error is `providers_unset`, and `status`/`begin` + report `provider_policy: missing`. A provider, mode or model outside it + gives `provider_not_allowed`. - 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. 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 9eb04ba..bfbfdb7 100644 --- a/packs/session-workflow/skills/session-start/references/workflow-contract.md +++ b/packs/session-workflow/skills/session-start/references/workflow-contract.md @@ -13,7 +13,9 @@ 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 +claims and runtimes. A session that acted in the last 30 minutes or has open +delegations may belong to a live chat or thread, so the new session joins in +parallel instead and warns. 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 diff --git a/packs/session-workflow/skills/session-start/scripts/session.py b/packs/session-workflow/skills/session-start/scripts/session.py index 849f465..fe0c94b 100644 --- a/packs/session-workflow/skills/session-start/scripts/session.py +++ b/packs/session-workflow/skills/session-start/scripts/session.py @@ -508,7 +508,12 @@ def scope_inventory(store, state, p): 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 + if os.name == "nt": + # Windows reports every file as executable and Git there ignores file modes: keep the index mode. + mode = inventory[name].split(":")[0] if name in inventory else "100644" + else: + mode = "100755" if os.access(root / name, os.X_OK) else "100644" + inventory[name] = mode + ":" + sha else: patterns, names = ignore_patterns(root), [] for value in (["*"] if whole else p["paths"]): @@ -672,7 +677,12 @@ def load(self): 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") + found = state.get("schema_version") if isinstance(state, dict) else None + newer = isinstance(found, int) and found > SCHEMA_VERSION + raise Problem("version", "Unsupported state schema; it was not modified." + ( + f" It was written by a newer estafeta (schema {found}); this copy of the skill is outdated, often " + "an older global install shadowing a newer one. Use the newest installed copy, or update all " + "installs: python3 scripts/workflow.py install --all-detected" if newer else "")) if state["schema_version"] == 1: state, self.migration = migrate_v1(state, self) validate(state, "state") @@ -781,6 +791,11 @@ 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"] + try: + if provider_policy(store, state) is None: + result["provider_policy"] = "missing" + except Problem: + result["provider_policy"] = "invalid" delegations = relay_open(store) if delegations: result["open_delegations"] = [{"name": r["name"], "provider": r["provider"], "mode": r["mode"], @@ -867,6 +882,42 @@ def prior_checkpoint(state, store): return best +def parse_time(value): + try: + return datetime.fromisoformat(value).timestamp() + except (TypeError, ValueError): + return 0.0 + + +def last_activity(store, state, session_id): + """Latest moment the runtime saw this session act: begin, checkpoints, its events, checks and delegations.""" + s = state["sessions"][session_id] + times = [parse_time(s["started_at"]), parse_time((s.get("checkpoint") or {}).get("at"))] + runs = {c["id"] for c in state["checks"].values() if c["session_id"] == session_id} + for event in state["events"]: + target = event.get("event", "").split(":", 1)[-1] + if target == session_id or target in runs: + times.append(parse_time(event.get("at"))) + for record in relay_records(store): + if record["session_id"] == session_id: + times += [parse_time(record.get("started_at")), parse_time(record.get("finished_at"))] + return max(times) + + +def takeover_blockers(store, state, candidates): + """Sessions a solo begin must not take over: recently active ones, or those with open delegations.""" + idle = 60 * float(os.environ.get("SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES", "30")) + delegating = {r["session_id"] for r in relay_open(store)} + blockers = [] + for sid in candidates: + quiet = time.time() - last_activity(store, state, sid) + if sid in delegating: + blockers.append(f"{sid} has open delegations") + elif quiet < idle: + blockers.append(f"{sid} was active {int(quiet // 60)} min ago") + return blockers + + def begin(store, args): with file_lock(store.directory / ".lock"): state = store.load() if store.path.exists() else initial_state(store, args) @@ -882,6 +933,14 @@ def begin(store, args): # 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 + # Another thread or agent may still be working here (T3 runs several threads in one folder): + # a solo begin does not take over a recently active session; it joins in parallel instead. + guarded = [] if args.parallel or joined else takeover_blockers( + store, state, [sid for sid in here if live_checks(store, state, sid) == []]) + if guarded: + joined = True + for sid in here: + state["sessions"][sid]["parallel"] = True if args.parallel: for sid in here: state["sessions"][sid]["parallel"] = True @@ -909,7 +968,12 @@ def begin(store, args): result = brief(state, store, session_id, live) if superseded: result["superseded"] = superseded - if joined: + if guarded: + result["warnings"] = result.get("warnings", []) + [ + "Another session here may still be working (" + "; ".join(guarded) + "), so this session joined " + "in parallel and took over nothing. Claim paths no one else claims. If that chat has stopped, " + "confirm with the user and release it."] + elif 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 " @@ -1035,10 +1099,68 @@ def group_alive(pgid): return True -def terminate_group(child, grace=4.0): +class WindowsJob: + """A Windows Job Object: the counterpart of a POSIX process group for descendants of one child.""" + + def __init__(self, pid): + import ctypes + from ctypes import wintypes + self.ctypes, self.wintypes = ctypes, wintypes + self.kernel = k = ctypes.WinDLL("kernel32", use_last_error=True) + signatures = { + "CreateJobObjectW": ([wintypes.LPVOID, wintypes.LPCWSTR], wintypes.HANDLE), + "OpenProcess": ([wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE), + "AssignProcessToJobObject": ([wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL), + "QueryInformationJobObject": ([wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD)], wintypes.BOOL), + "TerminateJobObject": ([wintypes.HANDLE, wintypes.UINT], wintypes.BOOL), + "CloseHandle": ([wintypes.HANDLE], wintypes.BOOL), + } + for name, (args, result) in signatures.items(): + getattr(k, name).argtypes, getattr(k, name).restype = args, result + self.handle = self.kernel.CreateJobObjectW(None, None) + process = self.kernel.OpenProcess(0x0101, False, pid) # PROCESS_TERMINATE | PROCESS_SET_QUOTA + try: + if not self.handle or not process or not self.kernel.AssignProcessToJobObject(self.handle, process): + raise OSError(ctypes.get_last_error(), "Cannot assign the process to a job object") + finally: + if process: + self.kernel.CloseHandle(process) + + def active(self): + ctypes, wintypes = self.ctypes, self.wintypes + + class Accounting(ctypes.Structure): + _fields_ = [("user", ctypes.c_int64), ("kernel", ctypes.c_int64), ("period_user", ctypes.c_int64), + ("period_kernel", ctypes.c_int64), ("page_faults", wintypes.DWORD), + ("total", wintypes.DWORD), ("active", wintypes.DWORD), ("terminated", wintypes.DWORD)] + info = Accounting() + if not self.kernel.QueryInformationJobObject(self.handle, 1, ctypes.byref(info), ctypes.sizeof(info), None): + return 0 + return info.active + + def terminate(self): + self.kernel.TerminateJobObject(self.handle, 1) + + +def terminate_group(child, grace=4.0, job=None): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": - if child.poll() is None: + # Ask the group to stop first: CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned. Then force whatever remains: the whole job, or the tree under a live leader. + def alive(): + return child.poll() is None or (job is not None and job.active() > 0) + if alive(): + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + except OSError: + pass + deadline = time.monotonic() + grace + while alive() and time.monotonic() < deadline: + time.sleep(0.1) + if job is not None and job.active() > 0: + job.terminate() + elif child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return try: @@ -1065,10 +1187,30 @@ class Interrupted(Exception): pass +STOP_FILE = "SESSION_WORKFLOW_STOP_FILE" + + +def wait_for_check(child, timeout): + """Wait like Popen.wait, but also stop when the supervisor of an enclosing worker asks for it. + + Signals cannot reach a check started by a worker everywhere (a Windows process without a console gets no + CTRL_BREAK), so the supervisor also writes the file named in SESSION_WORKFLOW_STOP_FILE. + """ + stop_file, deadline = os.environ.get(STOP_FILE), time.monotonic() + timeout + while True: + try: + return child.wait(timeout=min(0.2, max(0.0, deadline - time.monotonic()))) + except subprocess.TimeoutExpired: + if stop_file and os.path.exists(stop_file): + raise Interrupted() + if time.monotonic() >= deadline: + raise + + @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)] + names = [n for n in ("SIGTERM", "SIGHUP", "SIGBREAK") if hasattr(signal, n)] if threading.current_thread() is not threading.main_thread(): yield return @@ -1092,7 +1234,7 @@ def run_check(store, state, p, args, run_id, command, before): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) try: with interrupt_guard(): - code = child.wait(timeout=args.timeout) + code = wait_for_check(child, args.timeout) outcome = "pass" if code == 0 else "fail" except subprocess.TimeoutExpired: terminate_group(child) @@ -1162,6 +1304,14 @@ def stop_runtime(store, record): return {"status": "unknown", "reason": "Stop receipt unavailable; process was not killed by PID"} +def request_stop(stop_request, running, grace): + """Tell nested check runners to stop their checks before any signal: they record the run as abandoned.""" + atomic_write(stop_request, "stop\n") + deadline = time.monotonic() + min(3.0, grace / 2) + while running() and time.monotonic() < deadline: + time.sleep(0.1) + + def supervise(spec_path): """Private supervisor: owns one process group through a live Popen handle; accepts no PID.""" spec_path = Path(spec_path).resolve() @@ -1170,25 +1320,41 @@ def supervise(spec_path): receipt_path = directory / "receipt.json" try: # A delegated worker's stderr (progress, tool output) stays apart from its final answer on stdout. + stop_request = directory / "stop-request" 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()) + stdin=subprocess.DEVNULL, shell=False, + env={**os.environ, STOP_FILE: str(stop_request)}, **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): + job = None + if os.name == "nt": + try: + job = WindowsJob(child.pid) + except OSError: + job = None # Fall back to the leader and taskkill /T + + def running(): + if os.name == "nt": + return child.poll() is None or (job is not None and job.active() > 0) + # Descendants keep the group alive after the leader exits. + return child.poll() is None or group_alive(child.pid) + # Supervise until the leader and all of its descendants are gone. + while running(): 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)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) break if limit and time.monotonic() - started > limit: - terminate_group(child, spec.get("grace", 4.0)) + request_stop(stop_request, running, spec.get("grace", 4.0)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) @@ -1314,6 +1480,50 @@ def relay_runtime(store, state, record): return runtime_view(store, process) if process else {"status": "unknown", "reason": "No runtime record"} +POLICY_BEGIN, POLICY_END = "", "" +RELAY_MODES = ("consult", "review", "write") + + +def provider_policy(store, state=None): + """The project's AI providers table from the context document, or None when the user has not decided yet.""" + name = (state or {}).get("documents", {}).get("context") or "PROJECT.md" + path = relative_path(store.root, name) + if not path.is_file(): + return None + text = path.read_text(encoding="utf-8") + if POLICY_BEGIN not in text and POLICY_END not in text: + return None + if text.count(POLICY_BEGIN) != 1 or text.count(POLICY_END) != 1 or text.index(POLICY_BEGIN) > text.index(POLICY_END): + raise Problem("providers_invalid", f"Ambiguous AI providers markers in {name}; keep exactly one block", 3) + providers = {} + for line in text[text.index(POLICY_BEGIN) + len(POLICY_BEGIN):text.index(POLICY_END)].splitlines(): + cells = [c.strip().strip("`").strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 2 or not cells[0] or cells[0].lower() == "provider" or set(cells[0]) <= set("-: "): + continue + uses = {u for u in re.split(r"[,;\s]+", cells[1].lower()) if u} + models = [m for m in re.split(r"[,;\s]+", cells[2]) if m] if len(cells) > 2 else [] + providers[cells[0].lower()] = { + "modes": [m for m in RELAY_MODES if m in uses], "orchestrate": "orchestrate" in uses, + "models": [] if not models or models[0].lower() in ("any", "*", "-") else models} + return {"file": name, "providers": providers} + + +def check_policy(store, state, provider, mode, model): + policy = provider_policy(store, state) + if policy is None: + raise Problem("providers_unset", "This project has no AI providers table yet. Ask the user which providers, " + "modes and models this project allows, record them in PROJECT.md (agent-relay, step 1), then " + "retry", 3) + entry = policy["providers"].get(provider.lower()) + if entry is None or mode not in entry["modes"]: + allowed = ", ".join(f"{p}: {'/'.join(e['modes']) or 'none'}" for p, e in sorted(policy["providers"].items())) + raise Problem("provider_not_allowed", f"{policy['file']} does not allow {provider} for {mode} " + f"(allowed: {allowed or 'no providers'}). Ask the user before changing the table", 3) + if entry["models"] and model not in entry["models"]: + raise Problem("provider_not_allowed", f"{policy['file']} allows {provider} only with " + f"{', '.join(entry['models'])}; pass one of them with --model", 3) + + def relay_providers(): found = [] for name in PROVIDER_CLIS: @@ -1340,6 +1550,7 @@ def relay_start(store, state, args): 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) + check_policy(store, state, args.provider, mode, args.model or "") packet = Path(require(args.packet, "--packet")).expanduser() text = packet.read_text(encoding="utf-8") if not text.strip(): @@ -1662,7 +1873,19 @@ def relay_collect(store, state, args): used = str(source) break if body is None: - record.update(status="malformed", error="No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer") + error = "No " + RELAY_OPEN + " ... " + RELAY_CLOSE + " block in the final answer" + if record["transport"] == "cli" and not args.from_file: + # Diagnose a CLI that failed before answering, such as missing authentication. + runtime = relay_runtime(store, state, record) + _, process = relay_process(state, record) + tail = "" + log = relay_path(store, process["directory"]) / "stderr.log" if process else None + if log and log.is_file(): + with open(log, "rb") as handle: + handle.seek(max(0, log.stat().st_size - 600)) + tail = " ".join(handle.read().decode("utf-8", "replace").split()) + error += f"; worker exit code {runtime.get('exit_code')}" + (f"; stderr ends: {tail}" if tail else "") + record.update(status="malformed", error=error) relay_save(store, record) return relay_view(store, state, record), adopted try: @@ -2150,7 +2373,12 @@ def dispatch(store, args): 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() + found = relay_providers() + try: + found["policy"] = provider_policy(store, store.load() if store.path.exists() else None) + except Problem as exc: + found["policy"] = {"error": str(exc)} + return None, found if command == "delegate" and args.action != "status": require(args.session, "--session") if command == "snapshot": diff --git a/packs/session-workflow/skills/session-start/templates/project-context.md b/packs/session-workflow/skills/session-start/templates/project-context.md index f0bbe60..6ddf5a8 100644 --- a/packs/session-workflow/skills/session-start/templates/project-context.md +++ b/packs/session-workflow/skills/session-start/templates/project-context.md @@ -17,6 +17,19 @@ - Open decisions: - Next action: +## AI providers + +Which AI providers may work on this project, and how. The user decides; agents +ask before changing it. Allowed: orchestrate, consult, review, write, or none. +Models: any, or a list. The runtime refuses delegations outside this table. + + +| Provider | Allowed | Models | Notes | +| --- | --- | --- | --- | +| | orchestrate, consult, review, write | any | | +| | | | | + + ## Run and verify >> +``` +""" + + +def call(root, *args): + done = subprocess.run([sys.executable, str(RUNTIME), "--root", str(root), *args], capture_output=True, text=True) + value = json.loads(done.stdout) + if not value["ok"]: + raise SystemExit(f"runtime refused {args[:2]}: {json.dumps(value['error'])}") + return value + + +def git(root, *args): + subprocess.run(["git", "-C", str(root), "-c", "user.name=Live Golden", "-c", "user.email=live@example.invalid", + "-c", "commit.gpgsign=false", *args], check=True, capture_output=True) + + +def wait(root, name, limit): + deadline = time.monotonic() + limit + 30 + while time.monotonic() < deadline: + view = call(root, "delegate", "status", "--name", name)["result"]["delegations"][0] + if view["status"] != "running": + return view + time.sleep(5) + raise SystemExit(f"{name} still running") + + +def packet(header, task, rules, fields): + return (f"# Relay packet: {header['name']}\n\n```relay-packet\n{json.dumps(header, indent=2)}\n```\n\n" + "You are a worker for another agent, which orchestrates this project.\n\n" + f"## Task\n\n{task}\n\n## Rules\n\n{rules}\n\n" + REPLY_RULES.format(fields=fields)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--provider", choices=sorted(PRESETS), required=True) + parser.add_argument("--model", required=True) + parser.add_argument("--mode", choices=("consult", "write", "both"), default="both") + parser.add_argument("--timeout", type=int, default=900) + parser.add_argument("--keep", action="store_true", help="Keep the fixture for inspection") + args = parser.parse_args() + if not shutil.which(args.provider): + raise SystemExit(f"{args.provider} CLI is not on PATH") + base = Path(tempfile.mkdtemp(prefix=f"relay-live-{args.provider}-")).resolve() + root, worktree = base / "project", base / "worker wt" + root.mkdir() + git(root, "init", "-q") + (root / ".gitignore").write_text(".agent-state/\n__pycache__/\n") + (root / "greet.py").write_text('def greet(name: str) -> str:\n return f"Hello, {name}!"\n') + (root / "PROJECT.md").write_text( + "# Live golden fixture\n\n## AI providers\n\n\n" + "| Provider | Allowed | Models | Notes |\n| --- | --- | --- | --- |\n" + f"| {args.provider} | consult, write | {args.model} | authorized live golden run |\n" + "\n", encoding="utf-8") + git(root, "add", ".") + git(root, "commit", "-qm", "fixture") + orch, results = "orch-" + args.provider, {} + call(root, "begin", "--session", orch) + + def start(name, mode, text, extra): + (base / (name + ".md")).write_text(text, encoding="utf-8") + argv = [item.replace("MODEL", args.model) for item in PRESETS[args.provider][mode]] + (base / (name + ".json")).write_text(json.dumps(argv), encoding="utf-8") + rev = call(root, "status")["revision"] + call(root, "delegate", "start", "--session", orch, "--expect-revision", str(rev), "--name", name, + "--provider", args.provider, "--model", args.model, "--mode", "consult" if mode == "consult" else "write", + "--packet", str(base / (name + ".md")), "--timeout", str(args.timeout), + "--argv-file", str(base / (name + ".json")), *extra) + print(f"{name}: {wait(root, name, args.timeout)['runtime']}") + view = call(root, "delegate", "collect", "--session", orch, "--name", name)["result"] + print(f"{name}: {view['status']} {json.dumps(view.get('result') or view.get('error'))[:400]}") + print(f"{name}: discrepancies {view.get('discrepancies')}") + return view + + if args.mode in ("consult", "both"): + header = {"version": 1, "name": "ask-live", "mode": "consult", "orchestrator": orch, "workspace": str(root)} + start("ask-live", "consult", packet( + header, 'Which file defines `greet`, and what exactly does `greet("Ada")` return? Read the code.', + "- Read only. Change no files.", + '{"outcome": "completed | blocked | failed", "summary": "one sentence", "answer": "the answer"}'), []) + results["consult-cli"] = ["orch=" + orch, "ask-1=ask-live"] + if args.mode in ("write", "both"): + plan = {"phases": [{"id": "P1", "title": "Shout", "acceptance": "shout(name) returns NAME! and rejects empty names", + "paths": ["shout.py", "test_shout.py"], "required_checks": ["unit"]}]} + (base / "plan.json").write_text(json.dumps(plan)) + call(root, "plan", "--session", orch, "--expect-revision", str(call(root, "status")["revision"]), + "--input", str(base / "plan.json")) + git(root, "worktree", "add", "-q", "-b", "relay/P1", str(worktree)) + check = [sys.executable, "-m", "unittest", "-q", "test_shout"] + header = {"version": 1, "name": "write-live", "mode": "write", "orchestrator": orch, + "workspace": str(worktree), "runtime": [sys.executable, str(RUNTIME)], "phase": "P1", + "worker_session": args.provider + "-w1", "allowed_paths": ["shout.py", "test_shout.py"], + "required_checks": {"unit": check}} + rt = f'{sys.executable} {RUNTIME} --root "{worktree}"' + rules = f"""- Work only in the workspace above. Do not commit, push, install, publish or change any other file. +- Change only `allowed_paths`. Record your work with the runtime above (quote the workspace path): + 1. `{rt} begin --parallel --session {header['worker_session']}` + 2. `{rt} transition --session {header['worker_session']} --expect-revision R --phase P1 --to IN_PROGRESS` + 3. make the change + 4. `{rt} check run --session {header['worker_session']} --expect-revision R --phase P1 --name unit --timeout 120 -- {' '.join(check)}` + 5. `{rt} transition --session {header['worker_session']} --expect-revision R --phase P1 --to VERIFY`, then + `{rt} end --session {header['worker_session']} --expect-revision R --next-action "Orchestrator integrates P1"` + + R is the `revision` returned by your previous runtime call. An `ownership` refusal means stop and report blocked. +- Stop and report `blocked` instead of guessing when access, a decision or a dependency is missing.""" + view = start("write-live", "write", packet( + header, "Create `shout.py` with `shout(name: str) -> str` returning the name upper-cased plus `!` " + "(`shout(\"ada\") == \"ADA!\"`); it raises `ValueError` for an empty or whitespace-only name. Add " + "`test_shout.py` (standard-library unittest) covering both. Acceptance: the required check `unit` passes.", + rules, '{"outcome": "completed | blocked | failed", "summary": "one or two sentences", ' + f'"worker_session": "{header["worker_session"]}", "changed_files": ["paths"], ' + '"checks": [{"name": "unit", "result": "pass | fail"}], "gaps": []}'), + ["--phase", "P1", "--worker-session", header["worker_session"], "--workspace", str(worktree)]) + if view["status"] == "returned" and not view.get("discrepancies"): + git(worktree, "add", "shout.py", "test_shout.py") + git(worktree, "commit", "-qm", "P1 by " + header["worker_session"]) + git(root, "merge", "-q", "--ff-only", "relay/P1") + call(root, "transition", "--session", orch, "--expect-revision", str(call(root, "status")["revision"]), + "--phase", "P1", "--to", "VERIFY") + ran = call(root, "check", "run", "--session", orch, "--expect-revision", + str(call(root, "status")["revision"]), "--phase", "P1", "--name", "unit", "--", *check) + print("integrated check:", ran["result"]["result"]) + call(root, "transition", "--session", orch, "--expect-revision", str(ran["revision"]), "--phase", "P1", + "--to", "DONE", "--acceptance-note", "Live worker change reviewed, merged and rechecked") + git(root, "worktree", "remove", str(worktree)) + results["write-worktree"] = ["orch=" + orch, "write-1=write-live"] + call(root, "end", "--session", orch, "--expect-revision", str(call(root, "status")["revision"]), + "--next-action", "Live golden run finished") + passed = True + for scenario, mapping in results.items(): + argv = [sys.executable, str(RUNNER), "--live-verify", scenario, "--root", str(root)] + for item in mapping: + argv += ["--map", item] + passed = subprocess.run(argv).returncode == 0 and passed + print(("PASS" if passed else "FAIL") + f" {args.provider} {args.model}; fixture {base}") + if not args.keep and passed: + shutil.rmtree(base, ignore_errors=True) + return 0 if passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/golden/live_orchestrator.py b/scripts/golden/live_orchestrator.py new file mode 100644 index 0000000..8cd1b46 --- /dev/null +++ b/scripts/golden/live_orchestrator.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Live golden path with a real orchestrator outside T3: the agent itself follows agent-relay. + +Each case builds a disposable project with project-local copies of the estafeta +skills and an AI providers table, then gives an orchestrator CLI a plain user +request: consult another agent through agent-relay. The run passes when the +recorded state meets the consult-cli golden expectations (`--live-verify`). +These are paid runs; use them only with the user's authorization. + + python3 scripts/golden/live_orchestrator.py --case codex-to-claude +""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile + +HERE = Path(__file__).resolve().parent +PACK = HERE.parents[1] / "packs/session-workflow" +RUNNER = HERE.parent / "test_relay.py" +QUESTION = 'Which file defines `greet`, and what exactly does `greet("Ada")` return?' +CASES = { + "codex-to-claude": ("codex", "claude", "claude-haiku-5-5", + "Run Claude through its CLI (`claude -p`, model claude-haiku-5-5) as a supervised delegate " + "worker."), + "codex-to-native": ("codex", "codex", "gpt-6.1-sol", + "Use your own native subagent (a spawned Codex agent) and record it as a host task."), + "claude-to-codex": ("claude", "codex", "gpt-6.1-sol", + "Run Codex through its CLI (`codex exec`, model gpt-6.1-sol) as a supervised delegate worker."), + "claude-to-native": ("claude", "claude", "claude-haiku-5-5", + "Use your own native subagent (the Agent tool, model haiku) and record it as a host task."), +} + + +def orchestrator_argv(host, root, prompt, sandbox): + if host == "codex": + argv = ["codex", "exec", "--cd", str(root), "-m", "gpt-6.1-sol", "--output-last-message", + str(root.parent / "orchestrator-reply.txt")] + if sandbox == "workspace-write": + # Nested worker CLIs need the network and their own config directories. + argv += ["--sandbox", "workspace-write", "-c", "sandbox_workspace_write.network_access=true"] + for env, default in (("CLAUDE_CONFIG_DIR", "~/.claude"), ("CODEX_HOME", "~/.codex")): + argv += ["--add-dir", str(Path(os.environ.get(env, default)).expanduser())] + elif sandbox != "user-config": + argv += ["--sandbox", sandbox] + return argv + [prompt] + return ["claude", "-p", prompt, "--permission-mode", "acceptEdits", "--allowedTools", "Bash", "Read", "Write", + "Edit", "Glob", "Grep", "Agent", "--output-format", "json", "--model", "claude-sonnet-5-5", + "--max-budget-usd", "3"] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--case", choices=sorted(CASES), required=True) + parser.add_argument("--orchestrator-sandbox", default="workspace-write", + choices=("workspace-write", "user-config", "read-only"), + help="Codex orchestrator only: sandbox to run the orchestrator in") + parser.add_argument("--timeout", type=int, default=1200) + parser.add_argument("--keep", action="store_true") + args = parser.parse_args() + host, worker, model, hint = CASES[args.case] + base = Path(tempfile.mkdtemp(prefix=f"relay-orch-{args.case}-")).resolve() + root = base / "project" + root.mkdir() + git = ["git", "-C", str(root), "-c", "user.name=Live Golden", "-c", "user.email=live@example.invalid", + "-c", "commit.gpgsign=false"] + subprocess.run([*git, "init", "-q"], check=True) + (root / ".gitignore").write_text(".agent-state/\n.agents/\n.claude/\n__pycache__/\n") + (root / "greet.py").write_text('def greet(name: str) -> str:\n return f"Hello, {name}!"\n') + rows = {host: "orchestrate, consult", worker: "consult"} if host != worker else {host: "orchestrate, consult"} + (root / "PROJECT.md").write_text( + "# Greeter\n\nA one-function fixture project.\n\n## AI providers\n\n\n" + "| Provider | Allowed | Models | Notes |\n| --- | --- | --- | --- |\n" + + "".join(f"| {p} | {uses} | any | |\n" for p, uses in rows.items()) + + "\n", encoding="utf-8") + pointer = "Read PROJECT.md. Use session-start to begin or resume work.\n" + (root / "AGENTS.md").write_text(pointer) + (root / "CLAUDE.md").write_text(pointer) + for skills in (root / ".agents/skills", root / ".claude/skills"): + shutil.copytree(PACK / "skills", skills) + subprocess.run([*git, "add", "."], check=True) + subprocess.run([*git, "commit", "-qm", "fixture"], check=True) + prompt = ( + "Use the estafeta skills installed in this project.\n" + f"1. Start with session-start; use session ID orch-{host}.\n" + f"2. With agent-relay, consult {worker} and ask: {QUESTION} Name the delegation ask-1. {hint}\n" + "3. The project's AI providers table allows this, and I authorize this one consult run.\n" + "4. Collect the result with the runtime, end the session with session-end, then reply with the answer.") + argv = orchestrator_argv(host, root, prompt, args.orchestrator_sandbox) + print(f"case {args.case}: orchestrator {host}, worker {worker} ({model}); fixture {base}", flush=True) + try: + done = subprocess.run(argv, cwd=root, capture_output=True, text=True, timeout=args.timeout, + stdin=subprocess.DEVNULL) + (base / "orchestrator.out").write_text(done.stdout + "\n--- stderr ---\n" + done.stderr) + print(f"orchestrator exit {done.returncode}; output in {base / 'orchestrator.out'}") + except subprocess.TimeoutExpired: + print("orchestrator timed out") + status = subprocess.run([sys.executable, str(PACK / "shared/runtime/session.py"), "--root", str(root), + "delegate", "status"], capture_output=True, text=True) + for record in (json.loads(status.stdout).get("result") or {}).get("delegations", []): + print("delegation:", json.dumps({k: record.get(k) for k in ("name", "provider", "model", "transport", + "status", "host_ref", "error")})) + passed = subprocess.run([sys.executable, str(RUNNER), "--live-verify", "consult-cli", "--root", str(root), + "--map", "ask-1=ask-1"]).returncode == 0 + print(("PASS" if passed else "FAIL") + f" {args.case}") + if passed and not args.keep: + shutil.rmtree(base, ignore_errors=True) + return 0 if passed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/golden/relay-scenarios.json b/scripts/golden/relay-scenarios.json index a7b172c..694a5c4 100644 --- a/scripts/golden/relay-scenarios.json +++ b/scripts/golden/relay-scenarios.json @@ -1120,6 +1120,289 @@ } ] }, + { + "id": "provider-policy", + "description": "On first use the project has no AI providers table: the runtime flags it and refuses delegation until the user's choice is recorded, then enforces providers, modes and models from it.", + "steps": [ + { + "name": "orchestrator begins", + "do": "runtime", + "args": [ + "begin", + "--session", + "orch" + ] + }, + { + "name": "the project has not chosen its providers yet", + "do": "file", + "path": "PROJECT.md", + "text": "# Project\n" + }, + { + "name": "status flags the missing policy for session-start to ask", + "do": "runtime", + "args": [ + "status", + "--session", + "orch" + ], + "expect": [ + { + "path": "result.provider_policy", + "equals": "missing" + } + ] + }, + { + "name": "consult packet", + "do": "packet", + "file": "ask.md", + "header": { + "mode": "consult", + "name": "ask", + "orchestrator": "orch", + "workspace": "{root}" + }, + "body": "Question." + }, + { + "name": "delegation waits for the user's decision", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "ask-0", + "--provider", + "codex", + "--mode", + "consult", + "--packet", + "{packets}/ask.md", + "--host-ref", + "t3:ask-0", + "--model", + "gpt-6.1-sol" + ], + "expect": [ + { + "path": "ok", + "equals": false + }, + { + "path": "error.code", + "equals": "providers_unset" + } + ] + }, + { + "name": "the agent records the user's answer in PROJECT.md", + "do": "file", + "path": "PROJECT.md", + "text": "# Project\n\n## AI providers\n\n\n| Provider | Allowed | Models | Notes |\n| --- | --- | --- | --- |\n| claude | orchestrate, consult, review, write | any | this agent |\n| codex | consult | gpt-6.1-sol | reviews by T3 task |\n| gemini | none | - | no API key |\n\n" + }, + { + "name": "status no longer flags it", + "do": "runtime", + "args": [ + "status", + "--session", + "orch" + ], + "expect": [ + { + "path": "result.provider_policy", + "absent": true + } + ] + }, + { + "name": "providers lists the recorded policy", + "do": "runtime", + "args": [ + "delegate", + "providers" + ], + "expect": [ + { + "path": "result.policy.providers.codex.modes", + "equals": [ + "consult" + ] + }, + { + "path": "result.policy.providers.gemini.modes", + "equals": [] + } + ] + }, + { + "name": "a mode the project did not allow is refused", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "ask-1", + "--provider", + "codex", + "--mode", + "review", + "--packet", + "{packets}/ask.md", + "--host-ref", + "t3:ask-1", + "--model", + "gpt-6.1-sol" + ], + "expect": [ + { + "path": "error.code", + "equals": "provider_not_allowed" + } + ] + }, + { + "name": "a model the project did not allow is refused", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "ask-2", + "--provider", + "codex", + "--mode", + "consult", + "--packet", + "{packets}/ask.md", + "--host-ref", + "t3:ask-2", + "--model", + "gpt-5.6-terra" + ], + "expect": [ + { + "path": "error.code", + "equals": "provider_not_allowed" + }, + { + "path": "error.message", + "contains": "gpt-6.1-sol" + } + ] + }, + { + "name": "a provider marked none is refused", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "ask-3", + "--provider", + "gemini", + "--mode", + "consult", + "--packet", + "{packets}/ask.md", + "--host-ref", + "t3:ask-3" + ], + "expect": [ + { + "path": "error.code", + "equals": "provider_not_allowed" + } + ] + }, + { + "name": "an unlisted provider is refused", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "ask-4", + "--provider", + "cursor", + "--mode", + "consult", + "--packet", + "{packets}/ask.md", + "--host-ref", + "t3:ask-4" + ], + "expect": [ + { + "path": "error.code", + "equals": "provider_not_allowed" + } + ] + }, + { + "name": "the allowed provider, mode and model are accepted", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--name", + "ask-5", + "--provider", + "codex", + "--mode", + "consult", + "--packet", + "{packets}/ask.md", + "--host-ref", + "t3:ask-5", + "--model", + "gpt-6.1-sol" + ], + "expect": [ + { + "path": "ok", + "equals": true + }, + { + "path": "result.transport", + "equals": "host" + } + ] + }, + { + "name": "refusals created no records", + "do": "runtime", + "args": [ + "delegate", + "status" + ], + "expect": [ + { + "path": "result.delegations.0.name", + "equals": "ask-5" + }, + { + "path": "result.delegations.1", + "absent": true + } + ] + } + ] + }, { "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.", @@ -1388,6 +1671,91 @@ } ] }, + { + "name": "a worker that fails before answering is started", + "do": "runtime", + "args": [ + "delegate", + "start", + "--session", + "orch", + "--expect-revision", + "{rev}", + "--name", + "crash-1", + "--provider", + "stub", + "--mode", + "consult", + "--packet", + "{packets}/consult.md", + "--", + "{python}", + "{stub}", + "{packet}", + "--behavior", + "crash" + ] + }, + { + "name": "it exits with its error code", + "do": "wait", + "delegation": "crash-1", + "expect": [ + { + "path": "status", + "equals": "exited" + }, + { + "path": "runtime.exit_code", + "equals": 41 + } + ] + }, + { + "name": "collect reports the exit code and stderr instead of accepting anything", + "do": "runtime", + "args": [ + "delegate", + "collect", + "--session", + "orch", + "--name", + "crash-1" + ], + "expect": [ + { + "path": "result.status", + "equals": "malformed" + }, + { + "path": "result.error", + "contains": "exit code 41" + }, + { + "path": "result.error", + "contains": "Please set an Auth method" + } + ] + }, + { + "name": "cancel closes it", + "do": "runtime", + "args": [ + "delegate", + "cancel", + "--session", + "orch", + "--name", + "crash-1" + ], + "expect": [ + { + "path": "result.status", + "equals": "cancelled" + } + ] + }, { "name": "worktree for the lying worker", "do": "git", @@ -1519,7 +1887,7 @@ "unit": [ "{python}", "-c", - "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600) # relay-golden-slow" + "import signal, time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(600) # relay-golden-slow {packets}" ] } }, @@ -1586,7 +1954,7 @@ { "name": "its check process did not survive", "do": "no_process", - "pattern": "relay-golden-slow" + "pattern": "relay-golden-slow {packets}" }, { "name": "cancel closes it and names the worker session to release", diff --git a/scripts/golden/stub_worker.py b/scripts/golden/stub_worker.py index ad5527f..6d4f8f1 100644 --- a/scripts/golden/stub_worker.py +++ b/scripts/golden/stub_worker.py @@ -58,6 +58,11 @@ def write(packet, args): "--phase", pid, "--name", name, "--timeout", "900", "--", *argv) rev = done["revision"] checks.append({"name": name, "result": done["result"]["result"]}) + if done["result"]["result"] != "pass": + # As the packet asks: a failed or stopped required check means report blocked and stop here. + reply({"outcome": "blocked", "summary": f"Check {name} ended {done['result']['result']}", + "worker_session": sid, "changed_files": changed, "checks": checks, "gaps": [name]}) + return rev = runtime(packet, "transition", "--session", sid, "--expect-revision", str(rev), "--phase", pid, "--to", "VERIFY")["revision"] runtime(packet, "end", "--session", sid, "--expect-revision", str(rev), @@ -69,7 +74,7 @@ def write(packet, args): def main(): parser = argparse.ArgumentParser() parser.add_argument("packet") - parser.add_argument("--behavior", choices=("normal", "hang", "garbage", "lie"), default="normal") + parser.add_argument("--behavior", choices=("normal", "hang", "garbage", "lie", "crash"), default="normal") parser.add_argument("--answer", default="") parser.add_argument("--verdict", default="APPROVE") parser.add_argument("--finding", action="append", default=[]) @@ -78,6 +83,10 @@ def main(): packet = packet_header(args.packet) if args.behavior == "hang": time.sleep(600) + if args.behavior == "crash": + # Like a CLI without authentication: an error on stderr, nothing on stdout. + print(json.dumps({"error": {"message": "Please set an Auth method", "code": 41}}), file=sys.stderr) + return 41 if args.behavior == "garbage": print("I think the task is done.") return 0 diff --git a/scripts/test_relay.py b/scripts/test_relay.py index 8fb8955..4bb42b2 100644 --- a/scripts/test_relay.py +++ b/scripts/test_relay.py @@ -14,6 +14,7 @@ import argparse import json import os +import re from pathlib import Path import subprocess import sys @@ -24,6 +25,17 @@ SOURCE = HERE.parent / "packs/session-workflow/shared/runtime/session.py" SCENARIOS = HERE / "golden/relay-scenarios.json" STUB = HERE / "golden/stub_worker.py" +POLICY = """# Golden fixture + +## AI providers + + +| Provider | Allowed | Models | Notes | +| --- | --- | --- | --- | +| stub | consult, review, write | any | deterministic golden worker | +| codex | consult, review, write | any | host tasks in golden scenarios | + +""" class StepFailed(Exception): @@ -104,6 +116,9 @@ def step(self, step): if done.returncode: raise StepFailed("git failed: " + done.stderr[-500:]) return {"ok": True} + if action == "file": + (self.root / step["path"]).write_text(self.fill(step["text"]), encoding="utf-8") + 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" @@ -128,15 +143,23 @@ def step(self, step): time.sleep(0.2) raise StepFailed("delegation still running at the wait timeout") if action == "no_process": + # The pattern carries this run's fixture path, so other runs' processes never match. + pattern = self.fill(step["pattern"]) deadline = time.monotonic() + step.get("timeout", 10) + if os.name == "nt": + query = ["powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine " + f"-like '*{pattern}*' }} | ForEach-Object {{ $_.ProcessId }}"] + else: + query = ["pgrep", "-f", re.escape(pattern)] while True: # a stopped process may need a moment to be reaped - done = subprocess.run(["pgrep", "-f", step["pattern"]], capture_output=True, text=True) + done = subprocess.run(query, 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) + capture_output=True, text=True) if os.name != "nt" else done raise StepFailed("process still running: " + (ps.stdout.strip() or done.stdout.strip())) return {"ok": True} raise StepFailed("unknown action " + action) @@ -149,6 +172,7 @@ def fixture(base): subprocess.run(["git", "-C", str(root), *args], check=True) (root / "a.txt").write_text("a\n") (root / ".gitignore").write_text(".agent-state/\n") + (root / "PROJECT.md").write_text(POLICY, encoding="utf-8") 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) diff --git a/scripts/test_session.py b/scripts/test_session.py index 959811e..437e471 100644 --- a/scripts/test_session.py +++ b/scripts/test_session.py @@ -20,13 +20,37 @@ SPEC.loader.exec_module(r) +def pid_alive(pid): + """Probe a process without signalling it; os.kill(pid, 0) would terminate it on Windows.""" + if os.name == "nt": + import ctypes + kernel = ctypes.WinDLL("kernel32") + kernel.OpenProcess.restype = ctypes.c_void_p + handle = kernel.OpenProcess(0x00100000 | 0x1000, False, pid) # SYNCHRONIZE | QUERY_LIMITED_INFORMATION + if not handle: + return False + try: + return kernel.WaitForSingleObject(ctypes.c_void_p(handle), 0) == 0x102 # WAIT_TIMEOUT + finally: + kernel.CloseHandle(ctypes.c_void_p(handle)) + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + class SessionTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix="session-runtime-") self.base = Path(self.temp.name).resolve() self.env = patch.dict(os.environ, {"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull, "GIT_CONFIG_NOSYSTEM": "1", - "SESSION_WORKFLOW_MACHINE_ID": "test-machine"}) + "SESSION_WORKFLOW_MACHINE_ID": "test-machine", + # Most tests model a closed chat; the takeover guard has its own tests below. + "SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES": "0"}) self.env.start() self.root = self.base / "project with spaces" self.root.mkdir() @@ -78,6 +102,11 @@ def call(self, *args, code=0, root=None, revision=True): self.assertEqual(result, code, output.getvalue()) return json.loads(output.getvalue()) + def allow_providers(self, rows="| codex | consult, review, write | any |\n| stub | consult, review, write | any |\n"): + (self.root / "PROJECT.md").write_text( + "# Project\n\n## AI providers\n\n\n| Provider | Allowed | Models |\n" + "| --- | --- | --- |\n" + rows + "\n", encoding="utf-8") + def setup_sessions(self, *names): for name in names or ("one",): self.call("begin", "--session", name, "--parallel") @@ -124,6 +153,7 @@ def test_relay_result_rules_depend_on_mode(self): self.assertTrue(r.relay_problems({"mode": "consult"}, ["not", "an", "object"])) def test_delegate_start_validates_before_effects(self): + self.allow_providers() self.setup_sessions() self.plan() packet = self.base / "packet.md" @@ -159,6 +189,7 @@ def test_relay_scope_is_one_directional_and_rejects_traversal(self): self.assertFalse(r.relay_in_scope("srcx/a.py", ["src"])) def test_relay_blocked_result_with_bad_checks_is_malformed_not_a_crash(self): + self.allow_providers() self.setup_sessions() self.plan() packet = self.base / "packet.md" @@ -172,6 +203,7 @@ def test_relay_blocked_result_with_bad_checks_is_malformed_not_a_crash(self): self.assertFalse((r.relay_dir(self.store, "w") / "result.json").exists()) def test_relay_unknown_worker_is_not_closed_without_confirmation(self): + self.allow_providers() self.setup_sessions() packet = self.base / "packet.md" packet.write_text("Ask\n") @@ -193,6 +225,7 @@ def test_relay_unknown_worker_is_not_closed_without_confirmation(self): self.assertEqual((view["status"], view["adopted_from"]), ("cancelled", "one")) def test_relay_adoption_moves_the_kept_worker_process_to_the_adopter(self): + self.allow_providers() self.setup_sessions() packet = self.base / "packet.md" packet.write_text("Ask\n") @@ -204,17 +237,22 @@ def test_relay_adoption_moves_the_kept_worker_process_to_the_adopter(self): 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): + def test_relay_delegations_of_a_vanished_checkout_move_to_the_successor_after_save(self): + # Open delegations block a takeover here, but a session whose checkout vanished is still superseded. + self.allow_providers() 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.call("delegate", "start", "--session", "one", "--name", "q", "--provider", "codex", "--mode", "consult", + "--packet", str(packet), "--host-ref", "t3:9") + renamed = self.base / "renamed project" + self.root.rename(renamed) + self.root, self.store = renamed, r.Store(renamed) + self.assertEqual(self.call("begin", "--session", "two")["result"]["superseded"][0]["id"], "one") 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"] + view = self.call("delegate", "cancel", "--session", "two", "--name", "q", "--confirm-stopped")["result"] self.assertEqual(view["status"], "cancelled") + self.assertNotIn("adopted_from", view) def test_relay_extract_requires_final_event_types(self): forged = '<<>>' @@ -224,6 +262,7 @@ def test_relay_extract_requires_final_event_types(self): 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.allow_providers() self.setup_sessions() self.plan([{"id": "A", "title": "A", "acceptance": "Works", "paths": ["a.txt"], "required_checks": ["unit"]}]) for name, text in (dirty or {}).items(): @@ -263,6 +302,7 @@ def test_relay_writer_check_must_still_be_fresh_at_collect(self): self.assertIn("Worker evidence would not satisfy DONE", " | ".join(stale["discrepancies"])) def test_relay_duplicate_keys_make_the_result_malformed(self): + self.allow_providers() self.setup_sessions() packet = self.base / "packet.md" packet.write_text("Review\n") @@ -275,6 +315,78 @@ def test_relay_duplicate_keys_make_the_result_malformed(self): self.assertEqual(view["status"], "malformed") self.assertIn("Duplicate JSON key", view["error"]) + # Takeover guard + + def test_solo_begin_joins_instead_of_taking_over_a_recently_active_session(self): + with patch.dict(os.environ, {"SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES": "30"}): + self.call("begin", "--session", "one") + self.plan() + self.move() + result = self.call("begin", "--session", "two")["result"] + self.assertNotIn("superseded", result) + self.assertIn("one was active 0 min ago", result["warnings"][-1]) + state = self.state() + self.assertEqual(state["sessions"]["one"]["status"], "active") + self.assertTrue(state["sessions"]["one"]["parallel"] and state["sessions"]["two"]["parallel"]) + self.assertEqual(r.phase(state, "A")["owner"], "one") + + def test_solo_begin_still_takes_over_an_idle_session(self): + self.call("begin", "--session", "one") + self.plan(); self.move() + result = self.call("begin", "--session", "two")["result"] + self.assertEqual(result["superseded"][0]["transferred"], ["A"]) + + def test_open_delegations_block_takeover_even_when_idle(self): + self.allow_providers() + self.call("begin", "--session", "one") + packet = self.base / "packet.md" + packet.write_text("Ask\n") + self.call("delegate", "start", "--session", "one", "--name", "q", "--provider", "codex", "--mode", "consult", + "--packet", str(packet), "--host-ref", "t3:1") + result = self.call("begin", "--session", "two")["result"] + self.assertNotIn("superseded", result) + self.assertIn("one has open delegations", result["warnings"][-1]) + + def test_provider_policy_is_parsed_from_the_marked_table(self): + self.assertIsNone(r.provider_policy(self.store)) + self.allow_providers("| `Codex` | consult; review | gpt-6.1-sol, gpt-6-luna |\n| gemini | none | - |\n" + "| claude | orchestrate, write | any |\n") + policy = r.provider_policy(self.store)["providers"] + self.assertEqual(policy["codex"], {"modes": ["consult", "review"], "orchestrate": False, + "models": ["gpt-6.1-sol", "gpt-6-luna"]}) + self.assertEqual(policy["gemini"]["modes"], []) + self.assertTrue(policy["claude"]["orchestrate"]) + (self.root / "PROJECT.md").write_text("\n\n" + "\n") + with self.assertRaises(r.Problem): + r.provider_policy(self.store) + + def test_status_flags_a_missing_provider_policy_and_delegation_waits_for_it(self): + self.setup_sessions() + self.assertEqual(self.call("status")["result"]["provider_policy"], "missing") + packet = self.base / "packet.md" + packet.write_text("Ask\n") + start = ("delegate", "start", "--session", "one", "--name", "q", "--provider", "codex", "--mode", "consult", + "--packet", str(packet), "--host-ref", "t3:1") + self.assertEqual(self.call(*start, code=3)["error"]["code"], "providers_unset") + self.allow_providers("| codex | consult | gpt-6.1-sol |\n") + self.assertNotIn("provider_policy", self.call("status")["result"]) + self.assertEqual(self.call(*start, code=3)["error"]["code"], "provider_not_allowed") + self.call(*start, "--model", "gpt-6.1-sol") + self.assertEqual(r.relay_load(self.store, "q")["model"], "gpt-6.1-sol") + + def test_newer_state_tells_an_outdated_copy_how_to_update(self): + self.setup_sessions() + value = json.loads(self.store.path.read_text()) + value["schema_version"] = 99 + self.store.path.write_text(json.dumps(value)) + error = self.call("status", code=2)["error"] + self.assertEqual(error["code"], "version") + self.assertIn("newer estafeta", error["message"]) + self.assertIn("install --all-detected", error["message"]) + value["schema_version"] = 2 + self.store.path.write_text(json.dumps(value)) + # Snapshots and startup def test_snapshot_is_read_only_and_tracks_dirty_and_staged_content(self): @@ -1004,8 +1116,7 @@ def test_schema_1_state_is_upgraded_once_with_a_backup(self): # Processes and safety def test_stopping_a_runtime_stops_its_descendants(self): - if os.name == "nt": - self.skipTest("Process groups are POSIX-only") + # POSIX process groups and Windows job objects must both reach a grandchild. self.setup_sessions("one") pidfile = self.base / "grandchild.pid" script = ("import subprocess, sys\n" @@ -1021,11 +1132,9 @@ def test_stopping_a_runtime_stops_its_descendants(self): deadline = time.monotonic() + 5 alive = True while alive and time.monotonic() < deadline: - try: - os.kill(grandchild, 0) + alive = pid_alive(grandchild) + if alive: time.sleep(0.05) - except ProcessLookupError: - alive = False self.assertFalse(alive, "descendant survived runtime stop") def test_owned_processes_are_scoped_to_session(self):