diff --git a/assets/baton/icon.png b/assets/baton/icon.png new file mode 100644 index 00000000..ad908fc9 Binary files /dev/null and b/assets/baton/icon.png differ diff --git a/external_plugins/baton/.zcode-plugin/plugin.json b/external_plugins/baton/.zcode-plugin/plugin.json new file mode 100644 index 00000000..6183015f --- /dev/null +++ b/external_plugins/baton/.zcode-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "baton", + "version": "0.4.1", + "description": "Deterministic ticket/DAG/subagent control plane over OpenSpec changes: a local Python CLI computes the task frontier and returns session actions; the main session dispatches each task packet to a subagent in an isolated git worktree, and only a sealed receipt with green verification counts as done. Bundles the baton skill and the CLI.", + "description_i18n": { + "en": "Deterministic ticket/DAG/subagent control plane over OpenSpec changes: a local Python CLI computes the task frontier and returns session actions; the main session dispatches each task packet to a subagent in an isolated git worktree, and only a sealed receipt with green verification counts as done. Bundles the baton skill and the CLI.", + "zh-CN": "OpenSpec 变更的确定性 ticket/DAG/子代理控制面:本地 Python CLI 计算任务前沿并返回会话动作;主会话把任务包派发到隔离 git worktree 中的子代理,只有验证全绿的密封回执才算完成。内置 baton 技能与控制面 CLI。" + }, + "author": { + "name": "Lumos-789", + "url": "https://github.com/Lumos-789" + }, + "homepage": "https://github.com/Lumos-789/baton", + "repository": "https://github.com/Lumos-789/baton", + "license": "MIT", + "keywords": [ + "openspec", + "subagents", + "ticket-dag", + "workflow", + "deterministic-scheduling" + ], + "skills": "skills" +} diff --git a/external_plugins/baton/CHANGELOG.md b/external_plugins/baton/CHANGELOG.md new file mode 100644 index 00000000..1563d8ae --- /dev/null +++ b/external_plugins/baton/CHANGELOG.md @@ -0,0 +1,250 @@ +# Changelog + +## 0.4.1 (2026-11-09) + +Unfreeze simplify round — documentation parity and comment cleanup only, +zero behavior change. + +- `docs/architecture.md`: the Module Dependencies graph now carries the + `session_controller.py → relay.py` edge (introduced with the relay in + 0.4.0) and a `relay.py` entry joins the explanation list, matching the + AGENTS.md module table. +- `README_CN.md`: the observability section documents the two behaviors + README.md already had — omitting `` for `build`/`scan` when the + project has exactly one change (the omission fails loud on zero or + several), and the card-click `check`/`uncheck` commands that rewrite built + boards in place. +- Dropped the two decorative divider comment blocks (`scripts/ticket_board.py`, + `scripts/orchestration/model.py`); no code path touched. + +## 0.4.0 (2026-11-06) + +Relay-safety round: an in-flight attempt can no longer be silently destroyed +by a mistaken abandon, and a late sealed receipt is no longer protocol debris. +Born from the 2026-11-06 fusion-s1 incident — a taking-over session saw "no +receipt + clean worktree" 14 minutes into a ~26-minute ticket under a 2-hour +lease, declared the subagent dead and abandoned it; the subagent sealed a +valid receipt 11 minutes later and the ticket was rebuilt from scratch. Every +item below replays that incident in tests, at its exact parameters (14 min +elapsed / 106 min remaining) and end-to-end. + +- **`status` now answers "is it alive?".** Each attempt carries a `relay` + block: `elapsedSec`, `leaseRemainingSec`, `leaseElapsedFraction`, + `receiptExists`, worktree liveness signals (`heartbeatAgeSec`, + `lastActivityAgeSec`, `dirty`, `exists`) and a `suggestedAction` (`wait`, + `wait-lease-nearly-spent`, `reconcile`, `adopt`, `decide`, + `resolve-or-abandon`, `await-expiry`, `none`). A clean worktree mid-ticket + is presented as the normal shape it is — the subagent flow is + write → verify → commit → seal — not as a death verdict. +- **`abandon` is guarded.** Retiring an attempt that is plausibly still + working (active state, no failure evidence, no sealed receipt, lease less + than 80% spent) is refused with the full judgment data; `--force` bypasses + it for an operator who has read the refusal. The hatch stays free exactly + where it is needed: `unknown`, `blocked-review`, spent/expired leases and + terminal states are never guarded. +- **`adopt ` is the reverse hatch.** An abandoned attempt whose + sealed receipt arrived late can be revived: the receipt is fully re-verified + (body digest, claim token, packet hash, identity) *before* any state + changes, then integrated through the normal path (a merge conflict still + parks in `blocked-review` with a follow-up). If a newer attempt for the + same ticket already sealed, adopt refuses and lists both evidence sets for + a human choice; if one is still in flight, adopt says so instead of + double-integrating. +- **Heartbeat.** The dispatch prompt tells the subagent to refresh a + `progress` file in the run directory (outside the worktree, so it never + dirties a seal) after each milestone; `status` reports its age. Liveness + becomes observable, not inferred. +- New read-only module `scripts/orchestration/relay.py`; `status` stays + fully read-only. Tests 179 → 196. + +## 0.3.2 (2026-11-05) + +Correctness round over the integration path, plus the leftovers of the 0.3.0 +queue extraction. Every item below was reproduced against a real git repo +before it was fixed, and each fix carries a regression test. + +- **A preserved merge conflict no longer contaminates other assignments.** + `git merge` refuses to start while a conflicted merge is preserved, and + `git diff --diff-filter=U` lists the *earlier* merge's files. The old code + read that as "this branch conflicts", so one real conflict marked every + other pending receipt `blocked-review` and emitted a second `resolve_merge` + pointing at a conflict the branch never touched — two solvers, one shared + worktree. `integrate_assignment` now returns a reason: + `conflict` (this branch, `MERGE_HEAD` is its own tip), `merge-in-progress` + (another assignment owns the worktree: nothing attempted, the attempt is + left untouched and retried later), `worktree-dirty`, or a raised + `WorktreeError` for a merge that cannot run at all. +- **A dirty integration worktree no longer wedges the run.** An uncommitted + tracked change there used to make every `actions` call raise `WorktreeError` + while the attempt sat in `integrating` until its two-hour lease expired. It + is now a `blocked` action (`integration-worktree-dirty`) naming the files and + the `abandon` escape; the attempt keeps its lease and is retried as soon as + the worktree is clean. +- **Hard merge failures no longer strand the attempt.** A merge that fails + without conflict files parks the attempt in `blocked-review` + (`failure_code=integration-failed: …`) and surfaces a `blocked` action + instead of leaving it in `integrating`. +- **`status` is genuinely read-only.** It used to create `.dsh/baton/`, the + database and revision 1, and rewrite `plan.json` on every call — and a + mistyped change-id left a runtime directory behind. `PlanStore.snapshot()` + reads the existing store (if any) and writes nothing; `revision: 0` now means + "no plan revision recorded yet". +- **`ticket_board uncheck` no longer rewrites a CRLF file.** It is the + documented `reverse` of baton's own `[x]` write-back, but it read/wrote + through universal-newline text I/O and wrote non-atomically, so one flip + turned a Windows checkout into a whole-file diff. The flip moved into + `_ticket.uncheck_task`, the exact mirror of `check_task` (same checkbox + tolerance, same `newline=""` byte-preserving `tmp` + rename write). +- **Dead code removed** after the 0.3.0 queue extraction: the resource-lease + subsystem (`attempt_resource_leases` table + index, claim-time insert loop, + four deletes, `_attempt_resources_locked`) and the `assignments` table — + neither had a producer, since the tasks.md parser never fills `resources`, + `priority`, `agent_profile`, `authority`, `successor` or `source_amendment`. + `PlanTicket` is down to the fields the parser actually produces, + `TaskPacket.resources` is gone with its only writer, and the pre-0.3.1 + "adopt verification commands from existing packets" path is removed. +- **`plan_store.py` split** (1262 → 434 lines): `store_base.py` (errors, + terminal states, atomic JSON write), `store_schema.py` (DDL + WAL retry), + `attempt_store.py` (claim/lease/bind/state/finalize mixin, importing + `store_base` only, so the dependency stays one-way). +- **Docs corrected.** `BATON_PYTHON` was documented in README, README_CN and + the skill but read by no code — removed. `plan.json` is described as what it + is (a live projection rewritten per claim; the immutable revision history is + in `queue.db`), and the lease wording admits the constructor override. +- **New drift tripwire** (`tests/test_doc_cli_contract.py`): every CLI + invocation in README / README_CN / SKILL.md / architecture.md is parsed and + checked against the real argparse tree — the verb must be registered and each + flag must belong to it. The stage-5 docs gate only checked that *paths* + exist, which is why the 0.3.0 skill could teach a nonexistent `run` verb. + Verified to fail on exactly that bug. `session_control.build_parser()` was + extracted so the test reads the real parser instead of a copy. + +Migration notes: + +- Receipts and packets are unaffected; a **packet prepared by 0.3.1** contains + the removed `resources` field, so its task-packet hash no longer matches. + An attempt mid-flight across the upgrade is reported as + `receipt-invalid: task packet hash mismatch` — `abandon` it (or let the next + `actions` retry) and it re-dispatches with a fresh packet. +- `plan.json` no longer carries an `assignments` array (it was written but + never read; live attempt state is in `queue.db`). +- `status` reports `revision: 0` until a run records its first revision. + +Regression: 179 tests pass; `./tests/regression.sh` 5/5 stages green (was 156; +the additions are the new conflict/worktree/read-only-status/doc-contract/ +CRLF cases). + +## 0.3.1 (2026-11-05) + +Five release blockers found by an extended verification round. The sealed +receipt, the lease state machine, the verification configuration and receipt +identity are now enforced by the controller instead of documented only. + +- **The sealed receipt is actually tamper-evident.** `seal` now digests the + whole envelope body (canonical JSON: sorted keys, no whitespace, + `receiptHash` excluded) into `receiptHash` and records a `receiptSegments` + table for diagnostics. `reconcile` recomputes the digest from the file + before integration and rejects edits to `verification[*].exitCode`, + `changed_files`, `commit_sha` or any other field with a non-zero exit that + names the changed segment. Receipts sealed by 0.3.0 lack the digest and are + rejected as unsealed — re-seal the worktree, or abandon the attempt. +- **An expired lease no longer deadlocks the run.** Expired attempts are + retired before a batch is planned, a retry replaces the stale attempt (which + is marked `superseded`) instead of being re-dispatched, and a superseded + attempt stops emitting `recover`. The frontier advances to later tickets. + New operator escape: `session_control.py abandon + ` retires a stuck attempt, prints exactly what it destroys + (the lease and future reconciliation) and what it preserves (worktree, + branch, commits, packet, receipt), and makes the ticket claimable again. +- **`--verify` is persisted with the run.** The first claim stores the + change-wide verification commands in the plan store; every later packet + (including ones prepared by `reconcile` without the flag) reuses them. No + commands configured is now a loud failure instead of a silent + `git diff --check` downgrade. Passing `--verify` again is an explicit + override for new packets. +- **A wrong-ticket receipt is rejected before the merge.** `reconcile` + validates the receipt's `ticket_num`, `assignment_id`, `plan_revision`, + `attempt` and packet hash against the attempt before touching the + integration branch. A mismatch leaves the attempt `unknown` (not stuck in + `integrating`) and the ticket unresolved. +- **Release hygiene.** `.npmignore` explicitly excludes `__pycache__/` and + `*.pyc` (npm no longer relies on the `.gitignore` fallback); packaging tests + assert no bytecode is tracked or packed and that the orchestration modules + still import after the queue module moved out. + +Regression: `./tests/regression.sh` — 156 passed, 5/5 stages green (the 16 that +went away were exclusive to the deleted queue/worktree/topo-sort surface). + +### Simplification pass (same unreleased version) + +- **`SKILL.md` was teaching a command that does not exist.** It documented + `run ` as the canonical verb, "no subcommand -> defaults + to `run`", and `Default session_id="baton-run"`. The real CLI has no `run` + verb, `add_subparsers(required=True)`, and `--session-id` is required. An + agent following the skill would have failed on its first call. The skill now + tables the six real verbs with their argument contracts. +- SKILL.md 351 → 229 lines: the ASCII loop diagram (duplicated in README, + architecture and AGENTS), the `tasks.md` sample (duplicated verbatim in + `docs/ticket-format.md`), the `status` response schema and the "Typical usage" + walkthrough were all second copies. Every removed section is now a pointer. +- Leases are documented: fixed **7200 s**, no renewal entry point, so a ticket + longer than two hours always returns `lease-expired` and is re-dispatched. +- ~1340 lines of dead code removed, every deletion pre-verified as zero-call: + the local resource queue that moved out in 0.3.0 left six public methods, + nine helpers and two tables behind; the pre-0.3.0 worker API in `worktree.py` + was superseded by the assignment worktrees; `model.py` still carried eight + symbols from the retired agent-profile model; and `topo_sort`, `plan_history`, + `record_assignment`, `heartbeat_execution_attempt` and `record_result_envelope` + had no callers at all. `attempt_resource_leases` (the live path) is untouched. +- `README_CN.md` caught up with 0.3.1 (receiptHash, abandon, persisted `--verify`, + plus the History and Requirements sections) and `tests/check_docs.py` now + covers README_CN, architecture and ticket-format, so the next drift fails CI. + +## 0.3.0 (2026-11-04) + +Baton is one product again: the ticket/DAG control plane. Two things that were +not part of it left the repository. + +- **The generic resource queue moved out.** `scripts/queue.py`, + `scripts/coordination/` and their five test files (3.1k lines of code, 2.9k of + tests, 45% of the Python in this repo) implemented resource mutex, capacity + and drain barriers — a separate product that the controller never imports. + It now lives in the local `baton-queue` project; this bundle no longer ships + it, and the skill no longer documents the `queue` verbs. +- **The repository's own OpenSpec change is gone.** `openspec/` held the design + change for a past release of baton itself; `docs/architecture.md` carries the + current design, so the change file only repeated it. + +## 0.2.2 (2026-11-04) + +Four CLI reliability fixes found by running the real commands. No protocol or +state-machine change: the action batch, the plan store and the sealed-receipt +contract are unchanged. + +- **`queue.py exec` no longer wedges a resource on a spawn failure.** A command + that cannot be started (typo, missing binary) used to keep its lease, so the + resource stayed held by a dead owner and every later `gate` on it waited + forever. The failure now releases the lease as `failed`; the error still + surfaces as `Error: ...` (exit 1). +- **Bad `--capacity` / empty `--session-id` are usage errors, not tracebacks.** + `actions` and `reconcile` documented capacity 1..5 as a hard cap but crashed + with a Python traceback on `0`, `6` or `""`. Both flags are validated at the + CLI boundary now: exit 2, one usage line naming the allowed range. +- **An out-of-range task id is the documented `TasksMarkdownError`.** `## 100.` + or `1.100` in tasks.md escaped as a raw `ValueError` traceback from + `session_control.py status|actions` and `ticket_board.py check`; it now reads + like every other malformed-tasks.md case (one JSON error line, exit 1). +- **The `[x]` write-back preserves the file it edits.** `check_task` keeps the + file's line endings (a CRLF tasks.md used to be rewritten to LF, turning one + checkbox into a whole-file diff) and matches exactly the task lines the + parser accepts (extra spaces inside the brackets), so a parsed ticket can + always be checked off — previously such a line parsed fine and then failed + as "task not found" after the assignment had already been integrated. + +Regression: `./tests/regression.sh` — 309 passed, 2 skipped, 5/5 stages green. + +## 0.2.1 (2026-11-03) + +The CLI is the only control surface: the local MCP adapter is retired from the +bundle (`scripts/session_control.py` + `scripts/receipt.py` are the whole +surface). diff --git a/external_plugins/baton/LICENSE b/external_plugins/baton/LICENSE new file mode 100644 index 00000000..b6c14d9c --- /dev/null +++ b/external_plugins/baton/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lumos-789 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/external_plugins/baton/README.md b/external_plugins/baton/README.md new file mode 100644 index 00000000..151bd504 --- /dev/null +++ b/external_plugins/baton/README.md @@ -0,0 +1,250 @@ +

Baton icon

+ +

Baton

+ +

Tests +Python +License: MIT +Stars

+ +> The sole ticket / DAG / session-subagent control plane over OpenSpec changes. +> A local CLI exposes deterministic session actions; the main session +> delegates each assignment with the DSH `subagent` tool, binds the child id, +> and reconciles the completion notice against a sealed receipt. + +**[Discussions](https://github.com/Lumos-789/baton/discussions)** + +## The problem it solves + +OpenSpec is strong on spec-driven decomposition (proposal -> tasks -> archive) +but does not execute. Baton turns an approved change into finished work, +deterministically: the main session never decides ticket state itself. It +forwards the deterministic `actions` returned by the Baton CLI to DSH +subagents verbatim, then reconciles each completion notice against a **sealed +receipt** (the assignment's verification commands run green in an isolated +worktree). Free-text subagent output is ignored. + +A sealed receipt is hash-bound evidence produced locally by `receipt.py` +(task-packet hash + clean, advanced HEAD + exit-0 verification results). The +whole receipt body is covered by a canonical `sha256` digest (`receiptHash`), +so an edited `exitCode`, `changed_files`, `commit_sha` or a lease claim from +another ticket is rejected before any merge. It is integrity evidence, not a +cryptographic proof against a hostile subagent (the digest has no secret). + +The change's tasks and the execution share the same granularity (one +assignment ~ one task), and three seams are bridged deterministically: + +| Seam | Baton's answer | +|---|---| +| Dependency (section-order DAG -> what to run next) | `SessionController.actions()` computes the frontier fresh every call and atomically claims distinct tasks | +| Concurrency (<=5 parallel tasks) | one isolated git worktree per assignment; integration serialized by a repository-scoped lock | +| State (subagent done -> task resolved) | a sealed receipt is the only success evidence; the controller integrates the branch, finalizes the attempt, and flips the task's `[x]` in tasks.md | + +**Baton is the conductor. `SessionController` is the sole deterministic +scheduler; the main session is a mechanical forwarder; OpenSpec is the plan +face.** + +## Install (DSH) + +Baton is a DSH bundle: one package mounts the `baton` skill. Execution goes +through the bundled Python CLI (`scripts/session_control.py`). It requires DSH +`0.2.1-rc.2` or newer, a profile that provides the `subagent` tool (the shipped +`dsh-base` does), and Python 3.10+ on `PATH`. + +```bash +# From a checkout +dsh plugin add /path/to/baton + +# Once published +dsh plugin add dsh-openspec-baton +``` + +Verify the bundle (self-contained, needs only `python3` + `git`; drives the +real CLI against a temp git repo and exits 0 when healthy): + +```bash +python3 tests/cli_smoke.py +# CLI SMOKE PASS: actions dispatch, status snapshot, error exit - all green +``` + +The package name is `dsh-openspec-baton` to avoid colliding with the unrelated +`dsh-baton` plugin. The repository and brand remain `baton`. + +## Install (ZCode) + +The same repository also carries a ZCode plugin form +(`.zcode-plugin/plugin.json`): it mounts the identical +`skills/baton/SKILL.md`; the skill drives the same +`python3 scripts/session_control.py` CLI. Python 3.10+ must be reachable as +`python3`. + +## Quick start + +```bash +# In your business project, write an OpenSpec change (the project's normal +# openspec/AGENTS.md workflow) and get it approved: +# openspec/changes//proposal.md + tasks.md (unchecked N.M tasks) +``` + +`tasks.md` is the ticket source. Sections are phases, tasks carry `N.M` ids, +and a task is blocked by every task in earlier sections (tasks within one +section run in parallel). Minimal shape: + +```markdown +# Tasks: add-login + +## 1. Backend + +- [x] 1.1 token endpoint +- [ ] 1.2 session persistence + +## 2. Frontend + +- [ ] 2.1 login form +``` + +`[x]` = resolved, `[ ]` = ready (no draft state); the exact parsing rules are +in [docs/ticket-format.md](docs/ticket-format.md). + +Then ask the session: + +``` +run baton on +``` + +The skill drives `actions` -> `subagent` -> `bind` -> completion notice +-> `reconcile` until the batch returns `complete`. + +## Architecture + +``` +main session (mechanical forwarder - no model-driven state decisions) + |- session_control.py actions --session-id ... --capacity<=5 + | -> SessionController.actions(): recover/reconcile sealed receipts, + | compute frontier, atomically claim, prepare isolated worktrees + + | immutable Task Packets, return dispatch/recover/resolve_merge/ + | wait/blocked/complete actions + |- subagent(dispatch.prompt, cwd=dispatch.cwd) -> child id + |- session_control.py bind + |- runtime sends the subagent completion notice + |- session_control.py reconcile ... --agent-terminal ... + -> sealed receipt -> succeeded + integrated; otherwise unknown + recover + +SessionController (the sole deterministic scheduler) + |- durable plan store (.dsh/baton/queue.db): revisions, attempts, leases, + | sealed receipts, completion facts + |- isolated assignment worktrees + a shared integration worktree + |- serial git integration; merge conflicts preserved (never auto-aborted) + `- latest-plan completion (all required tickets verified + integrated) +``` + +## Capabilities + +**Session-subagent execution** (sole path): +- **Deterministic scheduling** - claim, lease, worktree, receipt verification, + integration, completion are all scripted; the model never decides state. +- **Isolated worktrees** - one branch per assignment; capacity <= 5 sliding + window; integration serialized by a repository-scoped lock. +- **Sealed receipts** - `scripts/receipt.py verify` / `seal` is the only + accepted success evidence; a completion notice without a receipt flips the + attempt to `unknown`. +- **Fixed 7200 s leases** - a claim's lease is hardcoded to 7200 s (2 h): there + is no CLI flag and no renewal entry point. A lease that runs out is judged + `lease-expired` (the attempt flips to `unknown` with that `failure_code`) and + the ticket is re-dispatched by the next `actions` call. +- **Operator escape hatches, both guarded** - `abandon ` retires + a stuck attempt: it releases the lease and drops the attempt's eligibility + for reconcile, while its worktree, branch, commits, packet and receipt stay + on disk for audit. Abandoning an attempt that is plausibly still working + (active, no failure evidence, no receipt, less than 80% of its lease spent) + is refused with the judgment data unless `--force` - so a session taking + over a run cannot silently destroy live work. `adopt ` is the + reverse hatch: an abandoned attempt whose sealed receipt arrived late is + re-verified and integrated instead of thrown away (a newer sealed successor + refuses the adopt and lists both evidence sets for a human choice). +- **Relay-safe judgments** - `status` shows per attempt a `relay` block: + `elapsedSec` / `leaseRemainingSec` / `leaseElapsedFraction`, + `receiptExists`, worktree liveness signals and a `suggestedAction` + (`wait` / `reconcile` / `adopt` / ...). The dispatch prompt tells subagents + to refresh a `progress` heartbeat in the run directory (outside the + worktree, so it never dirties a seal), and `status` reports its age - + "alive" is read from data, not guessed. + +**Control plane**: +- **OpenSpec-native cognitive phase** - "use baton to finish X" means the + session runs the project's OpenSpec workflow (align -> write the change -> + user approves), then drives execution to `complete`. No self-built + Grill/Brief/approval state machine. +- **Live plan = edit tasks.md** - add tasks or sections at runtime; the next + `actions()` call records a new plan revision and the frontier is recomputed. + In-flight packets stay immutable. +- **Verification is change-wide and persisted** - `--verify ""` (top-level, + repeatable) is persisted with the run on the first claim, so later packets - + including ones prepared by `reconcile` - reuse it; passing it again is an + explicit override. There is no per-task verification override. + +**Observability**: `scripts/ticket_board.py` produces a pure static HTML +Kanban board over a project's OpenSpec changes (zero process, zero port, +colored feature tags, open via `file://`): + +```bash +python3 scripts/ticket_board.py build-all /path/to/project +# → .dsh/baton/board-all.html (every change on one board) +python3 scripts/ticket_board.py build /path/to/project +# → .dsh/baton//board.html +# is optional for build/scan when the project has exactly one +# change — the common day-one case — and the omission fails loud (naming the +# dir or the candidates) on zero or several. +``` + +Clicking a card shows the copy-paste `check` / `uncheck` commands (runnable +from any directory); a flip rewrites any built board in place — just reload +the page. + +## Bundle contents + +| Path | Purpose | +|---|---| +| `index.js` | mounts the bundled `skills/` provider | +| `cordis.patch.yml` | DSH bundle insert row (`dsh-openspec-baton`) | +| `.zcode-plugin/plugin.json` | ZCode plugin manifest (same skill) | +| `skills/baton/SKILL.md` | the `baton` skill (actions -> subagent -> reconcile) | +| `scripts/session_control.py` | the control-plane CLI (`actions`/`bind`/`reconcile`/`status`/`cleanup`/`abandon`/`adopt`) | +| `scripts/` | deterministic controller, plan store, receipts | + +## History + +Baton v1 drove an external `ralph.sh` loop. v2 brought the loop into Python. +v3 replaced the subprocess model with subagents but still used a shared PRD + +`passes` flags. v4 retired the PRD/passes protocol and made +`SessionController` the sole deterministic scheduler. v5 adopted +OpenSpec as the single plan face - the change's `tasks.md` is the ticket source +(section-order DAG, `[x]` write-back), and the self-built cognitive/amendment +engines are retired. **v6 (current)** drops the local MCP adapter: the CLI is +the only control surface while the plugin is still iterating. + +## Requirements + +- **DSH** `0.2.1-rc.2` or newer, with `subagent` available (`dsh-base`) +- **Python 3.10+** (`python3` on macOS/Linux, `python` on Windows) + pytest +- A **git** target project (the controller prepares isolated worktrees) +- The target project uses **OpenSpec** (`openspec/changes//tasks.md`) + +## Regression + +The standard regression entry - full pytest suite (incl. temp-git-fixture +e2e), a live CLI smoke test, and a docs/file consistency +check: + +```bash +./tests/regression.sh +``` + +## Documentation + +- [docs/architecture.md](docs/architecture.md) - why the deterministic control plane is the core IP +- [docs/ticket-format.md](docs/ticket-format.md) - the OpenSpec change -> ticket mapping spec + +## License + +MIT diff --git a/external_plugins/baton/README_CN.md b/external_plugins/baton/README_CN.md new file mode 100644 index 00000000..1beb5073 --- /dev/null +++ b/external_plugins/baton/README_CN.md @@ -0,0 +1,218 @@ +

Baton 图标

+ +

Baton

+ +

Tests +Python +License: MIT +Stars

+ +> OpenSpec 变更唯一的 ticket / DAG / 会话子代理控制面。 +> 本地 CLI 返回确定性的会话动作;主会话用 DSH `subagent` 工具派发每个任务包、 +> 绑定子代理 id,并依据密封回执(sealed receipt)结算完成通知。 + +**[Discussions](https://github.com/Lumos-789/baton/discussions)** + +## 解决什么问题 + +OpenSpec 擅长规格驱动的分解(proposal -> tasks -> archive),但不负责执行。 +Baton 把一个已批准的变更确定性地变成完成的工作:主会话从不自行决定 ticket 状态, +而是原样转发 Baton CLI 返回的确定性 `actions` 给 DSH 子代理,再把每个完成 +通知对照**密封回执**(任务的验证命令在隔离 worktree 中全绿)结算。子代理的自由文本 +输出不被采信。 + +密封回执是 `receipt.py` 在本地产生的哈希绑定证据(任务包哈希 + 干净前进的 HEAD + +退出码为 0 的验证结果)。整份回执正文由规范化 `sha256` 摘要(`receiptHash`)覆盖: +改动 `exitCode`、`changed_files`、`commit_sha`,或拿别的 ticket 的租约来顶替, +都会在集成前被拒。它是完整性证据,不是对抗恶意子代理的密码学证明(摘要没有密钥)。 + +三条接缝由确定性机制桥接: + +| 接缝 | Baton 的答案 | +|---|---| +| 依赖(按 section 顺序的 DAG -> 下一步跑什么) | `SessionController.actions()` 每次调用重算前沿并原子认领不同任务 | +| 并发(<=5 个并行任务) | 每个任务一个隔离 git worktree;集成由仓库级锁串行化 | +| 状态(子代理结束 -> 任务完成) | 密封回执是唯一成功证据;控制器集成分支、落定尝试、回写 tasks.md 的 `[x]` | + +**Baton 是指挥。`SessionController` 是唯一确定性调度器;主会话是机械转发器; +OpenSpec 是计划面。** + +## 安装(DSH) + +Baton 是一个 DSH bundle:一个包挂载 `baton` 技能,执行通过内置的 Python CLI +(`scripts/session_control.py`)完成。要求 DSH `0.2.1-rc.2` 或更新、提供 +`subagent` 工具的 profile(自带的 `dsh-base` 即可)、PATH 上有 Python 3.10+。 + +```bash +# 从 checkout 安装 +dsh plugin add /path/to/baton + +# 发布后 +dsh plugin add dsh-openspec-baton +``` + +自检(自包含,只需 `python3` + `git`;对临时 git 仓库驱动真实 CLI,健康则退出 0): + +```bash +python3 tests/cli_smoke.py +# CLI SMOKE PASS: actions dispatch, status snapshot, error exit - all green +``` + +包名用 `dsh-openspec-baton` 以避开无关的 `dsh-baton` 插件;仓库名与品牌保持 +`baton`。 + +## 安装(ZCode) + +同一仓库还带有 ZCode 插件形态(`.zcode-plugin/plugin.json`):挂载同一份 +`skills/baton/SKILL.md`,技能驱动同样的 `python3 scripts/session_control.py` CLI。 +要求 `python3` 可达且为 Python 3.10+。 + +## 快速开始 + +在业务项目里按项目自身的 openspec 工作流写一个变更并获批: + +``` +openspec/changes//proposal.md + tasks.md (未勾选的 N.M 任务) +``` + +`tasks.md` 就是 ticket 源:section 是阶段,任务带 `N.M` 编号,一个任务被它之前 +所有 section 的任务阻塞(同一 section 内并行)。最小形态: + +```markdown +# Tasks: add-login + +## 1. 后端 + +- [x] 1.1 token 端点 +- [ ] 1.2 会话持久化 + +## 2. 前端 + +- [ ] 2.1 登录表单 +``` + +`[x]` = 已完成,`[ ]` = 就绪(没有草稿态);精确解析规则见 +[docs/ticket-format.md](docs/ticket-format.md)。 + +然后对会话说: + +``` +run baton on +``` + +技能会驱动 `actions` -> `subagent` -> `bind` -> 完成通知 -> `reconcile`, +直到批次返回 `complete`。 + +## 架构 + +``` +主会话(机械转发器 — 不做任何模型驱动的状态决策) + |- session_control.py actions --session-id ... --capacity<=5 + | -> SessionController.actions():恢复/结算密封回执、计算前沿、原子认领、 + | 准备隔离 worktree 与不可变任务包,返回 + | dispatch/recover/resolve_merge/wait/blocked/complete 动作 + |- subagent(dispatch.prompt, cwd=dispatch.cwd) -> 子代理 id + |- session_control.py bind + |- 运行时送达子代理完成通知 + `- session_control.py reconcile ... --agent-terminal ... + -> 密封回执 -> 成功并集成;否则 unknown + recover + +SessionController(唯一确定性调度器) + |- 持久计划存储(.dsh/baton/queue.db):修订、尝试、租约、密封回执、完成事实 + |- 隔离的任务 worktree + 共享集成 worktree + |- 串行 git 集成;合并冲突保留(从不自动放弃) + `- 按最新计划判定完成(全部必需 ticket 已验证并集成) +``` + +## 能力 + +**会话子代理执行**(唯一路径): +- **确定性调度** — 认领、租约、worktree、回执验证、集成、完成判定全部脚本化, + 模型不决定状态。 +- **隔离 worktree** — 每个任务一个分支;容量 <=5 的滑动窗口;集成由仓库级锁串行。 +- **密封回执** — `scripts/receipt.py` 的 `verify` / `seal` 是唯一被采信的成功 + 证据;没有回执的完成通知把尝试翻成 `unknown`。 +- **固定 7200 s 租约** — 认领的租约硬编码为 7200 s(2 小时):没有 CLI 开关, + 也没有续租入口。租约超时即判 `lease-expired`(尝试翻成 `unknown` 并带上该 + `failure_code`),下一次 `actions` 调用会重新派发该 ticket。 +- **运维逃生口(双向带护栏)** — `abandon ` 退役卡住的尝试:释放 + 租约、取消它参与结算的资格,但 worktree、分支、提交、任务包和回执都留在磁盘 + 上供审计。对「可能还在干活」的尝试(在飞、无失败证据、无回执、租约消耗不足 + 80%)abandon 默认拒绝并给出判定数据,`--force` 才放行——接手会话无法再悄悄 + 销毁在飞的活。`adopt ` 是反向逃生口:abandon 之后迟到密封的 + 回执可被复活——重新验证后正常入账,不再整票作废(同票已有后继密封证据时 + adopt 拒绝并列出两份证据,由人二选一)。 +- **接力判定可读** — `status` 为每个尝试输出 `relay` 块:`elapsedSec` / + `leaseRemainingSec` / `leaseElapsedFraction`、`receiptExists`、worktree + 存活信号与 `suggestedAction`(`wait` / `reconcile` / `adopt` / …)。派发 + prompt 要求子代理在 run 目录(worktree 之外,不会弄脏 seal)刷新 + `progress` 心跳文件,`status` 汇报其年龄——「活着」从数据里读,不靠猜。 + +**控制面**: +- **OpenSpec 原生的认知阶段** — “用 baton 完成 X” 指先跑项目的 OpenSpec 工作流 + (对齐 -> 写变更 -> 用户批准),再驱动执行到 `complete`。 +- **活计划 = 直接改 tasks.md** — 运行中增删任务,下一次 `actions()` 记录新计划 + 修订并重算前沿;在飞的任务包保持不可变。 +- **验证是变更级且持久化** — `--verify ""`(顶层参数,可重复)在首次认领时 + 随本次运行持久化,之后的任务包(包括 `reconcile` 准备的)都复用它;再次传入 + 即显式覆盖。不存在 per-task 验证覆盖。 + +**可观测性**:`scripts/ticket_board.py` 为项目的 OpenSpec 变更生成纯静态 HTML +看板(零进程、零端口): + +```bash +python3 scripts/ticket_board.py build-all /path/to/project +# -> .dsh/baton/board-all.html (所有变更一板) +python3 scripts/ticket_board.py build /path/to/project +# -> .dsh/baton//board.html +# 项目恰有一个变更时,build/scan 可省略 ——最常见的 day-one +# 情形;零个或多个变更时省略会 fail loud(点名所搜目录或列出候选)。 +``` + +点击卡片会显示可复制的 `check` / `uncheck` 命令(任意目录可执行);一次 +翻转就会就地改写所有已构建的看板——刷新页面即可。 + +## 包内容 + +| 路径 | 用途 | +|---|---| +| `index.js` | 挂载内置 `skills/` provider | +| `cordis.patch.yml` | DSH bundle insert 行(`dsh-openspec-baton`) | +| `.zcode-plugin/plugin.json` | ZCode 插件清单(同一技能) | +| `skills/baton/SKILL.md` | `baton` 技能(actions -> subagent -> reconcile) | +| `scripts/session_control.py` | 控制面 CLI(`actions`/`bind`/`reconcile`/`status`/`cleanup`/`abandon`/`adopt`) | +| `scripts/` | 确定性控制器、计划存储、回执 | + +## 历史 + +Baton v1 驱动外部的 `ralph.sh` 循环。v2 把循环搬进 Python。v3 用子代理替换了 +子进程模型,但仍然依赖共享 PRD + `passes` 标记。v4 退役 PRD/passes 协议,让 +`SessionController` 成为唯一确定性调度器。v5 把 OpenSpec 采纳为唯一的计划面: +变更的 `tasks.md` 就是 ticket 源(按 section 顺序的 DAG、`[x]` 回写),自建的 +认知/修订引擎随之退役。**v6(当前)** 去掉本地 MCP 适配层:插件仍在迭代期, +CLI 是唯一的控制面。 + +## 要求 + +- **DSH** `0.2.1-rc.2` 或更新,且 `subagent` 可用(`dsh-base`) +- **Python 3.10+**(macOS/Linux 上的 `python3`、Windows 上的 `python`)+ pytest +- 一个 **git** 目标仓库(控制器要在其中准备隔离 worktree) +- 目标项目使用 **OpenSpec**(`openspec/changes//tasks.md`) + +## 回归 + +标准回测入口 — 完整 pytest 套件(含临时 git fixture 的 e2e)、真实 CLI 冒烟、 +文档/文件一致性检查: + +```bash +./tests/regression.sh +``` + +## 文档 + +- [docs/architecture.md](docs/architecture.md) — 为什么确定性控制面是核心 IP +- [docs/ticket-format.md](docs/ticket-format.md) — OpenSpec 变更 -> ticket 映射规格 + +## 许可 + +MIT diff --git a/external_plugins/baton/assets/baton/icon.png b/external_plugins/baton/assets/baton/icon.png new file mode 100644 index 00000000..ad908fc9 Binary files /dev/null and b/external_plugins/baton/assets/baton/icon.png differ diff --git a/external_plugins/baton/assets/baton/icon.svg b/external_plugins/baton/assets/baton/icon.svg new file mode 100644 index 00000000..18400c7d --- /dev/null +++ b/external_plugins/baton/assets/baton/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/external_plugins/baton/cordis.patch.yml b/external_plugins/baton/cordis.patch.yml new file mode 100644 index 00000000..e75c0bfb --- /dev/null +++ b/external_plugins/baton/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: dsh-openspec-baton + name: dsh-openspec-baton diff --git a/external_plugins/baton/docs/architecture.md b/external_plugins/baton/docs/architecture.md new file mode 100644 index 00000000..7d053673 --- /dev/null +++ b/external_plugins/baton/docs/architecture.md @@ -0,0 +1,142 @@ +# Baton Architecture — The Deterministic Control Plane Is the Core IP + +> This document explains why baton is not "scaffolding" but the deterministic +> layer that makes session-subagent execution safe. + +## Each System's Strengths + +**Baton's `SessionController`** (`scripts/orchestration/session_controller.py`): +- Strength: deterministic scheduling. It owns claim, lease, worktree, receipt + verification, integration, recovery, and completion. The model never decides + ticket state. +- It is the **sole** execution scheduler — the CLI delegates to it. + +**OpenSpec** (the plan face): +- Strength: spec-driven decomposition. A change carries a `proposal.md` + (why/what, human-approved) and a `tasks.md` checklist (sections = phases, + `N.M` tasks), each task fitting one context window. +- Weakness: it does not execute; it only produces `.md` files. + +**Native session subagents** (DSH `subagent` tool): +- Strength: real isolated context per task, full tool access, report back to + the main session. +- Weakness: free-text output is unreliable for state decisions; a model saying + "done" is not evidence. + +The granularity aligns: **one assignment ≈ one task** (both "doable in one +context window"). + +## The Three Breaks (the reason baton exists) + +| # | Break | Symptom | baton solution | +|---|---|---|---| +| 1 | **Dependency** | tasks have a section-order DAG; what runs next must respect it | `SessionController.actions()` computes the frontier fresh on every call and atomically claims distinct unchecked tasks | +| 2 | **Concurrency** | ≤5 parallel tasks must not collide on a shared workspace | one isolated git worktree per assignment; serial integration under a repository-scoped lock | +| 3 | **State** | a subagent finishing is not the same as a task being done | a **sealed receipt** (verification commands run green in the worktree) is the only accepted success evidence; the controller integrates the branch, finalizes the attempt, and flips the task's `[x]` in tasks.md | + +### Why the sealed receipt solves the state problem + +A subagent's terminal lifecycle event (`completed`/`failed`/`canceled`) is **not** +success. `reconcile` only honors a sealed receipt written by +`scripts/receipt.py verify`/`seal` — which re-runs the packet's declared +verification commands on a clean, advanced HEAD in the assignment worktree. A +terminal event without a sealed receipt flips the attempt to `unknown` and +returns a `recover` action. It is never silently marked successful. + +A sealed receipt is hash-bound local evidence (packet hash, claim-token hash, +clean advanced HEAD, exit-0 verification results). `seal` digests the whole +envelope body — canonical JSON (sorted keys, no whitespace, `receiptHash` +excluded) — into `receiptHash`, and `reconcile` recomputes it from the file +*before* merging: an edited `exitCode`, `changed_files`, `commit_sha`, or any +other field is rejected with a non-zero exit and the changed segment named. The +receipt's ticket/assignment/revision are also checked against the attempt +before integration, so a receipt for another ticket can never leave an attempt +stuck in `integrating`. It bounds state transitions; it is not a cryptographic +guarantee against a subagent that edits files directly (the digest has no +secret). + +### Why leases are fixed at 7200 s + +A claim's lease is **7200 s (2 h)** — there is no CLI flag and no renewal entry +point (the constructor takes an override, used by tests only). `expire_execution_attempts()` runs before every batch: an +attempt whose lease ran out is marked `unknown` with +`failure_code=lease-expired`, and its ticket is claimable again on that same +`actions()` call (the retry supersedes the stale attempt). A ticket that +legitimately needs longer than two hours therefore always surfaces as +`lease-expired` and is re-dispatched instead of silently deadlocking. + +### Why isolated worktrees solve the concurrency problem + +Each assignment gets its own branch + worktree +(`-baton--tic-NNN-aNN`). A shared integration worktree +(`-baton--integration`) merges sealed branches serially under +a repository-scoped lock. Merge conflicts are **preserved** (never auto-aborted) and +surfaced as `resolve_merge` actions for a dedicated subagent to resolve. + +## The Loop + +``` +main session (mechanical forwarder) + ├─ actions → session_control.py actions + │ SessionController.actions(): recover/reconcile sealed receipts, + │ claim frontier, prepare worktrees + packets, return action batch + ├─ for each dispatch: subagent(prompt, cwd) → agent_id + ├─ bind → bind the agent_id to the assignment + ├─ wait → wait for the subagent terminal lifecycle event + └─ reconcile → session_control.py reconcile ... + sealed receipt → succeeded + integrated; otherwise unknown + recover +``` + +The main session adds **no** orchestration of its own. It forwards the +deterministic actions verbatim and reports objective lifecycle events back. +The cognitive phase is the project's OpenSpec workflow — baton has no +self-built Grill/Brief/approval state machine. + +## Module Dependencies + +``` +session_controller.py ──→ plan_store.py (durable: queue.db) + │ │ │ │ + │ │ │ └──→ relay.py (read-only liveness judgments) + │ │ └──→ integrator.py ──→ worktree.py (git) + │ └──────→ receipt.py (verify/seal — the subagent CLI) + └──────────→ session_model.py (action/attempt contracts) +plan_store.py ──→ _ticket.py (OpenSpec tasks.md parser) +ticket_board.py ──→ _frontier.py (DAG frontier, observability only) +session_control.py ──→ session_controller.py (thin CLI adapter) +``` + +- `plan_store.py`: durable plan store (`queue.db`) — plan revisions (synced + from tasks.md edits), execution attempts, leases, sealed receipts, + completion facts. +- `relay.py`: read-only relay judgments — elapsed vs the lease budget, + receipt existence, worktree/heartbeat liveness, the suggested action, and + the abandon guard verdict; feeds `status` and the abandon guard without + writing state. +- `_ticket.py`: OpenSpec tasks.md parsing — the ticket source the plan store + reads on every sync. +- `_frontier.py`: DAG frontier — used by `ticket_board.py` to label claimable + cards; the controller claims through `plan_store.py` instead. + +## Why the plan face is OpenSpec tasks.md + +- The user's projects already live in the OpenSpec workflow + (`openspec/changes//`): proposal → approval → tasks → archive. Baton + adopts it as-is instead of inventing a parallel ticket format. +- Sections are phases in real tasks.md files; making section order the + dependency order (earlier sections block later ones; tasks within a section + are parallel) is the safe default and needs zero extra syntax. +- Checkbox state is the status: `[x]` resolved, `[ ]` ready. Baton's only + write-back is flipping a checkbox on integration — no dual status fields to + drift. + +See [ticket-format.md](ticket-format.md) for the exact parsing rules. + +## History + +- **v1** — external `ralph.sh` bash loop spawning headless `zcode -p`. +- **v2** — loop moved into Python (`SerialExecutor`/`ParallelExecutor`), still headless subprocesses. +- **v3** — subprocesses replaced by subagents, but coordination used a shared PRD + `passes:true` flags. This caused overwrite-induced ticket loss, batches mis-marked complete, and double-writes. +- **v4** — `SessionController` is the sole deterministic scheduler. No PRD, no `passes` flags, isolated worktrees, sealed receipts, capacity-≤3 sliding window (3 was the cap at the time; the whole repository is ≤5 now). Ralph permanently retired. +- **v5** — OpenSpec is the single plan face: tasks.md is the ticket source, the self-built cognitive layer (CompleteFlow/Grill/Brief/Planner/Approval) and the amendment engine are retired, and live-plan edits are plain tasks.md edits picked up as new plan revisions. +- **v6 (current)** — the local MCP adapter is dropped: the CLI is the only control surface while the plugin is still iterating (same history as README.md). diff --git a/external_plugins/baton/docs/ticket-format.md b/external_plugins/baton/docs/ticket-format.md new file mode 100644 index 00000000..9cc5d96c --- /dev/null +++ b/external_plugins/baton/docs/ticket-format.md @@ -0,0 +1,97 @@ +# OpenSpec Change → Ticket Mapping (authoritative source of the parser) + +> The parsing rules of `_ticket.py` (`parse_tasks_md` / `check_task`) are +> governed by this document. Changing the mapping requires updating the +> parser + tests in sync. + +## Source of Truth + +The plan face is an **OpenSpec change**: + +``` +/openspec/changes// +├── proposal.md ← why/what — the human-approved plan (baton never writes it) +└── tasks.md ← task checklist — baton's ticket source + [x] write-back +``` + +There is no baton-specific ticket file format anymore. A change is the +feature; a task is a ticket. + +## tasks.md Format + +```markdown +# Tasks: + +## 1. First phase + +- [x] 1.1 already-done task (resolved seed) +- [ ] 1.2 another task + +## 2. Second phase + +- [ ] 2.1 independent task A +- [ ] 2.2 independent task B +``` + +### Parsing rules + +- `## N. Section title` starts section N (a `。` after the number is + tolerated for CJK writing). +- Task lines: `- [ ] N.M text` / `- [x] N.M text`. Bullets `-`/`*`/`+` and + checkbox states `[ ]`/`[x]`/`[X]` are all tolerated. +- A task line only counts inside a section; stray checkboxes before the + first `## N.` header are ignored. + +### Mapping + +| OpenSpec | baton | +|---|---| +| change `` | feature (job) | +| task `N.M` | ticket, num = `N*100+M` (1.2 → 102, 3.4 → 304; reversible for display) | +| `[ ]` unchecked | status `ready` | +| `[x]` checked | status `resolved` | +| task text | title / what-to-build / single acceptance criterion | +| (none) | draft — an unapproved change is simply not run | +| (none) | per-task `Verify:` override — verification is change-wide via the CLI `--verify` flag, persisted with the run on the first claim (no implicit default; an unconfigured run fails loud) | + +### Dependency semantics (section-order DAG) + +Task `N.M` is blocked by **every task in every section that appears before +section N** in the file; tasks within one section are independent and form +the parallel frontier. Sections are phases in real-world tasks.md files, so +file order is the safe dependency order. Consequences: + +- Section 1 tasks have no blockers. +- A later section only starts once all earlier sections are fully checked. +- Keep independent work in the same section; keep gating work in an earlier + section. + +### Validation errors (`TasksMarkdownError`) + +- tasks.md missing → error with the "create the change first" hint +- no `## N.` sections / no task lines +- duplicate task id (e.g. two `2.1`) +- task id filed under the wrong section (e.g. `1.2` under `## 2.`) + +### Write-back + +When a task's sealed receipt is integrated, `check_task` flips its checkbox +`[ ]` → `[x]` in place (atomic tmp+rename, idempotent). The write-back matches +exactly the task lines `parse_tasks_md` accepts (any bullet, any whitespace +inside the brackets) and preserves every other byte of the file — including +CRLF line endings, so a Windows checkout does not turn one flip into a +whole-file diff. There is no in-progress projection: live attempt state lives +in the durable store (`queue.db`), not in the plan face. + +### Validation of ids + +An id outside the supported range (`## 100.`, `1.100`) is a +`TasksMarkdownError` too — like every other malformed-tasks.md case, it is +reported as one error line, never as a traceback. + +## Live edits + +Editing tasks.md at runtime (adding tasks/sections, reordering, manual +checkbox flips) is the live-plan mechanism: the next `actions()` call +re-reads the file, records a **new plan revision**, and the frontier is +recomputed. In-flight assignments keep their immutable packets. diff --git a/external_plugins/baton/index.js b/external_plugins/baton/index.js new file mode 100644 index 00000000..c4d8f245 --- /dev/null +++ b/external_plugins/baton/index.js @@ -0,0 +1,16 @@ +import { fileURLToPath } from "node:url"; +import { apply as applySkillFilesystem } from "@deepseek-ai/dsh-skill-filesystem"; + +const skillsRoot = fileURLToPath(new URL("./skills/", import.meta.url)); + +export const name = "dsh-openspec-baton"; +export const inject = ["skills"]; + +export function apply(ctx) { + applySkillFilesystem(ctx, { + providerName: name, + includeDefaultRoots: false, + bundledSkillDir: skillsRoot, + watch: false, + }); +} diff --git a/external_plugins/baton/scripts/.npmignore b/external_plugins/baton/scripts/.npmignore new file mode 100644 index 00000000..15afabf9 --- /dev/null +++ b/external_plugins/baton/scripts/.npmignore @@ -0,0 +1,4 @@ +# Bytecode caches are build residue: never ship them (stale .pyc from a +# different interpreter, absolute source paths baked in, 2x package weight). +__pycache__/ +*.pyc diff --git a/external_plugins/baton/scripts/_frontier.py b/external_plugins/baton/scripts/_frontier.py new file mode 100644 index 00000000..43ef387e --- /dev/null +++ b/external_plugins/baton/scripts/_frontier.py @@ -0,0 +1,36 @@ +"""DAG + frontier — the core logic of the baton bridge. + +frontier definition (authoritative): + status == "ready" AND every ticket referenced by blocked_by has + status == "resolved". + +This is the basis of the "only dispatch frontier tickets" strategy: blocked +tickets are never claimed, so a task whose dependencies are not resolved +cannot reach the controller's claim loop. This is how the DAG problem is +solved. +""" + +from __future__ import annotations + +from _ticket import Ticket + + +def compute_frontier(tickets: list[Ticket]) -> list[Ticket]: + """Compute the frontier: ready + all blockers resolved. + + Args: + tickets: all tickets (sorted by num or unsorted, both fine). + + Returns: + frontier ticket list (unsorted; callers order by num where needed). + """ + resolved_nums = {t.num for t in tickets if t.status == "resolved"} + frontier = [ + t + for t in tickets + if t.status == "ready" + # every number in blocked_by must be resolved (empty set is a subset + # of any set, i.e. no blockers — OK) + and set(t.blocked_by) <= resolved_nums + ] + return frontier diff --git a/external_plugins/baton/scripts/_ticket.py b/external_plugins/baton/scripts/_ticket.py new file mode 100644 index 00000000..45954f72 --- /dev/null +++ b/external_plugins/baton/scripts/_ticket.py @@ -0,0 +1,253 @@ +"""OpenSpec tasks.md parsing — the ticket source of baton. + +The plan face is an OpenSpec change: ``openspec/changes//`` with +``proposal.md`` (why/what) and ``tasks.md`` (the task checklist). baton maps: + + change → feature (``openspec/changes/``) + task → ticket (``- [ ] 1.2 description`` in tasks.md) + +Mapping rules (authoritative source: docs/ticket-format.md): + +- ``## N. Section title`` starts section N. +- ``- [ ] N.M text`` / ``- [x] N.M text`` is task N.M (bullet ``-``/``*``/``+``, + checkbox ``[ ]``/``[x]``/``[X]`` all tolerated). +- Ticket num = ``N * 100 + M`` (1.2 → 102, 3.4 → 304); reversible for display. +- Status: unchecked → ``ready``; checked → ``resolved``. No draft state — + an unapproved change simply is not run. +- DAG: section-ordered. Task N.M is blocked by every task in sections that + appear before it in the file; tasks within one section are independent + (parallel frontier). Sections are phases in real-world tasks.md files, so + file order is the safe dependency order. +- ``check_task`` flips ``[ ]`` → ``[x]`` for one task (the resolved + write-back); there is no in-progress projection — the durable store owns + live attempt state. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path + +VALID_STATUSES = {"ready", "resolved"} + +_SECTION_RE = re.compile(r"^##\s+(\d+)\s*[.\u3002]?\s*(.*?)\s*$") +_TASK_RE = re.compile(r"^[-*+]\s*\[\s*([ xX])\s*\]\s+(\d+)\.(\d+)\s+(.+?)\s*$") +_CHANGE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + + +def validate_change_id(change_id: str) -> str: + """Return ``change_id`` when it is one safe path segment. + + A change id names both ``openspec/changes//`` and the runtime + directory ``.dsh/baton//``. Anything that can traverse out of those + roots (empty, ``.``, ``..``, separators) is rejected instead of being + silently rewritten. + """ + if not isinstance(change_id, str) or not change_id: + raise ValueError("change id must be a non-empty string") + if change_id in {".", ".."} or "/" in change_id or "\\" in change_id: + raise ValueError(f"change id must be one path segment, got {change_id!r}") + if not _CHANGE_ID_RE.match(change_id) or change_id.endswith("."): + raise ValueError( + "change id must match [A-Za-z0-9][A-Za-z0-9._-]* and not end with '.', " + f"got {change_id!r}" + ) + return change_id + + +@dataclass +class Ticket: + """One OpenSpec task, parsed from tasks.md. + + Attributes: + num: ticket number (N*100+M from task id N.M; authoritative source). + title: the task text (with the N.M id stripped). + what_to_build: same as title — the task text is the deliverable. + blocked_by: nums of all tasks in earlier sections. + status: ready (unchecked) / resolved (checked). + criteria: single criterion — the task text itself. + file_path: path to tasks.md (used for write-back). + section: title of the section the task lives in. + """ + + num: int + title: str + what_to_build: str = "" + blocked_by: list[int] = field(default_factory=list) + status: str = "ready" + criteria: list[tuple[bool, str]] = field(default_factory=list) + file_path: str = "" + section: str = "" + + @property + def task_id(self) -> str: + """Display id ``N.M`` reconstructed from num.""" + return f"{self.num // 100}.{self.num % 100}" + + +def task_num(section: int, item: int) -> int: + """Composite ticket number for task ``section.item``.""" + if not 1 <= section <= 99 or not 1 <= item <= 99: + raise ValueError(f"task id {section}.{item} out of supported range") + return section * 100 + item + + +class TasksMarkdownError(ValueError): + """tasks.md is missing or malformed.""" + + +def parse_tasks_md(path: str | Path) -> list[Ticket]: + """Parse an OpenSpec ``tasks.md`` into tickets, ordered by num. + + Raises: + TasksMarkdownError: file missing, no sections/tasks, duplicate or + misfiled task ids. + """ + path = Path(path) + if not path.is_file(): + raise TasksMarkdownError( + f"OpenSpec tasks.md not found: {path} — a change needs " + f"openspec/changes//proposal.md + tasks.md before baton can run it" + ) + text = path.read_text(encoding="utf-8") + + section_titles: dict[int, str] = {} # section no → title (file order kept below) + section_order: list[int] = [] + parsed: list[tuple[int, int, bool, str]] = [] # (section, item, done, text) + seen_nums: set[int] = set() + current_section: int | None = None + + for raw in text.splitlines(): + section_match = _SECTION_RE.match(raw) + if section_match: + current_section = int(section_match.group(1)) + if current_section not in section_titles: + section_order.append(current_section) + section_titles[current_section] = section_match.group(2) + continue + task_match = _TASK_RE.match(raw) + if task_match and current_section is not None: + done = task_match.group(1).lower() == "x" + sec, item = int(task_match.group(2)), int(task_match.group(3)) + if sec != current_section: + raise TasksMarkdownError( + f"task {sec}.{item} is listed under section {current_section} in {path}" + ) + try: + num = task_num(sec, item) + except ValueError as exc: + raise TasksMarkdownError(f"{path}: {exc}") from exc + if num in seen_nums: + raise TasksMarkdownError(f"duplicate task id {sec}.{item} in {path}") + seen_nums.add(num) + parsed.append((sec, item, done, task_match.group(4))) + + if not section_order: + raise TasksMarkdownError(f"no `## N.` sections found in {path}") + if not parsed: + raise TasksMarkdownError(f"no `- [ ] N.M` tasks found in {path}") + + # Section-order DAG in file order: each section is blocked by the union of + # all tasks in previously appearing sections. + blocked_by_section: dict[int, list[int]] = {} + earlier: set[int] = set() + for sec in section_order: + blocked_by_section[sec] = sorted(earlier) + earlier |= {task_num(p[0], p[1]) for p in parsed if p[0] == sec} + + tickets = [ + Ticket( + num=task_num(sec, item), + title=text, + what_to_build=text, + blocked_by=list(blocked_by_section[sec]), + status="resolved" if done else "ready", + criteria=[(done, text)], + file_path=str(path), + section=section_titles[sec], + ) + for sec, item, done, text in parsed + ] + tickets.sort(key=lambda t: t.num) + return tickets + + +def check_task(path: str | Path, num: int) -> None: + """Flip task ``num`` to checked ``[x]`` in tasks.md (idempotent, atomic). + + The checked-off line keeps every other byte of the file — the checkbox + tolerance mirrors ``parse_tasks_md`` (bullet ``-``/``*``/``+``, any + whitespace inside ``[ ]``) and the file's line endings are preserved, so + a CRLF checkout does not turn one flip into a whole-file diff. + + Raises: + TasksMarkdownError: file missing or the task id is not present. + """ + path = Path(path) + if not path.is_file(): + raise TasksMarkdownError(f"OpenSpec tasks.md not found: {path}") + task_id = f"{num // 100}.{num % 100}" + with open(path, encoding="utf-8", newline="") as handle: + text = handle.read() + pattern = re.compile( + r"^([-*+][ \t]*\[)[ \t]+(\][ \t]+" + re.escape(task_id) + r"(?:\s|$))", + re.MULTILINE, + ) + new_text, count = pattern.subn(r"\g<1>x\g<2>", text) + if count == 0: + if re.search( + r"^[-*+][ \t]*\[[ \t]*[xX][ \t]*\][ \t]+" + re.escape(task_id) + r"(?:\s|$)", + text, + re.MULTILINE, + ): + return # already checked — idempotent + raise TasksMarkdownError(f"task {task_id} not found in {path}") + tmp = path.with_suffix(".md.tmp") + with open(tmp, "w", encoding="utf-8", newline="") as handle: + handle.write(new_text) + tmp.replace(path) + + +def uncheck_task(path: str | Path, num: int) -> None: + """Flip task ``num`` back to unchecked ``[ ]`` in tasks.md (the reverse). + + The exact mirror of :func:`check_task`: same checkbox tolerance + (``[x]``/``[X]``/``[ x ]``), same byte-preserving atomic ``tmp`` + rename + write and the same ``newline=""`` read so a CRLF checkout keeps its line + endings. ``ticket_board uncheck`` is the documented ``reverse`` of baton's + own ``[x]`` write-back, so it must not turn one flip into a whole-file diff. + + Raises: + TasksMarkdownError: file missing or the task is not currently checked. + """ + path = Path(path) + if not path.is_file(): + raise TasksMarkdownError(f"OpenSpec tasks.md not found: {path}") + task_id = f"{num // 100}.{num % 100}" + with open(path, encoding="utf-8", newline="") as handle: + text = handle.read() + pattern = re.compile( + r"^([-*+][ \t]*\[)[ \t]*[xX][ \t]*(\][ \t]+" + + re.escape(task_id) + + r"(?:\s|$))", + re.MULTILINE, + ) + new_text, count = pattern.subn(r"\g<1> \g<2>", text) + if count == 0: + raise TasksMarkdownError(f"task {task_id} is not checked in {path}") + tmp = path.with_suffix(".md.tmp") + with open(tmp, "w", encoding="utf-8", newline="") as handle: + handle.write(new_text) + tmp.replace(path) + + +__all__ = [ + "TasksMarkdownError", + "Ticket", + "VALID_STATUSES", + "check_task", + "parse_tasks_md", + "task_num", + "uncheck_task", +] diff --git a/external_plugins/baton/scripts/orchestration/AGENTS.md b/external_plugins/baton/scripts/orchestration/AGENTS.md new file mode 100644 index 00000000..64869e6b --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/AGENTS.md @@ -0,0 +1,44 @@ +# Baton Orchestration Layer + +This folder contains the local control-plane primitives for Baton. + +- `plan_model.py` defines the shared plan data contracts (`PlanTicket`, + `PlanRevision`) and the `utc_now`/`stable_hash` helpers used across the + durable store. +- `plan_store.py` owns versioned persistence, SQLite event logging, the + run-wide verification settings, sealed result envelopes, completion facts, + the read-only `snapshot()` behind `status`, and the sync from the OpenSpec + change's `tasks.md` into plan revisions (edits and baton's own `[x]` + write-backs each produce a new revision). +- `attempt_store.py` is the attempt lifecycle mixin of `PlanStore` (claim, + lease + expiry, agent binding, state transitions, finalize). It imports + `store_base` only — never `plan_store` — so the dependency stays one-way. +- `store_base.py` holds the store errors/terminal states and the atomic JSON + write helper shared by both modules. +- `store_schema.py` holds the `queue.db` DDL and its WAL busy-retry loop. +- `session_controller.py` is the deterministic session-subagent state machine: + actions, native-agent binding, sealed-receipt reconciliation, integration, + recovery, and latest-plan completion. This is the **sole** execution + scheduler — the CLI delegates to it. +- `session_model.py` holds the action-batch models (dispatch / recover / + resolve_merge / wait / blocked / complete) and Task Packet finalization. +- `integrator.py` merges sealed assignment branches serially into the + integration worktree, preserving conflicts for `resolve_merge`. +- `worktree.py` prepares isolated per-assignment worktrees + the shared + integration worktree. +- `receipt.py` / `result.py` define the sealed-receipt evidence model. + +The former cognitive layer (`complete.py`, `grill.py`, `brief.py`, +`planner.py`, `approval.py`, `delta_grill.py`) and the amendment engine +(`amendment.py`, `impact.py`, `coordinator.py`) were removed in v5: the +OpenSpec workflow is the cognitive protocol, and live-plan edits are plain +`tasks.md` edits picked up as new revisions. The legacy driver kernel +(`dispatcher.py` / `router.py` / `registry.py` / `verifier.py`) was removed +with them. + +Conventions: + +- Keep these modules standard-library only. +- Do not import external agent runtimes from here. +- Treat the change's tasks.md and `queue.db` as shared local state; updates + must be versioned and auditable. diff --git a/external_plugins/baton/scripts/orchestration/__init__.py b/external_plugins/baton/scripts/orchestration/__init__.py new file mode 100644 index 00000000..1dac4a10 --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/__init__.py @@ -0,0 +1,2 @@ +"""Baton orchestration primitives.""" + diff --git a/external_plugins/baton/scripts/orchestration/attempt_store.py b/external_plugins/baton/scripts/orchestration/attempt_store.py new file mode 100644 index 00000000..07e95a6e --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/attempt_store.py @@ -0,0 +1,618 @@ +"""Execution-attempt lifecycle: claim, lease, bind, state, finalize. + +Mixed into :class:`orchestration.plan_store.PlanStore` so the store's +persistence and plan-revision logic stays readable. These methods use the +private helpers the base class provides (``_connect``, ``_event_locked``, +``_ensure_initial_plan_locked``, ``_write_plan_json``, ...); this module never +imports ``plan_store``, so the dependency stays one-way. +""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +import uuid + +from _ticket import TasksMarkdownError, check_task + +from orchestration.plan_model import stable_hash, utc_now +from orchestration.result import ResultEnvelope +from orchestration.session_model import ExecutionAttempt +from orchestration.store_base import ( + TERMINAL_ATTEMPT_STATES, + ExecutionAttemptError, + PlanStoreError, + _utc_after, +) + + +class AttemptStoreMixin: + """Attempt lifecycle methods for the durable plan store.""" + + def claim_ready_assignments( + self, + *, + session_id: str, + capacity: int, + base_branch: str = "main", + base_sha: str = "", + lease_seconds: int = 7200, + ) -> list[ExecutionAttempt]: + """Atomically claim up to ``capacity`` distinct ready tickets. + + This is the session-subagent claim primitive. It never claims the whole + frontier pre-emptively. Each + returned attempt receives a one-time claim token; only its hash is + persisted. + """ + if not session_id.strip(): + raise ValueError("session_id is required") + if capacity < 1 or capacity > 5: + raise ValueError("capacity must be between 1 and 5") + + conn = self._connect() + claimed: list[ExecutionAttempt] = [] + touched_files: list[Path] = [] + try: + conn.execute("BEGIN IMMEDIATE") + plan = self._ensure_initial_plan_locked(conn) + self._expire_execution_attempts_locked(conn) + active_states = ("leased", "spawned", "running", "submitted", "verifying", "integrating", "blocked-review") + active_rows = conn.execute( + "SELECT ticket_num FROM execution_attempts " + "WHERE job_id = ? AND state IN (?, ?, ?, ?, ?, ?, ?)", + (self.job_id, *active_states), + ).fetchall() + active_tickets = {int(row["ticket_num"]) for row in active_rows} + available = max(0, capacity - len(active_rows)) + if available == 0: + conn.commit() + return [] + + ticket_files = { + ticket.num: ticket for ticket in self._read_source_tickets() + } + resolved = { + num for num, ticket in ticket_files.items() if ticket.status == "resolved" + } + result_rows = conn.execute( + "SELECT ticket_num FROM result_envelopes " + "WHERE job_id = ? AND status = 'succeeded'", + (self.job_id,), + ).fetchall() + resolved.update(int(row["ticket_num"]) for row in result_rows) + + eligible = [ + ticket + for ticket in plan.tickets + if ticket.required + and ticket.num not in active_tickets + and ticket.num not in resolved + and ticket_files.get(ticket.num) is not None + and ticket_files[ticket.num].status == "ready" + and all(blocker in resolved for blocker in ticket.blocked_by) + ] + eligible.sort(key=lambda ticket: ticket.num) + + for ticket in eligible: + if len(claimed) >= available: + break + row = conn.execute( + "SELECT COALESCE(MAX(attempt), 0) AS n FROM execution_attempts " + "WHERE job_id = ? AND ticket_num = ?", + (self.job_id, ticket.num), + ).fetchone() + attempt_no = int(row["n"]) + 1 + assignment_id = ( + f"ASG-{self.feature}-{ticket.num:03d}-r{plan.revision}-a{attempt_no}" + ) + claim_token = uuid.uuid4().hex + claim_token_hash = self._claim_token_hash(claim_token) + packet_hash = stable_hash( + { + "planRevision": plan.revision, + "ticket": ticket.to_dict(), + "baseBranch": base_branch, + "baseSha": base_sha, + } + ) + now = utc_now() + expires_at = _utc_after(lease_seconds) + run_id = f"RUN-{self.feature}" + conn.execute( + "INSERT INTO execution_attempts " + "(job_id, run_id, assignment_id, ticket_num, attempt, state, " + "session_id, agent_id, plan_revision, task_packet_hash, " + "claim_token_hash, lease_expires_at, base_branch, base_sha, " + "worktree_path, worktree_branch, integration_path, " + "integration_branch, packet_path, receipt_path, commit_sha, " + "integration_sha, failure_code, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, 'leased', ?, '', ?, ?, ?, ?, ?, ?, " + "'', '', '', '', '', '', '', '', '', ?, ?)", + ( + self.job_id, run_id, assignment_id, ticket.num, attempt_no, + session_id, plan.revision, packet_hash, claim_token_hash, + expires_at, base_branch, base_sha, now, now, + ), + ) + self._event_locked( + conn, + "execution.attempt.claimed", + { + "assignmentId": assignment_id, + "ticket": ticket.num, + "attempt": attempt_no, + "sessionId": session_id, + "planRevision": plan.revision, + "taskPacketHash": packet_hash, + "leaseExpiresAt": expires_at, + }, + ) + claimed.append( + ExecutionAttempt( + assignment_id=assignment_id, + run_id=run_id, + ticket_num=ticket.num, + attempt=attempt_no, + state="leased", + session_id=session_id, + plan_revision=plan.revision, + task_packet_hash=packet_hash, + claim_token=claim_token, + lease_expires_at=expires_at, + base_branch=base_branch, + base_sha=base_sha, + ) + ) + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + if claimed: + self._write_plan_json(self.current_plan()) + return claimed + + def list_execution_attempts(self) -> list[ExecutionAttempt]: + self.ensure_initial_plan() + conn = self._connect() + try: + rows = conn.execute( + "SELECT * FROM execution_attempts WHERE job_id = ? " + "ORDER BY ticket_num, attempt", + (self.job_id,), + ).fetchall() + return [self._execution_attempt_from_row(row) for row in rows] + finally: + conn.close() + + def get_execution_attempt(self, assignment_id: str) -> ExecutionAttempt: + conn = self._connect() + try: + row = conn.execute( + "SELECT * FROM execution_attempts WHERE job_id = ? AND assignment_id = ?", + (self.job_id, assignment_id), + ).fetchone() + if row is None: + raise ExecutionAttemptError(f"unknown assignment: {assignment_id}") + return self._execution_attempt_from_row(row) + finally: + conn.close() + + def bind_attempt_agent( + self, assignment_id: str, claim_token: str, agent_id: str + ) -> ExecutionAttempt: + if not agent_id.strip(): + raise ValueError("agent_id is required") + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + row = self._load_execution_attempt_locked(conn, assignment_id) + self._require_claim_token(row, claim_token) + if row["state"] not in {"leased", "spawned"}: + raise ExecutionAttemptError( + f"cannot bind agent while {assignment_id} is {row['state']}" + ) + if row["state"] == "spawned": + if row["agent_id"] != agent_id: + raise ExecutionAttemptError( + f"assignment {assignment_id} is already bound to {row['agent_id']}" + ) + conn.commit() + return self.get_execution_attempt(assignment_id) + now = utc_now() + conn.execute( + "UPDATE execution_attempts SET state = 'spawned', agent_id = ?, " + "updated_at = ? WHERE job_id = ? AND assignment_id = ?", + (agent_id, now, self.job_id, assignment_id), + ) + self._event_locked( + conn, + "execution.agent.bound", + {"assignmentId": assignment_id, "agentId": agent_id}, + ) + conn.commit() + return self.get_execution_attempt(assignment_id) + finally: + conn.close() + + def configure_execution_attempt( + self, + assignment_id: str, + claim_token: str, + *, + task_packet_hash: str, + worktree_path: str, + worktree_branch: str, + integration_path: str, + integration_branch: str, + packet_path: str, + receipt_path: str, + ) -> ExecutionAttempt: + """Persist the immutable packet and workspace coordinates for a claim.""" + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + row = self._load_execution_attempt_locked(conn, assignment_id) + self._require_claim_token(row, claim_token) + if row["state"] not in {"leased", "spawned"}: + raise ExecutionAttemptError( + f"cannot configure {assignment_id} while it is {row['state']}" + ) + now = utc_now() + conn.execute( + "UPDATE execution_attempts SET task_packet_hash = ?, " + "worktree_path = ?, worktree_branch = ?, integration_path = ?, " + "integration_branch = ?, packet_path = ?, receipt_path = ?, " + "updated_at = ? WHERE job_id = ? AND assignment_id = ?", + ( + task_packet_hash, + worktree_path, + worktree_branch, + integration_path, + integration_branch, + packet_path, + receipt_path, + now, + self.job_id, + assignment_id, + ), + ) + self._event_locked( + conn, + "execution.attempt.configured", + { + "assignmentId": assignment_id, + "taskPacketHash": task_packet_hash, + "worktreePath": worktree_path, + "packetPath": packet_path, + }, + ) + conn.commit() + finally: + conn.close() + return self.get_execution_attempt(assignment_id) + + def mark_execution_attempt_state( + self, + assignment_id: str, + claim_token: str, + state: str, + *, + failure_code: str = "", + release_lease: bool = False, + ) -> ExecutionAttempt: + """Move an attempt to a controller-owned lifecycle state.""" + allowed = {"running", "submitted", "verifying", "integrating", "blocked-review", "unknown"} + if state not in allowed: + raise ValueError(f"unsupported execution attempt state: {state}") + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + row = self._load_execution_attempt_locked(conn, assignment_id) + self._require_claim_token(row, claim_token) + if row["state"] in TERMINAL_ATTEMPT_STATES: + raise ExecutionAttemptError(f"attempt is terminal: {assignment_id}") + now = utc_now() + conn.execute( + "UPDATE execution_attempts SET state = ?, failure_code = ?, " + "updated_at = ? WHERE job_id = ? AND assignment_id = ?", + (state, failure_code, now, self.job_id, assignment_id), + ) + self._event_locked( + conn, + f"execution.attempt.{state}", + { + "assignmentId": assignment_id, + "failureCode": failure_code, + "leaseReleased": release_lease, + }, + ) + conn.commit() + finally: + conn.close() + return self.get_execution_attempt(assignment_id) + + def expire_execution_attempts(self) -> None: + """Mark every attempt past its lease ``unknown`` and free the lease. + + Called by the controller before planning a batch so an expired lease is + never re-dispatched as if it were still owned. ``failure_code`` is set + to ``lease-expired``; the attempt can then be retried (superseded once + a successor exists) or abandoned by the operator. + """ + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + self._expire_execution_attempts_locked(conn) + conn.commit() + finally: + conn.close() + + def supersede_execution_attempt( + self, + assignment_id: str, + *, + success_assignment_id: str = "", + failure_code: str = "superseded-by-retry", + ) -> ExecutionAttempt: + """Retire a stale attempt that a newer attempt for the ticket replaced.""" + return self._resolve_execution_attempt( + assignment_id, + state="superseded", + failure_code=( + f"{failure_code} (successor {success_assignment_id})" + if success_assignment_id + else failure_code + ), + ) + + def abandon_execution_attempt( + self, + assignment_id: str, + *, + reason: str = "operator-abandoned", + ) -> ExecutionAttempt: + """Operator escape hatch: retire a stuck attempt without touching git. + + Releases the attempt's lease and marks it ``abandoned`` so + the ticket becomes claimable again. It deliberately does **not** remove + the assignment worktree/branch, its commits, packet or receipt — those + stay on disk for audit. It only destroys the attempt's ability to be + reconciled: any later receipt is ignored. + """ + return self._resolve_execution_attempt( + assignment_id, state="abandoned", failure_code=reason + ) + + def adopt_execution_attempt( + self, + assignment_id: str, + *, + lease_seconds: int = 7200, + ) -> ExecutionAttempt: + """Revive an abandoned attempt so a late sealed receipt can reconcile. + + The reverse hatch of :meth:`abandon_execution_attempt`: an abandoned + attempt whose subagent later sealed a valid receipt is put back into an + active state (``verifying``) with a fresh lease, so the controller can + verify and integrate the receipt as if the abandon never happened. The + caller is expected to hold the verified receipt; this transition only + changes scheduling state, never git. + """ + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + row = self._load_execution_attempt_locked(conn, assignment_id) + if row["state"] != "abandoned": + raise ExecutionAttemptError( + f"cannot adopt {assignment_id} while it is {row['state']} " + "(only abandoned attempts can be adopted)" + ) + now = utc_now() + expires_at = _utc_after(lease_seconds) + conn.execute( + "UPDATE execution_attempts SET state = 'verifying', " + "failure_code = '', lease_expires_at = ?, updated_at = ? " + "WHERE job_id = ? AND assignment_id = ?", + (expires_at, now, self.job_id, assignment_id), + ) + self._event_locked( + conn, + "execution.attempt.adopted", + {"assignmentId": assignment_id, "leaseExpiresAt": expires_at}, + ) + conn.commit() + finally: + conn.close() + return self.get_execution_attempt(assignment_id) + + def _resolve_execution_attempt( + self, + assignment_id: str, + *, + state: str, + failure_code: str, + ) -> ExecutionAttempt: + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + row = self._load_execution_attempt_locked(conn, assignment_id) + if row["state"] in TERMINAL_ATTEMPT_STATES: + raise ExecutionAttemptError( + f"attempt is already terminal ({row['state']}): {assignment_id}" + ) + now = utc_now() + conn.execute( + "UPDATE execution_attempts SET state = ?, failure_code = ?, " + "updated_at = ? WHERE job_id = ? AND assignment_id = ?", + (state, failure_code, now, self.job_id, assignment_id), + ) + self._event_locked( + conn, + f"execution.attempt.{state}", + { + "assignmentId": assignment_id, + "failureCode": failure_code, + "leaseReleased": True, + }, + ) + conn.commit() + finally: + conn.close() + return self.get_execution_attempt(assignment_id) + + def finalize_execution_attempt( + self, + assignment_id: str, + claim_token: str, + envelope: ResultEnvelope, + integration_sha: str, + ) -> ExecutionAttempt: + """Persist one sealed, integrated result and release its attempt lease.""" + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + row = self._load_execution_attempt_locked(conn, assignment_id) + self._require_claim_token(row, claim_token) + if row["state"] == "succeeded": + conn.commit() + return self.get_execution_attempt(assignment_id) + if row["state"] in TERMINAL_ATTEMPT_STATES: + raise ExecutionAttemptError(f"attempt is terminal: {assignment_id}") + if envelope.status != "succeeded": + raise ExecutionAttemptError("only succeeded sealed receipts can finalize") + expected = { + "assignment_id": assignment_id, + "ticket_num": int(row["ticket_num"]), + "plan_revision": int(row["plan_revision"]), + "task_packet_hash": str(row["task_packet_hash"]), + "attempt": int(row["attempt"]), + } + for field, value in expected.items(): + if getattr(envelope, field) != value: + raise ExecutionAttemptError( + f"receipt {field} mismatch for {assignment_id}" + ) + now = utc_now() + envelope.integration_sha = integration_sha + conn.execute( + "UPDATE execution_attempts SET state = 'succeeded', commit_sha = ?, " + "integration_sha = ?, failure_code = '', updated_at = ? " + "WHERE job_id = ? AND assignment_id = ?", + (envelope.commit_sha, integration_sha, now, self.job_id, assignment_id), + ) + envelope_id = self._result_envelope_id(envelope) + conn.execute( + "INSERT OR REPLACE INTO result_envelopes " + "(envelope_id, job_id, feature, ticket_num, assignment_id, " + "plan_revision, task_packet_hash, status, payload_json, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + envelope_id, + self.job_id, + self.feature, + envelope.ticket_num, + envelope.assignment_id, + envelope.plan_revision, + envelope.task_packet_hash, + envelope.status, + json.dumps(envelope.to_dict(), ensure_ascii=False, sort_keys=True), + envelope.created_at, + ), + ) + self._event_locked( + conn, + "execution.attempt.succeeded", + { + "assignmentId": assignment_id, + "ticket": envelope.ticket_num, + "commitSha": envelope.commit_sha, + "integrationSha": integration_sha, + }, + ) + conn.commit() + finally: + conn.close() + + try: + check_task(self.tasks_path, envelope.ticket_num) + except TasksMarkdownError as exc: + raise PlanStoreError(str(exc)) from exc + self._write_result_envelope_json(self._result_envelope_id(envelope), envelope) + self._write_plan_json(self.current_plan()) + return self.get_execution_attempt(assignment_id) + + def _expire_execution_attempts_locked(self, conn: sqlite3.Connection) -> None: + now = utc_now() + rows = conn.execute( + "SELECT assignment_id FROM execution_attempts " + "WHERE job_id = ? AND lease_expires_at <= ? " + "AND state IN ('leased', 'spawned', 'running', 'submitted', 'verifying', 'integrating')", + (self.job_id, now), + ).fetchall() + for row in rows: + assignment_id = str(row["assignment_id"]) + conn.execute( + "UPDATE execution_attempts SET state = 'unknown', failure_code = 'lease-expired', " + "updated_at = ? WHERE job_id = ? AND assignment_id = ?", + (now, self.job_id, assignment_id), + ) + self._event_locked( + conn, + "execution.attempt.unknown", + {"assignmentId": assignment_id, "reason": "lease-expired"}, + ) + + def _load_execution_attempt_locked( + self, conn: sqlite3.Connection, assignment_id: str + ) -> sqlite3.Row: + row = conn.execute( + "SELECT * FROM execution_attempts WHERE job_id = ? AND assignment_id = ?", + (self.job_id, assignment_id), + ).fetchone() + if row is None: + raise ExecutionAttemptError(f"unknown assignment: {assignment_id}") + return row + + @staticmethod + def _claim_token_hash(claim_token: str) -> str: + return hashlib.sha256(claim_token.encode("utf-8")).hexdigest() + + def _require_claim_token(self, row: sqlite3.Row, claim_token: str) -> None: + if self._claim_token_hash(claim_token) != row["claim_token_hash"]: + raise ExecutionAttemptError( + f"invalid claim token for assignment {row['assignment_id']}" + ) + + def _execution_attempt_from_row(self, row: sqlite3.Row) -> ExecutionAttempt: + return ExecutionAttempt( + assignment_id=str(row["assignment_id"]), + run_id=str(row["run_id"]), + ticket_num=int(row["ticket_num"]), + attempt=int(row["attempt"]), + state=str(row["state"]), + session_id=str(row["session_id"]), + plan_revision=int(row["plan_revision"]), + task_packet_hash=str(row["task_packet_hash"]), + claim_token="", + lease_expires_at=str(row["lease_expires_at"]), + base_branch=str(row["base_branch"]), + base_sha=str(row["base_sha"]), + worktree_path=str(row["worktree_path"]), + worktree_branch=str(row["worktree_branch"]), + integration_path=str(row["integration_path"]), + integration_branch=str(row["integration_branch"]), + packet_path=str(row["packet_path"]), + receipt_path=str(row["receipt_path"]), + agent_id=str(row["agent_id"]), + commit_sha=str(row["commit_sha"]), + integration_sha=str(row["integration_sha"]), + failure_code=str(row["failure_code"]), + created_at=str(row["created_at"]), + updated_at=str(row["updated_at"]), + ) + +__all__ = ["AttemptStoreMixin"] diff --git a/external_plugins/baton/scripts/orchestration/integrator.py b/external_plugins/baton/scripts/orchestration/integrator.py new file mode 100644 index 00000000..843a482b --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/integrator.py @@ -0,0 +1,203 @@ +"""Deterministic integration of assignment branches into a Baton feature branch.""" + +from __future__ import annotations + +import os +import subprocess +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +from orchestration.worktree import WorktreeError, branch_is_merged + +_LOCK_TIMEOUT_SECONDS = 120.0 +_LOCK_STALE_SECONDS = 3600 + + +def _git(path: Path, args: list[str]) -> subprocess.CompletedProcess: + try: + return subprocess.run( + ["git", *args], + cwd=str(path), + capture_output=True, + text=True, + timeout=60, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + raise WorktreeError(f"git {' '.join(args)} failed: {exc}") from exc + + +def _lock_owner_alive(lock_path: Path) -> bool: + """Best-effort liveness check so a crashed merge cannot wedge the repo.""" + try: + pid = int(lock_path.read_text(encoding="ascii").strip()) + except (OSError, ValueError): + return True + if os.name == "nt" or pid <= 0: + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +@contextmanager +def _integration_lock(path: Path) -> Iterator[None]: + """Serialize merges into the shared integration worktree across processes. + + Git's own ``index.lock`` makes concurrent merges fail rather than corrupt, + but a retry loop here turns a parallel reconcile into a wait. The lock file + lives in the repository's common git dir (shared by linked worktrees) and + is removed on every exit path. + """ + common = _git(path, ["rev-parse", "--git-common-dir"]) + if common.returncode != 0: + raise WorktreeError(common.stderr.strip() or "cannot resolve git common dir") + git_dir = Path(common.stdout.strip()) + if not git_dir.is_absolute(): + git_dir = (path / git_dir).resolve() + lock_path = git_dir / "baton-integration.lock" + deadline = time.monotonic() + _LOCK_TIMEOUT_SECONDS + while True: + try: + fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + except FileExistsError: + try: + age = time.time() - lock_path.stat().st_mtime + except FileNotFoundError: + continue + if age > _LOCK_STALE_SECONDS or not _lock_owner_alive(lock_path): + try: + lock_path.unlink() + except FileNotFoundError: + pass + continue + if time.monotonic() >= deadline: + raise WorktreeError(f"integration lock busy: {lock_path}") + time.sleep(0.05) + continue + try: + os.write(fd, f"{os.getpid()}\n".encode("ascii")) + finally: + os.close(fd) + break + try: + yield + finally: + try: + lock_path.unlink() + except FileNotFoundError: + pass + + +def _conflict_files(path: Path) -> list[str]: + result = _git(path, ["diff", "--name-only", "--diff-filter=U"]) + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def _merge_head(path: Path) -> str: + """The commit a preserved (unresolved) merge is merging, or ``''``. + + Git writes ``MERGE_HEAD`` for the whole duration of a conflicted merge, so + its presence is the authoritative "this worktree is mid-merge" signal. + """ + result = _git(path, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]) + return result.stdout.strip() if result.returncode == 0 else "" + + +def _branch_tip(path: Path, branch: str) -> str: + result = _git(path, ["rev-parse", "-q", "--verify", branch]) + return result.stdout.strip() if result.returncode == 0 else "" + + +def _dirty_files(path: Path) -> list[str]: + """Tracked files with uncommitted changes (untracked files are ignored).""" + result = _git(path, ["status", "--porcelain", "--untracked-files=no"]) + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def _not_merged(reason: str, files: list[str]) -> dict[str, Any]: + return { + "ok": False, + "reason": reason, + "already_merged": False, + "integration_sha": "", + "conflicts": files, + } + + +def integrate_assignment( + integration_path: str | Path, + assignment_branch: str, +) -> dict[str, Any]: + """Merge ``assignment_branch`` into the shared integration worktree. + + Returns ``ok=True`` with the new ``integration_sha``, or ``ok=False`` with + a ``reason`` the controller must distinguish: + + - ``conflict``: *this* branch conflicted; the conflict is preserved in the + worktree and needs a ``resolve_merge`` action. + - ``merge-in-progress``: the worktree is already mid-merge for **another** + assignment. Nothing was attempted; try again after that one is resolved. + (Git refuses a second merge while ``MERGE_HEAD`` exists, and its unmerged + path list belongs to the earlier merge — reporting it as this branch's + conflict would mis-attribute the failure and hand two solvers the same + worktree.) + - ``worktree-dirty``: tracked files in the shared worktree have + uncommitted changes, so a merge could destroy them. Nothing was + attempted. + """ + path = Path(integration_path).resolve() + with _integration_lock(path): + return _integrate_locked(path, assignment_branch) + + +def _integrate_locked(path: Path, assignment_branch: str) -> dict[str, Any]: + merge_head = _merge_head(path) + if merge_head: + # A preserved conflict blocks every other merge. ``MERGE_HEAD`` is the + # tip of the branch being merged, which tells us whether the pending + # merge belongs to this assignment or to a different one. + if merge_head == _branch_tip(path, assignment_branch): + return _not_merged("conflict", _conflict_files(path)) + return _not_merged("merge-in-progress", _conflict_files(path)) + + dirty = _dirty_files(path) + if dirty: + return _not_merged("worktree-dirty", dirty) + + if branch_is_merged(path, assignment_branch, "HEAD"): + head = _git(path, ["rev-parse", "HEAD"]) + return { + "ok": True, + "already_merged": True, + "integration_sha": head.stdout.strip(), + "conflicts": [], + } + + result = _git(path, ["merge", "--no-ff", "--no-edit", assignment_branch]) + if result.returncode == 0: + head = _git(path, ["rev-parse", "HEAD"]) + if head.returncode != 0: + raise WorktreeError(head.stderr.strip() or "cannot read integration HEAD") + return { + "ok": True, + "already_merged": False, + "integration_sha": head.stdout.strip(), + "conflicts": [], + } + + conflict_files = _conflict_files(path) + if conflict_files: + return _not_merged("conflict", conflict_files) + raise WorktreeError( + result.stderr.strip() or result.stdout.strip() or "git merge failed" + ) + + +__all__ = ["integrate_assignment"] diff --git a/external_plugins/baton/scripts/orchestration/model.py b/external_plugins/baton/scripts/orchestration/model.py new file mode 100644 index 00000000..9f8b9d47 --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/model.py @@ -0,0 +1,71 @@ +"""Multi-agent orchestration data contracts. + +``TaskPacket`` is the immutable, hashable-by-construction contract handed to a +builder subagent: goal, acceptance criteria, verification commands, workspace +and resources. + +The structured receipt returned by an agent is reused verbatim from +``orchestration/result.py`` (``ResultEnvelope``) — it is **not** redefined +here. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + +#: The role a Task Packet declares: the subagent that implements the ticket. +ROLE_BUILDER = "builder" + + +@dataclass +class TaskPacket: + """The immutable, hashable-by-construction work contract handed to a + builder agent. + + A packet fully describes one unit of work: the goal, how success is + judged (``acceptance_criteria`` / ``verification_commands`` / + ``output_contract``), what it depends on (``dependencies`` / + ``base_commit`` / ``artifacts``), where it may touch (``project_path`` / + ``allowed_paths``), and how the orchestrator should + behave on ambiguity (``retry_policy``). ``plan_revision`` binds the + packet to the Live Plan revision that produced it; ``task_packet_hash`` + is the canonical content hash callers use for idempotency. + """ + + task_id: str + role: str + goal: str + acceptance_criteria: list[str] = field(default_factory=list) + dependencies: list[str] = field(default_factory=list) + base_commit: str = "" + artifacts: list[str] = field(default_factory=list) + project_path: str = "" + allowed_paths: list[str] = field(default_factory=list) + verification_commands: list[str] = field(default_factory=list) + output_contract: list[str] = field(default_factory=list) + retry_policy: str = "manual-on-ambiguous" + plan_revision: int = 0 + task_packet_hash: str = "" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "TaskPacket": + return cls( + task_id=str(data.get("task_id", "")), + role=str(data.get("role", "")), + goal=str(data.get("goal", "")), + acceptance_criteria=[str(x) for x in data.get("acceptance_criteria", [])], + dependencies=[str(x) for x in data.get("dependencies", [])], + base_commit=str(data.get("base_commit", "")), + artifacts=[str(x) for x in data.get("artifacts", [])], + project_path=str(data.get("project_path", "")), + allowed_paths=[str(x) for x in data.get("allowed_paths", [])], + verification_commands=[str(x) for x in data.get("verification_commands", [])], + output_contract=[str(x) for x in data.get("output_contract", [])], + retry_policy=str(data.get("retry_policy", "manual-on-ambiguous")), + plan_revision=int(data.get("plan_revision", 0)), + task_packet_hash=str(data.get("task_packet_hash", "")), + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) diff --git a/external_plugins/baton/scripts/orchestration/plan_model.py b/external_plugins/baton/scripts/orchestration/plan_model.py new file mode 100644 index 00000000..77093214 --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/plan_model.py @@ -0,0 +1,122 @@ +"""Shared plan data model and stable-hash helpers. + +Split out of the retired amendment engine: these contracts are the durable +plan-store schema (``queue.db`` snapshots, ``plan.json``) and are used by the +plan store, the session model, and result envelopes. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any + + +def utc_now() -> str: + """Return a stable UTC timestamp for persisted audit records.""" + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def stable_json(data: Any) -> str: + """Serialize data with deterministic key ordering.""" + return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def stable_hash(data: Any) -> str: + """Compute a short, stable hash for plan snapshots and task packets.""" + return hashlib.sha256(stable_json(data).encode("utf-8")).hexdigest()[:16] + + +@dataclass +class PlanTicket: + """Machine-readable snapshot of one OpenSpec task in a plan revision.""" + + num: int + title: str + what_to_build: str + blocked_by: list[int] = field(default_factory=list) + status: str = "ready" + criteria: list[str] = field(default_factory=list) + required: bool = True + source_revision: int = 1 + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PlanTicket": + return cls( + num=int(data["num"]), + title=str(data["title"]), + what_to_build=str(data.get("what_to_build", "")), + blocked_by=[int(x) for x in data.get("blocked_by", [])], + status=str(data.get("status", "ready")), + criteria=[str(x) for x in data.get("criteria", [])], + required=bool(data.get("required", True)), + source_revision=int(data.get("source_revision", 1)), + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class PlanRevision: + """A complete approved plan snapshot.""" + + job_id: str + feature: str + revision: int + parent_revision: int | None + plan_hash: str + approved_by: str + approved_at: str + created_at: str + tickets: list[PlanTicket] = field(default_factory=list) + completion_state: str = "active" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PlanRevision": + return cls( + job_id=str(data["job_id"]), + feature=str(data["feature"]), + revision=int(data["revision"]), + parent_revision=( + int(data["parent_revision"]) + if data.get("parent_revision") is not None + else None + ), + plan_hash=str(data["plan_hash"]), + approved_by=str(data.get("approved_by", "")), + approved_at=str(data.get("approved_at", "")), + created_at=str(data.get("created_at", "")), + tickets=[PlanTicket.from_dict(x) for x in data.get("tickets", [])], + completion_state=str(data.get("completion_state", "active")), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "job_id": self.job_id, + "feature": self.feature, + "revision": self.revision, + "parent_revision": self.parent_revision, + "plan_hash": self.plan_hash, + "approved_by": self.approved_by, + "approved_at": self.approved_at, + "created_at": self.created_at, + "tickets": [t.to_dict() for t in self.tickets], + "completion_state": self.completion_state, + } + + def hashable_dict(self) -> dict[str, Any]: + data = self.to_dict() + data["plan_hash"] = "" + return data + + +__all__ = [ + "PlanRevision", + "PlanTicket", + "stable_hash", + "stable_json", + "utc_now", +] diff --git a/external_plugins/baton/scripts/orchestration/plan_store.py b/external_plugins/baton/scripts/orchestration/plan_store.py new file mode 100644 index 00000000..1ed88ac4 --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/plan_store.py @@ -0,0 +1,434 @@ +"""Versioned local storage for Baton live plans.""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any + +from _ticket import TasksMarkdownError, Ticket, parse_tasks_md, validate_change_id + +from orchestration.attempt_store import AttemptStoreMixin +from orchestration.plan_model import PlanRevision, PlanTicket, stable_hash, utc_now +from orchestration.result import ResultEnvelope +from orchestration.store_base import ( + VERIFICATION_COMMANDS_SETTING, + ExecutionAttemptError, + PlanStoreError, + TERMINAL_ATTEMPT_STATES, + _atomic_write, + _pretty_json, +) +from orchestration.store_schema import init_db + + +class PlanStore(AttemptStoreMixin): + """Persist plan revisions, execution attempts, and audit events. + + The plan face is an OpenSpec change: ``openspec/changes//tasks.md`` + is re-read on every sync — edits (including baton's own ``[x]`` write-backs) + produce a new plan revision. `queue.db` lives at + `/.dsh/baton/queue.db`; runtime snapshots live under + `/.dsh/baton//`. + """ + + def __init__(self, project_path: str | Path, feature: str): + self.project_path = Path(project_path).resolve() + try: + self.feature = validate_change_id(feature) + except ValueError as exc: + raise PlanStoreError(str(exc)) from exc + self.job_id = self.feature + self.baton_dir = self.project_path / ".dsh" / "baton" + self.feature_dir = self.baton_dir / self.feature + self.change_dir = self.project_path / "openspec" / "changes" / self.feature + self.tasks_path = self.change_dir / "tasks.md" + self.results_dir = self.feature_dir / "results" + self.plan_path = self.feature_dir / "plan.json" + self.db_path = self.baton_dir / "queue.db" + + def ensure_initial_plan(self, approved_by: str = "baton") -> PlanRevision: + """Create revision 1 from existing Markdown tickets if needed.""" + self._ensure_dirs() + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + plan = self._ensure_initial_plan_locked(conn, approved_by) + conn.commit() + finally: + conn.close() + + self._write_plan_json(plan) + return plan + + def current_plan(self) -> PlanRevision: + """Load the latest approved plan revision.""" + self.ensure_initial_plan() + conn = self._connect() + try: + plan = self._load_current_plan_locked(conn) + if plan is None: + raise PlanStoreError("initial plan was not created") + return plan + finally: + conn.close() + + + + + + + + + + + + def run_verification_commands(self) -> list[str]: + """Return the verification commands persisted for this run, if any. + + The controller persists the commands on the first claim so every later + packet for the run reuses them even when the CLI call that follows does + not repeat ``--verify``. + """ + self.ensure_initial_plan() + conn = self._connect() + try: + row = conn.execute( + "SELECT value_json FROM run_settings " + "WHERE job_id = ? AND setting_key = ?", + (self.job_id, VERIFICATION_COMMANDS_SETTING), + ).fetchone() + finally: + conn.close() + if row is None: + return [] + try: + value = json.loads(row["value_json"]) + except (TypeError, json.JSONDecodeError) as exc: + raise PlanStoreError( + f"run settings {VERIFICATION_COMMANDS_SETTING} are corrupt: {exc}" + ) from exc + if not isinstance(value, list): + raise PlanStoreError( + f"run settings {VERIFICATION_COMMANDS_SETTING} must be a list, " + f"got {type(value).__name__}" + ) + return [str(item) for item in value] + + def set_run_verification_commands(self, commands: list[str]) -> None: + """Persist the run-wide verification commands (explicit override wins).""" + normalized = [str(command) for command in commands] + if not normalized: + raise PlanStoreError("verification commands must not be empty") + self.ensure_initial_plan() + now = utc_now() + payload = json.dumps(normalized, ensure_ascii=False, sort_keys=True) + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + conn.execute( + "INSERT INTO run_settings (job_id, setting_key, value_json, updated_at) " + "VALUES (?, ?, ?, ?) ON CONFLICT(job_id, setting_key) DO UPDATE SET " + "value_json = excluded.value_json, updated_at = excluded.updated_at", + (self.job_id, VERIFICATION_COMMANDS_SETTING, payload, now), + ) + self._event_locked( + conn, + "run.settings.verification-commands", + {"commands": normalized}, + ) + conn.commit() + finally: + conn.close() + + + def result_envelopes(self) -> list[ResultEnvelope]: + """Return persisted Result Envelopes for the current job.""" + self.ensure_initial_plan() + conn = self._connect() + try: + rows = conn.execute( + "SELECT payload_json FROM result_envelopes " + "WHERE job_id = ? ORDER BY created_at, envelope_id", + (self.job_id,), + ).fetchall() + return [ + ResultEnvelope.from_dict(json.loads(row["payload_json"])) + for row in rows + ] + finally: + conn.close() + + def latest_completion(self) -> dict[str, Any]: + """Check completion against the latest approved revision's required tickets.""" + plan = self.current_plan() + required = {t.num for t in plan.tickets if t.required} + status_by_num = {t.num: t.status for t in self._read_source_tickets()} + conn = self._connect() + try: + rows = conn.execute( + "SELECT ticket_num FROM result_envelopes " + "WHERE job_id = ? AND status = 'succeeded'", + (self.job_id,), + ).fetchall() + for row in rows: + status_by_num[int(row["ticket_num"])] = "resolved" + finally: + conn.close() + missing = sorted( + n for n in required if status_by_num.get(n) != "resolved" + ) + return { + "jobId": self.job_id, + "revision": plan.revision, + "complete": not missing, + "missingRequiredTickets": missing, + } + + def snapshot(self) -> dict[str, Any]: + """Read the run state for a progress report — creating nothing. + + ``status`` must be safe to call on a project baton never ran, and must + not leave runtime artifacts behind (a typo'd change-id is a read, not a + write). It therefore never syncs a plan revision, never writes + ``plan.json`` and never touches the database unless the store file + already exists. + """ + tickets = self._read_source_tickets() + resolved = {ticket.num for ticket in tickets if ticket.status == "resolved"} + attempts: list[dict[str, Any]] = [] + revision = 0 + if self.db_path.is_file(): + conn = sqlite3.connect(self.db_path, timeout=30) + conn.row_factory = sqlite3.Row + try: + row = conn.execute( + "SELECT MAX(revision) AS revision FROM plan_revisions " + "WHERE job_id = ?", + (self.job_id,), + ).fetchone() + revision = int(row["revision"] or 0) + attempts = [ + self._execution_attempt_from_row(attempt_row).to_dict() + | {"claim_token": ""} + for attempt_row in conn.execute( + "SELECT * FROM execution_attempts WHERE job_id = ? " + "ORDER BY ticket_num, attempt", + (self.job_id,), + ) + ] + resolved.update( + int(row["ticket_num"]) + for row in conn.execute( + "SELECT ticket_num FROM result_envelopes " + "WHERE job_id = ? AND status = 'succeeded'", + (self.job_id,), + ) + ) + finally: + conn.close() + missing = sorted(ticket.num for ticket in tickets if ticket.num not in resolved) + return { + "revision": revision, + "complete": not missing, + "missingRequiredTickets": missing, + "attempts": attempts, + } + + def events(self) -> list[dict[str, Any]]: + self.ensure_initial_plan() + conn = self._connect() + try: + rows = conn.execute( + "SELECT event_type, created_at, payload_json FROM events " + "WHERE job_id = ? ORDER BY id", + (self.job_id,), + ).fetchall() + return [ + { + "eventType": row["event_type"], + "createdAt": row["created_at"], + "payload": json.loads(row["payload_json"]), + } + for row in rows + ] + finally: + conn.close() + + def _ensure_dirs(self) -> None: + self.baton_dir.mkdir(parents=True, exist_ok=True) + self.feature_dir.mkdir(parents=True, exist_ok=True) + self.results_dir.mkdir(parents=True, exist_ok=True) + + def _connect(self) -> sqlite3.Connection: + self._ensure_dirs() + conn = sqlite3.connect(self.db_path, timeout=30, isolation_level=None) + conn.row_factory = sqlite3.Row + init_db(conn) + return conn + + def _read_source_tickets(self) -> list[Ticket]: + """Parse the change's tasks.md — the single ticket source.""" + try: + return parse_tasks_md(self.tasks_path) + except TasksMarkdownError as exc: + raise PlanStoreError(str(exc)) from exc + + @staticmethod + def _tickets_hash(tickets: list[PlanTicket]) -> str: + """Hash the ticket set for change detection. + + ``source_revision`` is projected out: candidates are parsed with the + *next* revision number stamped in, so including it would make every + sync look like a change (runaway revision creep). + """ + def projection(ticket: PlanTicket) -> dict[str, Any]: + data = ticket.to_dict() + data["source_revision"] = 0 + return data + + return stable_hash([projection(t) for t in tickets]) + + def _plan_tickets_from_source(self, revision: int) -> list[PlanTicket]: + return [ + PlanTicket( + num=parsed.num, + title=parsed.title, + what_to_build=parsed.what_to_build, + blocked_by=parsed.blocked_by, + status=parsed.status, + criteria=[text for _, text in parsed.criteria], + source_revision=revision, + ) + for parsed in self._read_source_tickets() + ] + + def _ensure_initial_plan_locked( + self, + conn: sqlite3.Connection, + approved_by: str = "openspec/tasks.md", + ) -> PlanRevision: + """Sync the durable plan with the OpenSpec change's tasks.md. + + No plan yet → revision 1. tasks.md changed since the latest revision + (human edits, added tasks, or baton's own ``[x]`` write-backs) → a + fresh revision, so the next frontier reflects the current file. In-flight + attempts keep their immutable packets and are never rewritten. + """ + current = self._load_current_plan_locked(conn) + candidate_revision = 1 if current is None else current.revision + 1 + tickets = self._plan_tickets_from_source(candidate_revision) + if ( + current is not None + and self._tickets_hash(tickets) == self._tickets_hash(current.tickets) + ): + return current + + now = utc_now() + plan = PlanRevision( + job_id=self.job_id, + feature=self.feature, + revision=candidate_revision, + parent_revision=None if current is None else current.revision, + plan_hash="", + approved_by=approved_by, + approved_at=now, + created_at=now, + tickets=tickets, + ) + plan.plan_hash = stable_hash(plan.hashable_dict()) + self._insert_revision_locked(conn, plan) + self._event_locked( + conn, + "plan.revision.initialized" if current is None else "plan.revision.updated", + { + "revision": plan.revision, + "parentRevision": plan.parent_revision, + "planHash": plan.plan_hash, + "source": f"openspec/changes/{self.feature}/tasks.md", + }, + ) + return plan + + + def _load_current_plan_locked( + self, + conn: sqlite3.Connection, + ) -> PlanRevision | None: + row = conn.execute( + "SELECT snapshot_json FROM plan_revisions " + "WHERE job_id = ? ORDER BY revision DESC LIMIT 1", + (self.job_id,), + ).fetchone() + if row is None: + return None + return self._plan_from_snapshot_json(row["snapshot_json"]) + + def _plan_from_snapshot_json(self, snapshot_json: str) -> PlanRevision: + """Materialize the plan revision stored in a snapshot.""" + return PlanRevision.from_dict(json.loads(snapshot_json)) + + def _insert_revision_locked( + self, + conn: sqlite3.Connection, + plan: PlanRevision, + ) -> None: + conn.execute( + "INSERT INTO plan_revisions " + "(job_id, feature, revision, parent_revision, plan_hash, approved_by, " + "approved_at, created_at, snapshot_json) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + plan.job_id, + plan.feature, + plan.revision, + plan.parent_revision, + plan.plan_hash, + plan.approved_by, + plan.approved_at, + plan.created_at, + json.dumps(plan.to_dict(), ensure_ascii=False, sort_keys=True), + ), + ) + + + def _event_locked( + self, + conn: sqlite3.Connection, + event_type: str, + payload: dict[str, Any], + ) -> None: + conn.execute( + "INSERT INTO events " + "(job_id, feature, event_type, created_at, payload_json) " + "VALUES (?, ?, ?, ?, ?)", + ( + self.job_id, + self.feature, + event_type, + utc_now(), + json.dumps(payload, ensure_ascii=False, sort_keys=True), + ), + ) + + + + + + def _write_plan_json(self, plan: PlanRevision) -> None: + _atomic_write(self.plan_path, _pretty_json(plan.to_dict())) + + + def _result_envelope_id(self, envelope: ResultEnvelope) -> str: + if envelope.assignment_id: + return f"RE-{envelope.assignment_id}" + return f"RE-{self.feature}-{envelope.ticket_num:03d}" + + def _write_result_envelope_json( + self, + envelope_id: str, + envelope: ResultEnvelope, + ) -> None: + path = self.results_dir / f"{envelope_id}.json" + _atomic_write(path, _pretty_json(envelope.to_dict())) diff --git a/external_plugins/baton/scripts/orchestration/receipt.py b/external_plugins/baton/scripts/orchestration/receipt.py new file mode 100644 index 00000000..70ef943f --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/receipt.py @@ -0,0 +1,322 @@ +"""Deterministic verification and sealed receipts for session assignments.""" + +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +from pathlib import Path +from typing import Any + +from orchestration.model import TaskPacket +from orchestration.result import ResultEnvelope +from orchestration.session_model import finalize_task_packet + + +class ReceiptError(RuntimeError): + """The assignment cannot produce a trusted receipt.""" + + +RECEIPT_HASH_FIELD = "receiptHash" +RECEIPT_SEGMENTS_FIELD = "receiptSegments" + + +def canonical_receipt_json(body: dict[str, Any]) -> str: + """Serialize a receipt body canonically for digesting. + + The canonical form is ``json.dumps`` with sorted object keys at every + level, no insignificant whitespace (``separators=(",", ":")``) and + ``ensure_ascii=False`` so the digest covers the same bytes regardless of + how a writer escapes non-ASCII text. Only the ``receiptHash`` field is + excluded; every other field (including ``receiptSegments``) is covered. + """ + return json.dumps(body, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def compute_receipt_hash(receipt: dict[str, Any]) -> str: + """Return ``sha256`` over the canonical body, excluding ``receiptHash``.""" + body = {key: value for key, value in receipt.items() if key != RECEIPT_HASH_FIELD} + return hashlib.sha256(canonical_receipt_json(body).encode("utf-8")).hexdigest() + + +def _segment_digest(value: Any) -> str: + payload = value if isinstance(value, str) else canonical_receipt_json(value) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def compute_receipt_segments(receipt: dict[str, Any]) -> dict[str, str]: + """Return per-field digests used to name the segment a tamper touched. + + These digests are only a diagnostic aid: the authoritative integrity + check is :func:`compute_receipt_hash` over the whole body (which includes + this table). Listing every top-level field, every list element and every + verification-result field lets a rejected receipt report e.g. + ``verification[0].exitCode`` instead of "something changed". + """ + segments: dict[str, str] = {} + for key in sorted(receipt): + if key in {RECEIPT_HASH_FIELD, RECEIPT_SEGMENTS_FIELD}: + continue + value = receipt[key] + if isinstance(value, list): + segments[f"{key}.length"] = _segment_digest(len(value)) + for index, item in enumerate(value): + if isinstance(item, dict): + for subkey in sorted(item): + segments[f"{key}[{index}].{subkey}"] = _segment_digest( + item[subkey] + ) + else: + segments[f"{key}[{index}]"] = _segment_digest(item) + else: + segments[key] = _segment_digest(value) + return segments + + +def verify_receipt_body(receipt: dict[str, Any], *, source: str = "receipt") -> None: + """Recompute the receipt digest from disk and reject any mismatch. + + Raises: + ReceiptError: the receipt predates sealing (no ``receiptHash``) or its + body no longer matches the digest. The message names the changed + segment(s) when the (sealed) segment table can still tell. + """ + stored = receipt.get(RECEIPT_HASH_FIELD) + if not isinstance(stored, str) or not stored: + raise ReceiptError( + f"{source} is not sealed: missing {RECEIPT_HASH_FIELD} " + "(re-seal it with `receipt.py seal`)" + ) + actual = compute_receipt_hash(receipt) + if stored == actual: + return + changed: list[str] = [] + stored_segments = receipt.get(RECEIPT_SEGMENTS_FIELD) + if isinstance(stored_segments, dict): + current = compute_receipt_segments(receipt) + changed = sorted( + key + for key in set(current) | set(stored_segments) + if current.get(key) != stored_segments.get(key) + ) + if changed: + detail = "changed segment(s): " + ", ".join(changed[:8]) + if len(changed) > 8: + detail += f" (+{len(changed) - 8} more)" + else: + detail = ( + "no segment digest pins the edit " + f"(the {RECEIPT_HASH_FIELD} or {RECEIPT_SEGMENTS_FIELD} table was rewritten)" + ) + raise ReceiptError( + f"{source} body digest mismatch: {detail}; " + f"stored {RECEIPT_HASH_FIELD}={stored[:12]}..., recomputed={actual[:12]}..." + ) + + +def verify_sealed_receipt( + receipt: dict[str, Any], + *, + claim_token: str, + task_packet_hash: str = "", +) -> ResultEnvelope: + """Verify a receipt read from disk and return its envelope. + + Checks the body digest first (so an edited ``exitCode``/``changed_files``/ + ``commit_sha`` is rejected before anything is integrated), then the claim + token hash and, when given, the task packet hash. + """ + verify_receipt_body(receipt, source="sealed receipt") + expected_token_hash = hashlib.sha256(claim_token.encode("utf-8")).hexdigest() + if receipt.get("claimTokenHash") != expected_token_hash: + raise ReceiptError("sealed receipt claim token mismatch") + if task_packet_hash and receipt.get("task_packet_hash") != task_packet_hash: + raise ReceiptError("sealed receipt task packet hash mismatch") + try: + return ResultEnvelope.from_dict(receipt) + except (KeyError, TypeError, ValueError) as exc: + raise ReceiptError(f"malformed sealed receipt: {exc}") from exc + + +def shell_command(command: str, *, platform: str | None = None) -> list[str]: + """Wrap a verification command in the platform's bootstrap shell.""" + name = os.name if platform is None else platform + if name == "nt": + return ["powershell", "-NoProfile", "-NonInteractive", "-Command", command] + return ["/bin/sh", "-lc", command] + + +def _atomic_json(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "w", encoding="utf-8") as handle: + json.dump(data, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + + +def _load_packet(packet_path: str | Path) -> tuple[Path, dict[str, Any], TaskPacket]: + path = Path(packet_path).resolve() + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ReceiptError(f"cannot read packet {path}: {exc}") from exc + raw_task = payload.get("taskPacket") + if not isinstance(raw_task, dict): + raise ReceiptError("packet is missing taskPacket") + task = TaskPacket.from_dict(raw_task) + expected = task.task_packet_hash + actual = finalize_task_packet(TaskPacket.from_dict(raw_task)).task_packet_hash + if not expected or expected != actual: + raise ReceiptError("task packet hash mismatch") + if not payload.get("assignmentId") or not payload.get("claimToken"): + raise ReceiptError("packet is missing assignment identity or claim token") + return path, payload, task + + +def _run_git(worktree: Path, args: list[str]) -> str: + result = subprocess.run( + ["git", *args], + cwd=str(worktree), + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode != 0: + raise ReceiptError( + result.stderr.strip() or result.stdout.strip() or f"git {' '.join(args)} failed" + ) + return result.stdout.strip() + + +def _verification_path(payload: dict[str, Any], packet_path: Path) -> Path: + raw = payload.get("verificationPath") + return Path(raw).resolve() if raw else packet_path.with_name("verification.json") + + +def _receipt_path(payload: dict[str, Any], packet_path: Path) -> Path: + raw = payload.get("receiptPath") + return Path(raw).resolve() if raw else packet_path.with_name("receipt.json") + + +def _run_verification_commands(worktree: Path, commands: list[str]) -> list[dict[str, Any]]: + if not commands: + raise ReceiptError("task packet has no verification commands") + results: list[dict[str, Any]] = [] + for command in commands: + completed = subprocess.run( + shell_command(command), + cwd=str(worktree), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=1800, + check=False, + ) + output = (completed.stdout or "") + (completed.stderr or "") + results.append( + { + "command": command, + "exitCode": completed.returncode, + "outputSha256": hashlib.sha256(output.encode("utf-8")).hexdigest(), + "outputTail": output[-2000:], + } + ) + if completed.returncode != 0: + raise ReceiptError(f"verification failed: {command}") + return results + + +def verify_packet(packet_path: str | Path) -> dict[str, Any]: + """Run the packet's declared checks as a pre-commit feedback loop.""" + path, payload, task = _load_packet(packet_path) + worktree = Path(payload["worktreePath"]).resolve() + results = _run_verification_commands(worktree, task.verification_commands) + report = { + "assignmentId": payload["assignmentId"], + "taskPacketHash": task.task_packet_hash, + "worktreePath": str(worktree), + "verification": results, + } + _atomic_json(_verification_path(payload, path), report) + return report + + +def seal_receipt(packet_path: str | Path) -> ResultEnvelope: + """Re-run checks on committed code and atomically seal a ResultEnvelope.""" + path, payload, task = _load_packet(packet_path) + worktree = Path(payload["worktreePath"]).resolve() + expected_branch = str(payload["worktreeBranch"]) + branch = _run_git(worktree, ["branch", "--show-current"]) + if branch != expected_branch: + raise ReceiptError(f"wrong branch: expected {expected_branch}, got {branch}") + status = _run_git(worktree, ["status", "--porcelain"]) + if status: + raise ReceiptError("worktree is dirty; commit all changes before sealing") + head = _run_git(worktree, ["rev-parse", "HEAD"]) + base = str(payload.get("baseSha") or task.base_commit) + if not base: + raise ReceiptError("packet has no base SHA") + if head == base: + raise ReceiptError( + "HEAD did not advance from the assignment baseline — commit the " + "ticket's work. If the ticket legitimately changes no files (a " + "verification/decision-only task), record that explicitly with " + "`git commit --allow-empty -m \'\'`" + ) + _run_git(worktree, ["merge-base", "--is-ancestor", base, head]) + verification = _run_verification_commands(worktree, task.verification_commands) + changed = [ + line.strip() + for line in _run_git(worktree, ["diff", "--name-only", f"{base}..{head}"]).splitlines() + if line.strip() + ] + if task.allowed_paths: + disallowed = [ + file + for file in changed + if not any(file == allowed or file.startswith(allowed.rstrip("/") + "/") for allowed in task.allowed_paths) + ] + if disallowed: + raise ReceiptError(f"changed files outside allowed paths: {disallowed}") + envelope = ResultEnvelope( + task_id=task.task_id, + profile_id=str(payload.get("profileId", "session-subagent")), + status="succeeded", + ticket_num=int(payload["ticketNum"]), + assignment_id=str(payload["assignmentId"]), + plan_revision=task.plan_revision, + task_packet_hash=task.task_packet_hash, + attempt=int(payload.get("attempt", 0)), + commit_sha=head, + receipt_path=str(_receipt_path(payload, path)), + changed_files=changed, + verification=verification, + ) + receipt = envelope.to_dict() + receipt["claimTokenHash"] = hashlib.sha256( + str(payload["claimToken"]).encode("utf-8") + ).hexdigest() + receipt[RECEIPT_SEGMENTS_FIELD] = compute_receipt_segments(receipt) + receipt[RECEIPT_HASH_FIELD] = compute_receipt_hash(receipt) + _atomic_json(_receipt_path(payload, path), receipt) + return envelope + + +__all__ = [ + "RECEIPT_HASH_FIELD", + "RECEIPT_SEGMENTS_FIELD", + "ReceiptError", + "canonical_receipt_json", + "compute_receipt_hash", + "compute_receipt_segments", + "seal_receipt", + "verify_packet", + "verify_receipt_body", + "verify_sealed_receipt", +] diff --git a/external_plugins/baton/scripts/orchestration/relay.py b/external_plugins/baton/scripts/orchestration/relay.py new file mode 100644 index 00000000..862cb959 --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/relay.py @@ -0,0 +1,251 @@ +"""Mechanical relay judgments: is an in-flight attempt alive, and what next? + +The 2026-11-06 fusion-s1 incident: a handoff session saw "no receipt + a clean +worktree" 14 minutes into a ~26-minute ticket under a 2-hour lease, concluded +the subagent was dead, and abandoned the attempt. The subagent was alive and +sealed a receipt 11 minutes later — but the abandon had already destroyed the +attempt's reconcile eligibility, so the ticket was rebuilt from scratch. All +three pieces of "evidence" actually said *alive*: the subagent flow is +write → verify → commit → seal, so a mid-flight worktree is clean by design; a +receipt only exists after that whole pipeline; and "the notice was lost" was an +unverified assumption copied from the handoff note. + +This module turns that judgment into data the CLI can print: elapsed vs the +lease budget, receipt existence, worktree activity, a heartbeat age, and a +suggested action. Everything here is read-only. +""" + +from __future__ import annotations + +import json +import subprocess +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from orchestration.session_model import ExecutionAttempt +from orchestration.store_base import TERMINAL_ATTEMPT_STATES + +#: States that mean "the subagent may still be working". ``blocked-review`` +#: and ``unknown`` are deliberately absent: both already carry failure +#: evidence, so the abandon escape hatch must stay cheap for them. +ACTIVE_ATTEMPT_STATES = frozenset( + {"leased", "spawned", "running", "submitted", "verifying", "integrating"} +) + +#: An attempt whose lease is less than this fraction spent is treated as +#: plausibly mid-flight: abandoning it is refused without ``--force``. +ABANDON_GUARD_FRACTION = 0.8 + +HEARTBEAT_NAME = "progress" + + +def _now() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def _parse(ts: str) -> datetime | None: + try: + return datetime.fromisoformat(ts) + except (TypeError, ValueError): + return None + + +def _age_sec(now: datetime, ts: str) -> int | None: + parsed = _parse(ts) + if parsed is None: + return None + return max(0, int((now - parsed).total_seconds())) + + +def _heartbeat_path(attempt: ExecutionAttempt) -> Path | None: + if not attempt.packet_path: + return None + return Path(attempt.packet_path).parent / HEARTBEAT_NAME + + +def _file_age_sec(path: Path) -> int | None: + try: + mtime = path.stat().st_mtime + except OSError: + return None + return max(0, int(time.time() - mtime)) + + +def _worktree_signals(attempt: ExecutionAttempt) -> dict[str, Any]: + """Weak liveness signals from the assignment worktree — read-only. + + A mid-flight worktree is *expected* to be clean (the subagent commits only + right before sealing), so ``dirty: false`` is not a death verdict. The + signals that matter are ages: freshly touched files (dirty or index) or a + fresh heartbeat mean "alive"; everything missing or stale is simply + uninformative and reported as such. + """ + path = Path(attempt.worktree_path) if attempt.worktree_path else None + if path is None or not path.is_dir(): + return {"exists": False, "dirty": None, "lastActivityAgeSec": None} + signals: dict[str, Any] = {"exists": True, "dirty": None, "lastActivityAgeSec": None} + candidates: list[int] = [] + try: + proc = subprocess.run( + ["git", "status", "--porcelain"], + cwd=str(path), + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return signals + if proc.returncode != 0: + signals["note"] = "git status failed" + return signals + lines = [line for line in proc.stdout.splitlines() if line.strip()] + signals["dirty"] = bool(lines) + for line in lines: + # porcelain v1: "XY ", rename targets after " -> " + name = line[3:].split(" -> ")[-1].strip().strip('"') + age = _file_age_sec(path / name) + if age is not None: + candidates.append(age) + try: + proc = subprocess.run( + ["git", "rev-parse", "--git-path", "index"], + cwd=str(path), + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if proc.returncode == 0: + index_age = _file_age_sec(path / proc.stdout.strip()) + if index_age is not None: + candidates.append(index_age) + except (OSError, subprocess.SubprocessError): + pass + if candidates: + signals["lastActivityAgeSec"] = min(candidates) + return signals + + +def relay_facts(attempt: ExecutionAttempt) -> dict[str, Any]: + """Everything a relay decision needs, computed from one attempt row.""" + now = _now() + elapsed = _age_sec(now, attempt.created_at) + remaining: int | None = None + fraction: float | None = None + expires = _parse(attempt.lease_expires_at) + created = _parse(attempt.created_at) + if expires is not None and created is not None: + total = (expires - created).total_seconds() + if total > 0: + remaining = int((expires - now).total_seconds()) + fraction = round(max(0.0, 1.0 - remaining / total), 3) + worktree = _worktree_signals(attempt) + heartbeat = _heartbeat_path(attempt) + if heartbeat is not None: + # The heartbeat lives in the run directory (outside the worktree, so + # touching it never dirties a seal), and survives worktree removal. + worktree["heartbeatAgeSec"] = _file_age_sec(heartbeat) + return { + "elapsedSec": elapsed, + "leaseRemainingSec": remaining, + "leaseElapsedFraction": fraction, + "receiptExists": bool( + attempt.receipt_path and Path(attempt.receipt_path).is_file() + ), + "worktree": worktree, + } + + +def suggest_action(attempt: ExecutionAttempt, facts: dict[str, Any]) -> str: + """One coarse verdict per attempt, for a relay session to read aloud.""" + if attempt.state in TERMINAL_ATTEMPT_STATES: + if attempt.state == "abandoned" and facts["receiptExists"]: + return "adopt" + return "none" + if facts["receiptExists"]: + return "reconcile" + if attempt.state == "blocked-review": + return "resolve-or-abandon" + if attempt.state == "unknown": + return "decide" + if attempt.failure_code: + return "decide" + remaining = facts["leaseRemainingSec"] + if remaining is not None and remaining <= 0: + return "await-expiry" + if ( + facts["leaseElapsedFraction"] is not None + and facts["leaseElapsedFraction"] >= ABANDON_GUARD_FRACTION + ): + return "wait-lease-nearly-spent" + return "wait" + + +def abandon_refusal( + attempt: ExecutionAttempt, facts: dict[str, Any] +) -> dict[str, Any] | None: + """The guard payload when abandon would destroy plausibly-live work. + + Returns ``None`` when abandoning is reasonable (failure evidence present, + lease expired or nearly spent, or the attempt already terminal) — the + escape hatch must stay cheap exactly where it is needed. A refusal is + data, not a block: ``--force`` bypasses it. + """ + if attempt.state not in ACTIVE_ATTEMPT_STATES: + return None + if attempt.failure_code: + return None + remaining = facts["leaseRemainingSec"] + fraction = facts["leaseElapsedFraction"] + if facts["receiptExists"]: + reason = ( + "a sealed receipt already exists for this attempt — abandoning " + "would discard verified work" + ) + advice = "reconcile the receipt (adopt it if the attempt was abandoned)" + elif remaining is not None and remaining > 0 and fraction is not None: + if fraction >= ABANDON_GUARD_FRACTION: + return None + reason = ( + f"no failure evidence and only {int(fraction * 100)}% of the lease " + f"has elapsed ({remaining}s remaining) — the attempt is plausibly " + "still working" + ) + advice = ( + "wait for the completion notice; a clean worktree mid-ticket is " + "normal (write → verify → commit → seal), not a death verdict" + ) + else: + # Unreadable budget (or an expired lease): never block the hatch. + return None + return { + "assignmentId": attempt.assignment_id, + "state": attempt.state, + "elapsedSec": facts["elapsedSec"], + "leaseRemainingSec": remaining, + "leaseElapsedFraction": fraction, + "receiptExists": facts["receiptExists"], + "worktree": facts["worktree"], + "reason": reason, + "suggestedAction": suggest_action(attempt, facts), + "advice": advice, + "force": "pass --force to abandon anyway", + } + + +def refusal_message(refusal: dict[str, Any]) -> str: + return "abandon refused — " + json.dumps(refusal, ensure_ascii=False) + + +__all__ = [ + "ABANDON_GUARD_FRACTION", + "ACTIVE_ATTEMPT_STATES", + "HEARTBEAT_NAME", + "abandon_refusal", + "refusal_message", + "relay_facts", + "suggest_action", +] diff --git a/external_plugins/baton/scripts/orchestration/result.py b/external_plugins/baton/scripts/orchestration/result.py new file mode 100644 index 00000000..b7769094 --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/result.py @@ -0,0 +1,56 @@ +"""Result Envelope contract for Baton agent outputs.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any + +from orchestration.plan_model import utc_now + + +@dataclass +class ResultEnvelope: + """Structured output returned by a worker/agent for one task.""" + + task_id: str + profile_id: str + status: str + ticket_num: int + assignment_id: str + plan_revision: int + task_packet_hash: str + attempt: int = 0 + commit_sha: str = "" + receipt_path: str = "" + integration_sha: str = "" + changed_files: list[str] = field(default_factory=list) + verification: list[dict[str, Any]] = field(default_factory=list) + artifacts: list[str] = field(default_factory=list) + findings: list[str] = field(default_factory=list) + proposed_followups: list[str] = field(default_factory=list) + created_at: str = field(default_factory=utc_now) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ResultEnvelope": + return cls( + task_id=str(data["task_id"]), + profile_id=str(data.get("profile_id", "")), + status=str(data["status"]), + ticket_num=int(data["ticket_num"]), + assignment_id=str(data.get("assignment_id", "")), + plan_revision=int(data.get("plan_revision", 0)), + task_packet_hash=str(data.get("task_packet_hash", "")), + attempt=int(data.get("attempt", 0)), + commit_sha=str(data.get("commit_sha", "")), + receipt_path=str(data.get("receipt_path", "")), + integration_sha=str(data.get("integration_sha", "")), + changed_files=[str(x) for x in data.get("changed_files", [])], + verification=[dict(x) for x in data.get("verification", [])], + artifacts=[str(x) for x in data.get("artifacts", [])], + findings=[str(x) for x in data.get("findings", [])], + proposed_followups=[str(x) for x in data.get("proposed_followups", [])], + created_at=str(data.get("created_at", utc_now())), + ) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) diff --git a/external_plugins/baton/scripts/orchestration/session_controller.py b/external_plugins/baton/scripts/orchestration/session_controller.py new file mode 100644 index 00000000..b92284d3 --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/session_controller.py @@ -0,0 +1,861 @@ +"""Deterministic session-subagent scheduling and reconciliation.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +from orchestration.integrator import integrate_assignment +from orchestration.model import ROLE_BUILDER, TaskPacket +from orchestration.plan_store import ( + ExecutionAttemptError, + PlanStore, + TERMINAL_ATTEMPT_STATES, +) +from orchestration.receipt import ReceiptError, verify_sealed_receipt +from orchestration.relay import ( + HEARTBEAT_NAME, + abandon_refusal, + refusal_message, + relay_facts, + suggest_action, +) +from orchestration.result import ResultEnvelope +from orchestration.worktree import cleanup_feature_worktrees +from orchestration.session_model import ( + ActionBatch, + BaseAction, + BlockedAction, + CompleteAction, + DispatchAction, + ExecutionAttempt, + RecoverAction, + ResolveMergeAction, + WaitAction, + finalize_task_packet, +) +from orchestration.worktree import ( + WorktreeError, + prepare_assignment_worktree, + prepare_integration_worktree, +) + + +class SessionControllerError(RuntimeError): + """A session action cannot be produced from the durable state.""" + + +class SessionController: + """The sole deterministic control plane for native session subagents.""" + + def __init__( + self, + project_path: str | Path, + feature: str, + *, + verification_commands: list[str] | None = None, + lease_seconds: int = 7200, + ) -> None: + self.project_path = Path(project_path).resolve() + self.feature = feature + self.store = PlanStore(self.project_path, feature) + # No implicit fallback: an unconfigured run must fail loud instead of + # silently verifying with ``git diff --check``. Commands passed here + # are persisted on the first batch and reused by every later packet. + self.verification_commands = list(verification_commands or []) + self.lease_seconds = lease_seconds + + def actions( + self, + session_id: str, + capacity: int = 1, + base_branch: str = "main", + ) -> ActionBatch: + """Recover durable attempts, then claim and prepare remaining capacity.""" + if not session_id.strip(): + raise ValueError("session_id is required") + if capacity < 1 or capacity > 5: + raise ValueError("capacity must be between 1 and 5") + self.store.ensure_initial_plan() + # A lease that ran out is no longer owned: retire it before planning so + # a stale attempt is never re-dispatched as if it were still running. + self.store.expire_execution_attempts() + verification_commands = self._run_verification_commands() + + try: + integration = prepare_integration_worktree( + self.project_path, self.feature, base_branch=base_branch + ) + integration_sha = self._git(integration.path, "rev-parse", "HEAD") + except WorktreeError as exc: + return self._batch( + "blocked", + [BlockedAction(code="integration-worktree", reason=str(exc))], + ) + + produced: list[Any] = [] + # Pass 1: reconcile every valid sealed receipt, including receipts whose + # attempt lease ran out (the evidence is re-verified, not trusted by age). + for attempt in self.store.list_execution_attempts(): + if attempt.state in TERMINAL_ATTEMPT_STATES: + continue + token = self._token_from_packet(attempt.packet_path) + if not token or attempt.failure_code.startswith("receipt-invalid"): + continue + if attempt.receipt_path and Path(attempt.receipt_path).is_file(): + action = self._reconcile_sealed(attempt, token) + if action is not None: + produced.append(action) + + # Pass 2: resolve stale attempts, re-dispatch this session's claims and + # surface only recoveries that still need an operator decision. + attempts = self.store.list_execution_attempts() + recover_actions: list[RecoverAction] = [] + retryable_recover_ids: set[str] = set() + for attempt in attempts: + if attempt.state in TERMINAL_ATTEMPT_STATES: + continue + token = self._token_from_packet(attempt.packet_path) + if attempt.failure_code.startswith("receipt-invalid"): + recover_actions.append( + RecoverAction( + assignment_id=attempt.assignment_id, + ticket_num=attempt.ticket_num, + reason=attempt.failure_code, + ) + ) + continue + if attempt.receipt_path and Path(attempt.receipt_path).is_file() and token: + continue + if attempt.state == "unknown": + successor = self._successor_with_sealed_receipt(attempt, attempts) + if successor is not None: + self.store.supersede_execution_attempt( + attempt.assignment_id, + success_assignment_id=successor.assignment_id, + failure_code="superseded-by-successor-receipt", + ) + continue + reason = attempt.failure_code or "attempt outcome is unknown" + recover_actions.append( + RecoverAction( + assignment_id=attempt.assignment_id, + ticket_num=attempt.ticket_num, + reason=reason, + ) + ) + if reason == "lease-expired": + # A dead lease is retryable: allow the claim below to + # replace this attempt instead of deadlocking the run. + retryable_recover_ids.add(attempt.assignment_id) + elif attempt.state == "blocked-review": + produced.append(self._merge_action(attempt, token)) + elif attempt.session_id == session_id and attempt.state == "leased" and token: + produced.append(self._dispatch_from_attempt(attempt, token)) + + blocking_recover = [ + action + for action in recover_actions + if action.assignment_id not in retryable_recover_ids + ] + if not any( + isinstance(action, (ResolveMergeAction, BlockedAction)) + for action in produced + ) and not blocking_recover: + claimed = self.store.claim_ready_assignments( + session_id=session_id, + capacity=capacity, + base_branch=base_branch, + base_sha=integration_sha, + lease_seconds=self.lease_seconds, + ) + for attempt in claimed: + try: + produced.append( + self._prepare_dispatch( + attempt, + integration.path, + integration.branch, + verification_commands, + ) + ) + except Exception as exc: + self.store.mark_execution_attempt_state( + attempt.assignment_id, + attempt.claim_token, + "unknown", + failure_code=f"prepare-failed:{exc}", + release_lease=True, + ) + produced.append( + RecoverAction( + assignment_id=attempt.assignment_id, + ticket_num=attempt.ticket_num, + reason=f"assignment preparation failed: {exc}", + ) + ) + # A retry replaces the stale attempt for that ticket: retire the + # old attempt now so it never emits ``recover`` again. + claimed_by_ticket = { + attempt.ticket_num: attempt.assignment_id for attempt in claimed + } + if claimed_by_ticket and recover_actions: + remaining: list[RecoverAction] = [] + for action in recover_actions: + successor_id = claimed_by_ticket.get(action.ticket_num) + if successor_id is None: + remaining.append(action) + continue + try: + self.store.supersede_execution_attempt( + action.assignment_id, + success_assignment_id=successor_id, + failure_code="superseded-by-retry", + ) + except ExecutionAttemptError: + # It became terminal between pass 2 and the claim; the + # successor still owns the ticket, so drop the recovery. + pass + recover_actions = remaining + produced.extend(recover_actions) + + if produced: + state = "blocked" if any( + isinstance(action, (RecoverAction, ResolveMergeAction, BlockedAction)) + for action in produced + ) else "executing" + return self._batch(state, produced) + + completion = self.store.latest_completion() + if completion["complete"]: + plan = self.store.current_plan() + # A completed run's per-assignment worktrees (baton/-tic-*) + # are stale — their work is already integrated into the integration + # branch. Auto-remove them. The shared integration worktree stays so + # the user can merge baton/ to main, then `cleanup + # --include-integration` removes it. + cleanup = cleanup_feature_worktrees(self.project_path, self.feature) + return self._batch( + "complete", + [ + CompleteAction( + revision=int(completion["revision"]), + resolved_tickets=sorted( + ticket.num for ticket in plan.tickets if ticket.required + ), + worktrees_removed=cleanup["removed"], + worktrees_skipped=cleanup["skipped"], + ) + ], + ) + + active = [ + attempt.assignment_id + for attempt in self.store.list_execution_attempts() + if attempt.state in { + "leased", "spawned", "running", "submitted", "verifying", "integrating" + } + ] + return self._batch( + "waiting", + [ + WaitAction( + reason="no newly dispatchable ticket; required work remains", + active_assignments=active, + ) + ], + ) + + def bind_agent( + self, assignment_id: str, claim_token: str, agent_id: str + ) -> ExecutionAttempt: + return self.store.bind_attempt_agent(assignment_id, claim_token, agent_id) + + def abandon( + self, + assignment_id: str, + *, + reason: str = "operator-abandoned", + force: bool = False, + ) -> dict[str, Any]: + """Retire a stuck attempt and tell the operator exactly what that costs. + + Guarded: abandoning an attempt that is plausibly still working (active + state, no failure evidence, no sealed receipt, most of its lease left) + is refused with the judgment data — ``force=True`` bypasses the guard + for an operator who has looked at it. + """ + attempt = self.store.get_execution_attempt(assignment_id) + refusal = None if force else abandon_refusal(attempt, relay_facts(attempt)) + if refusal is not None: + raise SessionControllerError(refusal_message(refusal)) + attempt = self.store.abandon_execution_attempt(assignment_id, reason=reason) + return { + "assignmentId": attempt.assignment_id, + "ticketNum": attempt.ticket_num, + "state": attempt.state, + "failureCode": attempt.failure_code, + "destroys": ( + "this attempt's lease and its eligibility for reconcile; any " + "later receipt from it is ignored unless explicitly revived " + "with `adopt`" + ), + "preserves": ( + "the assignment worktree, branch, commits, packet and receipt " + "on disk" + ), + "next": "the ticket can be claimed again by the next `actions` call", + } + + def adopt(self, assignment_id: str) -> dict[str, Any]: + """Revive an abandoned attempt whose sealed receipt arrived late. + + The 2026-11-06 relay incident: an abandoned subagent finished and + sealed a valid receipt after the abandon, and the protocol threw the + evidence away — the ticket was rebuilt from scratch. ``adopt`` re-enters + the receipt into the normal verify → integrate path instead. The + receipt is fully re-verified (body digest, claim token, packet hash, + identity) *before* any state changes, and a newer attempt for the same + ticket with its own sealed evidence refuses the adopt: a human must + choose between the two. + """ + attempt = self.store.get_execution_attempt(assignment_id) + if attempt.state != "abandoned": + raise SessionControllerError( + f"only abandoned attempts can be adopted; " + f"{assignment_id} is {attempt.state}" + ) + if not attempt.receipt_path or not Path(attempt.receipt_path).is_file(): + raise SessionControllerError( + f"no sealed receipt for {assignment_id} at " + f"{attempt.receipt_path or ''} — nothing to adopt; the " + "abandon stands" + ) + successors = sorted( + ( + candidate + for candidate in self.store.list_execution_attempts() + if candidate.ticket_num == attempt.ticket_num + and candidate.attempt > attempt.attempt + ), + key=lambda candidate: candidate.attempt, + ) + sealed = [ + candidate + for candidate in successors + if candidate.state == "succeeded" + or ( + candidate.receipt_path + and Path(candidate.receipt_path).is_file() + and not candidate.failure_code.startswith("receipt-invalid") + ) + ] + if sealed: + raise SessionControllerError( + f"cannot adopt {assignment_id}: a newer attempt for ticket " + f"{attempt.ticket_num} already has sealed evidence. Choose " + "one: keep the successor (do nothing), or abandon the " + "successor and adopt this one. Evidence: " + + "; ".join( + f"{candidate.assignment_id} state={candidate.state} " + f"commit={candidate.commit_sha or '?'} receipt={candidate.receipt_path}" + for candidate in sealed + ) + + f" — versus {assignment_id} receipt={attempt.receipt_path}" + ) + in_flight = [ + candidate + for candidate in successors + if candidate.state + in {"leased", "spawned", "running", "submitted", "verifying", + "integrating", "blocked-review"} + ] + if in_flight: + raise SessionControllerError( + f"cannot adopt {assignment_id}: a newer attempt for ticket " + f"{attempt.ticket_num} is still in flight (" + + ", ".join( + f"{candidate.assignment_id} state={candidate.state}" + for candidate in in_flight + ) + + "). Stop it first (abandon it, or wait for its terminal " + "notice) so the ticket is not integrated twice." + ) + token = self._token_from_packet(attempt.packet_path) + if not token: + raise SessionControllerError( + f"cannot read the claim token from packet {attempt.packet_path}; " + f"without it the receipt for {assignment_id} cannot be verified" + ) + try: + payload = json.loads( + Path(attempt.receipt_path).read_text(encoding="utf-8") + ) + except (OSError, json.JSONDecodeError) as exc: + raise SessionControllerError( + f"cannot read sealed receipt for {assignment_id}: {exc}" + ) from exc + try: + envelope = verify_sealed_receipt( + payload, + claim_token=token, + task_packet_hash=attempt.task_packet_hash, + ) + self._check_receipt_identity(attempt, envelope) + except ReceiptError as exc: + raise SessionControllerError( + f"sealed receipt rejected for {assignment_id}, the attempt " + f"stays abandoned: {exc}" + ) from exc + revived = self.store.adopt_execution_attempt( + assignment_id, lease_seconds=self.lease_seconds + ) + action = self._reconcile_sealed(revived, token) + final = self.store.get_execution_attempt(assignment_id) + return { + "assignmentId": assignment_id, + "ticketNum": attempt.ticket_num, + "state": final.state, + "adoptedFrom": "abandoned", + "commitSha": final.commit_sha, + "integrationSha": final.integration_sha, + "followUp": ( + None + if action is None + else f"{action.kind} still required — handle it as `actions` " + "would (see the baton skill)" + ), + } + + def status(self) -> dict[str, Any]: + """Return a read snapshot — this creates no plan revision and no files. + + ``revision`` is 0 until the first ``actions`` call records a revision, + so "never ran" is distinguishable from "ran and progressed". Each + attempt carries a ``relay`` block — the mechanical alive/dead judgment + (elapsed vs lease budget, receipt existence, worktree activity, and a + suggested action) a handing-off or taking-over session should read + before deciding to wait or to abandon. + """ + snapshot = self.store.snapshot() + attempts: list[dict[str, Any]] = [] + for row in snapshot["attempts"]: + attempt = ExecutionAttempt.from_dict(row) + facts = relay_facts(attempt) + row["relay"] = {"suggestedAction": suggest_action(attempt, facts), **facts} + attempts.append(row) + return { + "ok": True, + "runId": f"RUN-{self.feature}", + "feature": self.feature, + "projectPath": str(self.project_path), + "revision": snapshot["revision"], + "complete": bool(snapshot["complete"]), + "missingRequiredTickets": [ + f"{num // 100}.{num % 100}" + for num in snapshot["missingRequiredTickets"] + ], + "attempts": attempts, + } + + def reconcile( + self, + assignment_id: str, + claim_token: str, + *, + agent_terminal: str, + session_id: str, + capacity: int = 1, + base_branch: str = "main", + final_text: str = "", + ) -> ActionBatch: + """Consume a terminal lifecycle event; free text is deliberately ignored.""" + del final_text + if agent_terminal not in {"completed", "failed", "canceled"}: + raise ValueError("agent_terminal must be completed, failed, or canceled") + attempt = self.store.get_execution_attempt(assignment_id) + if attempt.state in TERMINAL_ATTEMPT_STATES and attempt.state != "succeeded": + # The attempt was retired (superseded by a retry, or abandoned); a + # late terminal notice cannot change it, and must not wedge the run. + return self.actions(session_id, capacity=capacity, base_branch=base_branch) + if attempt.state != "succeeded": + if not attempt.receipt_path or not Path(attempt.receipt_path).is_file(): + self.store.mark_execution_attempt_state( + assignment_id, + claim_token, + "unknown", + failure_code=f"agent-{agent_terminal}-without-sealed-receipt", + release_lease=True, + ) + else: + self._reconcile_sealed(attempt, claim_token) + return self.actions(session_id, capacity=capacity, base_branch=base_branch) + + def _run_verification_commands(self) -> list[str]: + """Resolve the run-wide commands, persist an explicit override, fail loud. + + ``--verify`` is persisted with the run on the first claim, so a later + ``reconcile``/``actions`` call that omits the flag keeps using the same + commands instead of falling back to some implicit default. + """ + if self.verification_commands: + commands = list(self.verification_commands) + if self.store.run_verification_commands() != commands: + self.store.set_run_verification_commands(commands) + return commands + stored = self.store.run_verification_commands() + if stored: + return stored + if self.store.latest_completion()["complete"]: + return [] + raise SessionControllerError( + f"no verification commands configured for run {self.feature!r}; " + 'pass --verify "" on actions (or reconcile) — it is persisted ' + "for every packet in the run" + ) + + def _successor_with_sealed_receipt( + self, attempt: ExecutionAttempt, attempts: list[ExecutionAttempt] + ) -> ExecutionAttempt | None: + """Return a newer attempt for the same ticket that already has evidence.""" + later = sorted( + ( + candidate + for candidate in attempts + if candidate.ticket_num == attempt.ticket_num + and candidate.attempt > attempt.attempt + ), + key=lambda candidate: candidate.attempt, + ) + for candidate in later: + if candidate.state in {"abandoned", "superseded"}: + continue + if candidate.state == "succeeded": + return candidate + if candidate.failure_code.startswith("receipt-invalid"): + continue + if candidate.receipt_path and Path(candidate.receipt_path).is_file(): + return candidate + return None + + def _prepare_dispatch( + self, + attempt: ExecutionAttempt, + integration_path: Path, + integration_branch: str, + verification_commands: list[str], + ) -> DispatchAction: + slot = prepare_assignment_worktree( + self.project_path, + self.feature, + attempt.ticket_num, + attempt.attempt, + ) + plan = self.store.current_plan() + ticket = next(t for t in plan.tickets if t.num == attempt.ticket_num) + packet = finalize_task_packet( + TaskPacket( + task_id=f"{self.feature}:ticket-{ticket.num:03d}:build", + role=ROLE_BUILDER, + goal=ticket.what_to_build or ticket.title, + acceptance_criteria=list(ticket.criteria), + dependencies=[f"ticket-{num:03d}" for num in ticket.blocked_by], + base_commit=attempt.base_sha, + project_path=str(slot.path), + verification_commands=list(verification_commands), + output_contract=["sealed receipt"], + plan_revision=attempt.plan_revision, + ) + ) + run_dir = ( + self.project_path + / ".dsh" + / "baton" + / "runs" + / attempt.assignment_id + ) + packet_path = run_dir / "packet.json" + receipt_path = run_dir / "receipt.json" + payload = { + "assignmentId": attempt.assignment_id, + "ticketNum": attempt.ticket_num, + "attempt": attempt.attempt, + "claimToken": attempt.claim_token, + "worktreePath": str(slot.path), + "worktreeBranch": slot.branch, + "integrationPath": str(integration_path), + "integrationBranch": integration_branch, + "baseSha": attempt.base_sha, + "verificationPath": str(run_dir / "verification.json"), + "receiptPath": str(receipt_path), + "taskPacket": packet.to_dict(), + } + self._write_immutable_json(packet_path, payload) + configured = self.store.configure_execution_attempt( + attempt.assignment_id, + attempt.claim_token, + task_packet_hash=packet.task_packet_hash, + worktree_path=str(slot.path), + worktree_branch=slot.branch, + integration_path=str(integration_path), + integration_branch=integration_branch, + packet_path=str(packet_path), + receipt_path=str(receipt_path), + ) + return self._dispatch_from_attempt(configured, attempt.claim_token) + + def _dispatch_from_attempt( + self, attempt: ExecutionAttempt, claim_token: str + ) -> DispatchAction: + if not attempt.packet_path: + raise SessionControllerError(f"attempt has no packet: {attempt.assignment_id}") + receipt_cli = Path(__file__).resolve().parents[1] / "receipt.py" + heartbeat = Path(attempt.packet_path).parent / HEARTBEAT_NAME + prompt = ( + f"Implement Baton assignment {attempt.assignment_id} only.\n" + f"Work exclusively in `{attempt.worktree_path}` on branch " + f"`{attempt.worktree_branch}`. Do not change ticket or controller state.\n" + f"Refresh the heartbeat file `{heartbeat}` after each meaningful " + f"milestone (overwrite or touch it; it lives outside the worktree, " + "so never commit it).\n" + f"For change context, read `openspec/changes/{self.feature}/proposal.md` " + f"and `tasks.md` in the worktree.\n" + f"Read immutable packet `{attempt.packet_path}`. Implement the ticket, then run:\n" + f'`"{sys.executable}" "{receipt_cli}" verify ' + f'--packet "{attempt.packet_path}"`\n' + "Commit all changes, then run the same command with `seal`. " + "A sealed receipt is the only success evidence." + ) + return DispatchAction( + assignment_id=attempt.assignment_id, + ticket_num=attempt.ticket_num, + task_name=f"baton_ticket_{attempt.ticket_num:03d}", + cwd=attempt.worktree_path, + prompt=prompt, + claim_token=claim_token, + packet_path=attempt.packet_path, + task_packet_hash=attempt.task_packet_hash, + ) + + def _reconcile_sealed( + self, attempt: ExecutionAttempt, claim_token: str + ) -> BaseAction | None: + receipt_path = Path(attempt.receipt_path) + try: + payload = json.loads(receipt_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SessionControllerError(f"cannot read sealed receipt: {exc}") from exc + try: + envelope = verify_sealed_receipt( + payload, + claim_token=claim_token, + task_packet_hash=attempt.task_packet_hash, + ) + self._check_receipt_identity(attempt, envelope) + except ReceiptError as exc: + self._reject_receipt(attempt, claim_token, str(exc)) + raise SessionControllerError( + f"sealed receipt rejected for {attempt.assignment_id}: {exc}" + ) from exc + try: + result = integrate_assignment( + attempt.integration_path, attempt.worktree_branch + ) + except WorktreeError as exc: + return self._integration_failed(attempt, claim_token, str(exc)) + + if result["ok"]: + self.store.mark_execution_attempt_state( + attempt.assignment_id, claim_token, "integrating" + ) + self.store.finalize_execution_attempt( + attempt.assignment_id, + claim_token, + envelope, + str(result["integration_sha"]), + ) + return None + + reason = result.get("reason") + if reason == "conflict": + self.store.mark_execution_attempt_state( + attempt.assignment_id, + claim_token, + "blocked-review", + failure_code="merge-conflict", + ) + return self._merge_action(attempt, claim_token, result.get("conflicts", [])) + if reason == "merge-in-progress": + # The shared worktree is mid-merge for a *different* assignment. + # Leave this attempt untouched: it is retried on the next call, + # once that conflict is resolved, and must not be reported as + # conflicted itself. + return None + if reason == "worktree-dirty": + return BlockedAction( + code="integration-worktree-dirty", + ticket_num=attempt.ticket_num, + reason=self._dirty_worktree_reason(attempt, result.get("conflicts", [])), + ) + raise SessionControllerError( + f"integration returned an unknown outcome {reason!r} for " + f"{attempt.assignment_id}" + ) + + def _integration_failed( + self, attempt: ExecutionAttempt, claim_token: str, detail: str + ) -> BlockedAction: + """Surface a hard integration failure without wedging the attempt. + + The attempt moves to ``blocked-review`` (still active, so no retry + churn) instead of being left in ``integrating`` until its lease runs + out two hours later. + """ + failure_code = f"integration-failed: {detail}" + try: + self.store.mark_execution_attempt_state( + attempt.assignment_id, + claim_token, + "blocked-review", + failure_code=failure_code, + ) + except ExecutionAttemptError: + pass + return BlockedAction( + code="integration-failed", + ticket_num=attempt.ticket_num, + reason=( + f"cannot merge {attempt.worktree_branch} into " + f"{attempt.integration_path}: {detail}. Nothing was merged. Fix the " + f"integration worktree, or retire the attempt with " + f"`abandon {attempt.assignment_id}`." + ), + ) + + @staticmethod + def _dirty_worktree_reason( + attempt: ExecutionAttempt, dirty: list[str] + ) -> str: + listing = ", ".join(dirty[:5]) or "unknown files" + return ( + f"integration worktree {attempt.integration_path} has uncommitted " + f"changes ({listing}) and nothing was merged. Commit or discard them " + f"(or `git -C merge --abort` if a merge is half done), then " + f"call actions again; to retire this attempt instead, " + f"`abandon {attempt.assignment_id}`." + ) + + @staticmethod + def _check_receipt_identity( + attempt: ExecutionAttempt, envelope: ResultEnvelope + ) -> None: + """Bind the sealed receipt to this exact attempt *before* integrating. + + ``finalize_execution_attempt`` repeats these checks as a last line of + defence, but by then the branch has already been merged — a receipt for + another ticket must be rejected while the attempt is still recoverable. + """ + expected = { + "status": "succeeded", + "assignment_id": attempt.assignment_id, + "ticket_num": attempt.ticket_num, + "plan_revision": attempt.plan_revision, + "attempt": attempt.attempt, + "task_packet_hash": attempt.task_packet_hash, + } + for field, value in expected.items(): + actual = getattr(envelope, field) + if actual != value: + raise ReceiptError( + f"receipt {field} mismatch for {attempt.assignment_id}: " + f"receipt={actual!r}, expected={value!r}" + ) + + def _reject_receipt( + self, attempt: ExecutionAttempt, claim_token: str, reason: str + ) -> None: + """Leave a bad receipt's attempt recoverable (no merge, lease freed).""" + try: + self.store.mark_execution_attempt_state( + attempt.assignment_id, + claim_token, + "unknown", + failure_code=f"receipt-invalid: {reason}", + release_lease=True, + ) + except ExecutionAttemptError: + # The attempt already reached a terminal state; the caller still + # surfaces the rejection loudly. + pass + + def _merge_action( + self, + attempt: ExecutionAttempt, + claim_token: str, + conflicts: list[str] | None = None, + ) -> ResolveMergeAction: + return ResolveMergeAction( + assignment_id=attempt.assignment_id, + ticket_num=attempt.ticket_num, + cwd=attempt.integration_path, + claim_token=claim_token, + prompt=( + f"Resolve the preserved merge conflict for {attempt.assignment_id} in " + f"`{attempt.integration_path}`. Conflicts: {conflicts or 'inspect git status'}. " + "Do not abort or discard the merge. Resolve, run the packet verification " + "commands, and commit the merge; then the main session will reconcile again." + ), + ) + + def _token_from_packet(self, packet_path: str) -> str: + if not packet_path or not Path(packet_path).is_file(): + return "" + try: + payload = json.loads(Path(packet_path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return "" + return str(payload.get("claimToken", "")) + + def _batch(self, state: str, actions: list[Any]) -> ActionBatch: + return ActionBatch( + run_id=f"RUN-{self.feature}", + generation=len(self.store.events()), + state=state, + actions=actions, + ) + + @staticmethod + def _git(path: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], + cwd=str(path), + capture_output=True, + text=True, + timeout=30, + check=False, + ) + if result.returncode != 0: + raise WorktreeError(result.stderr.strip() or "git command failed") + return result.stdout.strip() + + @staticmethod + def _write_immutable_json(path: Path, payload: dict[str, Any]) -> None: + text = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + if path.exists(): + if path.read_text(encoding="utf-8") != text: + raise SessionControllerError(f"immutable packet changed: {path}") + return + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, path) + + +__all__ = ["SessionController", "SessionControllerError"] diff --git a/external_plugins/baton/scripts/orchestration/session_model.py b/external_plugins/baton/scripts/orchestration/session_model.py new file mode 100644 index 00000000..a50f6d68 --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/session_model.py @@ -0,0 +1,186 @@ +"""Host-neutral contracts for session-driven Baton execution.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, ClassVar + +from orchestration.plan_model import stable_hash, utc_now +from orchestration.model import TaskPacket + + +@dataclass +class ExecutionAttempt: + """One durable attempt to execute a ticket with a native subagent.""" + + assignment_id: str + run_id: str + ticket_num: int + attempt: int + state: str + session_id: str + plan_revision: int + task_packet_hash: str + claim_token: str + lease_expires_at: str + base_branch: str + base_sha: str + worktree_path: str = "" + worktree_branch: str = "" + integration_path: str = "" + integration_branch: str = "" + packet_path: str = "" + receipt_path: str = "" + agent_id: str = "" + commit_sha: str = "" + integration_sha: str = "" + failure_code: str = "" + created_at: str = field(default_factory=utc_now) + updated_at: str = field(default_factory=utc_now) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ExecutionAttempt": + fields = cls.__dataclass_fields__ + return cls(**{name: data.get(name, field.default) for name, field in fields.items()}) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass +class BaseAction: + kind: ClassVar[str] = "base" + + def to_dict(self) -> dict[str, Any]: + data = asdict(self) + data["kind"] = self.kind + return data + + +@dataclass +class DispatchAction(BaseAction): + kind: ClassVar[str] = "dispatch" + assignment_id: str + ticket_num: int + task_name: str + cwd: str + prompt: str + claim_token: str + packet_path: str + task_packet_hash: str + + +@dataclass +class ResolveMergeAction(BaseAction): + kind: ClassVar[str] = "resolve_merge" + assignment_id: str + ticket_num: int + cwd: str + prompt: str + claim_token: str + + +@dataclass +class RecoverAction(BaseAction): + kind: ClassVar[str] = "recover" + assignment_id: str + ticket_num: int + reason: str + + +@dataclass +class WaitAction(BaseAction): + kind: ClassVar[str] = "wait" + reason: str + active_assignments: list[str] = field(default_factory=list) + + +@dataclass +class BlockedAction(BaseAction): + kind: ClassVar[str] = "blocked" + code: str + reason: str + ticket_num: int = 0 + + +@dataclass +class CompleteAction(BaseAction): + kind: ClassVar[str] = "complete" + revision: int + resolved_tickets: list[int] = field(default_factory=list) + worktrees_removed: list[dict] = field(default_factory=list) + worktrees_skipped: list[dict] = field(default_factory=list) + + +ACTION_TYPES: dict[str, type[BaseAction]] = { + action.kind: action + for action in ( + DispatchAction, + ResolveMergeAction, + RecoverAction, + WaitAction, + BlockedAction, + CompleteAction, + ) +} + + +def action_from_dict(data: dict[str, Any]) -> BaseAction: + kind = str(data.get("kind", "")) + action_type = ACTION_TYPES.get(kind) + if action_type is None: + raise ValueError(f"unknown session action kind: {kind!r}") + payload = {k: v for k, v in data.items() if k != "kind"} + return action_type(**payload) + + +@dataclass +class ActionBatch: + run_id: str + generation: int + state: str + actions: list[BaseAction] = field(default_factory=list) + created_at: str = field(default_factory=utc_now) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ActionBatch": + return cls( + run_id=str(data["run_id"]), + generation=int(data["generation"]), + state=str(data["state"]), + actions=[action_from_dict(x) for x in data.get("actions", [])], + created_at=str(data.get("created_at", utc_now())), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "run_id": self.run_id, + "generation": self.generation, + "state": self.state, + "actions": [action.to_dict() for action in self.actions], + "created_at": self.created_at, + } + + +def finalize_task_packet(packet: TaskPacket) -> TaskPacket: + """Return ``packet`` with a stable hash over its semantic contents.""" + data = packet.to_dict() + data["task_packet_hash"] = "" + packet.task_packet_hash = stable_hash(data) + return packet + + +__all__ = [ + "ACTION_TYPES", + "ActionBatch", + "BaseAction", + "BlockedAction", + "CompleteAction", + "DispatchAction", + "ExecutionAttempt", + "RecoverAction", + "ResolveMergeAction", + "WaitAction", + "action_from_dict", + "finalize_task_packet", +] diff --git a/external_plugins/baton/scripts/orchestration/store_base.py b/external_plugins/baton/scripts/orchestration/store_base.py new file mode 100644 index 00000000..c980d1a3 --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/store_base.py @@ -0,0 +1,76 @@ +"""Shared plan-store errors, constants and small durable-file helpers. + +Imported by both ``plan_store`` (persistence, revisions) and ``attempt_store`` +(attempt lifecycle) so neither has to import the other. +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + + +class PlanStoreError(RuntimeError): + """Base class for plan-store failures.""" + + +class ExecutionAttemptError(PlanStoreError): + """Raised when a session execution attempt transition is invalid.""" + + +#: Attempt states that never transition again. ``superseded`` (a stale lease +#: replaced by a retry) and ``abandoned`` (operator escape hatch) are terminal +#: for scheduling purposes but deliberately kept as distinct audit states. +TERMINAL_ATTEMPT_STATES = frozenset( + {"succeeded", "failed", "canceled", "superseded", "abandoned"} +) + +VERIFICATION_COMMANDS_SETTING = "verification_commands" + + +def _pretty_json(data: dict[str, Any]) -> str: + return json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + + +def _atomic_write(path: Path, text: str) -> None: + """Write ``text`` to ``path`` atomically (write-temp + os.replace). + + A crash mid-write (SIGKILL, disk full, power loss) must not leave a + truncated file at ``path`` — readers would hit JSONDecodeError with no + recovery path. We write to a sibling temp file then ``os.replace`` (POSIX + atomic rename) so ``path`` is either the old content or the complete new + content, never a partial write. ``fsync`` before rename persists the bytes + so a crash after rename still has the file on disk. + """ + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + f.flush() + try: + os.fsync(f.fileno()) + except OSError: + # fsync may be unavailable/forbidden on some filesystems; the + # rename is still atomic and the OS page cache is good enough in + # the common case. Don't let fsync failure abort the write. + pass + os.replace(tmp, path) + + +def _utc_after(seconds: int) -> str: + return ( + datetime.now(timezone.utc) + .replace(microsecond=0) + + timedelta(seconds=seconds) + ).isoformat() + + +__all__ = [ + "ExecutionAttemptError", + "PlanStoreError", + "TERMINAL_ATTEMPT_STATES", + "VERIFICATION_COMMANDS_SETTING", +] diff --git a/external_plugins/baton/scripts/orchestration/store_schema.py b/external_plugins/baton/scripts/orchestration/store_schema.py new file mode 100644 index 00000000..c1950139 --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/store_schema.py @@ -0,0 +1,124 @@ +"""queue.db schema bootstrap — DDL plus the WAL busy-retry loop. + +Split out of ``plan_store`` so the store's read/write logic is not buried +under ~120 lines of SQL. ``init_db`` is idempotent: every statement is +``IF NOT EXISTS``, so it is safe to run on every connect. +""" + +from __future__ import annotations + +import sqlite3 +import time + +SCHEMA_SQL = """\ + + PRAGMA journal_mode=WAL; + CREATE TABLE IF NOT EXISTS plan_revisions ( + job_id TEXT NOT NULL, + feature TEXT NOT NULL, + revision INTEGER NOT NULL, + parent_revision INTEGER, + plan_hash TEXT NOT NULL, + approved_by TEXT NOT NULL, + approved_at TEXT NOT NULL, + created_at TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + PRIMARY KEY (job_id, revision) + ); + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL, + feature TEXT NOT NULL, + event_type TEXT NOT NULL, + created_at TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_events_job + ON events(job_id, id); + CREATE TABLE IF NOT EXISTS result_envelopes ( + envelope_id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + feature TEXT NOT NULL, + ticket_num INTEGER NOT NULL, + assignment_id TEXT NOT NULL, + plan_revision INTEGER NOT NULL, + task_packet_hash TEXT NOT NULL, + status TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS execution_attempts ( + job_id TEXT NOT NULL, + run_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + ticket_num INTEGER NOT NULL, + attempt INTEGER NOT NULL, + state TEXT NOT NULL, + session_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + plan_revision INTEGER NOT NULL, + task_packet_hash TEXT NOT NULL, + claim_token_hash TEXT NOT NULL, + lease_expires_at TEXT NOT NULL, + base_branch TEXT NOT NULL, + base_sha TEXT NOT NULL, + worktree_path TEXT NOT NULL, + worktree_branch TEXT NOT NULL, + integration_path TEXT NOT NULL, + integration_branch TEXT NOT NULL, + packet_path TEXT NOT NULL, + receipt_path TEXT NOT NULL, + commit_sha TEXT NOT NULL, + integration_sha TEXT NOT NULL, + failure_code TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (job_id, assignment_id), + UNIQUE(job_id, ticket_num, attempt) + ); + CREATE INDEX IF NOT EXISTS idx_execution_attempts_job_state + ON execution_attempts(job_id, state, ticket_num); + CREATE UNIQUE INDEX IF NOT EXISTS idx_execution_attempts_one_active_ticket + ON execution_attempts(job_id, ticket_num) + WHERE state IN ('leased', 'spawned', 'running', 'submitted', + 'verifying', 'integrating', 'blocked-review'); + CREATE TABLE IF NOT EXISTS run_settings ( + job_id TEXT NOT NULL, + setting_key TEXT NOT NULL, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (job_id, setting_key) + ); + CREATE INDEX IF NOT EXISTS idx_result_envelopes_job + ON result_envelopes(job_id, ticket_num); +""" + + +def init_db(conn: sqlite3.Connection) -> None: + """Create all tables/indexes idempotently and enable WAL. + + Wrapped in a short busy-retry loop: ``PRAGMA journal_mode=WAL`` and the + schema DDL run inside ``executescript``, which does **not** honor the + connection's ``timeout`` busy-wait. When two processes connect to a + freshly-created database simultaneously, the second one's pragma collides + with the first's lock and raises ``database is locked`` immediately rather + than waiting. Retrying on that transient error lets schema bootstrap + serialize cleanly instead of crashing the loser. The retry is bounded and + short — once WAL + tables exist the script is a no-op. + """ + last_err: Exception | None = None + for _ in range(50): + try: + conn.executescript(SCHEMA_SQL) + return + except sqlite3.OperationalError as exc: + if "locked" in str(exc).lower(): + last_err = exc + time.sleep(0.02) + continue + raise + # Exhausted retries — surface the original locked error. + raise last_err # type: ignore[misc] + + +__all__ = ["SCHEMA_SQL", "init_db"] diff --git a/external_plugins/baton/scripts/orchestration/worktree.py b/external_plugins/baton/scripts/orchestration/worktree.py new file mode 100644 index 00000000..3bf6e7bd --- /dev/null +++ b/external_plugins/baton/scripts/orchestration/worktree.py @@ -0,0 +1,389 @@ +"""worktree.py — git worktree working-area isolation (the technical basis for parallel execution). + +Two agents on the same project committing to the **same git working tree** +concurrently = files overwriting each other and commits colliding (cf. the +AGENTS.md red-line section "concurrent editing is the norm" — but that one is +about "editing the same file in turn"; what we avoid here is "two agents +editing the same working tree at the same time"). + +git worktree is git's native parallel-development mechanism: multiple +independent working trees are attached under the **same repository**, each +checking out its own branch, sharing the ``.git`` object store while the +working trees stay isolated. This is what "N assignments developing the same +project in parallel" needs: + + main repo/ ← never checked out/switched + ├── .git/ ← shared object store + └── ../-baton--tic-2-a01/ ← one assignment + └── src/... ← own branch, no stomping + +This module provides the primitives the controller drives: one isolated +worktree per assignment (``prepare_assignment_worktree``), the shared +integration worktree (``prepare_integration_worktree``), and +``cleanup_feature_worktrees`` for disk hygiene once a run completes. It +**never touches** uncommitted changes in the main working tree (red line: +"never discard the worktree") — a worktree is a new checkout, not an +overwrite. + +Design decisions: +- Worktrees are attached in a **sibling directory of the project** + (``../-baton--...``), not inside the project, to avoid + polluting ``.gitignore`` and being swept up by ``git add -A``. +- Assignment branches are ``baton/-tic--a``, created + from the integration branch ``baton/``. +- Worktree creation failure (branch exists, path occupied, not a git repo) + **raises an exception** rather than failing silently — in parallel execution + a worktree that did not come up must surface to the caller. +""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +class WorktreeError(Exception): + """Worktree creation/cleanup failure.""" + + +@dataclass +class WorktreeSlot: + """Description of one worker's independent working area. + + ``path`` is the worktree's absolute path (the agent edits code here); + ``branch`` is the branch this worktree checks out; + ``main_repo`` is the main repository root (where the shared ``.git`` lives); + ``worker_id`` names the slot owner (``integration-`` or + ``tic--a``). + """ + + path: Path + branch: str + main_repo: Path + worker_id: str + + +def _run_git(args: list[str], cwd: str, timeout: int = 30) -> subprocess.CompletedProcess: + """Run a git subprocess in ``cwd``; raise WorktreeError on failure. + + git unavailable / not a git repo / branch conflict → uniformly raised as + WorktreeError, so the executor can decide whether to skip this worker or + abort the whole batch. + """ + try: + result = subprocess.run( + ["git"] + args, + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as exc: + raise WorktreeError(f"git unavailable: {exc}") from exc + except subprocess.TimeoutExpired as exc: + raise WorktreeError(f"git {' '.join(args)} timed out ({exc.timeout}s)") from exc + if result.returncode != 0: + stderr = (result.stderr or "").strip() + raise WorktreeError( + f"git {' '.join(args)} failed (exit {result.returncode}): {stderr}" + ) + return result + + +def _is_git_repo(path: Path) -> bool: + """Whether ``path`` is a git repo (has ``.git`` or git rev-parse succeeds).""" + try: + result = subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=str(path), + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 and result.stdout.strip() == "true" + + +def _safe_component(value: str) -> str: + safe = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-") + if not safe: + raise WorktreeError("feature must contain a safe branch/path component") + return safe + + +def integration_branch(feature: str) -> str: + return f"baton/{_safe_component(feature)}" + + +def assignment_branch(feature: str, ticket_num: int, attempt: int) -> str: + # Git refs cannot have both ``baton/feat`` and ``baton/feat/...`` because + # the former occupies the path as a file. Keep sibling refs instead. + return f"{integration_branch(feature)}-tic-{ticket_num:03d}-a{attempt:02d}" + + +def integration_worktree_path(project_path: str | Path, feature: str) -> Path: + project = Path(project_path).resolve() + return project.parent / ( + f"{project.name}-baton-{_safe_component(feature)}-integration" + ) + + +def assignment_worktree_path( + project_path: str | Path, feature: str, ticket_num: int, attempt: int +) -> Path: + project = Path(project_path).resolve() + return project.parent / ( + f"{project.name}-baton-{_safe_component(feature)}-" + f"tic-{ticket_num:03d}-a{attempt:02d}" + ) + + +def _prepare_explicit_worktree( + project: Path, + *, + path: Path, + branch: str, + base_ref: str, + worker_id: str, +) -> WorktreeSlot: + if not _is_git_repo(project): + raise WorktreeError(f"project is not a git repo: {project}") + if path.exists() and _is_git_repo(path): + current = _current_branch(path) + if current == branch: + return WorktreeSlot(path, branch, project, worker_id) + raise WorktreeError( + f"worktree path {path} checks out {current}, expected {branch}" + ) + if path.exists(): + raise WorktreeError(f"worktree path is occupied by a non-worktree: {path}") + if _branch_exists(project, branch): + _run_git(["worktree", "add", str(path), branch], cwd=str(project)) + else: + _run_git( + ["worktree", "add", "-b", branch, str(path), base_ref], + cwd=str(project), + ) + return WorktreeSlot(path, branch, project, worker_id) + + +def prepare_integration_worktree( + project_path: str | Path, + feature: str, + *, + base_branch: str = "main", +) -> WorktreeSlot: + project = Path(project_path).resolve() + return _prepare_explicit_worktree( + project, + path=integration_worktree_path(project, feature), + branch=integration_branch(feature), + base_ref=base_branch, + worker_id=f"integration-{_safe_component(feature)}", + ) + + +def prepare_assignment_worktree( + project_path: str | Path, + feature: str, + ticket_num: int, + attempt: int, +) -> WorktreeSlot: + project = Path(project_path).resolve() + return _prepare_explicit_worktree( + project, + path=assignment_worktree_path(project, feature, ticket_num, attempt), + branch=assignment_branch(feature, ticket_num, attempt), + base_ref=integration_branch(feature), + worker_id=f"tic-{ticket_num:03d}-a{attempt:02d}", + ) + + +def branch_is_merged(repo_path: str | Path, branch: str, target: str) -> bool: + try: + _run_git( + ["merge-base", "--is-ancestor", branch, target], + cwd=str(Path(repo_path).resolve()), + ) + return True + except WorktreeError: + return False + + +def _current_branch(path: Path) -> str: + """Read the current branch name at ``path``; returns empty for non-repos or detached HEAD.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=str(path), + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return "" + return (result.stdout or "").strip() + + +def _branch_exists(repo: Path, branch: str) -> bool: + """Whether ``branch`` exists in ``repo``.""" + try: + result = subprocess.run( + ["git", "rev-parse", "--verify", branch], + cwd=str(repo), + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def _git_quiet(args: list[str], cwd: str, timeout: int = 30) -> subprocess.CompletedProcess: + """Run git WITHOUT raising on non-zero exit (for tolerant operations). + + Unlike :func:`_run_git`, this does not raise — used by cleanup where a + non-zero exit (e.g. a dirty worktree) is an expected, handleable case. + """ + try: + return subprocess.run( + ["git"] + args, + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + raise WorktreeError(f"git {' '.join(args)} unavailable/timed out: {exc}") from exc + + +def cleanup_feature_worktrees( + project_path: str | Path, + feature: str, + *, + include_integration: bool = False, + dry_run: bool = False, +) -> dict: + """Remove a finished run's worktrees (disk hygiene). + + Safe by construction: + - Never touches the main working tree. + - Removes assignment worktrees on ``baton/-tic-*`` branches — their + work is already integrated into the integration branch on a sealed receipt, + so they are stale once the run completes. The shared ``baton/`` + integration worktree is removed only when ``include_integration=True`` + (after it has been merged to main). + - Uses ``git worktree remove`` WITHOUT ``--force``: a dirty worktree is + skipped, never discarded (red line: never discard the worktree). + - ``dry_run=True`` removes nothing: the matched worktrees are listed as + ``wouldRemove`` (a dirty one would still be skipped by the real run). + + Returns a ``{"removed": [...], "skipped": [...], "integrationIncluded": bool}`` + summary, or ``{"dryRun": True, "wouldRemove": [...], "integrationIncluded": + bool}`` when previewing. The controller calls this automatically when a run + reaches ``complete`` (assignment worktrees only); the CLI ``cleanup`` + command exposes it directly. + + Fails loud instead of an empty success when the directory is not a git + repository, or when nothing matches and no ``baton/*`` branch + exists either — a typo'd change-id is otherwise indistinguishable from a + fully cleaned run. Any run that dispatched created the integration branch + (``prepare_integration_worktree`` runs before every action batch) and + branches outlive worktree removal, so a feature that really ran keeps the + empty-but-successful answer. + """ + project = Path(project_path).resolve() + result = _git_quiet(["worktree", "list", "--porcelain"], cwd=str(project)) + if result.returncode != 0: + raise WorktreeError( + f"git worktree list failed in {project}: " + f"{(result.stderr or result.stdout or '').strip()}" + ) + entries: list[tuple[str, str]] = [] + cur_path: str | None = None + cur_branch = "" + for line in (result.stdout or "").splitlines(): + if line.startswith("worktree "): + if cur_path: + entries.append((cur_path, cur_branch)) + cur_path = line[len("worktree ") :] + cur_branch = "" + elif line.startswith("branch "): + cur_branch = line[len("branch ") :] + if cur_branch.startswith("refs/heads/"): + cur_branch = cur_branch[len("refs/heads/") :] + if cur_path: + entries.append((cur_path, cur_branch)) + + integ_branch = integration_branch(feature) + tic_prefix = f"{integ_branch}-tic-" + matches = [ + (wt_path, branch) + for wt_path, branch in entries + if Path(wt_path) != project # never the main working tree + and ( + branch.startswith(tic_prefix) + or (branch == integ_branch and include_integration) + ) + ] + + if not matches: + probe = _git_quiet( + ["branch", "--list", integ_branch, f"{tic_prefix}*"], cwd=str(project) + ) + if not (probe.stdout or "").strip(): + raise WorktreeError( + f"no baton worktrees or branches found for '{feature}' — " + "nothing indicates this change ever ran here; check the change-id" + ) + + if dry_run: + return { + "dryRun": True, + "wouldRemove": [{"path": p, "branch": b} for p, b in matches], + "integrationIncluded": include_integration, + } + + removed: list[dict] = [] + skipped: list[dict] = [] + for wt_path, branch in matches: + r = _git_quiet(["worktree", "remove", wt_path], cwd=str(project)) + if r.returncode == 0: + removed.append({"path": wt_path, "branch": branch}) + else: + skipped.append( + { + "path": wt_path, + "branch": branch, + "reason": (r.stderr or r.stdout or "").strip() + or "worktree remove failed (likely dirty — not discarded)", + } + ) + return { + "removed": removed, + "skipped": skipped, + "integrationIncluded": include_integration, + } + + +__all__ = [ + "WorktreeError", + "WorktreeSlot", + "assignment_branch", + "assignment_worktree_path", + "branch_is_merged", + "cleanup_feature_worktrees", + "integration_branch", + "integration_worktree_path", + "prepare_assignment_worktree", + "prepare_integration_worktree", +] diff --git a/external_plugins/baton/scripts/receipt.py b/external_plugins/baton/scripts/receipt.py new file mode 100644 index 00000000..576ef26f --- /dev/null +++ b/external_plugins/baton/scripts/receipt.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""CLI for deterministic Baton assignment verification receipts.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from orchestration.receipt import ReceiptError, seal_receipt, verify_packet # noqa: E402 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="baton-receipt") + sub = parser.add_subparsers(dest="command", required=True) + for name in ("verify", "seal"): + command = sub.add_parser(name) + command.add_argument("--packet", required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + result = ( + verify_packet(args.packet) + if args.command == "verify" + else seal_receipt(args.packet).to_dict() + ) + except ReceiptError as exc: + print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr) + return 1 + print(json.dumps({"ok": True, "result": result}, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/external_plugins/baton/scripts/session_control.py b/external_plugins/baton/scripts/session_control.py new file mode 100644 index 00000000..eacc421a --- /dev/null +++ b/external_plugins/baton/scripts/session_control.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""CLI adapter for the deterministic SessionController.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from orchestration.plan_store import PlanStoreError # noqa: E402 +from orchestration.session_controller import ( # noqa: E402 + SessionController, + SessionControllerError, +) +from orchestration.worktree import ( # noqa: E402 + WorktreeError, + cleanup_feature_worktrees, +) + +# Domain failures the CLI renders as one JSON error line; anything else +# (TypeError, bugs) keeps its full traceback — fail loud, just not ugly. +DOMAIN_ERRORS = (PlanStoreError, SessionControllerError, WorktreeError) + + +def _controller(args: argparse.Namespace) -> SessionController: + return SessionController( + args.project, + args.feature, + verification_commands=args.verify, + ) + + +def _session_id(value: str) -> str: + """argparse type: a session id identifies one run's claims — never empty.""" + if not value.strip(): + raise argparse.ArgumentTypeError("session id must be a non-empty string") + return value + + +def _capacity(value: str) -> int: + """argparse type: 1..5 parallel assignments (the documented hard cap).""" + try: + capacity = int(value) + except ValueError: + raise argparse.ArgumentTypeError( + f"capacity must be an integer, got {value!r}" + ) from None + if not 1 <= capacity <= 5: + raise argparse.ArgumentTypeError( + f"capacity must be between 1 and 5, got {capacity} " + "(baton runs at most 5 parallel assignments)" + ) + return capacity + + +def _print(value: object) -> None: + if hasattr(value, "to_dict"): + value = value.to_dict() + print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)) + + +def _ok(value: object) -> dict[str, object]: + if hasattr(value, "to_dict"): + value = value.to_dict() + return {"ok": True, **value} if isinstance(value, dict) else {"ok": True} + + +def build_parser() -> argparse.ArgumentParser: + """The CLI surface — also the contract the docs are checked against.""" + parser = argparse.ArgumentParser(description="Baton session control") + parser.add_argument("project") + parser.add_argument("feature") + parser.add_argument( + "--verify", + action="append", + help="verification command; the first claim persists it for the run " + "(repeatable). Passing it again overrides the stored commands for new " + "packets. Required until a run has commands configured.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + actions = sub.add_parser("actions") + actions.add_argument("--session-id", required=True, type=_session_id) + actions.add_argument("--capacity", type=_capacity, default=1) + actions.add_argument("--base-branch", default="main") + + bind = sub.add_parser("bind") + bind.add_argument("assignment_id") + bind.add_argument("claim_token") + bind.add_argument("agent_id") + + reconcile = sub.add_parser("reconcile") + reconcile.add_argument("assignment_id") + reconcile.add_argument("claim_token") + reconcile.add_argument( + "--agent-terminal", + required=True, + choices=["completed", "failed", "canceled"], + ) + reconcile.add_argument("--session-id", required=True, type=_session_id) + reconcile.add_argument("--capacity", type=_capacity, default=1) + reconcile.add_argument("--base-branch", default="main") + + sub.add_parser( + "status", + help="read-only progress snapshot", + ) + + cleanup = sub.add_parser( + "cleanup", + help="remove a finished run's worktrees (assignment branches " + "baton/-tic-*); never touches the main working tree, " + "never force-removes dirty worktrees", + ) + cleanup.add_argument( + "--include-integration", + action="store_true", + help="also remove the baton/ integration worktree (only after merging to main)", + ) + cleanup.add_argument( + "--dry-run", + action="store_true", + help="preview: list the worktrees cleanup would remove, remove nothing", + ) + + abandon = sub.add_parser( + "abandon", + help="operator escape hatch: retire a stuck attempt (releases its lease, " + "ignores future receipts); it does NOT delete the worktree, branch, " + "commits, packet or receipt; refused while the attempt is plausibly " + "still working (active + no failure evidence + most of its lease left) " + "unless --force", + ) + abandon.add_argument("assignment_id") + abandon.add_argument( + "--reason", + default="operator-abandoned", + help="recorded in the attempt's failure_code / event log", + ) + abandon.add_argument( + "--force", + action="store_true", + help="bypass the live-attempt guard after reading its refusal data", + ) + + adopt = sub.add_parser( + "adopt", + help="revive an abandoned attempt whose sealed receipt arrived late: " + "re-verify the receipt and integrate it; refuses when a newer attempt " + "for the same ticket already sealed or is still in flight", + ) + adopt.add_argument("assignment_id") + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + if args.command == "cleanup": + _print( + cleanup_feature_worktrees( + args.project, + args.feature, + include_integration=args.include_integration, + dry_run=args.dry_run, + ) + ) + return 0 + controller = _controller(args) + if args.command == "actions": + result = controller.actions(args.session_id, args.capacity, args.base_branch) + elif args.command == "bind": + result = controller.bind_agent( + args.assignment_id, args.claim_token, args.agent_id + ) + elif args.command == "status": + result = controller.status() + elif args.command == "abandon": + result = controller.abandon( + args.assignment_id, reason=args.reason, force=args.force + ) + elif args.command == "adopt": + result = controller.adopt(args.assignment_id) + else: + result = controller.reconcile( + args.assignment_id, + args.claim_token, + agent_terminal=args.agent_terminal, + session_id=args.session_id, + capacity=args.capacity, + base_branch=args.base_branch, + ) + _print(_ok(result)) + return 0 + except DOMAIN_ERRORS as exc: + _print({"error": f"{type(exc).__name__}: {exc}", "command": args.command}) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/external_plugins/baton/scripts/ticket_board.py b/external_plugins/baton/scripts/ticket_board.py new file mode 100644 index 00000000..4a83afe4 --- /dev/null +++ b/external_plugins/baton/scripts/ticket_board.py @@ -0,0 +1,496 @@ +#!/usr/bin/env python3 +"""ticket_board.py — local board generator over OpenSpec changes. + +The plan face is OpenSpec: every change lives at +``openspec/changes//tasks.md``. This tool is read-only observability +plus a thin check/uncheck helper — to add/remove/reorder tasks, edit tasks.md +directly (baton picks the change up as a new plan revision on the next run). + +Usage: + python3 scripts/ticket_board.py scan [] # id optional when + python3 scripts/ticket_board.py build [] # exactly one change + python3 scripts/ticket_board.py build-all + python3 scripts/ticket_board.py check [--dry-run] # flip [ ]→[x] + python3 scripts/ticket_board.py uncheck [--dry-run] # flip [x]→[ ] + +Both flips echo ``reverse`` — the exact command that undoes them — because +tasks.md is the run's audit record; ``--dry-run`` runs the same validation +and previews the flip without writing tasks.md or board.json. Every real +write that regenerates board.json (check/uncheck/scan) also rewrites any +already-built board.html / board-all.html in place, so reloading the open +board shows the fresh state without a manual rebuild. +""" + +from __future__ import annotations + +import argparse +import json +import shlex +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from _ticket import ( # noqa: E402 + TasksMarkdownError, + check_task, + parse_tasks_md, + uncheck_task, + validate_change_id, +) +from _frontier import compute_frontier # noqa: E402 +from orchestration.plan_model import utc_now # noqa: E402 + + +def _tasks_path(project: str | Path, feature: str) -> Path: + return ( + Path(project).resolve() + / "openspec" + / "changes" + / validate_change_id(feature) + / "tasks.md" + ) + + +def _board_path(project: str | Path, feature: str) -> Path: + return Path(project).resolve() / ".dsh" / "baton" / validate_change_id(feature) / "board.json" + + +def scan_board(project: str | Path, feature: str) -> dict: + """Walk the change's tasks.md and produce a board data dict.""" + try: + tasks_path = _tasks_path(project, feature) + tickets = parse_tasks_md(tasks_path) + except (TasksMarkdownError, ValueError) as exc: + return {"error": str(exc)} + + frontier_set = {t.num for t in compute_frontier(tickets)} + + ticket_dicts = [] + stats: dict[str, int] = {"total": len(tickets)} + for t in tickets: + ticket_dicts.append({ + "num": t.num, + "task_id": t.task_id, + "title": t.title, + "status": t.status, + "blocked_by": list(t.blocked_by), + "section": t.section, + "file": "tasks.md", + "what_to_build": t.what_to_build[:200], + "is_frontier": t.num in frontier_set, + "progress": 1.0 if t.status == "resolved" else 0.0, + "feature": feature, + }) + stats[t.status] = stats.get(t.status, 0) + 1 + + ticket_dicts.sort(key=lambda x: x["num"]) + + return { + "feature": feature, + "project": str(Path(project).resolve().name), + "project_path": str(Path(project).resolve()), + "generated_at": utc_now(), + "stats": stats, + "frontier": sorted(frontier_set), + "tickets": ticket_dicts, + } + + +def save_board(project: str | Path, feature: str) -> dict: + board = scan_board(project, feature) + if "error" not in board: + path = _board_path(project, feature) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(board, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return board + + +def _list_changes(project: str | Path) -> list[str]: + """Every non-archived change that has a tasks.md.""" + changes_dir = Path(project).resolve() / "openspec" / "changes" + if not changes_dir.is_dir(): + return [] + return sorted( + child.name + for child in changes_dir.iterdir() + if child.is_dir() and child.name != "archive" and (child / "tasks.md").is_file() + ) + + +def _sole_change(project: str | Path) -> tuple[str | None, str | None]: + """Resolve an omitted to the project's single change. + + Returns ``(feature, error)``. Exactly one change with a tasks.md → that + id. Zero → an error naming the searched dir; more than one → an error + listing the candidates so the caller can pass one explicitly. + """ + changes = _list_changes(project) + if len(changes) == 1: + return changes[0], None + changes_dir = Path(project).resolve() / "openspec" / "changes" + if not changes: + return None, ( + f"no change id given and no OpenSpec changes with tasks.md " + f"found under {changes_dir}" + ) + preview = ", ".join(changes[:5]) + ("…" if len(changes) > 5 else "") + return None, ( + f"no change id given and {len(changes)} changes found ({preview}); " + f"pass one explicitly" + ) + + +def scan_all(project: str | Path) -> dict: + """Scan every change in the project and merge into a multi-change board. + + A change whose tasks.md fails to parse is reported under + ``skipped_changes`` (feature + error) instead of silently vanishing from + the board; the healthy changes still get a board. + """ + all_tickets: list[dict] = [] + feature_summaries: list[dict] = [] + skipped_changes: list[dict] = [] + + for change in _list_changes(project): + board = scan_board(project, change) + if "error" in board: + skipped_changes.append({"feature": change, "error": board["error"]}) + continue + all_tickets.extend(board["tickets"]) + s = board["stats"] + feature_summaries.append({ + "feature": change, + "total": s.get("total", 0), + "resolved": s.get("resolved", 0), + "ready": s.get("ready", 0), + "frontier": len(board["frontier"]), + }) + + stats: dict[str, int] = {"total": len(all_tickets)} + for t in all_tickets: + stats[t["status"]] = stats.get(t["status"], 0) + 1 + + all_tickets.sort(key=lambda x: (x["feature"], x["num"])) + + return { + "feature": "(all)", + "features": feature_summaries, + "skipped_changes": skipped_changes, + "project": str(Path(project).resolve().name), + "project_path": str(Path(project).resolve()), + "generated_at": utc_now(), + "stats": stats, + "frontier": [t["num"] for t in all_tickets if t.get("is_frontier")], + "tickets": all_tickets, + } + + +def save_board_all(project: str | Path) -> dict: + """Scan every change and persist ``board-all.json``. + + The json (and the ``.dsh/baton/`` tree) is written only when the scan + has something to record — tickets, or skipped changes as parse-failure + evidence. A pure-empty project (no changes with tasks.md) is an empty + state, not a board: writing an all-empty artifact there would leave a + stray file that reads as broken output on a failing exit. + """ + board = scan_all(project) + if board.get("tickets") or board.get("skipped_changes"): + path = Path(project).resolve() / ".dsh" / "baton" / "board-all.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(board, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return board + + +def _reverse_command(project: str | Path, feature: str, task_id: str, checked: bool) -> str: + """The exact CLI invocation that undoes a flip of ``task_id``.""" + verb = "uncheck" if checked else "check" + return " ".join([ + "python3", + shlex.quote(str(Path(__file__).resolve())), + verb, + shlex.quote(str(project)), + shlex.quote(feature), + task_id, + ]) + + +def set_task_checked( + project: str | Path, feature: str, task_id: str, checked: bool, dry_run: bool = False +) -> dict: + """Flip (or preview flipping) one task's checkbox (``N.M`` id). + + ``dry_run=True`` runs the same validation but writes nothing; every + success — real or preview — carries ``reverse``, the command that undoes + the flip. + """ + from _ticket import task_num + + parts = task_id.split(".") + if len(parts) != 2 or not all(p.isdigit() for p in parts): + return {"error": f"task id must look like N.M (e.g. 2.1), got: {task_id!r}"} + try: + num = task_num(int(parts[0]), int(parts[1])) + except ValueError as exc: + return {"error": str(exc)} + reverse = _reverse_command(project, feature, task_id, checked) + try: + tasks_path = _tasks_path(project, feature) + except ValueError as exc: + return {"error": str(exc)} + if dry_run: + try: + tickets = parse_tasks_md(tasks_path) + except TasksMarkdownError as exc: + return {"error": str(exc)} + ticket = next((t for t in tickets if t.num == num), None) + if ticket is None: + return {"error": f"task {task_id} not found in {tasks_path}"} + if checked: + if ticket.status == "resolved": # check_task is idempotent + return {"task": task_id, "action": "no-op", "current": "resolved", + "dry_run": True, "reverse": reverse} + return {"task": task_id, "action": "would check", "current": "ready", + "dry_run": True, "reverse": reverse} + if ticket.status != "resolved": # mirrors the real uncheck error + return {"error": f"task {task_id} is not checked"} + return {"task": task_id, "action": "would uncheck", "current": "resolved", + "dry_run": True, "reverse": reverse} + if checked: + try: + check_task(tasks_path, num) + except TasksMarkdownError as exc: + return {"error": str(exc)} + return {"task": task_id, "action": "checked", "reverse": reverse} + try: + uncheck_task(tasks_path, num) + except TasksMarkdownError as exc: + return {"error": str(exc)} + return {"task": task_id, "action": "unchecked", "reverse": reverse} + + +def _print(data: dict) -> None: + print(json.dumps(data, ensure_ascii=False, indent=2)) + + +def cmd_scan(args): + if args.feature is None: + args.feature, err = _sole_change(args.project) + if err: + _print({"error": err}) + return 1 + board = save_board(args.project, args.feature) + if "error" in board: + _print(board) + return 1 + refreshed = _refresh_built_boards(args.project, args.feature, board) + if refreshed: + board["boards_refreshed"] = refreshed + _print(board) + return 0 + + +def cmd_check(args): + result = set_task_checked(args.project, args.feature, args.task, True, dry_run=args.dry_run) + return _finish_flip(args, result) + + +def cmd_uncheck(args): + result = set_task_checked(args.project, args.feature, args.task, False, dry_run=args.dry_run) + return _finish_flip(args, result) + + +def _finish_flip(args, result: dict) -> int: + """Print the flip result; a real write also refreshes every built board. + + The board's copy-paste workflow ends with the user reloading the browser + tab they copied the command from, so the flip itself must bring the + already-built board.html files back in sync with the fresh board.json. + """ + if "error" in result: + _print(result) + return 1 + if not args.dry_run: + board = save_board(args.project, args.feature) + if "error" not in board: + refreshed = _refresh_built_boards(args.project, args.feature, board) + if refreshed: + result["boards_refreshed"] = refreshed + _print(result) + return 0 + + +def _refresh_built_boards(project: str | Path, feature: str, board: dict) -> list[str]: + """Rewrite already-built HTML boards that a fresh board.json made stale. + + A built board.html bakes its data in at build time, so a flip that + regenerates board.json leaves every open board frozen on the old state. + The feature's board.html is re-rendered from ``board``; a global + board-all.html, whose data spans every change, is re-rendered from a + fresh scan_all. Boards never built are not created here — building stays + an explicit choice. Returns the paths rewritten. + """ + base = Path(project).resolve() / ".dsh" / "baton" + refreshed: list[str] = [] + feature_html = base / validate_change_id(feature) / "board.html" + if feature_html.is_file(): + feature_html.write_text(_render_html(board), encoding="utf-8") + refreshed.append(str(feature_html)) + all_html = base / "board-all.html" + if all_html.is_file(): + merged = scan_all(project) + if merged.get("tickets"): # never regress the global board to empty + all_html.write_text(_render_html(merged), encoding="utf-8") + refreshed.append(str(all_html)) + return refreshed + + +def _json_for_html(board: dict) -> str: + """JSON encoded for embedding inside the template's ```` (ordinary text in a web project's + tasks.md) would terminate the script element mid-JSON: the board renders + empty and the trailing title text becomes live markup. Escaping them as + ``\\uXXXX`` keeps the blob valid JSON and inert in HTML. + """ + return ( + json.dumps(board, ensure_ascii=False, indent=2) + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026") + ) + + +def _tool_cmd() -> str: + """The runnable command prefix that reaches this script from any cwd.""" + return f"python3 {shlex.quote(str(Path(__file__).resolve()))}" + + +def _render_html(board: dict) -> str: + template_path = SCRIPT_DIR.parent / "templates" / "ticket-board.html" + html = template_path.read_text(encoding="utf-8") + return ( + html.replace("__BOARD_DATA__", _json_for_html(board)) + # _json_for_html on a plain string yields a quoted JS literal, markup-escaped + .replace("__TOOL_CMD__", _json_for_html(_tool_cmd())) + ) + + +def cmd_build(args): + if args.feature is None: + args.feature, err = _sole_change(args.project) + if err: + _print({"error": err}) + return 1 + board = save_board(args.project, args.feature) + if "error" in board: + _print(board) + return 1 + out_path = Path(args.project).resolve() / ".dsh" / "baton" / validate_change_id(args.feature) / "board.html" + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(_render_html(board), encoding="utf-8") + print(f"Board generated: {out_path}") + print(f" Open by double-clicking, or: file://{out_path}") + print(f" Tasks: {board['stats']['total']} resolved: {board['stats'].get('resolved', 0)}") + return 0 + + +def cmd_build_all(args): + board = save_board_all(args.project) + for entry in board.get("skipped_changes", []): + print(f"Skipped broken change {entry['feature']}: {entry['error']}", file=sys.stderr) + if not board.get("tickets"): + skipped = board.get("skipped_changes", []) + if skipped: + features = ", ".join(entry["feature"] for entry in skipped) + print( + f"No board generated: all {len(skipped)} change(s) failed to " + f"parse ({features})", + file=sys.stderr, + ) + else: + changes_dir = Path(args.project).resolve() / "openspec" / "changes" + print( + f"No OpenSpec changes with tasks.md found under {changes_dir}", + file=sys.stderr, + ) + return 1 + out_path = Path(args.project).resolve() / ".dsh" / "baton" / "board-all.html" + out_path.write_text(_render_html(board), encoding="utf-8") + print(f"Global board generated: {out_path}") + print(f" Open by double-clicking, or: file://{out_path}") + print(f" Changes: {len(board.get('features', []))} Total tasks: {board['stats']['total']} resolved: {board['stats'].get('resolved', 0)}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="OpenSpec change board — static HTML boards + check helpers" + ) + sub = parser.add_subparsers(dest="command", required=True) + + scan = sub.add_parser("scan", help="scan a change's tasks.md → board.json") + scan.add_argument("project") + scan.add_argument( + "feature", + nargs="?", + help="optional: inferred when the project has exactly one change", + ) + scan.set_defaults(func=cmd_scan) + + check = sub.add_parser("check", help="flip a task to [x] (N.M id)") + check.add_argument("project") + check.add_argument("feature") + check.add_argument("task", metavar="N.M") + check.add_argument( + "--dry-run", + action="store_true", + help="validate and preview the flip; write nothing (no tasks.md, no board.json)", + ) + check.set_defaults(func=cmd_check) + + uncheck = sub.add_parser("uncheck", help="flip a task back to [ ] (N.M id)") + uncheck.add_argument("project") + uncheck.add_argument("feature") + uncheck.add_argument("task", metavar="N.M") + uncheck.add_argument( + "--dry-run", + action="store_true", + help="validate and preview the flip; write nothing (no tasks.md, no board.json)", + ) + uncheck.set_defaults(func=cmd_uncheck) + + build = sub.add_parser("build", help="generate a static HTML board (single change)") + build.add_argument("project") + build.add_argument( + "feature", + nargs="?", + help="optional: inferred when the project has exactly one change", + ) + build.set_defaults(func=cmd_build) + + build_all = sub.add_parser("build-all", help="generate the global multi-change board") + build_all.add_argument("project") + build_all.set_defaults(func=cmd_build_all) + + return parser + + +def main(argv: list[str]) -> int: + parser = build_parser() + args = parser.parse_args(argv[1:]) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/external_plugins/baton/skills/baton/SKILL.md b/external_plugins/baton/skills/baton/SKILL.md new file mode 100644 index 00000000..69a66c18 --- /dev/null +++ b/external_plugins/baton/skills/baton/SKILL.md @@ -0,0 +1,315 @@ +--- +name: baton +description: baton 接力调度——用确定性 ticket/DAG 控制面执行已批准的 OpenSpec change。当用户说「跑 baton」「跑一下 baton」「baton status」「用 baton 排活」「派活」「派发任务」「把这个 OpenSpec change 跑完」或问 baton 能不能排活时使用;本地 CLI 返回确定 actions,会话派发给隔离 worktree 子代理,验证全绿的密封回执才算完成。Also use when the user says "run baton", "baton status", or wants an OpenSpec change executed with verified integration. +whenToUse: The user mentions baton in any phrasing (跑 baton / baton status / 排活 / 派活 / dispatch) or wants an approved OpenSpec change executed or inspected in a git project. +--- + +# Baton + +Baton is the **sole ticket / DAG / session-subagent control plane over OpenSpec +changes**. The main session is a mechanical forwarder: it never decides ticket +state itself. It forwards the deterministic `actions` the CLI returns to native +subagents verbatim, binds each returned agent id, and reports the terminal event +back via `reconcile`. Free-text subagent output is ignored; only a **sealed +receipt** (the packet's verification commands ran green in an isolated worktree) +counts as success. + +## Invocation + +`` is the directory containing this bundle's `package.json` (two +levels above this `SKILL.md`). Relative doc paths below are written against that +root, not the skill directory. + +`` is a Python 3.10+ interpreter: `python3` on macOS/Linux, `python` on +Windows. + +`` is ` /scripts/session_control.py`. Every verb prints +one JSON object to stdout and exits non-zero on a domain failure (one JSON error +object, no traceback). Quote project paths. If the harness truncates stdout, +write the batch to `.dsh/baton/batch.json` and read that file. + +| Verb | Contract | +|---|---| +| `actions --session-id [--capacity 1-5] [--base-branch main]` | claim + return an action batch. `--session-id` is **required** (no default). | +| `bind ` | bind a spawned child to its assignment | +| `reconcile --agent-terminal completed\|failed\|canceled --session-id ` | turn a terminal event into a sealed receipt; returns the next batch | +| `status` | read-only progress snapshot (each attempt carries a `relay` judgment: elapsed / lease budget / receipt / liveness + `suggestedAction`) | +| `cleanup [--include-integration] [--dry-run]` | remove a finished run's worktrees | +| `abandon [--reason ""] [--force]` | retire a stuck attempt (guarded — refused while it is plausibly still working, see "Taking over a run") | +| `adopt ` | revive an abandoned attempt whose sealed receipt arrived late: re-verify + integrate | + +`--verify ""` is a **top-level** flag (before the verb, repeatable). The +first claim **must** declare the change-wide verification commands: there is no +implicit `git diff --check` fallback, and an unconfigured run fails loud. They +are persisted with the run, so later packets (including ones prepared by +`reconcile`) reuse them; passing `--verify` again is an explicit override for +new packets. + +## The loop + +**Responsibility**: drive the deterministic session-subagent loop until every +required task of the change is verified and integrated. + +### Prerequisites + +- The `dsh-openspec-baton` bundle is installed (this skill is mounted, and + `/scripts/session_control.py` exists). +- The active DSH profile provides `subagent`. `list_agents` / `send_message` are + only needed to inspect or steer a running child. +- The target project is a **git repository** with an OpenSpec change at + `/openspec/changes//` containing `proposal.md` and a + `tasks.md` with at least one unchecked `- [ ] N.M` task whose section + predecessors are all checked. + +### Steps + +1. **`actions`** to get the first batch: + + ```bash + "" --verify "" actions \ + --session-id --capacity 1 --base-branch main + ``` + + Returns `{ok, run_id, state, actions:[...]}`. State the chosen capacity + before raising it above 1. + +2. **Handle every action in the batch**: + - `dispatch` → `subagent(prompt=action.prompt, cwd=action.cwd, + description=" ticket ")`, then immediately + ` "" bind `. + - `resolve_merge` → `subagent(prompt=action.prompt, cwd=action.cwd, + description=" merge ")`. It resolves the preserved + conflict, runs the packet's verification commands and commits the merge. + - `wait` → nothing to spawn; end the turn and wait for outstanding notices. + - `recover` → no sealed receipt, so the outcome is unknown. A `lease-expired` + recovery is retried automatically (the next claim supersedes the stale + attempt and never blocks the frontier). Any other `recover` needs a + decision: retry/repair, or `abandon` if the attempt is stuck (abandon is + guarded — see "Taking over a run"). **Never** mark it succeeded. + - `blocked` → report the block code and reason; nothing to spawn. + - `complete` → every required ticket has a verified, integrated receipt. Run + `status` for the final summary and stop. + +3. **On each subagent completion notice**, `reconcile`. DSH settlement notices + are prose, not a status enum — map them explicitly: "finished" → `completed`; + stopped / canceled / interrupted → `canceled`; failed / "ran out of room" / + declined / abnormal → `failed`. + + ```bash + "" reconcile \ + --agent-terminal completed --session-id + ``` + + Reconcile honors only a sealed receipt written by + `/scripts/receipt.py verify` / `seal` in the assignment worktree; + any other terminal event flips the attempt to `unknown` and returns + `recover`. Nothing is merged for a rejected receipt. + +4. **Process the batch `reconcile` returns directly.** Call `actions` again only + when starting a run or resuming after a restart. Continue until a batch + contains `complete`. + +If a batch produces only running children, end the turn and wait for their +completion notices instead of polling. + +### Concurrency + +- Capacity 1 is strictly serial. Capacity 2–5 dispatches that many isolated + worktrees in parallel; integration into the shared worktree is serialized + under a repository-scoped lock. +- Merge conflicts are preserved (never auto-aborted) and surfaced as + `resolve_merge` actions. +- Never exceed capacity 5 (the controller rejects it) and never spawn more + children than the batch returned `dispatch` actions for. + +### Completion and cleanup + +At `complete` the controller auto-removes the per-assignment worktrees +(`baton/-tic-*`) and reports them in `worktrees_removed` / +`worktrees_skipped`. The shared integration worktree (`baton/`) is left +in place: merge it into the project's main branch, then remove it with +`cleanup --include-integration`. + +`cleanup` never touches the project's main working tree and never force-removes a +dirty worktree (it skips and reports). `--dry-run` lists what it would remove. A +change-id that never ran fails with `no baton worktrees or branches found` +instead of an empty success. + +### Taking over a run (relay) + +A handed-off run's subagents may still be working. A **clean worktree mid-ticket +is normal** — the subagent flow is write → verify → commit → seal, so nothing is +committed until the end — and a receipt only exists after that whole pipeline. +"No receipt yet" inside the lease is not a death verdict; "the completion notice +was lost" in a handoff note is an assumption, not evidence. + +Before judging any in-flight attempt, run `status` and read its `relay` block: +`elapsedSec`, `leaseRemainingSec`, `leaseElapsedFraction`, `receiptExists`, +worktree signals (`heartbeatAgeSec`, `lastActivityAgeSec`, `dirty`, `exists`) +and the `suggestedAction`. Then follow it mechanically: + +- **`wait`** (active, lease healthy, no receipt, no failure evidence) → end the + turn and wait for the completion notice; `reconcile` only needs + `assignment_id` + `claim_token`, which the batch already gave you. Do not + abandon. +- **`wait-lease-nearly-spent`** → keep waiting; the guard no longer applies but + an expired lease auto-recovers (the next `actions` supersedes the attempt and + re-dispatches). +- **`reconcile`** (active attempt, sealed receipt on disk) → the notice may have + beaten you to it; reconcile or just run `actions` (it auto-reconciles sealed + receipts). +- **`adopt`** (abandoned attempt, sealed receipt on disk) → the subagent + finished *after* a (mistaken) abandon. Run `adopt `; it + re-verifies the receipt and integrates it. If a newer attempt for the same + ticket already sealed its own evidence, adopt refuses and lists both — pick + one deliberately (keep the successor, or abandon it and adopt the earlier + attempt). If a successor is still in flight, stop it first. +- **`decide` / `resolve-or-abandon` / `await-expiry`** → the failure is already + on record (`unknown`, `blocked-review`, expired lease); treat as below. + +### Abandon a stuck attempt (guarded) + +```bash + "" abandon [--reason ""] [--force] +``` + +The operator escape hatch for an attempt that can no longer progress (a +non-retryable `recover`, or a receipt rejected as tampered). It **destroys** the +attempt's lease and its eligibility for reconcile — any later receipt from that +assignment is ignored unless explicitly revived with `adopt`. It **preserves** +the worktree, branch, commits, packet and receipt on disk for audit; it reverts +and merges nothing. The ticket becomes claimable again on the next `actions` +call. The command prints `destroys` / `preserves` so the consequence is explicit. + +**Guard**: abandoning an attempt that is plausibly still working — active state, +no failure evidence, no sealed receipt, less than 80% of its lease spent — is +**refused** with the full judgment data (elapsed, lease remaining, liveness +signals, advice). Read it, then either wait as advised or pass `--force`. The +hatch stays free where it is actually needed: `unknown` outcomes, +`blocked-review`, expired leases and terminal states are never guarded. + +### Adopt a late sealed receipt + +```bash + "" adopt +``` + +The reverse hatch for the case abandon got wrong: the abandoned subagent turned +out to be alive and sealed a valid receipt after the abandon. `adopt` fully +re-verifies the receipt (body digest, claim token, packet hash, identity) +*before* any state changes, then integrates it through the normal path — the +ticket lands with zero re-work. A receipt that fails verification leaves the +attempt abandoned. A merge conflict still parks the attempt in `blocked-review` +and reports the follow-up, exactly like a normal reconcile. + +## Status + +```bash + "" status +``` + +Returns `{ok, runId, revision, complete, feature, projectPath, +missingRequiredTickets:["2.1","3.2"], attempts:[...]}`. Each attempt carries a +`relay` block — the mechanical alive/dead judgment (`elapsedSec`, +`leaseRemainingSec`, `leaseElapsedFraction`, `receiptExists`, worktree +liveness signals, `suggestedAction`) that "Taking over a run" tells you to +read before deciding to wait or to abandon. + +`status` writes nothing: it creates no plan revision, no runtime directory and +no `plan.json`, so it is safe on a project that never ran (and on a mistyped +change-id, which still fails loud with the missing-tasks.md error). `revision: +0` therefore means "no plan revision recorded yet"; the first `actions` call +creates revision 1. Completion comes from the plan store's required-ticket +check against the latest plan revision — not from any PRD `passes` flags. + +## Editing the plan (live plan) + +Add, reorder or split tasks by editing the change's `tasks.md`. The next +`actions` call records a new plan revision and recomputes the frontier. + +- A running assignment keeps its original `planRevision` and `taskPacketHash`; + packets are immutable. +- Completion is judged against the latest revision, so every unchecked task in + the final `tasks.md` must be delivered. +- Tasks added to an earlier section block later sections; keep runtime additions + in a trailing section unless they must gate pending work. + +Mapping (`docs/ticket-format.md` is authoritative — it has the exact parsing +rules, the section-order DAG and the write-back contract): + +- change → feature; task `N.M` → ticket (`num = N*100 + M`) +- `[x]` = resolved, `[ ]` = ready; there is no draft state +- task `N.M` is blocked by every task in earlier sections; tasks inside one + section are independent (parallel frontier) +- verification is change-wide via `--verify`, not per task +- an integrated receipt flips the checkbox to `[x]` in `tasks.md` + +## Runtime directory layout + +Only what the loop needs to name; the rest lives in `AGENTS.md`. + +``` +/.dsh/baton/ +|-- queue.db durable plan store (name kept for compatibility) +|-- runs// packet.json / verification.json / receipt.json +`-- / plan.json / results/ +``` + +`openspec/changes//tasks.md` is the plan face. There is no +`prd-.json` on the active path; historical `prd.json` files are inert +and must not be used to judge completion. + +## Troubleshooting + +- **"No receipt + a clean worktree" reads like death** → it isn't: mid-flight + worktrees are clean by design (write → verify → commit → seal). Read + `status`'s `relay.suggestedAction`; inside the lease it says `wait`, and + `abandon` refuses without `--force`. A receipt that arrives *after* an + abandon can still be revived with `adopt`. +- **Frontier empty** → are the target task's section predecessors all `[x]`? Is + the task itself unchecked? +- **An attempt is `unknown`** → the subagent finished without a sealed receipt, + or its lease expired. Leases are fixed at **7200 s (2 h)** with no renewal + entry point, so a ticket that runs longer than two hours will always come back + as `lease-expired` and be re-dispatched. `lease-expired` recovers + automatically; any other `recover` needs a decision, or `abandon`. +- **Non-zero exit mentioning `receiptHash`** → the sealed receipt's body was + edited on disk (or written by an older baton). Nothing was merged. Re-seal from + the assignment worktree, or `abandon` and let the next `actions` claim fresh. +- **A `resolve_merge` action appeared** → two assignments touched overlapping + files; the conflict is preserved in the integration worktree. Resolve it in a + subagent, then reconcile again. Only the assignment whose branch owns the + preserved merge gets this action; other pending receipts keep waiting (they + are not marked conflicted, and nothing merges into a worktree that is already + mid-merge). +- **`blocked: integration-worktree-dirty`** → the shared integration worktree + has uncommitted tracked changes (typically a half-finished conflict + resolution). Nothing was merged. Commit or discard them there, then call + `actions` again. The attempt keeps its lease, so it is retried once the + worktree is clean; `abandon ` retires it instead. +- **`blocked: integration-failed`** → the merge could not run at all (for + example unrelated histories). Nothing was merged and the attempt is parked in + `blocked-review` instead of sitting in `integrating` until its lease expires. + Fix the integration worktree, then `actions` again, or `abandon`. +- **`HEAD did not advance from the assignment baseline`** → the subagent + committed nothing. If the ticket genuinely changes no files (a verification + or decision-only task), have it record that with an explicit empty commit + (`git commit --allow-empty -m ""`); the sealed receipt then + carries `changed_files: []` and the packet's verification commands are still + what decides success. +- **`capacity > 5` rejected** → the controller caps concurrency at 5. +- **A sealed receipt is not semantic correctness**: it binds the packet hash, + claim token, a clean advanced HEAD, the exit codes and the changed-file list + into one JSON file, and covers the whole body with a canonical `sha256` digest + (`receiptHash`). That is integrity evidence produced by `receipt.py`, not a + cryptographic proof (the digest has no secret) — pass strong + `verification_commands` and still review destructive outcomes. +- **`tasks.md` changed mid-run** → fine; the next `actions` call records a new + plan revision and in-flight packets stay immutable. + +## Installation + +Baton ships as the `dsh-openspec-baton` DSH bundle; installing it mounts +`skills/` so this skill is available. No background process and no manual tool +registration are required — the skill drives +`/scripts/session_control.py` directly. diff --git a/external_plugins/baton/templates/ticket-board.html b/external_plugins/baton/templates/ticket-board.html new file mode 100644 index 00000000..9faf298b --- /dev/null +++ b/external_plugins/baton/templates/ticket-board.html @@ -0,0 +1,477 @@ + + + + + +Ticket Board + + + + +
+
+
+ +
+
OpenSpec control plane
+

Baton

+
Change ...
+
+
+
+
+
+ +
+ + Select a ticket to inspect its scope and copy the check/uncheck command. Edit tasks.md to change the plan, then rebuild the board. +
+ +
+
+ Static snapshot. A real check/uncheck rewrites this file in place; reload to see the result. After editing tasks.md, run build again. + +
+
+ + + + + + + diff --git a/marketplace.json b/marketplace.json index c7f09a06..4dc7c619 100644 --- a/marketplace.json +++ b/marketplace.json @@ -10,7 +10,10 @@ "url": "https://z.ai" }, "unlistedPlugins": [ - { "name": "openpencil", "source": "./external_plugins/openpencil" } + { + "name": "openpencil", + "source": "./external_plugins/openpencil" + } ], "plugins": [ { @@ -827,7 +830,9 @@ }, { "name": "excalidraw", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "Excalidraw", "displayName_i18n": { "en": "Excalidraw", @@ -850,7 +855,9 @@ "name": "photocraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/photocraft/icon.png", "source": "./external_plugins/photocraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PhotoCraft", "displayName_i18n": { "en": "PhotoCraft", @@ -877,7 +884,9 @@ "name": "vectorcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/vectorcraft/icon.png", "source": "./external_plugins/vectorcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "VectorCraft", "displayName_i18n": { "en": "VectorCraft", @@ -904,7 +913,9 @@ "name": "pdfcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/pdfcraft/icon.png", "source": "./external_plugins/pdfcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PdfCraft", "displayName_i18n": { "en": "PdfCraft", @@ -931,7 +942,9 @@ "name": "designcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/designcraft/icon.png", "source": "./external_plugins/designcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DesignCraft", "displayName_i18n": { "en": "DesignCraft", @@ -958,7 +971,9 @@ "name": "wordcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/wordcraft/icon.png", "source": "./external_plugins/wordcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "WordCraft", "displayName_i18n": { "en": "WordCraft", @@ -985,7 +1000,9 @@ "name": "gridcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/gridcraft/icon.png", "source": "./external_plugins/gridcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "GridCraft", "displayName_i18n": { "en": "GridCraft", @@ -1012,7 +1029,9 @@ "name": "deckcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/deckcraft/icon.png", "source": "./external_plugins/deckcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DeckCraft", "displayName_i18n": { "en": "DeckCraft", @@ -1039,7 +1058,9 @@ "name": "cadcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/cadcraft/icon.png", "source": "./external_plugins/cadcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "CADCraft", "displayName_i18n": { "en": "CADCraft", @@ -1066,7 +1087,9 @@ "name": "soundcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/soundcraft/icon.png", "source": "./external_plugins/soundcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "SoundCraft", "displayName_i18n": { "en": "SoundCraft", @@ -1149,7 +1172,9 @@ }, "category": "developer-tools", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/kicad/icon.png", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "KiCad", "displayName_i18n": { "en": "KiCad", @@ -1443,6 +1468,35 @@ "mcp", "verification" ] + }, + { + "name": "baton", + "source": "./external_plugins/baton", + "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/baton/icon.png", + "description": "Deterministic ticket/DAG/subagent control plane over OpenSpec changes: a local Python CLI computes the task frontier and returns session actions; the main session dispatches each task packet to a subagent in an isolated git worktree, and only a sealed receipt with green verification counts as done. Bundles the baton skill and the CLI.", + "description_i18n": { + "en": "Deterministic ticket/DAG/subagent control plane over OpenSpec changes: a local Python CLI computes the task frontier and returns session actions; the main session dispatches each task packet to a subagent in an isolated git worktree, and only a sealed receipt with green verification counts as done. Bundles the baton skill and the CLI.", + "zh-CN": "OpenSpec 变更的确定性 ticket/DAG/子代理控制面:本地 Python CLI 计算任务前沿并返回会话动作;主会话把任务包派发到隔离 git worktree 中的子代理,只有验证全绿的密封回执才算完成。内置 baton 技能与控制面 CLI。" + }, + "version": "0.4.1", + "author": { + "name": "Lumos-789", + "url": "https://github.com/Lumos-789" + }, + "category": "developer-tools", + "keywords": [ + "openspec", + "subagents", + "ticket-dag", + "workflow", + "deterministic-scheduling" + ], + "examplePrompts": [], + "examplePrompts_i18n": {}, + "displayName": "Baton", + "displayName_i18n": { + "zh-CN": "Baton" + } } ] -} \ No newline at end of file +}