pi-subagents 0.48.0 → 0.50.0

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 (88) hide show
  1. package/CHANGELOG.md +82 -19
  2. package/agents/oracle.md +7 -5
  3. package/agents/researcher.md +1 -1
  4. package/agents/reviewer.md +2 -2
  5. package/agents/scout.md +1 -1
  6. package/agents/worker.md +1 -1
  7. package/docs/agents.md +3 -0
  8. package/docs/configuration.md +75 -8
  9. package/docs/extension-api.md +36 -0
  10. package/docs/missions.md +5 -3
  11. package/docs/observability.md +42 -2
  12. package/docs/tool-reference.md +19 -2
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  16. package/skills/pi-subagents/references/execution-controls.md +5 -4
  17. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  18. package/skills/pi-subagents/references/prompting-and-roles.md +31 -15
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +37 -12
  21. package/src/api/external-runs.ts +174 -84
  22. package/src/api/preflight.ts +13 -7
  23. package/src/extension/config.ts +53 -0
  24. package/src/extension/index.ts +62 -9
  25. package/src/extension/public-execution.ts +32 -4
  26. package/src/extension/rpc.ts +5 -1
  27. package/src/extension/schemas.ts +10 -8
  28. package/src/extension/tool-description.ts +13 -9
  29. package/src/inspectors/herdr/actions.ts +13 -8
  30. package/src/inspectors/herdr/inspector-runner.ts +16 -3
  31. package/src/inspectors/herdr/project-panes.ts +2 -6
  32. package/src/inspectors/herdr/shell-command.ts +16 -0
  33. package/src/intercom/intercom-bridge.ts +5 -4
  34. package/src/intercom/native-supervisor-channel.ts +19 -42
  35. package/src/missions/goal-driver.ts +3 -1
  36. package/src/missions/store.ts +8 -3
  37. package/src/runs/background/active-async-capacity.ts +82 -25
  38. package/src/runs/background/active-run-index.ts +71 -1
  39. package/src/runs/background/async-execution.ts +73 -43
  40. package/src/runs/background/async-job-tracker.ts +5 -0
  41. package/src/runs/background/async-resume.ts +14 -6
  42. package/src/runs/background/async-status-snapshot.ts +277 -0
  43. package/src/runs/background/async-status.ts +8 -3
  44. package/src/runs/background/chain-root-attachment.ts +2 -2
  45. package/src/runs/background/completion-replay.ts +11 -1
  46. package/src/runs/background/fleet-view.ts +21 -6
  47. package/src/runs/background/result-files.ts +437 -0
  48. package/src/runs/background/result-watcher.ts +188 -41
  49. package/src/runs/background/resume-guidance.ts +27 -7
  50. package/src/runs/background/retained-children.ts +75 -18
  51. package/src/runs/background/run-id-resolver.ts +30 -24
  52. package/src/runs/background/run-status.ts +101 -4
  53. package/src/runs/background/scheduled-runs.ts +54 -28
  54. package/src/runs/background/stale-run-reconciler.ts +27 -13
  55. package/src/runs/background/subagent-runner.ts +298 -33
  56. package/src/runs/background/subagent-wait.ts +2 -0
  57. package/src/runs/background/wait-completions.ts +5 -2
  58. package/src/runs/foreground/async-dismiss-action.ts +2 -1
  59. package/src/runs/foreground/chain-execution.ts +16 -0
  60. package/src/runs/foreground/execution.ts +219 -15
  61. package/src/runs/foreground/prompt-audit.ts +4 -3
  62. package/src/runs/foreground/subagent-executor.ts +328 -43
  63. package/src/runs/shared/completion-guard.ts +107 -1
  64. package/src/runs/shared/external-cli-runner.ts +4 -0
  65. package/src/runs/shared/llm-intent-arbiter.ts +39 -23
  66. package/src/runs/shared/model-fallback.ts +16 -2
  67. package/src/runs/shared/nested-events.ts +66 -62
  68. package/src/runs/shared/orca-progress-tabs.ts +375 -0
  69. package/src/runs/shared/parallel-utils.ts +2 -0
  70. package/src/runs/shared/subagent-control.ts +15 -0
  71. package/src/runs/shared/subagent-prompt-runtime.ts +1 -9
  72. package/src/runs/shared/subagent-startup-retry.ts +12 -0
  73. package/src/runs/shared/tool-timeout.ts +93 -0
  74. package/src/shared/agent-stream-options.ts +5 -0
  75. package/src/shared/artifacts.ts +2 -6
  76. package/src/shared/display-text.ts +50 -0
  77. package/src/shared/node-executable.ts +21 -0
  78. package/src/shared/types.ts +51 -5
  79. package/src/slash/slash-commands.ts +34 -25
  80. package/src/slash/slash-live-state.ts +3 -0
  81. package/src/tui/fleet-status.ts +160 -45
  82. package/src/tui/fleet-transcript.ts +1 -48
  83. package/src/tui/fleet.ts +128 -16
  84. package/src/tui/render.ts +122 -44
  85. package/src/watchdog/permission-arbiter.ts +2 -1
  86. package/src/watchdog/review.ts +4 -3
  87. package/src/workflows/chat-progress.ts +10 -2
  88. package/src/workflows/scripted-workflow.ts +272 -76
@@ -26,9 +26,8 @@ Use contact_supervisor first. It resolves the supervisor session "{orchestratorT
26
26
  - After contact_supervisor with reason "need_decision" or "interview_request", stay alive and continue only after the reply arrives. Do not finish your final response with a choose-one question.
27
27
  - Do not ask for clarification when the only conflict is review-only/no-edit versus progress-writing or artifact-writing instructions. If an output path is configured but no write-capable tool is available, return the complete artifact in your final response; the runtime will persist it. Do not contact the supervisor merely because you cannot write that output path directly.
28
28
  - Meaningful progress or unexpected discoveries that change the plan: contact_supervisor({ reason: "progress_update", message: "UPDATE: <summary>" })
29
- - Generic intercom is lower-level plumbing/fallback only: intercom({ action: "ask", to: "{orchestratorTarget}", message: "<question>" })
30
29
 
31
- Do not use contact_supervisor or intercom for routine completion handoffs. If no coordination is needed, return a focused task result.`;
30
+ Do not use contact_supervisor for routine completion handoffs. If no coordination is needed, return a focused task result.`;
32
31
 
33
32
  export interface IntercomBridgeState {
34
33
  active: boolean;
@@ -51,6 +50,8 @@ export interface IntercomBridgeDiagnostic {
51
50
 
52
51
  interface ResolveIntercomBridgeInput {
53
52
  config: ExtensionConfig["intercomBridge"];
53
+ /** Per-run config replaces the global config when supplied. */
54
+ override?: IntercomBridgeConfig;
54
55
  context: "fresh" | "fork" | undefined;
55
56
  orchestratorTarget?: string;
56
57
  cwd?: string;
@@ -143,7 +144,7 @@ export function diagnoseIntercomBridge(input: ResolveIntercomBridgeInput): Inter
143
144
  }
144
145
 
145
146
  export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeState {
146
- const config = resolveIntercomBridgeConfig(input.config);
147
+ const config = resolveIntercomBridgeConfig(input.override !== undefined ? input.override : input.config);
147
148
  const mode = config.mode;
148
149
  const orchestratorTarget = input.orchestratorTarget?.trim();
149
150
  const agentDir = path.resolve(input.agentDir ?? defaultAgentDir());
@@ -169,7 +170,7 @@ export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): Interc
169
170
  export function applyIntercomBridgeToAgent(agent: AgentConfig, bridge: IntercomBridgeState): AgentConfig {
170
171
  if (!bridge.active || !bridge.orchestratorTarget) return agent;
171
172
 
172
- const bridgeTools = ["intercom", "contact_supervisor"];
173
+ const bridgeTools = ["contact_supervisor"];
173
174
  const tools = agent.tools && agent.tools.length > 0
174
175
  ? [...agent.tools, ...bridgeTools.filter((tool) => !agent.tools?.includes(tool))]
175
176
  : agent.tools;
@@ -295,38 +295,18 @@ function hasTool(pi: ExtensionAPI, name: string): boolean {
295
295
  }
296
296
  }
297
297
 
298
- export function registerNativeSupervisorClient(pi: ExtensionAPI, options: { includeIntercomFallback?: boolean } = {}): void {
299
- if (!readChildMetadata()) return;
300
- const includeIntercomFallback = options.includeIntercomFallback !== false;
301
- if (!hasTool(pi, "contact_supervisor")) {
302
- const tool: ToolDefinition<typeof ContactSupervisorParamsSchema, Record<string, unknown>> = {
303
- name: "contact_supervisor",
304
- label: "Contact Supervisor",
305
- description: "Contact the parent/supervisor session for a blocking decision, structured interview, or progress update.",
306
- parameters: ContactSupervisorParamsSchema,
307
- execute(_id, params, signal) {
308
- return sendSupervisorRequest(params as ContactSupervisorParams, signal);
309
- },
310
- };
311
- pi.registerTool(tool);
312
- }
313
- if (includeIntercomFallback && !hasTool(pi, "intercom")) {
314
- const tool: ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> = {
315
- name: "intercom",
316
- label: "Intercom",
317
- description: "Native supervisor-channel intercom fallback for subagents. Prefer contact_supervisor when available.",
318
- parameters: IntercomParamsSchema,
319
- async execute(_id, params, signal) {
320
- const action = (params as IntercomParams).action;
321
- if (action === "status") return { content: [{ type: "text", text: "Native supervisor channel is active." }], details: { active: true } };
322
- if (action === "list") return { content: [{ type: "text", text: "Supervisor session available through contact_supervisor." }], details: { sessions: [] } };
323
- if (action === "send") return sendSupervisorRequest({ reason: "progress_update", message: (params as IntercomParams).message ?? "" }, signal);
324
- if (action === "ask") return sendSupervisorRequest({ reason: "need_decision", message: (params as IntercomParams).message ?? "" }, signal);
325
- throw new Error("Native child intercom supports status, list, send, and ask. Use parent intercom reply from the supervisor session.");
326
- },
327
- };
328
- pi.registerTool(tool);
329
- }
298
+ export function registerNativeSupervisorClient(pi: ExtensionAPI): void {
299
+ if (!readChildMetadata() || hasTool(pi, "contact_supervisor")) return;
300
+ const tool: ToolDefinition<typeof ContactSupervisorParamsSchema, Record<string, unknown>> = {
301
+ name: "contact_supervisor",
302
+ label: "Contact Supervisor",
303
+ description: "Contact the parent/supervisor session for a blocking decision, structured interview, or progress update.",
304
+ parameters: ContactSupervisorParamsSchema,
305
+ execute(_id, params, signal) {
306
+ return sendSupervisorRequest(params as ContactSupervisorParams, signal);
307
+ },
308
+ };
309
+ pi.registerTool(tool);
330
310
  }
331
311
 
332
312
  function parseRequestFile(file: string, channelDir: string): PendingSupervisorRequest | undefined {
@@ -601,13 +581,11 @@ function publicPendingRequests(pending: Map<string, PendingSupervisorRequest>):
601
581
  }));
602
582
  }
603
583
 
604
- function buildParentIntercomTool(pending: Map<string, PendingSupervisorRequest>, state: SubagentState, name = "intercom"): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
584
+ function buildParentSupervisorTool(pending: Map<string, PendingSupervisorRequest>, state: SubagentState): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
605
585
  return {
606
- name,
607
- label: name === "intercom" ? "Intercom" : "Subagent Supervisor",
608
- description: name === "intercom"
609
- ? "Native pi-subagents supervisor channel. Use reply/pending/status to answer child subagent requests."
610
- : "Native pi-subagents supervisor channel. Use reply/pending/status to answer child subagent requests without overriding pi-intercom.",
586
+ name: NATIVE_SUPERVISOR_TOOL_NAME,
587
+ label: "Subagent Supervisor",
588
+ description: "Native pi-subagents supervisor channel. Use reply/pending/status to answer child subagent requests without overriding pi-intercom.",
611
589
  parameters: IntercomParamsSchema,
612
590
  async execute(_id, params) {
613
591
  refreshPendingRequests(pending, state, state.lastUiContext ?? undefined);
@@ -627,9 +605,9 @@ function buildParentIntercomTool(pending: Map<string, PendingSupervisorRequest>,
627
605
  return { content: [{ type: "text", text: `Replied to supervisor request ${request.id}.` }], details: { replyTo: request.id, runId: request.runId, agent: request.agent } };
628
606
  }
629
607
  if (input.action === "send" || input.action === "ask") {
630
- throw new Error("Native pi-subagents intercom currently handles supervisor replies. Child agents initiate asks with contact_supervisor.");
608
+ throw new Error("The native subagent supervisor handles replies only. Child agents initiate asks with contact_supervisor.");
631
609
  }
632
- throw new Error(`Unsupported intercom action: ${input.action}`);
610
+ throw new Error(`Unsupported supervisor action: ${input.action}`);
633
611
  },
634
612
  };
635
613
  }
@@ -647,8 +625,7 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
647
625
  let lastStaleCleanupAt = 0;
648
626
 
649
627
  const registerParentTools = (): void => {
650
- if (!hasTool(pi, NATIVE_SUPERVISOR_TOOL_NAME)) pi.registerTool(buildParentIntercomTool(pending, state, NATIVE_SUPERVISOR_TOOL_NAME));
651
- if (!hasTool(pi, "intercom")) pi.registerTool(buildParentIntercomTool(pending, state));
628
+ if (!hasTool(pi, NATIVE_SUPERVISOR_TOOL_NAME)) pi.registerTool(buildParentSupervisorTool(pending, state));
652
629
  };
653
630
 
654
631
  const cleanupStaleChannelsIfDue = (): void => {
@@ -99,7 +99,9 @@ function missionStateAction(location: MissionStoreLocation, record: MissionRecor
99
99
  function retainedResumeTarget(record: MissionRecord, retainedChildren: RetainedChild[]): RetainedChild | undefined {
100
100
  const latestRun = record.runs.at(-1);
101
101
  if (!latestRun) return undefined;
102
- return retainedChildren.find((child) => child.runId === latestRun.runId || child.parentRunId === latestRun.runId);
102
+ return retainedChildren.find((child) =>
103
+ child.resumability.state === "resumable" && (child.runId === latestRun.runId || child.parentRunId === latestRun.runId)
104
+ );
103
105
  }
104
106
 
105
107
  function nextReadyAction(location: MissionStoreLocation, record: MissionRecord, retainedChildren: RetainedChild[]): string {
@@ -2,7 +2,6 @@ import { createHash, randomUUID } from "node:crypto";
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 { getProjectSubagentsDir } from "../shared/artifacts.ts";
6
5
  import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
7
6
  import { getAgentDir } from "../shared/utils.ts";
8
7
  import {
@@ -259,6 +258,11 @@ function expandConfiguredPath(value: string, projectRoot: string): string {
259
258
  return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(projectRoot, expanded);
260
259
  }
261
260
 
261
+ function projectMissionDirectory(agentDir: string, projectRoot: string): string {
262
+ const projectKey = createHash("sha256").update(projectRoot).digest("hex");
263
+ return path.join(agentDir, "missions", "projects", projectKey);
264
+ }
265
+
262
266
  export function validateMissionStoreConfig(value: unknown, label = "config.missions"): MissionStoreConfig | undefined {
263
267
  if (value === undefined) return undefined;
264
268
  const input = asObject(value, label);
@@ -289,12 +293,13 @@ export function resolveMissionStoreLocation(input: {
289
293
  agentDir?: string;
290
294
  }): MissionStoreLocation {
291
295
  const projectRoot = path.resolve(input.projectRoot);
296
+ const agentDir = input.agentDir ?? getAgentDir();
292
297
  const missionDir = input.config?.directory
293
298
  ? expandConfiguredPath(input.config.directory, projectRoot)
294
- : path.join(getProjectSubagentsDir(projectRoot), "missions");
299
+ : projectMissionDirectory(agentDir, projectRoot);
295
300
  const globalIndexDir = input.config?.globalIndexDir
296
301
  ? expandConfiguredPath(input.config.globalIndexDir, projectRoot)
297
- : path.join(input.agentDir ?? getAgentDir(), "missions", "index");
302
+ : path.join(agentDir, "missions", "index");
298
303
  return {
299
304
  projectRoot,
300
305
  missionDir,
@@ -39,6 +39,18 @@ interface CapacityOptions {
39
39
  afterSlotRename?: (releasedDir: string) => void;
40
40
  }
41
41
 
42
+ export type ActiveAsyncCapacityReleaseVerdict =
43
+ | { state: "releasable"; reason: string }
44
+ | { state: "retained"; reason: string }
45
+ | { state: "not-owned"; reason: string };
46
+
47
+ export interface ActiveAsyncCapacityInspection {
48
+ owner?: ActiveAsyncCapacityOwnerV1;
49
+ relation: "current" | "source" | "none";
50
+ slotDir?: string;
51
+ release: ActiveAsyncCapacityReleaseVerdict;
52
+ }
53
+
42
54
  export class ActiveAsyncCapacityError extends Error {
43
55
  readonly snapshot: ActiveAsyncCapacitySnapshot;
44
56
 
@@ -160,55 +172,100 @@ function terminalState(state: AsyncStatus["state"]): boolean {
160
172
  return state !== "queued" && state !== "running" && state !== "paused";
161
173
  }
162
174
 
163
- function runnerCanRelease(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus): boolean {
164
- if (!owner.runnerProcessInstanceId
165
- || status.sessionId !== owner.ownerSessionId
166
- || status.runId !== owner.runId
167
- || !terminalState(status.state)) return false;
175
+ function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null): ActiveAsyncCapacityReleaseVerdict {
176
+ if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
177
+ if (!owner.runnerProcessInstanceId) return { state: "retained", reason: "runner process identity has not been recorded" };
178
+ if (status.sessionId !== owner.ownerSessionId) return { state: "retained", reason: `status session ${status.sessionId ?? "unknown"} does not match owner session ${owner.ownerSessionId}` };
179
+ if (status.runId !== owner.runId) return { state: "retained", reason: `status run ${status.runId} does not match owner run ${owner.runId}` };
180
+ if (!terminalState(status.state)) return { state: "retained", reason: `run is still ${status.state}` };
168
181
  if (status.processTerminal?.state === "not-started"
169
182
  && status.processTerminal.runId === owner.runId
170
183
  && status.processTerminal.runnerProcessInstanceId === owner.runnerProcessInstanceId
171
184
  && typeof status.error === "string"
172
- && status.error) return true;
185
+ && status.error) return { state: "releasable", reason: "run failed before child startup completed" };
173
186
  const proof = readProcessTerminal(owner.asyncDir, {
174
187
  runId: owner.runId,
175
188
  runnerProcessInstanceId: owner.runnerProcessInstanceId,
176
189
  });
177
190
  return proof?.state === "observed"
178
191
  && proof.runId === owner.runId
179
- && proof.runnerProcessInstanceId === owner.runnerProcessInstanceId;
192
+ && proof.runnerProcessInstanceId === owner.runnerProcessInstanceId
193
+ ? { state: "releasable", reason: "matching observed process-terminal proof is present" }
194
+ : { state: "retained", reason: `process-terminal proof is ${proof?.state ?? "missing"}` };
180
195
  }
181
196
 
182
- function workflowCanRelease(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus, liveWorkflowRunIds: ReadonlySet<string>): boolean {
183
- if (status.sessionId !== owner.ownerSessionId
184
- || status.runId !== owner.runId
185
- || status.mode !== "workflow"
186
- || !terminalState(status.state)
187
- || liveWorkflowRunIds.has(owner.runId)) return false;
197
+ function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null, liveWorkflowRunIds: ReadonlySet<string>): ActiveAsyncCapacityReleaseVerdict {
198
+ if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
199
+ if (status.sessionId !== owner.ownerSessionId) return { state: "retained", reason: `status session ${status.sessionId ?? "unknown"} does not match owner session ${owner.ownerSessionId}` };
200
+ if (status.runId !== owner.runId) return { state: "retained", reason: `status run ${status.runId} does not match owner run ${owner.runId}` };
201
+ if (status.mode !== "workflow") return { state: "retained", reason: `status mode is ${status.mode}, not workflow` };
202
+ if (!terminalState(status.state)) return { state: "retained", reason: `workflow is still ${status.state}` };
203
+ if (liveWorkflowRunIds.has(owner.runId)) return { state: "retained", reason: "workflow controller is still live" };
188
204
  for (const step of status.steps ?? []) {
189
- if (step.status === "pending" || step.status === "running" || step.status === "paused") return false;
190
- if (typeof step.async !== "boolean") return false;
205
+ const label = step.workflowKey ?? step.agent;
206
+ if (step.status === "pending" || step.status === "running" || step.status === "paused") return { state: "retained", reason: `workflow child ${label} is still ${step.status}` };
207
+ if (typeof step.async !== "boolean") return { state: "retained", reason: `workflow child ${label} is missing async classification` };
191
208
  if (!step.async) continue;
192
- if (!step.runId) return false;
209
+ if (!step.runId) return { state: "retained", reason: `async workflow child ${label} is missing run id` };
193
210
  const childDir = path.join(path.dirname(owner.asyncDir), step.runId);
194
- if (!fs.existsSync(childDir)) return false;
211
+ if (!fs.existsSync(childDir)) return { state: "retained", reason: `async workflow child ${label} directory is missing` };
195
212
  const childStatus = readStatus(childDir);
196
- if (!childStatus || !terminalState(childStatus.state) || !childStatus.processTerminal?.runnerProcessInstanceId) return false;
213
+ if (!childStatus) return { state: "retained", reason: `async workflow child ${label} status is missing or unreadable` };
214
+ if (!terminalState(childStatus.state)) return { state: "retained", reason: `async workflow child ${label} is still ${childStatus.state}` };
215
+ if (!childStatus.processTerminal?.runnerProcessInstanceId) return { state: "retained", reason: `async workflow child ${label} has no runner process identity` };
197
216
  const proof = readProcessTerminal(childDir, {
198
217
  runId: step.runId,
199
218
  runnerProcessInstanceId: childStatus.processTerminal.runnerProcessInstanceId,
200
219
  });
201
- if (proof?.state !== "observed" || proof.runId !== step.runId) return false;
220
+ if (proof?.state !== "observed" || proof.runId !== step.runId) return { state: "retained", reason: `async workflow child ${label} process-terminal proof is ${proof?.state ?? "missing"}` };
202
221
  }
203
- return true;
222
+ return { state: "releasable", reason: "workflow is terminal, controller is gone, and async children have observed proof" };
204
223
  }
205
224
 
206
- function ownerCanRelease(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>): boolean {
225
+ function ownerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>): ActiveAsyncCapacityReleaseVerdict {
207
226
  const status = readStatus(owner.asyncDir);
208
- if (!status) return false;
209
227
  return owner.kind === "runner"
210
- ? runnerCanRelease(owner, status)
211
- : workflowCanRelease(owner, status, liveWorkflowRunIds);
228
+ ? runnerReleaseVerdict(owner, status)
229
+ : workflowReleaseVerdict(owner, status, liveWorkflowRunIds);
230
+ }
231
+
232
+ function capacitySessionDirs(rootDir: string, sessionId?: string): string[] {
233
+ if (sessionId) return [sessionDir(sessionId, rootDir)];
234
+ try {
235
+ return fs.readdirSync(rootDir, { withFileTypes: true })
236
+ .filter((entry) => entry.isDirectory())
237
+ .map((entry) => path.join(rootDir, entry.name));
238
+ } catch (error) {
239
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
240
+ throw error;
241
+ }
242
+ }
243
+
244
+ export function inspectActiveAsyncCapacityOwner(
245
+ input: { runId: string; sessionId?: string; asyncDir?: string },
246
+ options: CapacityOptions & { liveWorkflowRunIds?: ReadonlySet<string> } = {},
247
+ ): ActiveAsyncCapacityInspection {
248
+ const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
249
+ const liveWorkflowRunIds = options.liveWorkflowRunIds ?? new Set<string>();
250
+ for (const poolDir of capacitySessionDirs(rootDir, input.sessionId)) {
251
+ for (const dir of occupiedSlots(poolDir)) {
252
+ const owner = readOwner(dir);
253
+ if (!owner) continue;
254
+ const sameRun = owner.runId === input.runId || (input.asyncDir !== undefined && path.resolve(owner.asyncDir) === path.resolve(input.asyncDir));
255
+ const sourceRun = owner.sourceRunId === input.runId;
256
+ if (!sameRun && !sourceRun) continue;
257
+ if (sourceRun && !sameRun) {
258
+ return {
259
+ owner,
260
+ relation: "source",
261
+ slotDir: dir,
262
+ release: { state: "not-owned", reason: `slot was transferred to ${owner.runId}` },
263
+ };
264
+ }
265
+ return { owner, relation: "current", slotDir: dir, release: ownerReleaseVerdict(owner, liveWorkflowRunIds) };
266
+ }
267
+ }
268
+ return { relation: "none", release: { state: "not-owned", reason: "no active-capacity slot records this run" } };
212
269
  }
213
270
 
214
271
  export function reconcileActiveAsyncCapacity(
@@ -225,7 +282,7 @@ export function reconcileActiveAsyncCapacity(
225
282
  || owner.ownerSessionId !== sessionId
226
283
  || owner.ownerSessionKey !== activeAsyncCapacitySessionKey(sessionId)
227
284
  || path.basename(dir) !== `slot-${owner.slot}`
228
- || !ownerCanRelease(owner, liveWorkflowRunIds)) continue;
285
+ || ownerReleaseVerdict(owner, liveWorkflowRunIds).state !== "releasable") continue;
229
286
  removeOwnedSlot(dir, owner, options);
230
287
  }
231
288
  return snapshotFor(sessionId, limit, rootDir);
@@ -3,37 +3,96 @@ import * as path from "node:path";
3
3
  import type { AsyncStatus } from "../../shared/types.ts";
4
4
 
5
5
  export const ACTIVE_RUN_INDEX_DIR = ".active-runs";
6
+ export const DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS = 24 * 60 * 60 * 1000;
7
+
8
+ const TOOL_CALL_INDEX_DIR = "tool-calls";
6
9
 
7
10
  function indexDir(asyncDirRoot: string): string {
8
11
  return path.join(asyncDirRoot, ACTIVE_RUN_INDEX_DIR);
9
12
  }
10
13
 
14
+ function toolCallIndexDir(asyncDirRoot: string, toolCallId: string): string {
15
+ return path.join(indexDir(asyncDirRoot), TOOL_CALL_INDEX_DIR, encodeURIComponent(toolCallId));
16
+ }
17
+
18
+ function toolCallIndexPath(asyncDir: string, toolCallId: string): string {
19
+ return path.join(toolCallIndexDir(path.dirname(asyncDir), toolCallId), path.basename(asyncDir));
20
+ }
21
+
11
22
  function markerPath(asyncDir: string): string {
12
23
  return path.join(indexDir(path.dirname(asyncDir)), path.basename(asyncDir));
13
24
  }
14
25
 
26
+ function removeEmptyAncestors(start: string, stop: string): void {
27
+ let current = start;
28
+ while (current !== stop && current.startsWith(`${stop}${path.sep}`)) {
29
+ try {
30
+ fs.rmdirSync(current);
31
+ } catch {
32
+ return;
33
+ }
34
+ current = path.dirname(current);
35
+ }
36
+ }
37
+
15
38
  export function isActiveAsyncState(state: AsyncStatus["state"]): boolean {
16
39
  return state === "queued" || state === "running";
17
40
  }
18
41
 
42
+ function releaseToolCallAliases(asyncDir: string): void {
43
+ const root = path.join(indexDir(path.dirname(asyncDir)), TOOL_CALL_INDEX_DIR);
44
+ let entries: fs.Dirent[];
45
+ try {
46
+ entries = fs.readdirSync(root, { withFileTypes: true });
47
+ } catch (error) {
48
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
49
+ throw error;
50
+ }
51
+ for (const entry of entries) {
52
+ if (!entry.isDirectory()) continue;
53
+ const aliasMarker = path.join(root, entry.name, path.basename(asyncDir));
54
+ try {
55
+ fs.rmSync(aliasMarker, { force: true });
56
+ removeEmptyAncestors(path.dirname(aliasMarker), root);
57
+ } catch {
58
+ // Alias cleanup must not affect the authoritative active-run marker.
59
+ }
60
+ }
61
+ }
62
+
19
63
  export function releaseActiveRunIndex(asyncDir: string): void {
20
64
  try {
21
65
  fs.rmSync(markerPath(asyncDir));
22
66
  } catch (error) {
23
67
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
24
68
  }
69
+ releaseToolCallAliases(asyncDir);
25
70
  }
26
71
 
27
- export function updateActiveRunIndex(asyncDir: string, state: AsyncStatus["state"]): void {
72
+ export function updateActiveRunIndex(asyncDir: string, state: AsyncStatus["state"], toolCallId?: string): void {
28
73
  const marker = markerPath(asyncDir);
29
74
  if (isActiveAsyncState(state)) {
30
75
  fs.mkdirSync(path.dirname(marker), { recursive: true });
31
76
  fs.writeFileSync(marker, "", { flag: "a" });
77
+ if (toolCallId) {
78
+ const toolCallMarker = toolCallIndexPath(asyncDir, toolCallId);
79
+ fs.mkdirSync(path.dirname(toolCallMarker), { recursive: true });
80
+ fs.writeFileSync(toolCallMarker, "", { flag: "a" });
81
+ }
32
82
  return;
33
83
  }
34
84
  releaseActiveRunIndex(asyncDir);
35
85
  }
36
86
 
87
+ export function activeRunMarkerAgeMs(asyncDir: string, now = Date.now()): number | undefined {
88
+ try {
89
+ return Math.max(0, now - fs.statSync(markerPath(asyncDir)).mtimeMs);
90
+ } catch (error) {
91
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
92
+ throw error;
93
+ }
94
+ }
95
+
37
96
  export function readActiveRunIndex(asyncDirRoot: string): string[] | undefined {
38
97
  try {
39
98
  return fs.readdirSync(indexDir(asyncDirRoot), { withFileTypes: true })
@@ -44,3 +103,14 @@ export function readActiveRunIndex(asyncDirRoot: string): string[] | undefined {
44
103
  throw error;
45
104
  }
46
105
  }
106
+
107
+ export function readActiveRunToolCallIndex(asyncDirRoot: string, toolCallId: string): string[] {
108
+ try {
109
+ return fs.readdirSync(toolCallIndexDir(asyncDirRoot, toolCallId), { withFileTypes: true })
110
+ .filter((entry) => entry.isFile())
111
+ .map((entry) => entry.name);
112
+ } catch (error) {
113
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
114
+ throw error;
115
+ }
116
+ }