pi-subagents 0.56.0 → 0.57.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 +53 -0
- package/agents/claude-code-writer.md +15 -0
- package/agents/claude-code.md +15 -0
- package/agents/codex-exec-writer.md +15 -0
- package/agents/codex-exec.md +15 -0
- package/agents/cursor-agent-writer.md +14 -0
- package/agents/cursor-agent.md +14 -0
- package/docs/agents.md +111 -15
- package/docs/configuration.md +37 -0
- package/docs/extension-api.md +2 -2
- package/docs/observability.md +8 -7
- package/docs/tool-reference.md +11 -1
- package/docs/workflows.md +20 -0
- package/package.json +1 -1
- package/skills/pi-subagents/references/execution-controls.md +1 -1
- package/src/agents/agent-management.ts +13 -5
- package/src/agents/agent-refinements.ts +4 -4
- package/src/agents/agents.ts +219 -48
- package/src/agents/builtin-names.ts +6 -0
- package/src/agents/runtime-agent-registry.ts +8 -2
- package/src/api/preflight.ts +2 -2
- package/src/extension/config.ts +70 -0
- package/src/extension/doctor.ts +3 -3
- package/src/extension/index.ts +18 -4
- package/src/extension/public-execution.ts +29 -13
- package/src/extension/rpc.ts +20 -5
- package/src/extension/schemas.ts +4 -3
- package/src/extension/tool-description.ts +11 -9
- package/src/inspectors/herdr/actions.ts +2 -1
- package/src/inspectors/herdr/inspector-runner.ts +2 -10
- package/src/inspectors/herdr/session-roots-codec.ts +42 -0
- package/src/integrations/herdr-status.ts +46 -3
- package/src/runs/background/active-async-capacity.ts +77 -10
- package/src/runs/background/async-execution.ts +100 -9
- package/src/runs/background/async-job-tracker.ts +5 -0
- package/src/runs/background/async-retention.ts +20 -3
- package/src/runs/background/async-status.ts +5 -0
- package/src/runs/background/chain-root-attachment.ts +15 -1
- package/src/runs/background/fleet-view.ts +16 -10
- package/src/runs/background/inspect-rpc.ts +8 -8
- package/src/runs/background/result-files.ts +27 -14
- package/src/runs/background/result-watcher.ts +9 -5
- package/src/runs/background/run-status.ts +33 -6
- package/src/runs/background/scheduled-runs.ts +7 -1
- package/src/runs/background/subagent-runner.ts +177 -44
- package/src/runs/background/wait-completions.ts +4 -0
- package/src/runs/foreground/execution.ts +72 -10
- package/src/runs/foreground/foreground-control.ts +6 -0
- package/src/runs/foreground/foreground-history.ts +22 -1
- package/src/runs/foreground/subagent-executor.ts +270 -81
- package/src/runs/foreground/workflow-detach-reconcile.ts +16 -1
- package/src/runs/shared/child-protocol.ts +21 -7
- package/src/runs/shared/claude-code-adapter.ts +129 -0
- package/src/runs/shared/codex-exec-adapter.ts +129 -0
- package/src/runs/shared/cursor-agent-adapter.ts +114 -0
- package/src/runs/shared/external-cli-contract.ts +167 -0
- package/src/runs/shared/external-cli-preflight.ts +122 -0
- package/src/runs/shared/external-cli-runner.ts +348 -55
- package/src/runs/shared/model-exclusions.ts +57 -7
- package/src/runs/shared/model-fallback.ts +23 -1
- package/src/runs/shared/mutation-evidence.ts +7 -2
- package/src/runs/shared/nested-events.ts +3 -1
- package/src/runs/shared/nested-render.ts +2 -2
- package/src/runs/shared/process-signal.ts +13 -0
- package/src/runs/shared/run-history.ts +21 -1
- package/src/shared/fork-context.ts +21 -0
- package/src/shared/formatters.ts +7 -1
- package/src/shared/pruned-fork.ts +450 -0
- package/src/shared/session-file-trust.ts +19 -0
- package/src/shared/session-tokens.ts +14 -3
- package/src/shared/settings.ts +10 -2
- package/src/shared/shortcuts.ts +17 -0
- package/src/shared/types.ts +118 -9
- package/src/shared/workflow-child-permit.ts +116 -0
- package/src/slash/delegation-adapters.ts +0 -1
- package/src/slash/slash-commands.ts +8 -6
- package/src/tui/fleet-status.ts +27 -10
- package/src/tui/fleet-transcript.ts +11 -5
- package/src/tui/fleet.ts +28 -13
- package/src/tui/render.ts +47 -21
- package/src/workflows/scripted-workflow.ts +248 -27
- package/src/workflows/workflow-child-summary.ts +117 -0
- package/src/workflows/workflow-receipt.ts +119 -4
|
@@ -4,9 +4,13 @@ import * as path from "node:path";
|
|
|
4
4
|
import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
|
|
5
5
|
import { TEMP_ROOT_DIR, type ActiveAsyncCapacitySnapshot, type AsyncStatus } from "../../shared/types.ts";
|
|
6
6
|
import { readStatus } from "../../shared/utils.ts";
|
|
7
|
+
import { checkPidLiveness, type PidLiveness } from "./stale-run-reconciler.ts";
|
|
7
8
|
import { readProcessTerminal } from "./process-terminal.ts";
|
|
8
9
|
|
|
9
10
|
export const ACTIVE_ASYNC_CAPACITY_DIR = path.join(TEMP_ROOT_DIR, "session-active-async-capacity");
|
|
11
|
+
export const DEFAULT_ABANDONED_SLOT_RELEASE_AFTER_MS = 20 * 60 * 1000;
|
|
12
|
+
export const MIN_ABANDONED_SLOT_RELEASE_AFTER_MS = 5 * 60 * 1000;
|
|
13
|
+
export const MAX_ABANDONED_SLOT_RELEASE_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
10
14
|
|
|
11
15
|
export interface ActiveAsyncCapacityOwnerV1 {
|
|
12
16
|
version: 1;
|
|
@@ -36,11 +40,21 @@ interface CapacityOptions {
|
|
|
36
40
|
rootDir?: string;
|
|
37
41
|
now?: () => number;
|
|
38
42
|
token?: () => string;
|
|
43
|
+
abandonedSlotReleaseAfterMs?: number | false;
|
|
44
|
+
pidLiveness?: (pid: number) => PidLiveness;
|
|
39
45
|
afterSlotRename?: (releasedDir: string) => void;
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
export interface ActiveAsyncCapacityReleaseEvidence {
|
|
49
|
+
releasedBy: "abandoned-timeout";
|
|
50
|
+
processProof: "unknown";
|
|
51
|
+
runnerPid: "gone";
|
|
52
|
+
lastActivityAgeMs: number;
|
|
53
|
+
abandonedSlotReleaseAfterMs: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
export type ActiveAsyncCapacityReleaseVerdict =
|
|
43
|
-
| { state: "releasable"; reason: string }
|
|
57
|
+
| { state: "releasable"; reason: string; evidence?: ActiveAsyncCapacityReleaseEvidence }
|
|
44
58
|
| { state: "retained"; reason: string }
|
|
45
59
|
| { state: "not-owned"; reason: string };
|
|
46
60
|
|
|
@@ -66,6 +80,12 @@ export function resolveMaxActiveAsyncRunsPerSession(value: unknown): number | un
|
|
|
66
80
|
return value === 0 ? undefined : value;
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
export function resolveAbandonedSlotReleaseAfterMs(value: unknown): number | false {
|
|
84
|
+
if (value === false) return false;
|
|
85
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) return DEFAULT_ABANDONED_SLOT_RELEASE_AFTER_MS;
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
export function activeAsyncCapacitySessionKey(sessionId: string): string {
|
|
70
90
|
return createHash("sha256").update(sessionId).digest("hex");
|
|
71
91
|
}
|
|
@@ -154,7 +174,7 @@ function withSlotClaim<T>(dir: string, operation: () => T): { acquired: true; va
|
|
|
154
174
|
}
|
|
155
175
|
}
|
|
156
176
|
|
|
157
|
-
function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, options: CapacityOptions, requireUnstarted = false): boolean {
|
|
177
|
+
function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, options: CapacityOptions, requireUnstarted = false, release?: ActiveAsyncCapacityReleaseVerdict): boolean {
|
|
158
178
|
if (requireUnstarted && (expected.runnerProcessInstanceId || expected.runnerStartedAt)) return false;
|
|
159
179
|
const claimed = withSlotClaim(dir, () => {
|
|
160
180
|
const current = matchingOwner(dir, expected);
|
|
@@ -162,17 +182,36 @@ function removeOwnedSlot(dir: string, expected: ActiveAsyncCapacityOwnerV1, opti
|
|
|
162
182
|
const releasedDir = path.join(path.dirname(dir), `.${path.basename(dir)}.released-${randomUUID()}`);
|
|
163
183
|
fs.renameSync(dir, releasedDir);
|
|
164
184
|
options.afterSlotRename?.(releasedDir);
|
|
185
|
+
if (release?.state === "releasable" && release.evidence) {
|
|
186
|
+
appendAbandonedReleaseEvent(expected.asyncDir, expected, release.evidence, options.now?.() ?? Date.now());
|
|
187
|
+
}
|
|
165
188
|
fs.rmSync(releasedDir, { recursive: true, force: true });
|
|
166
189
|
return true;
|
|
167
190
|
});
|
|
168
191
|
return claimed.acquired && claimed.value;
|
|
169
192
|
}
|
|
170
193
|
|
|
194
|
+
function appendAbandonedReleaseEvent(asyncDir: string, owner: ActiveAsyncCapacityOwnerV1, evidence: ActiveAsyncCapacityReleaseEvidence, now: number): void {
|
|
195
|
+
try {
|
|
196
|
+
const eventsPath = path.join(asyncDir, "events.jsonl");
|
|
197
|
+
fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
|
|
198
|
+
fs.appendFileSync(eventsPath, `${JSON.stringify({
|
|
199
|
+
type: "subagent.capacity.released",
|
|
200
|
+
ts: now,
|
|
201
|
+
runId: owner.runId,
|
|
202
|
+
sessionId: owner.ownerSessionId,
|
|
203
|
+
...evidence,
|
|
204
|
+
})}\n`, "utf-8");
|
|
205
|
+
} catch {
|
|
206
|
+
// Capacity release must not fail because its diagnostic event cannot be written.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
171
210
|
function terminalState(state: AsyncStatus["state"]): boolean {
|
|
172
211
|
return state !== "queued" && state !== "running" && state !== "paused";
|
|
173
212
|
}
|
|
174
213
|
|
|
175
|
-
function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null): ActiveAsyncCapacityReleaseVerdict {
|
|
214
|
+
function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
|
|
176
215
|
if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
|
|
177
216
|
if (!owner.runnerProcessInstanceId) return { state: "retained", reason: "runner process identity has not been recorded" };
|
|
178
217
|
if (status.sessionId !== owner.ownerSessionId) return { state: "retained", reason: `status session ${status.sessionId ?? "unknown"} does not match owner session ${owner.ownerSessionId}` };
|
|
@@ -191,7 +230,34 @@ function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncSt
|
|
|
191
230
|
&& proof.runId === owner.runId
|
|
192
231
|
&& proof.runnerProcessInstanceId === owner.runnerProcessInstanceId
|
|
193
232
|
? { state: "releasable", reason: "matching observed process-terminal proof is present" }
|
|
194
|
-
:
|
|
233
|
+
: abandonedRunnerReleaseVerdict(owner, status, proof?.state ?? "missing", options);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function abandonedRunnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus, proofState: string, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
|
|
237
|
+
const proofReason = `process-terminal proof is ${proofState}`;
|
|
238
|
+
const thresholdMs = resolveAbandonedSlotReleaseAfterMs(options.abandonedSlotReleaseAfterMs);
|
|
239
|
+
if (thresholdMs === false) return { state: "retained", reason: `${proofReason}; abandoned-timeout policy is disabled` };
|
|
240
|
+
if (status.state !== "failed") return { state: "retained", reason: `${proofReason}; abandoned-timeout policy requires a failed run, not ${status.state}` };
|
|
241
|
+
if (typeof status.pid !== "number" || !Number.isInteger(status.pid) || status.pid < 1) return { state: "retained", reason: `${proofReason}; runner PID is missing or invalid` };
|
|
242
|
+
const liveness = (options.pidLiveness ?? checkPidLiveness)(status.pid);
|
|
243
|
+
if (liveness !== "dead") return { state: "retained", reason: `${proofReason}; runner PID liveness is ${liveness}` };
|
|
244
|
+
const lastActivityAt = status.lastActivityAt ?? status.lastUpdate ?? status.endedAt;
|
|
245
|
+
if (typeof lastActivityAt !== "number" || !Number.isFinite(lastActivityAt)) return { state: "retained", reason: `${proofReason}; last activity timestamp is missing or invalid` };
|
|
246
|
+
const now = options.now?.() ?? Date.now();
|
|
247
|
+
const lastActivityAgeMs = Math.max(0, now - lastActivityAt);
|
|
248
|
+
if (lastActivityAgeMs <= thresholdMs) return { state: "retained", reason: `${proofReason}; last activity age ${lastActivityAgeMs}ms has not exceeded abandoned-timeout ${thresholdMs}ms` };
|
|
249
|
+
const evidence: ActiveAsyncCapacityReleaseEvidence = {
|
|
250
|
+
releasedBy: "abandoned-timeout",
|
|
251
|
+
processProof: "unknown",
|
|
252
|
+
runnerPid: "gone",
|
|
253
|
+
lastActivityAgeMs,
|
|
254
|
+
abandonedSlotReleaseAfterMs: thresholdMs,
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
state: "releasable",
|
|
258
|
+
reason: `${evidence.releasedBy}: ${proofReason}; runner PID is gone; process proof unknown; last activity age ${lastActivityAgeMs}ms exceeds ${thresholdMs}ms`,
|
|
259
|
+
evidence,
|
|
260
|
+
};
|
|
195
261
|
}
|
|
196
262
|
|
|
197
263
|
function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null, liveWorkflowRunIds: ReadonlySet<string>): ActiveAsyncCapacityReleaseVerdict {
|
|
@@ -222,10 +288,10 @@ function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: Async
|
|
|
222
288
|
return { state: "releasable", reason: "workflow is terminal, controller is gone, and async children have observed proof" };
|
|
223
289
|
}
|
|
224
290
|
|
|
225
|
-
function ownerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string
|
|
291
|
+
function ownerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>, options: CapacityOptions): ActiveAsyncCapacityReleaseVerdict {
|
|
226
292
|
const status = readStatus(owner.asyncDir);
|
|
227
293
|
return owner.kind === "runner"
|
|
228
|
-
? runnerReleaseVerdict(owner, status)
|
|
294
|
+
? runnerReleaseVerdict(owner, status, options)
|
|
229
295
|
: workflowReleaseVerdict(owner, status, liveWorkflowRunIds);
|
|
230
296
|
}
|
|
231
297
|
|
|
@@ -262,7 +328,7 @@ export function inspectActiveAsyncCapacityOwner(
|
|
|
262
328
|
release: { state: "not-owned", reason: `slot was transferred to ${owner.runId}` },
|
|
263
329
|
};
|
|
264
330
|
}
|
|
265
|
-
return { owner, relation: "current", slotDir: dir, release: ownerReleaseVerdict(owner, liveWorkflowRunIds) };
|
|
331
|
+
return { owner, relation: "current", slotDir: dir, release: ownerReleaseVerdict(owner, liveWorkflowRunIds, options) };
|
|
266
332
|
}
|
|
267
333
|
}
|
|
268
334
|
return { relation: "none", release: { state: "not-owned", reason: "no active-capacity slot records this run" } };
|
|
@@ -281,9 +347,10 @@ export function reconcileActiveAsyncCapacity(
|
|
|
281
347
|
if (!owner
|
|
282
348
|
|| owner.ownerSessionId !== sessionId
|
|
283
349
|
|| owner.ownerSessionKey !== activeAsyncCapacitySessionKey(sessionId)
|
|
284
|
-
|| path.basename(dir) !== `slot-${owner.slot}`
|
|
285
|
-
|
|
286
|
-
|
|
350
|
+
|| path.basename(dir) !== `slot-${owner.slot}`) continue;
|
|
351
|
+
const release = ownerReleaseVerdict(owner, liveWorkflowRunIds, options);
|
|
352
|
+
if (release.state !== "releasable") continue;
|
|
353
|
+
removeOwnedSlot(dir, owner, options, false, release);
|
|
287
354
|
}
|
|
288
355
|
return snapshotFor(sessionId, limit, rootDir);
|
|
289
356
|
}
|
|
@@ -10,7 +10,7 @@ import * as path from "node:path";
|
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import type
|
|
13
|
+
import { discoverAgents, formatUnknownAgentError, unknownAgentDiagnosticContext, type AgentConfig, type UnknownAgentDiagnosticContext } from "../../agents/agents.ts";
|
|
14
14
|
import { appendAgentRefinementOverlay } from "../../agents/agent-refinements.ts";
|
|
15
15
|
import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
|
|
16
16
|
import { currentCompletionOwnerId } from "../../shared/completion-owner.ts";
|
|
@@ -39,6 +39,7 @@ import { validateImplementationToolContract } from "../shared/completion-guard.t
|
|
|
39
39
|
import {
|
|
40
40
|
type AcceptanceInput,
|
|
41
41
|
type AgentContract,
|
|
42
|
+
type AsyncParallelGroupStatus,
|
|
42
43
|
type AsyncStatus,
|
|
43
44
|
type ArtifactConfig,
|
|
44
45
|
type Details,
|
|
@@ -70,6 +71,7 @@ import type { ImportedAsyncRoot } from "./chain-root-attachment.ts";
|
|
|
70
71
|
import type { SessionLeaseRequest } from "../shared/session-lease.ts";
|
|
71
72
|
import { finalizeProcessTerminal, readProcessTerminal } from "./process-terminal.ts";
|
|
72
73
|
import type { ActiveAsyncCapacityHandle } from "./active-async-capacity.ts";
|
|
74
|
+
import { statusStepDescription } from "./chain-append.ts";
|
|
73
75
|
import { SUBAGENT_PROCESS_TERMINAL_EVENT } from "../../shared/types.ts";
|
|
74
76
|
import { assertAgentAllowedByCapabilityCeiling, decodeSubagentCapabilityCeiling, intersectSubagentCapabilityCeilings, resolveCurrentSubagentCapabilityCeiling, SUBAGENT_CAPABILITY_CEILING_ENV, type ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
|
|
75
77
|
import { agentDefinitionDigest, launchBindingDigest } from "../../shared/launch-contract.ts";
|
|
@@ -151,6 +153,8 @@ interface AsyncChainParams {
|
|
|
151
153
|
attachRoot?: ImportedAsyncRoot & { agent: string; outputName?: string; label?: string };
|
|
152
154
|
resultMode?: SubagentRunMode;
|
|
153
155
|
agents: AgentConfig[];
|
|
156
|
+
/** Original discovery provenance, retained by normal callers for unknown-agent diagnostics. */
|
|
157
|
+
unknownAgentDiagnosticContext?: UnknownAgentDiagnosticContext;
|
|
154
158
|
ctx: AsyncExecutionContext;
|
|
155
159
|
availableModels?: AvailableModelInfo[];
|
|
156
160
|
cwd?: string;
|
|
@@ -283,6 +287,8 @@ export interface AsyncRunnerStepBuildParams {
|
|
|
283
287
|
attachRoot?: ImportedAsyncRoot & { agent: string; outputName?: string; label?: string };
|
|
284
288
|
resultMode?: SubagentRunMode;
|
|
285
289
|
agents: AgentConfig[];
|
|
290
|
+
/** Exact discovery provenance for failed resolution; omission triggers defensive fallback discovery. */
|
|
291
|
+
unknownAgentDiagnosticContext?: UnknownAgentDiagnosticContext;
|
|
286
292
|
ctx: AsyncExecutionContext;
|
|
287
293
|
availableModels?: AvailableModelInfo[];
|
|
288
294
|
cwd?: string;
|
|
@@ -493,7 +499,20 @@ interface SpawnRunnerResult {
|
|
|
493
499
|
startupDidNotProceed?: boolean;
|
|
494
500
|
}
|
|
495
501
|
|
|
496
|
-
function
|
|
502
|
+
function isStaleExtensionContextError(error: unknown): boolean {
|
|
503
|
+
return error instanceof Error && /extension ctx is stale|stale after session replacement or reload/i.test(error.message);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function emitProcessTerminalEvent(ctx: AsyncExecutionContext, proof: unknown): void {
|
|
507
|
+
try {
|
|
508
|
+
ctx.pi.events.emit(SUBAGENT_PROCESS_TERMINAL_EVENT, proof);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
if (isStaleExtensionContextError(error)) return;
|
|
511
|
+
console.error("Failed to emit subagent process-terminal event:", error);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function spawnRunner(cfg: object, suffix: string, cwd: string, initialStatus: Omit<AsyncStatus, "pid" | "processTerminal">, initialStatusPath: string, onProcessTerminal?: (proof: unknown) => void): SpawnRunnerResult {
|
|
497
516
|
if (!jitiCliPath) {
|
|
498
517
|
return { error: "upstream jiti for TypeScript execution could not be found; ensure package dependencies are installed" };
|
|
499
518
|
}
|
|
@@ -510,7 +529,9 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
|
|
|
510
529
|
fs.mkdirSync(TEMP_ROOT_DIR, { recursive: true });
|
|
511
530
|
const cfgPath = getAsyncConfigPath(suffix);
|
|
512
531
|
const runnerProcessInstanceId = randomUUID();
|
|
513
|
-
const
|
|
532
|
+
const hasRevivalLease = typeof (cfg as { revivalLease?: unknown }).revivalLease === "object";
|
|
533
|
+
const launchBarrierToken = hasRevivalLease ? undefined : runnerProcessInstanceId;
|
|
534
|
+
const launchConfig = { ...cfg, runnerProcessInstanceId, ...(launchBarrierToken ? { launchBarrierToken } : {}) };
|
|
514
535
|
writePrivateAtomicJson(cfgPath, launchConfig);
|
|
515
536
|
const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
|
|
516
537
|
const nodeCommand = resolveNodeExecutable();
|
|
@@ -523,7 +544,9 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
|
|
|
523
544
|
? path.join(launchAsyncDir, "runner-startup.json")
|
|
524
545
|
: undefined;
|
|
525
546
|
const startupAckPath = startupPath ? path.join(path.dirname(startupPath), "runner-startup-ack.json") : undefined;
|
|
526
|
-
const startupProceedPath =
|
|
547
|
+
const startupProceedPath = launchAsyncDir && (startupPath || launchBarrierToken)
|
|
548
|
+
? path.join(launchAsyncDir, "runner-startup-proceed.json")
|
|
549
|
+
: undefined;
|
|
527
550
|
if (startupPath) fs.rmSync(startupPath, { force: true });
|
|
528
551
|
if (startupAckPath) fs.rmSync(startupAckPath, { force: true });
|
|
529
552
|
if (startupProceedPath) fs.rmSync(startupProceedPath, { force: true });
|
|
@@ -605,6 +628,27 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
|
|
|
605
628
|
if (typeof proc.pid !== "number") {
|
|
606
629
|
return { error: `async runner did not produce a pid for cwd: ${cwd}` };
|
|
607
630
|
}
|
|
631
|
+
try {
|
|
632
|
+
writePrivateAtomicJson(initialStatusPath, {
|
|
633
|
+
...initialStatus,
|
|
634
|
+
pid: proc.pid,
|
|
635
|
+
processTerminal: { version: 1, state: "pending", runId: initialStatus.runId, runnerProcessInstanceId },
|
|
636
|
+
});
|
|
637
|
+
} catch (error) {
|
|
638
|
+
const message = `Failed to persist initial async status: ${error instanceof Error ? error.message : String(error)}`;
|
|
639
|
+
const terminationObserved = terminateRunnerBeforeProceed(proc.pid);
|
|
640
|
+
return { pid: proc.pid, runnerProcessInstanceId, error: message, terminationObserved, startupDidNotProceed: true };
|
|
641
|
+
}
|
|
642
|
+
if (launchBarrierToken && startupProceedPath) {
|
|
643
|
+
try {
|
|
644
|
+
writeRunnerStartupControl(startupProceedPath, { action: "proceed", token: launchBarrierToken });
|
|
645
|
+
} catch (error) {
|
|
646
|
+
const message = `Failed to authorize async runner startup: ${error instanceof Error ? error.message : String(error)}`;
|
|
647
|
+
if (launchAsyncDir) persistPreProceedStartupFailure(launchAsyncDir, launchRunId, runnerProcessInstanceId, launchSessionId, launchCompletionOwnerId, message);
|
|
648
|
+
const terminationObserved = terminateRunnerBeforeProceed(proc.pid);
|
|
649
|
+
return { pid: proc.pid, runnerProcessInstanceId, error: message, terminationObserved, startupDidNotProceed: true };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
608
652
|
proc.unref();
|
|
609
653
|
if (startupPath && startupAckPath && startupProceedPath) {
|
|
610
654
|
const persistStartupFailure = (message: string) => {
|
|
@@ -709,6 +753,8 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
709
753
|
}
|
|
710
754
|
const workflowGraph = buildWorkflowGraphSnapshot({ runId: id, mode: resultMode, steps: graphChain });
|
|
711
755
|
|
|
756
|
+
const diagnosticContext = params.unknownAgentDiagnosticContext
|
|
757
|
+
?? unknownAgentDiagnosticContext(discoverAgents(path.resolve(runnerCwd), "both"));
|
|
712
758
|
for (const s of chain) {
|
|
713
759
|
const stepAgents = isParallelStep(s)
|
|
714
760
|
? s.parallel.map((t) => t.agent)
|
|
@@ -716,9 +762,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
716
762
|
? [s.parallel.agent]
|
|
717
763
|
: [(s as SequentialStep).agent];
|
|
718
764
|
for (const agentName of stepAgents) {
|
|
719
|
-
if (!agents.find((x) => x.name === agentName)) {
|
|
720
|
-
return { error: `Unknown agent: ${agentName}` };
|
|
721
|
-
}
|
|
765
|
+
if (!agents.find((x) => x.name === agentName)) return { error: formatUnknownAgentError(agentName, diagnosticContext) };
|
|
722
766
|
}
|
|
723
767
|
}
|
|
724
768
|
|
|
@@ -1140,6 +1184,7 @@ export function executeAsyncChain(
|
|
|
1140
1184
|
attachRoot: params.attachRoot,
|
|
1141
1185
|
resultMode,
|
|
1142
1186
|
agents,
|
|
1187
|
+
unknownAgentDiagnosticContext: params.unknownAgentDiagnosticContext,
|
|
1143
1188
|
ctx,
|
|
1144
1189
|
availableModels: params.availableModels,
|
|
1145
1190
|
cwd,
|
|
@@ -1191,6 +1236,21 @@ export function executeAsyncChain(
|
|
|
1191
1236
|
}
|
|
1192
1237
|
return "agent" in step ? [childIntercomTarget(step.agent, childTargetIndex++)] : [undefined];
|
|
1193
1238
|
}) : undefined;
|
|
1239
|
+
const initialStatusSteps = eventChain.flatMap((step) => isParallelStep(step)
|
|
1240
|
+
? step.parallel.map((task) => ({ agent: task.agent, ...(statusStepDescription(task.task) ? { description: statusStepDescription(task.task) } : {}), ...(task.label ? { label: task.label } : {}), ...(task.as ? { outputName: task.as } : {}), status: "pending" as const }))
|
|
1241
|
+
: isDynamicParallelStep(step)
|
|
1242
|
+
? [{ agent: `expand:${step.parallel.agent}`, label: step.label ?? step.parallel.label ?? `Dynamic fanout (${step.collect.as})`, outputName: step.collect.as, status: "pending" as const }]
|
|
1243
|
+
: [{ agent: (step as SequentialStep).agent, ...(statusStepDescription((step as SequentialStep).task) ? { description: statusStepDescription((step as SequentialStep).task) } : {}), ...((step as SequentialStep).label ? { label: (step as SequentialStep).label } : {}), ...((step as SequentialStep).as ? { outputName: (step as SequentialStep).as } : {}), status: "pending" as const }]);
|
|
1244
|
+
const initialParallelGroups: AsyncParallelGroupStatus[] = [];
|
|
1245
|
+
let initialFlatIndex = 0;
|
|
1246
|
+
for (let stepIndex = 0; stepIndex < eventChain.length; stepIndex++) {
|
|
1247
|
+
const step = eventChain[stepIndex]!;
|
|
1248
|
+
if (isParallelStep(step)) initialParallelGroups.push({ start: initialFlatIndex, count: step.parallel.length, stepIndex });
|
|
1249
|
+
else if (isDynamicParallelStep(step)) initialParallelGroups.push({ start: initialFlatIndex, count: 1, stepIndex });
|
|
1250
|
+
initialFlatIndex += isParallelStep(step) ? step.parallel.length : 1;
|
|
1251
|
+
}
|
|
1252
|
+
const initialStatusAt = Date.now();
|
|
1253
|
+
const initialCompletionOwnerId = ctx.completionOwnerId ?? currentCompletionOwnerId();
|
|
1194
1254
|
|
|
1195
1255
|
let spawnResult: SpawnRunnerResult = {};
|
|
1196
1256
|
try {
|
|
@@ -1240,7 +1300,22 @@ export function executeAsyncChain(
|
|
|
1240
1300
|
},
|
|
1241
1301
|
id,
|
|
1242
1302
|
runnerCwd,
|
|
1243
|
-
|
|
1303
|
+
{
|
|
1304
|
+
lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
|
|
1305
|
+
runId: id,
|
|
1306
|
+
...(ctx.currentSessionId ? { sessionId: ctx.currentSessionId } : {}),
|
|
1307
|
+
...(initialCompletionOwnerId ? { completionOwnerId: initialCompletionOwnerId } : {}),
|
|
1308
|
+
mode: resultMode,
|
|
1309
|
+
state: "running",
|
|
1310
|
+
startedAt: initialStatusAt,
|
|
1311
|
+
lastUpdate: initialStatusAt,
|
|
1312
|
+
currentStep: 0,
|
|
1313
|
+
chainStepCount: eventChain.length,
|
|
1314
|
+
...(initialParallelGroups.length ? { parallelGroups: initialParallelGroups } : {}),
|
|
1315
|
+
steps: initialStatusSteps,
|
|
1316
|
+
},
|
|
1317
|
+
path.join(asyncDir, "status.json"),
|
|
1318
|
+
(proof) => emitProcessTerminalEvent(ctx, proof),
|
|
1244
1319
|
);
|
|
1245
1320
|
} catch (error) {
|
|
1246
1321
|
params.activeAsyncCapacity?.rollback();
|
|
@@ -1678,6 +1753,8 @@ export function executeAsyncSingle(
|
|
|
1678
1753
|
}
|
|
1679
1754
|
}
|
|
1680
1755
|
let spawnResult: SpawnRunnerResult = {};
|
|
1756
|
+
const initialStatusAt = Date.now();
|
|
1757
|
+
const initialCompletionOwnerId = ctx.completionOwnerId ?? currentCompletionOwnerId();
|
|
1681
1758
|
try {
|
|
1682
1759
|
spawnResult = spawnRunner(
|
|
1683
1760
|
{
|
|
@@ -1773,7 +1850,21 @@ export function executeAsyncSingle(
|
|
|
1773
1850
|
},
|
|
1774
1851
|
id,
|
|
1775
1852
|
runnerCwd,
|
|
1776
|
-
|
|
1853
|
+
{
|
|
1854
|
+
lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
|
|
1855
|
+
runId: id,
|
|
1856
|
+
...(ctx.currentSessionId ? { sessionId: ctx.currentSessionId } : {}),
|
|
1857
|
+
...(initialCompletionOwnerId ? { completionOwnerId: initialCompletionOwnerId } : {}),
|
|
1858
|
+
mode: "single",
|
|
1859
|
+
state: "running",
|
|
1860
|
+
startedAt: initialStatusAt,
|
|
1861
|
+
lastUpdate: initialStatusAt,
|
|
1862
|
+
currentStep: 0,
|
|
1863
|
+
chainStepCount: 1,
|
|
1864
|
+
steps: [{ agent, status: "pending" }],
|
|
1865
|
+
},
|
|
1866
|
+
path.join(asyncDir, "status.json"),
|
|
1867
|
+
(proof) => emitProcessTerminalEvent(ctx, proof),
|
|
1777
1868
|
);
|
|
1778
1869
|
} catch (error) {
|
|
1779
1870
|
params.activeAsyncCapacity?.rollback();
|
|
@@ -24,6 +24,7 @@ import { findNestedRouteForRootId, hasLiveNestedDescendants, updateAsyncJobNeste
|
|
|
24
24
|
import { listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
|
|
25
25
|
import { EXTERNAL_JOB_BRIDGE_REQUEST_DIR, serviceExternalJobBridgeRequests } from "../shared/external-job-bridge.ts";
|
|
26
26
|
import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
|
|
27
|
+
import { parseWorkflowChildSummary } from "../../workflows/workflow-child-summary.ts";
|
|
27
28
|
|
|
28
29
|
interface AsyncJobTrackerOptions {
|
|
29
30
|
completionRetentionMs?: number;
|
|
@@ -171,6 +172,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
171
172
|
parentWorkflowRunId: run.parentWorkflowRunId,
|
|
172
173
|
workflowKey: run.workflowKey,
|
|
173
174
|
workflow: run.workflow,
|
|
175
|
+
workflowChildren: parseWorkflowChildSummary(run.workflowChildren),
|
|
174
176
|
};
|
|
175
177
|
};
|
|
176
178
|
const cancelCleanup = (asyncId: string) => {
|
|
@@ -398,6 +400,9 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
398
400
|
job.parentWorkflowRunId = status.parentWorkflowRunId ?? job.parentWorkflowRunId;
|
|
399
401
|
job.workflowKey = status.workflowKey ?? job.workflowKey;
|
|
400
402
|
job.workflow = status.workflow ?? job.workflow;
|
|
403
|
+
const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
|
|
404
|
+
if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error("workflowChildren.workflowRunId does not match async status runId.");
|
|
405
|
+
job.workflowChildren = workflowChildren ?? job.workflowChildren;
|
|
401
406
|
job.currentStep = status.currentStep ?? job.currentStep;
|
|
402
407
|
job.chainStepCount = status.chainStepCount ?? job.chainStepCount;
|
|
403
408
|
job.startedAt = status.startedAt ?? job.startedAt;
|
|
@@ -9,6 +9,7 @@ import type { AsyncStatus } from "../../shared/types.ts";
|
|
|
9
9
|
import { MISSION_BINDING_FILE } from "../../missions/lifecycle.ts";
|
|
10
10
|
import { ACTIVE_RUN_INDEX_DIR } from "./active-run-index.ts";
|
|
11
11
|
import { encodeIndexSegment } from "./index-segment.ts";
|
|
12
|
+
import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
|
|
12
13
|
|
|
13
14
|
export const ASYNC_RETENTION_DAYS = 30;
|
|
14
15
|
export const ASYNC_RETENTION_BATCH_SIZE = 100;
|
|
@@ -96,11 +97,13 @@ export interface AsyncRetentionOptions {
|
|
|
96
97
|
lstatSync?: typeof fs.lstatSync;
|
|
97
98
|
signal?: AbortSignal;
|
|
98
99
|
discoveryWorkerUrl?: URL;
|
|
100
|
+
reconcileKill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
export interface AsyncRetentionResult {
|
|
102
104
|
acquired: boolean;
|
|
103
105
|
scanned: number;
|
|
106
|
+
repairedRuns: number;
|
|
104
107
|
deletedRuns: number;
|
|
105
108
|
deletedResults: number;
|
|
106
109
|
reapedTombstones: number;
|
|
@@ -135,7 +138,7 @@ function listDir(dir: string): fs.Dirent[] {
|
|
|
135
138
|
|
|
136
139
|
function readJson(filePath: string): Record<string, unknown> | undefined {
|
|
137
140
|
try {
|
|
138
|
-
const parsed = JSON.parse(fs.readFileSync(filePath
|
|
141
|
+
const parsed = JSON.parse(fs.readFileSync(filePath).toString("utf8")) as unknown;
|
|
139
142
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : undefined;
|
|
140
143
|
} catch {
|
|
141
144
|
return undefined;
|
|
@@ -320,7 +323,7 @@ function readCursor(root: string): RetentionCursor {
|
|
|
320
323
|
function processStartIdentity(pid: number): string | undefined {
|
|
321
324
|
if (process.platform === "linux") {
|
|
322
325
|
try {
|
|
323
|
-
const stat = fs.readFileSync(`/proc/${pid}/stat
|
|
326
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`).toString("utf8");
|
|
324
327
|
const commandEnd = stat.lastIndexOf(")");
|
|
325
328
|
if (commandEnd === -1) return undefined;
|
|
326
329
|
const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
|
|
@@ -504,6 +507,7 @@ function appendMaintenanceLog(root: string, result: AsyncRetentionResult, now: n
|
|
|
504
507
|
at: new Date(now).toISOString(),
|
|
505
508
|
acquired: result.acquired,
|
|
506
509
|
scanned: result.scanned,
|
|
510
|
+
repairedRuns: result.repairedRuns,
|
|
507
511
|
deletedRuns: result.deletedRuns,
|
|
508
512
|
deletedResults: result.deletedResults,
|
|
509
513
|
reapedTombstones: result.reapedTombstones,
|
|
@@ -654,6 +658,7 @@ export async function cleanupAsyncRetention(options: AsyncRetentionOptions): Pro
|
|
|
654
658
|
const result: AsyncRetentionResult = {
|
|
655
659
|
acquired: false,
|
|
656
660
|
scanned: 0,
|
|
661
|
+
repairedRuns: 0,
|
|
657
662
|
deletedRuns: 0,
|
|
658
663
|
deletedResults: 0,
|
|
659
664
|
reapedTombstones: 0,
|
|
@@ -750,7 +755,19 @@ export async function cleanupAsyncRetention(options: AsyncRetentionOptions): Pro
|
|
|
750
755
|
increment(result.skipped, "unsafe-run-path");
|
|
751
756
|
continue;
|
|
752
757
|
}
|
|
753
|
-
|
|
758
|
+
let status = readStatus(runDir);
|
|
759
|
+
if (status?.state === "running"
|
|
760
|
+
&& validRunId(status.runId)
|
|
761
|
+
&& path.basename(runDir) === status.runId
|
|
762
|
+
&& !protectedRunIds.has(status.runId)) {
|
|
763
|
+
const reconciliation = reconcileAsyncRun(runDir, {
|
|
764
|
+
resultsDir: options.resultsDir,
|
|
765
|
+
kill: options.reconcileKill,
|
|
766
|
+
now,
|
|
767
|
+
});
|
|
768
|
+
status = reconciliation.status ?? undefined;
|
|
769
|
+
if (reconciliation.repaired) result.repairedRuns += 1;
|
|
770
|
+
}
|
|
754
771
|
const initialReason = runSkipReason({ runDir, status, asyncDirRoot: options.asyncDirRoot, resultsDir: options.resultsDir, cutoff, protectedRunIds, waitRunIds: waitReferences.runIds });
|
|
755
772
|
if (initialReason) {
|
|
756
773
|
increment(result.skipped, initialReason);
|
|
@@ -16,6 +16,7 @@ import { ACTIVE_RUN_INDEX_DIR, DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS, activeRu
|
|
|
16
16
|
import { readRecentTerminalRunIndex, TERMINAL_RUN_INDEX_DIR } from "./terminal-run-index.ts";
|
|
17
17
|
import { canScanAsyncRunPrefix } from "./run-id-query.ts";
|
|
18
18
|
import { asyncStatusChildIdentity } from "../shared/child-identity.ts";
|
|
19
|
+
import { parseWorkflowChildSummary } from "../../workflows/workflow-child-summary.ts";
|
|
19
20
|
|
|
20
21
|
interface AsyncRunStepSummary {
|
|
21
22
|
index: number;
|
|
@@ -121,6 +122,7 @@ export interface AsyncRunSummary {
|
|
|
121
122
|
parentWorkflowRunId?: string;
|
|
122
123
|
workflowKey?: string;
|
|
123
124
|
workflow?: Details["workflow"];
|
|
125
|
+
workflowChildren?: Details["workflowChildren"];
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
interface AsyncRunListOptions {
|
|
@@ -229,6 +231,8 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
|
|
|
229
231
|
}
|
|
230
232
|
|
|
231
233
|
function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = [], nestedRoute?: NestedRoute): AsyncRunSummary {
|
|
234
|
+
const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
|
|
235
|
+
if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': workflowChildren.workflowRunId does not match.`);
|
|
232
236
|
if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
|
|
233
237
|
throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': sessionId must be a string.`);
|
|
234
238
|
}
|
|
@@ -357,6 +361,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
|
|
|
357
361
|
...(status.parentWorkflowRunId ? { parentWorkflowRunId: status.parentWorkflowRunId } : {}),
|
|
358
362
|
...(status.workflowKey ? { workflowKey: status.workflowKey } : {}),
|
|
359
363
|
...(status.workflow ? { workflow: status.workflow } : {}),
|
|
364
|
+
...(workflowChildren ? { workflowChildren } : {}),
|
|
360
365
|
...(status.sessionDir ? { sessionDir: status.sessionDir } : {}),
|
|
361
366
|
...(status.outputFile ? { outputFile: status.outputFile } : {}),
|
|
362
367
|
...(status.totalTokens ? { totalTokens: status.totalTokens } : {}),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import { resultFilePath } from "./result-files.ts";
|
|
3
|
-
import type { AcceptanceLedger, AsyncStatus, CostSummary, ModelAttempt } from "../../shared/types.ts";
|
|
3
|
+
import type { AcceptanceLedger, ArtifactPaths, AsyncStatus, CostSummary, ModelAttempt } from "../../shared/types.ts";
|
|
4
4
|
import { readStatus } from "../../shared/utils.ts";
|
|
5
5
|
|
|
6
6
|
export interface ImportedAsyncRoot {
|
|
@@ -27,6 +27,10 @@ export interface ImportedAsyncRootResult {
|
|
|
27
27
|
structuredOutputPath?: string;
|
|
28
28
|
structuredOutputSchemaPath?: string;
|
|
29
29
|
acceptance?: AcceptanceLedger;
|
|
30
|
+
artifactPaths?: ArtifactPaths;
|
|
31
|
+
outputSaveError?: string;
|
|
32
|
+
transcriptPath?: string;
|
|
33
|
+
transcriptError?: string;
|
|
30
34
|
timedOut?: boolean;
|
|
31
35
|
stopped?: boolean;
|
|
32
36
|
}
|
|
@@ -56,6 +60,10 @@ interface AsyncResultFile {
|
|
|
56
60
|
structuredOutputPath?: string;
|
|
57
61
|
structuredOutputSchemaPath?: string;
|
|
58
62
|
acceptance?: AcceptanceLedger;
|
|
63
|
+
artifactPaths?: ArtifactPaths;
|
|
64
|
+
outputSaveError?: string;
|
|
65
|
+
transcriptPath?: string;
|
|
66
|
+
transcriptError?: string;
|
|
59
67
|
}>;
|
|
60
68
|
}
|
|
61
69
|
|
|
@@ -118,6 +126,7 @@ function outputFromTerminalStatus(root: ImportedAsyncRoot, status: AsyncStatus,
|
|
|
118
126
|
...(step?.structuredOutputPath ? { structuredOutputPath: step.structuredOutputPath } : {}),
|
|
119
127
|
...(step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: step.structuredOutputSchemaPath } : {}),
|
|
120
128
|
...(step?.acceptance ? { acceptance: step.acceptance } : {}),
|
|
129
|
+
...(step?.transcriptPath ? { transcriptPath: step.transcriptPath } : {}),
|
|
121
130
|
};
|
|
122
131
|
}
|
|
123
132
|
|
|
@@ -136,6 +145,7 @@ function outputFromTimeout(root: ImportedAsyncRoot, status: AsyncStatus | null,
|
|
|
136
145
|
...(step?.modelAttempts ? { modelAttempts: step.modelAttempts } : {}),
|
|
137
146
|
...(step?.contextOverflow ? { contextOverflow: true } : {}),
|
|
138
147
|
...(step?.totalCost ? { totalCost: step.totalCost } : {}),
|
|
148
|
+
...(step?.transcriptPath ? { transcriptPath: step.transcriptPath } : {}),
|
|
139
149
|
};
|
|
140
150
|
}
|
|
141
151
|
|
|
@@ -168,6 +178,10 @@ function buildImportedResult(root: ImportedAsyncRoot, status: AsyncStatus | null
|
|
|
168
178
|
...(child?.structuredOutputPath ?? step?.structuredOutputPath ? { structuredOutputPath: child?.structuredOutputPath ?? step?.structuredOutputPath } : {}),
|
|
169
179
|
...(child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath } : {}),
|
|
170
180
|
...(child?.acceptance ?? step?.acceptance ? { acceptance: child?.acceptance ?? step?.acceptance } : {}),
|
|
181
|
+
...(child?.artifactPaths ? { artifactPaths: child.artifactPaths } : {}),
|
|
182
|
+
...(child?.outputSaveError ? { outputSaveError: child.outputSaveError } : {}),
|
|
183
|
+
...(child?.transcriptPath ?? step?.transcriptPath ? { transcriptPath: child?.transcriptPath ?? step?.transcriptPath } : {}),
|
|
184
|
+
...(child?.transcriptError ? { transcriptError: child.transcriptError } : {}),
|
|
171
185
|
};
|
|
172
186
|
}
|
|
173
187
|
|
|
@@ -18,6 +18,7 @@ import { readStatus } from "../../shared/utils.ts";
|
|
|
18
18
|
import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
|
|
19
19
|
import { contextModeLabel, summarizeContextModes } from "../shared/context-mode.ts";
|
|
20
20
|
import { formatAsyncRunOutputPath, formatAsyncRunProgressLabel, listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
|
|
21
|
+
import { isTrustedRecordedSessionFile } from "../../shared/session-file-trust.ts";
|
|
21
22
|
|
|
22
23
|
const DEFAULT_TRANSCRIPT_LINES = 80;
|
|
23
24
|
const MAX_TRANSCRIPT_LINES = 500;
|
|
@@ -43,6 +44,8 @@ interface TranscriptOptions {
|
|
|
43
44
|
index?: number;
|
|
44
45
|
lines?: number;
|
|
45
46
|
sessionRoots?: string[];
|
|
47
|
+
trustedSessionFiles?: string[];
|
|
48
|
+
trustedSessionFileRoot?: string;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
interface TextTailResult {
|
|
@@ -120,10 +123,13 @@ function readTextTail(filePath: string, maxLines: number): TextTailResult {
|
|
|
120
123
|
}
|
|
121
124
|
}
|
|
122
125
|
|
|
123
|
-
function readContainedTextTail(filePath: string, maxLines: number, trustedRoots: string[], label: string): TextTailResult {
|
|
124
|
-
if (trustedRoots.length === 0) return { path: filePath, lines: [], truncated: false, error: `Refusing to read ${label} transcript path without a trusted root: ${filePath}` };
|
|
126
|
+
function readContainedTextTail(filePath: string, maxLines: number, trustedRoots: string[], label: string, trustedFiles: string[] = [], trustedFileRoot?: string): TextTailResult {
|
|
127
|
+
if (trustedRoots.length === 0 && (!trustedFileRoot || trustedFiles.length === 0)) return { path: filePath, lines: [], truncated: false, error: `Refusing to read ${label} transcript path without a trusted root: ${filePath}` };
|
|
125
128
|
const resolvedPath = path.resolve(filePath);
|
|
126
|
-
|
|
129
|
+
const recordedCandidate = trustedFileRoot
|
|
130
|
+
&& pathWithin(trustedFileRoot, resolvedPath)
|
|
131
|
+
&& trustedFiles.some((file) => path.resolve(file) === resolvedPath);
|
|
132
|
+
if (!trustedRoots.some((root) => pathWithin(root, resolvedPath)) && !recordedCandidate) {
|
|
127
133
|
return { path: filePath, lines: [], truncated: false, error: `Refusing to read ${label} transcript path outside trusted roots: ${filePath}` };
|
|
128
134
|
}
|
|
129
135
|
let lstat: fs.Stats;
|
|
@@ -143,7 +149,7 @@ function readContainedTextTail(filePath: string, maxLines: number, trustedRoots:
|
|
|
143
149
|
} catch (error) {
|
|
144
150
|
return { path: filePath, lines: [], truncated: false, error: getErrorMessage(error) };
|
|
145
151
|
}
|
|
146
|
-
if (!realRoots.some((root) => pathWithin(root, realPath))) {
|
|
152
|
+
if (!realRoots.some((root) => pathWithin(root, realPath)) && !isTrustedRecordedSessionFile(realPath, trustedFiles, trustedFileRoot)) {
|
|
147
153
|
return { path: filePath, lines: [], truncated: false, error: `Refusing to read ${label} transcript path outside trusted roots: ${filePath}` };
|
|
148
154
|
}
|
|
149
155
|
return readTextTail(resolvedPath, maxLines);
|
|
@@ -213,8 +219,8 @@ function sessionMessageLine(record: unknown): string | undefined {
|
|
|
213
219
|
|
|
214
220
|
/** Structured session tail: parsed content parts, newest last, bounded by
|
|
215
221
|
* maxMessages. Same trusted-root containment as the prose transcript tail. */
|
|
216
|
-
export function readSessionMessagesTail(sessionFile: string, maxMessages: number, trustedRoots: string[]): { messages: SessionTranscriptMessage[]; warnings: string[]; truncated: boolean } {
|
|
217
|
-
const tail = readContainedTextTail(sessionFile, Math.max(maxMessages * 4, maxMessages), trustedRoots, "session");
|
|
222
|
+
export function readSessionMessagesTail(sessionFile: string, maxMessages: number, trustedRoots: string[], trustedFiles: string[] = [], trustedFileRoot?: string): { messages: SessionTranscriptMessage[]; warnings: string[]; truncated: boolean } {
|
|
223
|
+
const tail = readContainedTextTail(sessionFile, Math.max(maxMessages * 4, maxMessages), trustedRoots, "session", trustedFiles, trustedFileRoot);
|
|
218
224
|
const warnings: string[] = [];
|
|
219
225
|
if (tail.error) warnings.push(`Session read failed for ${sessionFile}: ${tail.error}`);
|
|
220
226
|
const parsedMessages: SessionTranscriptMessage[] = [];
|
|
@@ -235,8 +241,8 @@ export function readSessionMessagesTail(sessionFile: string, maxMessages: number
|
|
|
235
241
|
return { messages, warnings, truncated: tail.truncated || parsedMessages.length > messages.length };
|
|
236
242
|
}
|
|
237
243
|
|
|
238
|
-
function readSessionTranscriptTail(sessionFile: string, maxLines: number, trustedRoots: string[]): { lines: string[]; warnings: string[] } {
|
|
239
|
-
const tail = readContainedTextTail(sessionFile, Math.max(maxLines * 4, maxLines), trustedRoots, "session");
|
|
244
|
+
function readSessionTranscriptTail(sessionFile: string, maxLines: number, trustedRoots: string[], trustedFiles: string[] = [], trustedFileRoot?: string): { lines: string[]; warnings: string[] } {
|
|
245
|
+
const tail = readContainedTextTail(sessionFile, Math.max(maxLines * 4, maxLines), trustedRoots, "session", trustedFiles, trustedFileRoot);
|
|
240
246
|
const warnings: string[] = [];
|
|
241
247
|
if (tail.error) warnings.push(`Session read failed for ${sessionFile}: ${tail.error}`);
|
|
242
248
|
const lines: string[] = [];
|
|
@@ -523,7 +529,7 @@ export function formatAsyncRunTranscript(status: AsyncStatus, asyncDir: string,
|
|
|
523
529
|
transcriptSource = "Recent output from status.json";
|
|
524
530
|
}
|
|
525
531
|
if (transcriptLines.length === 0 && sessionFile) {
|
|
526
|
-
const sessionTail = readSessionTranscriptTail(sessionFile, lineLimit, options.sessionRoots ?? []);
|
|
532
|
+
const sessionTail = readSessionTranscriptTail(sessionFile, lineLimit, options.sessionRoots ?? [], options.trustedSessionFiles, options.trustedSessionFileRoot);
|
|
527
533
|
transcriptLines = sessionTail.lines;
|
|
528
534
|
warnings.push(...sessionTail.warnings);
|
|
529
535
|
if (transcriptLines.length > 0) transcriptSource = `Session transcript tail from ${sessionFile}`;
|
|
@@ -554,7 +560,7 @@ export function formatNestedRunTranscript(run: NestedRunSummary, options: Transc
|
|
|
554
560
|
appendTranscriptBody(lines, "Transcript tail", [], false);
|
|
555
561
|
return safeTranscriptLines(lines);
|
|
556
562
|
}
|
|
557
|
-
const sessionTail = readSessionTranscriptTail(run.sessionFile, lineLimit, options.sessionRoots ?? []);
|
|
563
|
+
const sessionTail = readSessionTranscriptTail(run.sessionFile, lineLimit, options.sessionRoots ?? [], options.trustedSessionFiles, options.trustedSessionFileRoot);
|
|
558
564
|
if (sessionTail.warnings.length) {
|
|
559
565
|
lines.push("Warnings:");
|
|
560
566
|
for (const warning of sessionTail.warnings) lines.push(` ${warning}`);
|