@henryqw/pi-subagent 23.0.1 → 23.0.2
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/README.md +5 -3
- package/dist/herdr-runtime.d.ts +1 -0
- package/dist/herdr-runtime.js +50 -4
- package/dist/runner.d.ts +1 -0
- package/dist/runner.js +143 -37
- package/dist/schema.js +6 -6
- package/docs/orchestration.md +4 -2
- package/extensions/isolated.ts +1 -1
- package/package.json +1 -1
- package/skills/pi-subagent/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -113,7 +113,7 @@ An isolated request has one durable ID, one goal, and 1–8 typed tasks:
|
|
|
113
113
|
"modelClass": "balanced",
|
|
114
114
|
"requirements": "Implement the smallest correct repair.",
|
|
115
115
|
"deliverable": "A committed candidate in the owned worktree.",
|
|
116
|
-
"dependsOn": [
|
|
116
|
+
"dependsOn": [],
|
|
117
117
|
"contextFrom": ["inspect"],
|
|
118
118
|
"checks": [
|
|
119
119
|
{ "command": "pnpm", "args": ["test", "--", "refresh"] }
|
|
@@ -131,7 +131,7 @@ An isolated request has one durable ID, one goal, and 1–8 typed tasks:
|
|
|
131
131
|
}
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
-
`dependsOn` controls scheduling. `contextFrom`
|
|
134
|
+
`dependsOn` controls scheduling. `contextFrom` also schedules dependencies and carries completed text outputs in the declared order. Do not repeat the same task in both fields. Changesets require focused task checks; a graph with changesets requires final checks. A canonical root `package.json` test script is required: the runner adds `pnpm test` to final checks if missing, and Main runs it on the combined integration checkout before promotion. Checks and judgments bind to exact Git identities; candidate drift, Main drift, mutation during validation, ambiguity, or conflicts stop promotion and retain evidence.
|
|
135
135
|
|
|
136
136
|
`delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Main can do other work or end its turn instead of sleeping or polling. Pi sends a follow-up in the launching session whenever a checked worker becomes ready or a worker needs attention, and again when the wave finishes. A worker whose exact turn ends without a changed clean commit—including a failed or aborted turn—needs attention, not an inferred candidate; use `subagent_status` for its saved evidence. If delivery is missed, inspect `subagent_status`.
|
|
137
137
|
|
|
@@ -147,9 +147,11 @@ A queued follow-up runs after the current turn settles and must produce a new cl
|
|
|
147
147
|
|
|
148
148
|
After a coordinator interruption, run `/subagent recover` once. It acquires the repository productive lease before changing any orphaned `pending` or `running` state, preserves unreadable state files and retained candidates, and reports blockers and existing continuations to Main without replaying prompts, merges, promotion, or cleanup. If another productive run holds the lease, it only reads status and reports the live owner; wait rather than taking over. Repeating the command is safe. Main uses `subagent_status` for exact identities and the recovery tools in the interface table above for deliberate decisions; the command never stages or promotes. Direct Herdr tab recovery remains in `/subagent`.
|
|
149
149
|
|
|
150
|
+
If an isolated worker produced no candidate, `subagent_abort` terminates it and removes only proven unchanged, clean owned resources. Cleanup includes ignored-file checks and never discards unique commits. If productive work is still settling, termination is uncertain, or cleanup fails, the request stays aborted with retained-resource evidence. Inspect `subagent_status`, resolve the reported blocker, then repeat `subagent_abort` to reconcile cleanup; it does not replay worker prompts. Dirty work, unique commits, and uncertain allocations remain for deliberate manual recovery.
|
|
151
|
+
|
|
150
152
|
The following details describe exact identity and retained-work requirements.
|
|
151
153
|
|
|
152
|
-
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 using those actions. No dirty work is replayed or merged automatically. Pass the exact `generation`, candidate (`taskId`, `attempt`, `candidate`) and `expectedTip` identities reported by status to stage; pass the generation and combined `expectedTip` to integrate. If Main advances cleanly before promotion, call `subagent_integrate refresh` with the recorded `expectedMain`, old `expectedTip`, and new clean `newMain`. Main must restage selected immutable candidates into the new generation and rerun combined checks. Dirty or divergent Main blocks refresh. Stale identities fail. If integration allocation was uncertain, `subagent_stage resolve` first proves the owned checkout is still at its base before starting the selected merge; otherwise it retains the intent. At most two integration worktrees may remain unreleased per request; a superseded generation is read-only. To release a superseded *clean* integration checkout, call `subagent_integrate` with `{ id, action: "release", generation, expectedTip }`, using that generation's last staged tip (or integration base when no stage completed). To release a rejected candidate after its worker has terminated and all generations using it are released, pass `{ id, action: "release", generation: <latest generation number, or 1>, taskId, attempt, expectedTip: <candidate tip> }`. Release persists each host/checkout/branch step, verifies ownership and cleanliness (including ignored files), and deletes the branch only if its ref still names the exact tip. Dirty, conflicted, changed, or uncertain resources remain for manual inspection; retry the same release after resolving the obstacle. Released checkouts free a retained slot; abort is available after every retained resource is released. Rejection can invalidate dependent work; if fresh dependent execution is impossible, start a new request instead of forcing promotion. Productive requests have no whole-run wall-clock deadline. Resume does not reset the recorded policy or correction count. Child limits, abort signals, process I/O, status inspection, exact termination, and cleanup retain finite safety bounds.
|
|
154
|
+
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 using those actions. No dirty work is replayed or merged automatically. Pass the exact `generation`, candidate (`taskId`, `attempt`, `candidate`) and `expectedTip` identities reported by status to stage; pass the generation and combined `expectedTip` to integrate. If Main advances cleanly before promotion, call `subagent_integrate refresh` with the recorded `expectedMain`, old `expectedTip`, and new clean `newMain`. Main must restage selected immutable candidates into the new generation and rerun combined checks. Dirty or divergent Main blocks refresh. Stale identities fail. If integration allocation was uncertain, `subagent_stage resolve` first proves the owned checkout is still at its base before starting the selected merge; otherwise it retains the intent. At most two integration worktrees may remain unreleased per request; a superseded generation is read-only. To release a superseded *clean* integration checkout, call `subagent_integrate` with `{ id, action: "release", generation, expectedTip }`, using that generation's last staged tip (or integration base when no stage completed). To release a rejected candidate after its worker has terminated and all generations using it are released, pass `{ id, action: "release", generation: <latest generation number, or 1>, taskId, attempt, expectedTip: <candidate tip> }`. Release persists each host/checkout/branch step, verifies ownership and cleanliness (including ignored files), and deletes the branch only if its ref still names the exact tip. Dirty, conflicted, changed, or uncertain resources remain for manual inspection; retry the same release after resolving the obstacle. Released checkouts free a retained slot; abort is available after every retained resource is released. Rejection can invalidate dependent work; if fresh dependent execution is impossible, start a new request instead of forcing 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 consume one. Verification requires passing preliminary checks and any required judgment; it cannot override failed review. Child limits, abort signals, process I/O, status inspection, exact termination, and cleanup retain finite safety bounds.
|
|
153
155
|
|
|
154
156
|
The extension never pushes, opens a pull request, publishes, deploys, force-cleans recoverable work, or silently falls back to Main.
|
|
155
157
|
|
package/dist/herdr-runtime.d.ts
CHANGED
package/dist/herdr-runtime.js
CHANGED
|
@@ -643,9 +643,41 @@ export class HerdrHostRuntime {
|
|
|
643
643
|
return { outcome: "unknown", failure: safeText(error) };
|
|
644
644
|
}
|
|
645
645
|
}
|
|
646
|
+
async proveAbsentAgentForCleanup(allocation, attempt, context) {
|
|
647
|
+
requireIntentIdentity(allocation, attempt);
|
|
648
|
+
assertAgentIntent(allocation, attempt);
|
|
649
|
+
this.assertLeasePath(allocation.leasePath, attempt.correlationToken);
|
|
650
|
+
await this.assertPrivateLeaseDirectories(allocation.leasePath, true);
|
|
651
|
+
await this.assertPrivateLease(allocation.leasePath, true);
|
|
652
|
+
const agents = (await this.listAgents(allocation.worktreeCwd, context)).map((agent) => ({
|
|
653
|
+
name: typeof agent.name === "string" ? agent.name : undefined,
|
|
654
|
+
paneId: exactString(agent.pane_id, "Herdr listed agent pane ID"),
|
|
655
|
+
tabId: exactString(agent.tab_id, "Herdr listed agent tab ID"),
|
|
656
|
+
}));
|
|
657
|
+
if (agents.some((agent) => agent.name === allocation.agentName
|
|
658
|
+
|| agent.paneId === allocation.paneId || agent.tabId === allocation.tabId)) {
|
|
659
|
+
throw new Error("The exact absent-agent allocation now has a matching Herdr agent.");
|
|
660
|
+
}
|
|
661
|
+
const paneIsAbsent = await this.paneAbsent(allocation.paneId, allocation.worktreeCwd, context);
|
|
662
|
+
if (!paneIsAbsent)
|
|
663
|
+
await this.assertStartableAgentPane(allocation, context, { requireExclusiveTty: true });
|
|
664
|
+
if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context, undefined, true)).length) {
|
|
665
|
+
throw new Error("The absent-agent process lease still has holders.");
|
|
666
|
+
}
|
|
667
|
+
await this.delay(50, context.signal);
|
|
668
|
+
if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context, undefined, true)).length) {
|
|
669
|
+
throw new Error("The absent-agent process lease did not remain empty for two consecutive scans.");
|
|
670
|
+
}
|
|
671
|
+
return paneIsAbsent;
|
|
672
|
+
}
|
|
646
673
|
async cleanupHost(input, context) {
|
|
647
|
-
|
|
648
|
-
|
|
674
|
+
const terminationProven = input.attempt.termination?.status === "terminated";
|
|
675
|
+
const agentIntents = input.attempt.allocations.filter((intent) => intent.kind === "agent");
|
|
676
|
+
const latestAgent = agentIntents.at(-1);
|
|
677
|
+
const neverOwned = !input.attempt.termination && latestAgent?.status === "absent"
|
|
678
|
+
&& agentIntents.every((intent) => intent.status === "absent");
|
|
679
|
+
if (!terminationProven && !neverOwned) {
|
|
680
|
+
return { outcome: "blocked", failure: "Host cleanup requires exact worker termination or a definitive absent-agent allocation." };
|
|
649
681
|
}
|
|
650
682
|
try {
|
|
651
683
|
if (input.kind === "worker_tab") {
|
|
@@ -663,8 +695,19 @@ export class HerdrHostRuntime {
|
|
|
663
695
|
if (tabId === allocation.workspaceRootTabId)
|
|
664
696
|
throw new Error("Saved worker tab aliases the workspace root tab.");
|
|
665
697
|
const paneId = exactString(allocation.paneId, "saved worker pane ID");
|
|
666
|
-
|
|
667
|
-
|
|
698
|
+
const paneIsAbsent = neverOwned
|
|
699
|
+
? await this.proveAbsentAgentForCleanup(latestAgent, input.attempt, context)
|
|
700
|
+
: await this.paneAbsent(paneId, allocation.worktreeCwd, context);
|
|
701
|
+
if (!paneIsAbsent) {
|
|
702
|
+
if (!neverOwned)
|
|
703
|
+
return { outcome: "blocked", failure: "The exact saved worker pane still exists after termination." };
|
|
704
|
+
const closed = await this.herdr.exec(["pane", "close", paneId], this.processOptions(allocation.worktreeCwd, context, HERDR_OPERATION_CAP_MS));
|
|
705
|
+
if (closed.code !== 0 || closed.killed)
|
|
706
|
+
return { outcome: "blocked", failure: safeText(herdrCommandFailure(["pane", "close"], closed)) };
|
|
707
|
+
requireOkResponse(closed.stdout, "Herdr pane close response");
|
|
708
|
+
if (!await this.paneAbsent(paneId, allocation.worktreeCwd, context)) {
|
|
709
|
+
return { outcome: "blocked", failure: "The exact unstarted worker pane still exists after close." };
|
|
710
|
+
}
|
|
668
711
|
}
|
|
669
712
|
const workspaceIsAbsent = await this.workspaceAbsent(workspaceId, workspace, context);
|
|
670
713
|
const tab = await this.getTab(tabId, allocation.worktreeCwd, context);
|
|
@@ -686,6 +729,9 @@ export class HerdrHostRuntime {
|
|
|
686
729
|
const workerCleanup = input.attempt.cleanup.find((step) => step.kind === "worker_tab");
|
|
687
730
|
if (workerCleanup?.status !== "completed")
|
|
688
731
|
return { outcome: "blocked", failure: "Workspace cleanup must follow worker-tab reconciliation." };
|
|
732
|
+
if (neverOwned && !await this.proveAbsentAgentForCleanup(latestAgent, input.attempt, context)) {
|
|
733
|
+
return { outcome: "blocked", failure: "The exact unstarted worker pane remains after worker-tab cleanup." };
|
|
734
|
+
}
|
|
689
735
|
const allocation = ownedIntent(input.attempt, "workspace");
|
|
690
736
|
requireIntentIdentity(allocation, input.attempt);
|
|
691
737
|
assertWorkspaceIntent(allocation, input.attempt);
|
package/dist/runner.d.ts
CHANGED
|
@@ -334,6 +334,7 @@ export declare class IsolatedRunner {
|
|
|
334
334
|
private cleanupPromoted;
|
|
335
335
|
private requireExactIntegration;
|
|
336
336
|
abort(id: string, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
|
|
337
|
+
private cleanupAborted;
|
|
337
338
|
status(id: string, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
|
|
338
339
|
private run;
|
|
339
340
|
private dispatchTask;
|
package/dist/runner.js
CHANGED
|
@@ -305,10 +305,17 @@ function stagedDependencySnapshot(state, taskId, tip) {
|
|
|
305
305
|
function textRetryEligible(state, task) {
|
|
306
306
|
const attempt = task.attempts.at(-1);
|
|
307
307
|
return task.status === "needs_attention"
|
|
308
|
-
&& attempt
|
|
308
|
+
&& (!attempt || attempt.status === "failed" || attempt.status === "superseded")
|
|
309
309
|
&& task.attempts.length < 2
|
|
310
310
|
&& taskDependenciesCompleted(state, task.taskId);
|
|
311
311
|
}
|
|
312
|
+
function hasPassingPreliminaryEvidence(request, attempt) {
|
|
313
|
+
return Boolean(attempt.candidate && attempt.candidateBase
|
|
314
|
+
&& checkBatchPasses(attempt.preliminaryChecks, request.checks, attempt.candidate)
|
|
315
|
+
&& (!request.judgment || reviewEvidencePasses(attempt.preliminaryReview, "preliminary", request.judgment.criterion, attempt.candidateBase, attempt.candidate))
|
|
316
|
+
&& !attempt.prompts.some((prompt) => prompt.status === "ambiguous")
|
|
317
|
+
&& allocationByKind(attempt, "agent")?.agentName && !attempt.termination);
|
|
318
|
+
}
|
|
312
319
|
function changesetTaskState(state, id) {
|
|
313
320
|
const task = taskState(state, id);
|
|
314
321
|
if (task.kind !== "changeset")
|
|
@@ -523,10 +530,10 @@ export class IsolatedRunner {
|
|
|
523
530
|
const { states: entries, invalidIds } = await this.store.list(root);
|
|
524
531
|
return { invalidIds, states: entries.map(({ state }) => state), requests: entries.map(({ state }) => ({
|
|
525
532
|
id: state.request.id, name: state.request.goal,
|
|
526
|
-
status: state.status
|
|
533
|
+
status: ["completed", "aborted"].includes(state.status) && (state.tasks.some((task) => task.kind === "changeset" && task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed")))
|
|
527
534
|
|| state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
|
|
528
535
|
|| (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true)))
|
|
529
|
-
?
|
|
536
|
+
? `${state.status} · retained` : state.status,
|
|
530
537
|
tasks: state.request.tasks.map((task) => ({
|
|
531
538
|
id: task.id, name: task.requirements, kind: task.kind,
|
|
532
539
|
status: state.tasks.find((item) => item.taskId === task.id).status,
|
|
@@ -543,7 +550,7 @@ export class IsolatedRunner {
|
|
|
543
550
|
inventory = await this.withProductiveRun(root, async (lifecycle) => await this.store.withLock(root, async () => {
|
|
544
551
|
const listed = await this.listRequests(root);
|
|
545
552
|
for (const request of listed.requests) {
|
|
546
|
-
if (
|
|
553
|
+
if (!["pending", "running", "needs_attention"].includes(request.status))
|
|
547
554
|
continue;
|
|
548
555
|
const handle = await this.store.load(root, request.id);
|
|
549
556
|
if (this.recoverInterrupted(handle.state))
|
|
@@ -560,7 +567,7 @@ export class IsolatedRunner {
|
|
|
560
567
|
}
|
|
561
568
|
const requests = [];
|
|
562
569
|
for (const request of inventory.requests) {
|
|
563
|
-
if (["pending", "running", "needs_attention", "completed · retained"].includes(request.status)) {
|
|
570
|
+
if (["pending", "running", "needs_attention", "completed · retained", "aborted · retained"].includes(request.status)) {
|
|
564
571
|
requests.push(await this.status(request.id, root));
|
|
565
572
|
}
|
|
566
573
|
}
|
|
@@ -754,8 +761,8 @@ export class IsolatedRunner {
|
|
|
754
761
|
throw new Error(`Text task ${task.taskId} cannot be verified.`);
|
|
755
762
|
if (!textRetryEligible(state, task)) {
|
|
756
763
|
const attempt = task.attempts.at(-1);
|
|
757
|
-
if (attempt
|
|
758
|
-
throw new Error(`Text task ${task.taskId} retry requires
|
|
764
|
+
if ((attempt && !["failed", "superseded"].includes(attempt.status)) || task.attempts.length >= 2) {
|
|
765
|
+
throw new Error(`Text task ${task.taskId} retry requires an unstarted, failed or superseded dispatch and fewer than two attempts.`);
|
|
759
766
|
}
|
|
760
767
|
throw new Error(`Text task ${task.taskId} dependencies are not completed.`);
|
|
761
768
|
}
|
|
@@ -1587,7 +1594,7 @@ export class IsolatedRunner {
|
|
|
1587
1594
|
return await this.store.withLock(root, async (lifecycle) => {
|
|
1588
1595
|
const handle = await this.store.load(root, id);
|
|
1589
1596
|
const state = handle.state;
|
|
1590
|
-
if (terminal(state))
|
|
1597
|
+
if (terminal(state) && state.status !== "aborted")
|
|
1591
1598
|
return this.response(state);
|
|
1592
1599
|
if (state.integration.candidates.some((candidate) => candidate.worker !== "released")
|
|
1593
1600
|
|| state.integration.generations.some((generation) => generation.worktree
|
|
@@ -1609,7 +1616,31 @@ export class IsolatedRunner {
|
|
|
1609
1616
|
for (const attempt of task.attempts) {
|
|
1610
1617
|
if (!allocationByKind(attempt, "agent")?.agentName || attempt.termination?.status === "terminated")
|
|
1611
1618
|
continue;
|
|
1612
|
-
|
|
1619
|
+
if (attempt.termination) {
|
|
1620
|
+
const safety = new DeadlineScope(safetyDeadline, () => this.coordinatorRuntime.now(), outerSignal);
|
|
1621
|
+
try {
|
|
1622
|
+
const result = await safety.call((context) => this.hostRuntime.reconcileWorkerTermination({
|
|
1623
|
+
task: changesetTaskRequest(state, task.taskId), attempt,
|
|
1624
|
+
workerId: attempt.termination.workerId, candidate: attempt.termination.candidate,
|
|
1625
|
+
}, context));
|
|
1626
|
+
if (result.outcome !== "terminated")
|
|
1627
|
+
throw new Error(result.outcome === "unknown"
|
|
1628
|
+
? result.failure : "Exact worker remains active; termination was not replayed.");
|
|
1629
|
+
attempt.termination = { ...attempt.termination, status: "terminated", at: this.coordinatorRuntime.now() };
|
|
1630
|
+
delete attempt.termination.failure;
|
|
1631
|
+
}
|
|
1632
|
+
catch (error) {
|
|
1633
|
+
attempt.termination.status = "unknown";
|
|
1634
|
+
attempt.termination.failure = errorText(error);
|
|
1635
|
+
this.attention(task, attempt.termination.failure);
|
|
1636
|
+
}
|
|
1637
|
+
finally {
|
|
1638
|
+
safety.close();
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
else {
|
|
1642
|
+
await this.terminateWithSafety(handle, task, attempt, this.terminationCandidate(attempt), outerSignal, safetyDeadline, true);
|
|
1643
|
+
}
|
|
1613
1644
|
}
|
|
1614
1645
|
}
|
|
1615
1646
|
state.status = "aborted";
|
|
@@ -1617,9 +1648,73 @@ export class IsolatedRunner {
|
|
|
1617
1648
|
state.accepted = false;
|
|
1618
1649
|
state.updatedAt = this.coordinatorRuntime.now();
|
|
1619
1650
|
await handle.save();
|
|
1651
|
+
// In-flight checks or allocation callbacks must settle before removing their checkout.
|
|
1652
|
+
if (!lifecycle.productiveRunLeaseActive)
|
|
1653
|
+
await this.cleanupAborted(handle, outerSignal);
|
|
1620
1654
|
return this.response(state);
|
|
1621
1655
|
}, { purpose: "abort" });
|
|
1622
1656
|
}
|
|
1657
|
+
async cleanupAborted(handle, signal) {
|
|
1658
|
+
const state = handle.state;
|
|
1659
|
+
const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now(), signal);
|
|
1660
|
+
try {
|
|
1661
|
+
for (const task of state.tasks) {
|
|
1662
|
+
if (task.kind !== "changeset")
|
|
1663
|
+
continue;
|
|
1664
|
+
for (const attempt of task.attempts) {
|
|
1665
|
+
if (attempt.cleanup.every((step) => step.status === "completed"))
|
|
1666
|
+
continue;
|
|
1667
|
+
const worker = allocationByKind(attempt, "worktree")?.worktree;
|
|
1668
|
+
const expected = worker ? { ...attempt.waveBase, branch: `refs/heads/${worker.branch}` } : undefined;
|
|
1669
|
+
let pending = attempt.cleanup.find((step) => step.status !== "completed");
|
|
1670
|
+
try {
|
|
1671
|
+
if (attempt.allocations.some((item) => item.status === "allocating" || item.status === "unknown")) {
|
|
1672
|
+
throw new Error("Allocation ownership is uncertain; preserve the recorded resources for inspection.");
|
|
1673
|
+
}
|
|
1674
|
+
if (allocationByKind(attempt, "agent") && attempt.termination?.status !== "terminated") {
|
|
1675
|
+
throw new Error("Exact worker termination remains unproved.");
|
|
1676
|
+
}
|
|
1677
|
+
if (expected && attempt.termination && attempt.termination.candidate.head !== expected.head) {
|
|
1678
|
+
throw new Error("Aborted worker may contain committed work; automatic cleanup preserves it.");
|
|
1679
|
+
}
|
|
1680
|
+
for (const step of attempt.cleanup) {
|
|
1681
|
+
if (step.status === "completed")
|
|
1682
|
+
continue;
|
|
1683
|
+
pending = step;
|
|
1684
|
+
const kind = step.kind;
|
|
1685
|
+
step.status = "running";
|
|
1686
|
+
delete step.failure;
|
|
1687
|
+
await handle.save();
|
|
1688
|
+
if ((kind === "worker_tab" || kind === "workspace") && allocationByKind(attempt, kind)) {
|
|
1689
|
+
const result = await scope.call((context) => this.hostRuntime.cleanupHost({
|
|
1690
|
+
requestId: state.request.id, kind,
|
|
1691
|
+
task: changesetTaskRequest(state, task.taskId), attempt,
|
|
1692
|
+
}, context));
|
|
1693
|
+
if (result.outcome === "blocked")
|
|
1694
|
+
throw new Error(result.failure);
|
|
1695
|
+
}
|
|
1696
|
+
else if ((kind === "worktree" || kind === "branch") && worker && expected) {
|
|
1697
|
+
const result = await scope.call((context) => this.integrationGit.release(state.root, worker, expected, kind, context.signal));
|
|
1698
|
+
if (result.outcome !== "ready")
|
|
1699
|
+
throw new Error(result.failure);
|
|
1700
|
+
}
|
|
1701
|
+
step.status = "completed";
|
|
1702
|
+
await handle.save();
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
catch (error) {
|
|
1706
|
+
if (pending.status === "completed")
|
|
1707
|
+
pending.status = "running";
|
|
1708
|
+
pending.failure = bounded(`Abort cleanup retained resources: ${errorText(error)}`);
|
|
1709
|
+
await handle.save();
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
finally {
|
|
1715
|
+
scope.close();
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1623
1718
|
async status(id, root, outerSignal) {
|
|
1624
1719
|
root = realpathSync.native(root);
|
|
1625
1720
|
return await this.store.withLock(root, async () => {
|
|
@@ -2354,9 +2449,7 @@ export class IsolatedRunner {
|
|
|
2354
2449
|
if (!attempt.readiness || !attempt.candidate || !attempt.candidateBase || !attempt.preliminaryChecks
|
|
2355
2450
|
|| !sameIdentity(attempt.readiness.candidate, attempt.candidate)
|
|
2356
2451
|
|| !sameIdentity(attempt.readiness.base, attempt.candidateBase)
|
|
2357
|
-
|| !
|
|
2358
|
-
|| (request.judgment && !reviewEvidencePasses(attempt.preliminaryReview, "preliminary", request.judgment.criterion, attempt.candidateBase, attempt.candidate))
|
|
2359
|
-
|| !allocationByKind(attempt, "agent")?.agentName || attempt.termination) {
|
|
2452
|
+
|| !hasPassingPreliminaryEvidence(request, attempt)) {
|
|
2360
2453
|
throw new Error(`Task ${task.taskId} has no exact ready live candidate.`);
|
|
2361
2454
|
}
|
|
2362
2455
|
if (state.integration.candidates.some((candidate) => candidate.taskId === task.taskId && !candidate.decision)) {
|
|
@@ -2380,31 +2473,23 @@ export class IsolatedRunner {
|
|
|
2380
2473
|
}, context)), "Retained task candidate identity");
|
|
2381
2474
|
if (!isCleanCommitted(candidate))
|
|
2382
2475
|
throw new Error("Retained task candidate is not clean and committed.");
|
|
2383
|
-
if (attempt.readiness) {
|
|
2384
|
-
if (!sameIdentity(candidate, attempt.readiness.candidate))
|
|
2385
|
-
throw new Error("Ready retained task candidate drifted from its exact lineage.");
|
|
2386
|
-
task.status = "ready_to_integrate";
|
|
2387
|
-
task.failure = undefined;
|
|
2388
|
-
await this.saveProductive(handle);
|
|
2389
|
-
this.retainCandidate(handle.state, task);
|
|
2390
|
-
handle.state.status = "needs_attention";
|
|
2391
|
-
await this.saveProductive(handle);
|
|
2392
|
-
return this.response(handle.state);
|
|
2393
|
-
}
|
|
2394
2476
|
const request = changesetTaskRequest(handle.state, task.taskId);
|
|
2395
2477
|
if (!attempt.candidate || !attempt.candidateBase || !sameIdentity(candidate, attempt.candidate)
|
|
2396
|
-
|| !
|
|
2397
|
-
throw new Error("Retained task work lacks exact passing preliminary
|
|
2478
|
+
|| !hasPassingPreliminaryEvidence(request, attempt)) {
|
|
2479
|
+
throw new Error("Retained task work lacks exact passing preliminary checks, judgment or live ownership.");
|
|
2398
2480
|
}
|
|
2399
|
-
attempt.readiness
|
|
2481
|
+
if (attempt.readiness && (!sameIdentity(candidate, attempt.readiness.candidate)
|
|
2482
|
+
|| !sameIdentity(attempt.candidateBase, attempt.readiness.base))) {
|
|
2483
|
+
throw new Error("Ready retained task candidate drifted from its exact lineage.");
|
|
2484
|
+
}
|
|
2485
|
+
attempt.readiness ??= {
|
|
2400
2486
|
candidate: attempt.candidate,
|
|
2401
2487
|
base: attempt.candidateBase,
|
|
2402
2488
|
at: nextAttemptEventAt(attempt, this.coordinatorRuntime.now()),
|
|
2403
2489
|
};
|
|
2490
|
+
this.retainCandidate(handle.state, task);
|
|
2404
2491
|
task.status = "ready_to_integrate";
|
|
2405
2492
|
task.failure = undefined;
|
|
2406
|
-
await this.saveProductive(handle);
|
|
2407
|
-
this.retainCandidate(handle.state, task);
|
|
2408
2493
|
handle.state.status = "needs_attention";
|
|
2409
2494
|
await this.saveProductive(handle);
|
|
2410
2495
|
return this.response(handle.state);
|
|
@@ -2593,6 +2678,26 @@ export class IsolatedRunner {
|
|
|
2593
2678
|
await this.saveProductive(handle);
|
|
2594
2679
|
}
|
|
2595
2680
|
recoverInterrupted(state) {
|
|
2681
|
+
if (state.status === "needs_attention") {
|
|
2682
|
+
let changed = false;
|
|
2683
|
+
for (const task of state.tasks) {
|
|
2684
|
+
if (task.kind !== "changeset" || task.status !== "ready_to_integrate"
|
|
2685
|
+
|| state.integration.candidates.some((candidate) => candidate.taskId === task.taskId))
|
|
2686
|
+
continue;
|
|
2687
|
+
const attempt = latestAttempt(task);
|
|
2688
|
+
if (hasPassingPreliminaryEvidence(changesetTaskRequest(state, task.taskId), attempt)) {
|
|
2689
|
+
this.attention(task, "Candidate readiness was saved before retention; verify to reconstruct the candidate from exact passing evidence.");
|
|
2690
|
+
changed = true;
|
|
2691
|
+
continue;
|
|
2692
|
+
}
|
|
2693
|
+
delete attempt.readiness;
|
|
2694
|
+
this.attention(task, "Readiness lacked passing preliminary checks, judgment or live ownership; retained work requires attention.");
|
|
2695
|
+
changed = true;
|
|
2696
|
+
}
|
|
2697
|
+
if (changed)
|
|
2698
|
+
state.updatedAt = this.coordinatorRuntime.now();
|
|
2699
|
+
return changed;
|
|
2700
|
+
}
|
|
2596
2701
|
if (state.status === "pending") {
|
|
2597
2702
|
const ready = readyPendingTasks(state).filter((task) => task.attempts.length === 0);
|
|
2598
2703
|
if (!ready.length)
|
|
@@ -2718,11 +2823,6 @@ export class IsolatedRunner {
|
|
|
2718
2823
|
attempt.status = "failed";
|
|
2719
2824
|
attempt.failure = boundedFailure;
|
|
2720
2825
|
}
|
|
2721
|
-
else {
|
|
2722
|
-
if (task.attempts.length >= 2)
|
|
2723
|
-
throw new Error(`Text task ${task.taskId} cannot record another failed attempt.`);
|
|
2724
|
-
task.attempts.push({ number: task.attempts.length + 1, status: "failed", failure: boundedFailure });
|
|
2725
|
-
}
|
|
2726
2826
|
}
|
|
2727
2827
|
task.status = "needs_attention";
|
|
2728
2828
|
task.failure = boundedFailure;
|
|
@@ -2746,12 +2846,15 @@ export class IsolatedRunner {
|
|
|
2746
2846
|
}
|
|
2747
2847
|
else if (attention?.kind === "changeset" && !attention.attempts.at(-1)?.termination) {
|
|
2748
2848
|
const attempt = attention.attempts.at(-1);
|
|
2749
|
-
|
|
2849
|
+
const hasEvidence = Boolean(attempt && !state.integration.candidates.length
|
|
2850
|
+
&& hasPassingPreliminaryEvidence(changesetTaskRequest(state, attention.taskId), attempt));
|
|
2851
|
+
if (attempt?.readiness && hasEvidence) {
|
|
2852
|
+
continuation = { id: state.request.id, action: "verify", taskId: attention.taskId };
|
|
2853
|
+
}
|
|
2854
|
+
else if (attempt && this.correctionAllowed(state, changesetTaskRequest(state, attention.taskId), attempt)) {
|
|
2750
2855
|
continuation = { id: state.request.id, action: "retry", taskId: attention.taskId };
|
|
2751
2856
|
}
|
|
2752
|
-
else if (
|
|
2753
|
-
|| (attempt?.candidate && attempt.candidateBase
|
|
2754
|
-
&& checkBatchPasses(attempt.preliminaryChecks, changesetTaskRequest(state, attention.taskId).checks, attempt.candidate))) {
|
|
2857
|
+
else if (hasEvidence) {
|
|
2755
2858
|
continuation = { id: state.request.id, action: "verify", taskId: attention.taskId };
|
|
2756
2859
|
}
|
|
2757
2860
|
}
|
|
@@ -2763,6 +2866,9 @@ export class IsolatedRunner {
|
|
|
2763
2866
|
text: bounded([
|
|
2764
2867
|
`Pi Subagent ${state.request.id}: ${state.status}.`,
|
|
2765
2868
|
`Tasks: ${completed}/${state.tasks.length} completed, ${rejected} rejected. Accepted: ${state.accepted}.`,
|
|
2869
|
+
...(state.status === "aborted" && state.tasks.some((task) => task.kind === "changeset"
|
|
2870
|
+
&& task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed")))
|
|
2871
|
+
? ["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."] : []),
|
|
2766
2872
|
...(main?.status === "current" ? ["Main: current at the recorded exact identity."] : []),
|
|
2767
2873
|
...(main?.status === "drifted" ? [
|
|
2768
2874
|
`Main: drifted from ${main.expected.branch}@${main.expected.head} to ${main.actual.branch}@${main.actual.head}.`,
|
package/dist/schema.js
CHANGED
|
@@ -653,10 +653,10 @@ function validateTextTaskState(taskState) {
|
|
|
653
653
|
? taskState.attempts.length ? "superseded" : undefined
|
|
654
654
|
: taskState.status === "running"
|
|
655
655
|
? "running"
|
|
656
|
-
:
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
656
|
+
: "completed";
|
|
657
|
+
if (taskState.status === "needs_attention"
|
|
658
|
+
? latest && !["failed", "superseded"].includes(latest.status)
|
|
659
|
+
: latest?.status !== expectedLatestStatus) {
|
|
660
660
|
throw new Error(`Text task ${taskState.taskId} status ${taskState.status} has an incompatible latest attempt.`);
|
|
661
661
|
}
|
|
662
662
|
if ((taskState.failure !== undefined) !== (taskState.status === "needs_attention")) {
|
|
@@ -665,8 +665,8 @@ function validateTextTaskState(taskState) {
|
|
|
665
665
|
if (taskState.failure !== undefined) {
|
|
666
666
|
requireRuntimeTextByteLength(taskState.failure, `Text task ${taskState.taskId} failure`);
|
|
667
667
|
}
|
|
668
|
-
if (taskState.status === "needs_attention" && latest?.
|
|
669
|
-
throw new Error(`Text task ${taskState.taskId}
|
|
668
|
+
if (taskState.status === "needs_attention" && latest?.status === "failed" && latest.failure === undefined) {
|
|
669
|
+
throw new Error(`Text task ${taskState.taskId} lacks its latest execution failure.`);
|
|
670
670
|
}
|
|
671
671
|
}
|
|
672
672
|
/** Check the persisted integration projection against the request before recovery or promotion. */
|
package/docs/orchestration.md
CHANGED
|
@@ -88,7 +88,7 @@ Every task declares `id`, `kind`, `role`, `modelClass`, `requirements`, `deliver
|
|
|
88
88
|
- A `text` task runs in a disposable child worktree and must leave it unchanged.
|
|
89
89
|
- A `changeset` task requires focused checks and owns one worktree, Herdr workspace, worker tab, and retained worker.
|
|
90
90
|
- `dependsOn` controls ready waves; dependent tasks can run from a selected staged snapshot only after Main explicitly advances it.
|
|
91
|
-
- `contextFrom` may reference completed text tasks only and preserves declaration order.
|
|
91
|
+
- `contextFrom` may reference completed text tasks only, implies scheduling dependencies, and preserves declaration order. It must not overlap `dependsOn`.
|
|
92
92
|
- A graph containing changesets requires final checks. The repository root must have a `package.json` test script; admission prepends canonical `pnpm test` if absent from final checks. Combined validation rejects a changed root test script.
|
|
93
93
|
|
|
94
94
|
Admission and worker-wave dispatch pin Main's exact committed branch, HEAD, and tree without inspecting or modifying its staged, unstaged, or untracked edits. Worker allocation verifies the same committed identity around worktree creation, including resumed waves; staged-dependent waves verify their clean integration snapshot. A dirty Main can therefore produce a checked retained candidate, but branch/HEAD drift stops new waves. The runner executes ready worker tasks concurrently, inspects clean committed candidates, runs preliminary task checks and optional preliminary judgment, then records ready candidates for Main. **It does not automatically integrate them.** Main selects exact candidates via `subagent_stage` into an owned integration checkout in explicit order. Each stage is a committed merge of the candidate, not a mutation of Main. Conflicts retain the checkout for Main to resolve; `resolve` verifies the exact two-parent merge instead of replaying an uncertain merge. Main can explicitly run dependent tasks from an exact staged tip with `subagent_integrate advance` and stage their results afterward. Independent candidates may be staged without re-running workers.
|
|
@@ -118,7 +118,9 @@ State version 5 is stored privately under:
|
|
|
118
118
|
<agent-dir>/config/pi-subagent/state/<repository-hash>/<request-id>.json
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
-
Strict parsing rejects unknown properties, malformed evidence, invalid lineage, and old v4 state without migration; preserve old files and recover their work manually. Writes are atomic and repository productive work uses a durable process lease. `delegate_task` and `subagent_resume` acknowledge only after durable state is saved, then continue productive work asynchronously. A durably recorded worker attention state sends an immediate once-per-transition Pi follow-up to the launching session, even while siblings run; the wave also reports its final outcome. Isolated workers persist a private exact Pi session under the owned process lease so a proved final answer without a changed clean commit becomes attention rather than an indefinitely pending prompt. Missing or mismatched session proof cannot establish completion and never authorizes prompt replay. `subagent_status` is read-only and remains the recovery authority if a follow-up is missed. `subagent_resume` accepts only the continuation reported by state. `subagent_abort` performs exact worker termination and teardown.
|
|
121
|
+
Strict parsing rejects unknown properties, malformed evidence, invalid lineage, and old v4 state without migration; preserve old files and recover their work manually. Writes are atomic and repository productive work uses a durable process lease. `delegate_task` and `subagent_resume` acknowledge only after durable state is saved, then continue productive work asynchronously. A durably recorded worker attention state sends an immediate once-per-transition Pi follow-up to the launching session, even while siblings run; the wave also reports its final outcome. Isolated workers persist a private exact Pi session under the owned process lease so a proved final answer without a changed clean commit becomes attention rather than an indefinitely pending prompt. Missing or mismatched session proof cannot establish completion and never authorizes prompt replay. `subagent_status` is read-only and remains the recovery authority if a follow-up is missed. `subagent_resume` accepts only the continuation reported by state. `subagent_abort` performs exact worker termination and conservative teardown. Without a retained candidate, it removes only owned checkouts still clean and unchanged from their pinned base, including ignored-file checks. Active productive execution defers cleanup until it settles. Repeating abort on an aborted request reconciles pending cleanup without replaying uncertain termination. Dirty or committed work and uncertain allocations stay retained with visible blockers; `/subagent recover` includes aborted requests with retained resources.
|
|
122
|
+
|
|
123
|
+
Text attempt history records dispatches, not pre-dispatch inspection failures. Such failures retain task-level attention without adding an attempt or rewriting earlier execution diagnostics. A recorded interrupted dispatch still consumes an attempt. Verification requires the same passing preliminary checks, judgment, and ownership as candidate retention; readiness and the verified candidate are saved together.
|
|
122
124
|
|
|
123
125
|
Productive execution has no whole-run wall-clock deadline. Long productive work and later resumes remain valid. The request retains its original policy snapshot and correction count; a resume cannot refill them. Current configuration may tighten the correction allowance. Child idle/hard runtime, subprocess I/O, status inspection, termination, cleanup, and outer abort remain bounded independently.
|
|
124
126
|
|
package/extensions/isolated.ts
CHANGED
|
@@ -659,7 +659,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
659
659
|
pi.registerTool({
|
|
660
660
|
name: "subagent_abort",
|
|
661
661
|
label: "Subagent abort",
|
|
662
|
-
description: "Abort
|
|
662
|
+
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.",
|
|
663
663
|
parameters: IdOnlySchema,
|
|
664
664
|
prepareArguments: parseIdOnly,
|
|
665
665
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
package/package.json
CHANGED
|
@@ -26,12 +26,12 @@ Use one compact `role`/`name`/`task` packet, `tasks` for independent packets, or
|
|
|
26
26
|
|
|
27
27
|
# Isolated requests
|
|
28
28
|
|
|
29
|
-
Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks. The root must have a `package.json` test script; `pnpm test` is added to final checks if missing and runs against the combined tip before Main promotion. Text context may name only completed text tasks
|
|
29
|
+
Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks. The root must have a `package.json` test script; `pnpm test` is added to final checks if missing and runs against the combined tip before Main promotion. Text context may name only completed text tasks, preserves declared order, and implies a scheduling dependency; do not repeat an edge in `dependsOn` and `contextFrom`.
|
|
30
30
|
|
|
31
31
|
Every changeset advances automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for sibling workers. There is no guaranteed post-completion editing window, and late follow-ups fail visibly.
|
|
32
32
|
|
|
33
33
|
A ready candidate does not integrate automatically. Each checked worker triggers a follow-up while its siblings continue; inspect `subagent_status` and immediately use `subagent_stage` with the exact `generation`, `taskId`, `attempt`, candidate `tip`, and `expectedTip` to select and merge it into the owned integration checkout. Stage and resolve are available during the active wave; wait for the wave to settle before rejecting or revising. Resolve conflicts in the integration checkout and verify with `resolve`; never edit Main to resolve them. For dependents, `subagent_integrate advance` acknowledges a durable background wave, then reports checked candidates as they finish. When selected work is staged and the wave settles, use `subagent_integrate validate` on the exact `combinedTip` for full root checks and optional final judgment; `promote` requires passing evidence and unchanged Main. One Main-authored committed correction to a definitively failed combined validation can be recorded with `correct`, then revalidated. Only proven promotion triggers selected-worker termination and verified cleanup. Failures, ambiguity, conflicts, interruption, child-limit exhaustion, or Main drift retain work and never waive checks or identity guards. The extension never pushes, opens a pull request, publishes, or deploys.
|
|
34
34
|
|
|
35
|
-
After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once: it acquires the productive lease, classifies orphaned interruptions, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations;
|
|
35
|
+
After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once: it acquires the productive lease, classifies orphaned interruptions, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations; release them first. For workers without candidates, abort cleans only proven unchanged, clean owned resources. If cleanup is deferred or blocked, inspect status and repeat abort after resolving the blocker. Dirty or committed work and uncertain allocations remain for manual recovery. Old v4 state is rejected (current state is v5), not migrated. Status, abort, process I/O, termination, and verified post-promotion cleanup keep finite safety budgets.
|
|
36
36
|
|
|
37
37
|
All launches use the global policy in `config/pi-subagent/config.json`: productive concurrency, per-child turns/tokens, and idle timeout. Ephemeral children also have a hard runtime limit; isolated requests have a correction cap. Productive requests have no whole-run wall-clock deadline. Request fields cannot replenish or override limits.
|