@henryqw/pi-subagent 23.0.1 → 24.0.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 +1 -1
- package/README.md +6 -4
- package/dist/herdr-runtime.d.ts +1 -0
- package/dist/herdr-runtime.js +50 -4
- package/dist/index.d.ts +5 -6
- package/dist/index.js +37 -59
- package/dist/launch-runtime.d.ts +0 -2
- package/dist/launch-runtime.js +2 -9
- package/dist/mcp-role.d.ts +0 -1
- package/dist/mcp-role.js +0 -11
- package/dist/runner.d.ts +1 -0
- package/dist/runner.js +152 -53
- package/dist/schema.d.ts +9 -8
- package/dist/schema.js +6 -6
- package/dist/worktree.d.ts +0 -2
- package/dist/worktree.js +0 -4
- package/docs/orchestration.md +4 -2
- package/extensions/admission.ts +23 -31
- package/extensions/config.ts +2 -8
- package/extensions/isolated.ts +1 -2
- package/extensions/result-transport.ts +15 -32
- package/extensions/role-mcp.ts +3 -7
- package/extensions/role-tools.ts +2 -8
- package/extensions/subagent-command.ts +5 -5
- package/extensions/subagent.ts +14 -25
- package/extensions/workflow.ts +0 -1
- package/package.json +1 -1
- package/skills/pi-subagent/SKILL.md +2 -2
- package/dist/working-evidence.d.ts +0 -25
- package/dist/working-evidence.js +0 -261
package/dist/runner.js
CHANGED
|
@@ -69,7 +69,7 @@ class DurableRunStopped extends Error {
|
|
|
69
69
|
}
|
|
70
70
|
class ProductiveScope {
|
|
71
71
|
signal;
|
|
72
|
-
constructor(
|
|
72
|
+
constructor(outerSignal) {
|
|
73
73
|
this.signal = outerSignal ?? new AbortController().signal;
|
|
74
74
|
}
|
|
75
75
|
async call(operation) {
|
|
@@ -150,10 +150,6 @@ class FollowupControl {
|
|
|
150
150
|
this.sealed = true;
|
|
151
151
|
return undefined;
|
|
152
152
|
}
|
|
153
|
-
invalidate() {
|
|
154
|
-
this.sealed = true;
|
|
155
|
-
this.queue.length = 0;
|
|
156
|
-
}
|
|
157
153
|
close() {
|
|
158
154
|
this.sealed = true;
|
|
159
155
|
this.queue.length = 0;
|
|
@@ -305,10 +301,17 @@ function stagedDependencySnapshot(state, taskId, tip) {
|
|
|
305
301
|
function textRetryEligible(state, task) {
|
|
306
302
|
const attempt = task.attempts.at(-1);
|
|
307
303
|
return task.status === "needs_attention"
|
|
308
|
-
&& attempt
|
|
304
|
+
&& (!attempt || attempt.status === "failed" || attempt.status === "superseded")
|
|
309
305
|
&& task.attempts.length < 2
|
|
310
306
|
&& taskDependenciesCompleted(state, task.taskId);
|
|
311
307
|
}
|
|
308
|
+
function hasPassingPreliminaryEvidence(request, attempt) {
|
|
309
|
+
return Boolean(attempt.candidate && attempt.candidateBase
|
|
310
|
+
&& checkBatchPasses(attempt.preliminaryChecks, request.checks, attempt.candidate)
|
|
311
|
+
&& (!request.judgment || reviewEvidencePasses(attempt.preliminaryReview, "preliminary", request.judgment.criterion, attempt.candidateBase, attempt.candidate))
|
|
312
|
+
&& !attempt.prompts.some((prompt) => prompt.status === "ambiguous")
|
|
313
|
+
&& allocationByKind(attempt, "agent")?.agentName && !attempt.termination);
|
|
314
|
+
}
|
|
312
315
|
function changesetTaskState(state, id) {
|
|
313
316
|
const task = taskState(state, id);
|
|
314
317
|
if (task.kind !== "changeset")
|
|
@@ -523,10 +526,10 @@ export class IsolatedRunner {
|
|
|
523
526
|
const { states: entries, invalidIds } = await this.store.list(root);
|
|
524
527
|
return { invalidIds, states: entries.map(({ state }) => state), requests: entries.map(({ state }) => ({
|
|
525
528
|
id: state.request.id, name: state.request.goal,
|
|
526
|
-
status: state.status
|
|
529
|
+
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
530
|
|| state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")
|
|
528
531
|
|| (generation.worktree && generation.cleanup?.every((step) => step.status === "completed") !== true)))
|
|
529
|
-
?
|
|
532
|
+
? `${state.status} · retained` : state.status,
|
|
530
533
|
tasks: state.request.tasks.map((task) => ({
|
|
531
534
|
id: task.id, name: task.requirements, kind: task.kind,
|
|
532
535
|
status: state.tasks.find((item) => item.taskId === task.id).status,
|
|
@@ -543,7 +546,7 @@ export class IsolatedRunner {
|
|
|
543
546
|
inventory = await this.withProductiveRun(root, async (lifecycle) => await this.store.withLock(root, async () => {
|
|
544
547
|
const listed = await this.listRequests(root);
|
|
545
548
|
for (const request of listed.requests) {
|
|
546
|
-
if (
|
|
549
|
+
if (!["pending", "running", "needs_attention"].includes(request.status))
|
|
547
550
|
continue;
|
|
548
551
|
const handle = await this.store.load(root, request.id);
|
|
549
552
|
if (this.recoverInterrupted(handle.state))
|
|
@@ -560,7 +563,7 @@ export class IsolatedRunner {
|
|
|
560
563
|
}
|
|
561
564
|
const requests = [];
|
|
562
565
|
for (const request of inventory.requests) {
|
|
563
|
-
if (["pending", "running", "needs_attention", "completed · retained"].includes(request.status)) {
|
|
566
|
+
if (["pending", "running", "needs_attention", "completed · retained", "aborted · retained"].includes(request.status)) {
|
|
564
567
|
requests.push(await this.status(request.id, root));
|
|
565
568
|
}
|
|
566
569
|
}
|
|
@@ -655,7 +658,7 @@ export class IsolatedRunner {
|
|
|
655
658
|
async execute(value, cwd, outerSignal) {
|
|
656
659
|
const request = parseExecuteRequest(value);
|
|
657
660
|
const policy = Object.freeze({ ...this.currentPolicy() });
|
|
658
|
-
const scope = new ProductiveScope(
|
|
661
|
+
const scope = new ProductiveScope(outerSignal);
|
|
659
662
|
const canonicalCwd = realpathSync.native(cwd);
|
|
660
663
|
const prepared = await scope.call(async (context) => await this.coordinatorRuntime.preflight({ request, cwd: canonicalCwd }, context));
|
|
661
664
|
const root = realpathSync.native(prepared.root);
|
|
@@ -741,8 +744,7 @@ export class IsolatedRunner {
|
|
|
741
744
|
return loaded;
|
|
742
745
|
}, { productiveRunLease: lifecycle.lease });
|
|
743
746
|
const state = handle.state;
|
|
744
|
-
const
|
|
745
|
-
const scope = new ProductiveScope(Math.min(state.policy.childMaxMs, current.childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
|
|
747
|
+
const scope = new ProductiveScope(outerSignal);
|
|
746
748
|
try {
|
|
747
749
|
if (request.action === "finalize")
|
|
748
750
|
return await this.finalize(handle, scope);
|
|
@@ -754,8 +756,8 @@ export class IsolatedRunner {
|
|
|
754
756
|
throw new Error(`Text task ${task.taskId} cannot be verified.`);
|
|
755
757
|
if (!textRetryEligible(state, task)) {
|
|
756
758
|
const attempt = task.attempts.at(-1);
|
|
757
|
-
if (attempt
|
|
758
|
-
throw new Error(`Text task ${task.taskId} retry requires
|
|
759
|
+
if ((attempt && !["failed", "superseded"].includes(attempt.status)) || task.attempts.length >= 2) {
|
|
760
|
+
throw new Error(`Text task ${task.taskId} retry requires an unstarted, failed or superseded dispatch and fewer than two attempts.`);
|
|
759
761
|
}
|
|
760
762
|
throw new Error(`Text task ${task.taskId} dependencies are not completed.`);
|
|
761
763
|
}
|
|
@@ -775,8 +777,6 @@ export class IsolatedRunner {
|
|
|
775
777
|
throw error;
|
|
776
778
|
}
|
|
777
779
|
finally {
|
|
778
|
-
if (scope instanceof DeadlineScope)
|
|
779
|
-
scope.close();
|
|
780
780
|
this.closeRequestControls(root, request.id);
|
|
781
781
|
if (!lifecycle.stopped) {
|
|
782
782
|
delete state.recovery;
|
|
@@ -813,7 +813,7 @@ export class IsolatedRunner {
|
|
|
813
813
|
? generation.combinedTip ?? generation.stages.at(-1)?.tip ?? generation.integrationBase : state.main;
|
|
814
814
|
if (!sameIdentity(action.expectedTip, expected))
|
|
815
815
|
throw new Error("Rejection recovery has a stale integration tip.");
|
|
816
|
-
const scope = new ProductiveScope(
|
|
816
|
+
const scope = new ProductiveScope(outerSignal);
|
|
817
817
|
const termination = attempt.termination;
|
|
818
818
|
const result = await scope.call((context) => this.hostRuntime.reconcileWorkerTermination({
|
|
819
819
|
task: changesetTaskRequest(state, task.taskId), attempt, workerId: termination.workerId,
|
|
@@ -834,7 +834,7 @@ export class IsolatedRunner {
|
|
|
834
834
|
|| !sameIdentity(attempt.readiness.candidate, action.candidate)) {
|
|
835
835
|
throw new Error("Stage action refers to a stale or unowned candidate.");
|
|
836
836
|
}
|
|
837
|
-
const scope = new ProductiveScope(
|
|
837
|
+
const scope = new ProductiveScope(outerSignal);
|
|
838
838
|
const generations = state.integration.generations;
|
|
839
839
|
let generation = generations.at(-1);
|
|
840
840
|
const nextNumber = generation?.status === "superseded" ? generation.number + 1 : generation?.number ?? 1;
|
|
@@ -1093,7 +1093,7 @@ export class IsolatedRunner {
|
|
|
1093
1093
|
const request = parseExecuteRequest(state.request);
|
|
1094
1094
|
const generation = state.integration.generations.at(-1);
|
|
1095
1095
|
if (action.action === "release") {
|
|
1096
|
-
const scope = new ProductiveScope(
|
|
1096
|
+
const scope = new ProductiveScope(outerSignal);
|
|
1097
1097
|
if (action.taskId !== undefined || action.attempt !== undefined) {
|
|
1098
1098
|
if (!action.taskId || !action.attempt || action.generation !== (generation?.number ?? 1)) {
|
|
1099
1099
|
throw new Error("Candidate release requires the latest generation and exact task attempt.");
|
|
@@ -1142,7 +1142,7 @@ export class IsolatedRunner {
|
|
|
1142
1142
|
if (state.integration.generations.filter((item) => item.worktree && !item.cleanup?.every((step) => step.status === "completed")).length >= MAX_RETAINED_INTEGRATION_GENERATIONS) {
|
|
1143
1143
|
throw new Error("Retained integration worktree limit is exhausted.");
|
|
1144
1144
|
}
|
|
1145
|
-
const scope = new ProductiveScope(
|
|
1145
|
+
const scope = new ProductiveScope(outerSignal);
|
|
1146
1146
|
const actualMain = await scope.call((context) => this.gitRuntime.inspectMain({ root }, context));
|
|
1147
1147
|
if (!isCleanCommitted(action.newMain) || !sameIdentity(actualMain, action.newMain)) {
|
|
1148
1148
|
throw new Error("Refresh requires the exact new clean Main identity.");
|
|
@@ -1226,7 +1226,7 @@ export class IsolatedRunner {
|
|
|
1226
1226
|
|| (terminal(state) && !(state.status === "completed" && action.action === "cleanup"))
|
|
1227
1227
|
|| (state.status !== "needs_attention" && !(state.status === "completed" && action.action === "cleanup")))
|
|
1228
1228
|
throw new Error("Integration action has a stale generation or combined tip.");
|
|
1229
|
-
const scope = new ProductiveScope(
|
|
1229
|
+
const scope = new ProductiveScope(outerSignal);
|
|
1230
1230
|
const receipts = this.stageReceipts(generation);
|
|
1231
1231
|
if (action.action === "advance") {
|
|
1232
1232
|
if (generation.status !== "staging" || generation.stages.some((stage) => stage.status !== "staged")
|
|
@@ -1587,7 +1587,7 @@ export class IsolatedRunner {
|
|
|
1587
1587
|
return await this.store.withLock(root, async (lifecycle) => {
|
|
1588
1588
|
const handle = await this.store.load(root, id);
|
|
1589
1589
|
const state = handle.state;
|
|
1590
|
-
if (terminal(state))
|
|
1590
|
+
if (terminal(state) && state.status !== "aborted")
|
|
1591
1591
|
return this.response(state);
|
|
1592
1592
|
if (state.integration.candidates.some((candidate) => candidate.worker !== "released")
|
|
1593
1593
|
|| state.integration.generations.some((generation) => generation.worktree
|
|
@@ -1596,7 +1596,7 @@ export class IsolatedRunner {
|
|
|
1596
1596
|
}
|
|
1597
1597
|
const activeControls = this.activeControls(root, id);
|
|
1598
1598
|
for (const { control } of activeControls)
|
|
1599
|
-
control.
|
|
1599
|
+
control.close();
|
|
1600
1600
|
if (!activeControls.length
|
|
1601
1601
|
&& !lifecycle.productiveRunLeaseActive
|
|
1602
1602
|
&& this.recoverInterrupted(state)) {
|
|
@@ -1609,7 +1609,31 @@ export class IsolatedRunner {
|
|
|
1609
1609
|
for (const attempt of task.attempts) {
|
|
1610
1610
|
if (!allocationByKind(attempt, "agent")?.agentName || attempt.termination?.status === "terminated")
|
|
1611
1611
|
continue;
|
|
1612
|
-
|
|
1612
|
+
if (attempt.termination) {
|
|
1613
|
+
const safety = new DeadlineScope(safetyDeadline, () => this.coordinatorRuntime.now(), outerSignal);
|
|
1614
|
+
try {
|
|
1615
|
+
const result = await safety.call((context) => this.hostRuntime.reconcileWorkerTermination({
|
|
1616
|
+
task: changesetTaskRequest(state, task.taskId), attempt,
|
|
1617
|
+
workerId: attempt.termination.workerId, candidate: attempt.termination.candidate,
|
|
1618
|
+
}, context));
|
|
1619
|
+
if (result.outcome !== "terminated")
|
|
1620
|
+
throw new Error(result.outcome === "unknown"
|
|
1621
|
+
? result.failure : "Exact worker remains active; termination was not replayed.");
|
|
1622
|
+
attempt.termination = { ...attempt.termination, status: "terminated", at: this.coordinatorRuntime.now() };
|
|
1623
|
+
delete attempt.termination.failure;
|
|
1624
|
+
}
|
|
1625
|
+
catch (error) {
|
|
1626
|
+
attempt.termination.status = "unknown";
|
|
1627
|
+
attempt.termination.failure = errorText(error);
|
|
1628
|
+
this.attention(task, attempt.termination.failure);
|
|
1629
|
+
}
|
|
1630
|
+
finally {
|
|
1631
|
+
safety.close();
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
else {
|
|
1635
|
+
await this.terminateWithSafety(handle, task, attempt, this.terminationCandidate(attempt), outerSignal, safetyDeadline, true);
|
|
1636
|
+
}
|
|
1613
1637
|
}
|
|
1614
1638
|
}
|
|
1615
1639
|
state.status = "aborted";
|
|
@@ -1617,9 +1641,73 @@ export class IsolatedRunner {
|
|
|
1617
1641
|
state.accepted = false;
|
|
1618
1642
|
state.updatedAt = this.coordinatorRuntime.now();
|
|
1619
1643
|
await handle.save();
|
|
1644
|
+
// In-flight checks or allocation callbacks must settle before removing their checkout.
|
|
1645
|
+
if (!lifecycle.productiveRunLeaseActive)
|
|
1646
|
+
await this.cleanupAborted(handle, outerSignal);
|
|
1620
1647
|
return this.response(state);
|
|
1621
1648
|
}, { purpose: "abort" });
|
|
1622
1649
|
}
|
|
1650
|
+
async cleanupAborted(handle, signal) {
|
|
1651
|
+
const state = handle.state;
|
|
1652
|
+
const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now(), signal);
|
|
1653
|
+
try {
|
|
1654
|
+
for (const task of state.tasks) {
|
|
1655
|
+
if (task.kind !== "changeset")
|
|
1656
|
+
continue;
|
|
1657
|
+
for (const attempt of task.attempts) {
|
|
1658
|
+
if (attempt.cleanup.every((step) => step.status === "completed"))
|
|
1659
|
+
continue;
|
|
1660
|
+
const worker = allocationByKind(attempt, "worktree")?.worktree;
|
|
1661
|
+
const expected = worker ? { ...attempt.waveBase, branch: `refs/heads/${worker.branch}` } : undefined;
|
|
1662
|
+
let pending = attempt.cleanup.find((step) => step.status !== "completed");
|
|
1663
|
+
try {
|
|
1664
|
+
if (attempt.allocations.some((item) => item.status === "allocating" || item.status === "unknown")) {
|
|
1665
|
+
throw new Error("Allocation ownership is uncertain; preserve the recorded resources for inspection.");
|
|
1666
|
+
}
|
|
1667
|
+
if (allocationByKind(attempt, "agent") && attempt.termination?.status !== "terminated") {
|
|
1668
|
+
throw new Error("Exact worker termination remains unproved.");
|
|
1669
|
+
}
|
|
1670
|
+
if (expected && attempt.termination && attempt.termination.candidate.head !== expected.head) {
|
|
1671
|
+
throw new Error("Aborted worker may contain committed work; automatic cleanup preserves it.");
|
|
1672
|
+
}
|
|
1673
|
+
for (const step of attempt.cleanup) {
|
|
1674
|
+
if (step.status === "completed")
|
|
1675
|
+
continue;
|
|
1676
|
+
pending = step;
|
|
1677
|
+
const kind = step.kind;
|
|
1678
|
+
step.status = "running";
|
|
1679
|
+
delete step.failure;
|
|
1680
|
+
await handle.save();
|
|
1681
|
+
if ((kind === "worker_tab" || kind === "workspace") && allocationByKind(attempt, kind)) {
|
|
1682
|
+
const result = await scope.call((context) => this.hostRuntime.cleanupHost({
|
|
1683
|
+
requestId: state.request.id, kind,
|
|
1684
|
+
task: changesetTaskRequest(state, task.taskId), attempt,
|
|
1685
|
+
}, context));
|
|
1686
|
+
if (result.outcome === "blocked")
|
|
1687
|
+
throw new Error(result.failure);
|
|
1688
|
+
}
|
|
1689
|
+
else if ((kind === "worktree" || kind === "branch") && worker && expected) {
|
|
1690
|
+
const result = await scope.call((context) => this.integrationGit.release(state.root, worker, expected, kind, context.signal));
|
|
1691
|
+
if (result.outcome !== "ready")
|
|
1692
|
+
throw new Error(result.failure);
|
|
1693
|
+
}
|
|
1694
|
+
step.status = "completed";
|
|
1695
|
+
await handle.save();
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
catch (error) {
|
|
1699
|
+
if (pending.status === "completed")
|
|
1700
|
+
pending.status = "running";
|
|
1701
|
+
pending.failure = bounded(`Abort cleanup retained resources: ${errorText(error)}`);
|
|
1702
|
+
await handle.save();
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
finally {
|
|
1708
|
+
scope.close();
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1623
1711
|
async status(id, root, outerSignal) {
|
|
1624
1712
|
root = realpathSync.native(root);
|
|
1625
1713
|
return await this.store.withLock(root, async () => {
|
|
@@ -2354,9 +2442,7 @@ export class IsolatedRunner {
|
|
|
2354
2442
|
if (!attempt.readiness || !attempt.candidate || !attempt.candidateBase || !attempt.preliminaryChecks
|
|
2355
2443
|
|| !sameIdentity(attempt.readiness.candidate, attempt.candidate)
|
|
2356
2444
|
|| !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) {
|
|
2445
|
+
|| !hasPassingPreliminaryEvidence(request, attempt)) {
|
|
2360
2446
|
throw new Error(`Task ${task.taskId} has no exact ready live candidate.`);
|
|
2361
2447
|
}
|
|
2362
2448
|
if (state.integration.candidates.some((candidate) => candidate.taskId === task.taskId && !candidate.decision)) {
|
|
@@ -2380,31 +2466,23 @@ export class IsolatedRunner {
|
|
|
2380
2466
|
}, context)), "Retained task candidate identity");
|
|
2381
2467
|
if (!isCleanCommitted(candidate))
|
|
2382
2468
|
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
2469
|
const request = changesetTaskRequest(handle.state, task.taskId);
|
|
2395
2470
|
if (!attempt.candidate || !attempt.candidateBase || !sameIdentity(candidate, attempt.candidate)
|
|
2396
|
-
|| !
|
|
2397
|
-
throw new Error("Retained task work lacks exact passing preliminary
|
|
2471
|
+
|| !hasPassingPreliminaryEvidence(request, attempt)) {
|
|
2472
|
+
throw new Error("Retained task work lacks exact passing preliminary checks, judgment or live ownership.");
|
|
2398
2473
|
}
|
|
2399
|
-
attempt.readiness
|
|
2474
|
+
if (attempt.readiness && (!sameIdentity(candidate, attempt.readiness.candidate)
|
|
2475
|
+
|| !sameIdentity(attempt.candidateBase, attempt.readiness.base))) {
|
|
2476
|
+
throw new Error("Ready retained task candidate drifted from its exact lineage.");
|
|
2477
|
+
}
|
|
2478
|
+
attempt.readiness ??= {
|
|
2400
2479
|
candidate: attempt.candidate,
|
|
2401
2480
|
base: attempt.candidateBase,
|
|
2402
2481
|
at: nextAttemptEventAt(attempt, this.coordinatorRuntime.now()),
|
|
2403
2482
|
};
|
|
2483
|
+
this.retainCandidate(handle.state, task);
|
|
2404
2484
|
task.status = "ready_to_integrate";
|
|
2405
2485
|
task.failure = undefined;
|
|
2406
|
-
await this.saveProductive(handle);
|
|
2407
|
-
this.retainCandidate(handle.state, task);
|
|
2408
2486
|
handle.state.status = "needs_attention";
|
|
2409
2487
|
await this.saveProductive(handle);
|
|
2410
2488
|
return this.response(handle.state);
|
|
@@ -2593,6 +2671,26 @@ export class IsolatedRunner {
|
|
|
2593
2671
|
await this.saveProductive(handle);
|
|
2594
2672
|
}
|
|
2595
2673
|
recoverInterrupted(state) {
|
|
2674
|
+
if (state.status === "needs_attention") {
|
|
2675
|
+
let changed = false;
|
|
2676
|
+
for (const task of state.tasks) {
|
|
2677
|
+
if (task.kind !== "changeset" || task.status !== "ready_to_integrate"
|
|
2678
|
+
|| state.integration.candidates.some((candidate) => candidate.taskId === task.taskId))
|
|
2679
|
+
continue;
|
|
2680
|
+
const attempt = latestAttempt(task);
|
|
2681
|
+
if (hasPassingPreliminaryEvidence(changesetTaskRequest(state, task.taskId), attempt)) {
|
|
2682
|
+
this.attention(task, "Candidate readiness was saved before retention; verify to reconstruct the candidate from exact passing evidence.");
|
|
2683
|
+
changed = true;
|
|
2684
|
+
continue;
|
|
2685
|
+
}
|
|
2686
|
+
delete attempt.readiness;
|
|
2687
|
+
this.attention(task, "Readiness lacked passing preliminary checks, judgment or live ownership; retained work requires attention.");
|
|
2688
|
+
changed = true;
|
|
2689
|
+
}
|
|
2690
|
+
if (changed)
|
|
2691
|
+
state.updatedAt = this.coordinatorRuntime.now();
|
|
2692
|
+
return changed;
|
|
2693
|
+
}
|
|
2596
2694
|
if (state.status === "pending") {
|
|
2597
2695
|
const ready = readyPendingTasks(state).filter((task) => task.attempts.length === 0);
|
|
2598
2696
|
if (!ready.length)
|
|
@@ -2718,11 +2816,6 @@ export class IsolatedRunner {
|
|
|
2718
2816
|
attempt.status = "failed";
|
|
2719
2817
|
attempt.failure = boundedFailure;
|
|
2720
2818
|
}
|
|
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
2819
|
}
|
|
2727
2820
|
task.status = "needs_attention";
|
|
2728
2821
|
task.failure = boundedFailure;
|
|
@@ -2746,12 +2839,15 @@ export class IsolatedRunner {
|
|
|
2746
2839
|
}
|
|
2747
2840
|
else if (attention?.kind === "changeset" && !attention.attempts.at(-1)?.termination) {
|
|
2748
2841
|
const attempt = attention.attempts.at(-1);
|
|
2749
|
-
|
|
2842
|
+
const hasEvidence = Boolean(attempt && !state.integration.candidates.length
|
|
2843
|
+
&& hasPassingPreliminaryEvidence(changesetTaskRequest(state, attention.taskId), attempt));
|
|
2844
|
+
if (attempt?.readiness && hasEvidence) {
|
|
2845
|
+
continuation = { id: state.request.id, action: "verify", taskId: attention.taskId };
|
|
2846
|
+
}
|
|
2847
|
+
else if (attempt && this.correctionAllowed(state, changesetTaskRequest(state, attention.taskId), attempt)) {
|
|
2750
2848
|
continuation = { id: state.request.id, action: "retry", taskId: attention.taskId };
|
|
2751
2849
|
}
|
|
2752
|
-
else if (
|
|
2753
|
-
|| (attempt?.candidate && attempt.candidateBase
|
|
2754
|
-
&& checkBatchPasses(attempt.preliminaryChecks, changesetTaskRequest(state, attention.taskId).checks, attempt.candidate))) {
|
|
2850
|
+
else if (hasEvidence) {
|
|
2755
2851
|
continuation = { id: state.request.id, action: "verify", taskId: attention.taskId };
|
|
2756
2852
|
}
|
|
2757
2853
|
}
|
|
@@ -2763,6 +2859,9 @@ export class IsolatedRunner {
|
|
|
2763
2859
|
text: bounded([
|
|
2764
2860
|
`Pi Subagent ${state.request.id}: ${state.status}.`,
|
|
2765
2861
|
`Tasks: ${completed}/${state.tasks.length} completed, ${rejected} rejected. Accepted: ${state.accepted}.`,
|
|
2862
|
+
...(state.status === "aborted" && state.tasks.some((task) => task.kind === "changeset"
|
|
2863
|
+
&& task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed")))
|
|
2864
|
+
? ["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
2865
|
...(main?.status === "current" ? ["Main: current at the recorded exact identity."] : []),
|
|
2767
2866
|
...(main?.status === "drifted" ? [
|
|
2768
2867
|
`Main: drifted from ${main.expected.branch}@${main.expected.head} to ${main.actual.branch}@${main.actual.head}.`,
|
package/dist/schema.d.ts
CHANGED
|
@@ -139,14 +139,7 @@ type ExecuteRequestInput = Static<typeof ExecuteRequestSchema>;
|
|
|
139
139
|
export type ExecuteRequest = Omit<ExecuteRequestInput, "finalChecks"> & {
|
|
140
140
|
finalChecks: CheckCommand[];
|
|
141
141
|
};
|
|
142
|
-
export
|
|
143
|
-
maxSubagents: number;
|
|
144
|
-
maxTurns: number;
|
|
145
|
-
maxTokens?: number;
|
|
146
|
-
childIdleMs: number;
|
|
147
|
-
childMaxMs: number;
|
|
148
|
-
maxCorrections: number;
|
|
149
|
-
}
|
|
142
|
+
export type ExecutionPolicySnapshot = Static<typeof ExecutionPolicySnapshotSchema>;
|
|
150
143
|
export type ResumeRequest = Static<typeof ResumeRequestSchema>;
|
|
151
144
|
export type WorkspaceIdentity = Static<typeof WorkspaceSchema>;
|
|
152
145
|
declare const WorktreeRecordSchema: Type.TObject<{
|
|
@@ -1083,6 +1076,14 @@ declare const WaveStateSchema: Type.TObject<{
|
|
|
1083
1076
|
taskIds: Type.TArray<Type.TString>;
|
|
1084
1077
|
status: Type.TUnion<[Type.TLiteral<"dispatching">, Type.TLiteral<"integrating">, Type.TLiteral<"completed">, Type.TLiteral<"needs_attention">]>;
|
|
1085
1078
|
}>;
|
|
1079
|
+
declare const ExecutionPolicySnapshotSchema: Type.TObject<{
|
|
1080
|
+
maxSubagents: Type.TInteger;
|
|
1081
|
+
maxTurns: Type.TInteger;
|
|
1082
|
+
maxTokens: Type.TOptional<Type.TInteger>;
|
|
1083
|
+
childIdleMs: Type.TNumber;
|
|
1084
|
+
childMaxMs: Type.TNumber;
|
|
1085
|
+
maxCorrections: Type.TInteger;
|
|
1086
|
+
}>;
|
|
1086
1087
|
declare const IntegrationCandidateSchema: Type.TObject<{
|
|
1087
1088
|
taskId: Type.TString;
|
|
1088
1089
|
attempt: Type.TInteger;
|
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/dist/worktree.d.ts
CHANGED
|
@@ -57,5 +57,3 @@ export declare function inspectWorktreeDirty(cwd: string, run?: GitRunner, inclu
|
|
|
57
57
|
* recorded; any uncertainty preserves work and omits unknown measurements.
|
|
58
58
|
*/
|
|
59
59
|
export declare function finalizeChildWorktree(info: WorktreeInfo, run?: GitRunner): Promise<WorktreePayload>;
|
|
60
|
-
/** Context block telling the child to work inside its isolated worktree. */
|
|
61
|
-
export declare function worktreeContextNote(info: WorktreeInfo): string;
|
package/dist/worktree.js
CHANGED
|
@@ -269,7 +269,3 @@ export async function finalizeChildWorktree(info, run = runGit) {
|
|
|
269
269
|
}
|
|
270
270
|
return { outcome: "pruned", path: info.path, branch: info.branch };
|
|
271
271
|
}
|
|
272
|
-
/** Context block telling the child to work inside its isolated worktree. */
|
|
273
|
-
export function worktreeContextNote(info) {
|
|
274
|
-
return `\n\n[WORKTREE ISOLATION] Work only in ${info.path} on ${info.branch}; do not use main checkout. Commit changes to this branch for parent review.`;
|
|
275
|
-
}
|
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/admission.ts
CHANGED
|
@@ -14,10 +14,6 @@ export function roleIsReadOnlyScout(role: Role): boolean {
|
|
|
14
14
|
return !roleCanWrite(role) && role.extensions.length === 0 && !role.mcps?.length;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export interface CheckoutAdmission {
|
|
18
|
-
register(pi: ExtensionAPI, directCanWrite: (input: unknown) => boolean): void;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
17
|
async function checkoutKey(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string> {
|
|
22
18
|
const cwd = await realpath(ctx.cwd);
|
|
23
19
|
const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 5_000 });
|
|
@@ -32,7 +28,7 @@ async function checkoutKey(pi: ExtensionAPI, ctx: ExtensionContext): Promise<str
|
|
|
32
28
|
* not an OS sandbox: external processes and trusted extension lifecycle code
|
|
33
29
|
* remain outside interception.
|
|
34
30
|
*/
|
|
35
|
-
export function
|
|
31
|
+
export function registerCheckoutAdmission(pi: ExtensionAPI, directCanWrite: (input: unknown) => boolean): void {
|
|
36
32
|
const ownerByCheckout = new Map<string, string>();
|
|
37
33
|
const checkoutByCall = new Map<string, string>();
|
|
38
34
|
const release = (toolCallId: string) => {
|
|
@@ -41,30 +37,26 @@ export function createCheckoutAdmission(): CheckoutAdmission {
|
|
|
41
37
|
checkoutByCall.delete(toolCallId);
|
|
42
38
|
if (ownerByCheckout.get(key) === toolCallId) ownerByCheckout.delete(key);
|
|
43
39
|
};
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
checkoutByCall.clear();
|
|
67
|
-
});
|
|
68
|
-
},
|
|
69
|
-
};
|
|
40
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
41
|
+
const potentiallyWriting = event.toolName === "delegate_task"
|
|
42
|
+
? directCanWrite(event.input)
|
|
43
|
+
: !READ_ONLY_TOOLS.has(event.toolName);
|
|
44
|
+
if (!potentiallyWriting) return;
|
|
45
|
+
const key = await checkoutKey(pi, ctx);
|
|
46
|
+
const owner = ownerByCheckout.get(key);
|
|
47
|
+
if (owner && owner !== event.toolCallId) {
|
|
48
|
+
return {
|
|
49
|
+
block: true,
|
|
50
|
+
reason: `Checkout ${key} already has an admitted Pi writer (${owner}); retry after it settles.`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
ownerByCheckout.set(key, event.toolCallId);
|
|
54
|
+
checkoutByCall.set(event.toolCallId, key);
|
|
55
|
+
});
|
|
56
|
+
pi.on("tool_result", (event) => release(event.toolCallId));
|
|
57
|
+
pi.on("tool_execution_end", (event) => release(event.toolCallId));
|
|
58
|
+
pi.on("session_shutdown", () => {
|
|
59
|
+
ownerByCheckout.clear();
|
|
60
|
+
checkoutByCall.clear();
|
|
61
|
+
});
|
|
70
62
|
}
|
package/extensions/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { createConfigStore, extensionConfigPath } from "@henryqw/pi-config-store";
|
|
3
|
+
import type { ExecutionPolicySnapshot } from "../dist/schema.js";
|
|
3
4
|
|
|
4
5
|
export interface SubagentTimeoutConfig {
|
|
5
6
|
/** Minutes a child may stay idle before it is asked to stop. */
|
|
@@ -23,14 +24,7 @@ export interface LoadedSubagentConfig {
|
|
|
23
24
|
error?: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export
|
|
27
|
-
maxSubagents: number;
|
|
28
|
-
maxTurns: number;
|
|
29
|
-
maxTokens?: number;
|
|
30
|
-
childIdleMs: number;
|
|
31
|
-
childMaxMs: number;
|
|
32
|
-
maxCorrections: number;
|
|
33
|
-
}
|
|
27
|
+
export type EffectiveExecutionPolicy = ExecutionPolicySnapshot;
|
|
34
28
|
|
|
35
29
|
type ParsedSubagentConfig = Omit<LoadedSubagentConfig, "source">;
|
|
36
30
|
|
package/extensions/isolated.ts
CHANGED
|
@@ -77,7 +77,6 @@ export const createIsolatedComponents: CreateIsolatedComponents = ({
|
|
|
77
77
|
resolveRoot,
|
|
78
78
|
preflightHost: async ({ root }, operation) => await host.preflightHost({ root }, operation),
|
|
79
79
|
inspectMainBase: async (input, operation) => await git.inspectMainBase(input, operation),
|
|
80
|
-
executionBudget,
|
|
81
80
|
});
|
|
82
81
|
return {
|
|
83
82
|
resolveRoot,
|
|
@@ -659,7 +658,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
659
658
|
pi.registerTool({
|
|
660
659
|
name: "subagent_abort",
|
|
661
660
|
label: "Subagent abort",
|
|
662
|
-
description: "Abort
|
|
661
|
+
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
662
|
parameters: IdOnlySchema,
|
|
664
663
|
prepareArguments: parseIdOnly,
|
|
665
664
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|