pi-subagents 0.50.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 +103 -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/async-retention-discovery-worker.mjs +180 -0
- package/docs/agents.md +40 -2
- package/docs/configuration.md +30 -12
- package/docs/extension-api.md +42 -1
- package/docs/models.md +2 -0
- package/docs/observability.md +41 -5
- package/docs/tool-reference.md +38 -39
- package/docs/workflows.md +171 -3
- package/package.json +4 -2
- package/skills/pi-subagents/SKILL.md +6 -4
- package/skills/pi-subagents/references/constraints-and-recipes.md +12 -6
- package/skills/pi-subagents/references/execution-controls.md +29 -15
- package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
- package/skills/pi-subagents/references/prompting-and-roles.md +4 -2
- package/src/agents/agent-management.ts +124 -351
- package/src/agents/agents.ts +157 -31
- package/src/agents/skills.ts +1 -1
- package/src/api/external-job-provider.ts +185 -0
- package/src/api/preflight.ts +36 -8
- package/src/api/shared-types.ts +2 -0
- package/src/extension/config.ts +3 -3
- package/src/extension/doctor.ts +3 -6
- package/src/extension/fanout-child.ts +2 -2
- package/src/extension/index.ts +166 -88
- package/src/extension/public-execution.ts +31 -2
- package/src/extension/schemas.ts +12 -35
- package/src/extension/tool-description.ts +35 -24
- package/src/inspectors/herdr/actions.ts +2 -2
- package/src/inspectors/herdr/inspector-runner.ts +2 -1
- package/src/inspectors/herdr/project-panes.ts +2 -2
- package/src/intercom/native-supervisor-channel.ts +32 -10
- package/src/missions/lifecycle.ts +6 -1
- package/src/missions/store.ts +4 -9
- package/src/missions/workflow-state.ts +2 -2
- package/src/profiles/profiles.ts +3 -1
- package/src/runs/background/active-run-index.ts +31 -8
- package/src/runs/background/async-execution.ts +68 -40
- package/src/runs/background/async-job-tracker.ts +20 -4
- package/src/runs/background/async-resume.ts +47 -15
- package/src/runs/background/async-retention.ts +886 -0
- package/src/runs/background/async-status.ts +39 -53
- package/src/runs/background/chain-append.ts +3 -33
- package/src/runs/background/completion-dedupe.ts +5 -1
- package/src/runs/background/completion-replay.ts +22 -12
- package/src/runs/background/control-channel.ts +14 -68
- package/src/runs/background/fleet-view.ts +68 -19
- package/src/runs/background/index-segment.ts +59 -0
- package/src/runs/background/inspect-rpc.ts +443 -0
- package/src/runs/background/notify.ts +31 -5
- package/src/runs/background/result-files.ts +158 -90
- package/src/runs/background/result-watcher.ts +116 -20
- package/src/runs/background/resume-guidance.ts +8 -5
- package/src/runs/background/retained-children.ts +13 -3
- package/src/runs/background/run-id-query.ts +7 -0
- package/src/runs/background/run-id-resolver.ts +11 -9
- package/src/runs/background/run-status.ts +27 -18
- package/src/runs/background/scheduled-runs.ts +24 -4
- package/src/runs/background/stale-run-reconciler.ts +6 -3
- package/src/runs/background/steering.ts +11 -1
- package/src/runs/background/subagent-runner.ts +253 -140
- package/src/runs/background/subagent-wait.ts +8 -8
- package/src/runs/background/terminal-run-index.ts +129 -0
- package/src/runs/background/wait-completions.ts +21 -4
- package/src/runs/background/wait-subscriptions.ts +81 -2
- package/src/runs/foreground/async-steering-action.ts +21 -14
- package/src/runs/foreground/execution.ts +3 -1
- package/src/runs/foreground/subagent-executor.ts +666 -1579
- package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
- package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
- package/src/runs/shared/acceptance.ts +4 -4
- package/src/runs/shared/chain-outputs.ts +1 -3
- package/src/runs/shared/external-job-bridge.ts +444 -0
- package/src/runs/shared/external-job-runner.ts +286 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
- package/src/runs/shared/model-fallback.ts +98 -38
- package/src/runs/shared/orca-progress-tabs.ts +84 -22
- package/src/runs/shared/parallel-handoff.ts +46 -4
- package/src/runs/shared/parallel-utils.ts +4 -15
- package/src/runs/shared/permissions.ts +5 -1
- package/src/runs/shared/pi-args.ts +8 -1
- package/src/runs/shared/session-lease.ts +0 -6
- package/src/runs/shared/subagent-control.ts +26 -4
- package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
- package/src/runs/shared/workflow-graph.ts +1 -23
- package/src/runs/shared/worktree.ts +14 -2
- package/src/shared/atomic-json.ts +22 -2
- package/src/shared/capacity-resilient-json.ts +102 -0
- package/src/shared/completion-owner.ts +14 -0
- package/src/shared/file-system-retry.ts +49 -1
- package/src/shared/fork-context.ts +42 -0
- package/src/shared/prompt-resources.ts +0 -40
- package/src/shared/settings.ts +3 -27
- package/src/shared/types.ts +71 -26
- package/src/shared/utils.ts +8 -0
- package/src/shared/watch-strategy.ts +10 -0
- package/src/slash/slash-bridge.ts +2 -1
- package/src/slash/slash-commands.ts +29 -3
- package/src/tui/fleet.ts +63 -15
- package/src/watchdog/change-signature.ts +1 -1
- package/src/watchdog/lsp-diagnostics.ts +1 -0
- package/src/workflows/chat-progress.ts +2 -2
- package/src/workflows/scripted-workflow.ts +220 -67
- package/src/runs/foreground/chain-clarify.ts +0 -1354
- package/src/runs/foreground/chain-execution.ts +0 -1581
|
@@ -226,7 +226,7 @@ function summarizeForegroundChildren(run: ForegroundResumeRun, indices: Set<numb
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
function foregroundChildrenNeedingAttention(run: ForegroundResumeRun, indices: Set<number>) {
|
|
229
|
-
return run.children.filter((child) => indices.has(child.index) && child.status === "detached" && child.activityState === "needs_attention");
|
|
229
|
+
return run.children.filter((child) => indices.has(child.index) && child.status === "detached" && child.activityState === "needs_attention" && child.currentTool === "contact_supervisor");
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
function formatForegroundAttention(run: ForegroundResumeRun, children: ReturnType<typeof foregroundChildrenNeedingAttention>, elapsedMs: number): AgentToolResult<Details> {
|
|
@@ -278,19 +278,19 @@ function attentionRunsForSession(params: SubagentWaitParams, deps: SubagentWaitD
|
|
|
278
278
|
return activeRunsForSession(params, deps).filter((run) => needsAttention(run) && initialIds.has(run.id));
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
-
/**
|
|
282
|
-
function
|
|
281
|
+
/** Exact initial runs in any state, for the final summary. */
|
|
282
|
+
function runsForIds(runIds: Iterable<string>, deps: SubagentWaitDeps): AsyncRunSummary[] {
|
|
283
283
|
const asyncDirRoot = deps.asyncDirRoot ?? DIRS.async;
|
|
284
284
|
const resultsDir = deps.resultsDir ?? DIRS.results;
|
|
285
|
-
|
|
285
|
+
return [...runIds].flatMap((runId) => listAsyncRuns(asyncDirRoot, {
|
|
286
286
|
sessionId: deps.state.currentSessionId ?? undefined,
|
|
287
287
|
resultsDir,
|
|
288
288
|
kill: deps.kill,
|
|
289
289
|
now: deps.now,
|
|
290
290
|
includeNested: false,
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
291
|
+
runId,
|
|
292
|
+
exactRunId: true,
|
|
293
|
+
}));
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
function summarizeTerminalRuns(runs: AsyncRunSummary[], providerFinishedCount = 0): string {
|
|
@@ -611,7 +611,7 @@ export async function waitForSubagents(
|
|
|
611
611
|
const activeProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
|
|
612
612
|
const providerFinishedCount = [...initialProviderIds].filter((id) => !activeProviderIds.has(id)).length;
|
|
613
613
|
try {
|
|
614
|
-
const allNow =
|
|
614
|
+
const allNow = runsForIds(initialAsyncIds, deps);
|
|
615
615
|
const terminal = allNow.filter((run) => !ACTIVE_STATES.includes(run.state) && initialAsyncIds.has(run.id));
|
|
616
616
|
finishedAsyncCount = terminal.length;
|
|
617
617
|
failedAsyncCount = terminal.filter((run) => run.state === "failed").length;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { AsyncStatus } from "../../shared/types.ts";
|
|
4
|
+
import { readStatus } from "../../shared/utils.ts";
|
|
5
|
+
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
6
|
+
import { encodeIndexSegment } from "./index-segment.ts";
|
|
7
|
+
|
|
8
|
+
export const TERMINAL_RUN_INDEX_DIR = ".terminal-runs";
|
|
9
|
+
|
|
10
|
+
const TERMINAL_RUN_INDEX_VERSION = 1;
|
|
11
|
+
const TIMESTAMP_WIDTH = 16;
|
|
12
|
+
|
|
13
|
+
interface TerminalRunIndexEntry {
|
|
14
|
+
version: 1;
|
|
15
|
+
runId: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
endedAt: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isTerminalState(state: AsyncStatus["state"]): boolean {
|
|
21
|
+
return state === "complete" || state === "failed" || state === "paused" || state === "stopped" || state === "rejected";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function indexRoot(asyncDirRoot: string): string {
|
|
25
|
+
return path.join(asyncDirRoot, TERMINAL_RUN_INDEX_DIR);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sessionIndexDir(asyncDirRoot: string, sessionId: string): string {
|
|
29
|
+
return path.join(indexRoot(asyncDirRoot), encodeIndexSegment(sessionId));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function markerName(endedAt: number, runId: string): string {
|
|
33
|
+
return `${Math.max(0, Math.floor(endedAt)).toString().padStart(TIMESTAMP_WIDTH, "0")}-${encodeIndexSegment(runId)}.json`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function markerPath(asyncDir: string, sessionId: string, endedAt: number): string {
|
|
37
|
+
return path.join(sessionIndexDir(path.dirname(asyncDir), sessionId), markerName(endedAt, path.basename(asyncDir)));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseEntry(value: unknown): TerminalRunIndexEntry | undefined {
|
|
41
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
42
|
+
const entry = value as Partial<TerminalRunIndexEntry>;
|
|
43
|
+
if (entry.version !== TERMINAL_RUN_INDEX_VERSION
|
|
44
|
+
|| typeof entry.runId !== "string" || !entry.runId
|
|
45
|
+
|| typeof entry.sessionId !== "string" || !entry.sessionId
|
|
46
|
+
|| typeof entry.endedAt !== "number" || !Number.isFinite(entry.endedAt)) return undefined;
|
|
47
|
+
return { version: TERMINAL_RUN_INDEX_VERSION, runId: entry.runId, sessionId: entry.sessionId, endedAt: entry.endedAt };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function updateTerminalRunIndex(asyncDir: string, status: AsyncStatus): void {
|
|
51
|
+
if (!isTerminalState(status.state) || typeof status.sessionId !== "string" || !status.sessionId) return;
|
|
52
|
+
const endedAt = status.endedAt ?? status.lastUpdate ?? status.startedAt;
|
|
53
|
+
const marker = markerPath(asyncDir, status.sessionId, endedAt);
|
|
54
|
+
const entry: TerminalRunIndexEntry = {
|
|
55
|
+
version: TERMINAL_RUN_INDEX_VERSION,
|
|
56
|
+
runId: status.runId || path.basename(asyncDir),
|
|
57
|
+
sessionId: status.sessionId,
|
|
58
|
+
endedAt,
|
|
59
|
+
};
|
|
60
|
+
writeAtomicJson(marker, entry);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function removeInvalidMarker(marker: string): void {
|
|
64
|
+
try {
|
|
65
|
+
fs.rmSync(marker, { force: true });
|
|
66
|
+
} catch {
|
|
67
|
+
// An advisory marker can remain for a later read when cleanup is contended.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function markerFiles(dir: string): string[] {
|
|
72
|
+
try {
|
|
73
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
74
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
75
|
+
.map((entry) => path.join(dir, entry.name));
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
78
|
+
if (code === "ENOENT" || code === "ENOTDIR") return [];
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sessionDirs(asyncDirRoot: string, sessionId: string | undefined): string[] {
|
|
84
|
+
if (sessionId) return [sessionIndexDir(asyncDirRoot, sessionId)];
|
|
85
|
+
try {
|
|
86
|
+
return fs.readdirSync(indexRoot(asyncDirRoot), { withFileTypes: true })
|
|
87
|
+
.filter((entry) => entry.isDirectory())
|
|
88
|
+
.map((entry) => path.join(indexRoot(asyncDirRoot), entry.name));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
91
|
+
if (code === "ENOENT" || code === "ENOTDIR") return [];
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readRecentTerminalRunIndex(asyncDirRoot: string, options: { sessionId?: string; limit?: number } = {}): string[] {
|
|
97
|
+
const limit = options.limit === undefined ? Number.POSITIVE_INFINITY : Math.max(0, Math.floor(options.limit));
|
|
98
|
+
const candidates = sessionDirs(asyncDirRoot, options.sessionId)
|
|
99
|
+
.flatMap(markerFiles)
|
|
100
|
+
.sort((left, right) => path.basename(right).localeCompare(path.basename(left)))
|
|
101
|
+
.slice(0, limit);
|
|
102
|
+
const runIds: string[] = [];
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
for (const marker of candidates) {
|
|
105
|
+
let entry: TerminalRunIndexEntry | undefined;
|
|
106
|
+
try {
|
|
107
|
+
entry = parseEntry(JSON.parse(fs.readFileSync(marker, "utf-8")));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
110
|
+
}
|
|
111
|
+
if (!entry || (options.sessionId !== undefined && entry.sessionId !== options.sessionId)) {
|
|
112
|
+
removeInvalidMarker(marker);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const asyncDir = path.join(asyncDirRoot, entry.runId);
|
|
116
|
+
const status = readStatus(asyncDir);
|
|
117
|
+
if (!status || !isTerminalState(status.state) || status.sessionId !== entry.sessionId || (status.runId && status.runId !== entry.runId)) {
|
|
118
|
+
removeInvalidMarker(marker);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (seen.has(entry.runId)) {
|
|
122
|
+
removeInvalidMarker(marker);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
seen.add(entry.runId);
|
|
126
|
+
runIds.push(entry.runId);
|
|
127
|
+
}
|
|
128
|
+
return runIds;
|
|
129
|
+
}
|
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import type { ArtifactPaths, SubagentState, WaitCompletion, WaitCompletionChild } from "../../shared/types.ts";
|
|
3
3
|
import type { AsyncRunSummary } from "./async-status.ts";
|
|
4
4
|
import { readCompletionReplay, writeCompletionReplay } from "./completion-replay.ts";
|
|
5
|
-
import { resultFilePath, resultPayloadPathForSessionRun } from "./result-files.ts";
|
|
5
|
+
import { fallbackResultPayloadPathForSessionRun, resultFilePath, resultPayloadPathForSessionRun } from "./result-files.ts";
|
|
6
6
|
|
|
7
7
|
function asNonEmptyString(value: unknown): string | undefined {
|
|
8
8
|
return typeof value === "string" && value ? value : undefined;
|
|
@@ -14,6 +14,11 @@ function errorCode(error: unknown): string | undefined {
|
|
|
14
14
|
: undefined;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function isAccessDenied(error: unknown): boolean {
|
|
18
|
+
const code = errorCode(error);
|
|
19
|
+
return code === "EPERM" || code === "EACCES";
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
function errorMessage(error: unknown): string {
|
|
18
23
|
return error instanceof Error ? error.message : String(error);
|
|
19
24
|
}
|
|
@@ -115,9 +120,21 @@ export function collectWaitCompletions(terminal: AsyncRunSummary[], state: Subag
|
|
|
115
120
|
continue;
|
|
116
121
|
}
|
|
117
122
|
const publicResultPath = resultFilePath(resultsDir, run.id);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
let resultPath = publicResultPath;
|
|
124
|
+
try {
|
|
125
|
+
resultPath = run.sessionId
|
|
126
|
+
? resultPayloadPathForSessionRun(resultsDir, run.sessionId, run.id) ?? publicResultPath
|
|
127
|
+
: publicResultPath;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (!isAccessDenied(error) || !run.sessionId) throw error;
|
|
130
|
+
try {
|
|
131
|
+
resultPath = fallbackResultPayloadPathForSessionRun(resultsDir, run.sessionId, run.id) ?? publicResultPath;
|
|
132
|
+
} catch (fallbackError) {
|
|
133
|
+
throw new Error(`Failed to read subagent result '${publicResultPath}': ${errorMessage(fallbackError)}`, {
|
|
134
|
+
cause: fallbackError instanceof Error ? fallbackError : undefined,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
121
138
|
try {
|
|
122
139
|
const raw = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as Record<string, unknown>;
|
|
123
140
|
completions.push(toWaitCompletion(raw, run.id));
|
|
@@ -21,6 +21,20 @@ import {
|
|
|
21
21
|
|
|
22
22
|
const SUBSCRIPTION_VERSION = 1;
|
|
23
23
|
const RECONCILE_INTERVAL_MS = 1_000;
|
|
24
|
+
/**
|
|
25
|
+
* How often the subscriptions directory is re-scanned for expired records left
|
|
26
|
+
* by other sessions. Rare compared to RECONCILE_INTERVAL_MS because it costs a
|
|
27
|
+
* directory read, and nothing depends on the sweep being prompt.
|
|
28
|
+
*/
|
|
29
|
+
const FOREIGN_SWEEP_INTERVAL_MS = 60_000;
|
|
30
|
+
/**
|
|
31
|
+
* How far past expiry a record armed by another session is kept before it is
|
|
32
|
+
* swept. The owning session settles its own expired records with a "timed out"
|
|
33
|
+
* notice on the next restore(), so removing one the moment it expires would take
|
|
34
|
+
* that notice away from a session that simply had not resumed yet. A day is long
|
|
35
|
+
* enough for an ordinary return and short enough to bound the directory.
|
|
36
|
+
*/
|
|
37
|
+
const FOREIGN_SWEEP_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
24
38
|
|
|
25
39
|
export interface ArmWaitSubscriptionInput {
|
|
26
40
|
targetKind: "async" | "foreground";
|
|
@@ -95,6 +109,63 @@ export function createWaitSubscriptionManager(
|
|
|
95
109
|
state.waitSubscriptions = subscriptions;
|
|
96
110
|
const unresolvedRestoredForegroundTokens = new Set<string>();
|
|
97
111
|
let disposed = false;
|
|
112
|
+
let lastForeignSweepAt = 0;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove expired records armed by another session.
|
|
116
|
+
*
|
|
117
|
+
* Only the owning session can be woken, so restore() never loads a foreign
|
|
118
|
+
* record into `subscriptions` and reconcileRecord() returns before its
|
|
119
|
+
* timeout branch. Nothing reconciles such a record and nothing expires it, so
|
|
120
|
+
* a session that arms a wait and never comes back (forked, renamed, deleted)
|
|
121
|
+
* leaves one file here per wait, forever.
|
|
122
|
+
*
|
|
123
|
+
* Kept for FOREIGN_SWEEP_GRACE_MS past expiry so an owner that resumes late
|
|
124
|
+
* still finds its record and gets the timeout notice.
|
|
125
|
+
*
|
|
126
|
+
* Runs on the reconcile timer as well as at restore(), because a record that
|
|
127
|
+
* is still live when another session starts would otherwise never be looked
|
|
128
|
+
* at again: restore() only fires on session_start, and reconcile() walks the
|
|
129
|
+
* in-memory map, which holds current-session records only. A host that keeps
|
|
130
|
+
* one session open for hours is exactly where that gap bites.
|
|
131
|
+
*
|
|
132
|
+
* Never throws: it runs from the reconcile timer.
|
|
133
|
+
*/
|
|
134
|
+
const sweepExpiredForeignSubscriptions = (force = false) => {
|
|
135
|
+
const sweptAt = now();
|
|
136
|
+
if (!force && sweptAt - lastForeignSweepAt < FOREIGN_SWEEP_INTERVAL_MS) return;
|
|
137
|
+
lastForeignSweepAt = sweptAt;
|
|
138
|
+
let files: string[];
|
|
139
|
+
try {
|
|
140
|
+
files = fs.readdirSync(subscriptionsDir).filter((file) => file.endsWith(".json"));
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (!isNotFound(error)) console.error(`Failed to scan wait subscriptions in '${subscriptionsDir}':`, error);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
const filePath = path.join(subscriptionsDir, file);
|
|
147
|
+
let record: WaitSubscriptionRecord | undefined;
|
|
148
|
+
try {
|
|
149
|
+
record = parseRecord(JSON.parse(fs.readFileSync(filePath, "utf-8")));
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error(`Ignoring invalid wait subscription '${filePath}':`, error);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!record || file !== `${record.token}.json`) continue;
|
|
155
|
+
// The owning session keeps its own expired records so reconcileRecord()
|
|
156
|
+
// can still settle them with the "timed out" notice callers expect.
|
|
157
|
+
if (record.sessionId === state.currentSessionId) continue;
|
|
158
|
+
if (sweptAt < record.expiresAt + FOREIGN_SWEEP_GRACE_MS) continue;
|
|
159
|
+
try {
|
|
160
|
+
fs.unlinkSync(filePath);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// Losing this race is harmless: another session may have swept the
|
|
163
|
+
// same expired record. Anything else is reported and retried on the
|
|
164
|
+
// next sweep rather than interrupting the caller.
|
|
165
|
+
if (!isNotFound(error)) console.error(`Failed to remove expired wait subscription '${filePath}':`, error);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
98
169
|
|
|
99
170
|
const remove = (record: WaitSubscriptionRecord) => {
|
|
100
171
|
try {
|
|
@@ -146,7 +217,7 @@ export function createWaitSubscriptionManager(
|
|
|
146
217
|
return;
|
|
147
218
|
}
|
|
148
219
|
const detached = run.children.filter((child) => child.status === "detached");
|
|
149
|
-
if (detached.some((child) => child.activityState === "needs_attention")) {
|
|
220
|
+
if (detached.some((child) => child.activityState === "needs_attention" && child.currentTool === "contact_supervisor")) {
|
|
150
221
|
settle(record, "needs attention", "Reply to the pending supervisor request or inspect the run status.");
|
|
151
222
|
return;
|
|
152
223
|
}
|
|
@@ -160,6 +231,7 @@ export function createWaitSubscriptionManager(
|
|
|
160
231
|
const runs = listAsyncRuns(asyncDirRoot, {
|
|
161
232
|
sessionId: record.sessionId,
|
|
162
233
|
runId: record.runId,
|
|
234
|
+
exactRunId: true,
|
|
163
235
|
resultsDir,
|
|
164
236
|
kill: options.kill,
|
|
165
237
|
now,
|
|
@@ -187,7 +259,11 @@ export function createWaitSubscriptionManager(
|
|
|
187
259
|
};
|
|
188
260
|
|
|
189
261
|
const reconcile = () => {
|
|
190
|
-
if (disposed
|
|
262
|
+
if (disposed) return;
|
|
263
|
+
// Before the session check: with no current session every record is
|
|
264
|
+
// foreign, and expired ones should still be cleaned up.
|
|
265
|
+
sweepExpiredForeignSubscriptions();
|
|
266
|
+
if (!state.currentSessionId) return;
|
|
191
267
|
for (const record of [...subscriptions.values()]) {
|
|
192
268
|
try {
|
|
193
269
|
reconcileRecord(record);
|
|
@@ -233,6 +309,9 @@ export function createWaitSubscriptionManager(
|
|
|
233
309
|
restore() {
|
|
234
310
|
subscriptions.clear();
|
|
235
311
|
unresolvedRestoredForegroundTokens.clear();
|
|
312
|
+
// Unconditional: a process that starts without a session identity should
|
|
313
|
+
// still clear records nobody can act on.
|
|
314
|
+
sweepExpiredForeignSubscriptions(true);
|
|
236
315
|
if (!state.currentSessionId) return;
|
|
237
316
|
let files: string[];
|
|
238
317
|
try {
|
|
@@ -8,7 +8,17 @@ import { readStatus } from "../../shared/utils.ts";
|
|
|
8
8
|
import { consumeSteerAcks, deliverInterruptRequest, queueRevivalBrief, requestAsyncSteer, type SteerDeliveryMode, type SteerRequest } from "../background/control-channel.ts";
|
|
9
9
|
import { resolveAsyncResumeTarget } from "../background/async-resume.ts";
|
|
10
10
|
import { reconcileAsyncRun } from "../background/stale-run-reconciler.ts";
|
|
11
|
-
import { actionResultFromSteeringStatus, claimSteeringRecovery, createSteeringStatus, recordSteeringRequest, remainingSteeringRecoveryLimits, updateSteeringTarget, waitForSteeringAction } from "../background/steering.ts";
|
|
11
|
+
import { actionResultFromSteeringStatus, claimSteeringRecovery, createSteeringStatus, recordSteeringRequest, remainingSteeringRecoveryLimits, steeringReceipt, updateSteeringTarget, waitForSteeringAction } from "../background/steering.ts";
|
|
12
|
+
|
|
13
|
+
export function canQueueRetainedAsyncFollowUp(status: AsyncStatus, index?: number): boolean {
|
|
14
|
+
const steps = status.steps ?? [];
|
|
15
|
+
return status.state === "complete"
|
|
16
|
+
&& Boolean(status.parentWorkflowRunId)
|
|
17
|
+
&& steps.length === 1
|
|
18
|
+
&& (steps[0]?.status === "complete" || steps[0]?.status === "completed")
|
|
19
|
+
&& Boolean(steps[0]?.sessionFile ?? status.sessionFile)
|
|
20
|
+
&& (index === undefined || index === 0);
|
|
21
|
+
}
|
|
12
22
|
|
|
13
23
|
export async function steerAsyncRun(input: {
|
|
14
24
|
state: SubagentState;
|
|
@@ -51,7 +61,7 @@ export async function steerAsyncRun(input: {
|
|
|
51
61
|
}
|
|
52
62
|
const steps = status.steps ?? [];
|
|
53
63
|
if (status.state !== "running" && status.state !== "queued") {
|
|
54
|
-
const retained = input.mode === "follow_up" &&
|
|
64
|
+
const retained = input.mode === "follow_up" && canQueueRetainedAsyncFollowUp(status, input.index);
|
|
55
65
|
if (!retained) {
|
|
56
66
|
return {
|
|
57
67
|
content: [{ type: "text", text: `Async run '${input.runId}' is not running or queued and cannot be steered.` }],
|
|
@@ -59,9 +69,6 @@ export async function steerAsyncRun(input: {
|
|
|
59
69
|
details: { mode: "management", results: [] },
|
|
60
70
|
};
|
|
61
71
|
}
|
|
62
|
-
if (input.index !== undefined && input.index !== 0) {
|
|
63
|
-
return { content: [{ type: "text", text: `Retained async run '${status.runId}' has one child. Index ${input.index} is out of range.` }], isError: true, details: { mode: "management", results: [] } };
|
|
64
|
-
}
|
|
65
72
|
const request: SteerRequest = { type: "steer", id: randomUUID(), ts: Date.now(), message: input.message.trim(), mode: "follow_up", targetIndex: 0, source: "steer-action" };
|
|
66
73
|
try {
|
|
67
74
|
queueRevivalBrief(asyncDir, request);
|
|
@@ -72,7 +79,7 @@ export async function steerAsyncRun(input: {
|
|
|
72
79
|
recordSteeringRequest(status.steering, { id: request.id, requestedAt: request.ts, source: request.source, message: request.message, targets: [{ index: 0, state: "scheduled" }] });
|
|
73
80
|
writeAtomicJson(path.join(asyncDir, "status.json"), status);
|
|
74
81
|
const queued = { requestId: request.id, state: "scheduled" as const, deliveryStatus: "queued" as const, sourceRunId: status.runId, targets: [{ index: 0, state: "scheduled" as const }] };
|
|
75
|
-
return { content: [{ type: "text", text: `Follow-up queued for the next resume of retained async run ${status.runId} (request ${request.id}).` }], details: { mode: "management", results: [], steering: queued } };
|
|
82
|
+
return { content: [{ type: "text", text: steeringReceipt(request.message, `Follow-up queued for the next resume of retained async run ${status.runId} (request ${request.id}).`) }], details: { mode: "management", results: [], steering: queued } };
|
|
76
83
|
}
|
|
77
84
|
if (input.index !== undefined) {
|
|
78
85
|
if (input.index < 0 || input.index >= steps.length) {
|
|
@@ -135,17 +142,17 @@ export async function steerAsyncRun(input: {
|
|
|
135
142
|
const targets = targetIndexes.map((index) => ({ index, state: status.steps?.[index]?.status === "pending" ? "scheduled" as const : "pending" as const }));
|
|
136
143
|
if (targets.every((target) => target.state === "scheduled")) {
|
|
137
144
|
const scheduled = { requestId, state: "scheduled" as const, deliveryStatus: "queued" as const, sourceRunId: status.runId, targets };
|
|
138
|
-
return { content: [{ type: "text", text: `Steering scheduled for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: scheduled } };
|
|
145
|
+
return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering scheduled for async run ${status.runId} (request ${requestId}).`) }], details: { mode: "management", results: [], steering: scheduled } };
|
|
139
146
|
}
|
|
140
147
|
const waited = await waitForSteeringAction({ asyncDir, sourceRunId: status.runId, requestId, timeoutMs: input.ackTimeoutMs ?? 3_000, signal: input.signal });
|
|
141
148
|
const result = waited ?? { requestId, state: "pending" as const, deliveryStatus: "queued" as const, sourceRunId: status.runId, targets };
|
|
142
149
|
if (input.signal?.aborted) {
|
|
143
|
-
return { content: [{ type: "text", text: `Steering pending for async run ${status.runId} (request ${requestId}); caller aborted before recovery.` }], details: { mode: "management", results: [], steering: result } };
|
|
150
|
+
return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering pending for async run ${status.runId} (request ${requestId}); caller aborted before recovery.`) }], details: { mode: "management", results: [], steering: result } };
|
|
144
151
|
}
|
|
145
152
|
const finalStatus = readStatus(asyncDir);
|
|
146
153
|
const finalResult = finalStatus?.steering ? actionResultFromSteeringStatus(finalStatus.steering, status.runId, requestId) : undefined;
|
|
147
154
|
if (finalResult?.state === "delivered") {
|
|
148
|
-
return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: finalResult } };
|
|
155
|
+
return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering delivered for async run ${status.runId} (request ${requestId}).`) }], details: { mode: "management", results: [], steering: finalResult } };
|
|
149
156
|
}
|
|
150
157
|
const running = (finalStatus?.steps ?? status.steps ?? []).filter((step) => step.status === "running");
|
|
151
158
|
const recoveryAllowed = (input.mode ?? "steer") === "steer" && status.mode === "single" && status.isNested !== true && running.length === 1 && Boolean(finalStatus?.steering) && (input.index === undefined || input.index === 0);
|
|
@@ -160,7 +167,7 @@ export async function steerAsyncRun(input: {
|
|
|
160
167
|
try {
|
|
161
168
|
const latest = readStatus(asyncDir);
|
|
162
169
|
const latestResult = latest?.steering ? actionResultFromSteeringStatus(latest.steering, status.runId, requestId) : undefined;
|
|
163
|
-
if (latestResult?.state === "delivered") return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: latestResult } };
|
|
170
|
+
if (latestResult?.state === "delivered") return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering delivered for async run ${status.runId} (request ${requestId}).`) }], details: { mode: "management", results: [], steering: latestResult } };
|
|
164
171
|
const committedAt = Date.now();
|
|
165
172
|
input.onBeforeRecoveryClaim?.(requestId, committedAt);
|
|
166
173
|
const { claimPath, markerPath } = claimSteeringRecovery(asyncDir, { requestId, sourceRunId: status.runId, committedAt });
|
|
@@ -170,7 +177,7 @@ export async function steerAsyncRun(input: {
|
|
|
170
177
|
if (preCommitResult?.state === "delivered" && preCommitResult.targets.every((target) => target.deliveredAt !== undefined && target.deliveredAt <= committedAt)) {
|
|
171
178
|
fs.rmSync(markerPath, { force: true });
|
|
172
179
|
fs.rmSync(claimPath, { force: true });
|
|
173
|
-
return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: preCommitResult } };
|
|
180
|
+
return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering delivered for async run ${status.runId} (request ${requestId}).`) }], details: { mode: "management", results: [], steering: preCommitResult } };
|
|
174
181
|
}
|
|
175
182
|
try {
|
|
176
183
|
deliverInterruptRequest({ asyncDir, source: "steering-recovery" });
|
|
@@ -228,7 +235,7 @@ export async function steerAsyncRun(input: {
|
|
|
228
235
|
}
|
|
229
236
|
const recovered = paused.steering ? actionResultFromSteeringStatus(paused.steering, status.runId, requestId, revived.details.asyncId) : undefined;
|
|
230
237
|
appendSteeringNotice("recovered", `Steering recovered for run ${status.runId}; replacement ${revived.details.asyncId} launched.`);
|
|
231
|
-
return { content: [{ type: "text", text: `Steering recovered for async run ${status.runId}; replacement ${revived.details.asyncId} launched after the source paused.` }], details: { mode: "management", results: [], steering: recovered ?? { requestId, state: "recovered", sourceRunId: status.runId, replacementRunId: revived.details.asyncId, deliveryStatus: "delivered", targets: [{ index: input.index ?? 0, state: "recovered" }] } } };
|
|
238
|
+
return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering recovered for async run ${status.runId}; replacement ${revived.details.asyncId} launched after the source paused.`) }], details: { mode: "management", results: [], steering: recovered ?? { requestId, state: "recovered", sourceRunId: status.runId, replacementRunId: revived.details.asyncId, deliveryStatus: "delivered", targets: [{ index: input.index ?? 0, state: "recovered" }] } } };
|
|
232
239
|
} catch (error) {
|
|
233
240
|
const reason = error instanceof Error ? error.message : String(error);
|
|
234
241
|
const failedStatus = readStatus(asyncDir);
|
|
@@ -242,13 +249,13 @@ export async function steerAsyncRun(input: {
|
|
|
242
249
|
}
|
|
243
250
|
const failed = failedStatus?.steering ? actionResultFromSteeringStatus(failedStatus.steering, status.runId, requestId) : undefined;
|
|
244
251
|
appendSteeringNotice("failed", `Steering failed for run ${status.runId}: ${reason}`);
|
|
245
|
-
return { content: [{ type: "text", text: `Steering failed for async run ${status.runId} (request ${requestId}): ${reason}` }], isError: true, details: { mode: "management", results: [], steering: failed ?? { requestId, state: "failed", sourceRunId: status.runId, deliveryStatus: "queued", targets: [{ index: input.index ?? 0, state: "failed", reason }] } } };
|
|
252
|
+
return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering failed for async run ${status.runId} (request ${requestId}): ${reason}`) }], isError: true, details: { mode: "management", results: [], steering: failed ?? { requestId, state: "failed", sourceRunId: status.runId, deliveryStatus: "queued", targets: [{ index: input.index ?? 0, state: "failed", reason }] } } };
|
|
246
253
|
}
|
|
247
254
|
}
|
|
248
255
|
const stateText = result.state === "failed" ? "failed" : result.state === "partial" ? "partial" : result.deliveryStatus === "queued" ? "queued" : result.state === "delivered" ? "delivered" : result.state === "scheduled" ? "scheduled" : result.state === "recovered" ? "recovered" : "pending";
|
|
249
256
|
const isError = result.state === "failed" || result.state === "partial";
|
|
250
257
|
return {
|
|
251
|
-
content: [{ type: "text", text: `Steering ${stateText} for async run ${status.runId} (request ${requestId}).` }],
|
|
258
|
+
content: [{ type: "text", text: steeringReceipt(input.message, `Steering ${stateText} for async run ${status.runId} (request ${requestId}).`) }],
|
|
252
259
|
...(isError ? { isError: true } : {}),
|
|
253
260
|
details: { mode: "management", results: [], steering: result },
|
|
254
261
|
};
|
|
@@ -783,6 +783,7 @@ async function runSingleAttempt(
|
|
|
783
783
|
currentToolDurationMs: input.currentToolDurationMs ?? currentToolDurationMs(now),
|
|
784
784
|
currentPath: input.currentPath ?? progress.currentPath,
|
|
785
785
|
recentFailureSummary: input.recentFailureSummary,
|
|
786
|
+
taskPreview: task,
|
|
786
787
|
});
|
|
787
788
|
emitControlEvent(event);
|
|
788
789
|
return previous !== "needs_attention";
|
|
@@ -809,6 +810,7 @@ async function runSingleAttempt(
|
|
|
809
810
|
currentToolDurationMs: currentToolDurationMs(now),
|
|
810
811
|
currentPath: progress.currentPath,
|
|
811
812
|
elapsedMs: now - startTime,
|
|
813
|
+
taskPreview: task,
|
|
812
814
|
}));
|
|
813
815
|
return true;
|
|
814
816
|
};
|
|
@@ -1671,7 +1673,7 @@ async function runSyncCompletionInner(
|
|
|
1671
1673
|
agent.fallbackModels,
|
|
1672
1674
|
options.availableModels,
|
|
1673
1675
|
options.preferredModelProvider,
|
|
1674
|
-
{ scope: options.modelScope },
|
|
1676
|
+
{ scope: options.modelScope, primaryModelFromParent: options.modelOverrideFromParent },
|
|
1675
1677
|
);
|
|
1676
1678
|
const attemptedModels: string[] = [];
|
|
1677
1679
|
const modelAttempts: ModelAttempt[] = [];
|