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.
- package/CHANGELOG.md +82 -19
- package/agents/oracle.md +7 -5
- package/agents/researcher.md +1 -1
- package/agents/reviewer.md +2 -2
- package/agents/scout.md +1 -1
- package/agents/worker.md +1 -1
- package/docs/agents.md +3 -0
- package/docs/configuration.md +75 -8
- package/docs/extension-api.md +36 -0
- package/docs/missions.md +5 -3
- package/docs/observability.md +42 -2
- package/docs/tool-reference.md +19 -2
- package/docs/workflows.md +2 -2
- package/package.json +1 -1
- package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
- package/skills/pi-subagents/references/execution-controls.md +5 -4
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
- package/skills/pi-subagents/references/prompting-and-roles.md +31 -15
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +37 -12
- package/src/api/external-runs.ts +174 -84
- package/src/api/preflight.ts +13 -7
- package/src/extension/config.ts +53 -0
- package/src/extension/index.ts +62 -9
- package/src/extension/public-execution.ts +32 -4
- package/src/extension/rpc.ts +5 -1
- package/src/extension/schemas.ts +10 -8
- package/src/extension/tool-description.ts +13 -9
- package/src/inspectors/herdr/actions.ts +13 -8
- package/src/inspectors/herdr/inspector-runner.ts +16 -3
- package/src/inspectors/herdr/project-panes.ts +2 -6
- package/src/inspectors/herdr/shell-command.ts +16 -0
- package/src/intercom/intercom-bridge.ts +5 -4
- package/src/intercom/native-supervisor-channel.ts +19 -42
- package/src/missions/goal-driver.ts +3 -1
- package/src/missions/store.ts +8 -3
- package/src/runs/background/active-async-capacity.ts +82 -25
- package/src/runs/background/active-run-index.ts +71 -1
- package/src/runs/background/async-execution.ts +73 -43
- package/src/runs/background/async-job-tracker.ts +5 -0
- package/src/runs/background/async-resume.ts +14 -6
- package/src/runs/background/async-status-snapshot.ts +277 -0
- package/src/runs/background/async-status.ts +8 -3
- package/src/runs/background/chain-root-attachment.ts +2 -2
- package/src/runs/background/completion-replay.ts +11 -1
- package/src/runs/background/fleet-view.ts +21 -6
- package/src/runs/background/result-files.ts +437 -0
- package/src/runs/background/result-watcher.ts +188 -41
- package/src/runs/background/resume-guidance.ts +27 -7
- package/src/runs/background/retained-children.ts +75 -18
- package/src/runs/background/run-id-resolver.ts +30 -24
- package/src/runs/background/run-status.ts +101 -4
- package/src/runs/background/scheduled-runs.ts +54 -28
- package/src/runs/background/stale-run-reconciler.ts +27 -13
- package/src/runs/background/subagent-runner.ts +298 -33
- package/src/runs/background/subagent-wait.ts +2 -0
- package/src/runs/background/wait-completions.ts +5 -2
- package/src/runs/foreground/async-dismiss-action.ts +2 -1
- package/src/runs/foreground/chain-execution.ts +16 -0
- package/src/runs/foreground/execution.ts +219 -15
- package/src/runs/foreground/prompt-audit.ts +4 -3
- package/src/runs/foreground/subagent-executor.ts +328 -43
- package/src/runs/shared/completion-guard.ts +107 -1
- package/src/runs/shared/external-cli-runner.ts +4 -0
- package/src/runs/shared/llm-intent-arbiter.ts +39 -23
- package/src/runs/shared/model-fallback.ts +16 -2
- package/src/runs/shared/nested-events.ts +66 -62
- package/src/runs/shared/orca-progress-tabs.ts +375 -0
- package/src/runs/shared/parallel-utils.ts +2 -0
- package/src/runs/shared/subagent-control.ts +15 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +1 -9
- package/src/runs/shared/subagent-startup-retry.ts +12 -0
- package/src/runs/shared/tool-timeout.ts +93 -0
- package/src/shared/agent-stream-options.ts +5 -0
- package/src/shared/artifacts.ts +2 -6
- package/src/shared/display-text.ts +50 -0
- package/src/shared/node-executable.ts +21 -0
- package/src/shared/types.ts +51 -5
- package/src/slash/slash-commands.ts +34 -25
- package/src/slash/slash-live-state.ts +3 -0
- package/src/tui/fleet-status.ts +160 -45
- package/src/tui/fleet-transcript.ts +1 -48
- package/src/tui/fleet.ts +128 -16
- package/src/tui/render.ts +122 -44
- package/src/watchdog/permission-arbiter.ts +2 -1
- package/src/watchdog/review.ts +4 -3
- package/src/workflows/chat-progress.ts +10 -2
- 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
|
|
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 = ["
|
|
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
|
|
299
|
-
if (!readChildMetadata()) return;
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
584
|
+
function buildParentSupervisorTool(pending: Map<string, PendingSupervisorRequest>, state: SubagentState): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
|
|
605
585
|
return {
|
|
606
|
-
name,
|
|
607
|
-
label:
|
|
608
|
-
description:
|
|
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("
|
|
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
|
|
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(
|
|
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) =>
|
|
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 {
|
package/src/missions/store.ts
CHANGED
|
@@ -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
|
-
:
|
|
299
|
+
: projectMissionDirectory(agentDir, projectRoot);
|
|
295
300
|
const globalIndexDir = input.config?.globalIndexDir
|
|
296
301
|
? expandConfiguredPath(input.config.globalIndexDir, projectRoot)
|
|
297
|
-
: path.join(
|
|
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
|
|
164
|
-
if (!
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
|
|
183
|
-
if (status
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
222
|
+
return { state: "releasable", reason: "workflow is terminal, controller is gone, and async children have observed proof" };
|
|
204
223
|
}
|
|
205
224
|
|
|
206
|
-
function
|
|
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
|
-
?
|
|
211
|
-
:
|
|
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
|
-
||
|
|
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
|
+
}
|