@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +7 -1
  3. package/dist/core/slash-commands.js +1 -0
  4. package/dist/defaults/models.json +41 -55
  5. package/dist/defaults/settings.json +7 -8
  6. package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
  7. package/dist/extensions/pi-subagents/README.md +2 -0
  8. package/dist/extensions/pi-subagents/docs/configuration.md +46 -2
  9. package/dist/extensions/pi-subagents/docs/observability.md +1 -1
  10. package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
  11. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  12. package/dist/extensions/pi-subagents/package.json +1 -1
  13. package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
  14. package/dist/extensions/pi-subagents/src/extension/config.ts +6 -0
  15. package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
  16. package/dist/extensions/pi-subagents/src/extension/index.ts +21 -0
  17. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
  18. package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
  19. package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
  20. package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
  21. package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
  22. package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
  23. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
  24. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
  25. package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
  26. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
  27. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
  28. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +11 -5
  29. package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
  30. package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
  31. package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
  32. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +5 -1
  33. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
  34. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
  35. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
  36. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
  37. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
  38. package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
  39. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +614 -187
  40. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
  41. package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
  42. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
  43. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
  44. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
  45. package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
  46. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
  47. package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
  48. package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
  49. package/dist/extensions/pi-subagents/src/shared/types.ts +92 -0
  50. package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
  51. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
  52. package/dist/extensions/pi-subagents/src/tui/fleet.ts +225 -12
  53. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
  54. package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
  55. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
  56. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
  57. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
  58. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
  59. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
  60. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
  61. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
  62. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
  63. package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
  64. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
  65. package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
  66. package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
  67. package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
  68. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
  69. package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
  70. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
  71. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
  72. package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
  73. package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
  74. package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
  75. package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
  76. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
  77. package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
  78. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
  79. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
  80. package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
  81. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
  82. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
  83. package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
  84. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
  85. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
  86. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
  87. package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
  88. package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
  89. package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
  90. package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
  91. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  92. package/dist/modes/interactive/interactive-mode.js +48 -1
  93. package/docs/quickstart.md +27 -43
  94. package/docs/settings.md +4 -0
  95. package/docs/usage.md +1 -0
  96. package/package.json +2 -1
@@ -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 malformed public execution shapes", () => {
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 }, goal: "Review the diff",
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.doesNotMatch(entry.goal, malformedSurrogate);
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, and goal", async () => {
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 runs and async children/i);
240
- assert.match(String(timeoutSchema.description ?? ""), /async children default to 30m/i);
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 ?? ""), /async children default to 30m/i);
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);
@@ -309,4 +309,32 @@ describe("finalizeSingleOutput", () => {
309
309
 
310
310
  assert.equal(result.displayOutput, "truncated output");
311
311
  });
312
+
313
+ it("preserves the saved-output reference for acceptance-only failures", () => {
314
+ const result = finalizeSingleOutput({
315
+ fullOutput: "useful output",
316
+ outputPath: "/tmp/review.md",
317
+ outputMode: "file-only",
318
+ exitCode: 1,
319
+ preserveSavedOutput: true,
320
+ savedPath: "/tmp/review.md",
321
+ });
322
+
323
+ assert.match(result.displayOutput, /^Output saved to:/);
324
+ assert.doesNotMatch(result.displayOutput, /useful output/);
325
+ });
326
+
327
+ it("does not duplicate an existing saved-output reference", () => {
328
+ const outputReference = formatSavedOutputReference("/tmp/review.md", "useful output");
329
+ const result = finalizeSingleOutput({
330
+ fullOutput: `useful output\n\n${outputReference.message}`,
331
+ outputPath: "/tmp/review.md",
332
+ exitCode: 1,
333
+ preserveSavedOutput: true,
334
+ savedPath: "/tmp/review.md",
335
+ outputReference,
336
+ });
337
+
338
+ assert.equal(result.displayOutput.match(/Output saved to:/g)?.length, 1);
339
+ });
312
340
  });
@@ -2,11 +2,12 @@ import assert from "node:assert/strict";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { describe, it } from "node:test";
5
+ import { afterEach, describe, it } from "node:test";
6
6
  import { writeAtomicJson, writePrivateAtomicJson } from "../../src/shared/atomic-json.ts";
7
7
  import { closeSteerInbox, interruptRequestPath, steerRequestsDir, writeSteerAck, type SteerRequest } from "../../src/runs/background/control-channel.ts";
8
8
  import { steerAsyncRun } from "../../src/runs/foreground/async-steering-action.ts";
9
9
  import { createSteeringStatus, recordSteeringRequest, updateSteeringTarget } from "../../src/runs/background/steering.ts";
10
+ import { createRunFanoutBudget } from "../../src/runs/shared/run-fanout-budget.ts";
10
11
  import { ASYNC_DIR, type AsyncStatus, type Details, type SteeringRecoveryDescriptor, type SteeringTargetState, type SubagentState } from "../../src/shared/types.ts";
11
12
 
12
13
  function createState(): SubagentState {
@@ -29,6 +30,11 @@ function createState(): SubagentState {
29
30
  }
30
31
 
31
32
  let statusWriteMtimeMs = Date.now();
33
+ const budgetDirectories: string[] = [];
34
+
35
+ afterEach(() => {
36
+ for (const directory of budgetDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
37
+ });
32
38
 
33
39
  function writeStatus(asyncDir: string, status: AsyncStatus): void {
34
40
  fs.mkdirSync(asyncDir, { recursive: true });
@@ -91,8 +97,11 @@ async function readRequest(asyncDir: string): Promise<SteerRequest> {
91
97
  }
92
98
 
93
99
  function recoveryDescriptor(runId: string): SteeringRecoveryDescriptor {
100
+ const runFanoutBudget = createRunFanoutBudget(runId, 64);
101
+ budgetDirectories.push(runFanoutBudget.directory);
94
102
  return {
95
103
  version: 1,
104
+ runFanoutBudget,
96
105
  sourceRunId: runId,
97
106
  agent: "worker-0",
98
107
  cwd: os.tmpdir(),
@@ -61,6 +61,45 @@ describe("classifyTaskMutationIntent", () => {
61
61
  assert.equal(classifyTaskMutationIntent("worker", "Create a summary").kind, "unknown");
62
62
  });
63
63
 
64
+ it("does not read hyphenated fix adjectives as the fix verb", () => {
65
+ // Core regression: "must-fix items" in review output specs (fails on old code).
66
+ assert.equal(classifyTaskMutationIntent("worker", "Return a review with the top 2-3 must-fix items").kind, "unknown");
67
+ assert.equal(classifyTaskMutationIntent("worker", "Return a review with the top 2-3 must-fix items.").kind, "unknown");
68
+ assert.equal(classifyTaskMutationIntent("worker", "Review and report MUST-FIX items").kind, "unknown");
69
+ // should-fix with a noun from the pattern's alternation (also fails on old code).
70
+ assert.equal(classifyTaskMutationIntent("worker", "List should-fix tests with severity labels").kind, "unknown");
71
+ // Same class for other verbs: hyphenated adjective + verb.
72
+ assert.equal(classifyTaskMutationIntent("worker", "Return must-edit findings").kind, "unknown");
73
+ assert.equal(classifyTaskMutationIntent("worker", "Return must-update items").kind, "unknown");
74
+ assert.equal(classifyTaskMutationIntent("worker", "List should-add files").kind, "unknown");
75
+ assert.equal(classifyTaskMutationIntent("worker", "Return must-apply changes").kind, "unknown");
76
+ assert.equal(classifyTaskMutationIntent("worker", "List should-make changes").kind, "unknown");
77
+ assert.equal(classifyTaskMutationIntent("worker", "must-do those fixes").kind, "unknown");
78
+ // Dash coverage: U+2010 hyphen, U+2011 non-breaking hyphen, en/em dashes.
79
+ assert.equal(classifyTaskMutationIntent("worker", "Return the must\u2010fix items").kind, "unknown");
80
+ assert.equal(classifyTaskMutationIntent("worker", "Return the must\u2011fix items").kind, "unknown");
81
+ assert.equal(classifyTaskMutationIntent("worker", "Return the must\u2013fix items").kind, "unknown");
82
+ assert.equal(classifyTaskMutationIntent("worker", "Return the must\u2014fix items").kind, "unknown");
83
+ // Clause-level em/en dashes are punctuation, not compounds: "branch—fix it".
84
+ assert.equal(classifyTaskMutationIntent("worker", "Inspect the failing branch\u2014fix it.").kind, "implementation");
85
+ assert.equal(classifyTaskMutationIntent("worker", "Inspect the failing branch\u2013fix it.").kind, "implementation");
86
+ assert.equal(taskMayMutate("Inspect the failing branch\u2014fix it."), true);
87
+ // Hyphenated genuine imperatives stay write-capable.
88
+ assert.equal(taskMayMutate("hot-fix the bug"), true);
89
+ assert.equal(taskMayMutate("quick-fix that bug"), true);
90
+ // Genuine imperative usage keeps classifying as implementation.
91
+ assert.equal(classifyTaskMutationIntent("worker", "Go through the list and fix items one by one").kind, "implementation");
92
+ assert.equal(classifyTaskMutationIntent("worker", "fix items on the sprint board").kind, "implementation");
93
+ // CLI flags keep write intent: "--fix" / "-w" are not hyphenated adjectives.
94
+ assert.equal(classifyTaskMutationIntent("worker", "Run eslint --fix code").kind, "implementation");
95
+ assert.equal(taskMayMutate("Run eslint --fix code"), true);
96
+ // "--write" was never a classifier verb (pre-existing unknown); the
97
+ // regression guard is that write-capability survives for acceptance.
98
+ assert.equal(taskMayMutate("Run prettier --write files"), true);
99
+ // Guard-facing mirror: the completion guard must not expect mutation.
100
+ assert.equal(expectsImplementationMutation("worker", "Return a review with the top 2-3 must-fix items"), false);
101
+ });
102
+
64
103
  it("expectsImplementationMutation mirrors the classifier", () => {
65
104
  assert.equal(expectsImplementationMutation("worker", "Do not modify tests; implement the fix"), true);
66
105
  assert.equal(expectsImplementationMutation("worker", "Review the diff and suggest fixes only. Do not edit files."), false);