pi-subagents 0.51.0 → 0.52.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 +41 -0
- package/agents/oracle.md +3 -1
- package/agents/reviewer.md +1 -0
- package/agents/scout.md +2 -2
- package/agents/worker.md +2 -1
- package/docs/agents.md +8 -3
- package/docs/extension-api.md +1 -1
- package/docs/models.md +2 -0
- package/docs/observability.md +37 -1
- package/docs/workflows.md +2 -0
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +1 -0
- package/skills/pi-subagents/references/constraints-and-recipes.md +6 -2
- package/skills/pi-subagents/references/execution-controls.md +12 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +2 -0
- package/src/agents/agent-management.ts +24 -6
- package/src/agents/agents.ts +37 -11
- package/src/agents/skills.ts +1 -1
- package/src/api/preflight.ts +5 -2
- package/src/extension/index.ts +1 -0
- package/src/extension/public-execution.ts +3 -1
- package/src/intercom/native-supervisor-channel.ts +2 -1
- package/src/missions/workflow-state.ts +2 -2
- package/src/runs/background/async-execution.ts +19 -9
- package/src/runs/background/async-resume.ts +19 -2
- package/src/runs/background/async-retention.ts +2 -4
- package/src/runs/background/completion-dedupe.ts +5 -1
- package/src/runs/background/completion-replay.ts +22 -12
- package/src/runs/background/fleet-view.ts +68 -19
- package/src/runs/background/inspect-rpc.ts +443 -0
- package/src/runs/background/notify.ts +28 -4
- package/src/runs/background/result-watcher.ts +44 -0
- package/src/runs/background/resume-guidance.ts +8 -5
- package/src/runs/background/scheduled-runs.ts +2 -0
- package/src/runs/background/subagent-runner.ts +3 -2
- package/src/runs/background/subagent-wait.ts +1 -1
- package/src/runs/background/wait-subscriptions.ts +1 -1
- package/src/runs/foreground/execution.ts +1 -1
- package/src/runs/foreground/subagent-executor.ts +239 -76
- package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
- package/src/runs/shared/acceptance.ts +4 -4
- package/src/runs/shared/external-job-bridge.ts +0 -6
- package/src/runs/shared/model-fallback.ts +77 -34
- package/src/runs/shared/session-lease.ts +0 -6
- package/src/runs/shared/worktree.ts +2 -1
- package/src/shared/types.ts +11 -0
- package/src/slash/slash-bridge.ts +2 -1
- package/src/slash/slash-commands.ts +18 -0
- package/src/watchdog/change-signature.ts +1 -1
- package/src/watchdog/lsp-diagnostics.ts +1 -0
- package/src/workflows/chat-progress.ts +1 -1
- package/src/workflows/scripted-workflow.ts +13 -2
- package/agents/gpt-pro.md +0 -17
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import { sanitizeDisplayText, truncateDisplayText } from "../../shared/display-text.ts";
|
|
3
|
+
import { decodeUtf8Tail } from "../../shared/utf8.ts";
|
|
4
|
+
import { DIRS, type AsyncStatus, type NestedRunSummary, type SubagentState } from "../../shared/types.ts";
|
|
5
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
6
|
+
import { readSessionMessagesTail, type SessionTranscriptMessage } from "./fleet-view.ts";
|
|
7
|
+
import { completionReplayPath, readCompletionArchive, readCompletionReplay } from "./completion-replay.ts";
|
|
8
|
+
import { resultPayloadPathForSessionRun } from "./result-files.ts";
|
|
9
|
+
import { resolveSubagentRunId } from "./run-id-resolver.ts";
|
|
10
|
+
import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
|
|
11
|
+
|
|
12
|
+
/** On-demand inspection of current-session async children. Re-reads canonical artifacts after the same reconciliation as status; nothing is persisted or broadcast. */
|
|
13
|
+
|
|
14
|
+
export const INSPECT_REPLY_KIND = "pi-subagents.inspect-reply";
|
|
15
|
+
export const INSPECT_REPLY_VERSION = 1;
|
|
16
|
+
export const INSPECT_WIDGET_KEY = "subagent-inspect";
|
|
17
|
+
export const INSPECT_WIDGET_PREFIX = "PI_SUBAGENT_INSPECT_JSON:";
|
|
18
|
+
|
|
19
|
+
export type InspectErrorCode =
|
|
20
|
+
| "invalid_request"
|
|
21
|
+
| "not_found"
|
|
22
|
+
| "foreign_session"
|
|
23
|
+
| "stale"
|
|
24
|
+
| "no_active_session"
|
|
25
|
+
| "internal";
|
|
26
|
+
|
|
27
|
+
export interface InspectRequest {
|
|
28
|
+
requestId: string;
|
|
29
|
+
asyncId: string;
|
|
30
|
+
childId?: string;
|
|
31
|
+
lines?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface InspectReplyMessage {
|
|
35
|
+
role: string;
|
|
36
|
+
kind: "text" | "toolCall" | "toolResult";
|
|
37
|
+
text: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
isError?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface InspectReply {
|
|
43
|
+
kind: typeof INSPECT_REPLY_KIND;
|
|
44
|
+
version: typeof INSPECT_REPLY_VERSION;
|
|
45
|
+
requestId: string;
|
|
46
|
+
/** Canonical run id of the inspected node. Absent on error replies that
|
|
47
|
+
* could not resolve a run. */
|
|
48
|
+
asyncId?: string;
|
|
49
|
+
childId?: string;
|
|
50
|
+
status?: AsyncStatus["state"];
|
|
51
|
+
label?: string;
|
|
52
|
+
task?: string;
|
|
53
|
+
messages?: InspectReplyMessage[];
|
|
54
|
+
finalOutput?: string;
|
|
55
|
+
truncated?: { task: boolean; messages: number; finalOutput: boolean };
|
|
56
|
+
error?: { code: InspectErrorCode; message: string };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
60
|
+
const MAX_ID_LENGTH = 256;
|
|
61
|
+
const MAX_LABEL_LENGTH = 160;
|
|
62
|
+
const MAX_TASK_LENGTH = 2_000;
|
|
63
|
+
const MAX_FINAL_OUTPUT_LENGTH = 8_000;
|
|
64
|
+
const MAX_MESSAGE_TEXT_LENGTH = 1_000;
|
|
65
|
+
const DEFAULT_MESSAGE_LINES = 100;
|
|
66
|
+
const MAX_MESSAGE_LINES = 200;
|
|
67
|
+
const FAILED_OUTPUT_ARTIFACT_PREFIX = "Subagent run failed before producing output.\n\nError:\n";
|
|
68
|
+
export const MAX_SERIALIZED_BYTES = 64 * 1024;
|
|
69
|
+
|
|
70
|
+
export interface InspectDeps {
|
|
71
|
+
state?: SubagentState;
|
|
72
|
+
asyncDirRoot?: string;
|
|
73
|
+
resultsDir?: string;
|
|
74
|
+
kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
|
|
75
|
+
now?: () => number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
79
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function publicText(value: unknown, maxLength: number): string | undefined {
|
|
83
|
+
if (typeof value !== "string") return undefined;
|
|
84
|
+
const normalized = sanitizeDisplayText(value.slice(0, maxLength * 4));
|
|
85
|
+
return normalized ? truncateDisplayText(normalized, maxLength) : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Bound long-form content without flattening it: task/finalOutput/message text
|
|
89
|
+
* must keep newlines readable. Only the two unicode line separators are
|
|
90
|
+
* normalized, since JSON.stringify does not escape them and hosts split the
|
|
91
|
+
* widget payload on line boundaries. */
|
|
92
|
+
function boundContent(value: string, maxLength: number): string {
|
|
93
|
+
const normalized = value.replace(/[\u2028\u2029]/g, "\n");
|
|
94
|
+
return truncateDisplayText(normalized, maxLength);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function errorReply(request: Partial<InspectRequest>, code: InspectErrorCode, message: string): InspectReply {
|
|
98
|
+
return {
|
|
99
|
+
kind: INSPECT_REPLY_KIND,
|
|
100
|
+
version: INSPECT_REPLY_VERSION,
|
|
101
|
+
requestId: typeof request.requestId === "string" && REQUEST_ID_PATTERN.test(request.requestId) ? request.requestId : "invalid",
|
|
102
|
+
...(typeof request.asyncId === "string" ? { asyncId: truncateDisplayText(request.asyncId, MAX_ID_LENGTH) } : {}),
|
|
103
|
+
...(typeof request.childId === "string" ? { childId: truncateDisplayText(request.childId, MAX_ID_LENGTH) } : {}),
|
|
104
|
+
error: { code, message },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function parseInspectRequest(args: string): { request?: InspectRequest; error?: string } {
|
|
109
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
110
|
+
const positional: string[] = [];
|
|
111
|
+
let lines: number | undefined;
|
|
112
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
113
|
+
const token = tokens[index]!;
|
|
114
|
+
if (token === "--lines") {
|
|
115
|
+
const value = tokens[++index];
|
|
116
|
+
const parsed = value === undefined ? NaN : Number.parseInt(value, 10);
|
|
117
|
+
if (!Number.isFinite(parsed)) return { error: "--lines requires an integer value." };
|
|
118
|
+
lines = parsed;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (token.startsWith("--")) return { error: `Unknown flag: ${token}. Supported: --lines N.` };
|
|
122
|
+
positional.push(token);
|
|
123
|
+
}
|
|
124
|
+
const [requestId, asyncId, childId, ...extra] = positional;
|
|
125
|
+
if (!requestId || !REQUEST_ID_PATTERN.test(requestId)) {
|
|
126
|
+
return { error: "requestId must match [A-Za-z0-9_-]{1,64}." };
|
|
127
|
+
}
|
|
128
|
+
if (!asyncId) return { error: "Usage: /subagents-inspect-rpc <requestId> <asyncId> [childId] [--lines N]." };
|
|
129
|
+
if (asyncId.length > MAX_ID_LENGTH || (childId !== undefined && childId.length > MAX_ID_LENGTH)) {
|
|
130
|
+
return { error: "asyncId/childId exceed the maximum length." };
|
|
131
|
+
}
|
|
132
|
+
if (extra.length > 0) return { error: "Too many positional arguments. Usage: /subagents-inspect-rpc <requestId> <asyncId> [childId] [--lines N]." };
|
|
133
|
+
if (lines !== undefined && (lines < 1 || !Number.isSafeInteger(lines))) return { error: "--lines must be a positive integer." };
|
|
134
|
+
return { request: { requestId, asyncId, childId, lines } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface ResolvedNode {
|
|
138
|
+
asyncDir: string;
|
|
139
|
+
status: AsyncStatus;
|
|
140
|
+
stepIndex?: number;
|
|
141
|
+
label?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findChildNode(status: AsyncStatus, asyncDir: string, childId: string): ResolvedNode | { error: string } {
|
|
145
|
+
const steps = status.steps ?? [];
|
|
146
|
+
for (let index = 0; index < steps.length; index++) {
|
|
147
|
+
const step = steps[index]!;
|
|
148
|
+
const ids = [step.workflowKey, step.runId, `step:${index}`];
|
|
149
|
+
if (ids.includes(childId)) {
|
|
150
|
+
return { asyncDir, status, stepIndex: index, label: step.label ?? step.agent };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const findNested = (children: readonly NestedRunSummary[] | undefined): NestedRunSummary | undefined => {
|
|
154
|
+
for (const child of children ?? []) {
|
|
155
|
+
if (child.id === childId) return child;
|
|
156
|
+
const deeper = findNested(child.children);
|
|
157
|
+
if (deeper) return deeper;
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
};
|
|
161
|
+
for (const step of steps) {
|
|
162
|
+
const nested = findNested(step.children);
|
|
163
|
+
if (nested) {
|
|
164
|
+
if (!nested.asyncDir) return { error: `Child '${childId}' has no async directory to inspect.` };
|
|
165
|
+
const childStatus = readStatus(nested.asyncDir);
|
|
166
|
+
if (!childStatus) return { error: `Child '${childId}' status is unavailable.` };
|
|
167
|
+
return { asyncDir: nested.asyncDir, status: childStatus, label: nested.agent ?? nested.agents?.join(", ") };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { error: `Child '${childId}' was not found under async run '${status.runId}'.` };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resultOutput(data: unknown, stepIndex: number | undefined): { output?: string; errorText?: string } {
|
|
174
|
+
if (!isRecord(data)) throw new Error("Async result payload must be an object.");
|
|
175
|
+
if (data.results !== undefined && !Array.isArray(data.results)) throw new Error("Async result payload results must be an array.");
|
|
176
|
+
if (data.summary !== undefined && typeof data.summary !== "string") throw new Error("Async result payload summary must be a string.");
|
|
177
|
+
const results = data.results;
|
|
178
|
+
if (stepIndex !== undefined && results) {
|
|
179
|
+
const stepResult = results[stepIndex];
|
|
180
|
+
if (stepResult === undefined) return {};
|
|
181
|
+
if (!isRecord(stepResult)) throw new Error("Async result payload step result must be an object.");
|
|
182
|
+
if (stepResult.output !== undefined && typeof stepResult.output !== "string") throw new Error("Async result payload step output must be a string.");
|
|
183
|
+
if (stepResult.error !== undefined && typeof stepResult.error !== "string") throw new Error("Async result payload step error must be a string.");
|
|
184
|
+
return {
|
|
185
|
+
...(typeof stepResult.output === "string" ? { output: stepResult.output } : {}),
|
|
186
|
+
...(typeof stepResult.error === "string" ? { errorText: stepResult.error } : {}),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (typeof data.summary === "string") return { output: data.summary };
|
|
190
|
+
if (results?.length === 1) {
|
|
191
|
+
const result = results[0];
|
|
192
|
+
if (!isRecord(result)) throw new Error("Async result payload result must be an object.");
|
|
193
|
+
if (result.output !== undefined && typeof result.output !== "string") throw new Error("Async result payload output must be a string.");
|
|
194
|
+
if (typeof result.output === "string") return { output: result.output };
|
|
195
|
+
}
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readOutputArtifact(outputPath: string): { output?: string; errorText?: string } {
|
|
200
|
+
const file = fs.openSync(outputPath, "r");
|
|
201
|
+
try {
|
|
202
|
+
const prefix = Buffer.allocUnsafe(Buffer.byteLength(FAILED_OUTPUT_ARTIFACT_PREFIX, "utf-8"));
|
|
203
|
+
const prefixBytes = fs.readSync(file, prefix, 0, prefix.length, 0);
|
|
204
|
+
const failedOutput = prefix.subarray(0, prefixBytes).toString("utf-8") === FAILED_OUTPUT_ARTIFACT_PREFIX;
|
|
205
|
+
const size = fs.fstatSync(file).size;
|
|
206
|
+
const length = Math.min(size, MAX_FINAL_OUTPUT_LENGTH * 4);
|
|
207
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
208
|
+
const bytesRead = fs.readSync(file, buffer, 0, length, size - length);
|
|
209
|
+
let text = decodeUtf8Tail(buffer.subarray(0, bytesRead));
|
|
210
|
+
if (!failedOutput) return { output: text };
|
|
211
|
+
const metadata = text.lastIndexOf("\nMetadata: ");
|
|
212
|
+
if (metadata >= 0 && !text.slice(metadata + 1).includes("\n")) text = text.slice(0, metadata);
|
|
213
|
+
const transcript = text.lastIndexOf("\n\nTranscript: ");
|
|
214
|
+
if (transcript >= 0 && !text.slice(transcript + 2).includes("\n")) text = text.slice(0, transcript);
|
|
215
|
+
return { errorText: text.startsWith(FAILED_OUTPUT_ARTIFACT_PREFIX) ? text.slice(FAILED_OUTPUT_ARTIFACT_PREFIX.length) : text };
|
|
216
|
+
} finally {
|
|
217
|
+
fs.closeSync(file);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function readSessionBackedOutput(sessionPath: string, trustedRoots: string[]): { output?: string } {
|
|
222
|
+
if (trustedRoots.length === 0) return {};
|
|
223
|
+
// The archive retains the child's session file as its output record. The
|
|
224
|
+
// final answer is the terminal assistant message's text — all text parts
|
|
225
|
+
// of that one session record, not just the last part.
|
|
226
|
+
const tail = readSessionMessagesTail(sessionPath, MAX_MESSAGE_LINES, trustedRoots);
|
|
227
|
+
const last = tail.messages.findLast((message) => message.role === "assistant" && message.kind === "text");
|
|
228
|
+
if (!last) return {};
|
|
229
|
+
const parts = tail.messages.filter((message) => message.recordIndex === last.recordIndex && message.kind === "text");
|
|
230
|
+
return { output: parts.map((part) => part.text).join("\n") };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readResultOutput(resultsDir: string, sessionId: string, runId: string, stepIndex: number | undefined, trustedRoots: string[], stepAgent: string | undefined, now: () => number): { output?: string; errorText?: string } {
|
|
234
|
+
const resultPath = resultPayloadPathForSessionRun(resultsDir, sessionId, runId);
|
|
235
|
+
if (resultPath) return resultOutput(JSON.parse(fs.readFileSync(resultPath, "utf-8")) as unknown, stepIndex);
|
|
236
|
+
// Read the raw record first: readCompletionReplay best-effort deletes
|
|
237
|
+
// invalid or expired records, so reading after it cannot distinguish
|
|
238
|
+
// "never existed" from "failed validation".
|
|
239
|
+
const replayPath = completionReplayPath(resultsDir, runId);
|
|
240
|
+
let rawReplay: unknown;
|
|
241
|
+
try {
|
|
242
|
+
rawReplay = JSON.parse(fs.readFileSync(replayPath, "utf-8")) as unknown;
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
245
|
+
}
|
|
246
|
+
const nowMs = now();
|
|
247
|
+
const replay = readCompletionReplay(resultsDir, runId, { sessionId, now: nowMs });
|
|
248
|
+
if (!replay) {
|
|
249
|
+
// readCompletionReplay also returns undefined for absent, expired, foreign,
|
|
250
|
+
// and unknown-version records. A current-version, unexpired record for this
|
|
251
|
+
// run and session that still failed validation is an inspection failure,
|
|
252
|
+
// not a child without output — surface it instead of replying success with
|
|
253
|
+
// no finalOutput.
|
|
254
|
+
if (isRecord(rawReplay) && rawReplay.version === 1 && rawReplay.runId === runId
|
|
255
|
+
&& rawReplay.sessionId === sessionId
|
|
256
|
+
&& typeof rawReplay.expiresAt === "number" && rawReplay.expiresAt > nowMs) {
|
|
257
|
+
throw new Error("Completion replay record failed validation.");
|
|
258
|
+
}
|
|
259
|
+
return {};
|
|
260
|
+
}
|
|
261
|
+
const archive = readCompletionArchive(replay.archivePath);
|
|
262
|
+
// Run-level inspection must not attribute a child's output to the run in a
|
|
263
|
+
// multi-child archive. Current child entries carry resultIndex and agent;
|
|
264
|
+
// run-level entries carry neither. Legacy archives (written before
|
|
265
|
+
// resultIndex existed) have child entries with agent but no resultIndex,
|
|
266
|
+
// so child reads fall back to a unique agent name. Duplicate legacy agent
|
|
267
|
+
// names fail closed. A single-child archive is the one exception: the run's
|
|
268
|
+
// output IS that child's output, including a legacy agent-tagged entry.
|
|
269
|
+
const childEntries = archive?.entries.filter((entry) => entry.resultIndex !== undefined || entry.agent !== undefined) ?? [];
|
|
270
|
+
const singleChild = childEntries.length === 1;
|
|
271
|
+
const uniqueLegacyAgent = (agent: string | undefined): boolean =>
|
|
272
|
+
agent !== undefined
|
|
273
|
+
&& archive?.entries.filter((entry) => entry.resultIndex === undefined && entry.agent === agent).length === 1;
|
|
274
|
+
const matches = (entry: { resultIndex?: number; agent?: string }): boolean => {
|
|
275
|
+
if (stepIndex !== undefined) {
|
|
276
|
+
if (entry.resultIndex !== undefined) return entry.resultIndex === stepIndex;
|
|
277
|
+
return uniqueLegacyAgent(stepAgent) && entry.agent === stepAgent;
|
|
278
|
+
}
|
|
279
|
+
if (singleChild) return entry.resultIndex !== undefined || entry.agent !== undefined;
|
|
280
|
+
return entry.resultIndex === undefined && entry.agent === undefined;
|
|
281
|
+
};
|
|
282
|
+
const artifact = archive?.entries.find((entry) => entry.source === "output-artifact" && matches(entry));
|
|
283
|
+
const artifactPath = artifact?.source === "output-artifact" ? artifact.path : undefined;
|
|
284
|
+
if (artifactPath) return readOutputArtifact(artifactPath);
|
|
285
|
+
const sessionEntry = archive?.entries.find((entry) => entry.source === "session" && matches(entry));
|
|
286
|
+
const sessionPath = sessionEntry?.source === "session" ? sessionEntry.path : undefined;
|
|
287
|
+
if (sessionPath) return readSessionBackedOutput(sessionPath, trustedRoots);
|
|
288
|
+
const output = archive?.entries.find((entry) => entry.source === "result-tail" && matches(entry))?.text;
|
|
289
|
+
return output ? { output } : {};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function toReplyMessage(message: SessionTranscriptMessage): InspectReplyMessage {
|
|
293
|
+
return {
|
|
294
|
+
role: truncateDisplayText(sanitizeDisplayText(message.role), 32),
|
|
295
|
+
kind: message.kind,
|
|
296
|
+
text: boundContent(message.text, MAX_MESSAGE_TEXT_LENGTH),
|
|
297
|
+
...(message.name ? { name: truncateDisplayText(sanitizeDisplayText(message.name), 96) } : {}),
|
|
298
|
+
...(message.isError === true ? { isError: true } : {}),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Fit the reply under MAX_SERIALIZED_BYTES: drop oldest messages first, then
|
|
303
|
+
* shrink finalOutput, then task. The envelope always survives. */
|
|
304
|
+
function enforceByteBudget(reply: InspectReply): InspectReply {
|
|
305
|
+
const truncated = reply.truncated ?? { task: false, messages: 0, finalOutput: false };
|
|
306
|
+
const sized = (): number => Buffer.byteLength(JSON.stringify(reply), "utf-8");
|
|
307
|
+
while (reply.messages && reply.messages.length > 0 && sized() > MAX_SERIALIZED_BYTES) {
|
|
308
|
+
reply.messages.shift();
|
|
309
|
+
truncated.messages += 1;
|
|
310
|
+
}
|
|
311
|
+
if (sized() > MAX_SERIALIZED_BYTES && reply.finalOutput) {
|
|
312
|
+
reply.finalOutput = truncateDisplayText(reply.finalOutput, Math.max(0, reply.finalOutput.length - (sized() - MAX_SERIALIZED_BYTES) - 256));
|
|
313
|
+
truncated.finalOutput = true;
|
|
314
|
+
}
|
|
315
|
+
if (sized() > MAX_SERIALIZED_BYTES && reply.task) {
|
|
316
|
+
reply.task = truncateDisplayText(reply.task, Math.max(0, reply.task.length - (sized() - MAX_SERIALIZED_BYTES) - 256));
|
|
317
|
+
truncated.task = true;
|
|
318
|
+
}
|
|
319
|
+
if (truncated.task || truncated.messages > 0 || truncated.finalOutput) reply.truncated = truncated;
|
|
320
|
+
return reply;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function buildInspectReply(request: InspectRequest, deps: InspectDeps = {}): InspectReply {
|
|
324
|
+
const asyncDirRoot = deps.asyncDirRoot ?? DIRS.async;
|
|
325
|
+
const resultsDir = deps.resultsDir ?? DIRS.results;
|
|
326
|
+
const currentSessionId = deps.state?.currentSessionId ?? undefined;
|
|
327
|
+
if (!currentSessionId) {
|
|
328
|
+
return errorReply(request, "no_active_session", "Inspection requires an active session; the request could not be attributed to a session.");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let resolved: ReturnType<typeof resolveSubagentRunId>;
|
|
332
|
+
try {
|
|
333
|
+
resolved = resolveSubagentRunId(request.asyncId, { asyncDirRoot, resultsDir, state: deps.state });
|
|
334
|
+
} catch {
|
|
335
|
+
return errorReply(request, "invalid_request", "The requested async run could not be resolved.");
|
|
336
|
+
}
|
|
337
|
+
if (!resolved || resolved.kind === "foreground") {
|
|
338
|
+
return errorReply(request, "not_found", `Async run '${request.asyncId}' was not found.`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
let asyncDir: string;
|
|
343
|
+
let status: AsyncStatus | null;
|
|
344
|
+
if (resolved.kind === "nested") {
|
|
345
|
+
reconcileNestedAsyncDescendants(resolved.match.route, { resultsDir, kill: deps.kill, now: deps.now });
|
|
346
|
+
asyncDir = resolved.match.run.asyncDir ?? "";
|
|
347
|
+
status = asyncDir ? readStatus(asyncDir) : null;
|
|
348
|
+
} else {
|
|
349
|
+
if (!resolved.location.asyncDir) return errorReply(request, "stale", `Async run '${request.asyncId}' has no remaining artifacts.`);
|
|
350
|
+
asyncDir = resolved.location.asyncDir;
|
|
351
|
+
status = reconcileAsyncRun(asyncDir, { resultsDir, kill: deps.kill, now: deps.now }).status ?? readStatus(asyncDir);
|
|
352
|
+
}
|
|
353
|
+
if (!status) return errorReply(request, "stale", `Async run '${request.asyncId}' artifacts are no longer available.`);
|
|
354
|
+
if (status.sessionId !== currentSessionId) {
|
|
355
|
+
return errorReply(request, "foreign_session", "Inspection is only available for async runs owned by the current session.");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const node: ResolvedNode | { error: string } = request.childId === undefined
|
|
359
|
+
? { asyncDir, status }
|
|
360
|
+
: findChildNode(status, asyncDir, request.childId);
|
|
361
|
+
if ("error" in node) return errorReply(request, "not_found", node.error);
|
|
362
|
+
if (node.asyncDir !== asyncDir) {
|
|
363
|
+
if (node.status.sessionId !== currentSessionId) {
|
|
364
|
+
return errorReply(request, "foreign_session", "Inspection is only available for async runs owned by the current session.");
|
|
365
|
+
}
|
|
366
|
+
reconcileAsyncRun(node.asyncDir, { resultsDir, kill: deps.kill, now: deps.now });
|
|
367
|
+
node.status = readStatus(node.asyncDir) ?? node.status;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const step = node.stepIndex !== undefined ? node.status.steps?.[node.stepIndex] : undefined;
|
|
371
|
+
const lineLimit = Math.max(1, Math.min(MAX_MESSAGE_LINES, Math.trunc(request.lines ?? DEFAULT_MESSAGE_LINES)));
|
|
372
|
+
const sessionFile = step?.sessionFile ?? node.status.sessionFile;
|
|
373
|
+
const trustedRoots = [...new Set([
|
|
374
|
+
...(deps.state?.trustedSessionRoots ?? []),
|
|
375
|
+
...(node.status.sessionRoot ? [node.status.sessionRoot] : []),
|
|
376
|
+
])];
|
|
377
|
+
|
|
378
|
+
let messages: InspectReplyMessage[] | undefined;
|
|
379
|
+
let task: string | undefined;
|
|
380
|
+
if (sessionFile && trustedRoots.length > 0) {
|
|
381
|
+
const tail = readSessionMessagesTail(sessionFile, lineLimit, trustedRoots);
|
|
382
|
+
if (tail.messages.length > 0) {
|
|
383
|
+
messages = tail.messages.map(toReplyMessage);
|
|
384
|
+
// The delegated task is the child session's first user message, but
|
|
385
|
+
// only when it is genuinely attributable: fresh-context children
|
|
386
|
+
// (forked sessions begin with inherited parent history) whose
|
|
387
|
+
// session file is fully inside the read window (a truncated tail
|
|
388
|
+
// means the first visible user message may be a steering message).
|
|
389
|
+
if (!tail.truncated && node.status.context !== "fork" && step?.context !== "fork") {
|
|
390
|
+
const firstUser = tail.messages.find((message) => message.role === "user" && message.kind === "text");
|
|
391
|
+
if (firstUser) task = firstUser.text;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const result = readResultOutput(resultsDir, currentSessionId, node.status.runId, node.stepIndex, trustedRoots, step?.agent, deps.now ?? Date.now);
|
|
397
|
+
const failedText = step?.error ?? node.status.error;
|
|
398
|
+
if (result.output === undefined && result.errorText === undefined && failedText !== undefined) {
|
|
399
|
+
return errorReply(request, "internal", "Inspection could not read the async run artifacts.");
|
|
400
|
+
}
|
|
401
|
+
const finalOutputRaw = result.output ?? result.errorText;
|
|
402
|
+
|
|
403
|
+
const truncated = { task: false, messages: 0, finalOutput: false };
|
|
404
|
+
const boundedTask = task !== undefined ? boundContent(task, MAX_TASK_LENGTH) : undefined;
|
|
405
|
+
if (task !== undefined && boundedTask !== task) truncated.task = true;
|
|
406
|
+
const boundedFinal = finalOutputRaw !== undefined ? boundContent(finalOutputRaw, MAX_FINAL_OUTPUT_LENGTH) : undefined;
|
|
407
|
+
if (finalOutputRaw !== undefined && boundedFinal !== finalOutputRaw) truncated.finalOutput = true;
|
|
408
|
+
const label = publicText(node.label ?? step?.label ?? step?.agent ?? node.status.runId, MAX_LABEL_LENGTH);
|
|
409
|
+
|
|
410
|
+
const reply: InspectReply = {
|
|
411
|
+
kind: INSPECT_REPLY_KIND,
|
|
412
|
+
version: INSPECT_REPLY_VERSION,
|
|
413
|
+
requestId: request.requestId,
|
|
414
|
+
asyncId: node.status.runId,
|
|
415
|
+
...(request.childId !== undefined ? { childId: request.childId } : {}),
|
|
416
|
+
status: node.status.state,
|
|
417
|
+
...(label ? { label } : {}),
|
|
418
|
+
...(boundedTask !== undefined ? { task: boundedTask } : {}),
|
|
419
|
+
...(messages && messages.length > 0 ? { messages } : {}),
|
|
420
|
+
...(boundedFinal !== undefined ? { finalOutput: boundedFinal } : {}),
|
|
421
|
+
...(truncated.task || truncated.finalOutput || truncated.messages > 0 ? { truncated } : {}),
|
|
422
|
+
};
|
|
423
|
+
return enforceByteBudget(reply);
|
|
424
|
+
} catch {
|
|
425
|
+
return errorReply(request, "internal", "Inspection could not read the async run artifacts.");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function encodeInspectReply(reply: InspectReply): string[] {
|
|
430
|
+
return [`${INSPECT_WIDGET_PREFIX}${JSON.stringify(reply)}`];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Parse the slash-command args and always return a correlated inspect reply. */
|
|
434
|
+
export function handleInspectRpcArgs(args: string, deps: InspectDeps = {}): InspectReply {
|
|
435
|
+
const parsed = parseInspectRequest(args);
|
|
436
|
+
if (parsed.error || !parsed.request) {
|
|
437
|
+
// Echo a well-formed requestId so the host can correlate the failure.
|
|
438
|
+
const firstToken = args.trim().split(/\s+/)[0];
|
|
439
|
+
const echo = firstToken !== undefined && REQUEST_ID_PATTERN.test(firstToken) ? { requestId: firstToken } : {};
|
|
440
|
+
return errorReply(echo, "invalid_request", parsed.error ?? "Invalid inspect request.");
|
|
441
|
+
}
|
|
442
|
+
return buildInspectReply(parsed.request, deps);
|
|
443
|
+
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
createCompletionBatcher,
|
|
15
15
|
resolveCompletionBatchConfig,
|
|
16
16
|
} from "./completion-batcher.ts";
|
|
17
|
-
import { SUBAGENT_ASYNC_COMPLETE_EVENT, SUBAGENT_FOREGROUND_COMPLETE_EVENT, type ParallelHandoffReference, type SubagentState } from "../../shared/types.ts";
|
|
17
|
+
import { SUBAGENT_ASYNC_COMPLETE_EVENT, SUBAGENT_FOREGROUND_COMPLETE_EVENT, type ParallelHandoffReference, type ScheduleOrigin, type SubagentState } from "../../shared/types.ts";
|
|
18
18
|
import { isUnexplainedProcessSignal } from "../shared/process-signal.ts";
|
|
19
19
|
|
|
20
20
|
export interface SubagentNotifyDetails {
|
|
@@ -27,6 +27,8 @@ export interface SubagentNotifyDetails {
|
|
|
27
27
|
sessionLabel?: string;
|
|
28
28
|
sessionValue?: string;
|
|
29
29
|
handoffPath?: string;
|
|
30
|
+
/** Present when a durable schedule launched the run. */
|
|
31
|
+
scheduleOrigin?: ScheduleOrigin;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface CompletionNotification {
|
|
@@ -68,6 +70,7 @@ export interface CompletionNotification {
|
|
|
68
70
|
/** True when an acknowledged grouped intercom relay already delivered this run. */
|
|
69
71
|
intercomDelivered?: boolean;
|
|
70
72
|
parallelHandoff?: ParallelHandoffReference;
|
|
73
|
+
scheduleOrigin?: ScheduleOrigin;
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
interface NotifyTimerApi {
|
|
@@ -94,9 +97,14 @@ function formatSessionLine(details: SubagentNotifyDetails): string | undefined {
|
|
|
94
97
|
export function formatSingleCompletion(details: SubagentNotifyDetails): string {
|
|
95
98
|
const sessionLine = formatSessionLine(details);
|
|
96
99
|
const taskKind = details.source === "foreground" ? "Detached foreground task" : "Background task";
|
|
100
|
+
const scheduleLine = details.scheduleOrigin
|
|
101
|
+
? `Scheduled run from **${details.scheduleOrigin.name ?? details.scheduleOrigin.id}** (schedule ${details.scheduleOrigin.id}).`
|
|
102
|
+
: undefined;
|
|
97
103
|
return [
|
|
98
104
|
`${taskKind} ${details.status}: **${details.agent}**${details.taskInfo ?? ""}`,
|
|
99
105
|
"",
|
|
106
|
+
scheduleLine,
|
|
107
|
+
scheduleLine ? "" : undefined,
|
|
100
108
|
details.resultPreview.trim() ? details.resultPreview : "(no output)",
|
|
101
109
|
details.handoffPath ? "" : undefined,
|
|
102
110
|
details.handoffPath ? `Parallel handoff: ${details.handoffPath}` : undefined,
|
|
@@ -111,7 +119,17 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
|
|
|
111
119
|
const lines = content.split("\n");
|
|
112
120
|
const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused|stopped): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
|
|
113
121
|
if (!match) return undefined;
|
|
114
|
-
|
|
122
|
+
let body = lines.slice(2);
|
|
123
|
+
// Restore the schedule origin so a re-rendered notice keeps its attribution and
|
|
124
|
+
// does not fold the line into the result preview.
|
|
125
|
+
const scheduleMatch = (body[0] ?? "").match(/^Scheduled run from \*\*(.+?)\*\* \(schedule (.+?)\)\.$/);
|
|
126
|
+
let parsedScheduleOrigin: ScheduleOrigin | undefined;
|
|
127
|
+
if (scheduleMatch) {
|
|
128
|
+
const label = scheduleMatch[1]!;
|
|
129
|
+
const id = scheduleMatch[2]!;
|
|
130
|
+
parsedScheduleOrigin = { id, ...(label === id ? {} : { name: label }) };
|
|
131
|
+
body = body.slice(body[1]?.trim() === "" ? 2 : 1);
|
|
132
|
+
}
|
|
115
133
|
let sessionIndex = -1;
|
|
116
134
|
for (let i = body.length - 1; i >= 1; i--) {
|
|
117
135
|
if (body[i - 1]?.trim() === "" && /^(Session|Session file|Session share error):\s+/.test(body[i]!)) {
|
|
@@ -138,6 +156,7 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
|
|
|
138
156
|
status: match[2] as SubagentNotifyDetails["status"],
|
|
139
157
|
...(match[1] === "Detached foreground task" ? { source: "foreground" as const } : {}),
|
|
140
158
|
...(match[4] ? { taskInfo: match[4] } : {}),
|
|
159
|
+
...(parsedScheduleOrigin ? { scheduleOrigin: parsedScheduleOrigin } : {}),
|
|
141
160
|
resultPreview,
|
|
142
161
|
...(handoffPath ? { handoffPath } : {}),
|
|
143
162
|
...(sessionLabel && sessionValue ? { sessionLabel, sessionValue } : {}),
|
|
@@ -151,7 +170,7 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
|
|
|
151
170
|
const detail = details[index];
|
|
152
171
|
if (!detail) continue;
|
|
153
172
|
const sessionLine = formatSessionLine(detail);
|
|
154
|
-
blocks.push(`${index + 1}. ${detail.agent}${detail.taskInfo ?? ""}`);
|
|
173
|
+
blocks.push(`${index + 1}. ${detail.agent}${detail.taskInfo ?? ""}${detail.scheduleOrigin ? ` — scheduled run from ${detail.scheduleOrigin.name ?? detail.scheduleOrigin.id} (schedule ${detail.scheduleOrigin.id})` : ""}`);
|
|
155
174
|
blocks.push(detail.resultPreview.trim() ? detail.resultPreview : "(no output)");
|
|
156
175
|
if (detail.handoffPath) blocks.push(`Parallel handoff: ${detail.handoffPath}`);
|
|
157
176
|
if (sessionLine) blocks.push(sessionLine);
|
|
@@ -171,7 +190,7 @@ function sendCompletion(pi: Pick<ExtensionAPI, "sendMessage">, items: PendingCom
|
|
|
171
190
|
if (items.length === 0) return true;
|
|
172
191
|
const details = items.map((item) => item.details);
|
|
173
192
|
const content = details.length === 1 ? formatSingleCompletion(details[0]!) : formatGroupedCompletion(details);
|
|
174
|
-
const display = details.some((detail) => detail.source === "foreground" || detail.status !== "completed");
|
|
193
|
+
const display = details.some((detail) => detail.source === "foreground" || detail.status !== "completed" || detail.scheduleOrigin !== undefined);
|
|
175
194
|
try {
|
|
176
195
|
pi.sendMessage(
|
|
177
196
|
{
|
|
@@ -226,9 +245,14 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
|
|
|
226
245
|
: result.sessionFile
|
|
227
246
|
? { label: "Session file", value: result.sessionFile }
|
|
228
247
|
: undefined;
|
|
248
|
+
const rawOrigin = result.scheduleOrigin;
|
|
249
|
+
const scheduleOrigin = rawOrigin && typeof rawOrigin.id === "string"
|
|
250
|
+
? { id: rawOrigin.id, ...(typeof rawOrigin.name === "string" ? { name: rawOrigin.name } : {}) }
|
|
251
|
+
: undefined;
|
|
229
252
|
return {
|
|
230
253
|
agent,
|
|
231
254
|
status,
|
|
255
|
+
...(scheduleOrigin ? { scheduleOrigin } : {}),
|
|
232
256
|
...(result.source ? { source: result.source } : {}),
|
|
233
257
|
...(taskInfo ? { taskInfo } : {}),
|
|
234
258
|
resultPreview: summary,
|
|
@@ -160,6 +160,21 @@ function hasDeliveredNotification(data: ResultFileData): boolean {
|
|
|
160
160
|
return typeof data.notificationDeliveredAt === "number" && Number.isFinite(data.notificationDeliveredAt);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
type PublicResultIdentity = {
|
|
164
|
+
state?: string;
|
|
165
|
+
timestamp?: number;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
function resultPayloadWasReplaced(delivered: ResultFileData, disk: PublicResultIdentity | undefined): boolean {
|
|
169
|
+
if (!disk) return false;
|
|
170
|
+
const deliveredState = typeof delivered.state === "string" ? delivered.state : undefined;
|
|
171
|
+
if (disk.state && deliveredState && disk.state !== deliveredState) return true;
|
|
172
|
+
const deliveredTimestamp = typeof delivered.timestamp === "number" && Number.isFinite(delivered.timestamp)
|
|
173
|
+
? delivered.timestamp
|
|
174
|
+
: undefined;
|
|
175
|
+
return disk.timestamp !== undefined && deliveredTimestamp !== undefined && disk.timestamp !== deliveredTimestamp;
|
|
176
|
+
}
|
|
177
|
+
|
|
163
178
|
function markDeliveredNotification(resultPath: string, data: ResultFileData, runId: string, now: number): ResultFileData {
|
|
164
179
|
const marked = { ...data, runId, notificationDeliveredAt: now };
|
|
165
180
|
writeAsyncResultFile(resultPath, marked);
|
|
@@ -323,7 +338,20 @@ export function createResultWatcher(
|
|
|
323
338
|
return;
|
|
324
339
|
}
|
|
325
340
|
processing.add(file);
|
|
341
|
+
let rereadReplacedPayload = false;
|
|
326
342
|
let resultPath = publicResultPath(file);
|
|
343
|
+
const readPublicResultIdentity = (): PublicResultIdentity | undefined => {
|
|
344
|
+
if (!publicResultFileExists(file)) return undefined;
|
|
345
|
+
const parsed: unknown = JSON.parse(fsApi.readFileSync(publicResultPath(file), "utf-8"));
|
|
346
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
|
|
347
|
+
const record = parsed as Record<string, unknown>;
|
|
348
|
+
const state = typeof record.state === "string" && record.state ? record.state : undefined;
|
|
349
|
+
const timestamp = typeof record.timestamp === "number" && Number.isFinite(record.timestamp)
|
|
350
|
+
? record.timestamp
|
|
351
|
+
: undefined;
|
|
352
|
+
if (!state && timestamp === undefined) return undefined;
|
|
353
|
+
return { state, timestamp };
|
|
354
|
+
};
|
|
327
355
|
try {
|
|
328
356
|
const payloadPath = resultPayloadPath(file, observed);
|
|
329
357
|
if (!payloadPath) return;
|
|
@@ -349,6 +377,18 @@ export function createResultWatcher(
|
|
|
349
377
|
}
|
|
350
378
|
}
|
|
351
379
|
let data = parseResult(raw);
|
|
380
|
+
const markReplacedPayload = (): boolean => {
|
|
381
|
+
try {
|
|
382
|
+
if (!resultPayloadWasReplaced(data, readPublicResultIdentity())) return false;
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (isAccessDenied(error)) throw error;
|
|
385
|
+
if (isNotFound(error)) return false;
|
|
386
|
+
console.error(`Failed to re-read subagent result file '${publicResultPath(file)}':`, error);
|
|
387
|
+
}
|
|
388
|
+
identityCache.delete(file);
|
|
389
|
+
rereadReplacedPayload = true;
|
|
390
|
+
return true;
|
|
391
|
+
};
|
|
352
392
|
if (typeof data.sessionId !== "string" || !data.sessionId) return;
|
|
353
393
|
const sessionId = data.sessionId;
|
|
354
394
|
const completionOwnerId = data.completionOwnerId;
|
|
@@ -399,6 +439,7 @@ export function createResultWatcher(
|
|
|
399
439
|
return;
|
|
400
440
|
}
|
|
401
441
|
if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
|
|
442
|
+
if (markReplacedPayload()) return;
|
|
402
443
|
if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
|
|
403
444
|
return;
|
|
404
445
|
}
|
|
@@ -454,6 +495,7 @@ export function createResultWatcher(
|
|
|
454
495
|
return;
|
|
455
496
|
}
|
|
456
497
|
if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
|
|
498
|
+
if (markReplacedPayload()) return;
|
|
457
499
|
if (!removeDeliveredResult(file, sessionId, runId, toolCallId)) scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
|
|
458
500
|
return;
|
|
459
501
|
}
|
|
@@ -503,6 +545,7 @@ export function createResultWatcher(
|
|
|
503
545
|
scheduleResult(file, triggerTurn, RETRY_DELAY_MS);
|
|
504
546
|
return;
|
|
505
547
|
}
|
|
548
|
+
if (markReplacedPayload()) return;
|
|
506
549
|
try {
|
|
507
550
|
data = markDeliveredNotification(publicResultPath(file), data, runId, Date.now());
|
|
508
551
|
identityCache.delete(file);
|
|
@@ -548,6 +591,7 @@ export function createResultWatcher(
|
|
|
548
591
|
} else if (!isNotFound(error)) console.error(`Failed to process subagent result file '${resultPath}':`, error);
|
|
549
592
|
} finally {
|
|
550
593
|
processing.delete(file);
|
|
594
|
+
if (rereadReplacedPayload) scheduleResult(file, triggerTurn);
|
|
551
595
|
}
|
|
552
596
|
};
|
|
553
597
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import type { AsyncRunSummary } from "./async-status.ts";
|
|
3
|
+
import { readAsyncRecoveryDescriptor } from "./async-resume.ts";
|
|
3
4
|
|
|
4
5
|
const INTERCOM_DETACH_ERROR = "detached for intercom coordination";
|
|
5
6
|
|
|
@@ -15,11 +16,13 @@ function formatIntercomDetachGuidance(run: AsyncRunSummary): string | undefined
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefined {
|
|
18
|
-
const step = run.steps.find((candidate) => candidate.status === "failed"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
const step = run.steps.find((candidate) => candidate.status === "failed");
|
|
20
|
+
const sessionFile = step?.sessionFile ?? (run.steps.length === 1 ? run.sessionFile : undefined);
|
|
21
|
+
if (!step || !sessionFile || !fs.existsSync(sessionFile)) return undefined;
|
|
22
|
+
try {
|
|
23
|
+
const descriptor = readAsyncRecoveryDescriptor(run.asyncDir);
|
|
24
|
+
if (!descriptor || descriptor.sourceRunId !== run.id || descriptor.agent !== step.agent) return undefined;
|
|
25
|
+
} catch {
|
|
23
26
|
return undefined;
|
|
24
27
|
}
|
|
25
28
|
const index = run.steps.length === 1 ? "" : `, index: ${step.index}`;
|
|
@@ -346,6 +346,8 @@ function executionParams(schedule: ScheduleRecord): SubagentParamsLike {
|
|
|
346
346
|
context: "fresh",
|
|
347
347
|
cwd: schedule.cwd,
|
|
348
348
|
mission: false,
|
|
349
|
+
// Scheduled fires have no operator watching, so completions must name the origin.
|
|
350
|
+
scheduleOrigin: { id: schedule.id, ...(schedule.name ? { name: schedule.name } : {}) },
|
|
349
351
|
...(schedule.timeoutMs === undefined ? {} : { timeoutMs: schedule.timeoutMs }),
|
|
350
352
|
};
|
|
351
353
|
}
|