From ca43e09eca1d9a0384043a0ae42b5d02c1d21c63 Mon Sep 17 00:00:00 2001 From: Pavlo Bieliaiev <25230576+pashbel@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:32:48 +0200 Subject: [PATCH 1/5] Plan 3.1.1 relay hardening; run CI on Windows and once per push Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 +- PROJECT.md | 2 +- docs/plans/relay-hardening.md | 79 +++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 docs/plans/relay-hardening.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2b4683..ab45ee6 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 diff --git a/PROJECT.md b/PROJECT.md index 1c66f96..6582de1 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. diff --git a/docs/plans/relay-hardening.md b/docs/plans/relay-hardening.md new file mode 100644 index 0000000..66ea659 --- /dev/null +++ b/docs/plans/relay-hardening.md @@ -0,0 +1,79 @@ +# Relay hardening plan (3.1.1) + +- 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 | + +## 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 update returns + APPROVE, or each finding is fixed with a test and the fix delta is + re-reviewed. +- Checks: `live-review-3`, plus the suites. + +### R5: Release 3.1.1 + +- Scope: versions, README limits, VALIDATION.md, handover, PR. +- Acceptance: 3.1.1 is consistent everywhere, and the "Not verified" list + holds only items with a concrete reason. +- Checks: `pack-check`, `unit-workflow`, `unit-session`, `golden-relay`. From e8d5769290a27360fd7041a70292c026eb56e884 Mon Sep 17 00:00:00 2001 From: Pavlo Bieliaiev <25230576+pashbel@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:39:40 +0200 Subject: [PATCH 2/5] Fix Windows fingerprints and worker stop; verify Codex CLI live; diagnose failed CLI workers - Windows: keep the index file mode for changed files (os.access reports every file executable), so commits no longer stale DONE evidence; stop workers with CTRL_BREAK before taskkill so a running check can record itself. - collect reports a CLI worker's exit code and stderr tail when no result block exists (seen live: gemini without login exits 41). - scripts/golden/live_cli.py replays consult/write golden paths with a real provider CLI; codex exec 0.162.1 passed both live. - Adapters: Codex must always pass --sandbox (user config may default to danger-full-access); Gemini lowers approval mode in untrusted folders. - CI runs every suite even after a failure. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 + .../shared/runtime/session.py | 31 ++- .../skills/agent-relay/references/adapters.md | 40 +++- .../skills/agent-relay/scripts/session.py | 31 ++- .../plan-and-orchestrate/scripts/session.py | 31 ++- .../skills/session-end/scripts/session.py | 31 ++- .../skills/session-guide/scripts/session.py | 31 ++- .../session-handover/scripts/session.py | 31 ++- .../skills/session-start/scripts/session.py | 31 ++- scripts/golden/live_cli.py | 192 ++++++++++++++++++ scripts/golden/relay-scenarios.json | 85 ++++++++ scripts/golden/stub_worker.py | 6 +- scripts/test_relay.py | 10 +- 13 files changed, 518 insertions(+), 35 deletions(-) create mode 100644 scripts/golden/live_cli.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab45ee6..c1b32c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,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/packs/session-workflow/shared/runtime/session.py b/packs/session-workflow/shared/runtime/session.py index 849f465..1fabd9c 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"]): @@ -1038,6 +1043,14 @@ def group_alive(pgid): def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": + # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned), then force the remaining tree. + if child.poll() is None: + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + child.wait(timeout=grace) + except (OSError, subprocess.TimeoutExpired): + pass if child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return @@ -1068,7 +1081,7 @@ class Interrupted(Exception): @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 @@ -1662,7 +1675,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: diff --git a/packs/session-workflow/skills/agent-relay/references/adapters.md b/packs/session-workflow/skills/agent-relay/references/adapters.md index e2c5d90..6c9d04a 100644 --- a/packs/session-workflow/skills/agent-relay/references/adapters.md +++ b/packs/session-workflow/skills/agent-relay/references/adapters.md @@ -14,7 +14,7 @@ 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 | ## Host-native tasks @@ -53,16 +53,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 +118,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/scripts/session.py b/packs/session-workflow/skills/agent-relay/scripts/session.py index 849f465..1fabd9c 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"]): @@ -1038,6 +1043,14 @@ def group_alive(pgid): def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": + # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned), then force the remaining tree. + if child.poll() is None: + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + child.wait(timeout=grace) + except (OSError, subprocess.TimeoutExpired): + pass if child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return @@ -1068,7 +1081,7 @@ class Interrupted(Exception): @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 @@ -1662,7 +1675,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: 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..1fabd9c 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"]): @@ -1038,6 +1043,14 @@ def group_alive(pgid): def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": + # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned), then force the remaining tree. + if child.poll() is None: + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + child.wait(timeout=grace) + except (OSError, subprocess.TimeoutExpired): + pass if child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return @@ -1068,7 +1081,7 @@ class Interrupted(Exception): @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 @@ -1662,7 +1675,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: diff --git a/packs/session-workflow/skills/session-end/scripts/session.py b/packs/session-workflow/skills/session-end/scripts/session.py index 849f465..1fabd9c 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"]): @@ -1038,6 +1043,14 @@ def group_alive(pgid): def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": + # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned), then force the remaining tree. + if child.poll() is None: + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + child.wait(timeout=grace) + except (OSError, subprocess.TimeoutExpired): + pass if child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return @@ -1068,7 +1081,7 @@ class Interrupted(Exception): @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 @@ -1662,7 +1675,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: diff --git a/packs/session-workflow/skills/session-guide/scripts/session.py b/packs/session-workflow/skills/session-guide/scripts/session.py index 849f465..1fabd9c 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"]): @@ -1038,6 +1043,14 @@ def group_alive(pgid): def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": + # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned), then force the remaining tree. + if child.poll() is None: + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + child.wait(timeout=grace) + except (OSError, subprocess.TimeoutExpired): + pass if child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return @@ -1068,7 +1081,7 @@ class Interrupted(Exception): @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 @@ -1662,7 +1675,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: diff --git a/packs/session-workflow/skills/session-handover/scripts/session.py b/packs/session-workflow/skills/session-handover/scripts/session.py index 849f465..1fabd9c 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"]): @@ -1038,6 +1043,14 @@ def group_alive(pgid): def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": + # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned), then force the remaining tree. + if child.poll() is None: + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + child.wait(timeout=grace) + except (OSError, subprocess.TimeoutExpired): + pass if child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return @@ -1068,7 +1081,7 @@ class Interrupted(Exception): @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 @@ -1662,7 +1675,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: diff --git a/packs/session-workflow/skills/session-start/scripts/session.py b/packs/session-workflow/skills/session-start/scripts/session.py index 849f465..1fabd9c 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"]): @@ -1038,6 +1043,14 @@ def group_alive(pgid): def terminate_group(child, grace=4.0): """Stop the direct child and every descendant in the process group it leads.""" if os.name == "nt": + # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records + # itself as abandoned), then force the remaining tree. + if child.poll() is None: + try: + os.kill(child.pid, signal.CTRL_BREAK_EVENT) + child.wait(timeout=grace) + except (OSError, subprocess.TimeoutExpired): + pass if child.poll() is None: subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True) return @@ -1068,7 +1081,7 @@ class Interrupted(Exception): @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 @@ -1662,7 +1675,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: diff --git a/scripts/golden/live_cli.py b/scripts/golden/live_cli.py new file mode 100644 index 0000000..ae94577 --- /dev/null +++ b/scripts/golden/live_cli.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Play the consult-cli and write-worktree golden paths live with a provider CLI. + +This is the paid, manual half of the golden path tests: a real model replaces +the stub worker. The script builds a disposable fixture, delegates through +the runtime's `delegate` command with the agent-relay adapter for the chosen +provider, integrates the writer's change as the orchestrator would, and then +checks the recorded state with `test_relay.py --live-verify`. It never uses +sandbox-bypass flags. Run it only when the user authorized the cost. + + python3 scripts/golden/live_cli.py --provider codex --model gpt-6.1-sol +""" +import argparse +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time + +HERE = Path(__file__).resolve().parent +RUNTIME = HERE.parents[1] / "packs/session-workflow/skills/agent-relay/scripts/session.py" +RUNNER = HERE.parent / "test_relay.py" +SANDBOX = json.dumps({"sandbox": {"enabled": True, "failIfUnavailable": True, "allowUnsandboxedCommands": False, + "filesystem": {"allowWrite": ["{state_dir}"]}}}) +# Adapter commands from skills/agent-relay/references/adapters.md; MODEL is replaced below. +PRESETS = { + "codex": { + "consult": ["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}"], + }, + "claude": { + "consult": ["claude", "-p", "{packet_text}", "--permission-mode", "dontAsk", "--tools", "Read,Grep,Glob", + "--output-format", "json", "--model", "MODEL", "--max-budget-usd", "1"], + "write": ["claude", "-p", "{packet_text}", "--permission-mode", "acceptEdits", "--settings", SANDBOX, + "--output-format", "json", "--model", "MODEL", "--max-budget-usd", "3"], + }, + "gemini": { + "consult": ["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}"], + }, +} +REPLY_RULES = """## Reply + +End your final message with exactly one block, valid JSON inside: + +```text +<<>> +``` +""" + + +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') + 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/relay-scenarios.json b/scripts/golden/relay-scenarios.json index a7b172c..4dd889a 100644 --- a/scripts/golden/relay-scenarios.json +++ b/scripts/golden/relay-scenarios.json @@ -1388,6 +1388,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", diff --git a/scripts/golden/stub_worker.py b/scripts/golden/stub_worker.py index ad5527f..b472744 100644 --- a/scripts/golden/stub_worker.py +++ b/scripts/golden/stub_worker.py @@ -69,7 +69,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 +78,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..63e9106 100644 --- a/scripts/test_relay.py +++ b/scripts/test_relay.py @@ -129,14 +129,20 @@ def step(self, step): raise StepFailed("delegation still running at the wait timeout") if action == "no_process": 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 '*{step['pattern']}*' }} | ForEach-Object {{ $_.ProcessId }}"] + else: + query = ["pgrep", "-f", step["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) From bc71dd442f2670de1a3b4c155a5df5fcc3e5e092 Mon Sep 17 00:00:00 2001 From: Pavlo Bieliaiev <25230576+pashbel@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:48:01 +0200 Subject: [PATCH 3/5] Supervise Windows workers with a job object On Windows the supervisor tracked only the leader, so a timed-out worker counted as exited while its check runner was still recording, leaving the check 'running' and its lock held. A job object now plays the role of the POSIX process group: the supervisor waits for every descendant, sends CTRL_BREAK first and terminates the job after the grace period. The runtime descendant test now runs on Windows too, with a non-signalling PID probe. Co-Authored-By: Claude Opus 5.5 --- packs/session-workflow/VALIDATION.md | 29 +++++++ .../shared/runtime/session.py | 84 ++++++++++++++++--- .../skills/agent-relay/scripts/session.py | 84 ++++++++++++++++--- .../plan-and-orchestrate/scripts/session.py | 84 ++++++++++++++++--- .../skills/session-end/scripts/session.py | 84 ++++++++++++++++--- .../skills/session-guide/scripts/session.py | 84 ++++++++++++++++--- .../session-handover/scripts/session.py | 84 ++++++++++++++++--- .../skills/session-start/scripts/session.py | 84 ++++++++++++++++--- scripts/test_session.py | 31 +++++-- 9 files changed, 565 insertions(+), 83 deletions(-) diff --git a/packs/session-workflow/VALIDATION.md b/packs/session-workflow/VALIDATION.md index a1d88df..bb42d03 100644 --- a/packs/session-workflow/VALIDATION.md +++ b/packs/session-workflow/VALIDATION.md @@ -1,5 +1,34 @@ # 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. + +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/runtime/session.py b/packs/session-workflow/shared/runtime/session.py index 1fabd9c..1e13d88 100644 --- a/packs/session-workflow/shared/runtime/session.py +++ b/packs/session-workflow/shared/runtime/session.py @@ -1040,18 +1040,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": - # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records - # itself as abandoned), then force the remaining tree. - 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) - child.wait(timeout=grace) - except (OSError, subprocess.TimeoutExpired): + except OSError: pass - if child.poll() is None: + 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: @@ -1190,18 +1240,30 @@ def supervise(spec_path): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 started, limit, timed_out = time.monotonic(), spec.get("timeout"), False - # Descendants keep the group alive after the leader exits; supervise until all are gone. - while child.poll() is None or group_alive(child.pid): + 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)) + 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)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) diff --git a/packs/session-workflow/skills/agent-relay/scripts/session.py b/packs/session-workflow/skills/agent-relay/scripts/session.py index 1fabd9c..1e13d88 100644 --- a/packs/session-workflow/skills/agent-relay/scripts/session.py +++ b/packs/session-workflow/skills/agent-relay/scripts/session.py @@ -1040,18 +1040,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": - # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records - # itself as abandoned), then force the remaining tree. - 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) - child.wait(timeout=grace) - except (OSError, subprocess.TimeoutExpired): + except OSError: pass - if child.poll() is None: + 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: @@ -1190,18 +1240,30 @@ def supervise(spec_path): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 started, limit, timed_out = time.monotonic(), spec.get("timeout"), False - # Descendants keep the group alive after the leader exits; supervise until all are gone. - while child.poll() is None or group_alive(child.pid): + 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)) + 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)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) 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 1fabd9c..1e13d88 100644 --- a/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py +++ b/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py @@ -1040,18 +1040,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": - # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records - # itself as abandoned), then force the remaining tree. - 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) - child.wait(timeout=grace) - except (OSError, subprocess.TimeoutExpired): + except OSError: pass - if child.poll() is None: + 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: @@ -1190,18 +1240,30 @@ def supervise(spec_path): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 started, limit, timed_out = time.monotonic(), spec.get("timeout"), False - # Descendants keep the group alive after the leader exits; supervise until all are gone. - while child.poll() is None or group_alive(child.pid): + 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)) + 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)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) diff --git a/packs/session-workflow/skills/session-end/scripts/session.py b/packs/session-workflow/skills/session-end/scripts/session.py index 1fabd9c..1e13d88 100644 --- a/packs/session-workflow/skills/session-end/scripts/session.py +++ b/packs/session-workflow/skills/session-end/scripts/session.py @@ -1040,18 +1040,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": - # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records - # itself as abandoned), then force the remaining tree. - 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) - child.wait(timeout=grace) - except (OSError, subprocess.TimeoutExpired): + except OSError: pass - if child.poll() is None: + 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: @@ -1190,18 +1240,30 @@ def supervise(spec_path): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 started, limit, timed_out = time.monotonic(), spec.get("timeout"), False - # Descendants keep the group alive after the leader exits; supervise until all are gone. - while child.poll() is None or group_alive(child.pid): + 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)) + 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)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) diff --git a/packs/session-workflow/skills/session-guide/scripts/session.py b/packs/session-workflow/skills/session-guide/scripts/session.py index 1fabd9c..1e13d88 100644 --- a/packs/session-workflow/skills/session-guide/scripts/session.py +++ b/packs/session-workflow/skills/session-guide/scripts/session.py @@ -1040,18 +1040,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": - # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records - # itself as abandoned), then force the remaining tree. - 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) - child.wait(timeout=grace) - except (OSError, subprocess.TimeoutExpired): + except OSError: pass - if child.poll() is None: + 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: @@ -1190,18 +1240,30 @@ def supervise(spec_path): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 started, limit, timed_out = time.monotonic(), spec.get("timeout"), False - # Descendants keep the group alive after the leader exits; supervise until all are gone. - while child.poll() is None or group_alive(child.pid): + 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)) + 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)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) diff --git a/packs/session-workflow/skills/session-handover/scripts/session.py b/packs/session-workflow/skills/session-handover/scripts/session.py index 1fabd9c..1e13d88 100644 --- a/packs/session-workflow/skills/session-handover/scripts/session.py +++ b/packs/session-workflow/skills/session-handover/scripts/session.py @@ -1040,18 +1040,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": - # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records - # itself as abandoned), then force the remaining tree. - 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) - child.wait(timeout=grace) - except (OSError, subprocess.TimeoutExpired): + except OSError: pass - if child.poll() is None: + 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: @@ -1190,18 +1240,30 @@ def supervise(spec_path): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 started, limit, timed_out = time.monotonic(), spec.get("timeout"), False - # Descendants keep the group alive after the leader exits; supervise until all are gone. - while child.poll() is None or group_alive(child.pid): + 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)) + 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)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) diff --git a/packs/session-workflow/skills/session-start/scripts/session.py b/packs/session-workflow/skills/session-start/scripts/session.py index 1fabd9c..1e13d88 100644 --- a/packs/session-workflow/skills/session-start/scripts/session.py +++ b/packs/session-workflow/skills/session-start/scripts/session.py @@ -1040,18 +1040,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": - # Ask the group to stop first (CTRL_BREAK reaches every process in it, and a running check records - # itself as abandoned), then force the remaining tree. - 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) - child.wait(timeout=grace) - except (OSError, subprocess.TimeoutExpired): + except OSError: pass - if child.poll() is None: + 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: @@ -1190,18 +1240,30 @@ def supervise(spec_path): stdin=subprocess.DEVNULL, shell=False, **group_kwargs()) stop, beat = directory / "stop.json", 0.0 started, limit, timed_out = time.monotonic(), spec.get("timeout"), False - # Descendants keep the group alive after the leader exits; supervise until all are gone. - while child.poll() is None or group_alive(child.pid): + 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)) + 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)) + terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break time.sleep(0.2) diff --git a/scripts/test_session.py b/scripts/test_session.py index 959811e..c35906e 100644 --- a/scripts/test_session.py +++ b/scripts/test_session.py @@ -20,6 +20,28 @@ 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-") @@ -1004,8 +1026,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 +1042,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): From c7986bcbaba042e1358f397984f78573661c6aa9 Mon Sep 17 00:00:00 2001 From: Pavlo Bieliaiev <25230576+pashbel@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:18:40 +0200 Subject: [PATCH 4/5] Guard live sessions from solo takeover; stop nested checks by request file - A solo begin no longer supersedes a session in the same checkout that acted within SESSION_WORKFLOW_TAKEOVER_IDLE_MINUTES (default 30) or has open delegations; it joins in parallel and warns. Observed live: a separate T3 thread's session-start took over the orchestrator and its claims. - Supervisors pass SESSION_WORKFLOW_STOP_FILE to workers and write it before any signal, so a worker's check run stops its own check and records it abandoned even where signals cannot reach it (Windows without a console). - The golden stub reports blocked when a required check does not pass, as the packet asks; no_process patterns are unique per run. - Plan extended to 3.2.0 (provider policy, orchestrator hosts). Co-Authored-By: Claude Opus 5.5 --- docs/USAGE.md | 10 ++- docs/plans/relay-hardening.md | 48 ++++++++-- packs/session-workflow/shared/runtime.md | 16 +++- .../shared/runtime/session.py | 87 ++++++++++++++++++- .../shared/workflow-contract.md | 4 +- .../skills/agent-relay/references/runtime.md | 16 +++- .../references/workflow-contract.md | 4 +- .../skills/agent-relay/scripts/session.py | 87 ++++++++++++++++++- .../references/runtime.md | 16 +++- .../references/workflow-contract.md | 4 +- .../plan-and-orchestrate/scripts/session.py | 87 ++++++++++++++++++- .../skills/session-end/references/runtime.md | 16 +++- .../references/workflow-contract.md | 4 +- .../skills/session-end/scripts/session.py | 87 ++++++++++++++++++- .../session-guide/references/runtime.md | 16 +++- .../references/workflow-contract.md | 4 +- .../skills/session-guide/scripts/session.py | 87 ++++++++++++++++++- .../session-handover/references/runtime.md | 16 +++- .../references/workflow-contract.md | 4 +- .../session-handover/scripts/session.py | 87 ++++++++++++++++++- .../session-start/references/runtime.md | 16 +++- .../references/workflow-contract.md | 4 +- .../skills/session-start/scripts/session.py | 87 ++++++++++++++++++- scripts/golden/relay-scenarios.json | 4 +- scripts/golden/stub_worker.py | 5 ++ scripts/test_relay.py | 7 +- scripts/test_session.py | 51 +++++++++-- 27 files changed, 813 insertions(+), 61 deletions(-) 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 index 66ea659..b78b173 100644 --- a/docs/plans/relay-hardening.md +++ b/docs/plans/relay-hardening.md @@ -1,4 +1,4 @@ -# Relay hardening plan (3.1.1) +# Relay hardening and provider policy plan (3.2.0) - Context: [PROJECT.md](../../PROJECT.md); previous workstream: [multi-provider](multi-provider.md). @@ -32,6 +32,9 @@ | 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 @@ -66,14 +69,47 @@ ### R4: Review round 3 - Scope: whatever its findings touch. -- Acceptance: a Codex review of the round 2 fixes and this update returns - APPROVE, or each finding is fixed with a test and the fix delta is - re-reviewed. +- 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. -### R5: Release 3.1.1 +### 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.1.1 is consistent everywhere, and the "Not verified" list +- 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`. diff --git a/packs/session-workflow/shared/runtime.md b/packs/session-workflow/shared/runtime.md index 0b95d32..3d7cf31 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 diff --git a/packs/session-workflow/shared/runtime/session.py b/packs/session-workflow/shared/runtime/session.py index 1e13d88..d39e546 100644 --- a/packs/session-workflow/shared/runtime/session.py +++ b/packs/session-workflow/shared/runtime/session.py @@ -872,6 +872,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) @@ -887,6 +923,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 @@ -914,7 +958,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 " @@ -1128,6 +1177,26 @@ 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.""" @@ -1155,7 +1224,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) @@ -1225,6 +1294,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() @@ -1233,11 +1310,13 @@ 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 job = None @@ -1260,9 +1339,11 @@ def running(): "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"]: + 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: + request_stop(stop_request, running, spec.get("grace", 4.0)) terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break 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/references/runtime.md b/packs/session-workflow/skills/agent-relay/references/runtime.md index 0b95d32..3d7cf31 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 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 1e13d88..d39e546 100644 --- a/packs/session-workflow/skills/agent-relay/scripts/session.py +++ b/packs/session-workflow/skills/agent-relay/scripts/session.py @@ -872,6 +872,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) @@ -887,6 +923,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 @@ -914,7 +958,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 " @@ -1128,6 +1177,26 @@ 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.""" @@ -1155,7 +1224,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) @@ -1225,6 +1294,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() @@ -1233,11 +1310,13 @@ 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 job = None @@ -1260,9 +1339,11 @@ def running(): "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"]: + 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: + request_stop(stop_request, running, spec.get("grace", 4.0)) terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break 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..3d7cf31 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 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 1e13d88..d39e546 100644 --- a/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py +++ b/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py @@ -872,6 +872,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) @@ -887,6 +923,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 @@ -914,7 +958,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 " @@ -1128,6 +1177,26 @@ 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.""" @@ -1155,7 +1224,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) @@ -1225,6 +1294,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() @@ -1233,11 +1310,13 @@ 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 job = None @@ -1260,9 +1339,11 @@ def running(): "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"]: + 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: + request_stop(stop_request, running, spec.get("grace", 4.0)) terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break diff --git a/packs/session-workflow/skills/session-end/references/runtime.md b/packs/session-workflow/skills/session-end/references/runtime.md index 0b95d32..3d7cf31 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 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 1e13d88..d39e546 100644 --- a/packs/session-workflow/skills/session-end/scripts/session.py +++ b/packs/session-workflow/skills/session-end/scripts/session.py @@ -872,6 +872,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) @@ -887,6 +923,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 @@ -914,7 +958,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 " @@ -1128,6 +1177,26 @@ 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.""" @@ -1155,7 +1224,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) @@ -1225,6 +1294,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() @@ -1233,11 +1310,13 @@ 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 job = None @@ -1260,9 +1339,11 @@ def running(): "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"]: + 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: + request_stop(stop_request, running, spec.get("grace", 4.0)) terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break diff --git a/packs/session-workflow/skills/session-guide/references/runtime.md b/packs/session-workflow/skills/session-guide/references/runtime.md index 0b95d32..3d7cf31 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 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 1e13d88..d39e546 100644 --- a/packs/session-workflow/skills/session-guide/scripts/session.py +++ b/packs/session-workflow/skills/session-guide/scripts/session.py @@ -872,6 +872,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) @@ -887,6 +923,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 @@ -914,7 +958,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 " @@ -1128,6 +1177,26 @@ 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.""" @@ -1155,7 +1224,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) @@ -1225,6 +1294,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() @@ -1233,11 +1310,13 @@ 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 job = None @@ -1260,9 +1339,11 @@ def running(): "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"]: + 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: + request_stop(stop_request, running, spec.get("grace", 4.0)) terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break diff --git a/packs/session-workflow/skills/session-handover/references/runtime.md b/packs/session-workflow/skills/session-handover/references/runtime.md index 0b95d32..3d7cf31 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 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 1e13d88..d39e546 100644 --- a/packs/session-workflow/skills/session-handover/scripts/session.py +++ b/packs/session-workflow/skills/session-handover/scripts/session.py @@ -872,6 +872,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) @@ -887,6 +923,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 @@ -914,7 +958,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 " @@ -1128,6 +1177,26 @@ 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.""" @@ -1155,7 +1224,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) @@ -1225,6 +1294,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() @@ -1233,11 +1310,13 @@ 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 job = None @@ -1260,9 +1339,11 @@ def running(): "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"]: + 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: + request_stop(stop_request, running, spec.get("grace", 4.0)) terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break diff --git a/packs/session-workflow/skills/session-start/references/runtime.md b/packs/session-workflow/skills/session-start/references/runtime.md index 0b95d32..3d7cf31 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 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 1e13d88..d39e546 100644 --- a/packs/session-workflow/skills/session-start/scripts/session.py +++ b/packs/session-workflow/skills/session-start/scripts/session.py @@ -872,6 +872,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) @@ -887,6 +923,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 @@ -914,7 +958,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 " @@ -1128,6 +1177,26 @@ 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.""" @@ -1155,7 +1224,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) @@ -1225,6 +1294,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() @@ -1233,11 +1310,13 @@ 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 job = None @@ -1260,9 +1339,11 @@ def running(): "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"]: + 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: + request_stop(stop_request, running, spec.get("grace", 4.0)) terminate_group(child, spec.get("grace", 4.0), job) timed_out = True break diff --git a/scripts/golden/relay-scenarios.json b/scripts/golden/relay-scenarios.json index 4dd889a..228099c 100644 --- a/scripts/golden/relay-scenarios.json +++ b/scripts/golden/relay-scenarios.json @@ -1604,7 +1604,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}" ] } }, @@ -1671,7 +1671,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 b472744..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), diff --git a/scripts/test_relay.py b/scripts/test_relay.py index 63e9106..6283d19 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 @@ -128,13 +129,15 @@ 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 '*{step['pattern']}*' }} | ForEach-Object {{ $_.ProcessId }}"] + f"-like '*{pattern}*' }} | ForEach-Object {{ $_.ProcessId }}"] else: - query = ["pgrep", "-f", step["pattern"]] + query = ["pgrep", "-f", re.escape(pattern)] while True: # a stopped process may need a moment to be reaped done = subprocess.run(query, capture_output=True, text=True) if not done.stdout.strip() or time.monotonic() > deadline: diff --git a/scripts/test_session.py b/scripts/test_session.py index c35906e..7bc4707 100644 --- a/scripts/test_session.py +++ b/scripts/test_session.py @@ -48,7 +48,9 @@ def setUp(self): 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() @@ -226,17 +228,21 @@ 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.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 = '<<>>' @@ -297,6 +303,37 @@ 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.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]) + # Snapshots and startup def test_snapshot_is_read_only_and_tracks_dirty_and_staged_content(self): From fcb69fb14caebc5a185c280d4c9f852038c6a5e8 Mon Sep 17 00:00:00 2001 From: Pavlo Bieliaiev <25230576+pashbel@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:38:10 +0200 Subject: [PATCH 5/5] Ask which AI providers a project allows on first use; enforce it in delegate - PROJECT.md gets a marked 'AI providers' table (provider, allowed modes, models). session-start asks the user once when status reports provider_policy: missing and records the answer; the template has the section. - delegate start refuses without the table (providers_unset) and refuses any provider, mode or model outside it (provider_not_allowed); delegate providers shows the policy. Account-level IDs such as claude-pricefx are supported. - Golden scenario provider-policy plays the first-use path end to end. - This project's table: all available providers except the Claude (Pricefx) work account (user, 2026-10-15). Co-Authored-By: Claude Opus 5.5 --- PROJECT.md | 17 ++ packs/session-workflow/shared/quickstart.md | 2 + packs/session-workflow/shared/runtime.md | 11 +- .../shared/runtime/session.py | 57 +++- .../skills/agent-relay/SKILL.md | 12 +- .../skills/agent-relay/references/adapters.md | 8 + .../agent-relay/references/quickstart.md | 2 + .../skills/agent-relay/references/runtime.md | 11 +- .../skills/agent-relay/scripts/session.py | 57 +++- .../references/quickstart.md | 2 + .../references/runtime.md | 11 +- .../plan-and-orchestrate/scripts/session.py | 57 +++- .../session-end/references/quickstart.md | 2 + .../skills/session-end/references/runtime.md | 11 +- .../skills/session-end/scripts/session.py | 57 +++- .../skills/session-guide/SKILL.md | 3 + .../session-guide/references/quickstart.md | 2 + .../session-guide/references/runtime.md | 11 +- .../skills/session-guide/scripts/session.py | 57 +++- .../session-handover/references/quickstart.md | 2 + .../session-handover/references/runtime.md | 11 +- .../session-handover/scripts/session.py | 57 +++- .../skills/session-start/SKILL.md | 14 +- .../session-start/references/quickstart.md | 2 + .../session-start/references/runtime.md | 11 +- .../skills/session-start/scripts/session.py | 57 +++- .../templates/project-context.md | 13 + scripts/golden/live_cli.py | 5 + scripts/golden/relay-scenarios.json | 283 ++++++++++++++++++ scripts/test_relay.py | 15 + scripts/test_session.py | 41 +++ 31 files changed, 882 insertions(+), 19 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 6582de1..edf54f0 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -19,6 +19,23 @@ Do not publish credentials, local runtime state, or personal logs. - 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. +## 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 Python 3.9+ and Git are required. 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 3d7cf31..032e7db 100644 --- a/packs/session-workflow/shared/runtime.md +++ b/packs/session-workflow/shared/runtime.md @@ -128,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 d39e546..078db33 100644 --- a/packs/session-workflow/shared/runtime/session.py +++ b/packs/session-workflow/shared/runtime/session.py @@ -786,6 +786,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"], @@ -1470,6 +1475,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: @@ -1496,6 +1545,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(): @@ -2318,7 +2368,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/agent-relay/SKILL.md b/packs/session-workflow/skills/agent-relay/SKILL.md index c2cfe72..a5d0a9b 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); diff --git a/packs/session-workflow/skills/agent-relay/references/adapters.md b/packs/session-workflow/skills/agent-relay/references/adapters.md index 6c9d04a..a1628b7 100644 --- a/packs/session-workflow/skills/agent-relay/references/adapters.md +++ b/packs/session-workflow/skills/agent-relay/references/adapters.md @@ -16,6 +16,14 @@ user explicitly asks for it for this delegation. | Claude Code | `--dangerously-skip-permissions`, `--allow-dangerously-skip-permissions`, `--permission-mode bypassPermissions` | | 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 Prefer these when the host offers them: the host owns authentication, 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 3d7cf31..032e7db 100644 --- a/packs/session-workflow/skills/agent-relay/references/runtime.md +++ b/packs/session-workflow/skills/agent-relay/references/runtime.md @@ -128,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/scripts/session.py b/packs/session-workflow/skills/agent-relay/scripts/session.py index d39e546..078db33 100644 --- a/packs/session-workflow/skills/agent-relay/scripts/session.py +++ b/packs/session-workflow/skills/agent-relay/scripts/session.py @@ -786,6 +786,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"], @@ -1470,6 +1475,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: @@ -1496,6 +1545,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(): @@ -2318,7 +2368,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 3d7cf31..032e7db 100644 --- a/packs/session-workflow/skills/plan-and-orchestrate/references/runtime.md +++ b/packs/session-workflow/skills/plan-and-orchestrate/references/runtime.md @@ -128,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/scripts/session.py b/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py index d39e546..078db33 100644 --- a/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py +++ b/packs/session-workflow/skills/plan-and-orchestrate/scripts/session.py @@ -786,6 +786,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"], @@ -1470,6 +1475,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: @@ -1496,6 +1545,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(): @@ -2318,7 +2368,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 3d7cf31..032e7db 100644 --- a/packs/session-workflow/skills/session-end/references/runtime.md +++ b/packs/session-workflow/skills/session-end/references/runtime.md @@ -128,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/scripts/session.py b/packs/session-workflow/skills/session-end/scripts/session.py index d39e546..078db33 100644 --- a/packs/session-workflow/skills/session-end/scripts/session.py +++ b/packs/session-workflow/skills/session-end/scripts/session.py @@ -786,6 +786,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"], @@ -1470,6 +1475,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: @@ -1496,6 +1545,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(): @@ -2318,7 +2368,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 3d7cf31..032e7db 100644 --- a/packs/session-workflow/skills/session-guide/references/runtime.md +++ b/packs/session-workflow/skills/session-guide/references/runtime.md @@ -128,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/scripts/session.py b/packs/session-workflow/skills/session-guide/scripts/session.py index d39e546..078db33 100644 --- a/packs/session-workflow/skills/session-guide/scripts/session.py +++ b/packs/session-workflow/skills/session-guide/scripts/session.py @@ -786,6 +786,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"], @@ -1470,6 +1475,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: @@ -1496,6 +1545,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(): @@ -2318,7 +2368,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 3d7cf31..032e7db 100644 --- a/packs/session-workflow/skills/session-handover/references/runtime.md +++ b/packs/session-workflow/skills/session-handover/references/runtime.md @@ -128,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/scripts/session.py b/packs/session-workflow/skills/session-handover/scripts/session.py index d39e546..078db33 100644 --- a/packs/session-workflow/skills/session-handover/scripts/session.py +++ b/packs/session-workflow/skills/session-handover/scripts/session.py @@ -786,6 +786,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"], @@ -1470,6 +1475,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: @@ -1496,6 +1545,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(): @@ -2318,7 +2368,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 3d7cf31..032e7db 100644 --- a/packs/session-workflow/skills/session-start/references/runtime.md +++ b/packs/session-workflow/skills/session-start/references/runtime.md @@ -128,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/scripts/session.py b/packs/session-workflow/skills/session-start/scripts/session.py index d39e546..078db33 100644 --- a/packs/session-workflow/skills/session-start/scripts/session.py +++ b/packs/session-workflow/skills/session-start/scripts/session.py @@ -786,6 +786,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"], @@ -1470,6 +1475,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: @@ -1496,6 +1545,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(): @@ -2318,7 +2368,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 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, {} diff --git a/scripts/golden/relay-scenarios.json b/scripts/golden/relay-scenarios.json index 228099c..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.", diff --git a/scripts/test_relay.py b/scripts/test_relay.py index 6283d19..4bb42b2 100644 --- a/scripts/test_relay.py +++ b/scripts/test_relay.py @@ -25,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): @@ -105,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" @@ -158,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 7bc4707..594681a 100644 --- a/scripts/test_session.py +++ b/scripts/test_session.py @@ -102,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") @@ -148,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" @@ -183,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" @@ -196,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") @@ -217,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") @@ -230,6 +239,7 @@ def test_relay_adoption_moves_the_kept_worker_process_to_the_adopter(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") @@ -252,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(): @@ -291,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") @@ -325,6 +337,7 @@ def test_solo_begin_still_takes_over_an_idle_session(self): 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") @@ -334,6 +347,34 @@ def test_open_delegations_block_takeover_even_when_idle(self): 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") + # Snapshots and startup def test_snapshot_is_read_only_and_tracks_dirty_and_staged_content(self):