pi-subagents 0.49.0 → 0.51.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 +97 -0
- package/agents/gpt-pro.md +17 -0
- package/agents/oracle.md +7 -5
- package/agents/researcher.md +1 -1
- package/agents/reviewer.md +2 -2
- package/agents/scout.md +1 -1
- package/agents/worker.md +1 -1
- package/async-retention-discovery-worker.mjs +180 -0
- package/docs/agents.md +37 -2
- package/docs/configuration.md +76 -14
- package/docs/extension-api.md +78 -1
- package/docs/missions.md +1 -1
- package/docs/observability.md +20 -4
- package/docs/tool-reference.md +55 -39
- package/docs/workflows.md +171 -5
- package/package.json +4 -2
- package/skills/pi-subagents/SKILL.md +5 -4
- package/skills/pi-subagents/references/constraints-and-recipes.md +9 -6
- package/skills/pi-subagents/references/execution-controls.md +22 -18
- package/skills/pi-subagents/references/management-authoring-rpc.md +5 -5
- package/skills/pi-subagents/references/prompting-and-roles.md +33 -17
- package/src/agents/agent-management.ts +100 -345
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +135 -25
- package/src/api/external-job-provider.ts +185 -0
- package/src/api/external-runs.ts +174 -84
- package/src/api/preflight.ts +42 -11
- package/src/api/shared-types.ts +2 -0
- package/src/extension/config.ts +36 -3
- package/src/extension/doctor.ts +3 -6
- package/src/extension/fanout-child.ts +2 -2
- package/src/extension/index.ts +210 -90
- package/src/extension/public-execution.ts +31 -2
- package/src/extension/rpc.ts +5 -1
- package/src/extension/schemas.ts +14 -36
- package/src/extension/tool-description.ts +37 -24
- package/src/inspectors/herdr/actions.ts +5 -9
- package/src/inspectors/herdr/inspector-runner.ts +2 -1
- package/src/inspectors/herdr/project-panes.ts +4 -8
- package/src/inspectors/herdr/shell-command.ts +16 -0
- package/src/intercom/intercom-bridge.ts +2 -3
- package/src/intercom/native-supervisor-channel.ts +49 -51
- package/src/missions/goal-driver.ts +3 -1
- package/src/missions/lifecycle.ts +6 -1
- package/src/missions/store.ts +4 -9
- package/src/profiles/profiles.ts +3 -1
- package/src/runs/background/active-run-index.ts +94 -1
- package/src/runs/background/async-execution.ts +98 -38
- package/src/runs/background/async-job-tracker.ts +21 -4
- package/src/runs/background/async-resume.ts +30 -17
- package/src/runs/background/async-retention.ts +888 -0
- package/src/runs/background/async-status-snapshot.ts +277 -0
- package/src/runs/background/async-status.ts +47 -56
- package/src/runs/background/chain-append.ts +3 -33
- package/src/runs/background/chain-root-attachment.ts +2 -2
- package/src/runs/background/completion-replay.ts +11 -1
- package/src/runs/background/control-channel.ts +14 -68
- package/src/runs/background/fleet-view.ts +3 -1
- package/src/runs/background/index-segment.ts +59 -0
- package/src/runs/background/notify.ts +3 -1
- package/src/runs/background/result-files.ts +505 -0
- package/src/runs/background/result-watcher.ts +250 -51
- package/src/runs/background/retained-children.ts +79 -20
- package/src/runs/background/run-id-query.ts +7 -0
- package/src/runs/background/run-id-resolver.ts +37 -29
- package/src/runs/background/run-status.ts +34 -20
- package/src/runs/background/scheduled-runs.ts +71 -27
- package/src/runs/background/stale-run-reconciler.ts +33 -16
- package/src/runs/background/steering.ts +11 -1
- package/src/runs/background/subagent-runner.ts +535 -161
- package/src/runs/background/subagent-wait.ts +9 -7
- package/src/runs/background/terminal-run-index.ts +129 -0
- package/src/runs/background/wait-completions.ts +22 -2
- package/src/runs/background/wait-subscriptions.ts +80 -1
- package/src/runs/foreground/async-dismiss-action.ts +2 -1
- package/src/runs/foreground/async-steering-action.ts +21 -14
- package/src/runs/foreground/execution.ts +221 -15
- package/src/runs/foreground/subagent-executor.ts +529 -1519
- package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
- package/src/runs/shared/chain-outputs.ts +1 -3
- package/src/runs/shared/completion-guard.ts +96 -6
- package/src/runs/shared/external-cli-runner.ts +4 -0
- package/src/runs/shared/external-job-bridge.ts +450 -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 +34 -3
- package/src/runs/shared/nested-events.ts +66 -62
- package/src/runs/shared/orca-progress-tabs.ts +437 -0
- package/src/runs/shared/parallel-handoff.ts +46 -4
- package/src/runs/shared/parallel-utils.ts +6 -15
- package/src/runs/shared/permissions.ts +5 -1
- package/src/runs/shared/pi-args.ts +8 -1
- package/src/runs/shared/subagent-control.ts +41 -4
- package/src/runs/shared/subagent-prompt-runtime.ts +13 -15
- package/src/runs/shared/subagent-startup-retry.ts +12 -0
- package/src/runs/shared/tool-timeout.ts +93 -0
- package/src/runs/shared/workflow-graph.ts +1 -23
- package/src/runs/shared/worktree.ts +12 -1
- 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 +88 -27
- package/src/shared/utils.ts +8 -0
- package/src/shared/watch-strategy.ts +10 -0
- package/src/slash/slash-commands.ts +45 -28
- package/src/slash/slash-live-state.ts +3 -0
- package/src/tui/fleet-status.ts +160 -45
- package/src/tui/fleet.ts +186 -28
- package/src/tui/render.ts +41 -10
- package/src/workflows/chat-progress.ts +7 -5
- package/src/workflows/scripted-workflow.ts +424 -125
- package/src/runs/foreground/chain-clarify.ts +0 -1354
- package/src/runs/foreground/chain-execution.ts +0 -1565
|
@@ -267,6 +267,7 @@ function activeRunsForSession(params: SubagentWaitParams, deps: SubagentWaitDeps
|
|
|
267
267
|
resultsDir,
|
|
268
268
|
kill: deps.kill,
|
|
269
269
|
now: deps.now,
|
|
270
|
+
includeNested: false,
|
|
270
271
|
...(params.id ? { runId: params.id } : {}),
|
|
271
272
|
});
|
|
272
273
|
return params.id ? runs.filter((run) => matchesId(run, params.id!)) : runs;
|
|
@@ -277,18 +278,19 @@ function attentionRunsForSession(params: SubagentWaitParams, deps: SubagentWaitD
|
|
|
277
278
|
return activeRunsForSession(params, deps).filter((run) => needsAttention(run) && initialIds.has(run.id));
|
|
278
279
|
}
|
|
279
280
|
|
|
280
|
-
/**
|
|
281
|
-
function
|
|
281
|
+
/** Exact initial runs in any state, for the final summary. */
|
|
282
|
+
function runsForIds(runIds: Iterable<string>, deps: SubagentWaitDeps): AsyncRunSummary[] {
|
|
282
283
|
const asyncDirRoot = deps.asyncDirRoot ?? DIRS.async;
|
|
283
284
|
const resultsDir = deps.resultsDir ?? DIRS.results;
|
|
284
|
-
|
|
285
|
+
return [...runIds].flatMap((runId) => listAsyncRuns(asyncDirRoot, {
|
|
285
286
|
sessionId: deps.state.currentSessionId ?? undefined,
|
|
286
287
|
resultsDir,
|
|
287
288
|
kill: deps.kill,
|
|
288
289
|
now: deps.now,
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
290
|
+
includeNested: false,
|
|
291
|
+
runId,
|
|
292
|
+
exactRunId: true,
|
|
293
|
+
}));
|
|
292
294
|
}
|
|
293
295
|
|
|
294
296
|
function summarizeTerminalRuns(runs: AsyncRunSummary[], providerFinishedCount = 0): string {
|
|
@@ -609,7 +611,7 @@ export async function waitForSubagents(
|
|
|
609
611
|
const activeProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
|
|
610
612
|
const providerFinishedCount = [...initialProviderIds].filter((id) => !activeProviderIds.has(id)).length;
|
|
611
613
|
try {
|
|
612
|
-
const allNow =
|
|
614
|
+
const allNow = runsForIds(initialAsyncIds, deps);
|
|
613
615
|
const terminal = allNow.filter((run) => !ACTIVE_STATES.includes(run.state) && initialAsyncIds.has(run.id));
|
|
614
616
|
finishedAsyncCount = terminal.length;
|
|
615
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
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import * as path from "node:path";
|
|
3
2
|
import type { ArtifactPaths, SubagentState, WaitCompletion, WaitCompletionChild } from "../../shared/types.ts";
|
|
4
3
|
import type { AsyncRunSummary } from "./async-status.ts";
|
|
5
4
|
import { readCompletionReplay, writeCompletionReplay } from "./completion-replay.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
|
}
|
|
@@ -114,7 +119,22 @@ export function collectWaitCompletions(terminal: AsyncRunSummary[], state: Subag
|
|
|
114
119
|
completions.push(recorded.completion);
|
|
115
120
|
continue;
|
|
116
121
|
}
|
|
117
|
-
const
|
|
122
|
+
const publicResultPath = resultFilePath(resultsDir, run.id);
|
|
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
|
+
}
|
|
118
138
|
try {
|
|
119
139
|
const raw = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as Record<string, unknown>;
|
|
120
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 {
|
|
@@ -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 {
|
|
@@ -6,6 +6,7 @@ import { DIRS, type Details, type SubagentState } from "../../shared/types.ts";
|
|
|
6
6
|
import { readStatus } from "../../shared/utils.ts";
|
|
7
7
|
import { updateActiveRunIndex } from "../background/active-run-index.ts";
|
|
8
8
|
import { reconcileAsyncRun } from "../background/stale-run-reconciler.ts";
|
|
9
|
+
import { resultFilePath } from "../background/result-files.ts";
|
|
9
10
|
|
|
10
11
|
export function dismissRecoveredWorkflow(
|
|
11
12
|
state: SubagentState,
|
|
@@ -50,7 +51,7 @@ export function dismissRecoveredWorkflow(
|
|
|
50
51
|
};
|
|
51
52
|
}
|
|
52
53
|
let latestStatus = status;
|
|
53
|
-
const resultPath =
|
|
54
|
+
const resultPath = resultFilePath(DIRS.results, status.runId);
|
|
54
55
|
if (fs.existsSync(resultPath)) {
|
|
55
56
|
const reconciled = reconcileAsyncRun(asyncDir).status;
|
|
56
57
|
if (reconciled && reconciled.state !== "running") {
|
|
@@ -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
|
};
|