@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/dist/runner.js CHANGED
@@ -69,7 +69,7 @@ class DurableRunStopped extends Error {
69
69
  }
70
70
  class ProductiveScope {
71
71
  signal;
72
- constructor(_timeoutMs, _now, outerSignal) {
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?.status === "failed"
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 === "completed" && (state.tasks.some((task) => task.kind === "changeset" && task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed")))
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
- ? "completed · retained" : state.status,
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 (request.status !== "pending" && request.status !== "running")
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(policy.childMaxMs, () => this.coordinatorRuntime.now(), outerSignal);
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 current = this.currentPolicy();
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?.status !== "failed" || task.attempts.length >= 2) {
758
- throw new Error(`Text task ${task.taskId} retry requires a failed latest attempt and fewer than two attempts.`);
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(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
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(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
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(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
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(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
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(Math.min(state.policy.childMaxMs, this.currentPolicy().childMaxMs), () => this.coordinatorRuntime.now(), outerSignal);
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.invalidate();
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
- await this.terminateWithSafety(handle, task, attempt, this.terminationCandidate(attempt), outerSignal, safetyDeadline, true);
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
- || !checkBatchPasses(attempt.preliminaryChecks, request.checks, attempt.candidate)
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
- || !checkBatchPasses(attempt.preliminaryChecks, request.checks, candidate)) {
2397
- throw new Error("Retained task work lacks exact passing preliminary candidate evidence.");
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
- if (attempt && this.correctionAllowed(state, changesetTaskRequest(state, attention.taskId), attempt)) {
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 ((attempt?.readiness && !state.integration.candidates.length)
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 interface ExecutionPolicySnapshot {
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
- : taskState.status === "completed"
657
- ? "completed"
658
- : "failed";
659
- if (latest?.status !== expectedLatestStatus) {
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?.failure !== taskState.failure) {
669
- throw new Error(`Text task ${taskState.taskId} needs-attention failure must exactly match its latest attempt failure.`);
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. */
@@ -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
- }
@@ -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
 
@@ -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 createCheckoutAdmission(): CheckoutAdmission {
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
- return {
45
- register(pi, directCanWrite) {
46
- pi.on("tool_call", async (event, ctx) => {
47
- const potentiallyWriting = event.toolName === "delegate_task"
48
- ? directCanWrite(event.input)
49
- : !READ_ONLY_TOOLS.has(event.toolName);
50
- if (!potentiallyWriting) return;
51
- const key = await checkoutKey(pi, ctx);
52
- const owner = ownerByCheckout.get(key);
53
- if (owner && owner !== event.toolCallId) {
54
- return {
55
- block: true,
56
- reason: `Checkout ${key} already has an admitted Pi writer (${owner}); retry after it settles.`,
57
- };
58
- }
59
- ownerByCheckout.set(key, event.toolCallId);
60
- checkoutByCall.set(event.toolCallId, key);
61
- });
62
- pi.on("tool_result", (event) => release(event.toolCallId));
63
- pi.on("tool_execution_end", (event) => release(event.toolCallId));
64
- pi.on("session_shutdown", () => {
65
- ownerByCheckout.clear();
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
  }
@@ -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 interface EffectiveExecutionPolicy {
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
 
@@ -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 one unfinished isolated request; retained candidates and integration checkouts must be explicitly rejected and released first. Terminate only exact owned workers.",
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) {