@selesai/code 0.8.4 → 0.8.5
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/CHANGELOG.md +10 -0
- package/README.md +7 -1
- package/dist/core/slash-commands.js +1 -0
- package/dist/defaults/models.json +41 -55
- package/dist/defaults/settings.json +7 -8
- package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
- package/dist/extensions/pi-subagents/README.md +2 -0
- package/dist/extensions/pi-subagents/docs/configuration.md +46 -2
- package/dist/extensions/pi-subagents/docs/observability.md +1 -1
- package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
- package/dist/extensions/pi-subagents/package-lock.json +2 -2
- package/dist/extensions/pi-subagents/package.json +1 -1
- package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
- package/dist/extensions/pi-subagents/src/extension/config.ts +6 -0
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +21 -0
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
- package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
- package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
- package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +11 -5
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
- package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +5 -1
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +614 -187
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
- package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
- package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
- package/dist/extensions/pi-subagents/src/shared/types.ts +92 -0
- package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +225 -12
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
- package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
- package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
- package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
- package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
- package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
- package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
- package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
- package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
- package/dist/modes/interactive/interactive-mode.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode.js +48 -1
- package/docs/quickstart.md +27 -43
- package/docs/settings.md +4 -0
- package/docs/usage.md +1 -0
- package/package.json +2 -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 {
|
|
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
|
|
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, "
|
|
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, "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
221
|
-
"
|
|
222
|
-
"
|
|
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"]);
|