@henryqw/pi-subagent 27.5.3 → 27.5.4

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.d.ts CHANGED
@@ -350,6 +350,7 @@ export declare class IsolatedRunner {
350
350
  private releaseRejected;
351
351
  private releaseSuperseded;
352
352
  private cleanupPromoted;
353
+ private cleanupPromotedStage;
353
354
  private requireExactIntegration;
354
355
  listOrphanRequests(cwd: string): Promise<{
355
356
  invalidIds: string[];
package/dist/runner.js CHANGED
@@ -592,6 +592,8 @@ export function nextIntegrationAction(state) {
592
592
  return `Call subagent_integrate refresh with generation ${generation.number + 1}, expectedTip ${JSON.stringify(generation.combinedTip)}, and identical expectedMain and newMain; then restage the candidates and validate again.`;
593
593
  }
594
594
  return undefined;
595
+ case "promoted":
596
+ return `Promotion is proven, but cleanup retained resources; resolve the recorded cleanup failures without discarding work, then call subagent_integrate cleanup with ${exact}.`;
595
597
  default:
596
598
  return undefined;
597
599
  }
@@ -1062,6 +1064,8 @@ export class IsolatedRunner {
1062
1064
  if (action.action === "reject" && allocationByKind(attempt, "agent")) {
1063
1065
  if (await this.terminateWithSafety(handle, task, attempt, action.candidate, scope.signal))
1064
1066
  task.status = "rejected";
1067
+ else
1068
+ this.attention(task, `Worker termination is unproved: ${attempt.termination.failure}`);
1065
1069
  await this.saveProductive(handle);
1066
1070
  }
1067
1071
  if (action.action === "revise") {
@@ -1605,101 +1609,123 @@ export class IsolatedRunner {
1605
1609
  // Promotion proves selection independently of worker or checkout cleanup.
1606
1610
  for (const stage of generation.stages)
1607
1611
  changesetTaskState(state, stage.taskId).status = "completed";
1612
+ const generationCleanup = generation.cleanup ??= [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
1613
+ const stepLists = [...generation.stages.map((stage) => changesetTaskState(state, stage.taskId).attempts[stage.attempt - 1].cleanup),
1614
+ generationCleanup];
1615
+ // Record each failure on the step it blocks; other stages and the integration checkout continue.
1616
+ const blocked = (steps, error) => {
1617
+ const step = steps.find((item) => item.status === "running") ?? steps.find((item) => item.status !== "completed");
1618
+ if (step)
1619
+ step.failure = bounded(`Cleanup requires attention: ${errorText(error)}`);
1620
+ };
1608
1621
  await this.saveProductive(handle);
1622
+ let main;
1609
1623
  try {
1610
- const main = await this.callProductive(handle, scope, (context) => this.gitRuntime.inspectMain({ root: state.root }, context));
1611
- if (!sameIdentity(main, promoted))
1612
- throw new Error("Main changed or became dirty after promotion; cleanup is blocked.");
1624
+ main = await this.callProductive(handle, scope, (context) => this.gitRuntime.inspectMain({ root: state.root }, context));
1625
+ }
1626
+ catch (error) {
1627
+ for (const steps of stepLists)
1628
+ blocked(steps, error);
1629
+ }
1630
+ if (main && !sameIdentity(main, promoted)) {
1631
+ for (const steps of stepLists)
1632
+ blocked(steps, new Error("Main changed or became dirty after promotion; cleanup is blocked."));
1633
+ }
1634
+ else if (main) {
1613
1635
  for (const stage of generation.stages) {
1614
1636
  const task = changesetTaskState(state, stage.taskId);
1615
1637
  const attempt = task.attempts[stage.attempt - 1];
1616
- const candidate = state.integration.candidates.find((item) => item.taskId === stage.taskId && item.attempt === stage.attempt
1617
- && sameIdentity(item.tip, stage.source));
1618
- const worker = allocationByKind(attempt, "worktree")?.worktree;
1619
- if (!worker)
1620
- throw new Error("Promoted worker lacks a proven checkout.");
1621
- candidate.worker = "release_pending";
1622
- await this.saveProductive(handle);
1623
- if (attempt.termination?.status !== "terminated") {
1624
- if (attempt.termination) {
1625
- const result = await this.callProductive(handle, scope, (context) => this.hostRuntime.reconcileWorkerTermination({
1626
- task: changesetTaskRequest(state, task.taskId), attempt,
1627
- workerId: attempt.termination.workerId, candidate: stage.source,
1628
- }, context));
1629
- if (result.outcome !== "terminated")
1630
- throw new Error("Worker termination cannot be proved; never replay termination.");
1631
- attempt.termination = { status: "terminated", workerId: attempt.termination.workerId,
1632
- candidate: stage.source, at: this.coordinatorRuntime.now() };
1633
- await this.saveProductive(handle);
1634
- }
1635
- else if (!await this.terminateWithSafety(handle, task, attempt, stage.source, scope.signal)) {
1636
- throw new Error("Worker termination was not proved.");
1637
- }
1638
+ try {
1639
+ await this.cleanupPromotedStage(handle, generation, receipts, promoted, stage, task, attempt, scope);
1638
1640
  }
1639
- for (const kind of CLEANUP_KINDS) {
1640
- const step = attempt.cleanup.find((item) => item.kind === kind);
1641
+ catch (error) {
1642
+ blocked(attempt.cleanup, error);
1643
+ }
1644
+ await this.saveProductive(handle);
1645
+ }
1646
+ try {
1647
+ for (const step of generationCleanup) {
1641
1648
  if (step.status === "completed")
1642
1649
  continue;
1643
1650
  step.status = "running";
1644
1651
  await this.saveProductive(handle);
1645
- if (kind === "worker_tab" || kind === "workspace") {
1646
- const result = await this.callProductive(handle, scope, (context) => this.hostRuntime.cleanupHost({
1647
- requestId: state.request.id, kind, task: changesetTaskRequest(state, task.taskId), attempt,
1648
- }, context));
1649
- if (result.outcome === "blocked")
1650
- throw new Error(result.failure);
1651
- }
1652
- else {
1653
- const result = await this.callProductive(handle, scope, (context) => this.integrationGit.cleanup(state.root, generation.worktree, worker, generation.expectedMain, receipts, promoted, kind, context.signal, generation.correction));
1654
- if (result.outcome !== "ready")
1655
- throw new Error(result.failure);
1656
- }
1652
+ const result = await this.callProductive(handle, scope, (context) => this.integrationGit.cleanup(state.root, generation.worktree, generation.worktree, generation.expectedMain, receipts, promoted, step.kind, context.signal, generation.correction));
1653
+ if (result.outcome !== "ready")
1654
+ throw new Error(result.failure);
1657
1655
  step.status = "completed";
1658
1656
  delete step.failure;
1659
1657
  await this.saveProductive(handle);
1660
1658
  }
1661
- candidate.worker = "released";
1662
- // Same-attempt revisions share this exact worker checkout; release historical
1663
- // rejected tips only after the physical checkout has been proved removed.
1664
- for (const old of state.integration.candidates.filter((item) => item.decision === "rejected"
1665
- && item.taskId === stage.taskId && item.attempt === stage.attempt))
1666
- old.worker = "released";
1667
- await this.saveProductive(handle);
1668
1659
  }
1669
- generation.cleanup ??= [{ kind: "worktree", status: "pending" }, { kind: "branch", status: "pending" }];
1670
- await this.saveProductive(handle);
1671
- for (const kind of ["worktree", "branch"]) {
1672
- const step = generation.cleanup.find((item) => item.kind === kind);
1673
- if (step.status === "completed")
1674
- continue;
1675
- step.status = "running";
1676
- await this.saveProductive(handle);
1677
- const result = await this.callProductive(handle, scope, (context) => this.integrationGit.cleanup(state.root, generation.worktree, generation.worktree, generation.expectedMain, receipts, promoted, kind, context.signal, generation.correction));
1678
- if (result.outcome !== "ready")
1679
- throw new Error(result.failure);
1680
- step.status = "completed";
1681
- delete step.failure;
1682
- await this.saveProductive(handle);
1660
+ catch (error) {
1661
+ blocked(generationCleanup, error);
1683
1662
  }
1684
1663
  }
1685
- catch (error) {
1686
- generation.failure = undefined; // Promotion remains proven; cleanup evidence stays inspectable.
1687
- const pending = generation.cleanup?.find((step) => step.status === "running")
1688
- ?? generation.stages.flatMap((stage) => changesetTaskState(state, stage.taskId).attempts[stage.attempt - 1].cleanup)
1689
- .find((step) => step.status === "running");
1690
- if (pending)
1691
- pending.failure = bounded(`Cleanup requires attention: ${errorText(error)}`);
1692
- await this.saveProductive(handle);
1693
- }
1694
- if (state.tasks.every((task) => task.status === "completed" || task.status === "rejected")
1664
+ // Promotion remains proven; the request stays visible until every promoted resource is released.
1665
+ if (stepLists.every((steps) => steps.every((step) => step.status === "completed"))
1666
+ && state.tasks.every((task) => task.status === "completed" || task.status === "rejected")
1695
1667
  && state.integration.candidates.every((candidate) => candidate.decision || generation.stages.some((stage) => stage.taskId === candidate.taskId && stage.attempt === candidate.attempt && sameIdentity(stage.source, candidate.tip)))) {
1696
1668
  state.final = { status: "passed", identity: promoted, checks: generation.checks,
1697
1669
  ...(generation.review ? { review: generation.review } : {}) };
1698
1670
  state.status = "completed";
1699
1671
  state.accepted = true;
1700
1672
  state.acceptedAt = this.coordinatorRuntime.now();
1673
+ }
1674
+ await this.saveProductive(handle);
1675
+ }
1676
+ async cleanupPromotedStage(handle, generation, receipts, promoted, stage, task, attempt, scope) {
1677
+ const state = handle.state;
1678
+ const candidate = state.integration.candidates.find((item) => item.taskId === stage.taskId && item.attempt === stage.attempt
1679
+ && sameIdentity(item.tip, stage.source));
1680
+ const worker = allocationByKind(attempt, "worktree")?.worktree;
1681
+ if (!worker)
1682
+ throw new Error("Promoted worker lacks a proven checkout.");
1683
+ candidate.worker = "release_pending";
1684
+ await this.saveProductive(handle);
1685
+ if (attempt.termination?.status !== "terminated") {
1686
+ if (attempt.termination) {
1687
+ const result = await this.callProductive(handle, scope, (context) => this.hostRuntime.reconcileWorkerTermination({
1688
+ task: changesetTaskRequest(state, task.taskId), attempt,
1689
+ workerId: attempt.termination.workerId, candidate: stage.source,
1690
+ }, context));
1691
+ if (result.outcome !== "terminated")
1692
+ throw new Error("Worker termination cannot be proved; never replay termination.");
1693
+ attempt.termination = { status: "terminated", workerId: attempt.termination.workerId,
1694
+ candidate: stage.source, at: this.coordinatorRuntime.now() };
1695
+ await this.saveProductive(handle);
1696
+ }
1697
+ else if (!await this.terminateWithSafety(handle, task, attempt, stage.source, scope.signal)) {
1698
+ throw new Error(`Worker termination was not proved: ${attempt.termination.failure}`);
1699
+ }
1700
+ }
1701
+ for (const kind of CLEANUP_KINDS) {
1702
+ const step = attempt.cleanup.find((item) => item.kind === kind);
1703
+ if (step.status === "completed")
1704
+ continue;
1705
+ step.status = "running";
1706
+ await this.saveProductive(handle);
1707
+ if (kind === "worker_tab" || kind === "workspace") {
1708
+ const result = await this.callProductive(handle, scope, (context) => this.hostRuntime.cleanupHost({
1709
+ requestId: state.request.id, kind, task: changesetTaskRequest(state, task.taskId), attempt,
1710
+ }, context));
1711
+ if (result.outcome === "blocked")
1712
+ throw new Error(result.failure);
1713
+ }
1714
+ else {
1715
+ const result = await this.callProductive(handle, scope, (context) => this.integrationGit.cleanup(state.root, generation.worktree, worker, generation.expectedMain, receipts, promoted, kind, context.signal, generation.correction));
1716
+ if (result.outcome !== "ready")
1717
+ throw new Error(result.failure);
1718
+ }
1719
+ step.status = "completed";
1720
+ delete step.failure;
1701
1721
  await this.saveProductive(handle);
1702
1722
  }
1723
+ candidate.worker = "released";
1724
+ // Same-attempt revisions share this exact worker checkout; release historical
1725
+ // rejected tips only after the physical checkout has been proved removed.
1726
+ for (const old of state.integration.candidates.filter((item) => item.decision === "rejected"
1727
+ && item.taskId === stage.taskId && item.attempt === stage.attempt))
1728
+ old.worker = "released";
1703
1729
  }
1704
1730
  async requireExactIntegration(handle, generation, receipts, scope, allowDirtyMain = false) {
1705
1731
  const root = handle.state.root;
@@ -2736,7 +2762,6 @@ export class IsolatedRunner {
2736
2762
  if (result.outcome !== "terminated") {
2737
2763
  const failure = boundedRuntimeText(result.failure, "Worker termination failure");
2738
2764
  attempt.termination = { status: "unknown", workerId, candidate, failure };
2739
- this.attention(task, `Worker termination is unproved: ${failure}`);
2740
2765
  return false;
2741
2766
  }
2742
2767
  attempt.termination = { status: "terminated", workerId, candidate, at: this.coordinatorRuntime.now() };
@@ -2745,7 +2770,6 @@ export class IsolatedRunner {
2745
2770
  catch (error) {
2746
2771
  this.rethrowStopped(error);
2747
2772
  attempt.termination = { status: "unknown", workerId, candidate, failure: errorText(error) };
2748
- this.attention(task, `Worker termination is unproved: ${errorText(error)}`);
2749
2773
  return false;
2750
2774
  }
2751
2775
  finally {
@@ -295,6 +295,8 @@ Rejection invalidates dependent work. Rejection and revision ignore dependent wo
295
295
 
296
296
  Use the generation's exact `combinedTip` as `expectedTip`. `advance` dispatches ready dependents from that snapshot. `correct` records one clean committed correction after a definitive failed check or review. `reconcile` inspects interrupted validation or promotion without replaying uncertain promotion. `cleanup` finishes proved post-promotion cleanup.
297
297
 
298
+ Post-promotion cleanup handles each promoted stage separately. A blocked termination or cleanup step records its failure on that step, and cleanup continues with the other stages and then the promoted integration checkout. Clean Main at the promoted identity is a shared precondition; if Main changed, every pending step records the blocker. Dirty, untracked, or ignored files block worktree removal and stay preserved. Until every promoted termination and cleanup step completes, the request stays `needs_attention` with promotion proven, selected tasks `completed`, and not accepted, so `/subagent` lists it. Resolve the recorded blockers, then call `cleanup` again; the request becomes `completed` and accepted only after cleanup completes.
299
+
298
300
  Validation acknowledges only after saving the exact generation's validation intent. Checks and final judgment then run in the background. Later turn cancellation does not cancel acknowledged validation; session replacement or shutdown does. Acknowledgement is not passing evidence. Inspect status if the completion follow-up fails.
299
301
 
300
302
  #### Refresh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-subagent",
3
- "version": "27.5.3",
3
+ "version": "27.5.4",
4
4
  "description": "Delegate bounded direct work or durable checked isolated task graphs to Pi roles.",
5
5
  "keywords": [
6
6
  "pi-package",