@henryqw/pi-subagent 27.2.1 → 27.3.1

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.
@@ -548,7 +548,8 @@ export class HerdrHostRuntime {
548
548
  throw new Error("Worker termination identity does not match the exact saved agent.");
549
549
  this.assertLeasePath(allocation.leasePath, allocation.token);
550
550
  await assertPrivateLease(allocation.leasePath, false);
551
- const agents = (await this.listAgents(allocation.worktreeCwd, context)).map((agent) => ({
551
+ const cwd = await this.queryCwd(allocation.worktreeCwd);
552
+ const agents = (await this.listAgents(cwd, context)).map((agent) => ({
552
553
  name: typeof agent.name === "string" ? agent.name : undefined,
553
554
  paneId: exactString(agent.pane_id, "Herdr listed agent pane ID"),
554
555
  tabId: exactString(agent.tab_id, "Herdr listed agent tab ID"),
@@ -559,7 +560,7 @@ export class HerdrHostRuntime {
559
560
  || related[0].paneId !== allocation.paneId || related[0].tabId !== allocation.tabId) {
560
561
  throw new Error("Worker termination requires one exact current agent, tab, and pane match.");
561
562
  }
562
- const processInfo = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(allocation.worktreeCwd, context, HERDR_OPERATION_CAP_MS));
563
+ const processInfo = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(cwd, context, HERDR_OPERATION_CAP_MS));
563
564
  const processResult = resultRecord(processInfo, "Herdr pane process-info response");
564
565
  if (processResult.type !== "pane_process_info")
565
566
  throw new Error("Herdr pane process-info response has the wrong type.");
@@ -567,27 +568,27 @@ export class HerdrHostRuntime {
567
568
  if (captured.pane_id !== allocation.paneId) {
568
569
  throw new Error("Herdr pane process-info did not match the exact saved pane.");
569
570
  }
570
- const closed = await this.herdr.exec(["pane", "close", allocation.paneId], this.processOptions(allocation.worktreeCwd, context, HERDR_OPERATION_CAP_MS));
571
+ const closed = await this.herdr.exec(["pane", "close", allocation.paneId], this.processOptions(cwd, context, HERDR_OPERATION_CAP_MS));
571
572
  if (closed.code !== 0 || closed.killed)
572
573
  throw new Error(herdrCommandFailure(["pane", "close"], closed));
573
574
  requireOkResponse(closed.stdout, "Herdr pane close response");
574
- if (!await this.paneAbsent(allocation.paneId, allocation.worktreeCwd, context))
575
+ if (!await this.paneAbsent(allocation.paneId, cwd, context))
575
576
  throw new Error("The exact saved pane still exists after pane close.");
576
- let holders = await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context);
577
+ let holders = await this.scanLease(allocation.leasePath, cwd, context);
577
578
  if (holders.length) {
578
- await this.signalExactHolders(holders, allocation, "SIGTERM", context);
579
+ await this.signalExactHolders(holders, { ...allocation, worktreeCwd: cwd }, "SIGTERM", context);
579
580
  await this.delay(250, context.signal);
580
- holders = await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context);
581
+ holders = await this.scanLease(allocation.leasePath, cwd, context);
581
582
  }
582
583
  if (holders.length) {
583
- await this.signalExactHolders(holders, allocation, "SIGKILL", context);
584
+ await this.signalExactHolders(holders, { ...allocation, worktreeCwd: cwd }, "SIGKILL", context);
584
585
  await this.delay(100, context.signal);
585
- holders = await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context);
586
+ holders = await this.scanLease(allocation.leasePath, cwd, context);
586
587
  }
587
588
  if (holders.length)
588
589
  throw new Error(`Exact process lease still has ${holders.length} surviving holder(s).`);
589
590
  await this.delay(50, context.signal);
590
- if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context)).length) {
591
+ if ((await this.scanLease(allocation.leasePath, cwd, context)).length) {
591
592
  throw new Error("Exact process lease did not remain empty for two consecutive scans.");
592
593
  }
593
594
  return { outcome: "terminated" };
@@ -605,7 +606,8 @@ export class HerdrHostRuntime {
605
606
  throw new Error("Saved termination worker does not match the exact owned agent.");
606
607
  this.assertLeasePath(allocation.leasePath, allocation.token);
607
608
  await assertPrivateLease(allocation.leasePath, false);
608
- const agents = (await this.listAgents(allocation.worktreeCwd, context)).map((agent) => ({
609
+ const cwd = await this.queryCwd(allocation.worktreeCwd);
610
+ const agents = (await this.listAgents(cwd, context)).map((agent) => ({
609
611
  name: typeof agent.name === "string" ? agent.name : undefined,
610
612
  paneId: exactString(agent.pane_id, "Herdr listed agent pane ID"),
611
613
  tabId: exactString(agent.tab_id, "Herdr listed agent tab ID"),
@@ -617,10 +619,10 @@ export class HerdrHostRuntime {
617
619
  || related[0].paneId !== allocation.paneId || related[0].tabId !== allocation.tabId) {
618
620
  throw new Error("Observed worker identity does not exactly match the saved owned agent and pane.");
619
621
  }
620
- if (await this.paneAbsent(allocation.paneId, allocation.worktreeCwd, context)) {
622
+ if (await this.paneAbsent(allocation.paneId, cwd, context)) {
621
623
  throw new Error("Exact saved agent exists but its saved pane is absent.");
622
624
  }
623
- const processInfo = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(allocation.worktreeCwd, context, HERDR_OPERATION_CAP_MS));
625
+ const processInfo = await this.herdr.json(["pane", "process-info", "--pane", allocation.paneId], this.processOptions(cwd, context, HERDR_OPERATION_CAP_MS));
624
626
  const result = resultRecord(processInfo, "Herdr pane process-info response");
625
627
  if (result.type !== "pane_process_info"
626
628
  || record(result.process_info, "Herdr pane process-info").pane_id !== allocation.paneId) {
@@ -628,14 +630,14 @@ export class HerdrHostRuntime {
628
630
  }
629
631
  return { outcome: "active" };
630
632
  }
631
- if (!await this.paneAbsent(allocation.paneId, allocation.worktreeCwd, context)) {
633
+ if (!await this.paneAbsent(allocation.paneId, cwd, context)) {
632
634
  throw new Error("Saved agent is absent but the exact saved pane still exists.");
633
635
  }
634
- if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context)).length) {
636
+ if ((await this.scanLease(allocation.leasePath, cwd, context)).length) {
635
637
  throw new Error("Saved worker is absent but its private process lease still has holders.");
636
638
  }
637
639
  await this.delay(50, context.signal);
638
- if ((await this.scanLease(allocation.leasePath, allocation.worktreeCwd, context)).length) {
640
+ if ((await this.scanLease(allocation.leasePath, cwd, context)).length) {
639
641
  throw new Error("Private process lease did not remain empty for two consecutive scans.");
640
642
  }
641
643
  return { outcome: "terminated" };
@@ -682,11 +684,13 @@ export class HerdrHostRuntime {
682
684
  }
683
685
  try {
684
686
  if (input.kind === "worker_tab") {
685
- const allocation = ownedIntent(input.attempt, "worker_tab");
687
+ const owned = ownedIntent(input.attempt, "worker_tab");
686
688
  const workspace = ownedIntent(input.attempt, "workspace");
687
- requireIntentIdentity(allocation, input.attempt);
689
+ requireIntentIdentity(owned, input.attempt);
688
690
  requireIntentIdentity(workspace, input.attempt);
689
- assertWorkerTabIntent(allocation, input.task, input.attempt);
691
+ assertWorkerTabIntent(owned, input.task, input.attempt);
692
+ // Validation uses the saved identity; queries run from an existing directory.
693
+ const allocation = { ...owned, worktreeCwd: await this.queryCwd(owned.worktreeCwd) };
690
694
  const leasePath = exactString(allocation.leasePath, "saved worker lease path");
691
695
  this.assertLeasePath(leasePath, input.attempt.correlationToken);
692
696
  assertWorkspaceIntent(workspace, input.attempt);
@@ -1327,6 +1331,18 @@ export class HerdrHostRuntime {
1327
1331
  throw new Error("Persisted workspace repository identity no longer matches Git.");
1328
1332
  }
1329
1333
  }
1334
+ /** Herdr and lsof queries are global, so a checkout removed outside Pi Subagent must not be their process cwd. */
1335
+ async queryCwd(cwd) {
1336
+ try {
1337
+ await lstat(cwd);
1338
+ return cwd;
1339
+ }
1340
+ catch (error) {
1341
+ if (error.code === "ENOENT")
1342
+ return this.leaseDirectory;
1343
+ throw error;
1344
+ }
1345
+ }
1330
1346
  processOptions(cwd, context, cap = HERDR_OPERATION_CAP_MS) {
1331
1347
  context.signal.throwIfAborted();
1332
1348
  return { cwd, signal: context.signal, timeoutMs: this.callTimeout(context, cap) };
@@ -49,6 +49,8 @@ export declare class IntegrationGit {
49
49
  to: WorkspaceIdentity;
50
50
  };
51
51
  }, signal: AbortSignal): Promise<GitOutcome<WorkspaceIdentity>>;
52
+ /** Read-only proof that an owned checkout, its registration, and its branch are all gone. */
53
+ absent(root: string, info: WorktreeInfo, signal: AbortSignal): Promise<boolean>;
52
54
  /** Explicit release of an unselected clean checkout. The branch deletion is an exact ref compare-and-swap. */
53
55
  release(root: string, info: WorktreeInfo, expected: WorkspaceIdentity, kind: "worktree" | "branch", signal: AbortSignal): Promise<GitOutcome<"removed" | "absent">>;
54
56
  /** Cleanup is deliberately separate per resource; callers persist each proven substep. */
@@ -358,15 +358,23 @@ export class IntegrationGit {
358
358
  catch { /* Retain all resources when verification fails. */ }
359
359
  return { outcome: "unknown", failure: "Promotion returned success but exact clean Main tip could not be proved.", possibleResources: [integration.path, integration.branch] };
360
360
  }
361
+ /** Read-only proof that an owned checkout, its registration, and its branch are all gone. */
362
+ async absent(root, info, signal) {
363
+ await ownershipMetadata(root, info, signal);
364
+ const registered = await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal);
365
+ if (registered.split("\0").includes(`worktree ${info.path}`) || await exists(info.path))
366
+ return false;
367
+ const args = ["show-ref", "--verify", "--quiet", `refs/heads/${info.branch}`];
368
+ const ref = await git(args, root, signal);
369
+ if (ref.code !== 0 && ref.code !== 1)
370
+ throw new Error(failure(args, ref));
371
+ return ref.code === 1;
372
+ }
361
373
  /** Explicit release of an unselected clean checkout. The branch deletion is an exact ref compare-and-swap. */
362
374
  async release(root, info, expected, kind, signal) {
363
375
  const possibleResources = [info.path, info.branch];
364
376
  try {
365
377
  await ownershipMetadata(root, info, signal);
366
- if (expected.branch !== `refs/heads/${info.branch}` || expected.index !== expected.tree
367
- || !await ancestor(info.baseCommit, expected.head, root, signal)) {
368
- return { outcome: "blocked", failure: "Release lacks an exact clean owned branch and base lineage." };
369
- }
370
378
  const registered = await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal);
371
379
  const present = registered.split("\0").includes(`worktree ${info.path}`);
372
380
  const pathPresent = await exists(info.path);
@@ -377,9 +385,14 @@ export class IntegrationGit {
377
385
  const branch = await git(["rev-parse", "--verify", ref], root, signal);
378
386
  if (branch.code !== 0 && branch.code !== 128)
379
387
  throw new Error("Owned branch tip could not be inspected.");
388
+ // Absence needs no lineage proof: nothing remains to remove, even after Git pruned the commits.
389
+ if (!present && !pathPresent && (kind === "worktree" || branch.code === 128))
390
+ return { outcome: "ready", value: "absent" };
391
+ if (expected.branch !== ref || expected.index !== expected.tree
392
+ || !await ancestor(info.baseCommit, expected.head, root, signal)) {
393
+ return { outcome: "blocked", failure: "Release lacks an exact clean owned branch and base lineage." };
394
+ }
380
395
  if (kind === "worktree") {
381
- if (!present && !pathPresent)
382
- return { outcome: "ready", value: "absent" };
383
396
  if (!present || !pathPresent || branch.stdout.trim() !== expected.head
384
397
  || !sameIdentity(await current(root, info, signal), expected)) {
385
398
  return { outcome: "blocked", failure: "Owned checkout, registration, branch or clean candidate changed." };
@@ -398,8 +411,6 @@ export class IntegrationGit {
398
411
  }
399
412
  if (present || pathPresent)
400
413
  return { outcome: "blocked", failure: "Release the exact checkout before branch deletion." };
401
- if (branch.code === 128)
402
- return { outcome: "ready", value: "absent" };
403
414
  if (branch.stdout.trim() !== expected.head)
404
415
  return { outcome: "blocked", failure: "Owned branch differs from the approved tip." };
405
416
  const args = ["update-ref", "-d", ref, expected.head];
package/dist/runner.d.ts CHANGED
@@ -347,6 +347,8 @@ export declare class IsolatedRunner {
347
347
  private cleanupPromoted;
348
348
  private requireExactIntegration;
349
349
  abort(id: string, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
350
+ /** Read-only proof that every retained candidate checkout and staged integration checkout is already gone. */
351
+ private retainedWorkAbsent;
350
352
  private cleanupAborted;
351
353
  status(id: string, root: string, outerSignal?: AbortSignal): Promise<RunResponse>;
352
354
  private run;
@@ -356,6 +358,11 @@ export declare class IsolatedRunner {
356
358
  private driveWorkerSafely;
357
359
  private driveWorker;
358
360
  private terminationCandidate;
361
+ /**
362
+ * Proves exact worker termination without replay. Reconciliation records a worker that is already gone;
363
+ * only an observed live worker without saved termination intent is closed.
364
+ */
365
+ private proveTermination;
359
366
  private terminateWithSafety;
360
367
  private runCheckBatch;
361
368
  private runReview;
package/dist/runner.js CHANGED
@@ -286,7 +286,27 @@ function affectedDependents(state, taskId) {
286
286
  }
287
287
  }
288
288
  }
289
- return state.tasks.filter((task) => task.taskId !== taskId && affected.has(task.taskId) && task.attempts.length > 0);
289
+ return state.tasks.filter((task) => task.taskId !== taskId && affected.has(task.taskId) && task.attempts.length > 0
290
+ // Refresh or an earlier rejection already invalidated this attempt; it needs no second invalidation.
291
+ && !(task.status === "pending" && (task.kind === "changeset" ? task.attempts.at(-1).superseded
292
+ : task.attempts.at(-1).status === "superseded")));
293
+ }
294
+ /** Freezes a generation as read-only. The correction stays recorded because it names the checkout's exact tip. */
295
+ function supersedeGeneration(generation, failure) {
296
+ if (generation.status === "promotion_failed") {
297
+ generation.supersededPromotion = generation.promotion;
298
+ delete generation.promotion;
299
+ }
300
+ generation.supersededFrom = generation.status;
301
+ generation.status = "superseded";
302
+ generation.failure = failure;
303
+ delete generation.combinedTip;
304
+ delete generation.checks;
305
+ delete generation.review;
306
+ }
307
+ /** The exact tip a retained integration checkout holds: its correction, last staged merge, or base. */
308
+ function retainedGenerationTip(generation) {
309
+ return generation.correction?.to ?? generation.stages.at(-1)?.tip ?? generation.integrationBase;
290
310
  }
291
311
  /** Every changeset predecessor must be present in this exact committed snapshot. */
292
312
  function stagedDependencySnapshot(state, taskId, tip) {
@@ -950,13 +970,7 @@ export class IsolatedRunner {
950
970
  }
951
971
  if (!["staging", "conflict", "validation_failed", "ready"].includes(generation.status))
952
972
  throw new Error("Cannot supersede an unresolved promotion or validation.");
953
- generation.supersededFrom = generation.status;
954
- generation.status = "superseded";
955
- generation.failure = `Main ${action.action === "reject" ? "rejected" : "revised"} ${action.taskId}; retained generation is read-only.`;
956
- delete generation.combinedTip;
957
- delete generation.checks;
958
- delete generation.review;
959
- delete generation.correction;
973
+ supersedeGeneration(generation, `Main ${action.action === "reject" ? "rejected" : "revised"} ${action.taskId}; retained generation is read-only.`);
960
974
  }
961
975
  candidate.decision = "rejected";
962
976
  for (const dependent of affected) {
@@ -1164,20 +1178,22 @@ export class IsolatedRunner {
1164
1178
  const candidates = state.integration.candidates.filter((item) => item.taskId === action.taskId && item.attempt === action.attempt);
1165
1179
  const candidate = candidates.find((item) => sameIdentity(item.tip, action.expectedTip));
1166
1180
  const worker = attempt?.allocations.find((item) => item.kind === "worktree" && item.status === "owned")?.worktree;
1167
- if (!candidate || candidate.decision !== "rejected" || task.status !== "rejected"
1168
- || attempt?.termination?.status !== "terminated"
1169
- || !sameIdentity(attempt.termination.candidate, action.expectedTip) || !worker
1181
+ const tip = attempt?.termination?.candidate ?? attempt?.candidate;
1182
+ // A rejected task or an invalidated dependent attempt never runs again; its worker can stop.
1183
+ if (!candidate || candidate.decision !== "rejected" || !attempt || (task.status !== "rejected" && !attempt.superseded)
1184
+ || !tip || !sameIdentity(tip, action.expectedTip) || !worker
1170
1185
  || candidates.some((item) => !item.decision)
1171
1186
  || state.integration.generations.some((item) => item.stages.some((stage) => stage.taskId === action.taskId
1172
1187
  && stage.attempt === action.attempt && item.cleanup?.every((step) => step.status === "completed") !== true))) {
1173
- throw new Error("Release requires a rejected terminated worker and no retained staged generation using its checkout.");
1188
+ throw new Error("Release requires a rejected candidate of a stopped or invalidated worker and no retained staged generation using its checkout.");
1174
1189
  }
1190
+ await this.proveTermination(handle, task, attempt, action.expectedTip, async () => await this.saveProductive(handle), scope.signal);
1175
1191
  await this.releaseRejected(handle, task, attempt, worker, scope);
1176
1192
  return this.response(state);
1177
1193
  }
1178
1194
  const superseded = state.integration.generations[action.generation - 1];
1179
1195
  if (!superseded?.worktree || superseded.status !== "superseded"
1180
- || !sameIdentity(action.expectedTip, superseded.stages.at(-1)?.tip ?? superseded.integrationBase)
1196
+ || !sameIdentity(action.expectedTip, retainedGenerationTip(superseded))
1181
1197
  || superseded.stages.some((stage) => stage.status === "pending" || stage.status === "staging")) {
1182
1198
  throw new Error("Release requires an exact superseded integration checkout without an uncertain stage.");
1183
1199
  }
@@ -1234,18 +1250,7 @@ export class IsolatedRunner {
1234
1250
  throw new Error("A dependent has unproved resources or exhausted fresh attempts; refresh is blocked.");
1235
1251
  }
1236
1252
  if (generation && generation.status !== "superseded") {
1237
- if (generation.status === "promotion_failed") {
1238
- generation.supersededPromotion = generation.promotion;
1239
- delete generation.promotion;
1240
- }
1241
- generation.supersededFrom = generation.status;
1242
- generation.status = "superseded";
1243
- generation.failure = `${unchangedMain ? "Validation evidence missing" : "Main advanced"}; retained generation is read-only. Restage immutable candidates in a new generation.`;
1244
- delete generation.combinedTip;
1245
- delete generation.checks;
1246
- delete generation.review;
1247
- if (!generation.supersededPromotion)
1248
- delete generation.correction;
1253
+ supersedeGeneration(generation, `${unchangedMain ? "Validation evidence missing" : "Main advanced"}; retained generation is read-only. Restage immutable candidates in a new generation.`);
1249
1254
  }
1250
1255
  for (const task of stale) {
1251
1256
  if (task.kind === "text") {
@@ -1659,10 +1664,23 @@ export class IsolatedRunner {
1659
1664
  const state = handle.state;
1660
1665
  if (terminal(state) && state.status !== "aborted")
1661
1666
  return this.response(state);
1662
- if (state.integration.candidates.some((candidate) => candidate.worker !== "released")
1663
- || state.integration.generations.some((generation) => generation.worktree
1664
- && !generation.cleanup?.every((step) => step.status === "completed"))) {
1665
- throw new Error("Retained candidates and integration worktrees must be explicitly rejected and released before abort.");
1667
+ const candidates = state.integration.candidates.filter((candidate) => candidate.worker !== "released");
1668
+ const generations = state.integration.generations.filter((generation) => generation.worktree
1669
+ && !generation.cleanup?.every((step) => step.status === "completed"));
1670
+ // Abort removes only unchanged resources: an empty refreshed generation, or candidate work proved already gone.
1671
+ if ((candidates.length || generations.length) && (lifecycle.productiveRunLeaseActive
1672
+ || generations.some((generation) => generation.status === "promotion_failed" ? generation.promotion?.outcome !== "drift"
1673
+ : !["staging", "conflict", "validation_failed", "ready", "superseded"].includes(generation.status))
1674
+ || ((candidates.length || generations.some((generation) => generation.stages.length))
1675
+ && !await this.retainedWorkAbsent(state, candidates, generations, outerSignal)))) {
1676
+ throw new Error("Retained candidates and integration worktrees must be explicitly rejected and released before abort, unless their checkouts and branches are proved absent.");
1677
+ }
1678
+ for (const candidate of candidates.filter((item) => !item.decision)) {
1679
+ candidate.decision = "rejected";
1680
+ this.attention(changesetTaskState(state, candidate.taskId), "Abort rejected this candidate after proving its checkout and branch absent.");
1681
+ }
1682
+ for (const generation of generations.filter((item) => item.status !== "superseded")) {
1683
+ supersedeGeneration(generation, "Request aborted; the integration checkout is released.");
1666
1684
  }
1667
1685
  const activeControls = this.activeControls(root, id);
1668
1686
  for (const { control } of activeControls)
@@ -1679,30 +1697,11 @@ export class IsolatedRunner {
1679
1697
  for (const attempt of task.attempts) {
1680
1698
  if (!allocationByKind(attempt, "agent")?.agentName || attempt.termination?.status === "terminated")
1681
1699
  continue;
1682
- if (attempt.termination) {
1683
- const safety = new DeadlineScope(safetyDeadline, () => this.coordinatorRuntime.now(), outerSignal);
1684
- try {
1685
- const result = await safety.call((context) => this.hostRuntime.reconcileWorkerTermination({
1686
- task: changesetTaskRequest(state, task.taskId), attempt,
1687
- workerId: attempt.termination.workerId, candidate: attempt.termination.candidate,
1688
- }, context));
1689
- if (result.outcome !== "terminated")
1690
- throw new Error(result.outcome === "unknown"
1691
- ? result.failure : "Exact worker remains active; termination was not replayed.");
1692
- attempt.termination = { ...attempt.termination, status: "terminated", at: this.coordinatorRuntime.now() };
1693
- delete attempt.termination.failure;
1694
- }
1695
- catch (error) {
1696
- attempt.termination.status = "unknown";
1697
- attempt.termination.failure = errorText(error);
1698
- this.attention(task, attempt.termination.failure);
1699
- }
1700
- finally {
1701
- safety.close();
1702
- }
1700
+ try {
1701
+ await this.proveTermination(handle, task, attempt, this.terminationCandidate(attempt), async () => await handle.save(), outerSignal, safetyDeadline);
1703
1702
  }
1704
- else {
1705
- await this.terminateWithSafety(handle, task, attempt, this.terminationCandidate(attempt), outerSignal, safetyDeadline, true);
1703
+ catch (error) {
1704
+ this.attention(task, errorText(error));
1706
1705
  }
1707
1706
  }
1708
1707
  }
@@ -1717,6 +1716,24 @@ export class IsolatedRunner {
1717
1716
  return this.response(state);
1718
1717
  }, { purpose: "abort" });
1719
1718
  }
1719
+ /** Read-only proof that every retained candidate checkout and staged integration checkout is already gone. */
1720
+ async retainedWorkAbsent(state, candidates, generations, signal) {
1721
+ const checkouts = [
1722
+ ...candidates.map((candidate) => allocationByKind(changesetTaskState(state, candidate.taskId).attempts[candidate.attempt - 1], "worktree")?.worktree),
1723
+ ...generations.filter((generation) => generation.stages.length).map((generation) => generation.worktree),
1724
+ ];
1725
+ const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now(), signal);
1726
+ try {
1727
+ for (const checkout of checkouts) {
1728
+ if (!checkout || !await scope.call((context) => this.integrationGit.absent(state.root, checkout, context.signal)))
1729
+ return false;
1730
+ }
1731
+ return true;
1732
+ }
1733
+ finally {
1734
+ scope.close();
1735
+ }
1736
+ }
1720
1737
  async cleanupAborted(handle, signal) {
1721
1738
  const state = handle.state;
1722
1739
  const scope = new DeadlineScope(this.coordinatorRuntime.now() + CLEANUP_SAFETY_BUDGET_MS, () => this.coordinatorRuntime.now(), signal);
@@ -1737,7 +1754,8 @@ export class IsolatedRunner {
1737
1754
  if (allocationByKind(attempt, "agent") && attempt.termination?.status !== "terminated") {
1738
1755
  throw new Error("Exact worker termination remains unproved.");
1739
1756
  }
1740
- if (expected && attempt.termination && attempt.termination.candidate.head !== expected.head) {
1757
+ if (expected && attempt.termination && attempt.termination.candidate.head !== expected.head
1758
+ && !await scope.call((context) => this.integrationGit.absent(state.root, worker, context.signal))) {
1741
1759
  throw new Error("Aborted worker may contain committed work; automatic cleanup preserves it.");
1742
1760
  }
1743
1761
  for (const step of attempt.cleanup) {
@@ -1773,6 +1791,45 @@ export class IsolatedRunner {
1773
1791
  }
1774
1792
  }
1775
1793
  }
1794
+ // Abort admitted retained candidates only after proving their checkouts absent.
1795
+ for (const candidate of state.integration.candidates) {
1796
+ const task = changesetTaskState(state, candidate.taskId);
1797
+ const attempt = task.attempts[candidate.attempt - 1];
1798
+ if (candidate.worker === "released" || candidate.decision !== "rejected" || attempt.termination?.status !== "terminated"
1799
+ || attempt.cleanup.some((step) => step.status !== "completed"))
1800
+ continue;
1801
+ candidate.worker = "released";
1802
+ if (task.status === "needs_attention" && attempt === task.attempts.at(-1) && !attempt.superseded) {
1803
+ task.status = "rejected";
1804
+ delete task.failure;
1805
+ }
1806
+ await handle.save();
1807
+ }
1808
+ for (const generation of state.integration.generations) {
1809
+ if (!generation.worktree || generation.status !== "superseded")
1810
+ continue;
1811
+ const steps = generation.cleanup ??= [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
1812
+ try {
1813
+ for (const step of steps) {
1814
+ if (step.status === "completed")
1815
+ continue;
1816
+ step.status = "running";
1817
+ delete step.failure;
1818
+ await handle.save();
1819
+ const result = await scope.call((context) => this.integrationGit.release(state.root, generation.worktree, retainedGenerationTip(generation), step.kind, context.signal));
1820
+ if (result.outcome !== "ready")
1821
+ throw new Error(result.failure);
1822
+ step.status = "completed";
1823
+ await handle.save();
1824
+ }
1825
+ }
1826
+ catch (error) {
1827
+ const pending = steps.find((step) => step.status === "running");
1828
+ if (pending)
1829
+ pending.failure = bounded(`Abort cleanup retained resources: ${errorText(error)}`);
1830
+ await handle.save();
1831
+ }
1832
+ }
1776
1833
  }
1777
1834
  finally {
1778
1835
  scope.close();
@@ -2434,6 +2491,43 @@ export class IsolatedRunner {
2434
2491
  ?? attempt.prompts.at(-1)?.preCandidate
2435
2492
  ?? attempt.waveBase;
2436
2493
  }
2494
+ /**
2495
+ * Proves exact worker termination without replay. Reconciliation records a worker that is already gone;
2496
+ * only an observed live worker without saved termination intent is closed.
2497
+ */
2498
+ async proveTermination(handle, task, attempt, candidate, save, signal, deadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS) {
2499
+ if (attempt.termination?.status === "terminated")
2500
+ return;
2501
+ const workerId = attempt.termination?.workerId ?? allocationByKind(attempt, "agent")?.agentName;
2502
+ if (!workerId)
2503
+ throw new Error("Worker termination requires an exact durably owned agent ID.");
2504
+ const input = { task: changesetTaskRequest(handle.state, task.taskId), attempt, workerId,
2505
+ candidate: attempt.termination?.candidate ?? candidate };
2506
+ const safety = new DeadlineScope(deadline, () => this.coordinatorRuntime.now(), signal);
2507
+ try {
2508
+ let result = await safety.call((context) => this.hostRuntime.reconcileWorkerTermination(input, context));
2509
+ if (result.outcome === "active" && !attempt.termination) {
2510
+ attempt.termination = { status: "terminating", workerId, candidate: input.candidate };
2511
+ await save(); // Intent before the exact pane close.
2512
+ result = await safety.call((context) => this.hostRuntime.terminateWorker(input, context));
2513
+ }
2514
+ if (result.outcome !== "terminated") {
2515
+ throw new Error(result.outcome === "unknown" ? result.failure : "Exact worker remains active; termination was not replayed.");
2516
+ }
2517
+ attempt.termination = { status: "terminated", workerId, candidate: input.candidate, at: this.coordinatorRuntime.now() };
2518
+ }
2519
+ catch (error) {
2520
+ const failure = bounded(`Worker termination is unproved: ${errorText(error)}`);
2521
+ // Without saved intent, nothing was closed; a later proof may still close the exact live worker.
2522
+ if (attempt.termination)
2523
+ attempt.termination = { status: "unknown", workerId, candidate: input.candidate, failure };
2524
+ throw new Error(failure, { cause: error });
2525
+ }
2526
+ finally {
2527
+ safety.close();
2528
+ await save();
2529
+ }
2530
+ }
2437
2531
  async terminateWithSafety(handle, task, attempt, candidate, outerSignal, safetyDeadline = this.coordinatorRuntime.now() + TERMINATION_SAFETY_BUDGET_MS, lifecycleLocked = false) {
2438
2532
  if (!lifecycleLocked && this.productiveLifecycles.has(handle.state.root)) {
2439
2533
  return await this.withLifecycleLock(handle, async () => await this.terminateWithSafety(handle, task, attempt, candidate, outerSignal, safetyDeadline, true));
@@ -2985,8 +3079,9 @@ export class IsolatedRunner {
2985
3079
  text: bounded([
2986
3080
  `Pi Subagent ${state.request.id}: ${state.status}.`,
2987
3081
  `Tasks: ${completed}/${state.tasks.length} completed, ${rejected} rejected. Accepted: ${state.accepted}.`,
2988
- ...(state.status === "aborted" && state.tasks.some((task) => task.kind === "changeset"
3082
+ ...(state.status === "aborted" && (state.tasks.some((task) => task.kind === "changeset"
2989
3083
  && task.attempts.some((attempt) => attempt.cleanup.some((step) => step.status !== "completed")))
3084
+ || state.integration.generations.some((generation) => generation.cleanup?.some((step) => step.status !== "completed")))
2990
3085
  ? ["Aborted resources remain retained. Inspect cleanup evidence; retry subagent_abort after active work settles or cleanup blockers are resolved. Unique commits and uncertain work are never discarded."] : []),
2991
3086
  ...(main?.status === "current" ? ["Main: current at the recorded exact identity."] : []),
2992
3087
  ...(main?.status === "drifted" ? [
@@ -3009,10 +3104,10 @@ export class IsolatedRunner {
3009
3104
  ] : []),
3010
3105
  ...(state.integration.generations.some((item) => item.status === "superseded" && item.worktree
3011
3106
  && !item.cleanup?.every((step) => step.status === "completed")) ? [
3012
- "Superseded integration worktrees remain owned. Use subagent_integrate release with their exact generation and last staged tip; dirty or conflicted work stays retained.",
3107
+ "Superseded integration worktrees remain owned. Use subagent_integrate release with their exact generation and recorded tip (correction tip, else last staged tip, else integration base); dirty or conflicted work stays retained.",
3013
3108
  ] : []),
3014
3109
  ...(state.integration.candidates.some((item) => item.decision === "rejected" && item.worker !== "released") ? [
3015
- "Rejected candidate resources remain retained. After proving worker termination and releasing any superseded stage, use subagent_integrate release with the exact task, attempt and candidate tip.",
3110
+ "Rejected candidate resources remain retained. Release every generation that staged it, then use subagent_integrate release with the exact task, attempt and candidate tip; release first proves or performs exact worker termination.",
3016
3111
  ] : []),
3017
3112
  ...(state.final.failure ? [`Final: ${state.final.failure}`] : []),
3018
3113
  ...(continuation ? [`Continuation: ${JSON.stringify(continuation)}`] : []),
package/dist/schema.js CHANGED
@@ -923,7 +923,7 @@ export function parseIntegrationState(value, request) {
923
923
  if (generation.status === "superseded") {
924
924
  if (!generation.supersededFrom || !generation.failure?.trim() || generation.promotion
925
925
  || generation.checks || generation.review || generation.combinedTip
926
- || (generation.supersededFrom === "promotion_failed" ? !generation.supersededPromotion : generation.correction || generation.supersededPromotion)) {
926
+ || (generation.supersededFrom === "promotion_failed" ? !generation.supersededPromotion : generation.supersededPromotion)) {
927
927
  throw new Error(`Superseded generation ${generation.number} retains usable evidence or lacks its reason.`);
928
928
  }
929
929
  }
@@ -1256,7 +1256,7 @@ export function parseRunState(value) {
1256
1256
  const selected = state.integration.generations.at(-1);
1257
1257
  const releasedWithRevisedAttempt = selected?.status === "promoted" && selected.stages.some((stage) => stage.taskId === candidate.taskId && stage.attempt === candidate.attempt && !sameIdentity(stage.source, candidate.tip))
1258
1258
  && attempt?.cleanup.every((step) => step.status === "completed");
1259
- const releasedAfterRejection = task?.status === "rejected" && attempt?.termination?.status === "terminated"
1259
+ const releasedAfterRejection = (task?.status === "rejected" || attempt?.superseded) && attempt?.termination?.status === "terminated"
1260
1260
  && (candidate.worker === "release_pending" || candidate.worker === "released"
1261
1261
  && attempt.cleanup.every((step) => step.status === "completed"));
1262
1262
  if (!attempt || (candidate.worker !== "retained" && !releasedAfterRejection
@@ -117,9 +117,9 @@ The user's request can authorize the declared local graph, checks, review, and M
117
117
  - `subagent_stage`: `{ id, action: "stage" | "resolve" | "reject" | "revise", generation, taskId, attempt, candidate, expectedTip, instruction? }`. `candidate` is the ready candidate's `tip`; `expectedTip` is Main's recorded identity for the first stage or the previous stage's exact tip. `resolve` reconciles a pending/conflicted merge; `reject` and `revise` arbitrate a candidate without changing Main.
118
118
  - `subagent_integrate`: `{ id, action: "advance" | "validate" | "correct" | "promote" | "reconcile" | "cleanup", generation, expectedTip }`. Use the generation's exact `combinedTip`. `advance` dispatches ready dependents on that snapshot, `correct` records the single clean committed correction after a definitive failed check/review, `reconcile` inspects an interrupted validation or promotion without replaying an uncertain promotion, and `cleanup` completes proved post-promotion cleanup.
119
119
  - `subagent_integrate refresh`: `{ id, action: "refresh", generation, expectedTip, expectedMain, newMain }`. If Main advanced to a clean same-branch descendant, this freezes the old generation and allocates a new integration checkout from `newMain`. Restage selected immutable candidates in explicit order, resolve fresh conflicts, then rerun combined validation. Dirty, divergent, or uncertain Main cannot refresh. For a reconciled `validation_failed` generation without checks, review, or a prior combined correction, identical `expectedMain` and `newMain` instead rebuild on the unchanged base. This requires the exact clean retained integration tip, leaves the old checkout retained, and never resumes its uncertain checks. Prove old coordinator/check processes stopped before recovery; a live productive lease blocks it. Restage explicitly and rerun all final gates; no correction allowance is replenished.
120
- - `subagent_integrate release`: `{ id, action: "release", generation, expectedTip, taskId?, attempt? }`. With no task, release an explicitly superseded checkout using that generation's last staged tip (or integration base if empty). With both `taskId` and `attempt`, release a rejected, terminated worker using its exact candidate tip and the latest generation number (or 1 when none exists); release every generation that staged it first. Cleanup steps are persisted separately, and a retry reconciles already-removed resources by proving absence. Release refuses dirty/conflicted work, changed refs, unproved termination, and still-needed checkouts.
120
+ - `subagent_integrate release`: `{ id, action: "release", generation, expectedTip, taskId?, attempt? }`. With no task, release an explicitly superseded checkout using that generation's recorded tip: its correction tip, else its last staged tip, else its integration base. Supersession keeps the correction record for this proof. With both `taskId` and `attempt`, release the rejected candidate of a rejected task or of a dependent attempt that refresh or rejection invalidated, using its exact candidate tip and the latest generation number (or 1 when none exists); release every generation that staged it first. Release proves exact worker termination before cleanup: reconciliation records an absent worker after pane absence and two empty lease scans, and only an observed live worker without saved termination intent is closed with the same proof. An uncertain termination is never replayed. Cleanup steps are persisted separately, and a retry reconciles already-removed resources by proving absence. Release refuses dirty/conflicted work, changed refs, unproved termination, and still-needed checkouts.
121
121
 
122
- A rejection or revision of a staged candidate supersedes that generation, leaves its checkout read-only, and requires explicit restaging of chosen candidates into a new generation. At most two unreleased owned integration checkouts can coexist per request. Superseded integration checkouts and rejected candidate resources are **retained until Main explicitly releases them**. Inspect exact status first; dirty or unresolved conflicts require manual recovery without force deletion. Rejection invalidates dependent attempts; if the required fresh dependent work cannot run within the bounded attempt/lineage constraints, start a new request. `subagent_abort` cannot discard retained resources, but can finish after all candidates are rejected and all owned checkouts are released.
122
+ A rejection or revision of a staged candidate supersedes that generation, leaves its checkout read-only, and requires explicit restaging of chosen candidates into a new generation. At most two unreleased owned integration checkouts can coexist per request. Superseded integration checkouts and rejected candidate resources are **retained until Main explicitly releases them**. Inspect exact status first; dirty or unresolved conflicts require manual recovery without force deletion. Rejection invalidates dependent attempts; if the required fresh dependent work cannot run within the bounded attempt/lineage constraints, start a new request. `subagent_abort` cannot discard retained resources, but can finish after all candidates are rejected and all owned checkouts are released. It also releases an empty refreshed generation that is clean at its base. If retained candidate checkouts and staged integration checkouts are proved absent (unregistered, path missing, branch ref missing) and their workers are proved absent, abort records those candidates as rejected and released and completes their cleanup; any present resource fails abort without state changes. Rejection and revision ignore dependents that refresh or an earlier rejection already invalidated.
123
123
 
124
124
  ### Recovery and state
125
125
 
@@ -439,7 +439,7 @@ function inspectionNotices({ state, main, continuation }: RunResponse): string[]
439
439
  && !generation.promotion?.failure && !generation.supersededPromotion?.failure
440
440
  && generation !== state.integration.generations.at(-1)) continue;
441
441
  const stagedTip = [...generation.stages].reverse().find((stage) => stage.tip)?.tip ?? generation.integrationBase;
442
- lines.push(`Integration generation ${generation.number}: ${generation.status}; ${retainedPath ? `${checkoutCleaned ? "historical checkout; branch cleanup pending" : "worktree"}: ${JSON.stringify({ path: generation.worktree!.path, cwd: generation.worktree!.cwd, branch: generation.worktree!.branch, repoRoot: generation.worktree!.repoRoot })}; ` : ""}last staged tip: ${JSON.stringify(stagedTip)}${generation.combinedTip ? `; combined tip: ${JSON.stringify(generation.combinedTip)}` : ""}${generation.failure ? `; blockage: ${boundedPublicText(generation.failure)}` : ""}.`);
442
+ lines.push(`Integration generation ${generation.number}: ${generation.status}; ${retainedPath ? `${checkoutCleaned ? "historical checkout; branch cleanup pending" : "worktree"}: ${JSON.stringify({ path: generation.worktree!.path, cwd: generation.worktree!.cwd, branch: generation.worktree!.branch, repoRoot: generation.worktree!.repoRoot })}; ` : ""}last staged tip: ${JSON.stringify(stagedTip)}${generation.correction ? `; correction tip: ${JSON.stringify(generation.correction.to)}` : ""}${generation.combinedTip ? `; combined tip: ${JSON.stringify(generation.combinedTip)}` : ""}${generation.failure ? `; blockage: ${boundedPublicText(generation.failure)}` : ""}.`);
443
443
  for (const stage of generation.stages.filter((item) => item.status === "conflict" || item.failure)) lines.push(`Integration generation ${generation.number} stage ${stage.taskId} attempt ${stage.attempt}: ${stage.status}${stage.failure ? `; blockage: ${boundedPublicText(stage.failure)}` : ""}.`);
444
444
  for (const promotion of [generation.promotion, generation.supersededPromotion]) if (promotion?.failure) lines.push(`Integration generation ${generation.number} promotion ${promotion.status}: ${boundedPublicText(promotion.failure)}`);
445
445
  if (generation.cleanup?.some((step) => step.status !== "completed")) lines.push(`Integration generation ${generation.number} cleanup pending: ${JSON.stringify(generation.cleanup.filter((step) => step.status !== "completed").map((step) => ({ kind: step.kind, status: step.status, failure: step.failure ? boundedPublicText(step.failure) : undefined })))}`);
@@ -766,7 +766,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
766
766
  pi.registerTool({
767
767
  name: "subagent_abort",
768
768
  label: "Subagent abort",
769
- description: "Abort an isolated request; reject and release retained candidates and integration checkouts first. Terminate only exact owned workers. Repeat abort to reconcile cleanup of unchanged no-candidate allocations; preserve dirty or committed work.",
769
+ description: "Abort an isolated request; reject and release retained candidates and integration checkouts first unless their checkouts and branches are proved absent. Terminate only exact owned workers. Repeat abort to reconcile cleanup of unchanged allocations; preserve dirty or committed work.",
770
770
  parameters: IdOnlySchema,
771
771
  prepareArguments: parseIdOnly,
772
772
  exposure: "model-only",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "27.2.1",
3
+ "version": "27.3.1",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",