@selesai/code 0.8.4 → 0.8.6

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.
Files changed (147) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +7 -1
  3. package/dist/core/slash-commands.js +1 -0
  4. package/dist/defaults/models.json +46 -55
  5. package/dist/defaults/settings.json +7 -8
  6. package/dist/extensions/agent-browser.test.ts +178 -2
  7. package/dist/extensions/agent-browser.ts +2 -0
  8. package/dist/extensions/copy-turn.test.ts +229 -0
  9. package/dist/extensions/copy-turn.ts +11 -0
  10. package/dist/extensions/grep-app/index.test.ts +356 -1
  11. package/dist/extensions/grep-app/index.ts +6 -1
  12. package/dist/extensions/handoff-new.test.ts +351 -161
  13. package/dist/extensions/handoff-new.ts +3 -0
  14. package/dist/extensions/inline-skills.test.ts +93 -0
  15. package/dist/extensions/inline-skills.ts +3 -0
  16. package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
  17. package/dist/extensions/pi-subagents/README.md +2 -0
  18. package/dist/extensions/pi-subagents/agents/builder.md +5 -2
  19. package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
  20. package/dist/extensions/pi-subagents/docs/configuration.md +54 -2
  21. package/dist/extensions/pi-subagents/docs/observability.md +1 -1
  22. package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
  23. package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
  24. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  25. package/dist/extensions/pi-subagents/package.json +1 -1
  26. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
  27. package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
  28. package/dist/extensions/pi-subagents/src/extension/config.ts +12 -0
  29. package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
  30. package/dist/extensions/pi-subagents/src/extension/index.ts +104 -14
  31. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
  32. package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
  33. package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
  34. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
  35. package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
  36. package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
  37. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
  38. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
  39. package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
  40. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
  41. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
  42. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
  43. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +14 -5
  44. package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
  45. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
  46. package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
  47. package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
  48. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
  49. package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
  50. package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
  51. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +12 -6
  52. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
  53. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
  54. package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
  55. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
  56. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
  57. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
  58. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
  59. package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
  60. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +653 -196
  61. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
  62. package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
  63. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
  64. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
  65. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
  66. package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
  67. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
  68. package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
  69. package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
  70. package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
  71. package/dist/extensions/pi-subagents/src/shared/types.ts +108 -0
  72. package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
  73. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
  74. package/dist/extensions/pi-subagents/src/tui/fleet.ts +226 -13
  75. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
  76. package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
  77. package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
  78. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
  79. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
  80. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
  81. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
  82. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
  83. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
  84. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
  85. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
  86. package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
  87. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
  88. package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
  89. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
  90. package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
  91. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
  92. package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
  93. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
  94. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
  95. package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
  96. package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
  97. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
  98. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
  99. package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
  100. package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
  101. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
  102. package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
  103. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
  104. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
  105. package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
  106. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
  107. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
  108. package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
  109. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
  110. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
  111. package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
  112. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
  113. package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
  114. package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
  115. package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
  116. package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
  117. package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
  118. package/dist/extensions/ponytail/index.js +11 -0
  119. package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
  120. package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
  121. package/dist/extensions/ponytail/test/extension.test.js +274 -140
  122. package/dist/extensions/ponytail/test/helpers.test.js +280 -92
  123. package/dist/extensions/question/index.ts +18 -0
  124. package/dist/extensions/question/question-list.ts +22 -0
  125. package/dist/extensions/question/tests/batch.test.ts +103 -65
  126. package/dist/extensions/question/tests/helpers.test.ts +68 -35
  127. package/dist/extensions/question/tests/question-list.test.ts +506 -204
  128. package/dist/extensions/question/tests/row-layout.test.ts +180 -114
  129. package/dist/extensions/question/tests/schemas.test.ts +42 -19
  130. package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
  131. package/dist/extensions/question/tests/wizard.test.ts +771 -50
  132. package/dist/extensions/rtk.test.ts +180 -1
  133. package/dist/extensions/tokenin-onboarding.ts +3 -0
  134. package/dist/extensions/undo.test.ts +720 -0
  135. package/dist/extensions/undo.ts +6 -0
  136. package/dist/extensions/web-agent-onboarding.test.ts +415 -0
  137. package/dist/extensions/workflow/extension.ts +3 -3
  138. package/dist/extensions/workflow/modes.ts +41 -19
  139. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  140. package/dist/modes/interactive/interactive-mode.js +48 -1
  141. package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
  142. package/docs/plans/workflow-autoloop-reference.md +1 -2
  143. package/docs/plans/workflow-handoff-carryover.md +108 -0
  144. package/docs/quickstart.md +27 -43
  145. package/docs/settings.md +4 -0
  146. package/docs/usage.md +1 -0
  147. package/package.json +5 -1
@@ -5,8 +5,10 @@ import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { after, afterEach, before, beforeEach, describe, it } from "node:test";
7
7
  import { ASYNC_DIR, INTERCOM_DETACH_REQUEST_EVENT, RESULTS_DIR, SUBAGENT_ASYNC_STARTED_EVENT, SUBAGENT_FOREGROUND_COMPLETE_EVENT } from "../../src/shared/types.ts";
8
+ import { ACTIVE_ASYNC_CAPACITY_DIR, acquireActiveAsyncCapacity, activeAsyncCapacitySessionKey, getActiveAsyncCapacitySnapshot } from "../../src/runs/background/active-async-capacity.ts";
8
9
  import { waitForSubagents } from "../../src/runs/background/subagent-wait.ts";
9
- import { sessionLeaseDir } from "../../src/runs/shared/session-lease.ts";
10
+ import { createRunFanoutBudget } from "../../src/runs/shared/run-fanout-budget.ts";
11
+ import { acquireSessionLease, sessionLeaseDir } from "../../src/runs/shared/session-lease.ts";
10
12
  import type { MockPi } from "../support/helpers.ts";
11
13
  import {
12
14
  createMockPi,
@@ -82,6 +84,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
82
84
  let mockPi: MockPi;
83
85
  let originalHome: string | undefined;
84
86
  let originalUserProfile: string | undefined;
87
+ let budgetDirectories: string[] = [];
85
88
 
86
89
  before(() => {
87
90
  originalHome = process.env.HOME;
@@ -112,6 +115,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
112
115
 
113
116
  afterEach(() => {
114
117
  removeTempDir(tempDir);
118
+ for (const directory of budgetDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
115
119
  });
116
120
 
117
121
  async function readMockCallArgs(index: number): Promise<string[]> {
@@ -162,7 +166,39 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
162
166
  }
163
167
  }
164
168
 
165
- function makeExecutor(options: { bridgeMode?: "always" | "off"; resultDelivery?: boolean; agents?: ReturnType<typeof makeAgent>[]; acknowledgeResults?: boolean; kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean } = {}) {
169
+ async function waitForStatus(filePath: string, predicate: (status: any) => boolean, timeoutMs = 10_000): Promise<any> {
170
+ const deadline = Date.now() + timeoutMs;
171
+ let lastStatus: any;
172
+ while (Date.now() <= deadline) {
173
+ if (fs.existsSync(filePath)) {
174
+ lastStatus = JSON.parse(fs.readFileSync(filePath, "utf-8"));
175
+ if (predicate(lastStatus)) return lastStatus;
176
+ }
177
+ await new Promise((resolve) => setTimeout(resolve, 50));
178
+ }
179
+ assert.fail(`Timed out waiting for status at ${filePath}; last status: ${JSON.stringify(lastStatus)}`);
180
+ }
181
+
182
+ function writeRecoveryDescriptor(asyncDir: string, runId: string, agent = "worker", overrides: Record<string, unknown> = {}): void {
183
+ const runFanoutBudget = createRunFanoutBudget(runId, 64);
184
+ budgetDirectories.push(runFanoutBudget.directory);
185
+ fs.writeFileSync(path.join(asyncDir, "recovery-descriptor.json"), JSON.stringify({
186
+ version: 1,
187
+ runFanoutBudget,
188
+ sourceRunId: runId,
189
+ agent,
190
+ cwd: tempDir,
191
+ systemPromptMode: "replace",
192
+ inheritProjectContext: false,
193
+ inheritSkills: false,
194
+ outputMode: "inline",
195
+ maxSubagentDepth: 2,
196
+ share: false,
197
+ ...overrides,
198
+ }, null, 2), "utf-8");
199
+ }
200
+
201
+ function makeExecutor(options: { bridgeMode?: "always" | "off"; resultDelivery?: boolean; agents?: ReturnType<typeof makeAgent>[]; acknowledgeResults?: boolean; kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean; maxActiveAsyncRunsPerSession?: number } = {}) {
166
202
  const events = createRecordingEventBus({ acknowledgeResults: options.acknowledgeResults ?? true });
167
203
  const state = {
168
204
  baseCwd: tempDir,
@@ -194,6 +230,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
194
230
  mode: options.bridgeMode ?? "always",
195
231
  ...(options.resultDelivery === undefined ? {} : { resultDelivery: options.resultDelivery }),
196
232
  },
233
+ ...(options.maxActiveAsyncRunsPerSession === undefined ? {} : { maxActiveAsyncRunsPerSession: options.maxActiveAsyncRunsPerSession }),
197
234
  },
198
235
  asyncByDefault: false,
199
236
  tempArtifactsDir: tempDir,
@@ -270,6 +307,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
270
307
  sessionFile,
271
308
  steps: [{ agent: "worker", status: "complete", sessionFile }],
272
309
  }, null, 2), "utf-8");
310
+ writeRecoveryDescriptor(sourceAsyncDir, sourceRunId);
273
311
  const { executor } = makeExecutor();
274
312
 
275
313
  const result = await executor.execute(
@@ -744,7 +782,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
744
782
  assert.deepEqual(startedEvent?.agents, ["worker", "reviewer"]);
745
783
  assert.deepEqual(startedEvent?.chain, ["worker", "reviewer"]);
746
784
  assert.equal(startedEvent?.chainStepCount, 2);
747
- assert.equal(startedEvent?.goal, "Review this root result: {previous}");
785
+ assert.equal(startedEvent?.goal, "[prompt redacted]");
748
786
  const attachedId = result.details?.asyncId;
749
787
  assert.ok(attachedId, "expected attached chain async id");
750
788
  assert.match(result.details?.asyncDir ?? "", new RegExp(`${attachedId}$`));
@@ -846,6 +884,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
846
884
  { agent: "b", status: "complete", sessionFile: secondSession, model: "anthropic/claude-sonnet-4", thinking: "high" },
847
885
  ],
848
886
  }, null, 2), "utf-8");
887
+ writeRecoveryDescriptor(asyncDir, runId, "b", { model: "anthropic/claude-sonnet-4:high" });
849
888
  const { executor } = makeExecutor({ agents: [makeAgent("a"), makeAgent("b")] });
850
889
 
851
890
  const result = await executor.execute(
@@ -887,6 +926,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
887
926
  sessionFile,
888
927
  steps: [{ agent: "worker", status: "complete", launchContractDigest: "source-launch-contract-digest" }],
889
928
  }, null, 2), "utf-8");
929
+ writeRecoveryDescriptor(asyncDir, runId, "worker", { launchContractDigest: "source-launch-contract-digest" });
890
930
  const { executor, events } = makeExecutor();
891
931
 
892
932
  const result = await executor.execute(
@@ -907,7 +947,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
907
947
  assert.ok(revivedId, "expected revived async id");
908
948
  assert.equal(result.details?.sourceLaunchContractDigest, "source-launch-contract-digest");
909
949
  const startedEvent = events.emitted.find((entry) => entry.channel === SUBAGENT_ASYNC_STARTED_EVENT && (entry.payload as { id?: string }).id === revivedId)?.payload as { goal?: string } | undefined;
910
- assert.equal(startedEvent?.goal, "What changed?");
950
+ assert.equal(startedEvent?.goal, "[prompt redacted]");
911
951
  const resultPath = path.join(RESULTS_DIR, `${revivedId}.json`);
912
952
  const deadline = Date.now() + 10_000;
913
953
  while (!fs.existsSync(resultPath)) {
@@ -932,21 +972,12 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
932
972
  runId, sessionId: "session-123", mode: "single", state: "paused", startedAt: 100, lastUpdate: 200, cwd: tempDir,
933
973
  steps: [{ agent: "removed-worker", status: "paused", sessionFile }],
934
974
  }, null, 2), "utf-8");
935
- fs.writeFileSync(path.join(asyncDir, "recovery-descriptor.json"), JSON.stringify({
936
- version: 1,
937
- sourceRunId: runId,
938
- agent: "removed-worker",
939
- cwd: tempDir,
975
+ writeRecoveryDescriptor(asyncDir, runId, "removed-worker", {
940
976
  model: "anthropic/claude-sonnet-4:high",
941
977
  tools: ["read"],
942
978
  systemPrompt: "Original persisted prompt",
943
- systemPromptMode: "replace",
944
- inheritProjectContext: false,
945
- inheritSkills: false,
946
- outputMode: "inline",
947
979
  maxSubagentDepth: 1,
948
- share: false,
949
- }, null, 2), "utf-8");
980
+ });
950
981
  const { executor } = makeExecutor({ agents: [] });
951
982
 
952
983
  const result = await executor.execute(
@@ -992,6 +1023,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
992
1023
  sessionFile,
993
1024
  steps: [{ agent: "worker", status: "complete" }],
994
1025
  }, null, 2), "utf-8");
1026
+ writeRecoveryDescriptor(sourceAsyncDir, sourceRunId);
995
1027
  try {
996
1028
  const { executor } = makeExecutor();
997
1029
  const first = await executor.execute(
@@ -1036,6 +1068,170 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
1036
1068
  }
1037
1069
  });
1038
1070
 
1071
+ it("marks a resumed workflow child async when its runner starts and fails before proceed", async () => {
1072
+ const parentSessionId = `session-workflow-resume-start-${Date.now()}`;
1073
+ const sourceRunId = `workflow-resume-start-source-${Date.now()}`;
1074
+ const sourceAsyncDir = path.join(ASYNC_DIR, sourceRunId);
1075
+ const sessionFile = path.join(tempDir, "workflow-resume-start-session.jsonl");
1076
+ let workflowRunId: string | undefined;
1077
+ let revivedRunId: string | undefined;
1078
+ try {
1079
+ fs.mkdirSync(sourceAsyncDir, { recursive: true });
1080
+ fs.writeFileSync(sessionFile, "", "utf-8");
1081
+ fs.writeFileSync(path.join(sourceAsyncDir, "status.json"), JSON.stringify({
1082
+ runId: sourceRunId,
1083
+ sessionId: parentSessionId,
1084
+ mode: "single",
1085
+ state: "complete",
1086
+ startedAt: 100,
1087
+ lastUpdate: 200,
1088
+ cwd: tempDir,
1089
+ sessionFile,
1090
+ steps: [{ agent: "worker", status: "complete", sessionFile }],
1091
+ }, null, 2), "utf-8");
1092
+ writeRecoveryDescriptor(sourceAsyncDir, sourceRunId);
1093
+ const competingLease = acquireSessionLease({ sessionFile, runId: "workflow-competing-revival", sourceRunId, parentSessionId });
1094
+ const { executor } = makeExecutor({ maxActiveAsyncRunsPerSession: 1 });
1095
+ const ctx = makeMinimalCtx(tempDir);
1096
+ ctx.sessionManager.getSessionId = () => parentSessionId;
1097
+ try {
1098
+ const failed = await executor.execute(
1099
+ "workflow-resume-start-failure",
1100
+ { workflowScript: `return await runs.run("resume-child", { resume: "${sourceRunId}", task: "Continue" });`, async: true },
1101
+ new AbortController().signal,
1102
+ undefined,
1103
+ ctx,
1104
+ );
1105
+ workflowRunId = failed.details?.asyncId;
1106
+ assert.ok(workflowRunId, "expected async workflow id");
1107
+ await waitForFile(path.join(RESULTS_DIR, `${workflowRunId}.json`));
1108
+ const workflowStatusPath = path.join(ASYNC_DIR, workflowRunId, "status.json");
1109
+ const workflowStatus = await waitForStatus(workflowStatusPath, (value) => value?.state === "failed") as { steps?: Array<{ async?: boolean; runId?: string }>; error?: string };
1110
+ revivedRunId = workflowStatus.steps?.[0]?.runId;
1111
+
1112
+ assert.equal(workflowStatus.steps?.[0]?.async, true);
1113
+ assert.ok(revivedRunId, "expected the workflow step to keep revived run identity");
1114
+ assert.match(workflowStatus.error ?? "", /already owned by run 'workflow-competing-revival'/);
1115
+ assert.deepEqual(getActiveAsyncCapacitySnapshot(parentSessionId, 1), { used: 0, limit: 1 });
1116
+ } finally {
1117
+ competingLease.release();
1118
+ }
1119
+ } finally {
1120
+ fs.rmSync(sourceAsyncDir, { recursive: true, force: true });
1121
+ if (workflowRunId) fs.rmSync(path.join(ASYNC_DIR, workflowRunId), { recursive: true, force: true });
1122
+ if (revivedRunId) fs.rmSync(path.join(ASYNC_DIR, revivedRunId), { recursive: true, force: true });
1123
+ fs.rmSync(path.join(ACTIVE_ASYNC_CAPACITY_DIR, activeAsyncCapacitySessionKey(parentSessionId)), { recursive: true, force: true });
1124
+ }
1125
+ });
1126
+
1127
+ it("releases async workflow capacity after child launch preparation fails", async () => {
1128
+ const parentSessionId = `session-workflow-prep-${Date.now()}`;
1129
+ try {
1130
+ const { executor } = makeExecutor({ maxActiveAsyncRunsPerSession: 1 });
1131
+ const ctx = makeMinimalCtx(tempDir);
1132
+ ctx.sessionManager.getSessionId = () => parentSessionId;
1133
+ const failed = await executor.execute(
1134
+ "workflow-prep-capacity-release",
1135
+ {
1136
+ workflowScript: `return await runs.run("gated", { agent: "worker", task: "run", gate: "npm test" });`,
1137
+ async: true,
1138
+ acceptance: false,
1139
+ },
1140
+ new AbortController().signal,
1141
+ undefined,
1142
+ ctx,
1143
+ );
1144
+ const workflowRunId = failed.details?.asyncId;
1145
+ assert.ok(workflowRunId, "expected async workflow id");
1146
+ await waitForFile(path.join(RESULTS_DIR, `${workflowRunId}.json`));
1147
+ const statusPath = path.join(ASYNC_DIR, workflowRunId, "status.json");
1148
+ const status = await waitForStatus(statusPath, (value) => value?.state === "failed") as { state?: string; steps?: Array<{ async?: boolean }>; error?: string };
1149
+
1150
+ assert.equal(status.state, "failed");
1151
+ assert.equal(status.steps?.[0]?.async, false);
1152
+ assert.match(status.error ?? "", /gate cannot be combined with acceptance/);
1153
+ assert.deepEqual(getActiveAsyncCapacitySnapshot(parentSessionId, 1), { used: 0, limit: 1 });
1154
+
1155
+ const next = await executor.execute(
1156
+ "workflow-prep-capacity-next",
1157
+ { workflowScript: `return "ok";`, async: true },
1158
+ new AbortController().signal,
1159
+ undefined,
1160
+ ctx,
1161
+ );
1162
+ assert.equal(next.isError, undefined);
1163
+ const nextWorkflowRunId = next.details?.asyncId;
1164
+ assert.ok(nextWorkflowRunId, "expected the next async workflow to acquire capacity");
1165
+ await waitForFile(path.join(RESULTS_DIR, `${nextWorkflowRunId}.json`));
1166
+ } finally {
1167
+ fs.rmSync(path.join(ACTIVE_ASYNC_CAPACITY_DIR, activeAsyncCapacitySessionKey(parentSessionId)), { recursive: true, force: true });
1168
+ }
1169
+ });
1170
+
1171
+ it("restores active capacity when revival fails before startup proceed", async () => {
1172
+ const parentSessionId = `session-resume-handshake-${Date.now()}`;
1173
+ const sourceRunId = `resume-handshake-source-${Date.now()}`;
1174
+ const sourceAsyncDir = path.join(ASYNC_DIR, sourceRunId);
1175
+ const sessionFile = path.join(tempDir, "resume-handshake-child.jsonl");
1176
+ fs.mkdirSync(sourceAsyncDir, { recursive: true });
1177
+ fs.writeFileSync(sessionFile, "", "utf-8");
1178
+ fs.writeFileSync(path.join(sourceAsyncDir, "status.json"), JSON.stringify({
1179
+ runId: sourceRunId,
1180
+ sessionId: parentSessionId,
1181
+ mode: "single",
1182
+ state: "complete",
1183
+ startedAt: 100,
1184
+ lastUpdate: 200,
1185
+ cwd: tempDir,
1186
+ sessionFile,
1187
+ steps: [{ agent: "worker", status: "complete", sessionFile }],
1188
+ }, null, 2), "utf-8");
1189
+ writeRecoveryDescriptor(sourceAsyncDir, sourceRunId);
1190
+ const sourceCapacity = acquireActiveAsyncCapacity({ sessionId: parentSessionId, limit: 1, runId: sourceRunId, kind: "runner", asyncDir: sourceAsyncDir });
1191
+ assert.ok(sourceCapacity);
1192
+ sourceCapacity.markStarted("source-runner");
1193
+ fs.writeFileSync(path.join(sourceAsyncDir, "process-terminal.json"), JSON.stringify({
1194
+ version: 1,
1195
+ state: "observed",
1196
+ runId: sourceRunId,
1197
+ runnerProcessInstanceId: "source-runner",
1198
+ observedAt: Date.now(),
1199
+ instances: [{ kind: "runner", processInstanceId: "source-runner", closeObservedAt: Date.now(), exitCode: 0, signal: null }],
1200
+ }), "utf-8");
1201
+ const competingLease = acquireSessionLease({ sessionFile, runId: "competing-revival", sourceRunId, parentSessionId });
1202
+ try {
1203
+ const { executor } = makeExecutor({ maxActiveAsyncRunsPerSession: 1 });
1204
+ const ctx = makeMinimalCtx(tempDir);
1205
+ ctx.sessionManager.getSessionId = () => parentSessionId;
1206
+ const failed = await executor.execute(
1207
+ "resume-handshake-failure",
1208
+ { action: "resume", id: sourceRunId, message: "Retry safely" },
1209
+ new AbortController().signal,
1210
+ undefined,
1211
+ ctx,
1212
+ );
1213
+ assert.equal(failed.isError, true);
1214
+ assert.match(failed.content[0]?.text ?? "", /already owned by run 'competing-revival'/);
1215
+ const revivedStatusDirs = fs.readdirSync(ASYNC_DIR)
1216
+ .map((name) => path.join(ASYNC_DIR, name))
1217
+ .filter((dir) => {
1218
+ try {
1219
+ const status = JSON.parse(fs.readFileSync(path.join(dir, "status.json"), "utf-8"));
1220
+ return status.sessionId === parentSessionId && status.runId !== sourceRunId && status.processTerminal?.runnerProcessInstanceId;
1221
+ } catch {
1222
+ return false;
1223
+ }
1224
+ });
1225
+ assert.equal(revivedStatusDirs.length, 1, "expected one failed revived run with runner identity");
1226
+ fs.rmSync(path.join(revivedStatusDirs[0]!, "process-terminal.json"), { force: true });
1227
+ assert.deepEqual(getActiveAsyncCapacitySnapshot(parentSessionId, 1), { used: 0, limit: 1 });
1228
+ } finally {
1229
+ competingLease.release();
1230
+ getActiveAsyncCapacitySnapshot(parentSessionId, 1);
1231
+ fs.rmSync(sourceAsyncDir, { recursive: true, force: true });
1232
+ }
1233
+ });
1234
+
1039
1235
  it("resume action revives a completed foreground child by index", async () => {
1040
1236
  mockPi.onCall({ output: "first child done" });
1041
1237
  mockPi.onCall({ output: "second child done" });
@@ -16,6 +16,7 @@ import * as os from "node:os";
16
16
  import * as path from "node:path";
17
17
  import type { MockPi } from "../support/helpers.ts";
18
18
  import { discoverAgents } from "../../src/agents/agents.ts";
19
+ import { getLivePromptAudit } from "../../src/runs/foreground/prompt-audit.ts";
19
20
  import {
20
21
  createEventBus,
21
22
  createMockPi,
@@ -217,9 +218,9 @@ describe("parallel agent execution", { skip: !piAvailable ? "pi packages not ava
217
218
  const control = [...state.foregroundControls.values()][0];
218
219
  assert.ok(control);
219
220
  assert.deepEqual([...control.activeChildren.values()].map((child) => child.description), [
220
- "Review correctness",
221
- "Review quality",
222
- "Review tests",
221
+ "reviewer child",
222
+ "reviewer child",
223
+ "reviewer child",
223
224
  ]);
224
225
  assert.equal(control.activeChildren.size, 3);
225
226
 
@@ -262,6 +263,43 @@ describe("parallel agent execution", { skip: !piAvailable ? "pi packages not ava
262
263
  assert.ok(updateIndexes.every((index) => Number.isInteger(index)));
263
264
  });
264
265
 
266
+ it("stores redo contracts for worktree-isolated parallel children as fresh managed worktree launches", { skip: !createSubagentExecutor || process.platform === "win32" ? "executor unavailable or worktree paths differ on Windows" : undefined }, async () => {
267
+ git(["init"]);
268
+ git(["config", "user.email", "test@example.com"]);
269
+ git(["config", "user.name", "Test User"]);
270
+ fs.writeFileSync(path.join(tempDir, "tracked.txt"), "base\n", "utf-8");
271
+ git(["add", "tracked.txt"]);
272
+ git(["commit", "-m", "initial"]);
273
+ mockPi.onCall({ output: "Worktree task complete", delay: 1000 });
274
+ const state = { baseCwd: tempDir, currentSessionId: null, asyncJobs: new Map(), foregroundControls: new Map(), lastForegroundControlId: null };
275
+ const executor = makeExecutor([makeAgent("echo")], state);
276
+ const executionPromise = executor.execute(
277
+ "foreground-worktree-redo-contract",
278
+ { tasks: [{ agent: "echo", task: "Work in isolation" }], worktree: true },
279
+ new AbortController().signal,
280
+ undefined,
281
+ makeMinimalCtx(tempDir),
282
+ );
283
+
284
+ let control = [...state.foregroundControls.values()][0];
285
+ let audit = control ? getLivePromptAudit(control, 0) : undefined;
286
+ for (let attempt = 0; attempt < 100 && !audit; attempt++) {
287
+ await new Promise((resolve) => setTimeout(resolve, 10));
288
+ control = [...state.foregroundControls.values()][0];
289
+ audit = control ? getLivePromptAudit(control, 0) : undefined;
290
+ }
291
+ assert.ok(control);
292
+ assert.ok(audit);
293
+ assert.equal(audit.rerun?.params.agent, "echo");
294
+ assert.equal(audit.rerun?.params.task, "Work in isolation");
295
+ assert.equal(audit.rerun?.params.worktree, true);
296
+ assert.equal(audit.rerun?.params.cwd, undefined);
297
+ assert.equal(audit.rerun?.params.tasks, undefined);
298
+
299
+ const result = await executionPromise;
300
+ assert.equal(result.isError, undefined);
301
+ });
302
+
265
303
  it("publishes a durable handoff before cleaning foreground parallel worktrees", { skip: !createSubagentExecutor || process.platform === "win32" ? "executor unavailable or worktree paths differ on Windows" : undefined }, async () => {
266
304
  git(["init"]);
267
305
  git(["config", "user.email", "test@example.com"]);