From 85c6941a6604003020dbe9326e0439789e837607 Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Fri, 25 Sep 2026 10:41:20 +0700 Subject: [PATCH 1/4] feat: recover interrupted dispatches in pickup and handoff PDN-0004 slice S5: - passdown-pickup reads the attempt store read-only (list --unresolved, claims, probe) in every plan repository. It classifies each unresolved attempt C1-C11 with its ownership risk and one proposed action, to be carried out later through dispatch Reconcile, and reports a handoff attempt missing from the store. It never writes a receipt, claim, plan or log, and never proposes retrying an uncertain attempt. - Its completion check follows the dispatch rule: a task with an unresolved attempt is never accepted, the latest Dispatched: line decides, and a legacy line counts only for a task with no receipts. - passdown-handoff lists unresolved attempts in an optional open_attempts frontmatter key and names them in Caveats / traps. It never resolves an attempt and never ticks a task that has one. - Both skills report a missing jq as "attempt store not readable" instead of guessing. The example workspace adds a handoff with an open attempt, SMOKE_TEST covers both skills against a live attempt, and tests/skills.sh and tests/docs.sh lint the new rules. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/SMOKE_TEST.md | 23 ++- ...06_passdown-demo-dispatch_claude-101500.md | 41 +++++ .../passdown/skills/passdown-handoff/SKILL.md | 29 +++- .../passdown/skills/passdown-pickup/SKILL.md | 141 ++++++++++++++---- tests/docs.sh | 3 + tests/skills.sh | 30 ++++ 7 files changed, 231 insertions(+), 40 deletions(-) create mode 100644 examples/basic-workspace/docs/log/2026-07-06_passdown-demo-dispatch_claude-101500.md diff --git a/README.md b/README.md index b993b8e..75aec7a 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ also why the skills survive any `cwd` and any repo. |---|---| | `passdown-intake` | Turns raw notes from an inbox (dropped there by weak capture tools like chat apps) into properly planned work in the right repo | | `passdown-dispatch` | Routes each task to the main session, an authorized native subagent, or an external CLI; records every delegated attempt and verifies its result before accepting it | -| `passdown-handoff` | Ends every session with a small handoff log: summary, next steps, and the traps that live nowhere else | -| `passdown-pickup` | Starts the next shift: reads the latest handoff and plan state, verifies them against the working tree, and briefs the session | +| `passdown-handoff` | Ends every session with a small handoff log: summary, next steps, open dispatch attempts, and the traps that live nowhere else | +| `passdown-pickup` | Starts the next shift: reads the latest handoff, plan state and any unresolved dispatch attempts, verifies them against the working tree, and briefs the session without changing anything | **The dispatch gate.** `passdown-dispatch` is a *pre-execution gate*, not just another skill: before a multi-task plan is executed — by the session itself or diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index c9fea4f..5a09dcd 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -116,9 +116,26 @@ schema, and a session log, so you can diff behavior instead of guessing. `status`/`branch`/`agent`/`plan` frontmatter; repeat with the same topic and confirm it creates another file rather than overwriting - [ ] **passdown-pickup**: in `examples/basic-workspace/`, run pickup and - confirm it reads the newest log's frontmatter, cross-checks - `openspec/changes/pkg-0001-demo/tasks.md`, and produces a briefing - without starting execution + confirm it reads the newest log's frontmatter + (`2026-07-06_passdown-demo-dispatch_claude-101500.md`), cross-checks + `openspec/changes/pkg-0001-demo/tasks.md`, reports attempt + `pd-20260706T100900Z-5b1e7c2a` as named in the handoff but missing + from the store (the example ships no store) with ownership risk, and + produces a briefing without starting execution or writing any file +- [ ] **pickup with a live attempt**: in a scratch Git repository laid out + like the fixture `tests/interrupt.sh` builds in `setup()` + (`docs/plan.md` with tasks 1.1–2.1), run + `REF_REPO=$PWD REF_JOURNAL=$PWD/../journal REF_CRASH_AT=after-first-write tests/harness/ref-host dispatch --mode write-then-sleep` + from the passdown checkout, then run pickup there live. Confirm it + lists the attempt as C3 with ownership risk, probes it, proposes to + wait or cancel (never a second writer), and that `git status` and the + store are unchanged afterwards. Kill the worker's process group when + done +- [ ] **handoff with an open attempt**: end that session with + `passdown-handoff` while the attempt is still unresolved; confirm the + log's frontmatter lists it under `open_attempts`, Caveats / traps + names its class, location and ownership risk, and task 1.1 stays + `[ ]` - [ ] **pickup flags unverified completion**: in a scratch copy, tick task 1.1 `[x]` and add `- Dispatched: agy (2026-09-21) — rejected: tests fail; verified: npm test` under it; confirm pickup reports 1.1 as diff --git a/examples/basic-workspace/docs/log/2026-07-06_passdown-demo-dispatch_claude-101500.md b/examples/basic-workspace/docs/log/2026-07-06_passdown-demo-dispatch_claude-101500.md new file mode 100644 index 0000000..2ac9107 --- /dev/null +++ b/examples/basic-workspace/docs/log/2026-07-06_passdown-demo-dispatch_claude-101500.md @@ -0,0 +1,41 @@ +--- +status: IN_PROGRESS +branch: main +agent: claude +plan: openspec/changes/pkg-0001-demo/ +open_attempts: + - pd-20260706T100900Z-5b1e7c2a +--- + +# 2026-07-06 — passdown demo, dispatch paused + +## Summary + +Routed `pkg-0001-demo` and dispatched task 1.1 to an external executor +under owner policy. The session had to stop while that attempt was still +running, so it is recorded here as open. This log exists to show what a +handoff with an unresolved attempt looks like; the example workspace ships +no attempt store, so pickup reports the attempt as missing from the store. + +## What was done + +- Routing written into `tasks.md`: 1.1 and 1.2 `[dispatch: external-ok]`, + 2.1 `[dispatch: main]`. +- Task 1.1 dispatched as attempt `pd-20260706T100900Z-5b1e7c2a` (external, + reason `owner-policy`, current checkout). + +## Next steps + +- [ ] Run `passdown-pickup`; resolve attempt `pd-20260706T100900Z-5b1e7c2a` + through the *Reconcile* section of `passdown-dispatch` before + dispatching anything else to this repository. +- [ ] Then dispatch 1.2, and do 2.1 in the main session. + +## Caveats / traps + +- Attempt `pd-20260706T100900Z-5b1e7c2a` (task + `openspec/changes/pkg-0001-demo/tasks.md#1.1`) is **C3, ownership risk**: + the worker was still running in the current checkout when this session + stopped. Probe it before anything else; do not start another writer in + this repository until it is stopped, and do not tick 1.1 until the host + has accepted it. diff --git a/plugins/passdown/skills/passdown-handoff/SKILL.md b/plugins/passdown/skills/passdown-handoff/SKILL.md index e368e40..76c5519 100644 --- a/plugins/passdown/skills/passdown-handoff/SKILL.md +++ b/plugins/passdown/skills/passdown-handoff/SKILL.md @@ -23,6 +23,7 @@ The effective configuration should contain: ## passdown - log_dir: - log_language: en # or another language for human-facing logs +- attempt_dir: # optional; default /passdown/attempts ``` If a required key is still missing, ask where session logs should live and @@ -46,6 +47,8 @@ suggest adding it to the appropriate `AGENTS.md`. branch: agent: plan: + open_attempts: # only when delegated attempts are unresolved + - pd-20260921T101530Z-3f9a1c2e --- ``` @@ -59,6 +62,15 @@ suggest adding it to the appropriate `AGENTS.md`. - repo-a/openspec/changes/slug-0009-short-name/ ``` + `open_attempts` lists every attempt that is unresolved when the log is + written: run `passdown-attempt --json list --unresolved` (the helper in + this skill's `scripts/` directory, with `--store ` when that + key is set) in each repository that holds a plan this session touched, + and list every ID it prints. Omit the key when there are none. The next + shift uses it to tell a known open attempt from an orphaned one. If `jq` + is missing, write "attempt store not readable: jq missing" in Caveats / + traps instead of guessing. + The `agent` value must match the `` field in the filename. Body sections: - **Summary**: 2–5 sentences — what was worked on and the outcome. @@ -67,7 +79,10 @@ suggest adding it to the appropriate `AGENTS.md`. - **Next steps**: checkboxes the next session can start on immediately. - **Caveats / traps**: known pitfalls discovered this session. This is the highest-value section — task state lives in the plan, but a fresh - trap lives nowhere else yet (step 4 promotes the durable ones). + trap lives nowhere else yet (step 4 promotes the durable ones). Name + every unresolved attempt here too: its ID, task, recovery class, + location, and whether it has ownership risk (a worker may still be + writing, so nobody may start another writer there). 3. **Sync task state**: if executing against a plan, update its checkboxes to match reality (for OpenSpec work: `tasks.md` of the change). Reality @@ -76,11 +91,14 @@ suggest adding it to the appropriate `AGENTS.md`. verification passed; - a delegated task is `[x]` only when its latest `Dispatched:` line is an accepted verdict as `passdown-dispatch` defines it: `accepted` - with a check the host ran. Lines written before the `accepted` wording - existed count as accepted when they report success and name a host - check after `verified:` — keep those `[x]`. Never tick a delegated + with a check the host ran, and either naming `attempt: ` whose + receipt is `accepted` for this task, or a host `main` line, or a legacy + line for a task with no receipt at all. Lines written before the `accepted` wording existed count as accepted when they report success and name a host check after `verified:` + — keep those `[x]` under the same condition. Never tick a delegated task because the worker reported success, because its files exist, or because the worker ticked the box itself; + - never tick a task that has an unresolved attempt, whatever its lines + say; it goes back to (or stays) `[ ]` and is named in Caveats / traps; - delegated work that is still unverified goes back to (or stays) `[ ]`, and the log's Caveats / traps names it as unverified so the next shift verifies it before building on it. @@ -103,6 +121,9 @@ suggest adding it to the appropriate `AGENTS.md`. ## Rules +- Handoff never resolves an attempt: no `abandon`, `observe`, `verdict`, + `release-claim` or other mutating helper command. It records what is open; + the next shift reconciles it through `passdown-dispatch`. - The log complements, never duplicates, the plan: plan = which tasks are done; log = why we stopped here and what to avoid. - Keep it short enough that the next session actually reads all of it. diff --git a/plugins/passdown/skills/passdown-pickup/SKILL.md b/plugins/passdown/skills/passdown-pickup/SKILL.md index a281e8f..a321709 100644 --- a/plugins/passdown/skills/passdown-pickup/SKILL.md +++ b/plugins/passdown/skills/passdown-pickup/SKILL.md @@ -1,6 +1,6 @@ --- name: passdown-pickup -description: Use when starting or resuming work in a workspace that keeps passdown handoff logs — reads the latest handoff and plan state, verifies them against the working tree, and briefs the session so work resumes from files instead of a replayed transcript +description: Use when starting or resuming work in a workspace that keeps passdown handoff logs — reads the latest handoff, plan state and unresolved dispatch attempts, verifies them against the working tree, and briefs the session so work resumes from files instead of a replayed transcript --- # Passdown Pickup @@ -9,6 +9,12 @@ Start of shift: read the previous passdown before touching anything. The handoff wrote state into small files; pickup turns those files back into working context for a fraction of what replaying a transcript costs. +Pickup is **read-only**. It reads logs, plans and the attempt store, and it +may run read-only probes, but it never writes a receipt, a claim, a plan or a +log, and it never starts executing. Recovery actions are proposed here and +carried out in the open through the *Reconcile* section of +`passdown-dispatch`. + ## Configuration (read first) Build the effective passdown configuration root-to-nearest. Read applicable @@ -25,56 +31,129 @@ The effective configuration should contain: - log_dir: - planning: markdown | openspec # used to locate plan state - plan_dir: # when planning: markdown +- attempt_dir: # optional; default /passdown/attempts +- executor_refs: # optional; workspace executor cards ``` If `log_dir` is still missing, ask where session logs live and suggest adding it to the appropriate `AGENTS.md`. +**The attempt helper.** Delegated dispatch records each attempt in a receipt +in the attempt store of the repository that holds the plan. Read it with +`scripts/passdown-attempt` in this skill's directory, passing `--store +` when that key is set and `--card-dir ` when that +key is set. Pickup uses only its read-only commands: `list`, `claims`, +`probe`, `digest` and `validate`. If `jq` is missing, say "attempt store not +readable: jq missing" in the briefing and treat every delegated `[x]` as +unconfirmed; do not guess. + ## Process 1. **Locate the handoff**: list `` and sort by the filename's date and `HHMMSS` suffix, newest first. Read the newest log's frontmatter - (`status`, `branch`, `agent`, `plan`); also read the frontmatter of any - other recent logs, in case another agent worked in parallel. Frontmatter - first, full text only for the logs that matter — that is what keeps pickup - cheap. + (`status`, `branch`, `agent`, `plan`, and `open_attempts` when present); + also read the frontmatter of any other recent logs, in case another agent + worked in parallel. Frontmatter first, full text only for the logs that + matter — that is what keeps pickup cheap. 2. **Read the passdown**: for the relevant log(s), read Summary, Next steps, and especially Caveats / traps in full. Task state lives in the plan; session-scoped traps live nowhere but the log. -3. **Verify against reality** — files may have moved on since the log was +3. **Read the attempt store.** For each repository that holds a plan in + `plan:` (and the current repository), run `passdown-attempt --json list + --unresolved`. It lists every attempt that is unresolved or still holds a + claim (C11), each with its recovery classes from the table below. For C2, C3 and C8 attempts, run + `passdown-attempt probe ` to see whether the worker is still alive. + Then run `passdown-attempt --json claims --repo ` for the current + repository and for every target repository an unresolved attempt names + (`location`). A claim held by another planner's attempt is shown with that + planner's store path. Every `open_attempts` ID from the latest handoff + must appear in one of those stores; a missing one is *inconsistent* (its + store may be gone) and is reported as an ownership risk. +4. **Verify against reality** — files may have moved on since the log was written: - the current branch vs the log's `branch:`; - `git status` and recent commits vs the log's "What was done"; - each plan listed in `plan:` (a single path or a YAML list, resolved from the workspace root) — compare its checkboxes and any - `Dispatched:` outcome lines against the log's next steps. - - **completion authority** in each plan: a delegated worker cannot - accept its own work (see `passdown-dispatch`), so check every `[x]` - task that shows signs of delegation: - - `[x]` whose latest `Dispatched:` line is not an accepted verdict - as `passdown-dispatch` defines it — **inconsistent**: the checkbox - claims completion the host never recorded. An accepted verdict - records `accepted` and names a host check after `verified:`. Lines - written before the `accepted` wording existed count as accepted when - they report success and name a host check after `verified:`. - - `[x]` on a `[dispatch: external-ok]` task with no `Dispatched:` line - at all — **unconfirmed**: nothing records who did it or whether it - was verified. - Report each such task by plan path and task ID, and treat it as not - accepted — still pending verification — in the briefing and the - proposed first action. - Report any mismatch instead of silently reconciling it; the working tree - and the plan win over the log, and an inconsistent `[x]` does not win - over the missing host verdict. -4. **Brief, then wait**: report status, verified next steps, and the traps. - Pickup produces a briefing and a proposed first action — it - never starts executing on its own. If the proposed work is a multi-task - plan, the `passdown-dispatch` gate still applies before implementation. + `Dispatched:` outcome lines against the log's next steps and the store; + - **completion authority** in each plan (next section). + Report any mismatch instead of silently reconciling it; the working tree, + the plan and the store win over the log, and an inconsistent `[x]` does + not win over a missing host verdict. +5. **Brief, then wait**: report status, verified next steps, the traps, and + every unresolved attempt with its class, location, ownership risk and one + proposed action. Pickup produces a briefing and a proposed first action — + it never starts executing on its own. If the proposed work is a multi-task + plan, or resolves an attempt, the `passdown-dispatch` gate and its + *Reconcile* section still apply before anything is executed. + +## Recovery classes + +`list --unresolved` computes the class; pickup adds C9 and explains each +class in the briefing. **Ownership risk** means a worker may still be writing +(`running` or `unknown`): no other writer may start on that repository until +it is resolved. Render a rejection by its reason (**needs input**, +**blocked**, …), never as a bare "rejected". + +| Class | Seen as | Ownership risk | Proposed action (carried out through dispatch *Reconcile*) | +|---|---|---|---| +| C1 never launched | `prepared` | No | Compare the location with the baseline read-only first. No writes → `abandon`. Writes → treat as C2. | +| C2 launch outcome unknown | `unknown`, no handle | **Yes** | Look for the process with the card's `discovery_hint`, inspect the location read-only. Never start another writer first; ask the owner to attest if no process remains. | +| C3 live or maybe live | `running`, or `unknown` with a handle | **Yes** | `probe`: alive → wait or cancel; gone with safe machine evidence → record the stop, then C4/C5; otherwise owner attestation. | +| C4 result, no verdict | `stopped`, valid result, `pending` | No | Run the acceptance lifecycle from `inspect`. | +| C5 stopped, no valid result | `stopped`, `pending`, result missing or invalid | No | Re-extract once; then inspect, reject `invalid_result`, and re-emit once, salvage or release. | +| C6 verdict, no projection | `accepted`, not projected | No | Re-check the task digest, the checks and the artifact digest; then finish the projection. | +| C7 stale | the task changed since `new` | Per observation | Resolve ownership risk first, then reject `stale`; new work is a new attempt. | +| C8 cancel unconfirmed | cancel requested, `running`/`unknown` | **Yes** | `probe`; escalate the cancel per the card; stop only with safe evidence. | +| C9 orphaned | another session's attempt, older than its dispatch budget (use one hour when none is recorded), not named in the latest handoff's `open_attempts` | Per observation | Surface it prominently with age and location, then classify it as C1–C8. | +| C10 interrupted continuation | unresolved, with `continues` set | Per observation | Show the chain; resolve its newest link as C1–C8. Nothing is re-asked. | +| C11 claim without a live holder | a claim held by a stopped attempt rejected with a claim-holding reason, or by an attempt whose store is unreadable | No, or **unknown** if the store is unreadable | Continue, salvage or `release-claim`; an unreadable store is released only after the owner confirms no writer. | + +An attempt can carry more than one class (for example `C9,C2` or `C10,C3`); +report all of them. + +## Completion authority + +A delegated worker cannot accept its own work (see `passdown-dispatch`), so +check every `[x]` task that shows signs of delegation, and every task with a +receipt in the store: + +- **A task with any unresolved attempt is not accepted**, whatever its plan + lines say: brief it as *unaccepted, attempt unresolved*. This covers a + worker that forged an `accepted` line and ticked the box before the host + crashed. +- Otherwise the task's **latest** `Dispatched:` line decides. It is an + accepted verdict when it records `accepted`, names a host check after + `verified:`, and is one of: + - a **v0.5 line** naming `attempt: ` whose receipt exists, is + `accepted`, and is for this exact task (plan path and task ID); + - a **host line**, `Dispatched: main …`, for work the host finished itself; + - a **legacy line** without `attempt:`, only while the store holds no + receipt at all for that task. + Lines written before the `accepted` wording existed count as accepted when they report success and name a host check after `verified:`, + under the same condition. +- `[x]` whose latest `Dispatched:` line is not an accepted verdict, or names + an attempt whose receipt is missing, not accepted or for another task — + **inconsistent**: the checkbox claims completion the host never recorded. +- `[x]` on a `[dispatch: external-ok]` task with no `Dispatched:` line at + all — **unconfirmed**: nothing records who did it or whether it was + verified. + +Report each such task by plan path and task ID, and treat it as not +accepted — still pending verification — in the briefing and the proposed +first action. Dispatches from before v0.5 have no receipts; they are judged +by the plan lines alone, and pickup never back-fills a receipt for them. ## Rules +- Pickup is read-only: never write a receipt, a claim, a plan or a log, and + never run `abandon`, `observe`, `cancel`, `verdict`, `release-claim` or any + other mutating helper command. The host does that later, in the open, + through dispatch *Reconcile*. +- Nothing uncertain is proposed as a retry. An attempt with ownership risk is + never answered with "start another writer". - An empty or missing `log_dir` is a fact to report, not an error to fix: - say so and brief from plan/task state alone. + say so and brief from plan, task and store state alone. - Pickup does not fix an inconsistent `[x]` itself; the host that verifies the task (or unticks it) records the verdict, per `passdown-dispatch`. - Never modify a previous shift's log. Corrections and discoveries belong in @@ -82,4 +161,4 @@ it to the appropriate `AGENTS.md`. - An `IN_PROGRESS` or `BLOCKED` log from another agent may mean that shift is still active — surface it before building on that work. - Trust files over the transcript: when a summarized conversation and the - logs disagree, the logs and the plan are the source of truth. + logs disagree, the logs, the plan and the store are the source of truth. diff --git a/tests/docs.sh b/tests/docs.sh index 208f98a..a112422 100755 --- a/tests/docs.sh +++ b/tests/docs.sh @@ -108,6 +108,9 @@ pass "example schema copy is synchronized" example_log="$repo_root/examples/basic-workspace/docs/log/2026-07-05_passdown-demo.md" require_text "$example_log" "^status:" \ "example handoff log carries machine-readable frontmatter" +example_open="$repo_root/examples/basic-workspace/docs/log/2026-07-06_passdown-demo-dispatch_claude-101500.md" +require_text "$example_open" "^open_attempts:" \ + "example workspace shows a handoff with an open attempt" changelog="$repo_root/CHANGELOG.md" while IFS= read -r heading; do diff --git a/tests/skills.sh b/tests/skills.sh index 016fe70..35681e4 100755 --- a/tests/skills.sh +++ b/tests/skills.sh @@ -238,6 +238,36 @@ require_text_block "$pickup" "\\[x\\].*Dispatched:.*accepted|\\[x\\].*accepted.* require_text_block "$pickup" "not accepted|still pending|as pending" \ "pickup treats unverified delegated completion as not accepted" +# v0.5 recovery (PDN-0004 S5): pickup reads the attempt store read-only and +# classifies; handoff records open attempts. The class names are checked +# against the helper behaviorally in tests/interrupt.sh PICKUP. +require_text_block "$pickup" "Pickup is \\*\\*read-only\\*\\*" \ + "pickup is read-only" +require_text_block "$pickup" "list +--unresolved" \ + "pickup lists unresolved attempts through the helper" +require_text "$pickup" "^## Recovery classes" \ + "pickup defines the recovery classes" +require_text_block "$pickup" "never run +\`abandon\`, +\`observe\`" \ + "pickup never runs a mutating helper command" +require_text_block "$pickup" "open_attempts" \ + "pickup reads the handoff's open attempts" +require_text_block "$pickup" "A task with any unresolved attempt is not accepted" \ + "pickup never counts a task with an unresolved attempt as accepted" +require_text_block "$pickup" "no +receipt at all for that task" \ + "pickup honors a legacy line only for a task without receipts" +require_text_block "$pickup" "Nothing uncertain is proposed as a retry" \ + "pickup never proposes retrying an uncertain attempt" +for skill in passdown-pickup passdown-handoff; do + require_text_block "$skills_root/$skill/SKILL.md" "attempt store not +readable: jq missing" \ + "$skill reports an unreadable store instead of guessing" +done +require_text "$handoff" "open_attempts:" \ + "handoff frontmatter lists open attempts" +require_text_block "$handoff" "never tick a task that has an unresolved attempt" \ + "handoff never ticks a task with an unresolved attempt" +require_text_block "$handoff" "Handoff never resolves an attempt" \ + "handoff never resolves an attempt" + schema="$repo_root/schemas/passdown/schema.yaml" apply_instruction="$(sed -n '/^apply:/,$p' "$schema")" grep -q "assigned" <<<"$apply_instruction" || From ece988c0fcad0cab2fc1e2ef1e2505f20b5ad24b Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Fri, 25 Sep 2026 10:41:21 +0700 Subject: [PATCH 2/4] test: check pickup's class names and read-only rule in the harness The reference pickup reads open_attempts from a handoff log's frontmatter (F16 now writes a real log) and reports an open attempt that has no receipt as inconsistent. New scenario PICKUP, the S5 acceptance check: - the class names used by the helper's CLASS_JQ and the reference pickup must equal the rows of the pickup skill's class table and of dispatch Reconcile; - every verdict the reference pickup prints is a term the skill defines; - with a live writer and a handoff naming a missing attempt, pickup leaves every file under docs/, src/ and the attempt store byte-identical. Dropping a class row from either skill, or letting pickup write, fails it. The design records the S5 implementation notes; CHANGELOG the slice. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 22 +++++++- .../PDN-0004-v05-acceptance-recovery.md | 23 +++++++++ tests/harness/ref-host | 18 ++++++- tests/interrupt.sh | 51 ++++++++++++++++++- 4 files changed, 109 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94ecd8c..a31ce26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,12 +83,32 @@ Beta build for the `release/v0.5.0` testing window — not a GitHub release. - `templates/plan.md` and `templates/AGENTS.thin.md` document the attempt reference and the new optional keys `attempt_dir`, `worktree_dir`, `executor_refs` and `concurrency_profiles`. +- `passdown-pickup` and `passdown-handoff` recover interrupted dispatches + (PDN-0004, slice S5): + - Pickup reads the attempt store read-only (`list --unresolved`, + `claims`, `probe`). It classifies every unresolved attempt C1–C11 with + its ownership risk and one proposed action, carried out later through + dispatch *Reconcile*, and reports a handoff attempt missing from the + store. It never writes a receipt, claim, plan or log. + - Pickup's completion check follows the dispatch rule: a task with an + unresolved attempt is never accepted, the latest `Dispatched:` line + decides, and a legacy line counts only for a task with no receipts. + - Handoff lists unresolved attempts in a new optional `open_attempts` + frontmatter key and names them in Caveats / traps. It never resolves + an attempt and never ticks a task that has one. + - A missing `jq` is reported as "attempt store not readable" instead of + guessed around. The example workspace gains a handoff with an open + attempt, and `docs/SMOKE_TEST.md` covers both skills against a live + attempt. - The reference host (`tests/harness/ref-host`) prints the skill's step names, and a new harness scenario (`tests/interrupt.sh STEPS`) checks that the host performs the skill's numbered steps by name and in order. The reference host now also takes the settle inspection a card asks for, and F18b uses a card that wrongly claims its worker never detaches and releases the late - write inside that window. + write inside that window. For S5, its pickup reads `open_attempts` from a + handoff log's frontmatter, and a new scenario (`tests/interrupt.sh + PICKUP`) checks that the helper, the pickup skill and dispatch Reconcile + use one set of class names, and that pickup changes no file. ### Fixed diff --git a/docs/design/PDN-0004-v05-acceptance-recovery.md b/docs/design/PDN-0004-v05-acceptance-recovery.md index b54071a..176e547 100644 --- a/docs/design/PDN-0004-v05-acceptance-recovery.md +++ b/docs/design/PDN-0004-v05-acceptance-recovery.md @@ -2025,6 +2025,29 @@ ships it. - Rollback: revert; dispatch still records receipts, pickup falls back to the v0.4 plan-only checks. +**Implementation notes (S5).** + +- Pickup names only read-only helper commands (`list`, `claims`, `probe`, + `digest`, `validate`) and forbids the mutating ones by name. The class + table in the pickup skill carries the same C1–C11 rows as dispatch + *Reconcile*, with the proposed action and the ownership risk. +- The C9 age threshold uses the attempt's dispatch budget. Cards have no + budget field (§18), so the skill falls back to one hour when none is + recorded. The reference host takes it as `--budget`. +- `tests/interrupt.sh PICKUP` is the S5 acceptance check. It extracts the + class names from the helper's `CLASS_JQ` and from the reference pickup, + and requires them to equal the rows of both skill tables. It also runs + the reference pickup against a live writer and a handoff that names an + attempt missing from the store, and requires every file under `docs/`, + `src/` and the attempt store to be byte-identical afterwards. Dropping a + class row from either skill, or letting pickup write, fails it. +- The reference host now reads `open_attempts` from a handoff log's + frontmatter (F16 writes a real log), and reports an `open_attempts` ID + that has no receipt as *inconsistent* (§23, "Store deleted"). +- The example workspace keeps its v0.4 log and adds a later handoff with + one open attempt. Because the example ships no store, pickup there shows + the missing-store case. + **S6 — Executor reference cards + E-KIRO-1.** - Files: `passdown-dispatch/references/executors/{claude,codex,kiro-cli}.md`, `docs/EXECUTOR_SETUP.md` (measuring and writing a card), diff --git a/tests/harness/ref-host b/tests/harness/ref-host index 4ea6531..f5baf5f 100755 --- a/tests/harness/ref-host +++ b/tests/harness/ref-host @@ -12,7 +12,8 @@ # ref-host continue --prev --mode --answer # ref-host salvage --prev # ref-host finish --id # resume the lifecycle after a crash (C3/C4/C5/C6) -# ref-host pickup [--session ] [--handoff ] [--budget ] +# ref-host pickup [--session ] [--handoff ] [--budget ] +# (: a handoff log; its frontmatter open_attempts are known) # # Environment: # REF_REPO target repository = planner repository (default: cwd) @@ -245,6 +246,15 @@ lifecycle() { # decide "$1" } +open_attempts_of() { # -> JSON array of the frontmatter's open_attempts + [ -n "$1" ] || { echo '[]'; return; } + awk 'NR == 1 && $0 == "---" { fm = 1; next } + fm && $0 == "---" { exit } + fm && /^open_attempts:/ { oa = 1; next } + fm && oa && /^ - / { sub(/^ - /, ""); sub(/[ \t]*#.*/, ""); print; next } + fm && oa && !/^ / { oa = 0 }' "$1" | jq -R -s -c 'split("\n") | map(select(. != ""))' +} + new_attempt() { # args for `new` -> sets $id; a refusal (exit 6) is reported, never worked around local code=0 id="$(H new --host ref-host --session "$session" --planner-repo "$repo" "$@" 2>"$store/../ref-new.err.$$")" || code=$? @@ -387,11 +397,15 @@ case "$cmd" in # Read-only briefing (section 13): classes from the helper, C9 from the # session/handoff rule, and the plan cross-check. H --json list --unresolved | jq -r --arg s "$pickup_session" --argjson budget "$budget" \ - --argjson open "$( [ -n "$handoff" ] && jq -R -s -c 'split("\n") | map(select(. != ""))' "$handoff" || echo '[]')" ' + --argjson open "$(open_attempts_of "$handoff")" ' .[] | .classes as $c | .id as $id | (if .host.session != $s and .age_seconds > $budget and ($open | index($id) | not) then ["C9"] else [] end) as $orphan | "class \(.id) \(($orphan + $c) | join(","))\(if .ownership_risk then " ownership-risk" else "" end)"' + # Every attempt the handoff named must still be in the store. + open_attempts_of "$handoff" | jq -r '.[]' | while IFS= read -r o; do + [ -f "$store/$o/receipt.json" ] || echo "inconsistent $o is named in the handoff but missing from the store" + done unresolved_tasks="$(H --json list --unresolved | jq -r '.[] | select(.classes | any(. != "C11")) | .task | sub(".*#"; "")' | sort -u)" { grep -E '^- \[[xX]\] ' "$repo/$plan_rel" || true; } | awk '{print $3}' | while IFS= read -r t; do # The latest Dispatched line decides (the dispatch skill's accepted-verdict rule). diff --git a/tests/interrupt.sh b/tests/interrupt.sh index 11b8523..2f4e177 100755 --- a/tests/interrupt.sh +++ b/tests/interrupt.sh @@ -470,7 +470,8 @@ F16() { sleep 1.1 "$harness/ref-host" pickup --session current-session --budget 0 >"$scratch/F16/pickup.out" 2>"$scratch/F16/pickup.err" || fail "pickup failed: $(cat "$scratch/F16/pickup.err")" grep -q "^class $a C9,C2 ownership-risk" "$scratch/F16/pickup.out" || fail "not surfaced as C9: $(cat "$scratch/F16/pickup.out")" - echo "$a" >"$scratch/F16/handoff" + printf -- '---\nstatus: IN_PROGRESS\nbranch: main\nagent: claude\nplan: docs/plan.md\nopen_attempts:\n - %s # C2\n---\n\n## Summary\n' "$a" \ + >"$scratch/F16/handoff" "$harness/ref-host" pickup --session current-session --budget 0 --handoff "$scratch/F16/handoff" >"$scratch/F16/pickup2.out" grep -q "^class $a C2 ownership-risk" "$scratch/F16/pickup2.out" || fail "handoff-named attempt still C9" oracle @@ -751,6 +752,52 @@ F14c() { pass "C6 refuses changed artifacts even when verification still passes" } +# ------------------------------------------------------------ pickup (S5) + +# classes_in : sorted C names used +# between two lines (inclusive) of a file. +classes_in() { + awk -v a="$2" -v b="$3" 'index($0, a) { on = 1 } on { print } on && index($0, b) { exit }' "$1" | + grep -oE '"C[0-9]+"|\| C[0-9]+ ' | tr -d '"| ' | sort -u | tr '\n' ' ' +} + +PICKUP() { + local a pickup_skill dispatch_skill helper_classes skill_classes reconcile_classes before after word + setup PICKUP + pickup_skill="$repo_root/plugins/passdown/skills/passdown-pickup/SKILL.md" + dispatch_skill="$repo_root/plugins/passdown/skills/passdown-dispatch/SKILL.md" + # 1. One set of class names: the helper's (plus C9, which pickup adds), the + # pickup skill's table and the dispatch skill's Reconcile table. + helper_classes="$(printf '%s %s' "$(classes_in "$REF_HELPER" "CLASS_JQ='" "]'")" \ + "$(classes_in "$harness/ref-host" " pickup)" "unresolved_tasks=")" | tr ' ' '\n' | grep . | sort -u | tr '\n' ' ')" + skill_classes="$(classes_in "$pickup_skill" "## Recovery classes" "## Completion authority")" + reconcile_classes="$(classes_in "$dispatch_skill" "## Reconcile" "## Rules")" + [ "$helper_classes" = "C1 C10 C11 C2 C3 C4 C5 C6 C7 C8 C9 " ] || fail "helper and ref-host classes: $helper_classes" + [ "$skill_classes" = "$helper_classes" ] || fail "pickup skill classes [$skill_classes] differ from [$helper_classes]" + [ "$reconcile_classes" = "$helper_classes" ] || fail "dispatch Reconcile classes [$reconcile_classes] differ from [$helper_classes]" + # 2. Every verdict the reference pickup prints is a term the skill defines. + for word in accepted unaccepted unconfirmed inconsistent; do + grep -qi "\*$word\|\*\*$word\|$word\*" "$pickup_skill" || fail "pickup skill does not define '$word'" + done + # 3. Pickup is read-only: with a live writer (C3, which also holds the + # repository's claim) and a handoff naming an attempt missing from the + # store, it changes no file. + REF_CRASH_AT=after-first-write host dispatch --mode write-then-sleep --task 1.1 + a="$(attempt_of)" + printf -- '---\nstatus: IN_PROGRESS\nbranch: main\nagent: claude\nplan: docs/plan.md\nopen_attempts:\n - %s\n - pd-20260101T000000Z-00000000\n---\n' "$a" \ + >"$scratch/PICKUP/handoff.md" + before="$(cd "$repo" && find docs src .git/passdown -type f | sort | xargs shasum)" + "$harness/ref-host" pickup --handoff "$scratch/PICKUP/handoff.md" >"$scratch/PICKUP/pickup.out" 2>&1 || fail "pickup failed" + after="$(cd "$repo" && find docs src .git/passdown -type f | sort | xargs shasum)" + [ "$before" = "$after" ] || fail "pickup changed files: $(diff <(echo "$before") <(echo "$after"))" + grep -q "^class $a C3 ownership-risk" "$scratch/PICKUP/pickup.out" || fail "live writer not C3: $(cat "$scratch/PICKUP/pickup.out")" + grep -q '^inconsistent pd-20260101T000000Z-00000000 is named in the handoff' "$scratch/PICKUP/pickup.out" || + fail "a handoff attempt missing from the store was not reported" + reap "$a" + oracle + pass "pickup uses the skills' class names, reports a missing handoff attempt, and writes nothing" +} + # ------------------------------------------------------ skill correspondence # skill_steps: " required|optional" for each numbered step of @@ -846,7 +893,7 @@ mutation() { pass "with the claim check stubbed out, F11, F15, F19b and F22 all fail (the suite detects the missing guard)" } -all="F1 F2 F2b F2c F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 F13 F14 F14c F14d F15 F16 F17 F18 F18b F19 F19b F20 F21 F22 F23 F24 F25 F26 STEPS mutation" +all="F1 F2 F2b F2c F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 F13 F14 F14c F14d F15 F16 F17 F18 F18b F19 F19b F20 F21 F22 F23 F24 F25 F26 STEPS PICKUP mutation" [ "$#" -gt 0 ] || read -r -a all_list <<<"$all" [ "$#" -gt 0 ] || set -- "${all_list[@]}" for s in "$@"; do From 3029311d8cd9fc3abf29c3f5bc60c3a8e643484e Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Fri, 25 Sep 2026 11:15:22 +0700 Subject: [PATCH 3/4] fix: keep open attempts across handoffs and measure orphans by inactivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 on #20: - An absent attempt store is not an empty one. `passdown-attempt list` warns "absent, not empty" when the store directory does not exist, and still prints []. Handoff builds open_attempts from the store's unresolved attempts plus every ID from the previous handoff whose receipt does not show it resolved. An ID with no receipt stays listed, marked `# receipt missing`, until the owner confirms no worker remains. Pickup treats the handoff's IDs as missing when the store is absent. - C9 follows inactivity, as §13.2 says ("no update for longer than the budget"). `list` reports idle_seconds, measured from the receipt's last transition, next to age_seconds, and pickup uses it. tests/attempt.sh covers both helper changes; tests/skills.sh lints the skill rules; the design records them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++ .../PDN-0004-v05-acceptance-recovery.md | 15 ++++++++++ .../scripts/passdown-attempt | 11 +++++-- .../passdown/skills/passdown-handoff/SKILL.md | 29 +++++++++++++------ .../passdown-handoff/scripts/passdown-attempt | 11 +++++-- .../passdown/skills/passdown-pickup/SKILL.md | 7 +++-- .../passdown-pickup/scripts/passdown-attempt | 11 +++++-- scripts/passdown-attempt | 11 +++++-- tests/attempt.sh | 17 +++++++++++ tests/skills.sh | 8 +++++ 10 files changed, 107 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a31ce26..b24d4af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,12 @@ Beta build for the `release/v0.5.0` testing window — not a GitHub release. - Handoff lists unresolved attempts in a new optional `open_attempts` frontmatter key and names them in Caveats / traps. It never resolves an attempt and never ticks a task that has one. + - Handoff carries forward every ID from the previous handoff's + `open_attempts` until its receipt shows it resolved; an ID whose receipt + is missing stays listed (`# receipt missing`) until the owner confirms + no worker remains. `passdown-attempt list` warns when the store is + absent ("absent, not empty") and reports `idle_seconds` since the last + receipt transition, which pickup uses for C9. - A missing `jq` is reported as "attempt store not readable" instead of guessed around. The example workspace gains a handoff with an open attempt, and `docs/SMOKE_TEST.md` covers both skills against a live diff --git a/docs/design/PDN-0004-v05-acceptance-recovery.md b/docs/design/PDN-0004-v05-acceptance-recovery.md index 176e547..de717df 100644 --- a/docs/design/PDN-0004-v05-acceptance-recovery.md +++ b/docs/design/PDN-0004-v05-acceptance-recovery.md @@ -2047,6 +2047,21 @@ ships it. - The example workspace keeps its v0.4 log and adds a later handoff with one open attempt. Because the example ships no store, pickup there shows the missing-store case. +- Review round 1 on [#20](https://github.com/vunm-io/passdown/pull/20): + - **C9 measures inactivity.** §13.2 says "no update for longer than the + budget". `list` now reports `idle_seconds`, measured from the receipt's + last transition, next to `age_seconds`. Pickup and the reference host + use it. F16b creates an old attempt, updates it recently, and requires + it not to be orphaned until it has been idle past the budget. + - **An absent store is not an empty one.** `list` warns "absent, not + empty" when the store directory does not exist, and still prints `[]`. + Handoff builds `open_attempts` from the store's unresolved attempts + plus every previous `open_attempts` ID whose receipt does not show it + resolved. A receipt-less ID is kept and marked `# receipt missing` + until the owner confirms no worker remains. The reference host gains + `handoff`, the reference for this rule. Scenario HANDOFF runs it with + a store and, after a pickup, with no store at all, and requires the + store not to be created. **S6 — Executor reference cards + E-KIRO-1.** - Files: `passdown-dispatch/references/executors/{claude,codex,kiro-cli}.md`, diff --git a/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt b/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt index 0043129..d666455 100755 --- a/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt +++ b/plugins/passdown/skills/passdown-dispatch/scripts/passdown-attempt @@ -1980,7 +1980,13 @@ cmd_list() { esac done resolve_store - [ -d "$STORE" ] || { [ "$JSON_OUT" = 1 ] && echo '[]'; return 0; } + if [ ! -d "$STORE" ]; then + # An absent store is not a verified empty one: a handoff must not drop + # attempts it knew about because their store is gone. + warn "no attempt store at $STORE (absent, not empty)" + [ "$JSON_OUT" = 1 ] && echo '[]' + return 0 + fi for f in "$STORE"/pd-*/receipt.json; do [ -f "$f" ] || continue id="$(jq -r .id "$f")" @@ -1999,11 +2005,12 @@ cmd_list() { claim: (.claim.state // \"claim-free\"), classes: \$classes, parent_exited: (.handle.parent_exited_at != null), location: .place.location, host: .host, age_seconds: ((\$now | fromdateiso8601) - (.created_at | fromdateiso8601)), + idle_seconds: ((\$now | fromdateiso8601) - (.transitions | map(.at | fromdateiso8601) | max)), rev }" "$f" done | if [ "$unresolved" = 1 ]; then jq -c 'select(.classes != [])'; else cat; fi | if [ "$JSON_OUT" = 1 ]; then jq -s -c .; else - jq -r '"\(.id)\t\(.task)\t\(.kind)\t\(.observation)\t\(.verdict)\tresult:\(.result)\t\(if .projected then "projected" else "-" end)\t\(if .ownership_risk then "OWNERSHIP-RISK" else "-" end)\tclaim:\(.claim)\t\(.classes | join(",") | if . == "" then "resolved" else . end)\tage \(.age_seconds)s"' + jq -r '"\(.id)\t\(.task)\t\(.kind)\t\(.observation)\t\(.verdict)\tresult:\(.result)\t\(if .projected then "projected" else "-" end)\t\(if .ownership_risk then "OWNERSHIP-RISK" else "-" end)\tclaim:\(.claim)\t\(.classes | join(",") | if . == "" then "resolved" else . end)\tage \(.age_seconds)s\tidle \(.idle_seconds)s"' fi } diff --git a/plugins/passdown/skills/passdown-handoff/SKILL.md b/plugins/passdown/skills/passdown-handoff/SKILL.md index 76c5519..2ce61f8 100644 --- a/plugins/passdown/skills/passdown-handoff/SKILL.md +++ b/plugins/passdown/skills/passdown-handoff/SKILL.md @@ -47,8 +47,9 @@ suggest adding it to the appropriate `AGENTS.md`. branch: agent: plan: - open_attempts: # only when delegated attempts are unresolved + open_attempts: # only when delegated attempts are not known to be resolved - pd-20260921T101530Z-3f9a1c2e + - pd-20260920T090000Z-1a2b3c4d # receipt missing --- ``` @@ -62,14 +63,24 @@ suggest adding it to the appropriate `AGENTS.md`. - repo-a/openspec/changes/slug-0009-short-name/ ``` - `open_attempts` lists every attempt that is unresolved when the log is - written: run `passdown-attempt --json list --unresolved` (the helper in - this skill's `scripts/` directory, with `--store ` when that - key is set) in each repository that holds a plan this session touched, - and list every ID it prints. Omit the key when there are none. The next - shift uses it to tell a known open attempt from an orphaned one. If `jq` - is missing, write "attempt store not readable: jq missing" in Caveats / - traps instead of guessing. + `open_attempts` lists every attempt that is not known to be resolved when + the log is written. It combines two sources: + - every ID that `passdown-attempt --json list --unresolved` prints (the + helper in this skill's `scripts/` directory, with `--store + ` when that key is set) in each repository that holds a + plan this session touched; + - every ID in the `open_attempts` of the previous handoff (the newest + earlier log in ``), unless its receipt exists and `list` shows + it with no recovery class. An ID whose receipt is missing — its store is + absent (the helper warns "absent, not empty") or was deleted — stays + listed, marked `# receipt missing`, until the owner confirms that no + worker for it remains; record that confirmation in Caveats / traps when + you drop it. + Omit the key only when both sources are empty. An absent store is never + evidence that nothing is open. The next shift uses this list to tell a + known open attempt from an orphaned one. If `jq` is missing, carry the + previous handoff's IDs forward unchanged and write "attempt store not + readable: jq missing" in Caveats / traps instead of guessing. The `agent` value must match the `` field in the filename. Body sections: diff --git a/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt b/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt index 0043129..d666455 100755 --- a/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt +++ b/plugins/passdown/skills/passdown-handoff/scripts/passdown-attempt @@ -1980,7 +1980,13 @@ cmd_list() { esac done resolve_store - [ -d "$STORE" ] || { [ "$JSON_OUT" = 1 ] && echo '[]'; return 0; } + if [ ! -d "$STORE" ]; then + # An absent store is not a verified empty one: a handoff must not drop + # attempts it knew about because their store is gone. + warn "no attempt store at $STORE (absent, not empty)" + [ "$JSON_OUT" = 1 ] && echo '[]' + return 0 + fi for f in "$STORE"/pd-*/receipt.json; do [ -f "$f" ] || continue id="$(jq -r .id "$f")" @@ -1999,11 +2005,12 @@ cmd_list() { claim: (.claim.state // \"claim-free\"), classes: \$classes, parent_exited: (.handle.parent_exited_at != null), location: .place.location, host: .host, age_seconds: ((\$now | fromdateiso8601) - (.created_at | fromdateiso8601)), + idle_seconds: ((\$now | fromdateiso8601) - (.transitions | map(.at | fromdateiso8601) | max)), rev }" "$f" done | if [ "$unresolved" = 1 ]; then jq -c 'select(.classes != [])'; else cat; fi | if [ "$JSON_OUT" = 1 ]; then jq -s -c .; else - jq -r '"\(.id)\t\(.task)\t\(.kind)\t\(.observation)\t\(.verdict)\tresult:\(.result)\t\(if .projected then "projected" else "-" end)\t\(if .ownership_risk then "OWNERSHIP-RISK" else "-" end)\tclaim:\(.claim)\t\(.classes | join(",") | if . == "" then "resolved" else . end)\tage \(.age_seconds)s"' + jq -r '"\(.id)\t\(.task)\t\(.kind)\t\(.observation)\t\(.verdict)\tresult:\(.result)\t\(if .projected then "projected" else "-" end)\t\(if .ownership_risk then "OWNERSHIP-RISK" else "-" end)\tclaim:\(.claim)\t\(.classes | join(",") | if . == "" then "resolved" else . end)\tage \(.age_seconds)s\tidle \(.idle_seconds)s"' fi } diff --git a/plugins/passdown/skills/passdown-pickup/SKILL.md b/plugins/passdown/skills/passdown-pickup/SKILL.md index a321709..9f74142 100644 --- a/plugins/passdown/skills/passdown-pickup/SKILL.md +++ b/plugins/passdown/skills/passdown-pickup/SKILL.md @@ -61,7 +61,10 @@ unconfirmed; do not guess. 3. **Read the attempt store.** For each repository that holds a plan in `plan:` (and the current repository), run `passdown-attempt --json list --unresolved`. It lists every attempt that is unresolved or still holds a - claim (C11), each with its recovery classes from the table below. For C2, C3 and C8 attempts, run + claim (C11), each with its recovery classes from the table below. If it + warns that there is no attempt store ("absent, not empty"), the store is + missing, not verified empty: say so, and treat every `open_attempts` ID + from the handoff as missing from the store. For C2, C3 and C8 attempts, run `passdown-attempt probe ` to see whether the worker is still alive. Then run `passdown-attempt --json claims --repo ` for the current repository and for every target repository an unresolved attempt names @@ -105,7 +108,7 @@ it is resolved. Render a rejection by its reason (**needs input**, | C6 verdict, no projection | `accepted`, not projected | No | Re-check the task digest, the checks and the artifact digest; then finish the projection. | | C7 stale | the task changed since `new` | Per observation | Resolve ownership risk first, then reject `stale`; new work is a new attempt. | | C8 cancel unconfirmed | cancel requested, `running`/`unknown` | **Yes** | `probe`; escalate the cancel per the card; stop only with safe evidence. | -| C9 orphaned | another session's attempt, older than its dispatch budget (use one hour when none is recorded), not named in the latest handoff's `open_attempts` | Per observation | Surface it prominently with age and location, then classify it as C1–C8. | +| C9 orphaned | another session's attempt whose receipt has not changed for longer than its dispatch budget (`idle_seconds` in `list`, measured from its last transition; use one hour when no budget is recorded), not named in the latest handoff's `open_attempts` | Per observation | Surface it prominently with age and location, then classify it as C1–C8. | | C10 interrupted continuation | unresolved, with `continues` set | Per observation | Show the chain; resolve its newest link as C1–C8. Nothing is re-asked. | | C11 claim without a live holder | a claim held by a stopped attempt rejected with a claim-holding reason, or by an attempt whose store is unreadable | No, or **unknown** if the store is unreadable | Continue, salvage or `release-claim`; an unreadable store is released only after the owner confirms no writer. | diff --git a/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt b/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt index 0043129..d666455 100755 --- a/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt +++ b/plugins/passdown/skills/passdown-pickup/scripts/passdown-attempt @@ -1980,7 +1980,13 @@ cmd_list() { esac done resolve_store - [ -d "$STORE" ] || { [ "$JSON_OUT" = 1 ] && echo '[]'; return 0; } + if [ ! -d "$STORE" ]; then + # An absent store is not a verified empty one: a handoff must not drop + # attempts it knew about because their store is gone. + warn "no attempt store at $STORE (absent, not empty)" + [ "$JSON_OUT" = 1 ] && echo '[]' + return 0 + fi for f in "$STORE"/pd-*/receipt.json; do [ -f "$f" ] || continue id="$(jq -r .id "$f")" @@ -1999,11 +2005,12 @@ cmd_list() { claim: (.claim.state // \"claim-free\"), classes: \$classes, parent_exited: (.handle.parent_exited_at != null), location: .place.location, host: .host, age_seconds: ((\$now | fromdateiso8601) - (.created_at | fromdateiso8601)), + idle_seconds: ((\$now | fromdateiso8601) - (.transitions | map(.at | fromdateiso8601) | max)), rev }" "$f" done | if [ "$unresolved" = 1 ]; then jq -c 'select(.classes != [])'; else cat; fi | if [ "$JSON_OUT" = 1 ]; then jq -s -c .; else - jq -r '"\(.id)\t\(.task)\t\(.kind)\t\(.observation)\t\(.verdict)\tresult:\(.result)\t\(if .projected then "projected" else "-" end)\t\(if .ownership_risk then "OWNERSHIP-RISK" else "-" end)\tclaim:\(.claim)\t\(.classes | join(",") | if . == "" then "resolved" else . end)\tage \(.age_seconds)s"' + jq -r '"\(.id)\t\(.task)\t\(.kind)\t\(.observation)\t\(.verdict)\tresult:\(.result)\t\(if .projected then "projected" else "-" end)\t\(if .ownership_risk then "OWNERSHIP-RISK" else "-" end)\tclaim:\(.claim)\t\(.classes | join(",") | if . == "" then "resolved" else . end)\tage \(.age_seconds)s\tidle \(.idle_seconds)s"' fi } diff --git a/scripts/passdown-attempt b/scripts/passdown-attempt index 0043129..d666455 100755 --- a/scripts/passdown-attempt +++ b/scripts/passdown-attempt @@ -1980,7 +1980,13 @@ cmd_list() { esac done resolve_store - [ -d "$STORE" ] || { [ "$JSON_OUT" = 1 ] && echo '[]'; return 0; } + if [ ! -d "$STORE" ]; then + # An absent store is not a verified empty one: a handoff must not drop + # attempts it knew about because their store is gone. + warn "no attempt store at $STORE (absent, not empty)" + [ "$JSON_OUT" = 1 ] && echo '[]' + return 0 + fi for f in "$STORE"/pd-*/receipt.json; do [ -f "$f" ] || continue id="$(jq -r .id "$f")" @@ -1999,11 +2005,12 @@ cmd_list() { claim: (.claim.state // \"claim-free\"), classes: \$classes, parent_exited: (.handle.parent_exited_at != null), location: .place.location, host: .host, age_seconds: ((\$now | fromdateiso8601) - (.created_at | fromdateiso8601)), + idle_seconds: ((\$now | fromdateiso8601) - (.transitions | map(.at | fromdateiso8601) | max)), rev }" "$f" done | if [ "$unresolved" = 1 ]; then jq -c 'select(.classes != [])'; else cat; fi | if [ "$JSON_OUT" = 1 ]; then jq -s -c .; else - jq -r '"\(.id)\t\(.task)\t\(.kind)\t\(.observation)\t\(.verdict)\tresult:\(.result)\t\(if .projected then "projected" else "-" end)\t\(if .ownership_risk then "OWNERSHIP-RISK" else "-" end)\tclaim:\(.claim)\t\(.classes | join(",") | if . == "" then "resolved" else . end)\tage \(.age_seconds)s"' + jq -r '"\(.id)\t\(.task)\t\(.kind)\t\(.observation)\t\(.verdict)\tresult:\(.result)\t\(if .projected then "projected" else "-" end)\t\(if .ownership_risk then "OWNERSHIP-RISK" else "-" end)\tclaim:\(.claim)\t\(.classes | join(",") | if . == "" then "resolved" else . end)\tage \(.age_seconds)s\tidle \(.idle_seconds)s"' fi } diff --git a/tests/attempt.sh b/tests/attempt.sh index 9ec8f7a..2155d77 100755 --- a/tests/attempt.sh +++ b/tests/attempt.sh @@ -831,6 +831,23 @@ group_list() { [ "$(jq -r '.[0].verdict' <<<"$out")" = accepted ] || fail "json list" pass "list classifies C1, C2, C3, C8, C5, C4, C7 and C6" + # Inactivity is measured from the last transition, not from creation (C9). + local idle="$scratch/list-idle" + new_repo "$idle" + store="$idle/.git/passdown/attempts" + new_write "$idle" + b="$id" + sleep 2 + ok "arm later" --store "$store" arm "$b" --rev "$(rev "$b")" --prompt "$prompt_file" + ok "json list" --store "$store" --json list --unresolved + [ "$(jq -r --arg b "$b" '.[] | select(.id == $b) | (.age_seconds >= 2 and .idle_seconds <= 1)' <<<"$out")" = true ] || + fail "idle_seconds does not follow the last transition: $out" + # An absent store is reported, not mistaken for a verified empty one. + ok "list without a store" --store "$scratch/list-no-store/attempts" --json list --unresolved + [ "$out" = "[]" ] && grep -q "absent, not empty" <<<"$err" || fail "absent store not reported: $err" + store="$repo/.git/passdown/attempts" + pass "list measures inactivity from the last transition and reports an absent store" + # validate catches a projection that disagrees with the claim file. b="$a" jq '.claim.state = "held"' "$store/$b/receipt.json" >"$scratch/r.json" diff --git a/tests/skills.sh b/tests/skills.sh index 35681e4..41b324d 100755 --- a/tests/skills.sh +++ b/tests/skills.sh @@ -267,6 +267,14 @@ require_text_block "$handoff" "never tick a task that has an unresolved attempt" "handoff never ticks a task with an unresolved attempt" require_text_block "$handoff" "Handoff never resolves an attempt" \ "handoff never resolves an attempt" +require_text_block "$handoff" "stays +listed, marked +\`# receipt missing\`" \ + "handoff carries forward an open attempt whose receipt is missing" +require_text_block "$handoff" "An absent store is never +evidence that nothing is open" \ + "handoff never reads an absent store as empty" +require_text_block "$pickup" "measured from its last transition" \ + "pickup measures C9 inactivity from the last receipt transition" +require_text_block "$pickup" "absent, not empty" \ + "pickup distinguishes an absent store from an empty one" schema="$repo_root/schemas/passdown/schema.yaml" apply_instruction="$(sed -n '/^apply:/,$p' "$schema")" From 32b0ff64f1ef7657edcfa2705af969f74f2c1491 Mon Sep 17 00:00:00 2001 From: Vu Nguyen Date: Fri, 25 Sep 2026 11:15:22 +0700 Subject: [PATCH 4/4] test: cover handoff carry-forward and C9 inactivity in the harness - ref-host gains `handoff`, the reference for the open_attempts rule, and measures C9 by idle_seconds. Its pickup and handoff no longer create the store. - HANDOFF: with a store, a live attempt is listed, a receipt-less ID is carried forward and a resolved one dropped. With no store at all, pickup reports the missing attempt and sees the absent store, handoff keeps the ID, and neither creates the store. - F16b: an attempt created long ago but updated recently by its own session is not orphaned; once idle past the budget it is C9. Both scenarios fail against the old rules (age-based C9; dropping receipt-less IDs). Co-Authored-By: Claude Opus 5.5 --- tests/harness/ref-host | 37 ++++++++++++++++++++++++--- tests/interrupt.sh | 58 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/tests/harness/ref-host b/tests/harness/ref-host index f5baf5f..2739e49 100755 --- a/tests/harness/ref-host +++ b/tests/harness/ref-host @@ -14,6 +14,8 @@ # ref-host finish --id # resume the lifecycle after a crash (C3/C4/C5/C6) # ref-host pickup [--session ] [--handoff ] [--budget ] # (: a handoff log; its frontmatter open_attempts are known) +# ref-host handoff --previous-log --out # writes open_attempts only +# pickup and handoff are read-only toward the store and never create it. # # Environment: # REF_REPO target repository = planner repository (default: cwd) @@ -269,7 +271,7 @@ new_attempt() { # args for `new` -> sets $id; a refusal (exit 6) is reported, ne cmd="${1:?command}" shift mode="" task=1.1 kind=write card=fake@1 guard="" isolation=current-checkout location="" prev="" answer="" -profile="" worktree_dir="" id="" handoff="" budget=600 pickup_session="$session" +profile="" worktree_dir="" id="" handoff="" budget=600 pickup_session="$session" previous_log="" out_log="" while [ "$#" -gt 0 ]; do case "$1" in --mode) mode="$2"; shift 2 ;; @@ -287,10 +289,12 @@ while [ "$#" -gt 0 ]; do --session) pickup_session="$2"; shift 2 ;; --handoff) handoff="$2"; shift 2 ;; --budget) budget="$2"; shift 2 ;; + --previous-log) previous_log="$2"; shift 2 ;; + --out) out_log="$2"; shift 2 ;; *) echo "ref-host: unknown option $1" >&2; exit 2 ;; esac done -mkdir -p "$store" +case "$cmd" in pickup | handoff) ;; *) mkdir -p "$store" ;; esac case "$cmd" in dispatch) @@ -393,6 +397,33 @@ case "$cmd" in fi decide "$id" ;; + handoff) + # The open_attempts rule of passdown-handoff (S5): what the store lists + # as unresolved, plus every ID the previous handoff named unless its + # receipt now shows it resolved. A missing receipt is carried forward. + listed="$(H --json list --unresolved 2>/dev/null | jq -r '.[].id')" + all="$(H --json list 2>/dev/null || echo '[]')" + carried="" + for o in $(open_attempts_of "$previous_log" | jq -r '.[]'); do + printf '%s\n' "$listed" | grep -qx "$o" && continue + if [ -f "$store/$o/receipt.json" ]; then + [ "$(jq -r --arg o "$o" '.[] | select(.id == $o) | .classes | length' <<<"$all")" = 0 ] && continue + carried="$carried$o +" + else + carried="$carried$o # receipt missing +" + fi + done + { + printf -- '---\nstatus: IN_PROGRESS\nbranch: main\nagent: ref-host\nplan: %s\n' "$plan_rel" + if [ -n "$listed$carried" ]; then + echo "open_attempts:" + { printf '%s\n' "$listed"; printf '%s' "$carried"; } | grep . | sed 's/^/ - /' + fi + printf -- '---\n' + } >"${out_log:?--out}" + ;; pickup) # Read-only briefing (section 13): classes from the helper, C9 from the # session/handoff rule, and the plan cross-check. @@ -400,7 +431,7 @@ case "$cmd" in --argjson open "$(open_attempts_of "$handoff")" ' .[] | .classes as $c | .id as $id - | (if .host.session != $s and .age_seconds > $budget and ($open | index($id) | not) then ["C9"] else [] end) as $orphan + | (if .host.session != $s and .idle_seconds > $budget and ($open | index($id) | not) then ["C9"] else [] end) as $orphan | "class \(.id) \(($orphan + $c) | join(","))\(if .ownership_risk then " ownership-risk" else "" end)"' # Every attempt the handoff named must still be in the store. open_attempts_of "$handoff" | jq -r '.[]' | while IFS= read -r o; do diff --git a/tests/interrupt.sh b/tests/interrupt.sh index 2f4e177..a58c94b 100755 --- a/tests/interrupt.sh +++ b/tests/interrupt.sh @@ -478,6 +478,24 @@ F16() { pass "an old attempt from another session, not in the handoff, is surfaced C9 with ownership risk" } +F16b() { + local a + setup F16b + # Created long ago by another session, but that session touched it just now: + # not orphaned. C9 follows the last transition, not the creation time. + REF_SESSION=other-session REF_CRASH_AT=after-arm host dispatch --mode ok + a="$(attempt_of)" + sleep 3 + H observe "$a" unknown --rev "$(rev "$a")" --note "re-probed by its own session" >/dev/null + "$harness/ref-host" pickup --session current-session --budget 2 >"$scratch/F16b/pickup.out" + grep -q "^class $a C2 ownership-risk" "$scratch/F16b/pickup.out" || fail "recently updated attempt called orphaned: $(cat "$scratch/F16b/pickup.out")" + sleep 3 + "$harness/ref-host" pickup --session current-session --budget 2 >"$scratch/F16b/pickup2.out" + grep -q "^class $a C9,C2 ownership-risk" "$scratch/F16b/pickup2.out" || fail "idle attempt not C9: $(cat "$scratch/F16b/pickup2.out")" + oracle + pass "C9 follows the last receipt update: an old attempt updated recently is not orphaned" +} + F17() { local o c setup F17 @@ -798,6 +816,44 @@ PICKUP() { pass "pickup uses the skills' class names, reports a missing handoff attempt, and writes nothing" } +HANDOFF() { + local a b gone="pd-20260101T000000Z-00000000" prev new + setup HANDOFF + prev="$scratch/HANDOFF/prev.md" + new="$scratch/HANDOFF/new.md" + # A store: a resolved attempt, a live one, and one whose receipt is gone. + host dispatch --mode ok --task 1.1 + a="$(attempt_of)" + [ "$(outcome_of)" = accepted ] || fail "fixture: $(outcome_of)" + REF_CRASH_AT=after-first-write host dispatch --mode write-then-sleep --task 1.2 + b="$(attempt_of)" + printf -- '---\nstatus: IN_PROGRESS\nbranch: main\nagent: claude\nplan: docs/plan.md\nopen_attempts:\n - %s\n - %s\n---\n' "$a" "$gone" >"$prev" + "$harness/ref-host" handoff --previous-log "$prev" --out "$new" + grep -q "^ - $b$" "$new" || fail "the live attempt is not listed: $(cat "$new")" + grep -q "^ - $gone # receipt missing$" "$new" || fail "an attempt with a missing receipt was dropped: $(cat "$new")" + grep -q "$a" "$new" && fail "a resolved attempt was carried forward" + reap "$b" + # B overwrote A's accepted file; restore it so the oracle compares A's + # verdict with A's bytes. + printf 'hello\n' >"$repo/src/hello.txt" + ground_truth_accepted="1.1" + oracle + # No store at all: pickup and then handoff keep the handoff's attempts, + # report them, and do not create the store. + setup HANDOFF-nostore + scenario=HANDOFF + prev="$scratch/HANDOFF-nostore/prev.md" + new="$scratch/HANDOFF-nostore/new.md" + printf -- '---\nstatus: IN_PROGRESS\nbranch: main\nagent: claude\nplan: docs/plan.md\nopen_attempts:\n - %s\n---\n' "$gone" >"$prev" + "$harness/ref-host" pickup --handoff "$prev" >"$scratch/HANDOFF-nostore/pickup.out" 2>"$scratch/HANDOFF-nostore/pickup.err" + grep -q "^inconsistent $gone is named in the handoff" "$scratch/HANDOFF-nostore/pickup.out" || fail "pickup did not report the missing attempt" + grep -q "absent, not empty" "$scratch/HANDOFF-nostore/pickup.err" || fail "pickup did not see that the store is absent" + "$harness/ref-host" handoff --previous-log "$prev" --out "$new" 2>/dev/null + grep -q "^ - $gone # receipt missing$" "$new" || fail "handoff dropped an attempt because its store is absent: $(cat "$new")" + [ ! -e "$store" ] || fail "pickup or handoff created the store" + pass "handoff carries forward unresolved and receipt-less attempts, drops resolved ones, and never reads an absent store as empty" +} + # ------------------------------------------------------ skill correspondence # skill_steps: " required|optional" for each numbered step of @@ -893,7 +949,7 @@ mutation() { pass "with the claim check stubbed out, F11, F15, F19b and F22 all fail (the suite detects the missing guard)" } -all="F1 F2 F2b F2c F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 F13 F14 F14c F14d F15 F16 F17 F18 F18b F19 F19b F20 F21 F22 F23 F24 F25 F26 STEPS PICKUP mutation" +all="F1 F2 F2b F2c F3 F4 F5 F6 F7 F8 F9 F10 F11 F12 F13 F14 F14c F14d F15 F16 F16b F17 F18 F18b F19 F19b F20 F21 F22 F23 F24 F25 F26 STEPS PICKUP HANDOFF mutation" [ "$#" -gt 0 ] || read -r -a all_list <<<"$all" [ "$#" -gt 0 ] || set -- "${all_list[@]}" for s in "$@"; do