pi-subagents 0.34.0 → 0.35.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 +78 -9
- package/README.md +213 -32
- package/index.ts +1 -0
- package/install.mjs +1 -1
- package/package.json +23 -8
- package/prompts/review-loop.md +3 -1
- package/skills/pi-subagents/SKILL.md +87 -25
- package/src/agents/agent-management.ts +82 -15
- package/src/agents/agent-serializer.ts +19 -0
- package/src/agents/agents.ts +91 -49
- package/src/agents/frontmatter.ts +67 -13
- package/src/agents/skills.ts +25 -12
- package/src/api/background-work.ts +197 -0
- package/src/api/delegation.ts +158 -0
- package/src/extension/chain-validation.ts +165 -0
- package/src/extension/doctor.ts +15 -0
- package/src/extension/fanout-child.ts +3 -1
- package/src/extension/index.ts +65 -124
- package/src/extension/rpc.ts +10 -2
- package/src/extension/schemas.ts +18 -14
- package/src/extension/steering-notices.ts +35 -0
- package/src/extension/tool-description.ts +20 -9
- package/src/intercom/intercom-bridge.ts +3 -2
- package/src/intercom/native-supervisor-channel.ts +9 -1
- package/src/intercom/result-intercom.ts +4 -0
- package/src/runs/background/async-execution.ts +293 -44
- package/src/runs/background/async-job-tracker.ts +56 -9
- package/src/runs/background/async-resume.ts +159 -52
- package/src/runs/background/async-status.ts +25 -18
- package/src/runs/background/auto-drain.ts +67 -0
- package/src/runs/background/chain-root-attachment.ts +16 -8
- package/src/runs/background/control-channel.ts +260 -13
- package/src/runs/background/fleet-view.ts +23 -2
- package/src/runs/background/notify.ts +79 -10
- package/src/runs/background/result-watcher.ts +12 -9
- package/src/runs/background/run-id-resolver.ts +14 -2
- package/src/runs/background/run-status.ts +23 -15
- package/src/runs/background/scheduled-runs.ts +3 -0
- package/src/runs/background/stale-run-reconciler.ts +32 -10
- package/src/runs/background/steering.ts +237 -0
- package/src/runs/background/subagent-runner.ts +898 -236
- package/src/runs/background/subagent-wait.ts +484 -0
- package/src/runs/background/top-level-async.ts +2 -1
- package/src/runs/background/wait-config.ts +36 -0
- package/src/runs/background/wait-tool.ts +26 -0
- package/src/runs/foreground/async-steering-action.ts +230 -0
- package/src/runs/foreground/chain-clarify.ts +22 -6
- package/src/runs/foreground/chain-execution.ts +50 -32
- package/src/runs/foreground/execution.ts +308 -94
- package/src/runs/foreground/subagent-executor.ts +592 -268
- package/src/runs/shared/acceptance.ts +355 -97
- package/src/runs/shared/child-protocol.ts +121 -0
- package/src/runs/shared/completion-guard.ts +8 -127
- package/src/runs/shared/dynamic-fanout.ts +6 -4
- package/src/runs/shared/model-fallback.ts +36 -0
- package/src/runs/shared/nested-events.ts +9 -4
- package/src/runs/shared/nested-render.ts +4 -1
- package/src/runs/shared/parallel-utils.ts +7 -0
- package/src/runs/shared/pi-args.ts +34 -7
- package/src/runs/shared/pi-spawn.ts +18 -12
- package/src/runs/shared/session-lease.ts +279 -0
- package/src/runs/shared/single-output.ts +61 -6
- package/src/runs/shared/spawn-budget.ts +128 -0
- package/src/runs/shared/subagent-control.ts +10 -6
- package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
- package/src/runs/shared/task-intent.ts +176 -0
- package/src/runs/shared/tool-availability.ts +65 -0
- package/src/runs/shared/turn-budget.ts +49 -4
- package/src/shared/atomic-json.ts +4 -1
- package/src/shared/fork-context.ts +28 -3
- package/src/shared/model-info.ts +7 -4
- package/src/shared/status-format.ts +7 -1
- package/src/shared/types.ts +203 -25
- package/src/shared/utils.ts +35 -7
- package/src/slash/delegation-adapters.ts +457 -0
- package/src/slash/delegation-request.ts +103 -0
- package/src/slash/prompt-template-bridge.ts +167 -344
- package/src/slash/slash-commands.ts +239 -6
- package/src/slash/subagents-admin.ts +428 -0
- package/src/slash/subagents-editor.ts +86 -0
- package/src/tui/fleet.ts +405 -0
- package/src/tui/render.ts +90 -16
- package/src/watchdog/change-signature.ts +127 -0
- package/src/watchdog/child-status.ts +205 -0
- package/src/watchdog/emission-guard.ts +123 -0
- package/src/watchdog/lsp-diagnostics.ts +532 -0
- package/src/watchdog/model-selection.ts +167 -0
- package/src/watchdog/register-child.ts +117 -0
- package/src/watchdog/register-main.ts +433 -0
- package/src/watchdog/render.ts +54 -0
- package/src/watchdog/review.ts +293 -0
- package/src/watchdog/runtime.ts +712 -0
- package/src/watchdog/settings.ts +528 -0
- package/src/watchdog/tool-actions.ts +155 -0
- package/src/watchdog/turn-delta.ts +161 -0
- package/src/watchdog/types.ts +188 -0
- package/src/watchdog/warning-format.ts +73 -0
- package/src/runs/background/wait.ts +0 -394
|
@@ -29,7 +29,19 @@ function exactAsyncLocation(id: string, asyncDirRoot: string, resultsDir: string
|
|
|
29
29
|
|
|
30
30
|
function foregroundIds(state: SubagentState | undefined): string[] {
|
|
31
31
|
if (!state) return [];
|
|
32
|
-
|
|
32
|
+
const remembered = state.currentSessionId
|
|
33
|
+
? [...(state.foregroundRuns?.values() ?? [])]
|
|
34
|
+
.filter((run) => run.sessionId === state.currentSessionId)
|
|
35
|
+
.map((run) => run.runId)
|
|
36
|
+
: [];
|
|
37
|
+
return [...new Set([...state.foregroundControls.keys(), ...remembered])];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasExactForegroundId(state: SubagentState | undefined, id: string): boolean {
|
|
41
|
+
if (!state) return false;
|
|
42
|
+
if (state.foregroundControls.has(id)) return true;
|
|
43
|
+
const remembered = state.foregroundRuns?.get(id);
|
|
44
|
+
return Boolean(remembered && state.currentSessionId && remembered.sessionId === state.currentSessionId);
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
function nestedScopeFromState(state: SubagentState | undefined): NestedRunResolutionScope | undefined {
|
|
@@ -58,7 +70,7 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
|
|
|
58
70
|
const resultsDir = deps.resultsDir ?? RESULTS_DIR;
|
|
59
71
|
|
|
60
72
|
const nestedScope = deps.nested ?? nestedScopeFromState(deps.state);
|
|
61
|
-
if (deps.state
|
|
73
|
+
if (hasExactForegroundId(deps.state, id)) return { kind: "foreground", id };
|
|
62
74
|
const exactAsync = exactAsyncLocation(id, asyncDirRoot, resultsDir);
|
|
63
75
|
if (exactAsync) return { kind: "async", id, location: exactAsync };
|
|
64
76
|
const exactNested = findNestedRunMatchesById(id, nestedScope ? { scope: nestedScope } : {});
|
|
@@ -6,7 +6,7 @@ import { formatAsyncResultTranscript, formatAsyncRunTranscript, formatNestedRunT
|
|
|
6
6
|
import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
|
|
7
7
|
import { formatModelThinking } from "../../shared/formatters.ts";
|
|
8
8
|
import { formatActivityLabel } from "../../shared/status-format.ts";
|
|
9
|
-
import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type Details, type ForegroundResumeRun, type NestedRunSummary, type SubagentState } from "../../shared/types.ts";
|
|
9
|
+
import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type Details, type ForegroundResumeRun, type NestedRunSummary, type SteeringStatus, type SubagentState } from "../../shared/types.ts";
|
|
10
10
|
import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
|
|
11
11
|
import { resolveAsyncRunLocation } from "./async-resume.ts";
|
|
12
12
|
import { resolveSubagentRunId } from "./run-id-resolver.ts";
|
|
@@ -38,7 +38,8 @@ function hasExistingSessionFile(value: unknown): value is string {
|
|
|
38
38
|
return typeof value === "string" && fs.existsSync(value);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown }>, fallbackSessionFile?: unknown): string {
|
|
41
|
+
function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown }>, fallbackSessionFile?: unknown, options: { stopped?: boolean } = {}): string {
|
|
42
|
+
if (options.stopped) return "Resume: unavailable; stopped runs are not resumable. Start a new run instead.";
|
|
42
43
|
const knownChildren = children
|
|
43
44
|
.map((child, index) => ({ child, index }))
|
|
44
45
|
.filter(({ child }) => typeof child.agent === "string");
|
|
@@ -73,15 +74,15 @@ function nestedRunDisplayName(run: NestedRunSummary): string {
|
|
|
73
74
|
return run.id;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
function formatSteeringSummary(input: {
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
return
|
|
77
|
+
function formatSteeringSummary(input: { steering?: SteeringStatus }): string | undefined {
|
|
78
|
+
const steering = input.steering;
|
|
79
|
+
if (!steering || steering.requested === 0) return undefined;
|
|
80
|
+
const lateAcknowledgments = steering.recent.reduce((count, request) => count + request.targets.filter((target) => target.lateDeliveredAt !== undefined).length, 0);
|
|
81
|
+
return `${steering.requested} requested, ${steering.scheduled} scheduled, ${steering.pending} pending, ${steering.delivered} delivered, ${steering.failed} failed, ${steering.recovered} recovered${lateAcknowledgments ? `, ${lateAcknowledgments} late acknowledged` : ""}`;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
function rememberedForegroundChildOutput(child: ForegroundResumeRun["children"][number]): string {
|
|
84
|
-
const outputPath = child.artifactPaths?.outputPath;
|
|
85
|
+
const outputPath = child.artifactPaths?.outputPath ?? child.savedOutputPath;
|
|
85
86
|
if (outputPath && fs.existsSync(outputPath)) {
|
|
86
87
|
try {
|
|
87
88
|
const artifactOutput = fs.readFileSync(outputPath, "utf-8").trim();
|
|
@@ -107,24 +108,29 @@ function formatRememberedForegroundStatus(run: ForegroundResumeRun): string {
|
|
|
107
108
|
`${child.index + 1}. ${child.agent} ${child.status}`,
|
|
108
109
|
child.exitCode !== undefined ? `exit ${child.exitCode}` : undefined,
|
|
109
110
|
child.detachedReason ? `detached: ${child.detachedReason}` : undefined,
|
|
111
|
+
child.acceptance ? `acceptance: ${child.acceptance.status}` : undefined,
|
|
112
|
+
child.error ? `error: ${child.error}` : undefined,
|
|
110
113
|
output ? `output: ${output.slice(0, 160)}` : undefined,
|
|
111
114
|
].filter(Boolean);
|
|
112
115
|
lines.push(parts.join(", "));
|
|
113
116
|
if (child.sessionFile) lines.push(` Session: ${child.sessionFile}`);
|
|
114
117
|
if (child.transcriptPath) lines.push(` Transcript: ${child.transcriptPath}`);
|
|
115
118
|
if (child.artifactPaths?.outputPath) lines.push(` Output: ${child.artifactPaths.outputPath}`);
|
|
119
|
+
if (child.savedOutputPath && child.savedOutputPath !== child.artifactPaths?.outputPath) lines.push(` Saved output: ${child.savedOutputPath}`);
|
|
120
|
+
if (child.outputSaveError) lines.push(` Output warning: ${child.outputSaveError}`);
|
|
116
121
|
if (child.transcriptError) lines.push(` Transcript warning: ${child.transcriptError}`);
|
|
117
122
|
}
|
|
118
123
|
lines.push("", `Status: subagent({ action: "status", id: "${run.runId}" })`);
|
|
119
124
|
if (run.children.length === 1) lines.push(`Transcript: subagent({ action: "status", id: "${run.runId}", view: "transcript" })`);
|
|
120
125
|
else lines.push(`Transcript: subagent({ action: "status", id: "${run.runId}", index: 0, view: "transcript" })`);
|
|
121
|
-
const
|
|
122
|
-
|
|
126
|
+
const detached = run.children.some((child) => child.status === "detached");
|
|
127
|
+
const resumable = run.children.find((child) => hasExistingSessionFile(child.sessionFile));
|
|
128
|
+
if (detached) {
|
|
129
|
+
lines.push(`Recovery: reply to the supervisor request first, then wait with subagent_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
|
|
130
|
+
} else if (resumable) {
|
|
123
131
|
lines.push(run.children.length === 1
|
|
124
132
|
? `Revive: subagent({ action: "resume", id: "${run.runId}", message: "..." })`
|
|
125
133
|
: `Revive child: subagent({ action: "resume", id: "${run.runId}", index: ${resumable.index}, message: "..." })`);
|
|
126
|
-
} else if (run.children.some((child) => child.status === "detached")) {
|
|
127
|
-
lines.push("Recovery: child detached for intercom coordination; status will show recovered output after the child exits when Pi can observe it.");
|
|
128
134
|
} else {
|
|
129
135
|
lines.push("Resume: unavailable; no child session file was persisted.");
|
|
130
136
|
}
|
|
@@ -147,6 +153,8 @@ function formatRememberedForegroundTranscript(run: ForegroundResumeRun, options:
|
|
|
147
153
|
child.sessionFile ? `Session: ${child.sessionFile}` : undefined,
|
|
148
154
|
child.transcriptPath ? `Transcript: ${child.transcriptPath}` : undefined,
|
|
149
155
|
child.artifactPaths?.outputPath ? `Output: ${child.artifactPaths.outputPath}` : undefined,
|
|
156
|
+
child.savedOutputPath && child.savedOutputPath !== child.artifactPaths?.outputPath ? `Saved output: ${child.savedOutputPath}` : undefined,
|
|
157
|
+
child.outputSaveError ? `Output warning: ${child.outputSaveError}` : undefined,
|
|
150
158
|
].filter((line): line is string => Boolean(line));
|
|
151
159
|
lines.push("Result transcript tail:");
|
|
152
160
|
if (outputLines.length === 0) lines.push(" (no recovered final output available yet)");
|
|
@@ -388,7 +396,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
388
396
|
if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
|
|
389
397
|
if (status.state === "running") lines.push(`Steer running child: subagent({ action: "steer", id: "${status.runId}", message: "..." })`);
|
|
390
398
|
if (status.state !== "running") {
|
|
391
|
-
lines.push(formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile));
|
|
399
|
+
lines.push(formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile, { stopped: status.state === "stopped" || status.stopped === true }));
|
|
392
400
|
}
|
|
393
401
|
if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
|
|
394
402
|
if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
|
|
@@ -409,11 +417,11 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
|
|
|
409
417
|
return { content: [{ type: "text", text: message }], isError: true, details: { mode: "single", results: [] } };
|
|
410
418
|
}
|
|
411
419
|
}
|
|
412
|
-
const status = data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
|
|
420
|
+
const status = data.state === "stopped" ? "stopped" : data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
|
|
413
421
|
const runId = data.runId ?? data.id ?? resolvedId;
|
|
414
422
|
const lines = [`Run: ${runId}`, `State: ${status}`, `Result: ${resultPath}`];
|
|
415
423
|
const children = Array.isArray(data.results) ? data.results : data.agent ? [{ agent: data.agent, sessionFile: data.sessionFile }] : [];
|
|
416
|
-
lines.push(formatResumeGuidance(runId, children, data.sessionFile));
|
|
424
|
+
lines.push(formatResumeGuidance(runId, children, data.sessionFile, { stopped: status === "stopped" }));
|
|
417
425
|
if (data.summary) lines.push("", data.summary);
|
|
418
426
|
return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
|
|
419
427
|
} catch (error) {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type ExtensionConfig,
|
|
13
13
|
} from "../../shared/types.ts";
|
|
14
14
|
import type { SubagentParamsLike } from "../foreground/subagent-executor.ts";
|
|
15
|
+
import { validateExecutionAcceptance } from "../shared/acceptance.ts";
|
|
15
16
|
|
|
16
17
|
export const SCHEDULED_RUNS_DIR = path.join(TEMP_ROOT_DIR, "scheduled-subagent-runs");
|
|
17
18
|
export const SCHEDULED_RUN_ACTIONS = ["schedule", "schedule-list", "schedule-status", "schedule-cancel"] as const;
|
|
@@ -228,6 +229,8 @@ function sanitizeScheduledParams(params: SubagentParamsLike): { params?: Subagen
|
|
|
228
229
|
if (params.context === "fork") return { error: "Scheduled subagent runs require fresh context. Forked parent-session context is not safe at fire time." };
|
|
229
230
|
if (params.async === false) return { error: "Scheduled subagent runs are always async; omit async or set async: true." };
|
|
230
231
|
if (params.clarify === true) return { error: "Scheduled subagent runs cannot open clarify UI; omit clarify or set clarify: false." };
|
|
232
|
+
const acceptanceErrors = validateExecutionAcceptance(params);
|
|
233
|
+
if (acceptanceErrors.length > 0) return { error: acceptanceErrors.join(" ") };
|
|
231
234
|
|
|
232
235
|
const {
|
|
233
236
|
action: _action,
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
4
4
|
import { RESULTS_DIR, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
|
|
5
|
+
import { resolveEffectiveThinking } from "../../shared/model-info.ts";
|
|
5
6
|
import { normalizeParallelGroups } from "./parallel-groups.ts";
|
|
6
7
|
import { nestedSummaryFromAsyncStatus, projectNestedEvents, resolveNestedAsyncDir, writeNestedEvent, type NestedRoute } from "../shared/nested-events.ts";
|
|
7
8
|
|
|
@@ -109,20 +110,30 @@ interface ResultChildOutcome {
|
|
|
109
110
|
error?: string;
|
|
110
111
|
sessionFile?: string;
|
|
111
112
|
model?: string;
|
|
113
|
+
thinking?: string;
|
|
112
114
|
attemptedModels?: string[];
|
|
113
115
|
modelAttempts?: NonNullable<AsyncStatus["steps"]>[number]["modelAttempts"];
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
interface ResultRepairData {
|
|
117
|
-
state: "complete" | "failed" | "paused";
|
|
119
|
+
state: "complete" | "failed" | "paused" | "stopped";
|
|
118
120
|
results?: ResultChildOutcome[];
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
function readResultRepairData(resultPath: string): ResultRepairData | undefined {
|
|
122
124
|
try {
|
|
123
|
-
const data = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { success?: boolean; state?: string; exitCode?: number; results?:
|
|
124
|
-
const state = data.success ? "complete" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
|
|
125
|
-
|
|
125
|
+
const data = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { success?: boolean; state?: string; exitCode?: number; results?: unknown };
|
|
126
|
+
const state = data.success ? "complete" : data.state === "stopped" ? "stopped" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
|
|
127
|
+
const results = Array.isArray(data.results)
|
|
128
|
+
? data.results.map((entry, index) => {
|
|
129
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return {};
|
|
130
|
+
const child = entry as ResultChildOutcome;
|
|
131
|
+
if (child.model !== undefined && typeof child.model !== "string") throw new Error(`Invalid async result file '${resultPath}': results[${index}].model must be a string.`);
|
|
132
|
+
if (child.thinking !== undefined && typeof child.thinking !== "string") throw new Error(`Invalid async result file '${resultPath}': results[${index}].thinking must be a string.`);
|
|
133
|
+
return child;
|
|
134
|
+
})
|
|
135
|
+
: undefined;
|
|
136
|
+
return { state, ...(results ? { results } : {}) };
|
|
126
137
|
} catch (error) {
|
|
127
138
|
if (isNotFoundError(error)) return undefined;
|
|
128
139
|
throw new Error(`Failed to read async result file '${resultPath}': ${getErrorMessage(error)}`, {
|
|
@@ -131,7 +142,7 @@ function readResultRepairData(resultPath: string): ResultRepairData | undefined
|
|
|
131
142
|
}
|
|
132
143
|
}
|
|
133
144
|
|
|
134
|
-
function childState(overallState: ResultRepairData["state"], child: ResultChildOutcome | undefined): "complete" | "failed" | "paused" {
|
|
145
|
+
function childState(overallState: ResultRepairData["state"], child: ResultChildOutcome | undefined): "complete" | "failed" | "paused" | "stopped" {
|
|
135
146
|
if (child?.success === true) return "complete";
|
|
136
147
|
if (child?.success === false) return "failed";
|
|
137
148
|
return overallState;
|
|
@@ -144,22 +155,27 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
|
|
|
144
155
|
if (step.status !== "running" && step.status !== "pending") return step;
|
|
145
156
|
const child = repair.results?.[index];
|
|
146
157
|
const state = childState(repair.state, child);
|
|
158
|
+
const model = child?.model ?? step.model;
|
|
159
|
+
const thinking = resolveEffectiveThinking(model, child?.thinking ?? step.thinking);
|
|
147
160
|
return {
|
|
148
161
|
...step,
|
|
149
162
|
status: state === "complete" ? "complete" as const : state,
|
|
150
163
|
endedAt: step.endedAt ?? now,
|
|
151
164
|
durationMs: step.startedAt !== undefined && step.durationMs === undefined ? Math.max(0, now - step.startedAt) : step.durationMs,
|
|
152
165
|
exitCode: step.exitCode ?? (state === "complete" || state === "paused" ? 0 : 1),
|
|
153
|
-
error: state === "failed" ? step.error ?? child?.error : step.error,
|
|
166
|
+
error: state === "failed" || state === "stopped" ? step.error ?? child?.error : step.error,
|
|
167
|
+
stopped: state === "stopped" ? true : step.stopped,
|
|
154
168
|
sessionFile: step.sessionFile ?? child?.sessionFile,
|
|
155
|
-
model
|
|
156
|
-
|
|
157
|
-
|
|
169
|
+
model,
|
|
170
|
+
thinking,
|
|
171
|
+
attemptedModels: child?.attemptedModels ?? step.attemptedModels,
|
|
172
|
+
modelAttempts: child?.modelAttempts ?? step.modelAttempts,
|
|
158
173
|
};
|
|
159
174
|
});
|
|
160
175
|
return {
|
|
161
176
|
...status,
|
|
162
177
|
state: repair.state,
|
|
178
|
+
...(repair.state === "stopped" ? { stopped: true } : {}),
|
|
163
179
|
activityState: undefined,
|
|
164
180
|
lastUpdate: now,
|
|
165
181
|
endedAt: status.endedAt ?? now,
|
|
@@ -267,7 +283,7 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
|
|
|
267
283
|
}
|
|
268
284
|
|
|
269
285
|
function terminal(state: AsyncStatus["state"]): boolean {
|
|
270
|
-
return state === "complete" || state === "failed" || state === "paused";
|
|
286
|
+
return state === "complete" || state === "failed" || state === "paused" || state === "stopped";
|
|
271
287
|
}
|
|
272
288
|
|
|
273
289
|
function* nestedRuns(children: NestedRunSummary[] | undefined): Generator<NestedRunSummary> {
|
|
@@ -330,6 +346,12 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
|
|
|
330
346
|
const startedStatus = !status && options.startedRun ? buildStartedStatus(asyncDir, options.startedRun, now) : undefined;
|
|
331
347
|
const effectiveStatus = status ?? startedStatus;
|
|
332
348
|
if (!effectiveStatus) return { status: null, repaired: false };
|
|
349
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
350
|
+
for (const [index, step] of (effectiveStatus.steps ?? []).entries()) {
|
|
351
|
+
const stepRecord = step as Record<string, unknown>;
|
|
352
|
+
if (stepRecord.model !== undefined && typeof stepRecord.model !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].model must be a string.`);
|
|
353
|
+
if (stepRecord.thinking !== undefined && typeof stepRecord.thinking !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].thinking must be a string.`);
|
|
354
|
+
}
|
|
333
355
|
|
|
334
356
|
const runId = effectiveStatus.runId || path.basename(asyncDir);
|
|
335
357
|
const resultPath = path.join(options.resultsDir ?? RESULTS_DIR, `${runId}.json`);
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
|
|
4
|
+
import type {
|
|
5
|
+
AsyncStatus,
|
|
6
|
+
ResolvedToolBudget,
|
|
7
|
+
ResolvedTurnBudget,
|
|
8
|
+
SteerActionResult,
|
|
9
|
+
SteeringRecoveryDescriptor,
|
|
10
|
+
SteeringRequestStatus,
|
|
11
|
+
SteeringStatus,
|
|
12
|
+
SteeringTargetState,
|
|
13
|
+
SteeringTargetStatus,
|
|
14
|
+
} from "../../shared/types.ts";
|
|
15
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
16
|
+
|
|
17
|
+
export const MAX_STEERING_REQUESTS = 20;
|
|
18
|
+
export const STEERING_MESSAGE_PREVIEW_LIMIT = 160;
|
|
19
|
+
|
|
20
|
+
export function createSteeringStatus(): SteeringStatus {
|
|
21
|
+
return { requested: 0, scheduled: 0, pending: 0, delivered: 0, failed: 0, recovered: 0, recent: [] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function steeringStatus(status: Pick<AsyncStatus, "steering">): SteeringStatus {
|
|
25
|
+
return status.steering ?? createSteeringStatus();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function recordSteeringRequest(
|
|
29
|
+
status: SteeringStatus,
|
|
30
|
+
input: { id: string; requestedAt: number; source?: string; message: string; targets: Array<{ index: number; state: SteeringTargetState; reason?: string }> },
|
|
31
|
+
): SteeringRequestStatus {
|
|
32
|
+
const existing = status.recent.find((request) => request.id === input.id);
|
|
33
|
+
if (existing) return existing;
|
|
34
|
+
const request: SteeringRequestStatus = {
|
|
35
|
+
id: input.id,
|
|
36
|
+
requestedAt: input.requestedAt,
|
|
37
|
+
...(input.source ? { source: input.source } : {}),
|
|
38
|
+
messagePreview: input.message.slice(0, STEERING_MESSAGE_PREVIEW_LIMIT),
|
|
39
|
+
targets: input.targets.map((target) => ({ index: target.index, state: target.state, ...(target.reason ? { reason: target.reason } : {}) })),
|
|
40
|
+
};
|
|
41
|
+
status.requested++;
|
|
42
|
+
status.lastRequestedAt = input.requestedAt;
|
|
43
|
+
status.recent = [...status.recent, request].slice(-MAX_STEERING_REQUESTS);
|
|
44
|
+
for (const target of request.targets) incrementStateCount(status, target.state);
|
|
45
|
+
return request;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function incrementStateCount(status: SteeringStatus, state: SteeringTargetState): void {
|
|
49
|
+
if (state === "scheduled") status.scheduled++;
|
|
50
|
+
else if (state === "routed") status.pending++;
|
|
51
|
+
else if (state === "delivered" || state === "late") status.delivered++;
|
|
52
|
+
else if (state === "failed") status.failed++;
|
|
53
|
+
else if (state === "recovered") status.recovered++;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function updateSteeringTarget(
|
|
57
|
+
status: SteeringStatus,
|
|
58
|
+
requestId: string,
|
|
59
|
+
index: number,
|
|
60
|
+
state: SteeringTargetState,
|
|
61
|
+
now: number,
|
|
62
|
+
fields: Pick<SteeringTargetStatus, "reason" | "replacementRunId"> = {},
|
|
63
|
+
): SteeringTargetStatus | undefined {
|
|
64
|
+
const request = status.recent.find((candidate) => candidate.id === requestId);
|
|
65
|
+
const target = request?.targets.find((candidate) => candidate.index === index);
|
|
66
|
+
if (!target) return undefined;
|
|
67
|
+
if (state === "late" && target.state === "recovered") {
|
|
68
|
+
if (target.lateDeliveredAt === undefined) {
|
|
69
|
+
target.lateDeliveredAt = now;
|
|
70
|
+
status.delivered++;
|
|
71
|
+
status.lastDeliveredAt = now;
|
|
72
|
+
}
|
|
73
|
+
if (fields.reason) target.reason = fields.reason;
|
|
74
|
+
return target;
|
|
75
|
+
}
|
|
76
|
+
if (target.state === state) {
|
|
77
|
+
if (state === "routed" && target.routedAt === undefined) target.routedAt = now;
|
|
78
|
+
if (state === "delivered" && target.deliveredAt === undefined) target.deliveredAt = now;
|
|
79
|
+
if (state === "late" && target.lateDeliveredAt === undefined) target.lateDeliveredAt = now;
|
|
80
|
+
if (fields.reason) target.reason = fields.reason;
|
|
81
|
+
if (fields.replacementRunId) target.replacementRunId = fields.replacementRunId;
|
|
82
|
+
return target;
|
|
83
|
+
}
|
|
84
|
+
if (target.state === "routed" && state !== "routed") status.pending = Math.max(0, status.pending - 1);
|
|
85
|
+
target.state = state;
|
|
86
|
+
if (state === "routed") target.routedAt = now;
|
|
87
|
+
if (state === "delivered") {
|
|
88
|
+
target.deliveredAt = now;
|
|
89
|
+
status.lastDeliveredAt = now;
|
|
90
|
+
}
|
|
91
|
+
if (state === "late") {
|
|
92
|
+
target.lateDeliveredAt = now;
|
|
93
|
+
status.lastDeliveredAt = now;
|
|
94
|
+
}
|
|
95
|
+
if (state === "failed") target.failedAt = now;
|
|
96
|
+
if (state === "recovered") target.recoveredAt = now;
|
|
97
|
+
if (fields.reason) target.reason = fields.reason;
|
|
98
|
+
if (fields.replacementRunId) target.replacementRunId = fields.replacementRunId;
|
|
99
|
+
incrementStateCount(status, state);
|
|
100
|
+
return target;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function findSteeringRequest(status: SteeringStatus, requestId: string): SteeringRequestStatus | undefined {
|
|
104
|
+
return status.recent.find((request) => request.id === requestId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function actionResultFromSteeringStatus(status: SteeringStatus, sourceRunId: string, requestId: string, replacementRunId?: string): SteerActionResult | undefined {
|
|
108
|
+
const request = findSteeringRequest(status, requestId);
|
|
109
|
+
if (!request) return undefined;
|
|
110
|
+
const targets = request.targets.map((target) => ({
|
|
111
|
+
index: target.index,
|
|
112
|
+
state: target.state,
|
|
113
|
+
...(target.deliveredAt !== undefined ? { deliveredAt: target.deliveredAt } : {}),
|
|
114
|
+
...(target.lateDeliveredAt !== undefined ? { lateDeliveredAt: target.lateDeliveredAt } : {}),
|
|
115
|
+
...(target.reason ? { reason: target.reason } : {}),
|
|
116
|
+
...(target.replacementRunId ? { replacementRunId: target.replacementRunId } : {}),
|
|
117
|
+
}));
|
|
118
|
+
const states = targets.map((target) => target.state);
|
|
119
|
+
let state: SteerActionResult["state"] = "pending";
|
|
120
|
+
if (states.length > 0 && states.every((candidate) => candidate === "delivered")) state = "delivered";
|
|
121
|
+
else if (states.length > 0 && states.every((candidate) => candidate === "scheduled")) state = "scheduled";
|
|
122
|
+
else if (states.length > 0 && states.every((candidate) => candidate === "recovered")) state = "recovered";
|
|
123
|
+
else if (states.length > 0 && states.every((candidate) => candidate === "failed" || candidate === "late")) state = "failed";
|
|
124
|
+
else if (states.some((candidate) => candidate === "failed" || candidate === "late") && states.some((candidate) => candidate !== "failed" && candidate !== "late")) state = "partial";
|
|
125
|
+
const effectiveReplacementRunId = replacementRunId ?? request.targets.find((target) => target.replacementRunId)?.replacementRunId;
|
|
126
|
+
return { requestId, state, sourceRunId, ...(effectiveReplacementRunId ? { replacementRunId: effectiveReplacementRunId } : {}), targets };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function steeringActionIsTerminal(result: SteerActionResult | undefined): boolean {
|
|
130
|
+
return result?.state === "delivered" || result?.state === "scheduled" || result?.state === "partial" || result?.state === "recovered" || result?.state === "failed";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function terminalSteeringNoticeState(status: SteeringStatus, requestId: string): "failed" | "partial" | undefined {
|
|
134
|
+
const request = status.recent.find((candidate) => candidate.id === requestId);
|
|
135
|
+
if (!request || request.targets.some((target) => target.state === "routed" || target.state === "scheduled")) return undefined;
|
|
136
|
+
const hasSuccess = request.targets.some((target) => target.state === "delivered" || target.state === "recovered");
|
|
137
|
+
const hasFailure = request.targets.some((target) => target.state === "failed" || target.state === "late");
|
|
138
|
+
if (hasSuccess && hasFailure) return "partial";
|
|
139
|
+
return hasFailure ? "failed" : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function claimSteeringRecovery(
|
|
143
|
+
asyncDir: string,
|
|
144
|
+
input: { requestId: string; sourceRunId: string; committedAt: number },
|
|
145
|
+
): { claimPath: string; markerPath: string } {
|
|
146
|
+
const recoveryDir = path.join(asyncDir, "control", "steer-recovery");
|
|
147
|
+
fs.mkdirSync(recoveryDir, { recursive: true, mode: 0o700 });
|
|
148
|
+
const claimPath = path.join(recoveryDir, "claim.json");
|
|
149
|
+
let fd: number;
|
|
150
|
+
try {
|
|
151
|
+
fd = fs.openSync(claimPath, "wx", 0o600);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
154
|
+
throw new Error("Another steering recovery is already committed for this source run.");
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
let writeError: unknown;
|
|
159
|
+
try {
|
|
160
|
+
fs.writeFileSync(fd, JSON.stringify({ version: 1, ...input }, null, 2), "utf-8");
|
|
161
|
+
fs.fsyncSync(fd);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
writeError = error;
|
|
164
|
+
} finally {
|
|
165
|
+
fs.closeSync(fd);
|
|
166
|
+
}
|
|
167
|
+
if (writeError !== undefined) {
|
|
168
|
+
fs.rmSync(claimPath, { force: true });
|
|
169
|
+
throw writeError;
|
|
170
|
+
}
|
|
171
|
+
const markerPath = path.join(recoveryDir, `${Buffer.from(input.requestId).toString("base64url")}.json`);
|
|
172
|
+
try {
|
|
173
|
+
writePrivateAtomicJson(markerPath, { version: 1, ...input });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
fs.rmSync(claimPath, { force: true });
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
return { claimPath, markerPath };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function readSteeringStatus(asyncDir: string): SteeringStatus | undefined {
|
|
182
|
+
return readStatus(asyncDir)?.steering;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function remainingSteeringRecoveryLimits(
|
|
186
|
+
descriptor: Pick<SteeringRecoveryDescriptor, "absoluteDeadlineAt" | "initialTurnBudget" | "initialToolBudget">,
|
|
187
|
+
status: Pick<AsyncStatus, "turnBudget" | "turnCount" | "toolBudget" | "toolCount">,
|
|
188
|
+
now = Date.now(),
|
|
189
|
+
): { timeoutMs?: number; absoluteDeadlineAt?: number; turnBudget?: ResolvedTurnBudget; toolBudget?: ResolvedToolBudget } {
|
|
190
|
+
const limits: { timeoutMs?: number; absoluteDeadlineAt?: number; turnBudget?: ResolvedTurnBudget; toolBudget?: ResolvedToolBudget } = {};
|
|
191
|
+
if (descriptor.absoluteDeadlineAt !== undefined) {
|
|
192
|
+
const timeoutMs = descriptor.absoluteDeadlineAt - now;
|
|
193
|
+
if (timeoutMs <= 0) throw new Error("Source run has no remaining deadline budget; it remains paused.");
|
|
194
|
+
limits.timeoutMs = timeoutMs;
|
|
195
|
+
limits.absoluteDeadlineAt = descriptor.absoluteDeadlineAt;
|
|
196
|
+
}
|
|
197
|
+
if (descriptor.initialTurnBudget) {
|
|
198
|
+
const consumed = status.turnBudget?.turnCount ?? status.turnCount ?? 0;
|
|
199
|
+
const totalRemaining = descriptor.initialTurnBudget.maxTurns + descriptor.initialTurnBudget.graceTurns - consumed;
|
|
200
|
+
if (totalRemaining <= 0) throw new Error("Source run has no remaining turn budget; it remains paused.");
|
|
201
|
+
const softRemaining = Math.max(0, descriptor.initialTurnBudget.maxTurns - consumed);
|
|
202
|
+
limits.turnBudget = softRemaining > 0
|
|
203
|
+
? { maxTurns: softRemaining, graceTurns: totalRemaining - softRemaining }
|
|
204
|
+
: { maxTurns: 1, graceTurns: totalRemaining - 1 };
|
|
205
|
+
}
|
|
206
|
+
if (descriptor.initialToolBudget) {
|
|
207
|
+
const consumed = status.toolBudget?.toolCount ?? status.toolCount ?? 0;
|
|
208
|
+
const hard = descriptor.initialToolBudget.hard - consumed;
|
|
209
|
+
if (hard <= 0) throw new Error("Source run has no remaining tool budget; it remains paused.");
|
|
210
|
+
const soft = descriptor.initialToolBudget.soft === undefined ? undefined : descriptor.initialToolBudget.soft - consumed;
|
|
211
|
+
limits.toolBudget = {
|
|
212
|
+
hard,
|
|
213
|
+
...(soft !== undefined && soft > 0 && soft < hard ? { soft } : {}),
|
|
214
|
+
block: descriptor.initialToolBudget.block,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return limits;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function waitForSteeringAction(input: {
|
|
221
|
+
asyncDir: string;
|
|
222
|
+
sourceRunId: string;
|
|
223
|
+
requestId: string;
|
|
224
|
+
timeoutMs: number;
|
|
225
|
+
signal?: AbortSignal;
|
|
226
|
+
}): Promise<SteerActionResult | undefined> {
|
|
227
|
+
const deadline = Date.now() + input.timeoutMs;
|
|
228
|
+
while (Date.now() <= deadline) {
|
|
229
|
+
if (input.signal?.aborted) return undefined;
|
|
230
|
+
const status = readSteeringStatus(input.asyncDir);
|
|
231
|
+
const result = status ? actionResultFromSteeringStatus(status, input.sourceRunId, input.requestId) : undefined;
|
|
232
|
+
if (steeringActionIsTerminal(result)) return result;
|
|
233
|
+
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(50, Math.max(1, deadline - Date.now()))));
|
|
234
|
+
}
|
|
235
|
+
const status = readSteeringStatus(input.asyncDir);
|
|
236
|
+
return status ? actionResultFromSteeringStatus(status, input.sourceRunId, input.requestId) : undefined;
|
|
237
|
+
}
|