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
|
@@ -4,6 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
5
5
|
import type { Details, ForegroundRunControl, SubagentState } from "../../shared/types.ts";
|
|
6
6
|
import { readStatus } from "../../shared/utils.ts";
|
|
7
|
+
import { steeringReceipt } from "../background/steering.ts";
|
|
7
8
|
import {
|
|
8
9
|
consumeSteerAckFromDir,
|
|
9
10
|
readSteerCapability,
|
|
@@ -146,18 +147,18 @@ export async function steerWorkflowForegroundTarget(input: {
|
|
|
146
147
|
targets: [target],
|
|
147
148
|
};
|
|
148
149
|
if (input.signal?.aborted) {
|
|
149
|
-
return { content: [{ type: "text", text: `Steering pending for foreground run ${control.runId} (request ${request.id}); caller aborted before acknowledgment.` }], details: { mode: "management", results: [], steering } };
|
|
150
|
+
return { content: [{ type: "text", text: steeringReceipt(request.message, `Steering pending for foreground run ${control.runId} (request ${request.id}); caller aborted before acknowledgment.`) }], details: { mode: "management", results: [], steering } };
|
|
150
151
|
}
|
|
151
152
|
if (ack?.state === "delivered") {
|
|
152
|
-
return { content: [{ type: "text", text: `Steering delivered for foreground run ${control.runId} (request ${request.id}).` }], details: { mode: "management", results: [], steering } };
|
|
153
|
+
return { content: [{ type: "text", text: steeringReceipt(request.message, `Steering delivered for foreground run ${control.runId} (request ${request.id}).`) }], details: { mode: "management", results: [], steering } };
|
|
153
154
|
}
|
|
154
155
|
if (ack?.state === "queued") {
|
|
155
|
-
return { content: [{ type: "text", text: `Steering queued for foreground run ${control.runId} (request ${request.id}).` }], details: { mode: "management", results: [], steering } };
|
|
156
|
+
return { content: [{ type: "text", text: steeringReceipt(request.message, `Steering queued for foreground run ${control.runId} (request ${request.id}).`) }], details: { mode: "management", results: [], steering } };
|
|
156
157
|
}
|
|
157
158
|
if (ack?.state === "failed") {
|
|
158
|
-
return { content: [{ type: "text", text: `Steering failed for foreground run ${control.runId} (request ${request.id}): ${ack.message}` }], isError: true, details: { mode: "management", results: [], steering } };
|
|
159
|
+
return { content: [{ type: "text", text: steeringReceipt(request.message, `Steering failed for foreground run ${control.runId} (request ${request.id}): ${ack.message}`) }], isError: true, details: { mode: "management", results: [], steering } };
|
|
159
160
|
}
|
|
160
|
-
return { content: [{ type: "text", text: `Steering pending for foreground run ${control.runId} (request ${request.id}); no acknowledgment was received.` }], details: { mode: "management", results: [], steering } };
|
|
161
|
+
return { content: [{ type: "text", text: steeringReceipt(request.message, `Steering pending for foreground run ${control.runId} (request ${request.id}); no acknowledgment was received.`) }], details: { mode: "management", results: [], steering } };
|
|
161
162
|
}
|
|
162
163
|
|
|
163
164
|
export function workflowForegroundSteeringDir(asyncDirRoot: string, workflowRunId: string, childRunId: string): string {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isDynamicParallelStep, isParallelStep, type ChainStep, type SequentialStep } from "../../shared/settings.ts";
|
|
2
2
|
import type { ChainOutputMap, ChainOutputMapEntry, SingleResult } from "../../shared/types.ts";
|
|
3
3
|
import { getSingleResultOutput } from "../../shared/utils.ts";
|
|
4
4
|
import { DynamicFanoutError, hasDynamicFanoutFields, type DynamicFanoutConfig, validateDynamicStepShape } from "./dynamic-fanout.ts";
|
|
@@ -14,7 +14,6 @@ export interface ChainOutputValidationContext {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
function outputNamesForStep(step: ChainStep): string[] {
|
|
17
|
-
if (isCheckpointStep(step)) return [];
|
|
18
17
|
if (isParallelStep(step)) return step.parallel.map((task) => task.as).filter((name): name is string => Boolean(name));
|
|
19
18
|
if (isDynamicParallelStep(step)) return [step.collect.as];
|
|
20
19
|
const name = (step as SequentialStep).as;
|
|
@@ -22,7 +21,6 @@ function outputNamesForStep(step: ChainStep): string[] {
|
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
function taskTemplatesForStep(step: ChainStep): string[] {
|
|
25
|
-
if (isCheckpointStep(step)) return [];
|
|
26
24
|
if (isParallelStep(step)) return step.parallel.map((task) => task.task ?? "{previous}");
|
|
27
25
|
if (isDynamicParallelStep(step)) return [step.parallel.task ?? "{previous}", step.parallel.label ?? ""].filter(Boolean);
|
|
28
26
|
return [(step as SequentialStep).task ?? "{previous}"];
|
|
@@ -22,9 +22,22 @@ const READ_ONLY_BUILTIN_TOOLS = new Set([
|
|
|
22
22
|
const CURSOR_FILE_MUTATION_THINKING =
|
|
23
23
|
/(?:^|\n)\s*Cursor (?:edit|write)\s*:/i;
|
|
24
24
|
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
25
|
+
const REVIVED_TASK_PREFIX = /^You are reviving a previous subagent conversation\.\n\nOriginal run: .+\nOriginal agent: .+(?:\nOriginal session file: .+)?\n\nUse the stored session context as background\. Answer the orchestrator's follow-up below\. Do not assume the original child process is still alive\.\n\nFollow-up:\n/;
|
|
26
|
+
const IMPLEMENTATION_CHALLENGE_FOLLOW_UP_PATTERN = /^(?:Run )?implementation challenge pass (?:one|two|\d+)(?:\s+and implement any better current[- ]scope change\.?|\s+for the accepted candidate\.\s+Reconsider it and implement any better current[- ]scope change\.?)?$/i;
|
|
27
|
+
const NO_BETTER_CHANGE_NEEDED_PATTERN = /^\s*no (?:better|further|additional) (?:current[- ]scope )?(?:code |source |file )?(?:change|changes|edit|edits|patch|patches) (?:is|are) needed\b/i;
|
|
28
|
+
const KEPT_CURRENT_IMPLEMENTATION_PATTERN = /\b(?:kept (?:the )?current (?:implementation|candidate|shape)|(?:the )?current (?:implementation|candidate|shape) was kept)\b/i;
|
|
29
|
+
const NO_CHANGE_MADE_PATTERN = /\bno (?:new )?(?:(?:code|source|file|test)(?:\s*(?:,\s*or|,|\/|or)\s*(?:code|source|file|test))* changes?|changes?) (?:were|was) made\b/i;
|
|
30
|
+
const NO_BETTER_CHANGE_QUALIFIER_PATTERN = /\b(?:do\s+not|don't|dont|not|never|cannot|can't|cant|unable|uncertain|unsure|unclear|disagree|wrong|false|reject|rejected|rejecting|maybe|might|may|\w+n['’]t)\b/i;
|
|
31
|
+
const REPORT_SENTENCE_PATTERN = /[^.!?]+(?:[.!?]+(?=(?:["”'’]?\s)|["”'’]?$)|$)/g;
|
|
32
|
+
const QUOTED_CLAIM_PATTERN = /["“]([^"”]+)["”]([.!?]?\s*[^.!?]*)/g;
|
|
33
|
+
const QUOTED_CLAIM_REFERENCE_PATTERN = /\b(?:that|this|it|claim|report|message|disagree)\b/i;
|
|
34
|
+
const CLAIM_CONTRADICTION_PATTERN = /\bbut\b[^.!?]*\b(?:uncertain|unsure|unclear|disagree|wrong|false|reject|rejected|rejecting)\b/i;
|
|
35
|
+
const IMPLEMENTATION_RETRACTION_PATTERN = /\b(?:found|identified)\s+(?:(?:a|an|the)\s+)?(?:required|necessary|additional|better)?\s*(?:(?:code|source|file|test)\s+)?(?:changes?|edits?|patches?)\b|\b(?:(?:implementation|code|source|file|test)\s+)?work\s+(?:remains?|is\s+(?:needed|required))\b|\b(?:(?:a|an|the)\s+)?(?:(?:code|source|file|test)\s+)?(?:changes?|edits?|patches?)\s+(?:are|is)\s+(?:needed|required)\b|\b(?:required|necessary|additional|better)\s+(?:(?:code|source|file|test)\s+)?(?:changes?|edits?|patches?)\b|\b(?:I|we)?\s*need\s+(?:(?:code|source|file|test)\s+)?(?:changes?|edits?|patches?)\b|\b(?:need|needs|require|requires)\s+(?:to\s+)?(?:implement|make|apply)\b/i;
|
|
36
|
+
const IMPLEMENTATION_RETRACTION_GLOBAL_PATTERN = new RegExp(IMPLEMENTATION_RETRACTION_PATTERN.source, "gi");
|
|
37
|
+
const DIRECT_CLAIM_RETRACTION_PATTERN = /^\s*(?:I\s+)?(?:disagree|reject|retract)\b|\b(?:disagree|retract|retracted|retracting|reject|rejected|rejecting)\b[^.!?]*\b(?:that|this|it|claim|report|message)\b|\b(?:that|this|it|claim|report|message)\b[^.!?]*\b(?:is|was)\s+(?:rejected|retracted)\b/i;
|
|
38
|
+
const NEGATED_RETRACTION_PREFIX_PATTERN = /\b(?:no|not|\w+n['’]t)\b(?:\s+\w+){0,2}\s*$/i;
|
|
39
|
+
const REPORTED_CLAIM_PREFIX_PATTERN = /["“]|\b(?:prior|previous)\s+(?:message|report)\b|\b(?:said|stated|reported)\b/i;
|
|
40
|
+
const POST_CLAIM_SEPARATOR_PATTERN = /^[\s,;:.—–'"“‘’-]+/;
|
|
28
41
|
|
|
29
42
|
interface CompletionMutationGuardInput {
|
|
30
43
|
agent: string;
|
|
@@ -40,6 +53,11 @@ interface CompletionMutationGuardResult {
|
|
|
40
53
|
triggered: boolean;
|
|
41
54
|
}
|
|
42
55
|
|
|
56
|
+
interface ReportSentence {
|
|
57
|
+
text: string;
|
|
58
|
+
offset: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
export function hasMutationToolCapability(tools: string[] | undefined, mcpDirectTools: string[] | undefined): boolean {
|
|
44
62
|
if ((mcpDirectTools?.length ?? 0) > 0) return true;
|
|
45
63
|
if (tools === undefined) return true;
|
|
@@ -86,14 +104,86 @@ export function hasMutationToolCall(messages: Message[]): boolean {
|
|
|
86
104
|
return false;
|
|
87
105
|
}
|
|
88
106
|
|
|
107
|
+
function hasImplementationRetraction(text: string): boolean {
|
|
108
|
+
return Array.from(text.matchAll(IMPLEMENTATION_RETRACTION_GLOBAL_PATTERN)).some((retraction) =>
|
|
109
|
+
!NEGATED_RETRACTION_PREFIX_PATTERN.test(text.slice(0, retraction.index!)));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function hasDirectClaimRetraction(text: string): boolean {
|
|
113
|
+
return DIRECT_CLAIM_RETRACTION_PATTERN.test(text.replace(POST_CLAIM_SEPARATOR_PATTERN, ""));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isInsideQuotedText(report: string, index: number): boolean {
|
|
117
|
+
let quote: "double" | "single" | undefined;
|
|
118
|
+
for (const char of report.slice(0, index)) {
|
|
119
|
+
if (quote === "double") {
|
|
120
|
+
if (char === "\"" || char === "”") quote = undefined;
|
|
121
|
+
} else if (quote === "single") {
|
|
122
|
+
if (char === "’") quote = undefined;
|
|
123
|
+
} else if (char === "\"" || char === "“") {
|
|
124
|
+
quote = "double";
|
|
125
|
+
} else if (char === "‘") {
|
|
126
|
+
quote = "single";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return quote !== undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function unqualifiedClaimIndex(report: string, sentences: ReportSentence[], pattern: RegExp): number {
|
|
133
|
+
return sentences.findIndex((sentence) => {
|
|
134
|
+
const claim = sentence.text.match(pattern);
|
|
135
|
+
if (claim === null) return false;
|
|
136
|
+
const before = sentence.text.slice(0, claim.index!);
|
|
137
|
+
const after = sentence.text.slice(claim.index! + claim[0].length);
|
|
138
|
+
const previous = before.trimEnd().at(-1);
|
|
139
|
+
return previous !== "'"
|
|
140
|
+
&& previous !== "‘"
|
|
141
|
+
&& !isInsideQuotedText(report, sentence.offset + claim.index!)
|
|
142
|
+
&& !REPORTED_CLAIM_PREFIX_PATTERN.test(before)
|
|
143
|
+
&& !NO_BETTER_CHANGE_QUALIFIER_PATTERN.test(before)
|
|
144
|
+
&& !CLAIM_CONTRADICTION_PATTERN.test(after)
|
|
145
|
+
&& !hasImplementationRetraction(after)
|
|
146
|
+
&& !hasDirectClaimRetraction(after);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hasLaterClaimRetraction(sentences: ReportSentence[], claimIndex: number): boolean {
|
|
151
|
+
return sentences.slice(claimIndex + 1).some((sentence) => hasImplementationRetraction(sentence.text)
|
|
152
|
+
|| hasDirectClaimRetraction(sentence.text));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function explicitlyRejectsQuotedClaim(report: string): boolean {
|
|
156
|
+
for (const [, claim, response] of report.matchAll(QUOTED_CLAIM_PATTERN)) {
|
|
157
|
+
if ((KEPT_CURRENT_IMPLEMENTATION_PATTERN.test(claim!) || NO_CHANGE_MADE_PATTERN.test(claim!))
|
|
158
|
+
&& NO_BETTER_CHANGE_QUALIFIER_PATTERN.test(response!)
|
|
159
|
+
&& QUOTED_CLAIM_REFERENCE_PATTERN.test(response!)) return true;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isImplementationChallengeTask(task: string): boolean {
|
|
165
|
+
const followUp = task.replace(REVIVED_TASK_PREFIX, "");
|
|
166
|
+
return followUp !== task && IMPLEMENTATION_CHALLENGE_FOLLOW_UP_PATTERN.test(followUp.trim());
|
|
167
|
+
}
|
|
168
|
+
|
|
89
169
|
function reportsNoBetterChallengeChange(messages: Message[]): boolean {
|
|
90
170
|
const report = messages
|
|
91
171
|
.filter((message) => message.role === "assistant")
|
|
92
172
|
.flatMap((message) => message.content)
|
|
93
173
|
.flatMap((part) => part.type === "text" ? [part.text] : [])
|
|
94
174
|
.join("\n");
|
|
95
|
-
|
|
96
|
-
|
|
175
|
+
if (explicitlyRejectsQuotedClaim(report)) return false;
|
|
176
|
+
const sentences = Array.from(report.matchAll(REPORT_SENTENCE_PATTERN), (match) => ({
|
|
177
|
+
text: match[0],
|
|
178
|
+
offset: match.index!,
|
|
179
|
+
}));
|
|
180
|
+
const noBetterIndex = unqualifiedClaimIndex(report, sentences, NO_BETTER_CHANGE_NEEDED_PATTERN);
|
|
181
|
+
if (noBetterIndex >= 0) return !hasLaterClaimRetraction(sentences, noBetterIndex);
|
|
182
|
+
const keptIndex = unqualifiedClaimIndex(report, sentences, KEPT_CURRENT_IMPLEMENTATION_PATTERN);
|
|
183
|
+
const noChangeIndex = unqualifiedClaimIndex(report, sentences, NO_CHANGE_MADE_PATTERN);
|
|
184
|
+
return keptIndex >= 0
|
|
185
|
+
&& noChangeIndex >= 0
|
|
186
|
+
&& !hasLaterClaimRetraction(sentences, Math.min(keptIndex, noChangeIndex));
|
|
97
187
|
}
|
|
98
188
|
|
|
99
189
|
export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
|
|
@@ -101,7 +191,7 @@ export function evaluateCompletionMutationGuard(input: CompletionMutationGuardIn
|
|
|
101
191
|
? expectsImplementationMutation(input.agent, input.task)
|
|
102
192
|
: false;
|
|
103
193
|
const attemptedMutation = hasMutationToolCall(input.messages);
|
|
104
|
-
const noEditChallengeComplete =
|
|
194
|
+
const noEditChallengeComplete = isImplementationChallengeTask(input.task)
|
|
105
195
|
&& reportsNoBetterChallengeChange(input.messages);
|
|
106
196
|
return {
|
|
107
197
|
expectedMutation,
|
|
@@ -35,6 +35,8 @@ export function runExternalCli(input: {
|
|
|
35
35
|
timeoutMessage?: string;
|
|
36
36
|
stopMessage?: string;
|
|
37
37
|
onProcess?: (process: ExternalProcessStatus) => void;
|
|
38
|
+
onStdout?: (chunk: Buffer) => void;
|
|
39
|
+
onStderr?: (chunk: Buffer) => void;
|
|
38
40
|
}): Promise<ExternalCliRunResult> {
|
|
39
41
|
return new Promise((resolve, reject) => {
|
|
40
42
|
const startedAt = Date.now();
|
|
@@ -65,11 +67,13 @@ export function runExternalCli(input: {
|
|
|
65
67
|
input.onProcess?.(initialProcess);
|
|
66
68
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
67
69
|
stdoutStream.write(chunk);
|
|
70
|
+
input.onStdout?.(chunk);
|
|
68
71
|
stdoutTail = Buffer.concat([stdoutTail, chunk]);
|
|
69
72
|
if (stdoutTail.length > MAX_OUTPUT_TAIL_BYTES) stdoutTail = stdoutTail.subarray(stdoutTail.length - MAX_OUTPUT_TAIL_BYTES);
|
|
70
73
|
});
|
|
71
74
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
72
75
|
stderrStream.write(chunk);
|
|
76
|
+
input.onStderr?.(chunk);
|
|
73
77
|
stderrTail = Buffer.concat([stderrTail, chunk]);
|
|
74
78
|
if (stderrTail.length > MAX_ERROR_TAIL_BYTES) stderrTail = stderrTail.subarray(stderrTail.length - MAX_ERROR_TAIL_BYTES);
|
|
75
79
|
});
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
7
|
+
import {
|
|
8
|
+
ExternalJobProviderError,
|
|
9
|
+
getExternalJobProvider,
|
|
10
|
+
validateExternalJobHandle,
|
|
11
|
+
validateExternalJobResult,
|
|
12
|
+
type ExternalJobHandle,
|
|
13
|
+
type ExternalJobOperation,
|
|
14
|
+
type ExternalJobResult,
|
|
15
|
+
type ExternalJobStartInput,
|
|
16
|
+
} from "../../api/external-job-provider.ts";
|
|
17
|
+
|
|
18
|
+
export const EXTERNAL_JOB_BRIDGE_REQUEST_DIR = "external-job-requests";
|
|
19
|
+
const EXTERNAL_JOB_BRIDGE_RESPONSE_DIR = "external-job-responses";
|
|
20
|
+
const DEFAULT_OPERATION_TIMEOUT_MS = 120_000;
|
|
21
|
+
const POLL_INTERVAL_MS = 50;
|
|
22
|
+
const MAX_REQUESTS_PER_SWEEP = 100;
|
|
23
|
+
|
|
24
|
+
interface ExternalJobBridgeRequest {
|
|
25
|
+
id: string;
|
|
26
|
+
operation: ExternalJobOperation;
|
|
27
|
+
provider: string;
|
|
28
|
+
providerJobId?: string;
|
|
29
|
+
start?: ExternalJobStartInput;
|
|
30
|
+
createdAt: number;
|
|
31
|
+
claimedAt?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ExternalJobClaimOwner {
|
|
35
|
+
version: 1;
|
|
36
|
+
pid: number;
|
|
37
|
+
hostname: string;
|
|
38
|
+
claimedAt: number;
|
|
39
|
+
processStartIdentity?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type ExternalJobBridgeResponse = {
|
|
43
|
+
id: string;
|
|
44
|
+
ok: true;
|
|
45
|
+
operation: ExternalJobOperation;
|
|
46
|
+
provider: string;
|
|
47
|
+
result: ExternalJobHandle | ExternalJobResult;
|
|
48
|
+
completedAt: number;
|
|
49
|
+
} | {
|
|
50
|
+
id: string;
|
|
51
|
+
ok: false;
|
|
52
|
+
operation: ExternalJobOperation;
|
|
53
|
+
provider: string;
|
|
54
|
+
code: string;
|
|
55
|
+
message: string;
|
|
56
|
+
blockingJobId?: string;
|
|
57
|
+
completedAt: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type ExternalJobBridgeCancel = () => ExternalJobProviderError | undefined;
|
|
61
|
+
|
|
62
|
+
const inFlight = new Set<string>();
|
|
63
|
+
|
|
64
|
+
function requestDir(asyncDir: string): string {
|
|
65
|
+
return path.join(asyncDir, EXTERNAL_JOB_BRIDGE_REQUEST_DIR);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function responseDir(asyncDir: string): string {
|
|
69
|
+
return path.join(asyncDir, EXTERNAL_JOB_BRIDGE_RESPONSE_DIR);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function requestPath(asyncDir: string, id: string): string {
|
|
73
|
+
return path.join(requestDir(asyncDir), `${id}.json`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function startClaimDir(asyncDir: string, id: string): string {
|
|
77
|
+
return path.join(requestDir(asyncDir), `${id}.claim`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function startClaimTempDir(asyncDir: string, id: string): string {
|
|
81
|
+
return path.join(requestDir(asyncDir), `${id}.claim.tmp-${randomUUID()}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function startClaimCompletedPath(claimDir: string): string {
|
|
85
|
+
return path.join(claimDir, "completed.json");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function startClaimHandlePath(claimDir: string): string {
|
|
89
|
+
return path.join(claimDir, "handle.json");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function completedStartClaimExists(asyncDir: string, id: string): boolean {
|
|
93
|
+
return fs.existsSync(startClaimCompletedPath(startClaimDir(asyncDir, id)));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cancelStartRequest(asyncDir: string, request: ExternalJobBridgeRequest): boolean {
|
|
97
|
+
const claimDir = startClaimDir(asyncDir, request.id);
|
|
98
|
+
const tempClaimDir = startClaimTempDir(asyncDir, request.id);
|
|
99
|
+
try {
|
|
100
|
+
fs.mkdirSync(tempClaimDir);
|
|
101
|
+
writeAtomicJson(startClaimCompletedPath(tempClaimDir), { completedAt: Date.now() });
|
|
102
|
+
fs.renameSync(tempClaimDir, claimDir);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
fs.rmSync(tempClaimDir, { recursive: true, force: true });
|
|
105
|
+
if (isClaimConflictError(error)) return false;
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
fs.rmSync(requestPath(asyncDir, request.id), { force: true });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function responsePath(asyncDir: string, id: string): string {
|
|
113
|
+
return path.join(responseDir(asyncDir), `${id}.json`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sleep(ms: number): Promise<void> {
|
|
117
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readJson<T>(filePath: string): T {
|
|
121
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function processStartIdentity(pid: number): string | undefined {
|
|
125
|
+
if (process.platform === "linux") {
|
|
126
|
+
try {
|
|
127
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
128
|
+
const commandEnd = stat.lastIndexOf(")");
|
|
129
|
+
if (commandEnd === -1) return undefined;
|
|
130
|
+
const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
|
|
131
|
+
const startTicks = fields[19];
|
|
132
|
+
return startTicks ? `linux:${startTicks}` : undefined;
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (process.platform === "darwin" || process.platform === "freebsd") {
|
|
138
|
+
const result = spawnSync("/bin/ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf-8" });
|
|
139
|
+
const started = result.status === 0 ? result.stdout.trim() : "";
|
|
140
|
+
return started ? `${process.platform}:${started}` : undefined;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function processIsAlive(pid: number): boolean | undefined {
|
|
146
|
+
try {
|
|
147
|
+
process.kill(pid, 0);
|
|
148
|
+
return true;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
151
|
+
if (code === "ESRCH") return false;
|
|
152
|
+
if (code === "EPERM") return true;
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function currentClaimOwner(): ExternalJobClaimOwner {
|
|
158
|
+
const startIdentity = processStartIdentity(process.pid);
|
|
159
|
+
return {
|
|
160
|
+
version: 1,
|
|
161
|
+
pid: process.pid,
|
|
162
|
+
hostname: os.hostname(),
|
|
163
|
+
claimedAt: Date.now(),
|
|
164
|
+
...(startIdentity ? { processStartIdentity: startIdentity } : {}),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isClaimConflictError(error: unknown): boolean {
|
|
169
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
|
170
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
171
|
+
return code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isNotFoundError(error: unknown): boolean {
|
|
175
|
+
return typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function bridgeError(error: unknown): { code: string; message: string; blockingJobId?: string } {
|
|
179
|
+
if (error instanceof ExternalJobProviderError) {
|
|
180
|
+
return { code: error.code, message: error.message, ...(error.blockingJobId ? { blockingJobId: error.blockingJobId } : {}) };
|
|
181
|
+
}
|
|
182
|
+
if (error && typeof error === "object") {
|
|
183
|
+
const record = error as Record<string, unknown>;
|
|
184
|
+
const code = typeof record.code === "string" && record.code.trim() ? record.code : "provider-error";
|
|
185
|
+
const blockingJobId = typeof record.blockingJobId === "string" && record.blockingJobId.trim() ? record.blockingJobId : undefined;
|
|
186
|
+
return {
|
|
187
|
+
code,
|
|
188
|
+
message: error instanceof Error ? error.message : String(error),
|
|
189
|
+
...(blockingJobId ? { blockingJobId } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return { code: "provider-error", message: String(error) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function assertRequest(value: unknown, filePath: string): ExternalJobBridgeRequest {
|
|
196
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`External-job bridge request '${filePath}' must be an object.`);
|
|
197
|
+
const request = value as ExternalJobBridgeRequest;
|
|
198
|
+
if (typeof request.id !== "string" || !request.id) throw new Error(`External-job bridge request '${filePath}' has invalid id.`);
|
|
199
|
+
if (request.operation !== "start" && request.operation !== "status" && request.operation !== "result" && request.operation !== "reattach") throw new Error(`External-job bridge request '${filePath}' has invalid operation.`);
|
|
200
|
+
if (typeof request.provider !== "string" || !request.provider.trim()) throw new Error(`External-job bridge request '${filePath}' has invalid provider.`);
|
|
201
|
+
if (typeof request.createdAt !== "number") throw new Error(`External-job bridge request '${filePath}' has invalid createdAt.`);
|
|
202
|
+
if (request.claimedAt !== undefined && typeof request.claimedAt !== "number") throw new Error(`External-job bridge request '${filePath}' has invalid claimedAt.`);
|
|
203
|
+
if (request.operation === "start") {
|
|
204
|
+
if (!request.start || typeof request.start !== "object" || Array.isArray(request.start)) throw new Error(`External-job bridge start request '${filePath}' is missing start input.`);
|
|
205
|
+
} else if (typeof request.providerJobId !== "string" || !request.providerJobId.trim()) {
|
|
206
|
+
throw new Error(`External-job bridge ${request.operation} request '${filePath}' is missing providerJobId.`);
|
|
207
|
+
}
|
|
208
|
+
return request;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function executeBridgeRequest(request: ExternalJobBridgeRequest, claimDir?: string): Promise<ExternalJobBridgeResponse> {
|
|
212
|
+
const provider = getExternalJobProvider(request.provider);
|
|
213
|
+
if (!provider) {
|
|
214
|
+
return {
|
|
215
|
+
id: request.id,
|
|
216
|
+
ok: false,
|
|
217
|
+
operation: request.operation,
|
|
218
|
+
provider: request.provider,
|
|
219
|
+
code: "provider-unavailable",
|
|
220
|
+
message: `External-job provider '${request.provider}' is not registered. Load the Surf Pi extension and its external-job provider bridge before starting this agent.`,
|
|
221
|
+
completedAt: Date.now(),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
const raw = request.operation === "start"
|
|
226
|
+
? await provider.start(request.start!)
|
|
227
|
+
: request.operation === "status"
|
|
228
|
+
? await provider.status(request.providerJobId!)
|
|
229
|
+
: request.operation === "reattach"
|
|
230
|
+
? await provider.reattach(request.providerJobId!)
|
|
231
|
+
: await provider.result(request.providerJobId!);
|
|
232
|
+
const result = request.operation === "result"
|
|
233
|
+
? validateExternalJobResult(provider.name, raw, "External-job bridge result")
|
|
234
|
+
: validateExternalJobHandle(provider.name, raw, "External-job bridge handle");
|
|
235
|
+
if (request.operation === "start" && claimDir) writeAtomicJson(startClaimHandlePath(claimDir), result);
|
|
236
|
+
return { id: request.id, ok: true, operation: request.operation, provider: request.provider, result, completedAt: Date.now() };
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const details = bridgeError(error);
|
|
239
|
+
return {
|
|
240
|
+
id: request.id,
|
|
241
|
+
ok: false,
|
|
242
|
+
operation: request.operation,
|
|
243
|
+
provider: request.provider,
|
|
244
|
+
...details,
|
|
245
|
+
completedAt: Date.now(),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function claimStartRequest(asyncDir: string, filePath: string, request: ExternalJobBridgeRequest): { request: ExternalJobBridgeRequest; filePath: string } | undefined {
|
|
251
|
+
const owner = currentClaimOwner();
|
|
252
|
+
const claimed: ExternalJobBridgeRequest = { ...request, claimedAt: owner.claimedAt };
|
|
253
|
+
const claimDir = startClaimDir(asyncDir, request.id);
|
|
254
|
+
const tempClaimDir = startClaimTempDir(asyncDir, request.id);
|
|
255
|
+
try {
|
|
256
|
+
fs.mkdirSync(tempClaimDir);
|
|
257
|
+
writeAtomicJson(path.join(tempClaimDir, "owner.json"), owner);
|
|
258
|
+
writeAtomicJson(path.join(tempClaimDir, "request.json"), claimed);
|
|
259
|
+
fs.renameSync(tempClaimDir, claimDir);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
fs.rmSync(tempClaimDir, { recursive: true, force: true });
|
|
262
|
+
if (isClaimConflictError(error)) return undefined;
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
fs.rmSync(filePath, { force: true });
|
|
266
|
+
return { request: claimed, filePath: claimDir };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function parseClaimOwner(value: unknown): ExternalJobClaimOwner | undefined {
|
|
270
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
271
|
+
const owner = value as Partial<ExternalJobClaimOwner>;
|
|
272
|
+
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;
|
|
273
|
+
if (owner.processStartIdentity !== undefined && typeof owner.processStartIdentity !== "string") return undefined;
|
|
274
|
+
return owner as ExternalJobClaimOwner;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function claimOwnerIsDead(owner: ExternalJobClaimOwner | undefined): boolean {
|
|
278
|
+
if (!owner || owner.hostname !== os.hostname()) return false;
|
|
279
|
+
const alive = processIsAlive(owner.pid);
|
|
280
|
+
if (alive === false) return true;
|
|
281
|
+
if (alive !== true || !owner.processStartIdentity) return false;
|
|
282
|
+
const currentIdentity = processStartIdentity(owner.pid);
|
|
283
|
+
return currentIdentity !== undefined && currentIdentity !== owner.processStartIdentity;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function readClaimOwner(claimDir: string): ExternalJobClaimOwner | undefined {
|
|
287
|
+
try {
|
|
288
|
+
return parseClaimOwner(readJson(path.join(claimDir, "owner.json")));
|
|
289
|
+
} catch {
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function readClaimHandle(provider: string, claimDir: string): ExternalJobHandle | undefined {
|
|
295
|
+
try {
|
|
296
|
+
return validateExternalJobHandle(provider, readJson(startClaimHandlePath(claimDir)), "External-job bridge recovered start handle");
|
|
297
|
+
} catch {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function serviceExternalJobBridgeRequests(asyncDir: string): void {
|
|
303
|
+
let files: string[];
|
|
304
|
+
try {
|
|
305
|
+
files = fs.readdirSync(requestDir(asyncDir), { withFileTypes: true })
|
|
306
|
+
.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)))))
|
|
307
|
+
.map((entry) => entry.name)
|
|
308
|
+
.slice(0, MAX_REQUESTS_PER_SWEEP);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
fs.mkdirSync(responseDir(asyncDir), { recursive: true });
|
|
314
|
+
for (const file of files) {
|
|
315
|
+
serviceExternalJobBridgeRequestFile(asyncDir, file);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function serviceExternalJobBridgeRequestFile(asyncDir: string, file: string): void {
|
|
320
|
+
fs.mkdirSync(responseDir(asyncDir), { recursive: true });
|
|
321
|
+
if (file.endsWith(".claim")) {
|
|
322
|
+
serviceExternalJobStartClaim(asyncDir, file);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const filePath = path.join(requestDir(asyncDir), file);
|
|
326
|
+
let request: ExternalJobBridgeRequest;
|
|
327
|
+
try {
|
|
328
|
+
request = assertRequest(readJson(filePath), filePath);
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (isNotFoundError(error)) return;
|
|
331
|
+
const id = file.replace(/\.json$/, "");
|
|
332
|
+
writeAtomicJson(responsePath(asyncDir, id), {
|
|
333
|
+
id,
|
|
334
|
+
ok: false,
|
|
335
|
+
operation: "status",
|
|
336
|
+
provider: "unknown",
|
|
337
|
+
code: "malformed-request",
|
|
338
|
+
message: error instanceof Error ? error.message : String(error),
|
|
339
|
+
completedAt: Date.now(),
|
|
340
|
+
} satisfies ExternalJobBridgeResponse);
|
|
341
|
+
fs.rmSync(filePath, { force: true });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (request.operation === "start" && completedStartClaimExists(asyncDir, request.id)) {
|
|
345
|
+
fs.rmSync(filePath, { force: true });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (inFlight.has(request.id) || fs.existsSync(responsePath(asyncDir, request.id))) return;
|
|
349
|
+
if (request.operation === "start" && request.claimedAt !== undefined) return;
|
|
350
|
+
const claimed = request.operation === "start" ? claimStartRequest(asyncDir, filePath, request) : { request, filePath };
|
|
351
|
+
if (!claimed) return;
|
|
352
|
+
const claimedRequest = claimed.request;
|
|
353
|
+
inFlight.add(claimedRequest.id);
|
|
354
|
+
void executeBridgeRequest(claimedRequest, claimedRequest.operation === "start" ? claimed.filePath : undefined).then((response) => {
|
|
355
|
+
writeAtomicJson(responsePath(asyncDir, claimedRequest.id), response);
|
|
356
|
+
if (claimedRequest.operation === "start") {
|
|
357
|
+
writeAtomicJson(startClaimCompletedPath(claimed.filePath), { completedAt: Date.now() });
|
|
358
|
+
} else {
|
|
359
|
+
fs.rmSync(claimed.filePath, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
}).catch((error) => {
|
|
362
|
+
writeAtomicJson(responsePath(asyncDir, claimedRequest.id), {
|
|
363
|
+
id: claimedRequest.id,
|
|
364
|
+
ok: false,
|
|
365
|
+
operation: claimedRequest.operation,
|
|
366
|
+
provider: claimedRequest.provider,
|
|
367
|
+
code: "bridge-error",
|
|
368
|
+
message: error instanceof Error ? error.message : String(error),
|
|
369
|
+
completedAt: Date.now(),
|
|
370
|
+
} satisfies ExternalJobBridgeResponse);
|
|
371
|
+
if (claimedRequest.operation === "start") writeAtomicJson(startClaimCompletedPath(claimed.filePath), { completedAt: Date.now() });
|
|
372
|
+
}).finally(() => {
|
|
373
|
+
inFlight.delete(claimedRequest.id);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function serviceExternalJobStartClaim(asyncDir: string, file: string): void {
|
|
378
|
+
const claimDir = path.join(requestDir(asyncDir), file);
|
|
379
|
+
if (fs.existsSync(startClaimCompletedPath(claimDir))) return;
|
|
380
|
+
const owner = readClaimOwner(claimDir);
|
|
381
|
+
if (!owner && fs.existsSync(requestPath(asyncDir, file.replace(/\.claim$/, ""))) && !fs.existsSync(responsePath(asyncDir, file.replace(/\.claim$/, "")))) {
|
|
382
|
+
fs.rmSync(claimDir, { recursive: true, force: true });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (!claimOwnerIsDead(owner)) return;
|
|
386
|
+
let request: ExternalJobBridgeRequest;
|
|
387
|
+
try {
|
|
388
|
+
request = assertRequest(readJson(path.join(claimDir, "request.json")), path.join(claimDir, "request.json"));
|
|
389
|
+
} catch {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (request.operation !== "start" || fs.existsSync(responsePath(asyncDir, request.id))) return;
|
|
393
|
+
const handle = readClaimHandle(request.provider, claimDir);
|
|
394
|
+
if (handle) {
|
|
395
|
+
writeAtomicJson(responsePath(asyncDir, request.id), {
|
|
396
|
+
id: request.id,
|
|
397
|
+
ok: true,
|
|
398
|
+
operation: request.operation,
|
|
399
|
+
provider: request.provider,
|
|
400
|
+
result: handle,
|
|
401
|
+
completedAt: Date.now(),
|
|
402
|
+
} satisfies ExternalJobBridgeResponse);
|
|
403
|
+
writeAtomicJson(startClaimCompletedPath(claimDir), { completedAt: Date.now() });
|
|
404
|
+
fs.rmSync(requestPath(asyncDir, request.id), { force: true });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
writeAtomicJson(responsePath(asyncDir, request.id), {
|
|
408
|
+
id: request.id,
|
|
409
|
+
ok: false,
|
|
410
|
+
operation: request.operation,
|
|
411
|
+
provider: request.provider,
|
|
412
|
+
code: "start-dispatch-abandoned",
|
|
413
|
+
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.`,
|
|
414
|
+
completedAt: Date.now(),
|
|
415
|
+
} satisfies ExternalJobBridgeResponse);
|
|
416
|
+
fs.rmSync(claimDir, { recursive: true, force: true });
|
|
417
|
+
fs.rmSync(requestPath(asyncDir, request.id), { force: true });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function requestExternalJobOperation<T extends ExternalJobHandle | ExternalJobResult>(asyncDir: string, request: Omit<ExternalJobBridgeRequest, "id" | "createdAt">, timeoutMs = DEFAULT_OPERATION_TIMEOUT_MS, cancel?: ExternalJobBridgeCancel): Promise<T> {
|
|
421
|
+
const id = randomUUID();
|
|
422
|
+
fs.mkdirSync(requestDir(asyncDir), { recursive: true });
|
|
423
|
+
fs.mkdirSync(responseDir(asyncDir), { recursive: true });
|
|
424
|
+
const bridgeRequest = { ...request, id, createdAt: Date.now() } satisfies ExternalJobBridgeRequest;
|
|
425
|
+
writeAtomicJson(requestPath(asyncDir, id), bridgeRequest);
|
|
426
|
+
const deadline = request.operation === "start" ? undefined : Date.now() + timeoutMs;
|
|
427
|
+
const outPath = responsePath(asyncDir, id);
|
|
428
|
+
while (!fs.existsSync(outPath)) {
|
|
429
|
+
const canceled = cancel?.();
|
|
430
|
+
if (canceled) {
|
|
431
|
+
if (request.operation === "start" && !cancelStartRequest(asyncDir, bridgeRequest)) {
|
|
432
|
+
await sleep(POLL_INTERVAL_MS);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (request.operation !== "start") fs.rmSync(requestPath(asyncDir, id), { force: true });
|
|
436
|
+
throw canceled;
|
|
437
|
+
}
|
|
438
|
+
if (deadline !== undefined && Date.now() >= deadline) {
|
|
439
|
+
throw new ExternalJobProviderError(
|
|
440
|
+
`External-job provider bridge did not respond to ${request.operation} for provider '${request.provider}' within ${timeoutMs}ms.`,
|
|
441
|
+
{ code: "bridge-timeout" },
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
await sleep(POLL_INTERVAL_MS);
|
|
445
|
+
}
|
|
446
|
+
const response = readJson<ExternalJobBridgeResponse>(outPath);
|
|
447
|
+
fs.rmSync(outPath, { force: true });
|
|
448
|
+
if (!response.ok) throw new ExternalJobProviderError(response.message, { code: response.code, ...(response.blockingJobId ? { blockingJobId: response.blockingJobId } : {}) });
|
|
449
|
+
return response.result as T;
|
|
450
|
+
}
|