@henryqw/pi-subagent 27.2.1 → 27.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTEXT.md CHANGED
@@ -1,95 +1,63 @@
1
- # Pi Subagent Context
2
-
3
- ## Purpose
4
-
5
- Own Role-configured Pi delegation through one `delegate_task` surface:
6
-
7
- - **direct mode** runs authorized compact shared-checkout work in Herdr tabs in Main's workspace and returns a handle before completion;
8
- - **isolated mode** runs durable checked graphs with Herdr worktrees, exact Git identities, guarded integration, and recovery; and
9
- - the package root exposes the Role, executor, worktree, evidence, schema, state, and runtime mechanisms used by the extension.
10
-
11
- ## Domain glossary
12
-
13
- - **Main**: the Pi session and checkout coordinating delegated work.
14
- - **Role**: package or user Markdown defining responsibility, tools, trusted extension sources, Skills, optional MCP names, instructions, and an optional model-class default. Roles do not select isolation.
15
- - **Model Class**: `fast`, `balanced`, `frontier`, or `fav`, resolved through `pi-task-models`.
16
- - **Direct task**: one bounded task using its Role's declared resources in Main's shared checkout, with a declared `access` of `read` or `write`; authorized writes take effect immediately without checked integration.
17
- - **Direct reference**: `D<workflow>.<worker>` display reference for one recorded direct worker on the session branch, such as `D7.1`. It identifies a worker for display and `continueFrom`; it never grants ownership.
18
- - **Continued worker**: a new direct worker, with a new reference and tab, that resumes a finished worker's saved Pi session and private lease through `continueFrom`.
19
- - **Isolated request**: one durable ID, goal, and checked task graph.
20
- - **Candidate**: exact clean committed task-worktree identity produced by an isolated changeset worker.
21
- - **Readiness**: durable proof that one exact live-worker candidate passed its preliminary checks and is sealed for Main's explicit selection; it is not a separate user approval.
22
- - **Integration generation**: Main's owned, ordered assembly of selected candidates in a separate checkout; each replaced assembly remains an inspectable superseded generation.
23
- - **Release**: Main's explicit, exact-identity cleanup of a rejected worker or superseded generation after proving it is no longer selected; it never discards dirty or conflicted work.
24
- - **Productive lease**: repository-scoped process lease that excludes concurrent execute/resume while permitting status and abort.
25
- - **Safety budget**: finite timeout for child work, process I/O, status, termination, or cleanup. It is not a whole-run deadline.
26
-
27
- ## Invariants
28
-
29
- ### Unified surface and modes
30
-
31
- - Every `delegate_task` request declares `mode: "direct"` or `mode: "isolated"`. No path silently falls back between modes.
32
- - Direct mode selects exactly one single, parallel, or chain shape. Unknown properties and mixed shapes fail before launch.
33
- - Isolated mode accepts 1–8 typed tasks. `dependsOn` controls waves. `contextFrom` may name completed text tasks only and preserves declared order.
34
- - Roles describe capability, not checkout placement. Role-level worktree isolation is rejected.
35
-
36
- ### Global limits
37
-
38
- - `config/pi-subagent/config.json` is the only user execution-policy source. It owns `maxSubagents`, `maxTurns`, optional `maxTokens`, `maxCorrections`, and child idle/hard timeout.
39
- - The config loads through `@henryqw/pi-config-store` fields. An unknown key or an invalid bounded limit, timeout, or `roleDefaults` key warns at launch and uses its finite default. An invalid `maxTokens`, or a file that cannot be read or parsed, disables delegation with an error, because the token default is unlimited. Role Markdown files keep per-file exclusion; durable state stays strict.
40
- - Request fields cannot override or replenish policy. Durable requests store the policy snapshot and cumulative correction count; current config may tighten the correction allowance.
41
- - Productive execution has no whole-run wall-clock deadline. Long productive work and later resume remain valid. Direct Herdr observation also has no whole-task elapsed deadline; worker idleness is bounded.
42
- - Child idle/hard runtime, turn/token handling, outer abort, process I/O, status inspection, termination, and cleanup remain independently bounded. Direct Herdr workers use idle limits, not ephemeral child hard runtime.
43
- - Ephemeral children share one FIFO executor pool. Queued time consumes no child timeout. Isolated Herdr Role launches receive the same non-refilling turn/token/runtime budget metadata.
44
-
45
- ### Direct evidence
46
-
47
- - Direct mode uses each Role's declared resources for explicitly authorized scope. Each task declares `access`; a request with any `write` task is a write request. `read` is Main's unenforced classification: read requests skip admission and return plain text; their parallel tasks run concurrently, chains stay ordered. Write requests serialize tasks and retain Pi checkout admission until exact owned worker termination is proved by pane absence and two empty private process-lease scans; uncertain termination retains admission and live recovery ownership. Write-request answers require an explicit structured `succeeded` outcome; failed, blocked, or malformed completion stops dispatch but does not prevent release after proved termination. `/subagent` Close/cancel-and-release retries termination under session, branch, epoch and exact ownership guards without replaying work or removing checkout/session evidence. This is a worker report, not proof of validation, cleanliness, rollback, or checked integration. Direct changesets require isolated mode.
48
- - Only direct write requests reserve checkout admission. They cannot start while another direct writer holds it or a Main potentially-writing call is in flight. Main calls, including Bash and nested `codemode` writes, do not block each other. A direct writer blocks Main's writes until verified worker termination; use isolated mode when Main must continue writing. Reads never wait.
49
- - Each direct worker has a recorded Herdr tab and Pi session identity. The first handle returns before completion; subsequent tab identities remain recoverable on the session branch.
50
- - `continueFrom` resolves to exactly one recorded tab with the same Role and a saved turn identity. It refuses before launch while a local task owns the session, when the session is missing, above 16 MiB, or its exact recorded turn is not terminal, or when a still-listed old agent is not the exact idle or done agent. Old-pane closure needs pane absence and two empty lease scans. The relaunch reuses the session file and private lease in a new tab and record.
51
- - `/subagent` **Stop this worker** exists only for running workers of locally owned all-read workflows. It aborts one entry, proves its termination, and records `Stopped by user from /subagent`; siblings continue and Main receives one result.
52
- - An exact settled worker with a bounded final Pi answer produces a follow-up to Main. Worker text reaching Main carries a no-authority header with imitated harness framing escaped; a failed task keeps a bounded partial excerpt, never shown as success. Blocked, unknown, idle-stalled, truncated, or ambiguous outcomes retain actionable recovery identity. Session replacement never delivers to the wrong Main session.
53
-
54
- ### Isolated lifecycle
55
-
56
- - Changeset tasks durably record allocation intent before each external side effect. Unknown outcomes are retained and never guessed.
57
- - One worker remains live across prompts, preliminary checks, follow-up/correction, readiness, Main's staging decisions, combined validation, and guarded promotion. Selected workers terminate after promotion; rejected workers terminate on explicit rejection.
58
- - Optional same-worker follow-ups are admitted only while a changeset is actively working. After the final checked-evidence save, one synchronous queue-or-seal transition prevents admitted revisions from being lost; the runner never waits for routine input.
59
- - Worker waves pin Main's committed branch/HEAD/tree, excluding staged, unstaged, and untracked changes; dirty Main permits checked candidate readiness, but branch/HEAD drift blocks dispatch and clean Main is required before staging, combined validation, or promotion.
60
- - Preliminary checks gate readiness. Any changed candidate invalidates previous readiness and validation.
61
- - Optional task judgment binds to the exact preliminary candidate. Main selects and orders candidates in a separate owned integration checkout, where final full-suite checks and optional final judgment bind to the clean combined tip before promotion.
62
- - Promotion requires exact unchanged clean Main and a passing combined generation. Durable promotion evidence precedes selected-worker termination and cleanup; recovery never rolls back a proven promotion.
63
- - Rejected workers and superseded generations stay retained until Main explicitly releases their exact clean, proved-owned resources. At most two unreleased integration checkouts coexist.
64
- - Failures, ambiguity, interruption, review findings, conflicts, drift, unproved termination, or cleanup failure enter `needs_attention` and preserve evidence.
65
- - `subagent_status` is read-only, callable from `codemode` scripts, and returns the bounded public projection as structured content, never raw run state. `delegate_task`, `subagent_resume`, `subagent_stage`, `subagent_integrate`, and `subagent_abort` are model-only. `subagent_resume` accepts only strict `retry`, `verify`, or `finalize` continuations; only a correcting `retry` takes Main's optional instruction. `subagent_abort` terminates only exact owned workers.
66
- - The extension never pushes, opens a pull request, publishes, deploys, stashes, resets, force-cleans, or deletes unproved recoverable work.
67
-
68
- ### Resource and launch policy
69
-
70
- - Ambient child extensions and Skills stay disabled. Only Role/caller resources plus required internal adapters load.
71
- - Every Role requires `tools`, `extensions`, and `skills` arrays; unknown frontmatter keys are rejected per file, and an invalid file blocks only the name it claims. `roleDefaults` in config merge into every Role first, deduplicated. `extensions` may name Pi built-in extensions as `builtin:<name>`. A Role activates `codemode` only by naming it in `tools` with `builtin:codemode` loaded; MCP configuration never activates it, and Pi's codemode settings apply unchanged. `mcps` names servers from the global `~/.pi/agent/mcp.json`; their tools keep their configured exposure in a codemode Role and are forced to direct exposure otherwise. Omitted or empty `mcps` denies MCP access. Direct `pi-mcp-adapter` loading is rejected because it bypasses the allowlist.
72
- - The Role tool policy declares the Role's `tools` and the extension tools Pi would declare on registration; extension tools with `codemode` or `deferred` exposure stay callable from scripts without being declared.
73
- - Selected extensions are trusted executable bundles, not a sandbox. All tools and lifecycle behavior they register load together.
74
- - Role Skill names resolve through Main's effective Pi registry. Missing Roles, Skills, tools, MCP servers, routes, models, or thinking levels fail before the first productive turn.
75
- - Route precedence is call model class, then Role default, then registered Model Task assignment/default. A direct model replaces only the route model and must support its thinking level.
76
- - Main-only delegation/recovery tools and `ask_question` are excluded from children. Recursive delegation is unavailable.
77
- - Package built-ins are `implementer`, `reviewer`, and `scout`; a same-named user Role overrides a built-in.
78
- - `git_read` is an internal read-only child tool loaded only for Roles declaring it; the built-in reviewer opts in. Fixed structured Git operations provide bounded local evidence without Bash, network, external filters, or mutations. Named-ref context supplements but never replaces the authoritative exact isolated review patch.
79
-
80
- ### Executor boundaries
81
-
82
- - A terminal response at the turn boundary succeeds; attempted continuation fails with typed `turn_limit` and bounded retained output.
83
- - Token accounting sums completed assistant responses once. A terminal crossing succeeds. A continuing crossing completes tools and receives one response-only handoff; further continuation fails.
84
- - Recognized Pi JSON events renew idle timeout; raw bytes do not. Child maximum runtime always terminates ephemeral children.
85
- - Output, stderr, protocol events, callback draining, and inherited descendant streams are bounded.
86
- - Low-level worktree finalization returns explicit `pruned`, `retained`, or `recovery` evidence and never force-deletes uncertain work.
87
- - Exact review helpers produce bounded private evidence; callers must preserve identity guards and verdict validation.
88
-
89
- ## Owned storage
90
-
91
- - User config and Role Markdown: `<agent-dir>/config/pi-subagent/`
92
- - Durable state: `<agent-dir>/config/pi-subagent/state/<repository-hash>/`
93
- - Shared model routes: `<agent-dir>/config/pi-task-models/config.json`
94
-
95
- State version 5 is strict. Older or malformed files are rejected rather than rewritten or migrated silently.
1
+ # Pi Subagent
2
+
3
+ Pi Subagent delegates bounded work to Role-configured Pi workers. Direct tasks share Main's checkout; isolated requests produce checked candidates for explicit integration.
4
+
5
+ ## Language
6
+
7
+ ### Delegation
8
+
9
+ **Main**:
10
+ The Pi session and checkout that coordinate delegated work.
11
+
12
+ **Role**:
13
+ A named capability definition with instructions, tools, trusted extension sources, Skills, optional MCP servers, and an optional Model Class default. A Role does not select isolation.
14
+
15
+ **Model Class**:
16
+ A `fast`, `balanced`, `frontier`, or `fav` choice resolved through Pi Task Models.
17
+
18
+ **Direct task**:
19
+ A bounded task in Main's shared checkout with declared `read` or `write` access. Authorized writes take effect immediately, without rollback or checked integration.
20
+
21
+ **Direct reference**:
22
+ A display and continuation reference for one recorded direct worker, such as `D7.1`. It does not grant ownership of that worker.
23
+
24
+ **Continued worker**:
25
+ A new direct worker that resumes a finished worker's saved Pi conversation in a new tab with a new Direct reference.
26
+
27
+ **Checkout admission**:
28
+ The exclusive permission held by a direct write request that prevents Main and other direct writers from writing to the shared checkout. It remains held until worker termination is proven; read classification is not a sandbox.
29
+
30
+ **Isolated request**:
31
+ A durable goal and dependency graph of text and changeset tasks, with checked results and recovery state.
32
+
33
+ **Text task**:
34
+ An isolated-graph task whose result is bounded text rather than a changeset. Completed text can supply context to later tasks.
35
+
36
+ **Changeset task**:
37
+ An isolated-graph task that produces a committed change in its own worker worktree.
38
+
39
+ ### Checked integration
40
+
41
+ **Candidate**:
42
+ The exact clean committed identity of a changeset worker's proposed change.
43
+
44
+ **Readiness**:
45
+ Saved proof that an exact candidate passed preliminary checks and is sealed for Main's explicit selection. It is not a separate user approval.
46
+
47
+ **Integration generation**:
48
+ Main's ordered assembly of selected candidates in a separate checkout. A replaced assembly remains available as a superseded generation.
49
+
50
+ **Judgment**:
51
+ A review decision bound to an exact candidate or combined integration generation, separate from automated validation.
52
+
53
+ **Promotion**:
54
+ The guarded transfer of a validated integration generation into unchanged, clean Main. Readiness alone does not permit promotion.
55
+
56
+ **Release**:
57
+ Main's explicit cleanup of an exact rejected worker or superseded generation after proving that it is no longer selected. Release does not discard dirty, conflicted, or uncertain work.
58
+
59
+ **Productive lease**:
60
+ Repository-scoped ownership that excludes concurrent execution or resumption while permitting status inspection and abort.
61
+
62
+ **Safety budget**:
63
+ A finite limit for child execution or a safety operation, not a deadline for the whole isolated request.
package/README.md CHANGED
@@ -42,7 +42,7 @@ Commands are for you. Delegation and state tools and the packaged skill are for
42
42
  | `subagent_resume` | tool | Perform the reported `retry`, `verify`, or `finalize` continuation; does not replace staging or integration. A `retry` that sends a same-worker correction accepts an optional `instruction` (at most 32000 characters), added to that prompt as Main's instruction after the saved failure. Other actions reject it. |
43
43
  | `subagent_stage` | tool | Stage or resolve an exact candidate, or reject/revise it; rejection of a staged candidate freezes that generation. |
44
44
  | `subagent_integrate` | tool | Advance staged dependents, refresh after clean Main drift or evidence-less validation failure, validate in the background, record one correction, promote, reconcile interrupted promotion, clean up proven promotion, or explicitly release rejected/superseded resources. |
45
- | `subagent_abort` | tool | Abort an isolated request only when no retained candidates or integration worktrees remain; cannot discard them. |
45
+ | `subagent_abort` | tool | Abort an isolated request when no retained candidates or integration worktrees with work remain, or when their checkouts and branches are proved absent. Cannot discard them. |
46
46
  | `git_read` | tool | For Role-declared children (not Main): inspect bounded local Git evidence without Bash, tests, network access, or writes. |
47
47
  | `pi-subagent` | skill | Guide Main through delegation, authorization, checks, integration, and recovery. |
48
48
  | Direct status widget (`D`) | ui | For you in the TUI: show direct worker references (such as `D7.1`), status, Role badges, names, route, task elapsed time, measured task tokens, pending tools, and the latest tool's recorded time. |
@@ -204,9 +204,11 @@ One confirmation removes every listed removable item. When the command removes a
204
204
 
205
205
  If an isolated worker produced no candidate, `subagent_abort` terminates it. It removes only proven unchanged, clean owned resources. Cleanup includes ignored-file checks and never discards unique commits. The request stays aborted with retained-resource evidence in these cases: productive work did not settle yet, termination is uncertain, or cleanup fails. Inspect `subagent_status`. Resolve the reported blocker. Then run `subagent_abort` again to reconcile cleanup. This does not replay worker prompts. Dirty work, unique commits, and uncertain allocations stay for deliberate manual recovery.
206
206
 
207
+ If you removed the checkouts, branches, and Herdr workspaces of a request outside Pi Subagent, `subagent_abort` can close the request. For each retained candidate and each integration generation with stages, abort proves that the checkout is not registered, that its path does not exist, and that its branch does not exist. It proves worker absence with the same pane and lease checks. Then it records the candidates as rejected and released, and it completes the cleanup records. If one of these resources still exists, abort fails and does not change the request. In that case, release the resources first.
208
+
207
209
  The following details describe exact identity and retained-work requirements.
208
210
 
209
- A checked candidate stays retained if Main is dirty. Staging, combined validation, and promotion require Main to be clean at the pinned committed identity. Clean your local edits deliberately before you use those actions. The extension does not replay or merge dirty work automatically. To stage, pass the exact `generation`, candidate (`taskId`, `attempt`, `candidate`), and `expectedTip` identities that status reports. To integrate, pass the generation and the combined `expectedTip`. If Main advances cleanly before promotion, call `subagent_integrate refresh` with the recorded `expectedMain`, the old `expectedTip`, and the new clean `newMain`. Main must restage selected immutable candidates into the new generation and run combined checks again. Dirty or divergent Main blocks refresh. Stale identities fail. If integration allocation was uncertain, `subagent_stage resolve` first proves that the owned checkout is still at its base. Then it starts the selected merge. Otherwise, it retains the intent. Each request can keep at most two unreleased integration worktrees. A superseded generation is read-only. To release a superseded *clean* integration checkout, call `subagent_integrate` with `{ id, action: "release", generation, expectedTip }`. As `expectedTip`, use the last staged tip of that generation, or the integration base when no stage completed. A rejected candidate can be released after its worker terminates and all generations that use it are released. To release it, pass `{ id, action: "release", generation: <latest generation number, or 1>, taskId, attempt, expectedTip: <candidate tip> }`. Release persists each host, checkout, and branch step. It verifies ownership and cleanliness, including ignored files. It deletes the branch only if its ref still names the exact tip. Dirty, conflicted, changed, or uncertain resources stay for manual inspection. After you resolve the obstacle, do the same release again. Each released checkout frees a retained slot. Abort is available after every retained resource is released. Rejection can invalidate dependent work. If fresh dependent execution is not possible, start a new request. Do not force promotion. Productive requests have no whole-run wall-clock deadline. Resume does not reset the recorded policy or correction count. Text tasks have two dispatch attempts. Inspection failures before dispatch do not use an attempt. Verification requires passing preliminary checks and any required judgment. It cannot override a failed review. Child limits, abort signals, process I/O, status inspection, exact termination, and cleanup keep finite safety bounds.
211
+ A checked candidate stays retained if Main is dirty. Staging, combined validation, and promotion require Main to be clean at the pinned committed identity. Clean your local edits deliberately before you use those actions. The extension does not replay or merge dirty work automatically. To stage, pass the exact `generation`, candidate (`taskId`, `attempt`, `candidate`), and `expectedTip` identities that status reports. To integrate, pass the generation and the combined `expectedTip`. If Main advances cleanly before promotion, call `subagent_integrate refresh` with the recorded `expectedMain`, the old `expectedTip`, and the new clean `newMain`. Main must restage selected immutable candidates into the new generation and run combined checks again. Dirty or divergent Main blocks refresh. Stale identities fail. If integration allocation was uncertain, `subagent_stage resolve` first proves that the owned checkout is still at its base. Then it starts the selected merge. Otherwise, it retains the intent. Each request can keep at most two unreleased integration worktrees. A superseded generation is read-only. To release a superseded *clean* integration checkout, call `subagent_integrate` with `{ id, action: "release", generation, expectedTip }`. As `expectedTip`, use the recorded tip of that generation: the correction tip after `correct`, else the last staged tip, else the integration base. A superseded generation keeps its correction record for this purpose. You can release a rejected candidate after you release all generations that use it. This includes a dependent candidate that a refresh or a rejection made invalid. Release first proves that the exact worker stopped. If the worker is already gone, release records the proof of absence. If the idle worker still runs, release closes its exact pane and proves pane absence and two empty lease scans. Release never tries again to close a worker after an uncertain termination. To release the candidate, pass `{ id, action: "release", generation: <latest generation number, or 1>, taskId, attempt, expectedTip: <candidate tip> }`. Release persists each host, checkout, and branch step. It verifies ownership and cleanliness, including ignored files. It deletes the branch only if its ref still names the exact tip. Dirty, conflicted, changed, or uncertain resources stay for manual inspection. After you resolve the obstacle, do the same release again. Each released checkout frees a retained slot. Abort is available after every retained resource is released. Abort also removes an empty integration generation that a refresh created, if its checkout is clean at its base. Rejection can invalidate dependent work. A rejection does not fail because of dependent attempts that a refresh or an earlier rejection already made invalid. If fresh dependent execution is not possible, start a new request. Do not force promotion. Productive requests have no whole-run wall-clock deadline. Resume does not reset the recorded policy or correction count. Text tasks have two dispatch attempts. Inspection failures before dispatch do not use an attempt. Verification requires passing preliminary checks and any required judgment. It cannot override a failed review. Child limits, abort signals, process I/O, status inspection, exact termination, and cleanup keep finite safety bounds.
210
212
 
211
213
  The extension never pushes, opens a pull request, publishes, deploys, force-cleans recoverable work, or silently falls back to Main.
212
214
 
@@ -138,6 +138,8 @@ export declare class HerdrHostRuntime implements HostRuntime {
138
138
  private assertWorkspaceEvidence;
139
139
  private repositoryIdentity;
140
140
  private assertRepositoryIdentity;
141
+ /** Herdr and lsof queries are global, so a checkout removed outside Pi Subagent must not be their process cwd. */
142
+ private queryCwd;
141
143
  private processOptions;
142
144
  private childContext;
143
145
  private callTimeout;
@@ -548,7 +548,8 @@ export class HerdrHostRuntime {
548
548
  throw new Error("Worker termination identity does not match the exact saved agent.");
549
549
  this.assertLeasePath(allocation.leasePath, allocation.token);
550
550
  await assertPrivateLease(allocation.leasePath, false);
551
- const agents = (await this.listAgents(allocation.worktreeCwd, context)).map((agent) => ({
551
+ const cwd = await this.queryCwd(allocation.worktreeCwd);
552
+ const agents = (await this.listAgents(cwd, context)).map((agent) => ({
552
553
  name: typeof agent.name === "string" ? agent.name : undefined,
553
554
  paneId: exactString(agent.pane_id, "Herdr listed agent pane ID"),
554
555
  tabId: exactString(agent.tab_id, "Herdr listed agent tab ID"),
@@ -559,7 +560,7 @@ export class HerdrHostRuntime {
559
560
  || related[0].paneId !== allocation.paneId || related[0].tabId !== allocation.tabId) {
560
561
  throw new Error("Worker termination requires one exact current agent, tab, and pane match.");
561
562
  }
562
- const processInfo = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(allocation.worktreeCwd, context, HERDR_OPERATION_CAP_MS));
563
+ const processInfo = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(cwd, context, HERDR_OPERATION_CAP_MS));
563
564
  const processResult = resultRecord(processInfo, "Herdr pane process-info response");
564
565
  if (processResult.type !== "pane_process_info")
565
566
  throw new Error("Herdr pane process-info response has the wrong type.");
@@ -567,27 +568,27 @@ export class HerdrHostRuntime {
567
568
  if (captured.pane_id !== allocation.paneId) {
568
569
  throw new Error("Herdr pane process-info did not match the exact saved pane.");
569
570
  }
570
- const closed = await this.herdr.exec(["pane", "close", allocation.paneId], this.processOptions(allocation.worktreeCwd, context, HERDR_OPERATION_CAP_MS));
571
+ const closed = await this.herdr.exec(["pane", "close", allocation.paneId], this.processOptions(cwd, context, HERDR_OPERATION_CAP_MS));
571
572
  if (closed.code !== 0 || closed.killed)
572
573
  throw new Error(herdrCommandFailure(["pane", "close"], closed));
573
574
  requireOkResponse(closed.stdout, "Herdr pane close response");
574
- if (!await this.paneAbsent(allocation.paneId, allocation.worktreeCwd, context))
575
+ if (!await this.paneAbsent(allocation.paneId, cwd, context))
575
576
  throw new Error("The exact saved pane still exists after pane close.");
576
- let holders = await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context);
577
+ let holders = await this.scanLease(allocation.leasePath, cwd, context);
577
578
  if (holders.length) {
578
- await this.signalExactHolders(holders, allocation, "SIGTERM", context);
579
+ await this.signalExactHolders(holders, { ...allocation, worktreeCwd: cwd }, "SIGTERM", context);
579
580
  await this.delay(250, context.signal);
580
- holders = await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context);
581
+ holders = await this.scanLease(allocation.leasePath, cwd, context);
581
582
  }
582
583
  if (holders.length) {
583
- await this.signalExactHolders(holders, allocation, "SIGKILL", context);
584
+ await this.signalExactHolders(holders, { ...allocation, worktreeCwd: cwd }, "SIGKILL", context);
584
585
  await this.delay(100, context.signal);
585
- holders = await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context);
586
+ holders = await this.scanLease(allocation.leasePath, cwd, context);
586
587
  }
587
588
  if (holders.length)
588
589
  throw new Error(`Exact process lease still has ${holders.length} surviving holder(s).`);
589
590
  await this.delay(50, context.signal);
590
- if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context)).length) {
591
+ if ((await this.scanLease(allocation.leasePath, cwd, context)).length) {
591
592
  throw new Error("Exact process lease did not remain empty for two consecutive scans.");
592
593
  }
593
594
  return { outcome: "terminated" };
@@ -605,7 +606,8 @@ export class HerdrHostRuntime {
605
606
  throw new Error("Saved termination worker does not match the exact owned agent.");
606
607
  this.assertLeasePath(allocation.leasePath, allocation.token);
607
608
  await assertPrivateLease(allocation.leasePath, false);
608
- const agents = (await this.listAgents(allocation.worktreeCwd, context)).map((agent) => ({
609
+ const cwd = await this.queryCwd(allocation.worktreeCwd);
610
+ const agents = (await this.listAgents(cwd, context)).map((agent) => ({
609
611
  name: typeof agent.name === "string" ? agent.name : undefined,
610
612
  paneId: exactString(agent.pane_id, "Herdr listed agent pane ID"),
611
613
  tabId: exactString(agent.tab_id, "Herdr listed agent tab ID"),
@@ -617,10 +619,10 @@ export class HerdrHostRuntime {
617
619
  || related[0].paneId !== allocation.paneId || related[0].tabId !== allocation.tabId) {
618
620
  throw new Error("Observed worker identity does not exactly match the saved owned agent and pane.");
619
621
  }
620
- if (await this.paneAbsent(allocation.paneId, allocation.worktreeCwd, context)) {
622
+ if (await this.paneAbsent(allocation.paneId, cwd, context)) {
621
623
  throw new Error("Exact saved agent exists but its saved pane is absent.");
622
624
  }
623
- const processInfo = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(allocation.worktreeCwd, context, HERDR_OPERATION_CAP_MS));
625
+ const processInfo = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(cwd, context, HERDR_OPERATION_CAP_MS));
624
626
  const result = resultRecord(processInfo, "Herdr pane process-info response");
625
627
  if (result.type !== "pane_process_info"
626
628
  || record(result.process_info, "Herdr pane process-info").pane_id !== allocation.paneId) {
@@ -628,14 +630,14 @@ export class HerdrHostRuntime {
628
630
  }
629
631
  return { outcome: "active" };
630
632
  }
631
- if (!await this.paneAbsent(allocation.paneId, allocation.worktreeCwd, context)) {
633
+ if (!await this.paneAbsent(allocation.paneId, cwd, context)) {
632
634
  throw new Error("Saved agent is absent but the exact saved pane still exists.");
633
635
  }
634
- if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context)).length) {
636
+ if ((await this.scanLease(allocation.leasePath, cwd, context)).length) {
635
637
  throw new Error("Saved worker is absent but its private process lease still has holders.");
636
638
  }
637
639
  await this.delay(50, context.signal);
638
- if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context)).length) {
640
+ if ((await this.scanLease(allocation.leasePath, cwd, context)).length) {
639
641
  throw new Error("Private process lease did not remain empty for two consecutive scans.");
640
642
  }
641
643
  return { outcome: "terminated" };
@@ -682,11 +684,13 @@ export class HerdrHostRuntime {
682
684
  }
683
685
  try {
684
686
  if (input.kind === "worker_tab") {
685
- const allocation = ownedIntent(input.attempt, "worker_tab");
687
+ const owned = ownedIntent(input.attempt, "worker_tab");
686
688
  const workspace = ownedIntent(input.attempt, "workspace");
687
- requireIntentIdentity(allocation, input.attempt);
689
+ requireIntentIdentity(owned, input.attempt);
688
690
  requireIntentIdentity(workspace, input.attempt);
689
- assertWorkerTabIntent(allocation, input.task, input.attempt);
691
+ assertWorkerTabIntent(owned, input.task, input.attempt);
692
+ // Validation uses the saved identity; queries run from an existing directory.
693
+ const allocation = { ...owned, worktreeCwd: await this.queryCwd(owned.worktreeCwd) };
690
694
  const leasePath = exactString(allocation.leasePath, "saved worker lease path");
691
695
  this.assertLeasePath(leasePath, input.attempt.correlationToken);
692
696
  assertWorkspaceIntent(workspace, input.attempt);
@@ -1327,6 +1331,18 @@ export class HerdrHostRuntime {
1327
1331
  throw new Error("Persisted workspace repository identity no longer matches Git.");
1328
1332
  }
1329
1333
  }
1334
+ /** Herdr and lsof queries are global, so a checkout removed outside Pi Subagent must not be their process cwd. */
1335
+ async queryCwd(cwd) {
1336
+ try {
1337
+ await lstat(cwd);
1338
+ return cwd;
1339
+ }
1340
+ catch (error) {
1341
+ if (error.code === "ENOENT")
1342
+ return this.leaseDirectory;
1343
+ throw error;
1344
+ }
1345
+ }
1330
1346
  processOptions(cwd, context, cap = HERDR_OPERATION_CAP_MS) {
1331
1347
  context.signal.throwIfAborted();
1332
1348
  return { cwd, signal: context.signal, timeoutMs: this.callTimeout(context, cap) };
@@ -49,6 +49,8 @@ export declare class IntegrationGit {
49
49
  to: WorkspaceIdentity;
50
50
  };
51
51
  }, signal: AbortSignal): Promise<GitOutcome<WorkspaceIdentity>>;
52
+ /** Read-only proof that an owned checkout, its registration, and its branch are all gone. */
53
+ absent(root: string, info: WorktreeInfo, signal: AbortSignal): Promise<boolean>;
52
54
  /** Explicit release of an unselected clean checkout. The branch deletion is an exact ref compare-and-swap. */
53
55
  release(root: string, info: WorktreeInfo, expected: WorkspaceIdentity, kind: "worktree" | "branch", signal: AbortSignal): Promise<GitOutcome<"removed" | "absent">>;
54
56
  /** Cleanup is deliberately separate per resource; callers persist each proven substep. */
@@ -358,15 +358,23 @@ export class IntegrationGit {
358
358
  catch { /* Retain all resources when verification fails. */ }
359
359
  return { outcome: "unknown", failure: "Promotion returned success but exact clean Main tip could not be proved.", possibleResources: [integration.path, integration.branch] };
360
360
  }
361
+ /** Read-only proof that an owned checkout, its registration, and its branch are all gone. */
362
+ async absent(root, info, signal) {
363
+ await ownershipMetadata(root, info, signal);
364
+ const registered = await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal);
365
+ if (registered.split("\0").includes(`worktree ${info.path}`) || await exists(info.path))
366
+ return false;
367
+ const args = ["show-ref", "--verify", "--quiet", `refs/heads/${info.branch}`];
368
+ const ref = await git(args, root, signal);
369
+ if (ref.code !== 0 && ref.code !== 1)
370
+ throw new Error(failure(args, ref));
371
+ return ref.code === 1;
372
+ }
361
373
  /** Explicit release of an unselected clean checkout. The branch deletion is an exact ref compare-and-swap. */
362
374
  async release(root, info, expected, kind, signal) {
363
375
  const possibleResources = [info.path, info.branch];
364
376
  try {
365
377
  await ownershipMetadata(root, info, signal);
366
- if (expected.branch !== `refs/heads/${info.branch}` || expected.index !== expected.tree
367
- || !await ancestor(info.baseCommit, expected.head, root, signal)) {
368
- return { outcome: "blocked", failure: "Release lacks an exact clean owned branch and base lineage." };
369
- }
370
378
  const registered = await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal);
371
379
  const present = registered.split("\0").includes(`worktree ${info.path}`);
372
380
  const pathPresent = await exists(info.path);
@@ -377,9 +385,14 @@ export class IntegrationGit {
377
385
  const branch = await git(["rev-parse", "--verify", ref], root, signal);
378
386
  if (branch.code !== 0 && branch.code !== 128)
379
387
  throw new Error("Owned branch tip could not be inspected.");
388
+ // Absence needs no lineage proof: nothing remains to remove, even after Git pruned the commits.
389
+ if (!present && !pathPresent && (kind === "worktree" || branch.code === 128))
390
+ return { outcome: "ready", value: "absent" };
391
+ if (expected.branch !== ref || expected.index !== expected.tree
392
+ || !await ancestor(info.baseCommit, expected.head, root, signal)) {
393
+ return { outcome: "blocked", failure: "Release lacks an exact clean owned branch and base lineage." };
394
+ }
380
395
  if (kind === "worktree") {
381
- if (!present && !pathPresent)
382
- return { outcome: "ready", value: "absent" };
383
396
  if (!present || !pathPresent || branch.stdout.trim() !== expected.head
384
397
  || !sameIdentity(await current(root, info, signal), expected)) {
385
398
  return { outcome: "blocked", failure: "Owned checkout, registration, branch or clean candidate changed." };
@@ -398,8 +411,6 @@ export class IntegrationGit {
398
411
  }
399
412
  if (present || pathPresent)
400
413
  return { outcome: "blocked", failure: "Release the exact checkout before branch deletion." };
401
- if (branch.code === 128)
402
- return { outcome: "ready", value: "absent" };
403
414
  if (branch.stdout.trim() !== expected.head)
404
415
  return { outcome: "blocked", failure: "Owned branch differs from the approved tip." };
405
416
  const args = ["update-ref", "-d", ref, expected.head];
package/dist/runner.d.ts CHANGED
@@ -347,6 +347,8 @@ export declare class IsolatedRunner {
347
347
  private cleanupPromoted;
348
348
  private requireExactIntegration;
349
349
  abort(id: string, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
350
+ /** Read-only proof that every retained candidate checkout and staged integration checkout is already gone. */
351
+ private retainedWorkAbsent;
350
352
  private cleanupAborted;
351
353
  status(id: string, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
352
354
  private run;
@@ -356,6 +358,11 @@ export declare class IsolatedRunner {
356
358
  private driveWorkerSafely;
357
359
  private driveWorker;
358
360
  private terminationCandidate;
361
+ /**
362
+ * Proves exact worker termination without replay. Reconciliation records a worker that is already gone;
363
+ * only an observed live worker without saved termination intent is closed.
364
+ */
365
+ private proveTermination;
359
366
  private terminateWithSafety;
360
367
  private runCheckBatch;
361
368
  private runReview;
package/dist/runner.js CHANGED
@@ -286,7 +286,27 @@ function affectedDependents(state, taskId) {
286
286
  }
287
287
  }
288
288
  }
289
- return state.tasks.filter((task) => task.taskId !== taskId && affected.has(task.taskId) && task.attempts.length > 0);
289
+ return state.tasks.filter((task) => task.taskId !== taskId && affected.has(task.taskId) && task.attempts.length > 0
290
+ // Refresh or an earlier rejection already invalidated this attempt; it needs no second invalidation.
291
+ && !(task.status === "pending" && (task.kind === "changeset" ? task.attempts.at(-1).superseded
292
+ : task.attempts.at(-1).status === "superseded")));
293
+ }
294
+ /** Freezes a generation as read-only. The correction stays recorded because it names the checkout's exact tip. */
295
+ function supersedeGeneration(generation, failure) {
296
+ if (generation.status === "promotion_failed") {
297
+ generation.supersededPromotion = generation.promotion;
298
+ delete generation.promotion;
299
+ }
300
+ generation.supersededFrom = generation.status;
301
+ generation.status = "superseded";
302
+ generation.failure = failure;
303
+ delete generation.combinedTip;
304
+ delete generation.checks;
305
+ delete generation.review;
306
+ }
307
+ /** The exact tip a retained integration checkout holds: its correction, last staged merge, or base. */
308
+ function retainedGenerationTip(generation) {
309
+ return generation.correction?.to ?? generation.stages.at(-1)?.tip ?? generation.integrationBase;
290
310
  }
291
311
  /** Every changeset predecessor must be present in this exact committed snapshot. */
292
312
  function stagedDependencySnapshot(state, taskId, tip) {
@@ -950,13 +970,7 @@ export class IsolatedRunner {
950
970
  }
951
971
  if (!["staging", "conflict", "validation_failed", "ready"].includes(generation.status))
952
972
  throw new Error("Cannot supersede an unresolved promotion or validation.");
953
- generation.supersededFrom = generation.status;
954
- generation.status = "superseded";
955
- generation.failure = `Main ${action.action === "reject" ? "rejected" : "revised"} ${action.taskId}; retained generation is read-only.`;
956
- delete generation.combinedTip;
957
- delete generation.checks;
958
- delete generation.review;
959
- delete generation.correction;
973
+ supersedeGeneration(generation, `Main ${action.action === "reject" ? "rejected" : "revised"} ${action.taskId}; retained generation is read-only.`);
960
974
  }
961
975
  candidate.decision = "rejected";
962
976
  for (const dependent of affected) {
@@ -1164,20 +1178,22 @@ export class IsolatedRunner {
1164
1178
  const candidates = state.integration.candidates.filter((item) => item.taskId === action.taskId && item.attempt === action.attempt);
1165
1179
  const candidate = candidates.find((item) => sameIdentity(item.tip, action.expectedTip));
1166
1180
  const worker = attempt?.allocations.find((item) => item.kind === "worktree" && item.status === "owned")?.worktree;
1167
- if (!candidate || candidate.decision !== "rejected" || task.status !== "rejected"
1168
- || attempt?.termination?.status !== "terminated"
1169
- || !sameIdentity(attempt.termination.candidate, action.expectedTip) || !worker
1181
+ const tip = attempt?.termination?.candidate ?? attempt?.candidate;
1182
+ // A rejected task or an invalidated dependent attempt never runs again; its worker can stop.
1183
+ if (!candidate || candidate.decision !== "rejected" || !attempt || (task.status !== "rejected" && !attempt.superseded)
1184
+ || !tip || !sameIdentity(tip, action.expectedTip) || !worker
1170
1185
  || candidates.some((item) => !item.decision)
1171
1186
  || state.integration.generations.some((item) => item.stages.some((stage) => stage.taskId === action.taskId
1172
1187
  && stage.attempt === action.attempt && item.cleanup?.every((step) => step.status === "completed") !== true))) {
1173
- throw new Error("Release requires a rejected terminated worker and no retained staged generation using its checkout.");
1188
+ throw new Error("Release requires a rejected candidate of a stopped or invalidated worker and no retained staged generation using its checkout.");
1174
1189
  }
1190
+ await this.proveTermination(handle, task, attempt, action.expectedTip, async () => await this.saveProductive(handle), scope.signal);
1175
1191
  await this.releaseRejected(handle, task, attempt, worker, scope);
1176
1192
  return this.response(state);
1177
1193
  }
1178
1194
  const superseded = state.integration.generations[action.generation - 1];
1179
1195
  if (!superseded?.worktree || superseded.status !== "superseded"
1180
- || !sameIdentity(action.expectedTip, superseded.stages.at(-1)?.tip ?? superseded.integrationBase)
1196
+ || !sameIdentity(action.expectedTip, retainedGenerationTip(superseded))
1181
1197
  || superseded.stages.some((stage) => stage.status === "pending" || stage.status === "staging")) {
1182
1198
  throw new Error("Release requires an exact superseded integration checkout without an uncertain stage.");
1183
1199
  }
@@ -1234,18 +1250,7 @@ export class IsolatedRunner {
1234
1250
  throw new Error("A dependent has unproved resources or exhausted fresh attempts; refresh is blocked.");
1235
1251
  }
1236
1252
  if (generation && generation.status !== "superseded") {
1237
- if (generation.status === "promotion_failed") {
1238
- generation.supersededPromotion = generation.promotion;
1239
- delete generation.promotion;
1240
- }
1241
- generation.supersededFrom = generation.status;
1242
- generation.status = "superseded";
1243
- generation.failure = `${unchangedMain ? "Validation evidence missing" : "Main advanced"}; retained generation is read-only. Restage immutable candidates in a new generation.`;
1244
- delete generation.combinedTip;
1245
- delete generation.checks;
1246
- delete generation.review;
1247
- if (!generation.supersededPromotion)
1248
- delete generation.correction;
1253
+ supersedeGeneration(generation, `${unchangedMain ? "Validation evidence missing" : "Main advanced"}; retained generation is read-only. Restage immutable candidates in a new generation.`);
1249
1254
  }
1250
1255
  for (const task of stale) {
1251
1256
  if (task.kind === "text") {
@@ -1659,10 +1664,23 @@ export class IsolatedRunner {
1659
1664
  const state = handle.state;
1660
1665
  if (terminal(state) && state.status !== "aborted")
1661
1666
  return this.response(state);
1662
- if (state.integration.candidates.some((candidate) => candidate.worker !== "released")
1663
- || state.integration.generations.some((generation) => generation.worktree
1664
- && !generation.cleanup?.every((step) => step.status === "completed"))) {
1665
- throw new Error("Retained candidates and integration worktrees must be explicitly rejected and released before abort.");
1667
+ const candidates = state.integration.candidates.filter((candidate) => candidate.worker !== "released");
1668
+ const generations = state.integration.generations.filter((generation) => generation.worktree
1669
+ && !generation.cleanup?.every((step) => step.status === "completed"));
1670
+ // Abort removes only unchanged resources: an empty refreshed generation, or candidate work proved already gone.
1671
+ if ((candidates.length || generations.length) && (lifecycle.productiveRunLeaseActive
1672
+ || generations.some((generation) => generation.status === "promotion_failed" ? generation.promotion?.outcome !== "drift"
1673
+ : !["staging", "conflict", "validation_failed", "ready", "superseded"].includes(generation.status))
1674
+ || ((candidates.length || generations.some((generation) => generation.stages.length))
1675
+ && !await this.retainedWorkAbsent(state, candidates, generations, outerSignal)))) {
1676
+ throw new Error("Retained candidates and integration worktrees must be explicitly rejected and released before abort, unless their checkouts and branches are proved absent.");
1677
+ }
1678
+ for (const candidate of candidates.filter((item) => !item.decision)) {
1679
+ candidate.decision = "rejected";
1680
+ this.attention(changesetTaskState(state, candidate.taskId), "Abort rejected this candidate after proving its checkout and branch absent.");
1681
+ }
1682
+ for (const generation of generations.filter((item) => item.status !== "superseded")) {
1683
+ supersedeGeneration(generation, "Request aborted; the integration checkout is released.");
1666
1684
  }
1667
1685
  const activeControls = this.activeControls(root, id);
1668
1686
  for (const { control } of activeControls)
@@ -1679,30 +1697,11 @@ export class IsolatedRunner {
1679
1697
  for (const attempt of task.attempts) {
1680
1698
  if (!allocationByKind(attempt, "agent")?.agentName || attempt.termination?.status === "terminated")
1681
1699
  continue;
1682
- if (attempt.termination) {
1683
- const safety = new DeadlineScope(safetyDeadline, () => this.coordinatorRuntime.now(), outerSignal);
1684
- try {
1685
- const result = await safety.call((context) => this.hostRuntime.reconcileWorkerTermination({
1686
- task: changesetTaskRequest(state, task.taskId), attempt,
1687
- workerId: attempt.termination.workerId, candidate: attempt.termination.candidate,
1688
- }, context));
1689
- if (result.outcome !== "terminated")
1690
- throw new Error(result.outcome === "unknown"
1691
- ? result.failure : "Exact worker remains active; termination was not replayed.");
1692
- attempt.termination = { ...attempt.termination, status: "terminated", at: this.coordinatorRuntime.now() };
1693
- delete attempt.termination.failure;
1694
- }
1695
- catch (error) {
1696
- attempt.termination.status = "unknown";
1697
- attempt.termination.failure = errorText(error);
1698
- this.attention(task, attempt.termination.failure);
1699
- }
1700
- finally {
1701
- safety.close();
1702
- }
1700
+ try {
1701
+ await this.proveTermination(handle, task, attempt, this.terminationCandidate(attempt), async () => await handle.save(), outerSignal, safetyDeadline);
1703
1702
  }
1704
- else {
1705
- await this.terminateWithSafety(handle, task, attempt, this.terminationCandidate(attempt), outerSignal, safetyDeadline, true);
1703
+ catch (error) {
1704
+ this.attention(task, errorText(error));
1706
1705
  }
1707
1706
  }
1708
1707
  }
@@ -1717,6 +1716,24 @@ export class IsolatedRunner {
1717
1716
  return this.response(state);
1718
1717
  }, { purpose: "abort" });
1719
1718
  }
1719
+ /** Read-only proof that every retained candidate checkout and staged integration checkout is already gone. */
1720
+ async retainedWorkAbsent(state, candidates, generations, signal) {
1721
+ const checkouts = [
1722
+ ...candidates.map((candidate) => allocationByKind(changesetTaskState(state, candidate.taskId).attempts[candidate.attempt - 1], "worktree")?.worktree),
1723
+ ...generations.filter((generation) => generation.stages.length).map((generation) => generation.worktree),
1724
+ ];
1725
+ const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now(), signal);
1726
+ try {
1727
+ for (const checkout of checkouts) {
1728
+ if (!checkout || !await scope.call((context) => this.integrationGit.absent(state.root, checkout, context.signal)))
1729
+ return false;
1730
+ }
1731
+ return true;
1732
+ }
1733
+ finally {
1734
+ scope.close();
1735
+ }
1736
+ }
1720
1737
  async cleanupAborted(handle, signal) {
1721
1738
  const state = handle.state;
1722
1739
  const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now(), signal);
@@ -1737,7 +1754,8 @@ export class IsolatedRunner {
1737
1754
  if (allocationByKind(attempt, "agent") && attempt.termination?.status !== "terminated") {
1738
1755
  throw new Error("Exact worker termination remains unproved.");
1739
1756
  }
1740
- if (expected && attempt.termination && attempt.termination.candidate.head !== expected.head) {
1757
+ if (expected && attempt.termination && attempt.termination.candidate.head !== expected.head
1758
+ && !await scope.call((context) => this.integrationGit.absent(state.root, worker, context.signal))) {
1741
1759
  throw new Error("Aborted worker may contain committed work; automatic cleanup preserves it.");
1742
1760
  }
1743
1761
  for (const step of attempt.cleanup) {
@@ -1773,6 +1791,45 @@ export class IsolatedRunner {
1773
1791
  }
1774
1792
  }
1775
1793
  }
1794
+ // Abort admitted retained candidates only after proving their checkouts absent.
1795
+ for (const candidate of state.integration.candidates) {
1796
+ const task = changesetTaskState(state, candidate.taskId);
1797
+ const attempt = task.attempts[candidate.attempt - 1];
1798
+ if (candidate.worker === "released" || candidate.decision !== "rejected" || attempt.termination?.status !== "terminated"
1799
+ || attempt.cleanup.some((step) => step.status !== "completed"))
1800
+ continue;
1801
+ candidate.worker = "released";
1802
+ if (task.status === "needs_attention" && attempt === task.attempts.at(-1) && !attempt.superseded) {
1803
+ task.status = "rejected";
1804
+ delete task.failure;
1805
+ }
1806
+ await handle.save();
1807
+ }
1808
+ for (const generation of state.integration.generations) {
1809
+ if (!generation.worktree || generation.status !== "superseded")
1810
+ continue;
1811
+ const steps = generation.cleanup ??= [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
1812
+ try {
1813
+ for (const step of steps) {
1814
+ if (step.status === "completed")
1815
+ continue;
1816
+ step.status = "running";
1817
+ delete step.failure;
1818
+ await handle.save();
1819
+ const result = await scope.call((context) => this.integrationGit.release(state.root, generation.worktree, retainedGenerationTip(generation), step.kind, context.signal));
1820
+ if (result.outcome !== "ready")
1821
+ throw new Error(result.failure);
1822
+ step.status = "completed";
1823
+ await handle.save();
1824
+ }
1825
+ }
1826
+ catch (error) {
1827
+ const pending = steps.find((step) => step.status === "running");
1828
+ if (pending)
1829
+ pending.failure = bounded(`Abort cleanup retained resources: ${errorText(error)}`);
1830
+ await handle.save();
1831
+ }
1832
+ }
1776
1833
  }
1777
1834
  finally {
1778
1835
  scope.close();
@@ -2434,6 +2491,43 @@ export class IsolatedRunner {
2434
2491
  ?? attempt.prompts.at(-1)?.preCandidate
2435
2492
  ?? attempt.waveBase;
2436
2493
  }
2494
+ /**
2495
+ * Proves exact worker termination without replay. Reconciliation records a worker that is already gone;
2496
+ * only an observed live worker without saved termination intent is closed.
2497
+ */
2498
+ async proveTermination(handle, task, attempt, candidate, save, signal, deadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS) {
2499
+ if (attempt.termination?.status === "terminated")
2500
+ return;
2501
+ const workerId = attempt.termination?.workerId ?? allocationByKind(attempt, "agent")?.agentName;
2502
+ if (!workerId)
2503
+ throw new Error("Worker termination requires an exact durably owned agent ID.");
2504
+ const input = { task: changesetTaskRequest(handle.state, task.taskId), attempt, workerId,
2505
+ candidate: attempt.termination?.candidate ?? candidate };
2506
+ const safety = new DeadlineScope(deadline, () => this.coordinatorRuntime.now(), signal);
2507
+ try {
2508
+ let result = await safety.call((context) => this.hostRuntime.reconcileWorkerTermination(input, context));
2509
+ if (result.outcome === "active" && !attempt.termination) {
2510
+ attempt.termination = { status: "terminating", workerId, candidate: input.candidate };
2511
+ await save(); // Intent before the exact pane close.
2512
+ result = await safety.call((context) => this.hostRuntime.terminateWorker(input, context));
2513
+ }
2514
+ if (result.outcome !== "terminated") {
2515
+ throw new Error(result.outcome === "unknown" ? result.failure : "Exact worker remains active; termination was not replayed.");
2516
+ }
2517
+ attempt.termination = { status: "terminated", workerId, candidate: input.candidate, at: this.coordinatorRuntime.now() };
2518
+ }
2519
+ catch (error) {
2520
+ const failure = bounded(`Worker termination is unproved: ${errorText(error)}`);
2521
+ // Without saved intent, nothing was closed; a later proof may still close the exact live worker.
2522
+ if (attempt.termination)
2523
+ attempt.termination = { status: "unknown", workerId, candidate: input.candidate, failure };
2524
+ throw new Error(failure, { cause: error });
2525
+ }
2526
+ finally {
2527
+ safety.close();
2528
+ await save();
2529
+ }
2530
+ }
2437
2531
  async terminateWithSafety(handle, task, attempt, candidate, outerSignal, safetyDeadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS, lifecycleLocked = false) {
2438
2532
  if (!lifecycleLocked && this.productiveLifecycles.has(handle.state.root)) {
2439
2533
  return await this.withLifecycleLock(handle, async () => await this.terminateWithSafety(handle, task, attempt, candidate, outerSignal, safetyDeadline, true));
@@ -2985,8 +3079,9 @@ export class IsolatedRunner {
2985
3079
  text: bounded([
2986
3080
  `Pi Subagent ${state.request.id}: ${state.status}.`,
2987
3081
  `Tasks: ${completed}/${state.tasks.length} completed, ${rejected} rejected. Accepted: ${state.accepted}.`,
2988
- ...(state.status === "aborted" && state.tasks.some((task) => task.kind === "changeset"
3082
+ ...(state.status === "aborted" && (state.tasks.some((task) => task.kind === "changeset"
2989
3083
  && task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed")))
3084
+ || state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")))
2990
3085
  ? ["Aborted resources remain retained. Inspect cleanup evidence; retry subagent_abort after active work settles or cleanup blockers are resolved. Unique commits and uncertain work are never discarded."] : []),
2991
3086
  ...(main?.status === "current" ? ["Main: current at the recorded exact identity."] : []),
2992
3087
  ...(main?.status === "drifted" ? [
@@ -3009,10 +3104,10 @@ export class IsolatedRunner {
3009
3104
  ] : []),
3010
3105
  ...(state.integration.generations.some((item) => item.status === "superseded" && item.worktree
3011
3106
  && !item.cleanup?.every((step) => step.status === "completed")) ? [
3012
- "Superseded integration worktrees remain owned. Use subagent_integrate release with their exact generation and last staged tip; dirty or conflicted work stays retained.",
3107
+ "Superseded integration worktrees remain owned. Use subagent_integrate release with their exact generation and recorded tip (correction tip, else last staged tip, else integration base); dirty or conflicted work stays retained.",
3013
3108
  ] : []),
3014
3109
  ...(state.integration.candidates.some((item) => item.decision === "rejected" && item.worker !== "released") ? [
3015
- "Rejected candidate resources remain retained. After proving worker termination and releasing any superseded stage, use subagent_integrate release with the exact task, attempt and candidate tip.",
3110
+ "Rejected candidate resources remain retained. Release every generation that staged it, then use subagent_integrate release with the exact task, attempt and candidate tip; release first proves or performs exact worker termination.",
3016
3111
  ] : []),
3017
3112
  ...(state.final.failure ? [`Final: ${state.final.failure}`] : []),
3018
3113
  ...(continuation ? [`Continuation: ${JSON.stringify(continuation)}`] : []),
package/dist/schema.js CHANGED
@@ -923,7 +923,7 @@ export function parseIntegrationState(value, request) {
923
923
  if (generation.status === "superseded") {
924
924
  if (!generation.supersededFrom || !generation.failure?.trim() || generation.promotion
925
925
  || generation.checks || generation.review || generation.combinedTip
926
- || (generation.supersededFrom === "promotion_failed" ? !generation.supersededPromotion : generation.correction || generation.supersededPromotion)) {
926
+ || (generation.supersededFrom === "promotion_failed" ? !generation.supersededPromotion : generation.supersededPromotion)) {
927
927
  throw new Error(`Superseded generation ${generation.number} retains usable evidence or lacks its reason.`);
928
928
  }
929
929
  }
@@ -1256,7 +1256,7 @@ export function parseRunState(value) {
1256
1256
  const selected = state.integration.generations.at(-1);
1257
1257
  const releasedWithRevisedAttempt = selected?.status === "promoted" && selected.stages.some((stage) => stage.taskId === candidate.taskId && stage.attempt === candidate.attempt && !sameIdentity(stage.source, candidate.tip))
1258
1258
  && attempt?.cleanup.every((step) => step.status === "completed");
1259
- const releasedAfterRejection = task?.status === "rejected" && attempt?.termination?.status === "terminated"
1259
+ const releasedAfterRejection = (task?.status === "rejected" || attempt?.superseded) && attempt?.termination?.status === "terminated"
1260
1260
  && (candidate.worker === "release_pending" || candidate.worker === "released"
1261
1261
  && attempt.cleanup.every((step) => step.status === "completed"));
1262
1262
  if (!attempt || (candidate.worker !== "retained" && !releasedAfterRejection
@@ -117,9 +117,9 @@ The user's request can authorize the declared local graph, checks, review, and M
117
117
  - `subagent_stage`: `{ id, action: "stage" | "resolve" | "reject" | "revise", generation, taskId, attempt, candidate, expectedTip, instruction? }`. `candidate` is the ready candidate's `tip`; `expectedTip` is Main's recorded identity for the first stage or the previous stage's exact tip. `resolve` reconciles a pending/conflicted merge; `reject` and `revise` arbitrate a candidate without changing Main.
118
118
  - `subagent_integrate`: `{ id, action: "advance" | "validate" | "correct" | "promote" | "reconcile" | "cleanup", generation, expectedTip }`. Use the generation's exact `combinedTip`. `advance` dispatches ready dependents on that snapshot, `correct` records the single clean committed correction after a definitive failed check/review, `reconcile` inspects an interrupted validation or promotion without replaying an uncertain promotion, and `cleanup` completes proved post-promotion cleanup.
119
119
  - `subagent_integrate refresh`: `{ id, action: "refresh", generation, expectedTip, expectedMain, newMain }`. If Main advanced to a clean same-branch descendant, this freezes the old generation and allocates a new integration checkout from `newMain`. Restage selected immutable candidates in explicit order, resolve fresh conflicts, then rerun combined validation. Dirty, divergent, or uncertain Main cannot refresh. For a reconciled `validation_failed` generation without checks, review, or a prior combined correction, identical `expectedMain` and `newMain` instead rebuild on the unchanged base. This requires the exact clean retained integration tip, leaves the old checkout retained, and never resumes its uncertain checks. Prove old coordinator/check processes stopped before recovery; a live productive lease blocks it. Restage explicitly and rerun all final gates; no correction allowance is replenished.
120
- - `subagent_integrate release`: `{ id, action: "release", generation, expectedTip, taskId?, attempt? }`. With no task, release an explicitly superseded checkout using that generation's last staged tip (or integration base if empty). With both `taskId` and `attempt`, release a rejected, terminated worker using its exact candidate tip and the latest generation number (or 1 when none exists); release every generation that staged it first. Cleanup steps are persisted separately, and a retry reconciles already-removed resources by proving absence. Release refuses dirty/conflicted work, changed refs, unproved termination, and still-needed checkouts.
120
+ - `subagent_integrate release`: `{ id, action: "release", generation, expectedTip, taskId?, attempt? }`. With no task, release an explicitly superseded checkout using that generation's recorded tip: its correction tip, else its last staged tip, else its integration base. Supersession keeps the correction record for this proof. With both `taskId` and `attempt`, release the rejected candidate of a rejected task or of a dependent attempt that refresh or rejection invalidated, using its exact candidate tip and the latest generation number (or 1 when none exists); release every generation that staged it first. Release proves exact worker termination before cleanup: reconciliation records an absent worker after pane absence and two empty lease scans, and only an observed live worker without saved termination intent is closed with the same proof. An uncertain termination is never replayed. Cleanup steps are persisted separately, and a retry reconciles already-removed resources by proving absence. Release refuses dirty/conflicted work, changed refs, unproved termination, and still-needed checkouts.
121
121
 
122
- A rejection or revision of a staged candidate supersedes that generation, leaves its checkout read-only, and requires explicit restaging of chosen candidates into a new generation. At most two unreleased owned integration checkouts can coexist per request. Superseded integration checkouts and rejected candidate resources are **retained until Main explicitly releases them**. Inspect exact status first; dirty or unresolved conflicts require manual recovery without force deletion. Rejection invalidates dependent attempts; if the required fresh dependent work cannot run within the bounded attempt/lineage constraints, start a new request. `subagent_abort` cannot discard retained resources, but can finish after all candidates are rejected and all owned checkouts are released.
122
+ A rejection or revision of a staged candidate supersedes that generation, leaves its checkout read-only, and requires explicit restaging of chosen candidates into a new generation. At most two unreleased owned integration checkouts can coexist per request. Superseded integration checkouts and rejected candidate resources are **retained until Main explicitly releases them**. Inspect exact status first; dirty or unresolved conflicts require manual recovery without force deletion. Rejection invalidates dependent attempts; if the required fresh dependent work cannot run within the bounded attempt/lineage constraints, start a new request. `subagent_abort` cannot discard retained resources, but can finish after all candidates are rejected and all owned checkouts are released. It also releases an empty refreshed generation that is clean at its base. If retained candidate checkouts and staged integration checkouts are proved absent (unregistered, path missing, branch ref missing) and their workers are proved absent, abort records those candidates as rejected and released and completes their cleanup; any present resource fails abort without state changes. Rejection and revision ignore dependents that refresh or an earlier rejection already invalidated.
123
123
 
124
124
  ### Recovery and state
125
125
 
@@ -439,7 +439,7 @@ function inspectionNotices({ state, main, continuation }: RunResponse): string[]
439
439
  && !generation.promotion?.failure && !generation.supersededPromotion?.failure
440
440
  && generation !== state.integration.generations.at(-1)) continue;
441
441
  const stagedTip = [...generation.stages].reverse().find((stage) => stage.tip)?.tip ?? generation.integrationBase;
442
- lines.push(`Integration generation ${generation.number}: ${generation.status}; ${retainedPath ? `${checkoutCleaned ? "historical checkout; branch cleanup pending" : "worktree"}: ${JSON.stringify({ path: generation.worktree!.path, cwd: generation.worktree!.cwd, branch: generation.worktree!.branch, repoRoot: generation.worktree!.repoRoot })}; ` : ""}last staged tip: ${JSON.stringify(stagedTip)}${generation.combinedTip ? `; combined tip: ${JSON.stringify(generation.combinedTip)}` : ""}${generation.failure ? `; blockage: ${boundedPublicText(generation.failure)}` : ""}.`);
442
+ lines.push(`Integration generation ${generation.number}: ${generation.status}; ${retainedPath ? `${checkoutCleaned ? "historical checkout; branch cleanup pending" : "worktree"}: ${JSON.stringify({ path: generation.worktree!.path, cwd: generation.worktree!.cwd, branch: generation.worktree!.branch, repoRoot: generation.worktree!.repoRoot })}; ` : ""}last staged tip: ${JSON.stringify(stagedTip)}${generation.correction ? `; correction tip: ${JSON.stringify(generation.correction.to)}` : ""}${generation.combinedTip ? `; combined tip: ${JSON.stringify(generation.combinedTip)}` : ""}${generation.failure ? `; blockage: ${boundedPublicText(generation.failure)}` : ""}.`);
443
443
  for (const stage of generation.stages.filter((item) => item.status === "conflict" || item.failure)) lines.push(`Integration generation ${generation.number} stage ${stage.taskId} attempt ${stage.attempt}: ${stage.status}${stage.failure ? `; blockage: ${boundedPublicText(stage.failure)}` : ""}.`);
444
444
  for (const promotion of [generation.promotion, generation.supersededPromotion]) if (promotion?.failure) lines.push(`Integration generation ${generation.number} promotion ${promotion.status}: ${boundedPublicText(promotion.failure)}`);
445
445
  if (generation.cleanup?.some((step) => step.status !== "completed")) lines.push(`Integration generation ${generation.number} cleanup pending: ${JSON.stringify(generation.cleanup.filter((step) => step.status !== "completed").map((step) => ({ kind: step.kind, status: step.status, failure: step.failure ? boundedPublicText(step.failure) : undefined })))}`);
@@ -766,7 +766,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
766
766
  pi.registerTool({
767
767
  name: "subagent_abort",
768
768
  label: "Subagent abort",
769
- description: "Abort an isolated request; reject and release retained candidates and integration checkouts first. Terminate only exact owned workers. Repeat abort to reconcile cleanup of unchanged no-candidate allocations; preserve dirty or committed work.",
769
+ description: "Abort an isolated request; reject and release retained candidates and integration checkouts first unless their checkouts and branches are proved absent. Terminate only exact owned workers. Repeat abort to reconcile cleanup of unchanged allocations; preserve dirty or committed work.",
770
770
  parameters: IdOnlySchema,
771
771
  prepareArguments: parseIdOnly,
772
772
  exposure: "model-only",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "27.2.1",
3
+ "version": "27.3.0",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",