@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
@@ -25,6 +25,7 @@ import { registerSubagentCapabilityCeiling } from "../../src/api/capability-ceil
25
25
  import { resolveSubagentLaunchContract } from "../../src/api/preflight.ts";
26
26
  import { discoverAgents } from "../../src/agents/agents.ts";
27
27
  import { runSync } from "../../src/runs/foreground/execution.ts";
28
+ import { ACTIVE_ASYNC_CAPACITY_DIR, acquireActiveAsyncCapacity, activeAsyncCapacitySessionKey } from "../../src/runs/background/active-async-capacity.ts";
28
29
 
29
30
  interface LaunchResolvedExtensions {
30
31
  version?: number;
@@ -80,7 +81,7 @@ interface AsyncResultPayload {
80
81
  totalCost?: { inputTokens: number; outputTokens: number; costUsd: number };
81
82
  usageBudget?: UsageBudgetState;
82
83
  checkpoint?: { name?: string; status?: string };
83
- results: Array<{ agent?: string; launchContractDigest?: string; launchResolvedExtensions?: LaunchResolvedExtensions; runtimeAcknowledgedExtensions?: RuntimeAcknowledgedExtensions; output?: string; outputState?: "present" | "absent" | "unknown"; success?: boolean; error?: string; protocolError?: { code?: string; stream?: string; limitBytes?: number; observedBytes?: number }; timedOut?: boolean; stopped?: boolean; turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; terminationDeferredAtTurn?: number; exceededAtTurn?: number }; turnBudgetExceeded?: boolean; wrapUpRequested?: boolean; model?: string; attemptedModels?: string[]; modelAttempts?: Array<{ success?: boolean; error?: string }>; totalCost?: { inputTokens: number; outputTokens: number; costUsd: number }; structuredOutput?: unknown; agentContract?: { version: 1 }; execution?: { status?: string; success?: boolean; exitCode?: number }; effects?: { fileMutation?: { status?: string; expected?: boolean; attempted?: boolean } }; intercomTarget?: string; acceptance?: { status?: string; effectiveAcceptance?: { level?: string }; childReport?: unknown; runtimeChecks?: Array<{ id?: string; status?: string; message?: string }> }; artifactPaths?: { outputPath?: string; inputPath?: string; metadataPath?: string }; outputSaveError?: string; metadataSaveError?: string; capabilityCeiling?: { version?: number; allowedTools?: string[]; denyExtensions?: boolean; sources?: string[] }; capabilityAudit?: { effectiveTools?: string[]; removedTools?: string[]; extensionsDenied?: boolean } }>;
84
+ results: Array<{ agent?: string; launchContractDigest?: string; launchResolvedExtensions?: LaunchResolvedExtensions; runtimeAcknowledgedExtensions?: RuntimeAcknowledgedExtensions; output?: string; outputState?: "present" | "absent" | "unknown"; success?: boolean; error?: string; protocolError?: { code?: string; stream?: string; limitBytes?: number; observedBytes?: number }; timedOut?: boolean; stopped?: boolean; turnBudget?: { maxTurns: number; graceTurns: number; outcome: string; turnCount: number; wrapUpRequestedAtTurn?: number; terminationDeferredAtTurn?: number; exceededAtTurn?: number }; turnBudgetExceeded?: boolean; wrapUpRequested?: boolean; model?: string; attemptedModels?: string[]; modelAttempts?: Array<{ success?: boolean; error?: string }>; totalCost?: { inputTokens: number; outputTokens: number; costUsd: number }; structuredOutput?: unknown; agentContract?: { version: 1 }; execution?: { status?: string; success?: boolean; exitCode?: number }; effects?: { fileMutation?: { status?: string; expected?: boolean; attempted?: boolean } }; intercomTarget?: string; acceptance?: { status?: string; effectiveAcceptance?: { level?: string }; childReport?: unknown; runtimeChecks?: Array<{ id?: string; status?: string; message?: string }> }; artifactPaths?: { outputPath?: string; inputPath?: string; metadataPath?: string; transcriptPath?: string }; outputSaveError?: string; metadataSaveError?: string; capabilityCeiling?: { version?: number; allowedTools?: string[]; denyExtensions?: boolean; sources?: string[] }; capabilityAudit?: { effectiveTools?: string[]; removedTools?: string[]; extensionsDenied?: boolean } }>;
84
85
  outputs?: Record<string, { text?: string; structured?: unknown }>;
85
86
  workflowGraph?: { nodes?: Array<{ kind?: string; label?: string; phase?: string; status?: string; acceptanceStatus?: string; error?: string; outputName?: string; structured?: boolean; children?: Array<{ label?: string; outputName?: string; itemKey?: string; status?: string; acceptanceStatus?: string; error?: string }> }> };
86
87
  parallelHandoff?: { version?: number; path?: string; groupCount?: number; childCount?: number; changedPatches?: number; cleanupState?: string };
@@ -572,6 +573,8 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
572
573
  acceptance: false,
573
574
  });
574
575
  assert.match(launch.details.launchContractDigest ?? "", /^[a-f0-9]{64}$/);
576
+ const recovery = JSON.parse(fs.readFileSync(path.join(ASYNC_DIR, id, "recovery-descriptor.json"), "utf-8")) as { runFanoutBudget?: { rootRunId?: string; limit?: number } };
577
+ assert.deepEqual(recovery.runFanoutBudget && { rootRunId: recovery.runFanoutBudget.rootRunId, limit: recovery.runFanoutBudget.limit }, { rootRunId: id, limit: 64 });
575
578
  const payload = await readAsyncPayload(id);
576
579
  const status = await waitForAsyncState(id, (candidate) => candidate.state === "complete" && candidate.runtimeAcknowledgedExtensions !== undefined);
577
580
  assert.equal(payload.launchContractDigest, launch.details.launchContractDigest);
@@ -796,6 +799,44 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
796
799
  }
797
800
  });
798
801
 
802
+ it("redacts async task text from durable artifacts, transcripts, and metadata", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
803
+ mockPi.onCall({ output: "async redaction complete" });
804
+ const id = `async-prompt-redaction-${Date.now().toString(36)}`;
805
+ const sentinel = "ASYNC_RAW_PROMPT_SENTINEL_1021";
806
+ executeAsyncSingle(id, {
807
+ agent: "worker",
808
+ task: `Handle ${sentinel} without persisting it`,
809
+ agentConfig: makeAgent("worker", { completionGuard: false }),
810
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
811
+ artifactConfig: { enabled: true, includeInput: true, includeOutput: true, includeJsonl: false, includeTranscript: true, includeMetadata: true, cleanupDays: 7 },
812
+ artifactsDir: path.join(tempDir, ".pi-subagents", "artifacts"),
813
+ shareEnabled: false,
814
+ maxSubagentDepth: 2,
815
+ acceptance: false,
816
+ });
817
+
818
+ await waitForMockPiCall(mockPi, 0);
819
+ const callFile = fs.readdirSync(mockPi.dir).find((name) => name.endsWith(".json"));
820
+ assert.ok(callFile);
821
+ const call = JSON.parse(fs.readFileSync(path.join(mockPi.dir, callFile), "utf-8")) as { args?: string[] };
822
+ assert.match(call.args?.join("\n") ?? "", new RegExp(sentinel));
823
+
824
+ const payload = await readAsyncPayload(id);
825
+ const artifactPaths = payload.results[0]?.artifactPaths;
826
+ assert.ok(artifactPaths?.inputPath);
827
+ assert.ok(artifactPaths.metadataPath);
828
+ assert.ok(artifactPaths.transcriptPath);
829
+ const inputText = fs.readFileSync(artifactPaths.inputPath, "utf-8");
830
+ const transcriptText = fs.readFileSync(artifactPaths.transcriptPath, "utf-8");
831
+ const metadataText = fs.readFileSync(artifactPaths.metadataPath, "utf-8");
832
+ assert.doesNotMatch(inputText, new RegExp(sentinel));
833
+ assert.doesNotMatch(transcriptText, new RegExp(sentinel));
834
+ assert.doesNotMatch(metadataText, new RegExp(sentinel));
835
+ assert.match(inputText, /\[prompt redacted\]/);
836
+ assert.match(transcriptText, /\[prompt redacted\]/);
837
+ assert.equal((JSON.parse(metadataText) as { task?: string }).task, "[prompt redacted]");
838
+ });
839
+
799
840
  it("background writes a failure stub to output artifacts when no output was produced", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
800
841
  mockPi.onCall({ output: "", stderr: "model unavailable", exitCode: 1 });
801
842
  const id = `async-empty-failure-artifact-${Date.now().toString(36)}`;
@@ -1531,8 +1572,8 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1531
1572
  assert.match(singleResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
1532
1573
  assert.match(singleResult.content[0]?.text ?? "", /call subagent_wait\(\)/i);
1533
1574
  assert.match(singleResult.content[0]?.text ?? "", /non-interactive run: Pi auto-drains current-session background work at agent_end/);
1534
- assert.equal(startedEvent(singleId).task, wrappedTask.slice(0, 50));
1535
- assert.equal(startedEvent(singleId).goal, rawGoal.slice(0, 120));
1575
+ assert.equal(startedEvent(singleId).task, "[prompt redacted]");
1576
+ assert.equal(startedEvent(singleId).goal, "[prompt redacted]");
1536
1577
  await waitForAsyncResultFile(singleId, 30_000);
1537
1578
 
1538
1579
  mockPi.onCall({ output: "interactive done" });
@@ -1562,7 +1603,7 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1562
1603
  assert.match(parallelResult.content[0]?.text ?? "", /Async parallel:/);
1563
1604
  assert.match(parallelResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
1564
1605
  assert.match(parallelResult.content[0]?.text ?? "", /call subagent_wait\(\)/i);
1565
- assert.equal(startedEvent(parallelId).goal, "Do one");
1606
+ assert.equal(startedEvent(parallelId).goal, "[prompt redacted]");
1566
1607
  const parallelResultPath = await waitForAsyncResultFile(parallelId, 10_000);
1567
1608
  const parallelPayload = JSON.parse(fs.readFileSync(parallelResultPath, "utf-8")) as { agent?: string; mode?: string };
1568
1609
  assert.equal(parallelPayload.mode, "parallel");
@@ -1581,8 +1622,8 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
1581
1622
  assert.match(chainResult.content[0]?.text ?? "", /Async chain:/);
1582
1623
  assert.match(chainResult.content[0]?.text ?? "", /Do not run sleep timers or polling loops/);
1583
1624
  const chainEvent = startedEvent(chainId);
1584
- assert.equal(chainEvent.task, chainChildTask.slice(0, 50));
1585
- assert.equal(chainEvent.goal, chainGoal.slice(0, 120));
1625
+ assert.equal(chainEvent.task, "[prompt redacted]");
1626
+ assert.equal(chainEvent.goal, "[prompt redacted]");
1586
1627
  await waitForAsyncResultFile(chainId, 10_000);
1587
1628
  });
1588
1629
 
@@ -3565,6 +3606,55 @@ describe("async execution utilities", { skip: !available ? "pi packages not avai
3565
3606
  assert.deepEqual(payload.results[0].attemptedModels, ["github-copilot/gpt-5-mini"]);
3566
3607
  });
3567
3608
 
3609
+ it("rejects an over-cap top-level async launch before creating run artifacts", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
3610
+ fs.rmSync(path.join(ACTIVE_ASYNC_CAPACITY_DIR, activeAsyncCapacitySessionKey("session-cap")), { recursive: true, force: true });
3611
+ const state = {
3612
+ baseCwd: tempDir,
3613
+ currentSessionId: "session-cap",
3614
+ asyncJobs: new Map(),
3615
+ fleetJobs: new Map(),
3616
+ foregroundControls: new Map(),
3617
+ lastForegroundControlId: null,
3618
+ };
3619
+ const occupied = acquireActiveAsyncCapacity({
3620
+ sessionId: "session-cap",
3621
+ limit: 1,
3622
+ runId: "held-run",
3623
+ kind: "runner",
3624
+ asyncDir: path.join(tempDir, "held-run"),
3625
+ });
3626
+ assert.ok(occupied);
3627
+ occupied.markStarted("held-runner");
3628
+ const rejectedAsyncDir = path.join(ASYNC_DIR, "cap-rejected");
3629
+ const rejectedResultPath = path.join(RESULTS_DIR, "cap-rejected.json");
3630
+ fs.rmSync(rejectedAsyncDir, { recursive: true, force: true });
3631
+ fs.rmSync(rejectedResultPath, { force: true });
3632
+ const executor = createSubagentExecutor!({
3633
+ pi: { events: createEventBus(), getSessionName: () => undefined },
3634
+ state,
3635
+ config: { maxActiveAsyncRunsPerSession: 1, artifactDir: "project" },
3636
+ asyncByDefault: false,
3637
+ tempArtifactsDir: tempDir,
3638
+ getSubagentSessionRoot: () => path.join(tempDir, "sessions"),
3639
+ expandTilde: (p: string) => p,
3640
+ discoverAgents: () => ({ agents: [makeAgent("worker")] }),
3641
+ });
3642
+ const context = makeMinimalCtx(tempDir);
3643
+ context.sessionManager.getSessionFile = () => null;
3644
+ context.sessionManager.getSessionId = () => "session-cap";
3645
+ const previousDepth = process.env.SELESAI_SUBAGENT_DEPTH;
3646
+ process.env.SELESAI_SUBAGENT_DEPTH = "0";
3647
+ const result = await executor.execute("cap-rejected", { agent: "worker", task: "Must not start", async: true }, new AbortController().signal, undefined, context);
3648
+ if (previousDepth === undefined) delete process.env.SELESAI_SUBAGENT_DEPTH;
3649
+ else process.env.SELESAI_SUBAGENT_DEPTH = previousDepth;
3650
+ assert.equal(result.isError, true);
3651
+ assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /Active async run capacity exhausted: 1\/1 used/);
3652
+ assert.equal(fs.existsSync(rejectedAsyncDir), false);
3653
+ assert.equal(fs.existsSync(rejectedResultPath), false);
3654
+ assert.equal(fs.existsSync(path.join(tempDir, ".pi-subagents")), false);
3655
+ fs.rmSync(path.join(ACTIVE_ASYNC_CAPACITY_DIR, activeAsyncCapacitySessionKey("session-cap")), { recursive: true, force: true });
3656
+ });
3657
+
3568
3658
  it("scheduled executor launches retain the live active session ownership", { skip: !isAsyncAvailable() || !createSubagentExecutor ? "jiti or executor not available" : undefined }, async () => {
3569
3659
  mockPi.onCall({ output: "Scheduled work completed" });
3570
3660
  const liveCwd = path.join(tempDir, "live-project");
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  import { describe, it } from "node:test";
6
6
  import { formatAsyncRunList, listAsyncRuns } from "../../src/runs/background/async-status.ts";
7
7
  import { ACTIVE_RUN_INDEX_DIR, updateActiveRunIndex } from "../../src/runs/background/active-run-index.ts";
8
+ import { claimRunFanoutBatch, createRunFanoutBudget, writeRunFanoutBudgetDescriptor } from "../../src/runs/shared/run-fanout-budget.ts";
8
9
 
9
10
  function createAsyncDir(root: string, id: string, status: Record<string, unknown>): string {
10
11
  const dir = path.join(root, id);
@@ -17,9 +18,10 @@ function createAsyncDir(root: string, id: string, status: Record<string, unknown
17
18
  describe("async status helpers", () => {
18
19
  it("lists only requested states and includes flattened step summaries", () => {
19
20
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-async-status-"));
21
+ let budgetDirectory: string | undefined;
20
22
  try {
21
23
  const outputFile = path.join(root, "run-a", "output-1.log");
22
- createAsyncDir(root, "run-a", {
24
+ const runDir = createAsyncDir(root, "run-a", {
23
25
  runId: "run-a",
24
26
  mode: "chain",
25
27
  state: "running",
@@ -27,12 +29,17 @@ describe("async status helpers", () => {
27
29
  lastUpdate: 200,
28
30
  cwd: "/repo-a",
29
31
  currentStep: 1,
32
+ runFanoutBudget: { used: 2, limit: 64, remaining: 62 },
30
33
  outputFile,
31
34
  steps: [
32
35
  { agent: "scout", status: "complete", durationMs: 10, description: "Inspect auth only" },
33
36
  { agent: "worker", status: "running", durationMs: 20, description: "Patch billing only" },
34
37
  ],
35
38
  });
39
+ const descriptor = createRunFanoutBudget("run-a", 64);
40
+ budgetDirectory = descriptor.directory;
41
+ writeRunFanoutBudgetDescriptor(runDir, descriptor);
42
+ claimRunFanoutBatch(descriptor, ["chain[0]", "chain[1]", "chain[1]/single"]);
36
43
  createAsyncDir(root, "run-b", {
37
44
  runId: "run-b",
38
45
  mode: "single",
@@ -51,9 +58,12 @@ describe("async status helpers", () => {
51
58
  assert.equal(runs[0]?.steps[1]?.status, "running");
52
59
  assert.equal(runs[0]?.steps[0]?.description, "Inspect auth only");
53
60
  assert.equal(runs[0]?.steps[1]?.description, "Patch billing only");
54
- assert.match(formatAsyncRunList(runs), /output: .*output-1\.log/);
61
+ const text = formatAsyncRunList(runs);
62
+ assert.match(text, /Run fan-out: 3\/64 used, 61 remaining/);
63
+ assert.match(text, /output: .*output-1\.log/);
55
64
  } finally {
56
65
  fs.rmSync(root, { recursive: true, force: true });
66
+ if (budgetDirectory) fs.rmSync(budgetDirectory, { recursive: true, force: true });
57
67
  }
58
68
  });
59
69
 
@@ -597,7 +607,7 @@ describe("async status helpers", () => {
597
607
  }
598
608
  });
599
609
 
600
- it("prunes terminal active markers while preserving history listing", () => {
610
+ it("keeps terminal active markers until observed process-terminal proof releases them", () => {
601
611
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-async-status-index-prune-"));
602
612
  try {
603
613
  const asyncDir = createAsyncDir(root, "finished", {
@@ -606,13 +616,26 @@ describe("async status helpers", () => {
606
616
  state: "complete",
607
617
  startedAt: 100,
608
618
  lastUpdate: 200,
619
+ displayDismissedAt: 250,
620
+ processTerminal: { version: 1, state: "unknown", runId: "finished", runnerProcessInstanceId: "runner", reason: "process-tree-unverified" },
609
621
  steps: [{ agent: "worker", status: "complete" }],
610
622
  });
611
623
  updateActiveRunIndex(asyncDir, "running");
624
+ const markerPath = path.join(root, ACTIVE_RUN_INDEX_DIR, "finished");
625
+
626
+ assert.deepEqual(listAsyncRuns(root, { states: ["running"], reconcile: false }), []);
627
+ assert.equal(fs.existsSync(markerPath), true);
612
628
 
629
+ fs.writeFileSync(path.join(asyncDir, "process-terminal.json"), JSON.stringify({
630
+ version: 1,
631
+ state: "observed",
632
+ runId: "finished",
633
+ runnerProcessInstanceId: "runner",
634
+ observedAt: 300,
635
+ instances: [{ kind: "runner", processInstanceId: "runner", closeObservedAt: 300, exitCode: 0, signal: null }],
636
+ }), "utf-8");
613
637
  assert.deepEqual(listAsyncRuns(root, { states: ["running"], reconcile: false }), []);
614
- assert.equal(fs.existsSync(path.join(root, ACTIVE_RUN_INDEX_DIR, "finished")), false);
615
- assert.deepEqual(listAsyncRuns(root, { states: ["complete"], reconcile: false }).map((run) => run.id), ["finished"]);
638
+ assert.equal(fs.existsSync(markerPath), false);
616
639
  } finally {
617
640
  fs.rmSync(root, { recursive: true, force: true });
618
641
  }
@@ -667,10 +667,11 @@ describe("chain execution — sequential", { skip: !available ? "pi packages not
667
667
  );
668
668
 
669
669
  assert.ok(!result.isError);
670
- const step2Task = result.details.results[1].task;
670
+ assert.equal(result.details.results[1].task, "[prompt redacted]");
671
+ const step2Task = readCallArgs(1).at(-1) ?? "";
671
672
  assert.ok(
672
673
  step2Task.includes("MARKER_ABC_123"),
673
- `step 2 task should contain step 1 output via {previous}: ${step2Task.slice(0, 200)}`,
674
+ `step 2 invocation should contain step 1 output via {previous}: ${step2Task.slice(0, 200)}`,
674
675
  );
675
676
  });
676
677
 
@@ -1164,10 +1165,11 @@ describe("chain execution — sequential", { skip: !available ? "pi packages not
1164
1165
  );
1165
1166
 
1166
1167
  assert.ok(!result.isError);
1167
- const workerTask = result.details.results[0].task;
1168
+ assert.equal(result.details.results[0].task, "[prompt redacted]");
1169
+ const workerTask = readCallArgs(0).at(-1) ?? "";
1168
1170
  assert.ok(
1169
1171
  workerTask.includes("the authentication module"),
1170
- `should substitute {task}: ${workerTask.slice(0, 200)}`,
1172
+ `should substitute {task} in the child invocation: ${workerTask.slice(0, 200)}`,
1171
1173
  );
1172
1174
  });
1173
1175
 
@@ -1566,7 +1568,8 @@ describe("chain execution — parallel steps", { skip: !available ? "pi packages
1566
1568
 
1567
1569
  assert.ok(!result.isError);
1568
1570
  assert.equal(result.details.results.length, 3);
1569
- const synthTask = result.details.results[2].task;
1571
+ assert.equal(result.details.results[2].task, "[prompt redacted]");
1572
+ const synthTask = readCallArgs(2).at(-1) ?? "";
1570
1573
  assert.ok(
1571
1574
  synthTask.includes("=== Parallel Task 1 (reviewer-a) ==="),
1572
1575
  "synthesizer should include reviewer-a output block",
@@ -5,6 +5,7 @@ import * as path from "node:path";
5
5
  import type { MockPi } from "../support/helpers.ts";
6
6
  import { createEventBus, createMockPi, createTempDir, events, removeTempDir, tryImport } from "../support/helpers.ts";
7
7
  import { discoverAgents } from "../../src/agents/agents.ts";
8
+ import { ACTIVE_ASYNC_CAPACITY_DIR, acquireActiveAsyncCapacity, activeAsyncCapacitySessionKey } from "../../src/runs/background/active-async-capacity.ts";
8
9
  import { DEFAULT_FORK_PREAMBLE, INTERCOM_DETACH_REQUEST_EVENT, SUBAGENT_ASYNC_STARTED_EVENT } from "../../src/shared/types.ts";
9
10
 
10
11
  interface ExecutorModule {
@@ -1523,6 +1524,7 @@ describe("fork context execution wiring", { skip: !available ? "subagent executo
1523
1524
  const rawParallelChainGoal = "Parallel chain first child raw goal";
1524
1525
  const paddedWorkflowGoal = " preserve this workflow padding ";
1525
1526
  const literalPreambleGoal = `${DEFAULT_FORK_PREAMBLE}\n\nTask:\nliteral fresh-context text`;
1527
+ const redactedGoal = "[prompt redacted]";
1526
1528
  const executor = makeExecutorWithDiscoverAgents(() => ({
1527
1529
  agents: [
1528
1530
  { name: "echo", description: "Echo", defaultContext: "fork" },
@@ -1576,13 +1578,91 @@ describe("fork context execution wiring", { skip: !available ? "subagent executo
1576
1578
  assert.ok(result.details?.asyncId, `${testCase.name}: expected an async id`);
1577
1579
  const event = started.find((entry) => entry.id === result.details?.asyncId);
1578
1580
  assert.ok(event, `${testCase.name}: missing async-started event for ${result.details?.asyncId}`);
1579
- assert.equal(event.goal, testCase.goal, testCase.name);
1581
+ assert.equal(event.goal, redactedGoal, testCase.name);
1580
1582
  if (!("allowsLiteralPreamble" in testCase)) {
1581
1583
  assert.doesNotMatch(event.goal ?? "", /delegated subagent running from a fork/, testCase.name);
1584
+ assert.notEqual(event.goal, testCase.goal, testCase.name);
1582
1585
  }
1583
1586
  }
1584
1587
  });
1585
1588
 
1589
+ it("rejects Clarify background switches over the active async capacity cap", { skip: !asyncAvailable ? "jiti not available" : undefined }, async () => {
1590
+ const previousDepth = process.env.SELESAI_SUBAGENT_DEPTH;
1591
+ process.env.SELESAI_SUBAGENT_DEPTH = "0";
1592
+ try {
1593
+ for (const testCase of [
1594
+ {
1595
+ name: "single",
1596
+ mode: "single",
1597
+ params: { agent: "echo", task: "Original single", async: true, clarify: true },
1598
+ templates: ["Edited single"],
1599
+ },
1600
+ {
1601
+ name: "parallel",
1602
+ mode: "parallel",
1603
+ params: { tasks: [{ agent: "echo", task: "Original parallel" }, { agent: "second", task: "Second child" }], async: true, clarify: true },
1604
+ templates: ["Edited parallel", "Second child"],
1605
+ },
1606
+ {
1607
+ name: "chain",
1608
+ mode: "chain",
1609
+ params: { chain: [{ agent: "echo", task: "Original chain" }], async: true, clarify: true },
1610
+ templates: ["Edited chain"],
1611
+ },
1612
+ ] as const) {
1613
+ const sessionId = `session-clarify-cap-${testCase.name}-${Date.now()}`;
1614
+ const sessionCapacityDir = path.join(ACTIVE_ASYNC_CAPACITY_DIR, activeAsyncCapacitySessionKey(sessionId));
1615
+ fs.rmSync(sessionCapacityDir, { recursive: true, force: true });
1616
+ const held = acquireActiveAsyncCapacity({
1617
+ sessionId,
1618
+ limit: 1,
1619
+ runId: `held-${testCase.name}`,
1620
+ kind: "runner",
1621
+ asyncDir: path.join(tempDir, `held-${testCase.name}`),
1622
+ });
1623
+ held?.markStarted(`held-runner-${testCase.name}`);
1624
+ try {
1625
+ const executor = makeExecutorWithConfig({ maxActiveAsyncRunsPerSession: 1 });
1626
+ const manager = {
1627
+ ...makeSessionManagerRecorder().manager,
1628
+ getSessionId: () => sessionId,
1629
+ };
1630
+ const ctx = {
1631
+ ...makeCtx(manager),
1632
+ hasUI: true,
1633
+ ui: {
1634
+ custom: async () => ({
1635
+ confirmed: true,
1636
+ templates: testCase.templates,
1637
+ behaviorOverrides: testCase.templates.map(() => undefined),
1638
+ runInBackground: true,
1639
+ }),
1640
+ },
1641
+ };
1642
+
1643
+ const result = await executor.execute(
1644
+ `clarify-cap-${testCase.name}`,
1645
+ testCase.params,
1646
+ new AbortController().signal,
1647
+ undefined,
1648
+ ctx,
1649
+ );
1650
+
1651
+ assert.equal(result.isError, true, testCase.name);
1652
+ assert.equal(result.details?.mode, testCase.mode, testCase.name);
1653
+ assert.equal(result.details?.asyncId, undefined, testCase.name);
1654
+ assert.match(result.content[0]?.text ?? "", /Active async run capacity exhausted: 1\/1 used/, testCase.name);
1655
+ assert.equal(mockPi.callCount(), 0, testCase.name);
1656
+ } finally {
1657
+ fs.rmSync(sessionCapacityDir, { recursive: true, force: true });
1658
+ }
1659
+ }
1660
+ } finally {
1661
+ if (previousDepth === undefined) delete process.env.SELESAI_SUBAGENT_DEPTH;
1662
+ else process.env.SELESAI_SUBAGENT_DEPTH = previousDepth;
1663
+ }
1664
+ });
1665
+
1586
1666
  it("uses edited raw goals when clarification switches each mode to background", { skip: !asyncAvailable ? "jiti not available" : undefined }, async () => {
1587
1667
  const executor = makeExecutorWithDiscoverAgents(() => ({
1588
1668
  agents: [
@@ -1640,7 +1720,8 @@ describe("fork context execution wiring", { skip: !available ? "subagent executo
1640
1720
  assert.equal(result.isError, undefined, testCase.name);
1641
1721
  assert.ok(result.details?.asyncId, `${testCase.name}: expected an async id`);
1642
1722
  const event = started.find((entry) => entry.id === result.details?.asyncId);
1643
- assert.equal(event?.goal, testCase.goal, testCase.name);
1723
+ assert.equal(event?.goal, "[prompt redacted]", testCase.name);
1724
+ assert.notEqual(event?.goal, testCase.goal, testCase.name);
1644
1725
  assert.doesNotMatch(event?.goal ?? "", /delegated subagent running from a fork/, testCase.name);
1645
1726
  }
1646
1727
  });