@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 +63 -95
- package/README.md +4 -2
- package/dist/herdr-runtime.d.ts +2 -0
- package/dist/herdr-runtime.js +35 -19
- package/dist/integration-git.d.ts +2 -0
- package/dist/integration-git.js +19 -8
- package/dist/runner.d.ts +7 -0
- package/dist/runner.js +151 -56
- package/dist/schema.js +2 -2
- package/docs/orchestration.md +2 -2
- package/extensions/isolated.ts +2 -2
- package/package.json +1 -1
package/CONTEXT.md
CHANGED
|
@@ -1,95 +1,63 @@
|
|
|
1
|
-
# Pi Subagent
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
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
|
|
package/dist/herdr-runtime.d.ts
CHANGED
|
@@ -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;
|
package/dist/herdr-runtime.js
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
687
|
+
const owned = ownedIntent(input.attempt, "worker_tab");
|
|
686
688
|
const workspace = ownedIntent(input.attempt, "workspace");
|
|
687
|
-
requireIntentIdentity(
|
|
689
|
+
requireIntentIdentity(owned, input.attempt);
|
|
688
690
|
requireIntentIdentity(workspace, input.attempt);
|
|
689
|
-
assertWorkerTabIntent(
|
|
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. */
|
package/dist/integration-git.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
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
|
-
|
|
1683
|
-
|
|
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
|
-
|
|
1705
|
-
|
|
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.
|
|
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.
|
|
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
|
package/docs/orchestration.md
CHANGED
|
@@ -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
|
|
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
|
|
package/extensions/isolated.ts
CHANGED
|
@@ -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
|
|
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",
|