@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.
- package/CHANGELOG.md +22 -0
- package/README.md +7 -1
- package/dist/core/slash-commands.js +1 -0
- package/dist/defaults/models.json +46 -55
- package/dist/defaults/settings.json +7 -8
- package/dist/extensions/agent-browser.test.ts +178 -2
- package/dist/extensions/agent-browser.ts +2 -0
- package/dist/extensions/copy-turn.test.ts +229 -0
- package/dist/extensions/copy-turn.ts +11 -0
- package/dist/extensions/grep-app/index.test.ts +356 -1
- package/dist/extensions/grep-app/index.ts +6 -1
- package/dist/extensions/handoff-new.test.ts +351 -161
- package/dist/extensions/handoff-new.ts +3 -0
- package/dist/extensions/inline-skills.test.ts +93 -0
- package/dist/extensions/inline-skills.ts +3 -0
- package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
- package/dist/extensions/pi-subagents/README.md +2 -0
- package/dist/extensions/pi-subagents/agents/builder.md +5 -2
- package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
- package/dist/extensions/pi-subagents/docs/configuration.md +54 -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/docs/workflows.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/skills/pi-subagents/references/execution-controls.md +1 -1
- package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
- package/dist/extensions/pi-subagents/src/extension/config.ts +12 -0
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +104 -14
- 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-job-tracker.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +14 -5
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
- 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/result-watcher.ts +19 -1
- package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +12 -6
- 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/background/wait-subscriptions.ts +2 -1
- 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/foreground-history.ts +7 -4
- package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +653 -196
- 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/session-lineage.ts +71 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +108 -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 +226 -13
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
- package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
- 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/handoff-adoption.test.ts +103 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -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/session-lineage.test.ts +73 -0
- 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/workflow-auto-relaunch.test.ts +45 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
- package/dist/extensions/ponytail/index.js +11 -0
- package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
- package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
- package/dist/extensions/ponytail/test/extension.test.js +274 -140
- package/dist/extensions/ponytail/test/helpers.test.js +280 -92
- package/dist/extensions/question/index.ts +18 -0
- package/dist/extensions/question/question-list.ts +22 -0
- package/dist/extensions/question/tests/batch.test.ts +103 -65
- package/dist/extensions/question/tests/helpers.test.ts +68 -35
- package/dist/extensions/question/tests/question-list.test.ts +506 -204
- package/dist/extensions/question/tests/row-layout.test.ts +180 -114
- package/dist/extensions/question/tests/schemas.test.ts +42 -19
- package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
- package/dist/extensions/question/tests/wizard.test.ts +771 -50
- package/dist/extensions/rtk.test.ts +180 -1
- package/dist/extensions/tokenin-onboarding.ts +3 -0
- package/dist/extensions/undo.test.ts +720 -0
- package/dist/extensions/undo.ts +6 -0
- package/dist/extensions/web-agent-onboarding.test.ts +415 -0
- package/dist/extensions/workflow/extension.ts +3 -3
- package/dist/extensions/workflow/modes.ts +41 -19
- package/dist/modes/interactive/interactive-mode.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode.js +48 -1
- package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
- package/docs/plans/workflow-autoloop-reference.md +1 -2
- package/docs/plans/workflow-handoff-carryover.md +108 -0
- package/docs/quickstart.md +27 -43
- package/docs/settings.md +4 -0
- package/docs/usage.md +1 -0
- package/package.json +5 -1
|
@@ -2,8 +2,10 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import {
|
|
4
4
|
buildModelCandidates,
|
|
5
|
+
formatModelRetryNote,
|
|
5
6
|
fuzzyResolveModel,
|
|
6
7
|
isRetryableModelFailure,
|
|
8
|
+
MODEL_RETRY_DELAYS_MS,
|
|
7
9
|
normalizeModelSegment,
|
|
8
10
|
resolveEffectiveSubagentModel,
|
|
9
11
|
resolveModelCandidate,
|
|
@@ -74,6 +76,18 @@ describe("model fallback helpers", () => {
|
|
|
74
76
|
assert.equal(isRetryableModelFailure("model load failed"), true);
|
|
75
77
|
assert.equal(isRetryableModelFailure("Stream ended without finish_reason"), true);
|
|
76
78
|
assert.equal(isRetryableModelFailure("Request timed out."), true);
|
|
79
|
+
assert.equal(isRetryableModelFailure("503 Service Unavailable"), true);
|
|
80
|
+
assert.equal(isRetryableModelFailure("No body returned for response"), true);
|
|
81
|
+
assert.equal(isRetryableModelFailure("no response body"), true);
|
|
82
|
+
assert.equal(isRetryableModelFailure("ECONNRESET"), true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("ships a bounded same-model retry schedule and note", () => {
|
|
86
|
+
assert.deepEqual(MODEL_RETRY_DELAYS_MS, [500, 1500, 4000]);
|
|
87
|
+
assert.match(
|
|
88
|
+
formatModelRetryNote({ model: "anthropic/claude-sonnet-4", attempt: 1, maxAttempts: 4, delayMs: 500 }),
|
|
89
|
+
/\[model-retry\] anthropic\/claude-sonnet-4.*attempt 1\/4.*500ms/,
|
|
90
|
+
);
|
|
77
91
|
});
|
|
78
92
|
|
|
79
93
|
it("does not treat ordinary task/tool failures as retryable model failures", () => {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { createOwnedProcessTreeController } from "../../src/runs/background/owned-process-tree.ts";
|
|
8
|
+
|
|
9
|
+
function processIsActive(pid: number): boolean {
|
|
10
|
+
const result = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf-8" });
|
|
11
|
+
return result.status === 0 && Boolean(result.stdout.trim()) && !result.stdout.trim().startsWith("Z");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test("owned process tree fails closed when process-group ownership is unsupported", { skip: process.platform !== "win32" }, async () => {
|
|
15
|
+
const proof = await createOwnedProcessTreeController(999_999).terminate();
|
|
16
|
+
assert.deepEqual(proof, { state: "unknown", reason: "unsupported-platform" });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("owned process tree kills descendants and verifies a TERM-resistant POSIX group", { skip: process.platform === "win32" }, async () => {
|
|
20
|
+
const writer = spawn(process.execPath, ["-e", `
|
|
21
|
+
const { spawn } = require("node:child_process");
|
|
22
|
+
const child = spawn(process.execPath, ["-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)"], { stdio: "ignore" });
|
|
23
|
+
process.on("SIGTERM", () => {});
|
|
24
|
+
process.stdout.write(String(child.pid) + "\\n");
|
|
25
|
+
setInterval(() => {}, 1000);
|
|
26
|
+
`], { detached: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
27
|
+
assert.ok(writer.pid);
|
|
28
|
+
const grandchildPid = await new Promise<number>((resolve, reject) => {
|
|
29
|
+
writer.once("error", reject);
|
|
30
|
+
writer.stdout!.once("data", (chunk) => resolve(Number(String(chunk).trim())));
|
|
31
|
+
});
|
|
32
|
+
const proof = await createOwnedProcessTreeController(writer.pid, { termGraceMs: 50, killVerifyMs: 1000 }).terminate();
|
|
33
|
+
assert.equal(proof.state, "observed", JSON.stringify(proof));
|
|
34
|
+
assert.equal(processIsActive(writer.pid), false);
|
|
35
|
+
assert.equal(processIsActive(grandchildPid), false);
|
|
36
|
+
|
|
37
|
+
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-process-tree-ps-"));
|
|
38
|
+
const realPs = spawnSync("sh", ["-c", "command -v ps"], { encoding: "utf-8" }).stdout.trim();
|
|
39
|
+
const marker = path.join(fixtureDir, "failed-once");
|
|
40
|
+
const originalPath = process.env.PATH;
|
|
41
|
+
try {
|
|
42
|
+
fs.writeFileSync(path.join(fixtureDir, "ps"), `#!/bin/sh\nif [ ! -e "$PI_TEST_PS_MARKER" ]; then\n : > "$PI_TEST_PS_MARKER"\n exit 1\nfi\nexec "$PI_TEST_REAL_PS" "$@"\n`);
|
|
43
|
+
fs.chmodSync(path.join(fixtureDir, "ps"), 0o755);
|
|
44
|
+
process.env.PATH = `${fixtureDir}${path.delimiter}${originalPath ?? ""}`;
|
|
45
|
+
process.env.PI_TEST_PS_MARKER = marker;
|
|
46
|
+
process.env.PI_TEST_REAL_PS = realPs;
|
|
47
|
+
const enumerationFailureWriter = spawn(process.execPath, ["-e", `
|
|
48
|
+
process.on("SIGTERM", () => {});
|
|
49
|
+
process.stdout.write("ready\\n");
|
|
50
|
+
setInterval(() => {}, 1000);
|
|
51
|
+
`], { detached: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
52
|
+
assert.ok(enumerationFailureWriter.pid);
|
|
53
|
+
await new Promise<void>((resolve, reject) => {
|
|
54
|
+
enumerationFailureWriter.once("error", reject);
|
|
55
|
+
enumerationFailureWriter.stdout!.once("data", () => resolve());
|
|
56
|
+
});
|
|
57
|
+
const termGraceMs = 50;
|
|
58
|
+
const startedAt = Date.now();
|
|
59
|
+
const failedProof = await createOwnedProcessTreeController(enumerationFailureWriter.pid, { termGraceMs, killVerifyMs: 1000 }).terminate();
|
|
60
|
+
assert.ok(Date.now() - startedAt >= termGraceMs, "enumeration failure still waits through the TERM grace before SIGKILL");
|
|
61
|
+
assert.equal(failedProof.state, "observed", JSON.stringify(failedProof));
|
|
62
|
+
assert.equal(processIsActive(enumerationFailureWriter.pid), false);
|
|
63
|
+
} finally {
|
|
64
|
+
process.env.PATH = originalPath;
|
|
65
|
+
delete process.env.PI_TEST_PS_MARKER;
|
|
66
|
+
delete process.env.PI_TEST_REAL_PS;
|
|
67
|
+
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
@@ -68,6 +68,7 @@ const originalEnv = {
|
|
|
68
68
|
[PI_INTERCOM_SESSION_ID_ENV]: process.env[PI_INTERCOM_SESSION_ID_ENV],
|
|
69
69
|
MCP_HASH_ROOT: process.env.MCP_HASH_ROOT,
|
|
70
70
|
MCP_HASH_TOKEN: process.env.MCP_HASH_TOKEN,
|
|
71
|
+
SELESAI_SUBAGENT_TASK_DELIVERY: process.env.SELESAI_SUBAGENT_TASK_DELIVERY,
|
|
71
72
|
};
|
|
72
73
|
const originalCwd = process.cwd();
|
|
73
74
|
const tempRoots: string[] = [];
|
|
@@ -481,6 +482,88 @@ describe("buildPiArgs model wiring", () => {
|
|
|
481
482
|
});
|
|
482
483
|
});
|
|
483
484
|
|
|
485
|
+
describe("buildPiArgs task delivery", () => {
|
|
486
|
+
const longTask = "x".repeat(8001);
|
|
487
|
+
|
|
488
|
+
function taskFileFromArgs(args: string[]): string | undefined {
|
|
489
|
+
const ref = args.find((arg) => arg.startsWith("@") && arg.endsWith("task.md"));
|
|
490
|
+
return ref ? ref.slice(1) : undefined;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
it("delivers short tasks inline by default", () => {
|
|
494
|
+
const { args } = buildPiArgs({
|
|
495
|
+
baseArgs: ["-p"],
|
|
496
|
+
task: "hello",
|
|
497
|
+
sessionEnabled: false,
|
|
498
|
+
inheritProjectContext: false,
|
|
499
|
+
inheritSkills: false,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
assert.ok(args.includes("Task: hello"));
|
|
503
|
+
assert.equal(taskFileFromArgs(args), undefined);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("delivers tasks over the argv limit via a temp file by default", () => {
|
|
507
|
+
const { args } = buildPiArgs({
|
|
508
|
+
baseArgs: ["-p"],
|
|
509
|
+
task: longTask,
|
|
510
|
+
sessionEnabled: false,
|
|
511
|
+
inheritProjectContext: false,
|
|
512
|
+
inheritSkills: false,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const taskFile = taskFileFromArgs(args);
|
|
516
|
+
assert.ok(taskFile, "expected an @task.md argv reference");
|
|
517
|
+
assert.equal(fs.readFileSync(taskFile, "utf-8"), `Task: ${longTask}`);
|
|
518
|
+
assert.ok(!args.includes(`Task: ${longTask}`));
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("delivers short tasks via file when SELESAI_SUBAGENT_TASK_DELIVERY=file", () => {
|
|
522
|
+
process.env.SELESAI_SUBAGENT_TASK_DELIVERY = "file";
|
|
523
|
+
const { args } = buildPiArgs({
|
|
524
|
+
baseArgs: ["-p"],
|
|
525
|
+
task: "hello",
|
|
526
|
+
sessionEnabled: false,
|
|
527
|
+
inheritProjectContext: false,
|
|
528
|
+
inheritSkills: false,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const taskFile = taskFileFromArgs(args);
|
|
532
|
+
assert.ok(taskFile, "expected an @task.md argv reference");
|
|
533
|
+
assert.equal(fs.readFileSync(taskFile, "utf-8"), "Task: hello");
|
|
534
|
+
assert.ok(!args.includes("Task: hello"));
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
it("falls back to auto when SELESAI_SUBAGENT_TASK_DELIVERY is invalid", () => {
|
|
539
|
+
process.env.SELESAI_SUBAGENT_TASK_DELIVERY = "carrier-pigeon";
|
|
540
|
+
const { args } = buildPiArgs({
|
|
541
|
+
baseArgs: ["-p"],
|
|
542
|
+
task: longTask,
|
|
543
|
+
sessionEnabled: false,
|
|
544
|
+
inheritProjectContext: false,
|
|
545
|
+
inheritSkills: false,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
assert.ok(taskFileFromArgs(args), "expected file delivery for over-limit task");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("lets the per-launch taskDelivery override beat the env setting", () => {
|
|
552
|
+
delete process.env.SELESAI_SUBAGENT_TASK_DELIVERY;
|
|
553
|
+
const { args } = buildPiArgs({
|
|
554
|
+
baseArgs: ["-p"],
|
|
555
|
+
task: "hello",
|
|
556
|
+
taskDelivery: "file",
|
|
557
|
+
sessionEnabled: false,
|
|
558
|
+
inheritProjectContext: false,
|
|
559
|
+
inheritSkills: false,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
assert.ok(taskFileFromArgs(args), "expected an @task.md argv reference");
|
|
563
|
+
assert.ok(!args.includes("Task: hello"));
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
484
567
|
describe("buildPiArgs system prompt mode wiring", () => {
|
|
485
568
|
it("uses --append-system-prompt by default", () => {
|
|
486
569
|
const { args } = buildPiArgs({
|
|
@@ -182,14 +182,14 @@ Package skill content.
|
|
|
182
182
|
});
|
|
183
183
|
|
|
184
184
|
it("records private redacted run history and cleans session artifacts under the configured agent dir", () => {
|
|
185
|
-
const task = "Inspect customer ACME token=SECRET";
|
|
185
|
+
const task = "PROMPT_AUDIT_SENTINEL_1021 Inspect customer ACME token=SECRET";
|
|
186
186
|
recordRun("env-agent", task, 0, 42);
|
|
187
187
|
const historyPath = path.join(agentDir, "run-history.jsonl");
|
|
188
188
|
assert.equal(fs.existsSync(historyPath), true);
|
|
189
189
|
assertPrivateHistoryModes(historyPath);
|
|
190
190
|
|
|
191
191
|
const rawHistory = fs.readFileSync(historyPath, "utf-8");
|
|
192
|
-
assert.doesNotMatch(rawHistory, /Inspect customer|ACME|SECRET/);
|
|
192
|
+
assert.doesNotMatch(rawHistory, /PROMPT_AUDIT_SENTINEL_1021|Inspect customer|ACME|SECRET/);
|
|
193
193
|
assert.match(rawHistory, /"task":"\[redacted\]"/);
|
|
194
194
|
assert.match(rawHistory, /"taskHash":"[a-f0-9]{64}"/);
|
|
195
195
|
|
|
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
|
+
import { ACTIVE_RUN_INDEX_DIR, updateActiveRunIndex } from "../../src/runs/background/active-run-index.ts";
|
|
6
7
|
import {
|
|
7
8
|
finalizeProcessTerminal,
|
|
8
9
|
processTerminalPath,
|
|
@@ -27,6 +28,7 @@ test("process-terminal proof requires the matching runner instance and writer cl
|
|
|
27
28
|
closeObservedAt: 10,
|
|
28
29
|
exitCode: 0,
|
|
29
30
|
signal: null,
|
|
31
|
+
processTree: { state: "observed", mechanism: "posix-process-group", processGroupId: 123, verifiedAt: 11 },
|
|
30
32
|
}],
|
|
31
33
|
},
|
|
32
34
|
});
|
|
@@ -82,6 +84,57 @@ test("process-terminal rejects missing writer close evidence", () => {
|
|
|
82
84
|
}
|
|
83
85
|
});
|
|
84
86
|
|
|
87
|
+
test("process-terminal fails closed when process-tree verification is unknown", () => {
|
|
88
|
+
const asyncDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-process-terminal-"));
|
|
89
|
+
try {
|
|
90
|
+
writeProcessTerminalCandidate(asyncDir, {
|
|
91
|
+
version: 1,
|
|
92
|
+
runId: "run-tree-unknown",
|
|
93
|
+
runnerProcessInstanceId: "runner-tree-unknown",
|
|
94
|
+
expectedWriters: { "0": 1 },
|
|
95
|
+
writers: {
|
|
96
|
+
"0": [{
|
|
97
|
+
processInstanceId: "writer-tree-unknown",
|
|
98
|
+
kind: "pi-writer",
|
|
99
|
+
attempt: 0,
|
|
100
|
+
closeObservedAt: 10,
|
|
101
|
+
exitCode: 1,
|
|
102
|
+
signal: "SIGKILL",
|
|
103
|
+
processTree: { state: "unknown", reason: "verification-failed", diagnostic: "still live" },
|
|
104
|
+
}],
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({ runId: "run-tree-unknown", state: "failed", lifecycleArtifactVersion: 3, steps: [{ agent: "worker", status: "failed" }] }));
|
|
108
|
+
fs.writeFileSync(path.join(asyncDir, "events.jsonl"), "");
|
|
109
|
+
updateActiveRunIndex(asyncDir, "running");
|
|
110
|
+
const markerPath = path.join(path.dirname(asyncDir), ACTIVE_RUN_INDEX_DIR, path.basename(asyncDir));
|
|
111
|
+
const proof = finalizeProcessTerminal(asyncDir, "run-tree-unknown", {
|
|
112
|
+
processInstanceId: "runner-tree-unknown",
|
|
113
|
+
closeObservedAt: 20,
|
|
114
|
+
exitCode: 1,
|
|
115
|
+
signal: null,
|
|
116
|
+
});
|
|
117
|
+
assert.equal(proof.state, "unknown");
|
|
118
|
+
assert.equal(proof.reason, "process-tree-unverified");
|
|
119
|
+
assert.equal(fs.existsSync(markerPath), true);
|
|
120
|
+
fs.rmSync(processTerminalPath(asyncDir), { force: true });
|
|
121
|
+
const candidatePath = path.join(asyncDir, "process-terminal-candidate.json");
|
|
122
|
+
const candidate = JSON.parse(fs.readFileSync(candidatePath, "utf-8"));
|
|
123
|
+
candidate.writers["0"][0].processTree = { state: "observed", mechanism: "posix-process-group", processGroupId: 126, verifiedAt: 21 };
|
|
124
|
+
fs.writeFileSync(candidatePath, JSON.stringify(candidate));
|
|
125
|
+
const observed = finalizeProcessTerminal(asyncDir, "run-tree-unknown", {
|
|
126
|
+
processInstanceId: "runner-tree-unknown",
|
|
127
|
+
closeObservedAt: 30,
|
|
128
|
+
exitCode: 1,
|
|
129
|
+
signal: null,
|
|
130
|
+
});
|
|
131
|
+
assert.equal(observed.state, "observed");
|
|
132
|
+
assert.equal(fs.existsSync(markerPath), false);
|
|
133
|
+
} finally {
|
|
134
|
+
fs.rmSync(asyncDir, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
85
138
|
test("malformed process-terminal sidecars project unknown instead of throwing", () => {
|
|
86
139
|
const asyncDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-process-terminal-"));
|
|
87
140
|
try {
|
|
@@ -112,7 +165,7 @@ test("process-terminal preserves stopped non-resumability and requires lease rel
|
|
|
112
165
|
runnerProcessInstanceId: "stopped-runner",
|
|
113
166
|
expectedWriters: { "0": 1 },
|
|
114
167
|
writers: {
|
|
115
|
-
"0": [{ processInstanceId: "stopped-writer", kind: "pi-writer", attempt: 0, closeObservedAt: 10, exitCode: 0, signal: null }],
|
|
168
|
+
"0": [{ processInstanceId: "stopped-writer", kind: "pi-writer", attempt: 0, closeObservedAt: 10, exitCode: 0, signal: null, processTree: { state: "observed", mechanism: "posix-process-group", processGroupId: 124, verifiedAt: 11 } }],
|
|
116
169
|
},
|
|
117
170
|
revivalLeaseToken: "lease-token",
|
|
118
171
|
revivalLeaseReleaseAcknowledged: false,
|
|
@@ -161,7 +214,7 @@ test("process-terminal rejects cross-run observed sidecars and inconsistent expe
|
|
|
161
214
|
runnerProcessInstanceId: "runner-1",
|
|
162
215
|
expectedWriters: { "0": 0 },
|
|
163
216
|
writers: {
|
|
164
|
-
"0": [{ processInstanceId: "unexpected-writer", kind: "pi-writer", attempt: 0, closeObservedAt: 10, exitCode: 0, signal: null }],
|
|
217
|
+
"0": [{ processInstanceId: "unexpected-writer", kind: "pi-writer", attempt: 0, closeObservedAt: 10, exitCode: 0, signal: null, processTree: { state: "observed", mechanism: "posix-process-group", processGroupId: 125, verifiedAt: 11 } }],
|
|
165
218
|
},
|
|
166
219
|
});
|
|
167
220
|
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({ runId: "actual-run", state: "complete", lifecycleArtifactVersion: 3, steps: [{ agent: "worker", status: "complete" }] }));
|
|
@@ -19,7 +19,18 @@ describe("public subagent execution normalization", () => {
|
|
|
19
19
|
);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
it("rejects
|
|
22
|
+
it("rejects private run fan-out fields at the public boundary", () => {
|
|
23
|
+
for (const params of [
|
|
24
|
+
{ workflowScript: "return 1", runFanoutBudget: { version: 1 } },
|
|
25
|
+
{ workflowScript: "return 1", runFanoutAdmitted: true },
|
|
26
|
+
] as const) {
|
|
27
|
+
const result = normalizePublicSubagentExecution(params);
|
|
28
|
+
assert.equal(result.ok, false);
|
|
29
|
+
if (!result.ok) assert.match(result.error, /does not accept internal run fan-out fields/);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects removed public execution shapes", () => {
|
|
23
34
|
for (const params of [
|
|
24
35
|
{ action: " " },
|
|
25
36
|
{ action: "single" },
|
|
@@ -146,7 +146,7 @@ describe("subagent extension RPC bridge", () => {
|
|
|
146
146
|
assert.deepEqual(executedParams, { action: "status", id: "abc123" });
|
|
147
147
|
assert.equal((reply as { data: { text?: string } }).data.text, "Run: abc123");
|
|
148
148
|
assert.deepEqual((reply as { data: { fleet?: unknown } }).data.fleet, {
|
|
149
|
-
version: 1, entries: [], totalActive: 0, omitted: 0,
|
|
149
|
+
version: 1, entries: [], totalActive: 0, topLevelAsyncCapacity: { used: 0, limit: 0 }, omitted: 0,
|
|
150
150
|
});
|
|
151
151
|
|
|
152
152
|
bridge.dispose();
|
|
@@ -174,8 +174,9 @@ describe("subagent extension RPC bridge", () => {
|
|
|
174
174
|
assert.equal((fleet as { omitted?: number }).omitted, 0);
|
|
175
175
|
assert.deepEqual(fleet.entries[0], {
|
|
176
176
|
key: "fleet-1", agent: "reviewer", role: "opaque label", model: "anthropic/claude-opus-4-8:high", effort: "high",
|
|
177
|
-
startedAt: 120, tokens: { input: 12, output: 34, total: 46 },
|
|
177
|
+
startedAt: 120, tokens: { input: 12, output: 34, total: 46 },
|
|
178
178
|
});
|
|
179
|
+
assert.equal(JSON.stringify(fleet).includes("Review the diff"), false);
|
|
179
180
|
assert.equal(JSON.stringify(fleet).includes("async-private-id"), false);
|
|
180
181
|
bridge.dispose();
|
|
181
182
|
});
|
|
@@ -200,15 +201,13 @@ describe("subagent extension RPC bridge", () => {
|
|
|
200
201
|
const malformedSurrogate = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/u;
|
|
201
202
|
|
|
202
203
|
assert.doesNotMatch(entry.agent, malformedSurrogate);
|
|
203
|
-
assert.
|
|
204
|
-
assert.doesNotMatch(entry.goal, /[\r\n]/);
|
|
204
|
+
assert.equal(entry.goal, undefined);
|
|
205
205
|
assert.ok(entry.agent.length <= 96);
|
|
206
|
-
assert.ok(entry.goal.length <= 512);
|
|
207
206
|
assert.match(entry.agent, /^worker broken/);
|
|
208
207
|
bridge.dispose();
|
|
209
208
|
});
|
|
210
209
|
|
|
211
|
-
it("projects resolved foreground model, effort, split usage
|
|
210
|
+
it("projects resolved foreground model, effort, and split usage without prompt goals", async () => {
|
|
212
211
|
const events = new FakeEvents();
|
|
213
212
|
const state = {
|
|
214
213
|
currentSessionId: "session-123",
|
|
@@ -231,6 +230,7 @@ describe("subagent extension RPC bridge", () => {
|
|
|
231
230
|
}]]),
|
|
232
231
|
}]]),
|
|
233
232
|
asyncJobs: new Map(),
|
|
233
|
+
activeAsyncCapacity: { used: 2, limit: 4 },
|
|
234
234
|
} as any;
|
|
235
235
|
state.foregroundControls.set("private-old", {
|
|
236
236
|
runId: "private-old",
|
|
@@ -250,6 +250,7 @@ describe("subagent extension RPC bridge", () => {
|
|
|
250
250
|
assert.deepEqual((reply as any).data.fleet, {
|
|
251
251
|
version: 1,
|
|
252
252
|
totalActive: 1,
|
|
253
|
+
topLevelAsyncCapacity: { used: 2, limit: 4 },
|
|
253
254
|
omitted: 0,
|
|
254
255
|
entries: [{
|
|
255
256
|
key: "fleet-1",
|
|
@@ -258,7 +259,6 @@ describe("subagent extension RPC bridge", () => {
|
|
|
258
259
|
effort: "high",
|
|
259
260
|
startedAt: 100,
|
|
260
261
|
tokens: { input: 321, output: 45, total: 366 },
|
|
261
|
-
goal: "Implement the fix",
|
|
262
262
|
}],
|
|
263
263
|
});
|
|
264
264
|
assert.equal(JSON.stringify((reply as any).data.fleet).includes("private-run"), false);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { afterEach, describe, it } from "node:test";
|
|
7
|
+
import {
|
|
8
|
+
claimRunFanoutBatch,
|
|
9
|
+
claimRunFanoutBatchWithCommit,
|
|
10
|
+
createRunFanoutBudget,
|
|
11
|
+
decodeRunFanoutBudgetDescriptor,
|
|
12
|
+
encodeRunFanoutBudgetDescriptor,
|
|
13
|
+
getRunFanoutBudgetSnapshot,
|
|
14
|
+
RunFanoutLimitError,
|
|
15
|
+
validateRunFanoutBudgetDescriptor,
|
|
16
|
+
} from "../../src/runs/shared/run-fanout-budget.ts";
|
|
17
|
+
import { resolveMaxSubagentSpawnsPerRun } from "../../src/shared/types.ts";
|
|
18
|
+
|
|
19
|
+
const directories: string[] = [];
|
|
20
|
+
const externalDirectories: string[] = [];
|
|
21
|
+
const previousEnv = process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN;
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const directory of directories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
|
25
|
+
for (const directory of externalDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
|
26
|
+
if (previousEnv === undefined) delete process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN;
|
|
27
|
+
else process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN = previousEnv;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function budget(limit: number) {
|
|
31
|
+
const descriptor = createRunFanoutBudget(`test-${Date.now()}-${Math.random()}`, limit);
|
|
32
|
+
directories.push(descriptor.directory);
|
|
33
|
+
return descriptor;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("run fan-out budget", () => {
|
|
37
|
+
it("resolves environment over config and falls back to 64", () => {
|
|
38
|
+
delete process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN;
|
|
39
|
+
assert.equal(resolveMaxSubagentSpawnsPerRun(undefined), 64);
|
|
40
|
+
assert.equal(resolveMaxSubagentSpawnsPerRun(12), 12);
|
|
41
|
+
for (const invalid of [0, -1, 1.5, "bad"]) assert.equal(resolveMaxSubagentSpawnsPerRun(invalid as number), 64);
|
|
42
|
+
process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN = "7";
|
|
43
|
+
assert.equal(resolveMaxSubagentSpawnsPerRun(12), 7);
|
|
44
|
+
for (const invalid of ["0", "-1", "1.5", "bad"]) {
|
|
45
|
+
process.env.SELESAI_SUBAGENT_MAX_SPAWNS_PER_RUN = invalid;
|
|
46
|
+
assert.equal(resolveMaxSubagentSpawnsPerRun(undefined), 64);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("persists claims and rejects the responsible next path at the exact cap", () => {
|
|
51
|
+
const descriptor = budget(2);
|
|
52
|
+
assert.deepEqual(claimRunFanoutBatch(descriptor, ["tasks[0]", "tasks[1]"]), { used: 2, limit: 2, remaining: 0 });
|
|
53
|
+
const restored = decodeRunFanoutBudgetDescriptor(encodeRunFanoutBudgetDescriptor(descriptor));
|
|
54
|
+
assert.deepEqual(getRunFanoutBudgetSnapshot(restored!), { used: 2, limit: 2, remaining: 0 });
|
|
55
|
+
assert.throws(() => claimRunFanoutBatch(descriptor, ["tasks[2]"]), (error: unknown) => {
|
|
56
|
+
assert.ok(error instanceof RunFanoutLimitError);
|
|
57
|
+
assert.equal(error.rejection.path, "tasks[2]");
|
|
58
|
+
assert.equal(error.rejection.used, 2);
|
|
59
|
+
assert.equal(error.rejection.remaining, 0);
|
|
60
|
+
return true;
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rolls back only the failed admission batch", () => {
|
|
65
|
+
const descriptor = budget(2);
|
|
66
|
+
claimRunFanoutBatch(descriptor, ["single"]);
|
|
67
|
+
assert.throws(() => claimRunFanoutBatch(descriptor, ["chain[0]", "chain[1]"]), RunFanoutLimitError);
|
|
68
|
+
assert.deepEqual(getRunFanoutBudgetSnapshot(descriptor), { used: 1, limit: 2, remaining: 1 });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rolls back a batch when its commit fails", () => {
|
|
72
|
+
const descriptor = budget(2);
|
|
73
|
+
claimRunFanoutBatch(descriptor, ["existing"]);
|
|
74
|
+
assert.throws(
|
|
75
|
+
() => claimRunFanoutBatchWithCommit(descriptor, ["append"], () => { throw new Error("enqueue failed"); }),
|
|
76
|
+
/enqueue failed/,
|
|
77
|
+
);
|
|
78
|
+
assert.deepEqual(getRunFanoutBudgetSnapshot(descriptor), { used: 1, limit: 2, remaining: 1 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("reports claims-directory I/O errors instead of ordinary exhaustion", () => {
|
|
82
|
+
const descriptor = budget(2);
|
|
83
|
+
fs.rmSync(path.join(descriptor.directory, "claims"), { recursive: true });
|
|
84
|
+
fs.writeFileSync(path.join(descriptor.directory, "claims"), "not a directory", "utf-8");
|
|
85
|
+
assert.throws(() => getRunFanoutBudgetSnapshot(descriptor), /claims directory is unreadable.*ENOTDIR/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("admits exactly one simultaneous two-slot batch at limit two", async () => {
|
|
89
|
+
const moduleUrl = new URL("../../src/runs/shared/run-fanout-budget.ts", import.meta.url).href;
|
|
90
|
+
const script = `import { claimRunFanoutBatch } from ${JSON.stringify(moduleUrl)}; const descriptor = JSON.parse(process.argv[1]); try { claimRunFanoutBatch(descriptor, [process.argv[2] + "[0]", process.argv[2] + "[1]"]); process.stdout.write("admitted"); } catch { process.stdout.write("rejected"); }`;
|
|
91
|
+
for (let iteration = 0; iteration < 12; iteration++) {
|
|
92
|
+
const descriptor = budget(2);
|
|
93
|
+
const launch = (claimPath: string) => new Promise<string>((resolve, reject) => {
|
|
94
|
+
const child = spawn(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", script, JSON.stringify(descriptor), claimPath], { stdio: ["ignore", "pipe", "pipe"] });
|
|
95
|
+
let stdout = "";
|
|
96
|
+
let stderr = "";
|
|
97
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
98
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
99
|
+
child.on("error", reject);
|
|
100
|
+
child.on("close", (code) => code === 0 ? resolve(stdout) : reject(new Error(stderr)));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
assert.deepEqual((await Promise.all([launch("group-a"), launch("group-b")])).sort(), ["admitted", "rejected"], `iteration ${iteration}`);
|
|
104
|
+
assert.deepEqual(getRunFanoutBudgetSnapshot(descriptor), { used: 2, limit: 2, remaining: 0 });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("fails closed when descriptor identity or managed-root containment is invalid", () => {
|
|
109
|
+
const descriptor = budget(2);
|
|
110
|
+
assert.throws(() => validateRunFanoutBudgetDescriptor({ ...descriptor, limit: 3 }), /does not match/);
|
|
111
|
+
|
|
112
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "pi-run-fanout-outside-"));
|
|
113
|
+
externalDirectories.push(outside);
|
|
114
|
+
fs.mkdirSync(path.join(outside, "claims"));
|
|
115
|
+
fs.writeFileSync(path.join(outside, "manifest.json"), JSON.stringify({ version: 1, rootRunId: descriptor.rootRunId, limit: 2, createdAt: Date.now() }));
|
|
116
|
+
const linked = path.join(path.dirname(descriptor.directory), `linked-${Date.now()}`);
|
|
117
|
+
fs.symlinkSync(outside, linked, process.platform === "win32" ? "junction" : "dir");
|
|
118
|
+
directories.push(linked);
|
|
119
|
+
assert.throws(() => validateRunFanoutBudgetDescriptor({ ...descriptor, directory: linked }), /resolves outside/);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -6,6 +6,7 @@ import { describe, it } from "node:test";
|
|
|
6
6
|
import { updateActiveRunIndex } from "../../src/runs/background/active-run-index.ts";
|
|
7
7
|
import { inspectSubagentStatus } from "../../src/runs/background/run-status.ts";
|
|
8
8
|
import { createNestedRoute, writeNestedEvent } from "../../src/runs/shared/nested-events.ts";
|
|
9
|
+
import { claimRunFanoutBatch, createRunFanoutBudget, writeRunFanoutBudgetDescriptor } from "../../src/runs/shared/run-fanout-budget.ts";
|
|
9
10
|
import { TEMP_ROOT_DIR, type SubagentState } from "../../src/shared/types.ts";
|
|
10
11
|
|
|
11
12
|
function errno(code: string): NodeJS.ErrnoException {
|
|
@@ -22,6 +23,7 @@ function textContent(result: ReturnType<typeof inspectSubagentStatus>): string {
|
|
|
22
23
|
describe("async run status inspection", () => {
|
|
23
24
|
it("repairs stale running status and reports diagnosis plus result path", () => {
|
|
24
25
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-run-status-stale-"));
|
|
26
|
+
let budgetDirectory: string | undefined;
|
|
25
27
|
try {
|
|
26
28
|
const asyncRoot = path.join(root, "runs");
|
|
27
29
|
const resultsDir = path.join(root, "results");
|
|
@@ -40,6 +42,10 @@ describe("async run status inspection", () => {
|
|
|
40
42
|
sessionFile,
|
|
41
43
|
steps: [{ agent: "scout", status: "running", startedAt: 100, sessionFile }],
|
|
42
44
|
}, null, 2), "utf-8");
|
|
45
|
+
const descriptor = createRunFanoutBudget("run-stale", 64);
|
|
46
|
+
budgetDirectory = descriptor.directory;
|
|
47
|
+
writeRunFanoutBudgetDescriptor(asyncDir, descriptor);
|
|
48
|
+
claimRunFanoutBatch(descriptor, ["single"]);
|
|
43
49
|
|
|
44
50
|
const result = inspectSubagentStatus({ id: "run-stale" }, {
|
|
45
51
|
asyncDirRoot: asyncRoot,
|
|
@@ -51,6 +57,8 @@ describe("async run status inspection", () => {
|
|
|
51
57
|
const text = textContent(result);
|
|
52
58
|
assert.equal(result.isError, undefined);
|
|
53
59
|
assert.match(text, /State: failed/);
|
|
60
|
+
assert.match(text, /Run fan-out: 1\/64 used, 63 remaining/);
|
|
61
|
+
assert.deepEqual(result.details.runFanoutBudget, { used: 1, limit: 64, remaining: 63 });
|
|
54
62
|
assert.match(text, /Diagnosis: Async runner process 12345 exited or disappeared/);
|
|
55
63
|
assert.match(text, new RegExp(`Result: ${path.join(resultsDir, "run-stale.json").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
56
64
|
assert.match(text, /Step 1: scout failed, error: Async runner process 12345 exited or disappeared/);
|
|
@@ -60,6 +68,7 @@ describe("async run status inspection", () => {
|
|
|
60
68
|
assert.equal(resultJson.results[0].sessionFile, sessionFile);
|
|
61
69
|
} finally {
|
|
62
70
|
fs.rmSync(root, { recursive: true, force: true });
|
|
71
|
+
if (budgetDirectory) fs.rmSync(budgetDirectory, { recursive: true, force: true });
|
|
63
72
|
}
|
|
64
73
|
});
|
|
65
74
|
|
|
@@ -236,12 +236,13 @@ describe("SubagentParams schema", { skip: !schemasAvailable ? "typebox not avail
|
|
|
236
236
|
assert.ok(maxRuntimeSchema, "maxRuntimeMs schema should exist");
|
|
237
237
|
assert.equal(timeoutSchema.minimum, 1);
|
|
238
238
|
assert.equal(maxRuntimeSchema.minimum, 1);
|
|
239
|
-
assert.match(String(timeoutSchema.description ?? ""), /foreground
|
|
240
|
-
assert.match(String(timeoutSchema.description ?? ""), /
|
|
239
|
+
assert.match(String(timeoutSchema.description ?? ""), /foreground and single async runs/i);
|
|
240
|
+
assert.match(String(timeoutSchema.description ?? ""), /use config timeoutMs, else 30m/i);
|
|
241
241
|
assert.match(String(timeoutSchema.description ?? ""), /async composites have no default parent deadline/i);
|
|
242
242
|
assert.doesNotMatch(String(timeoutSchema.description ?? ""), /foreground-only/i);
|
|
243
243
|
assert.match(String(maxRuntimeSchema.description ?? ""), /timeoutMs/i);
|
|
244
|
-
assert.match(String(maxRuntimeSchema.description ?? ""), /
|
|
244
|
+
assert.match(String(maxRuntimeSchema.description ?? ""), /foreground and single async runs/i);
|
|
245
|
+
assert.match(String(maxRuntimeSchema.description ?? ""), /use config timeoutMs, else 30m/i);
|
|
245
246
|
assert.match(String(maxRuntimeSchema.description ?? ""), /async composites have no default parent deadline/i);
|
|
246
247
|
assert.equal(turnBudgetSchema?.properties?.maxTurns?.minimum, 1);
|
|
247
248
|
assert.equal(turnBudgetSchema?.properties?.graceTurns?.minimum, 0);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import { resolveSessionLineage } from "../../src/shared/session-lineage.ts";
|
|
7
|
+
|
|
8
|
+
function sessionFile(dir: string, name: string, parentSession?: string): string {
|
|
9
|
+
const file = path.join(dir, name);
|
|
10
|
+
const header = {
|
|
11
|
+
type: "session",
|
|
12
|
+
version: 1,
|
|
13
|
+
id: name,
|
|
14
|
+
timestamp: new Date().toISOString(),
|
|
15
|
+
cwd: dir,
|
|
16
|
+
...(parentSession ? { parentSession } : {}),
|
|
17
|
+
};
|
|
18
|
+
fs.writeFileSync(file, `${JSON.stringify(header)}\n`);
|
|
19
|
+
return file;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("session lineage", () => {
|
|
23
|
+
it("resolves self plus the parent chain from session headers", () => {
|
|
24
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-lineage-"));
|
|
25
|
+
const grandparent = sessionFile(dir, "a.jsonl");
|
|
26
|
+
const parent = sessionFile(dir, "b.jsonl", grandparent);
|
|
27
|
+
const current = sessionFile(dir, "c.jsonl", parent);
|
|
28
|
+
const lineage = resolveSessionLineage({ sessionManager: { getSessionFile: () => current, getHeader: () => ({ parentSession: parent }) } });
|
|
29
|
+
assert.deepEqual(lineage, [current, parent, grandparent]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("prefers an explicit parent hint over the header", () => {
|
|
33
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-lineage-"));
|
|
34
|
+
const hinted = sessionFile(dir, "hinted.jsonl");
|
|
35
|
+
const headed = sessionFile(dir, "headed.jsonl");
|
|
36
|
+
const current = sessionFile(dir, "current.jsonl", headed);
|
|
37
|
+
const lineage = resolveSessionLineage({ sessionManager: { getSessionFile: () => current, getHeader: () => ({ parentSession: headed }) } }, { parentHint: hinted });
|
|
38
|
+
assert.deepEqual(lineage, [current, hinted]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("bounds the walk depth", () => {
|
|
42
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-lineage-"));
|
|
43
|
+
const files: string[] = [];
|
|
44
|
+
for (let i = 0; i < 12; i++) files.push(sessionFile(dir, `s${i}.jsonl`, i > 0 ? files[i - 1]! : undefined));
|
|
45
|
+
const current = sessionFile(dir, "current.jsonl", files[11]);
|
|
46
|
+
const lineage = resolveSessionLineage({ sessionManager: { getSessionFile: () => current } }, { depth: 3 });
|
|
47
|
+
assert.equal(lineage.length, 3);
|
|
48
|
+
assert.equal(lineage[0], current);
|
|
49
|
+
assert.equal(lineage[1], files[11]);
|
|
50
|
+
assert.equal(lineage[2], files[10]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("stops at missing parent files", () => {
|
|
54
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-lineage-"));
|
|
55
|
+
const missing = path.join(dir, "missing.jsonl");
|
|
56
|
+
const current = sessionFile(dir, "current.jsonl", missing);
|
|
57
|
+
const lineage = resolveSessionLineage({ sessionManager: { getSessionFile: () => current, getHeader: () => ({ parentSession: missing }) } });
|
|
58
|
+
assert.deepEqual(lineage, [current]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does not loop on cyclic parent chains", () => {
|
|
62
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-lineage-"));
|
|
63
|
+
const a = sessionFile(dir, "a.jsonl");
|
|
64
|
+
const b = sessionFile(dir, "b.jsonl", a);
|
|
65
|
+
fs.writeFileSync(a, `${JSON.stringify({ type: "session", version: 1, id: "a", timestamp: new Date().toISOString(), cwd: dir, parentSession: b })}\n`);
|
|
66
|
+
const lineage = resolveSessionLineage({ sessionManager: { getSessionFile: () => b, getHeader: () => ({ parentSession: a }) } });
|
|
67
|
+
assert.deepEqual(lineage, [b, a]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handles session managers without getHeader or session file", () => {
|
|
71
|
+
assert.deepEqual(resolveSessionLineage({ sessionManager: { getSessionFile: () => null } }, { parentHint: "/nonexistent/parent.jsonl" }), []);
|
|
72
|
+
});
|
|
73
|
+
});
|