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
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
6
|
+
import {
|
|
7
|
+
ExternalJobProviderError,
|
|
8
|
+
getExternalJobProvider,
|
|
9
|
+
validateExternalJobHandle,
|
|
10
|
+
validateExternalJobResult,
|
|
11
|
+
type ExternalJobHandle,
|
|
12
|
+
type ExternalJobOperation,
|
|
13
|
+
type ExternalJobResult,
|
|
14
|
+
type ExternalJobStartInput,
|
|
15
|
+
} from "../../api/external-job-provider.ts";
|
|
16
|
+
|
|
17
|
+
export const EXTERNAL_JOB_BRIDGE_REQUEST_DIR = "external-job-requests";
|
|
18
|
+
const EXTERNAL_JOB_BRIDGE_RESPONSE_DIR = "external-job-responses";
|
|
19
|
+
const DEFAULT_OPERATION_TIMEOUT_MS = 120_000;
|
|
20
|
+
const POLL_INTERVAL_MS = 50;
|
|
21
|
+
const MAX_REQUESTS_PER_SWEEP = 100;
|
|
22
|
+
|
|
23
|
+
interface ExternalJobBridgeRequest {
|
|
24
|
+
id: string;
|
|
25
|
+
operation: ExternalJobOperation;
|
|
26
|
+
provider: string;
|
|
27
|
+
providerJobId?: string;
|
|
28
|
+
start?: ExternalJobStartInput;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
claimedAt?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ExternalJobClaimOwner {
|
|
34
|
+
version: 1;
|
|
35
|
+
pid: number;
|
|
36
|
+
hostname: string;
|
|
37
|
+
claimedAt: number;
|
|
38
|
+
processStartIdentity?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ExternalJobBridgeResponse = {
|
|
42
|
+
id: string;
|
|
43
|
+
ok: true;
|
|
44
|
+
operation: ExternalJobOperation;
|
|
45
|
+
provider: string;
|
|
46
|
+
result: ExternalJobHandle | ExternalJobResult;
|
|
47
|
+
completedAt: number;
|
|
48
|
+
} | {
|
|
49
|
+
id: string;
|
|
50
|
+
ok: false;
|
|
51
|
+
operation: ExternalJobOperation;
|
|
52
|
+
provider: string;
|
|
53
|
+
code: string;
|
|
54
|
+
message: string;
|
|
55
|
+
blockingJobId?: string;
|
|
56
|
+
completedAt: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type ExternalJobBridgeCancel = () => ExternalJobProviderError | undefined;
|
|
60
|
+
|
|
61
|
+
const inFlight = new Set<string>();
|
|
62
|
+
|
|
63
|
+
function requestDir(asyncDir: string): string {
|
|
64
|
+
return path.join(asyncDir, EXTERNAL_JOB_BRIDGE_REQUEST_DIR);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function responseDir(asyncDir: string): string {
|
|
68
|
+
return path.join(asyncDir, EXTERNAL_JOB_BRIDGE_RESPONSE_DIR);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function requestPath(asyncDir: string, id: string): string {
|
|
72
|
+
return path.join(requestDir(asyncDir), `${id}.json`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function startClaimDir(asyncDir: string, id: string): string {
|
|
76
|
+
return path.join(requestDir(asyncDir), `${id}.claim`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function startClaimTempDir(asyncDir: string, id: string): string {
|
|
80
|
+
return path.join(requestDir(asyncDir), `${id}.claim.tmp-${randomUUID()}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function startClaimCompletedPath(claimDir: string): string {
|
|
84
|
+
return path.join(claimDir, "completed.json");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function startClaimHandlePath(claimDir: string): string {
|
|
88
|
+
return path.join(claimDir, "handle.json");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function completedStartClaimExists(asyncDir: string, id: string): boolean {
|
|
92
|
+
return fs.existsSync(startClaimCompletedPath(startClaimDir(asyncDir, id)));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function cancelStartRequest(asyncDir: string, request: ExternalJobBridgeRequest): boolean {
|
|
96
|
+
const claimDir = startClaimDir(asyncDir, request.id);
|
|
97
|
+
const tempClaimDir = startClaimTempDir(asyncDir, request.id);
|
|
98
|
+
try {
|
|
99
|
+
fs.mkdirSync(tempClaimDir);
|
|
100
|
+
writeAtomicJson(startClaimCompletedPath(tempClaimDir), { completedAt: Date.now() });
|
|
101
|
+
fs.renameSync(tempClaimDir, claimDir);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
fs.rmSync(tempClaimDir, { recursive: true, force: true });
|
|
104
|
+
if (isClaimConflictError(error)) return false;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
fs.rmSync(requestPath(asyncDir, request.id), { force: true });
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function responsePath(asyncDir: string, id: string): string {
|
|
112
|
+
return path.join(responseDir(asyncDir), `${id}.json`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sleep(ms: number): Promise<void> {
|
|
116
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readJson<T>(filePath: string): T {
|
|
120
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function processStartIdentity(pid: number): string | undefined {
|
|
124
|
+
if (process.platform === "linux") {
|
|
125
|
+
try {
|
|
126
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
127
|
+
const commandEnd = stat.lastIndexOf(")");
|
|
128
|
+
if (commandEnd === -1) return undefined;
|
|
129
|
+
const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
|
|
130
|
+
const startTicks = fields[19];
|
|
131
|
+
return startTicks ? `linux:${startTicks}` : undefined;
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function processIsAlive(pid: number): boolean | undefined {
|
|
140
|
+
try {
|
|
141
|
+
process.kill(pid, 0);
|
|
142
|
+
return true;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
145
|
+
if (code === "ESRCH") return false;
|
|
146
|
+
if (code === "EPERM") return true;
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function currentClaimOwner(): ExternalJobClaimOwner {
|
|
152
|
+
const startIdentity = processStartIdentity(process.pid);
|
|
153
|
+
return {
|
|
154
|
+
version: 1,
|
|
155
|
+
pid: process.pid,
|
|
156
|
+
hostname: os.hostname(),
|
|
157
|
+
claimedAt: Date.now(),
|
|
158
|
+
...(startIdentity ? { processStartIdentity: startIdentity } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isClaimConflictError(error: unknown): boolean {
|
|
163
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
|
164
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
165
|
+
return code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isNotFoundError(error: unknown): boolean {
|
|
169
|
+
return typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function bridgeError(error: unknown): { code: string; message: string; blockingJobId?: string } {
|
|
173
|
+
if (error instanceof ExternalJobProviderError) {
|
|
174
|
+
return { code: error.code, message: error.message, ...(error.blockingJobId ? { blockingJobId: error.blockingJobId } : {}) };
|
|
175
|
+
}
|
|
176
|
+
if (error && typeof error === "object") {
|
|
177
|
+
const record = error as Record<string, unknown>;
|
|
178
|
+
const code = typeof record.code === "string" && record.code.trim() ? record.code : "provider-error";
|
|
179
|
+
const blockingJobId = typeof record.blockingJobId === "string" && record.blockingJobId.trim() ? record.blockingJobId : undefined;
|
|
180
|
+
return {
|
|
181
|
+
code,
|
|
182
|
+
message: error instanceof Error ? error.message : String(error),
|
|
183
|
+
...(blockingJobId ? { blockingJobId } : {}),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return { code: "provider-error", message: String(error) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function assertRequest(value: unknown, filePath: string): ExternalJobBridgeRequest {
|
|
190
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`External-job bridge request '${filePath}' must be an object.`);
|
|
191
|
+
const request = value as ExternalJobBridgeRequest;
|
|
192
|
+
if (typeof request.id !== "string" || !request.id) throw new Error(`External-job bridge request '${filePath}' has invalid id.`);
|
|
193
|
+
if (request.operation !== "start" && request.operation !== "status" && request.operation !== "result" && request.operation !== "reattach") throw new Error(`External-job bridge request '${filePath}' has invalid operation.`);
|
|
194
|
+
if (typeof request.provider !== "string" || !request.provider.trim()) throw new Error(`External-job bridge request '${filePath}' has invalid provider.`);
|
|
195
|
+
if (typeof request.createdAt !== "number") throw new Error(`External-job bridge request '${filePath}' has invalid createdAt.`);
|
|
196
|
+
if (request.claimedAt !== undefined && typeof request.claimedAt !== "number") throw new Error(`External-job bridge request '${filePath}' has invalid claimedAt.`);
|
|
197
|
+
if (request.operation === "start") {
|
|
198
|
+
if (!request.start || typeof request.start !== "object" || Array.isArray(request.start)) throw new Error(`External-job bridge start request '${filePath}' is missing start input.`);
|
|
199
|
+
} else if (typeof request.providerJobId !== "string" || !request.providerJobId.trim()) {
|
|
200
|
+
throw new Error(`External-job bridge ${request.operation} request '${filePath}' is missing providerJobId.`);
|
|
201
|
+
}
|
|
202
|
+
return request;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function executeBridgeRequest(request: ExternalJobBridgeRequest, claimDir?: string): Promise<ExternalJobBridgeResponse> {
|
|
206
|
+
const provider = getExternalJobProvider(request.provider);
|
|
207
|
+
if (!provider) {
|
|
208
|
+
return {
|
|
209
|
+
id: request.id,
|
|
210
|
+
ok: false,
|
|
211
|
+
operation: request.operation,
|
|
212
|
+
provider: request.provider,
|
|
213
|
+
code: "provider-unavailable",
|
|
214
|
+
message: `External-job provider '${request.provider}' is not registered. Load the Surf Pi extension and its external-job provider bridge before starting this agent.`,
|
|
215
|
+
completedAt: Date.now(),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const raw = request.operation === "start"
|
|
220
|
+
? await provider.start(request.start!)
|
|
221
|
+
: request.operation === "status"
|
|
222
|
+
? await provider.status(request.providerJobId!)
|
|
223
|
+
: request.operation === "reattach"
|
|
224
|
+
? await provider.reattach(request.providerJobId!)
|
|
225
|
+
: await provider.result(request.providerJobId!);
|
|
226
|
+
const result = request.operation === "result"
|
|
227
|
+
? validateExternalJobResult(provider.name, raw, "External-job bridge result")
|
|
228
|
+
: validateExternalJobHandle(provider.name, raw, "External-job bridge handle");
|
|
229
|
+
if (request.operation === "start" && claimDir) writeAtomicJson(startClaimHandlePath(claimDir), result);
|
|
230
|
+
return { id: request.id, ok: true, operation: request.operation, provider: request.provider, result, completedAt: Date.now() };
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const details = bridgeError(error);
|
|
233
|
+
return {
|
|
234
|
+
id: request.id,
|
|
235
|
+
ok: false,
|
|
236
|
+
operation: request.operation,
|
|
237
|
+
provider: request.provider,
|
|
238
|
+
...details,
|
|
239
|
+
completedAt: Date.now(),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function claimStartRequest(asyncDir: string, filePath: string, request: ExternalJobBridgeRequest): { request: ExternalJobBridgeRequest; filePath: string } | undefined {
|
|
245
|
+
const owner = currentClaimOwner();
|
|
246
|
+
const claimed: ExternalJobBridgeRequest = { ...request, claimedAt: owner.claimedAt };
|
|
247
|
+
const claimDir = startClaimDir(asyncDir, request.id);
|
|
248
|
+
const tempClaimDir = startClaimTempDir(asyncDir, request.id);
|
|
249
|
+
try {
|
|
250
|
+
fs.mkdirSync(tempClaimDir);
|
|
251
|
+
writeAtomicJson(path.join(tempClaimDir, "owner.json"), owner);
|
|
252
|
+
writeAtomicJson(path.join(tempClaimDir, "request.json"), claimed);
|
|
253
|
+
fs.renameSync(tempClaimDir, claimDir);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
fs.rmSync(tempClaimDir, { recursive: true, force: true });
|
|
256
|
+
if (isClaimConflictError(error)) return undefined;
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
fs.rmSync(filePath, { force: true });
|
|
260
|
+
return { request: claimed, filePath: claimDir };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseClaimOwner(value: unknown): ExternalJobClaimOwner | undefined {
|
|
264
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
265
|
+
const owner = value as Partial<ExternalJobClaimOwner>;
|
|
266
|
+
if (owner.version !== 1 || typeof owner.pid !== "number" || !Number.isInteger(owner.pid) || owner.pid <= 0 || typeof owner.hostname !== "string" || typeof owner.claimedAt !== "number") return undefined;
|
|
267
|
+
if (owner.processStartIdentity !== undefined && typeof owner.processStartIdentity !== "string") return undefined;
|
|
268
|
+
return owner as ExternalJobClaimOwner;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function claimOwnerIsDead(owner: ExternalJobClaimOwner | undefined): boolean {
|
|
272
|
+
if (!owner || owner.hostname !== os.hostname()) return false;
|
|
273
|
+
const alive = processIsAlive(owner.pid);
|
|
274
|
+
if (alive === false) return true;
|
|
275
|
+
if (alive !== true || !owner.processStartIdentity) return false;
|
|
276
|
+
const currentIdentity = processStartIdentity(owner.pid);
|
|
277
|
+
return currentIdentity !== undefined && currentIdentity !== owner.processStartIdentity;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function readClaimOwner(claimDir: string): ExternalJobClaimOwner | undefined {
|
|
281
|
+
try {
|
|
282
|
+
return parseClaimOwner(readJson(path.join(claimDir, "owner.json")));
|
|
283
|
+
} catch {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function readClaimHandle(provider: string, claimDir: string): ExternalJobHandle | undefined {
|
|
289
|
+
try {
|
|
290
|
+
return validateExternalJobHandle(provider, readJson(startClaimHandlePath(claimDir)), "External-job bridge recovered start handle");
|
|
291
|
+
} catch {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function serviceExternalJobBridgeRequests(asyncDir: string): void {
|
|
297
|
+
let files: string[];
|
|
298
|
+
try {
|
|
299
|
+
files = fs.readdirSync(requestDir(asyncDir), { withFileTypes: true })
|
|
300
|
+
.filter((entry) => (entry.isFile() && entry.name.endsWith(".json") && !completedStartClaimExists(asyncDir, entry.name.replace(/\.json$/, ""))) || (entry.isDirectory() && entry.name.endsWith(".claim") && !fs.existsSync(startClaimCompletedPath(path.join(requestDir(asyncDir), entry.name)))))
|
|
301
|
+
.map((entry) => entry.name)
|
|
302
|
+
.slice(0, MAX_REQUESTS_PER_SWEEP);
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
fs.mkdirSync(responseDir(asyncDir), { recursive: true });
|
|
308
|
+
for (const file of files) {
|
|
309
|
+
serviceExternalJobBridgeRequestFile(asyncDir, file);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function serviceExternalJobBridgeRequestFile(asyncDir: string, file: string): void {
|
|
314
|
+
fs.mkdirSync(responseDir(asyncDir), { recursive: true });
|
|
315
|
+
if (file.endsWith(".claim")) {
|
|
316
|
+
serviceExternalJobStartClaim(asyncDir, file);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const filePath = path.join(requestDir(asyncDir), file);
|
|
320
|
+
let request: ExternalJobBridgeRequest;
|
|
321
|
+
try {
|
|
322
|
+
request = assertRequest(readJson(filePath), filePath);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (isNotFoundError(error)) return;
|
|
325
|
+
const id = file.replace(/\.json$/, "");
|
|
326
|
+
writeAtomicJson(responsePath(asyncDir, id), {
|
|
327
|
+
id,
|
|
328
|
+
ok: false,
|
|
329
|
+
operation: "status",
|
|
330
|
+
provider: "unknown",
|
|
331
|
+
code: "malformed-request",
|
|
332
|
+
message: error instanceof Error ? error.message : String(error),
|
|
333
|
+
completedAt: Date.now(),
|
|
334
|
+
} satisfies ExternalJobBridgeResponse);
|
|
335
|
+
fs.rmSync(filePath, { force: true });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (request.operation === "start" && completedStartClaimExists(asyncDir, request.id)) {
|
|
339
|
+
fs.rmSync(filePath, { force: true });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (inFlight.has(request.id) || fs.existsSync(responsePath(asyncDir, request.id))) return;
|
|
343
|
+
if (request.operation === "start" && request.claimedAt !== undefined) return;
|
|
344
|
+
const claimed = request.operation === "start" ? claimStartRequest(asyncDir, filePath, request) : { request, filePath };
|
|
345
|
+
if (!claimed) return;
|
|
346
|
+
const claimedRequest = claimed.request;
|
|
347
|
+
inFlight.add(claimedRequest.id);
|
|
348
|
+
void executeBridgeRequest(claimedRequest, claimedRequest.operation === "start" ? claimed.filePath : undefined).then((response) => {
|
|
349
|
+
writeAtomicJson(responsePath(asyncDir, claimedRequest.id), response);
|
|
350
|
+
if (claimedRequest.operation === "start") {
|
|
351
|
+
writeAtomicJson(startClaimCompletedPath(claimed.filePath), { completedAt: Date.now() });
|
|
352
|
+
} else {
|
|
353
|
+
fs.rmSync(claimed.filePath, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
}).catch((error) => {
|
|
356
|
+
writeAtomicJson(responsePath(asyncDir, claimedRequest.id), {
|
|
357
|
+
id: claimedRequest.id,
|
|
358
|
+
ok: false,
|
|
359
|
+
operation: claimedRequest.operation,
|
|
360
|
+
provider: claimedRequest.provider,
|
|
361
|
+
code: "bridge-error",
|
|
362
|
+
message: error instanceof Error ? error.message : String(error),
|
|
363
|
+
completedAt: Date.now(),
|
|
364
|
+
} satisfies ExternalJobBridgeResponse);
|
|
365
|
+
if (claimedRequest.operation === "start") writeAtomicJson(startClaimCompletedPath(claimed.filePath), { completedAt: Date.now() });
|
|
366
|
+
}).finally(() => {
|
|
367
|
+
inFlight.delete(claimedRequest.id);
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function serviceExternalJobStartClaim(asyncDir: string, file: string): void {
|
|
372
|
+
const claimDir = path.join(requestDir(asyncDir), file);
|
|
373
|
+
if (fs.existsSync(startClaimCompletedPath(claimDir))) return;
|
|
374
|
+
const owner = readClaimOwner(claimDir);
|
|
375
|
+
if (!owner && fs.existsSync(requestPath(asyncDir, file.replace(/\.claim$/, ""))) && !fs.existsSync(responsePath(asyncDir, file.replace(/\.claim$/, "")))) {
|
|
376
|
+
fs.rmSync(claimDir, { recursive: true, force: true });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (!claimOwnerIsDead(owner)) return;
|
|
380
|
+
let request: ExternalJobBridgeRequest;
|
|
381
|
+
try {
|
|
382
|
+
request = assertRequest(readJson(path.join(claimDir, "request.json")), path.join(claimDir, "request.json"));
|
|
383
|
+
} catch {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (request.operation !== "start" || fs.existsSync(responsePath(asyncDir, request.id))) return;
|
|
387
|
+
const handle = readClaimHandle(request.provider, claimDir);
|
|
388
|
+
if (handle) {
|
|
389
|
+
writeAtomicJson(responsePath(asyncDir, request.id), {
|
|
390
|
+
id: request.id,
|
|
391
|
+
ok: true,
|
|
392
|
+
operation: request.operation,
|
|
393
|
+
provider: request.provider,
|
|
394
|
+
result: handle,
|
|
395
|
+
completedAt: Date.now(),
|
|
396
|
+
} satisfies ExternalJobBridgeResponse);
|
|
397
|
+
writeAtomicJson(startClaimCompletedPath(claimDir), { completedAt: Date.now() });
|
|
398
|
+
fs.rmSync(requestPath(asyncDir, request.id), { force: true });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
writeAtomicJson(responsePath(asyncDir, request.id), {
|
|
402
|
+
id: request.id,
|
|
403
|
+
ok: false,
|
|
404
|
+
operation: request.operation,
|
|
405
|
+
provider: request.provider,
|
|
406
|
+
code: "start-dispatch-abandoned",
|
|
407
|
+
message: `External-job start for provider '${request.provider}' was claimed by a host process that is no longer alive before a provider job id was committed. Refusing to redispatch the prompt automatically.`,
|
|
408
|
+
completedAt: Date.now(),
|
|
409
|
+
} satisfies ExternalJobBridgeResponse);
|
|
410
|
+
fs.rmSync(claimDir, { recursive: true, force: true });
|
|
411
|
+
fs.rmSync(requestPath(asyncDir, request.id), { force: true });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function requestExternalJobOperation<T extends ExternalJobHandle | ExternalJobResult>(asyncDir: string, request: Omit<ExternalJobBridgeRequest, "id" | "createdAt">, timeoutMs = DEFAULT_OPERATION_TIMEOUT_MS, cancel?: ExternalJobBridgeCancel): Promise<T> {
|
|
415
|
+
const id = randomUUID();
|
|
416
|
+
fs.mkdirSync(requestDir(asyncDir), { recursive: true });
|
|
417
|
+
fs.mkdirSync(responseDir(asyncDir), { recursive: true });
|
|
418
|
+
const bridgeRequest = { ...request, id, createdAt: Date.now() } satisfies ExternalJobBridgeRequest;
|
|
419
|
+
writeAtomicJson(requestPath(asyncDir, id), bridgeRequest);
|
|
420
|
+
const deadline = request.operation === "start" ? undefined : Date.now() + timeoutMs;
|
|
421
|
+
const outPath = responsePath(asyncDir, id);
|
|
422
|
+
while (!fs.existsSync(outPath)) {
|
|
423
|
+
const canceled = cancel?.();
|
|
424
|
+
if (canceled) {
|
|
425
|
+
if (request.operation === "start" && !cancelStartRequest(asyncDir, bridgeRequest)) {
|
|
426
|
+
await sleep(POLL_INTERVAL_MS);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (request.operation !== "start") fs.rmSync(requestPath(asyncDir, id), { force: true });
|
|
430
|
+
throw canceled;
|
|
431
|
+
}
|
|
432
|
+
if (deadline !== undefined && Date.now() >= deadline) {
|
|
433
|
+
throw new ExternalJobProviderError(
|
|
434
|
+
`External-job provider bridge did not respond to ${request.operation} for provider '${request.provider}' within ${timeoutMs}ms.`,
|
|
435
|
+
{ code: "bridge-timeout" },
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
await sleep(POLL_INTERVAL_MS);
|
|
439
|
+
}
|
|
440
|
+
const response = readJson<ExternalJobBridgeResponse>(outPath);
|
|
441
|
+
fs.rmSync(outPath, { force: true });
|
|
442
|
+
if (!response.ok) throw new ExternalJobProviderError(response.message, { code: response.code, ...(response.blockingJobId ? { blockingJobId: response.blockingJobId } : {}) });
|
|
443
|
+
return response.result as T;
|
|
444
|
+
}
|