pi-subagents 0.53.0 → 0.55.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 +66 -5
- package/README.md +1 -1
- package/agents/reviewer.md +12 -2
- package/docs/agents.md +4 -4
- package/docs/configuration.md +6 -6
- package/docs/extension-api.md +14 -4
- package/docs/models.md +41 -6
- package/docs/observability.md +14 -3
- package/docs/tool-reference.md +21 -6
- package/docs/workflows.md +7 -1
- package/index.ts +10 -1
- package/package.json +1 -1
- package/prompts/council.md +19 -7
- package/prompts/parallel-review.md +5 -1
- package/prompts/review-loop.md +9 -5
- package/skills/council-mode/SKILL.md +50 -26
- package/skills/pi-subagents/SKILL.md +5 -1
- package/skills/pi-subagents/references/constraints-and-recipes.md +16 -1
- package/skills/pi-subagents/references/execution-controls.md +13 -4
- package/skills/pi-subagents/references/multi-lane-orchestration.md +3 -3
- package/skills/pi-subagents/references/prompting-and-roles.md +14 -7
- package/src/agents/agent-management.ts +115 -14
- package/src/agents/agent-serializer.ts +2 -2
- package/src/agents/agents.ts +195 -47
- package/src/api/external-job-provider.ts +10 -1
- package/src/api/preflight.ts +33 -19
- package/src/api/project-panes.ts +2 -0
- package/src/extension/doctor.ts +10 -0
- package/src/extension/fanout-child.ts +3 -2
- package/src/extension/index.ts +24 -4
- package/src/extension/public-execution.ts +12 -9
- package/src/extension/rpc.ts +77 -3
- package/src/extension/schemas.ts +2 -1
- package/src/extension/tool-description.ts +9 -6
- package/src/extension/tool-result.ts +19 -0
- package/src/inspectors/herdr/client.ts +3 -3
- package/src/inspectors/herdr/focus.ts +55 -0
- package/src/inspectors/herdr/project-panes.ts +228 -44
- package/src/integrations/herdr-status.ts +26 -4
- package/src/runs/background/async-execution.ts +112 -13
- package/src/runs/background/async-job-tracker.ts +33 -14
- package/src/runs/background/async-resume.ts +20 -2
- package/src/runs/background/async-retention.ts +1 -1
- package/src/runs/background/async-status.ts +10 -0
- package/src/runs/background/chain-root-attachment.ts +5 -0
- package/src/runs/background/control-channel.ts +98 -10
- package/src/runs/background/notify.ts +62 -1
- package/src/runs/background/result-watcher.ts +4 -3
- package/src/runs/background/run-status.ts +15 -1
- package/src/runs/background/stale-run-reconciler.ts +3 -0
- package/src/runs/background/subagent-runner.ts +251 -38
- package/src/runs/background/wait-completions.ts +2 -0
- package/src/runs/background/wait-tool.ts +4 -3
- package/src/runs/foreground/async-stop-action.ts +23 -2
- package/src/runs/foreground/execution.ts +110 -8
- package/src/runs/foreground/subagent-executor.ts +361 -45
- package/src/runs/foreground/workflow-detach-reconcile.ts +3 -0
- package/src/runs/shared/acceptance.ts +1 -0
- package/src/runs/shared/child-identity.ts +36 -0
- package/src/runs/shared/completion-guard.ts +50 -1
- package/src/runs/shared/external-job-bridge.ts +53 -37
- package/src/runs/shared/external-job-runner.ts +126 -23
- package/src/runs/shared/model-fallback.ts +46 -15
- package/src/runs/shared/model-scope.ts +106 -39
- package/src/runs/shared/orca-progress-tabs.ts +71 -9
- package/src/runs/shared/parallel-utils.ts +12 -0
- package/src/runs/shared/pi-args.ts +31 -1
- package/src/runs/shared/subagent-prompt-runtime.ts +39 -10
- package/src/runs/shared/tool-availability.ts +1 -3
- package/src/shared/launch-contract.ts +6 -5
- package/src/shared/thinking-ceiling.ts +52 -0
- package/src/shared/types.ts +72 -5
- package/src/tui/fleet-status.ts +66 -13
- package/src/tui/render.ts +5 -4
- package/src/watchdog/permission-arbiter.ts +59 -51
- package/src/workflows/scripted-workflow.ts +107 -10
|
@@ -119,6 +119,7 @@ function publishedWorkflowResult(status: AsyncStatus, childRunId: string, result
|
|
|
119
119
|
timestamp: Date.now(),
|
|
120
120
|
results: workflowResultChildren(status, childRunId, result, existing?.results),
|
|
121
121
|
workflow: status.workflow,
|
|
122
|
+
reconciledFromDetachedChild: childRunId,
|
|
122
123
|
...(receipt ? { workflowReceipt: { path: path.join(asyncDir, "workflow-receipt.json"), receipt } } : {}),
|
|
123
124
|
asyncDir,
|
|
124
125
|
cwd: status.cwd,
|
|
@@ -252,6 +253,8 @@ export function reconcileDetachedWorkflowChildCompletion(input: {
|
|
|
252
253
|
success: next.state === "complete",
|
|
253
254
|
state: next.state,
|
|
254
255
|
summary: typeof published.summary === "string" ? published.summary : (next.state === "complete" ? "Workflow completed." : next.error),
|
|
256
|
+
reconciledFromDetachedChild: input.childRunId,
|
|
257
|
+
...(Array.isArray(published.results) ? { results: published.results } : {}),
|
|
255
258
|
sessionId: next.sessionId,
|
|
256
259
|
completionOwnerId: next.completionOwnerId,
|
|
257
260
|
timestamp: Date.now(),
|
|
@@ -959,6 +959,7 @@ function checkNoStagedFiles(cwd: string): AcceptanceRuntimeCheck {
|
|
|
959
959
|
function runStructuralChecks(acceptance: ResolvedAcceptanceConfig, report: AcceptanceReport, cwd: string): AcceptanceRuntimeCheck[] {
|
|
960
960
|
const checks: AcceptanceRuntimeCheck[] = [];
|
|
961
961
|
for (const kind of acceptance.evidence) {
|
|
962
|
+
if (kind === "no-staged-files" && report.noStagedFiles === undefined) continue;
|
|
962
963
|
const status = reportEvidenceStatus(report, kind);
|
|
963
964
|
checks.push({
|
|
964
965
|
id: `evidence:${kind}`,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AsyncStatus } from "../../shared/types.ts";
|
|
2
|
+
|
|
3
|
+
export type AsyncStatusStep = NonNullable<AsyncStatus["steps"]>[number];
|
|
4
|
+
|
|
5
|
+
export interface ResolvedAsyncStatusChild {
|
|
6
|
+
index: number;
|
|
7
|
+
step: AsyncStatusStep;
|
|
8
|
+
id: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type AsyncStatusChildResolution =
|
|
12
|
+
| { ok: true; child: ResolvedAsyncStatusChild }
|
|
13
|
+
| { ok: false; code: "not_found" | "ambiguous"; message: string };
|
|
14
|
+
|
|
15
|
+
export function asyncStatusChildIdentity(step: AsyncStatusStep, index: number): string {
|
|
16
|
+
return step.workflowKey ?? step.runId ?? `step:${index}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function asyncStatusChildIdentityCandidates(step: AsyncStatusStep, index: number): string[] {
|
|
20
|
+
return [...new Set([step.workflowKey, step.runId, `step:${index}`].filter((value): value is string => typeof value === "string" && value.length > 0))];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveAsyncStatusChild(status: Pick<AsyncStatus, "runId" | "steps">, childId: string): AsyncStatusChildResolution {
|
|
24
|
+
const matches: ResolvedAsyncStatusChild[] = [];
|
|
25
|
+
for (const [index, step] of (status.steps ?? []).entries()) {
|
|
26
|
+
if (!asyncStatusChildIdentityCandidates(step, index).includes(childId)) continue;
|
|
27
|
+
matches.push({ index, step, id: asyncStatusChildIdentity(step, index) });
|
|
28
|
+
}
|
|
29
|
+
if (matches.length === 1) return { ok: true, child: matches[0]! };
|
|
30
|
+
if (matches.length > 1) return { ok: false, code: "ambiguous", message: `Child '${childId}' is ambiguous under async run '${status.runId}'.` };
|
|
31
|
+
return { ok: false, code: "not_found", message: `Child '${childId}' was not found under async run '${status.runId}'.` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isStoppableAsyncStatusStep(step: AsyncStatusStep): boolean {
|
|
35
|
+
return step.status === "pending" || step.status === "running";
|
|
36
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AcceptanceRole } from "../../shared/types.ts";
|
|
2
3
|
import { isMutatingTool } from "./long-running-guard.ts";
|
|
3
|
-
import { expectsImplementationMutation } from "./task-intent.ts";
|
|
4
|
+
import { classifyTaskMutationIntent, expectsImplementationMutation, taskMayMutate } from "./task-intent.ts";
|
|
4
5
|
|
|
5
6
|
export { expectsImplementationMutation };
|
|
6
7
|
|
|
@@ -14,6 +15,7 @@ const READ_ONLY_BUILTIN_TOOLS = new Set([
|
|
|
14
15
|
"get_search_content",
|
|
15
16
|
"intercom",
|
|
16
17
|
"contact_supervisor",
|
|
18
|
+
"structured_output",
|
|
17
19
|
]);
|
|
18
20
|
|
|
19
21
|
// Cursor native edit/write often land as thinking traces (inactive_trace /
|
|
@@ -45,12 +47,15 @@ interface CompletionMutationGuardInput {
|
|
|
45
47
|
messages: Message[];
|
|
46
48
|
tools?: string[];
|
|
47
49
|
mcpDirectTools?: string[];
|
|
50
|
+
toolAvailabilityError?: string;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
interface CompletionMutationGuardResult {
|
|
51
54
|
expectedMutation: boolean;
|
|
52
55
|
attemptedMutation: boolean;
|
|
53
56
|
triggered: boolean;
|
|
57
|
+
blocked: boolean;
|
|
58
|
+
message?: string;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
interface ReportSentence {
|
|
@@ -64,6 +69,40 @@ export function hasMutationToolCapability(tools: string[] | undefined, mcpDirect
|
|
|
64
69
|
return !tools.every((tool) => READ_ONLY_BUILTIN_TOOLS.has(tool));
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
function hasBuiltinMutationTool(tools: string[] | undefined): boolean {
|
|
73
|
+
return tools === undefined || tools.some((tool) => !READ_ONLY_BUILTIN_TOOLS.has(tool));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isWriterRole(agent: string, acceptanceRole: AcceptanceRole | undefined): boolean {
|
|
77
|
+
return acceptanceRole === "writer"
|
|
78
|
+
|| (acceptanceRole === undefined && /\bworker\b/i.test(agent));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const WRITER_DELIVERY_PATTERN = /\b(?:address|resolve|work)\s+(?:backlog\s+)?(?:item|issue)\b|\b(?:land|ship)\s+(?:the\s+)?(?:change|changes|fix|feature|implementation)\b/i;
|
|
82
|
+
|
|
83
|
+
export function validateImplementationToolContract(input: {
|
|
84
|
+
agent: string;
|
|
85
|
+
task: string;
|
|
86
|
+
tools?: string[];
|
|
87
|
+
mcpDirectTools?: string[];
|
|
88
|
+
configuredExtensions?: string[];
|
|
89
|
+
requestedTools?: string[];
|
|
90
|
+
acceptanceRole?: AcceptanceRole;
|
|
91
|
+
completionGuard?: boolean;
|
|
92
|
+
}): string | undefined {
|
|
93
|
+
if (input.completionGuard === false) return undefined;
|
|
94
|
+
const requestedMutationTools = input.requestedTools?.filter((tool) => !READ_ONLY_BUILTIN_TOOLS.has(tool)) ?? [];
|
|
95
|
+
const declaredMutationToolsWereRemoved = requestedMutationTools.length > 0 && !hasBuiltinMutationTool(input.tools);
|
|
96
|
+
const configuredExtensionCapability = (input.configuredExtensions?.length ?? 0) > 0 && !declaredMutationToolsWereRemoved;
|
|
97
|
+
if (hasMutationToolCapability(input.tools, input.mcpDirectTools) || configuredExtensionCapability) return undefined;
|
|
98
|
+
const intent = classifyTaskMutationIntent(input.agent, input.task);
|
|
99
|
+
if (intent.kind === "read-only") return undefined;
|
|
100
|
+
const writerTaskMayMutate = isWriterRole(input.agent, input.acceptanceRole)
|
|
101
|
+
&& (taskMayMutate(input.task) || WRITER_DELIVERY_PATTERN.test(input.task));
|
|
102
|
+
if (intent.kind !== "implementation" && !writerTaskMayMutate && !declaredMutationToolsWereRemoved) return undefined;
|
|
103
|
+
return `Agent '${input.agent}' was given an implementation task, but its tool allowlist has no mutation-capable tools. Add bash, edit, write, or another mutation-capable tool to the agent, or use a read-only task/agent.`;
|
|
104
|
+
}
|
|
105
|
+
|
|
67
106
|
function hasCheckpointMutationEvidence(message: Message): boolean {
|
|
68
107
|
const record = message as unknown as {
|
|
69
108
|
role?: string;
|
|
@@ -187,6 +226,15 @@ function reportsNoBetterChallengeChange(messages: Message[]): boolean {
|
|
|
187
226
|
}
|
|
188
227
|
|
|
189
228
|
export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
|
|
229
|
+
if (input.toolAvailabilityError && expectsImplementationMutation(input.agent, input.task)) {
|
|
230
|
+
return {
|
|
231
|
+
expectedMutation: true,
|
|
232
|
+
attemptedMutation: false,
|
|
233
|
+
triggered: false,
|
|
234
|
+
blocked: true,
|
|
235
|
+
message: input.toolAvailabilityError,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
190
238
|
const expectedMutation = hasMutationToolCapability(input.tools, input.mcpDirectTools)
|
|
191
239
|
? expectsImplementationMutation(input.agent, input.task)
|
|
192
240
|
: false;
|
|
@@ -197,5 +245,6 @@ export function evaluateCompletionMutationGuard(input: CompletionMutationGuardIn
|
|
|
197
245
|
expectedMutation,
|
|
198
246
|
attemptedMutation,
|
|
199
247
|
triggered: expectedMutation && !attemptedMutation && !noEditChallengeComplete,
|
|
248
|
+
blocked: false,
|
|
200
249
|
};
|
|
201
250
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ExternalJobHandle,
|
|
12
12
|
type ExternalJobOperation,
|
|
13
13
|
type ExternalJobResult,
|
|
14
|
+
type ExternalJobFollowUpInput,
|
|
14
15
|
type ExternalJobStartInput,
|
|
15
16
|
} from "../../api/external-job-provider.ts";
|
|
16
17
|
|
|
@@ -26,6 +27,7 @@ interface ExternalJobBridgeRequest {
|
|
|
26
27
|
provider: string;
|
|
27
28
|
providerJobId?: string;
|
|
28
29
|
start?: ExternalJobStartInput;
|
|
30
|
+
followUp?: ExternalJobFollowUpInput;
|
|
29
31
|
createdAt: number;
|
|
30
32
|
claimedAt?: number;
|
|
31
33
|
}
|
|
@@ -72,32 +74,36 @@ function requestPath(asyncDir: string, id: string): string {
|
|
|
72
74
|
return path.join(requestDir(asyncDir), `${id}.json`);
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
function
|
|
77
|
+
function isDispatchOperation(operation: ExternalJobOperation): boolean {
|
|
78
|
+
return operation === "start" || operation === "follow-up";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function dispatchClaimDir(asyncDir: string, id: string): string {
|
|
76
82
|
return path.join(requestDir(asyncDir), `${id}.claim`);
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
function
|
|
85
|
+
function dispatchClaimTempDir(asyncDir: string, id: string): string {
|
|
80
86
|
return path.join(requestDir(asyncDir), `${id}.claim.tmp-${randomUUID()}`);
|
|
81
87
|
}
|
|
82
88
|
|
|
83
|
-
function
|
|
89
|
+
function dispatchClaimCompletedPath(claimDir: string): string {
|
|
84
90
|
return path.join(claimDir, "completed.json");
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
function
|
|
93
|
+
function dispatchClaimHandlePath(claimDir: string): string {
|
|
88
94
|
return path.join(claimDir, "handle.json");
|
|
89
95
|
}
|
|
90
96
|
|
|
91
|
-
function
|
|
92
|
-
return fs.existsSync(
|
|
97
|
+
function completedDispatchClaimExists(asyncDir: string, id: string): boolean {
|
|
98
|
+
return fs.existsSync(dispatchClaimCompletedPath(dispatchClaimDir(asyncDir, id)));
|
|
93
99
|
}
|
|
94
100
|
|
|
95
|
-
function
|
|
96
|
-
const claimDir =
|
|
97
|
-
const tempClaimDir =
|
|
101
|
+
function cancelDispatchRequest(asyncDir: string, request: ExternalJobBridgeRequest): boolean {
|
|
102
|
+
const claimDir = dispatchClaimDir(asyncDir, request.id);
|
|
103
|
+
const tempClaimDir = dispatchClaimTempDir(asyncDir, request.id);
|
|
98
104
|
try {
|
|
99
105
|
fs.mkdirSync(tempClaimDir);
|
|
100
|
-
writeAtomicJson(
|
|
106
|
+
writeAtomicJson(dispatchClaimCompletedPath(tempClaimDir), { completedAt: Date.now() });
|
|
101
107
|
fs.renameSync(tempClaimDir, claimDir);
|
|
102
108
|
} catch (error) {
|
|
103
109
|
fs.rmSync(tempClaimDir, { recursive: true, force: true });
|
|
@@ -190,12 +196,14 @@ function assertRequest(value: unknown, filePath: string): ExternalJobBridgeReque
|
|
|
190
196
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`External-job bridge request '${filePath}' must be an object.`);
|
|
191
197
|
const request = value as ExternalJobBridgeRequest;
|
|
192
198
|
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.`);
|
|
199
|
+
if (request.operation !== "start" && request.operation !== "follow-up" && request.operation !== "status" && request.operation !== "result" && request.operation !== "reattach") throw new Error(`External-job bridge request '${filePath}' has invalid operation.`);
|
|
194
200
|
if (typeof request.provider !== "string" || !request.provider.trim()) throw new Error(`External-job bridge request '${filePath}' has invalid provider.`);
|
|
195
201
|
if (typeof request.createdAt !== "number") throw new Error(`External-job bridge request '${filePath}' has invalid createdAt.`);
|
|
196
202
|
if (request.claimedAt !== undefined && typeof request.claimedAt !== "number") throw new Error(`External-job bridge request '${filePath}' has invalid claimedAt.`);
|
|
197
203
|
if (request.operation === "start") {
|
|
198
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 (request.operation === "follow-up") {
|
|
206
|
+
if (!request.followUp || typeof request.followUp !== "object" || Array.isArray(request.followUp)) throw new Error(`External-job bridge follow-up request '${filePath}' is missing follow-up input.`);
|
|
199
207
|
} else if (typeof request.providerJobId !== "string" || !request.providerJobId.trim()) {
|
|
200
208
|
throw new Error(`External-job bridge ${request.operation} request '${filePath}' is missing providerJobId.`);
|
|
201
209
|
}
|
|
@@ -216,17 +224,25 @@ async function executeBridgeRequest(request: ExternalJobBridgeRequest, claimDir?
|
|
|
216
224
|
};
|
|
217
225
|
}
|
|
218
226
|
try {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
let raw: ExternalJobHandle | ExternalJobResult;
|
|
228
|
+
if (request.operation === "start") {
|
|
229
|
+
raw = await provider.start(request.start!);
|
|
230
|
+
} else if (request.operation === "follow-up") {
|
|
231
|
+
if (typeof provider.followUp !== "function") {
|
|
232
|
+
throw new ExternalJobProviderError(`External-job provider '${request.provider}' does not support follow-up. Update or reload the provider package, then retry action='resume'.`, { code: "follow-up-unsupported" });
|
|
233
|
+
}
|
|
234
|
+
raw = await provider.followUp(request.followUp!);
|
|
235
|
+
} else {
|
|
236
|
+
raw = request.operation === "status"
|
|
222
237
|
? await provider.status(request.providerJobId!)
|
|
223
238
|
: request.operation === "reattach"
|
|
224
239
|
? await provider.reattach(request.providerJobId!)
|
|
225
240
|
: await provider.result(request.providerJobId!);
|
|
241
|
+
}
|
|
226
242
|
const result = request.operation === "result"
|
|
227
243
|
? validateExternalJobResult(provider.name, raw, "External-job bridge result")
|
|
228
244
|
: validateExternalJobHandle(provider.name, raw, "External-job bridge handle");
|
|
229
|
-
if (request.operation
|
|
245
|
+
if (isDispatchOperation(request.operation) && claimDir) writeAtomicJson(dispatchClaimHandlePath(claimDir), result);
|
|
230
246
|
return { id: request.id, ok: true, operation: request.operation, provider: request.provider, result, completedAt: Date.now() };
|
|
231
247
|
} catch (error) {
|
|
232
248
|
const details = bridgeError(error);
|
|
@@ -241,11 +257,11 @@ async function executeBridgeRequest(request: ExternalJobBridgeRequest, claimDir?
|
|
|
241
257
|
}
|
|
242
258
|
}
|
|
243
259
|
|
|
244
|
-
function
|
|
260
|
+
function claimDispatchRequest(asyncDir: string, filePath: string, request: ExternalJobBridgeRequest): { request: ExternalJobBridgeRequest; filePath: string } | undefined {
|
|
245
261
|
const owner = currentClaimOwner();
|
|
246
262
|
const claimed: ExternalJobBridgeRequest = { ...request, claimedAt: owner.claimedAt };
|
|
247
|
-
const claimDir =
|
|
248
|
-
const tempClaimDir =
|
|
263
|
+
const claimDir = dispatchClaimDir(asyncDir, request.id);
|
|
264
|
+
const tempClaimDir = dispatchClaimTempDir(asyncDir, request.id);
|
|
249
265
|
try {
|
|
250
266
|
fs.mkdirSync(tempClaimDir);
|
|
251
267
|
writeAtomicJson(path.join(tempClaimDir, "owner.json"), owner);
|
|
@@ -287,7 +303,7 @@ function readClaimOwner(claimDir: string): ExternalJobClaimOwner | undefined {
|
|
|
287
303
|
|
|
288
304
|
function readClaimHandle(provider: string, claimDir: string): ExternalJobHandle | undefined {
|
|
289
305
|
try {
|
|
290
|
-
return validateExternalJobHandle(provider, readJson(
|
|
306
|
+
return validateExternalJobHandle(provider, readJson(dispatchClaimHandlePath(claimDir)), "External-job bridge recovered dispatch handle");
|
|
291
307
|
} catch {
|
|
292
308
|
return undefined;
|
|
293
309
|
}
|
|
@@ -297,7 +313,7 @@ export function serviceExternalJobBridgeRequests(asyncDir: string): void {
|
|
|
297
313
|
let files: string[];
|
|
298
314
|
try {
|
|
299
315
|
files = fs.readdirSync(requestDir(asyncDir), { withFileTypes: true })
|
|
300
|
-
.filter((entry) => (entry.isFile() && entry.name.endsWith(".json") && !
|
|
316
|
+
.filter((entry) => (entry.isFile() && entry.name.endsWith(".json") && !completedDispatchClaimExists(asyncDir, entry.name.replace(/\.json$/, ""))) || (entry.isDirectory() && entry.name.endsWith(".claim") && !fs.existsSync(dispatchClaimCompletedPath(path.join(requestDir(asyncDir), entry.name)))))
|
|
301
317
|
.map((entry) => entry.name)
|
|
302
318
|
.slice(0, MAX_REQUESTS_PER_SWEEP);
|
|
303
319
|
} catch (error) {
|
|
@@ -313,7 +329,7 @@ export function serviceExternalJobBridgeRequests(asyncDir: string): void {
|
|
|
313
329
|
export function serviceExternalJobBridgeRequestFile(asyncDir: string, file: string): void {
|
|
314
330
|
fs.mkdirSync(responseDir(asyncDir), { recursive: true });
|
|
315
331
|
if (file.endsWith(".claim")) {
|
|
316
|
-
|
|
332
|
+
serviceExternalJobDispatchClaim(asyncDir, file);
|
|
317
333
|
return;
|
|
318
334
|
}
|
|
319
335
|
const filePath = path.join(requestDir(asyncDir), file);
|
|
@@ -335,20 +351,20 @@ export function serviceExternalJobBridgeRequestFile(asyncDir: string, file: stri
|
|
|
335
351
|
fs.rmSync(filePath, { force: true });
|
|
336
352
|
return;
|
|
337
353
|
}
|
|
338
|
-
if (request.operation
|
|
354
|
+
if (isDispatchOperation(request.operation) && completedDispatchClaimExists(asyncDir, request.id)) {
|
|
339
355
|
fs.rmSync(filePath, { force: true });
|
|
340
356
|
return;
|
|
341
357
|
}
|
|
342
358
|
if (inFlight.has(request.id) || fs.existsSync(responsePath(asyncDir, request.id))) return;
|
|
343
|
-
if (request.operation
|
|
344
|
-
const claimed = request.operation
|
|
359
|
+
if (isDispatchOperation(request.operation) && request.claimedAt !== undefined) return;
|
|
360
|
+
const claimed = isDispatchOperation(request.operation) ? claimDispatchRequest(asyncDir, filePath, request) : { request, filePath };
|
|
345
361
|
if (!claimed) return;
|
|
346
362
|
const claimedRequest = claimed.request;
|
|
347
363
|
inFlight.add(claimedRequest.id);
|
|
348
|
-
void executeBridgeRequest(claimedRequest, claimedRequest.operation
|
|
364
|
+
void executeBridgeRequest(claimedRequest, isDispatchOperation(claimedRequest.operation) ? claimed.filePath : undefined).then((response) => {
|
|
349
365
|
writeAtomicJson(responsePath(asyncDir, claimedRequest.id), response);
|
|
350
|
-
if (claimedRequest.operation
|
|
351
|
-
writeAtomicJson(
|
|
366
|
+
if (isDispatchOperation(claimedRequest.operation)) {
|
|
367
|
+
writeAtomicJson(dispatchClaimCompletedPath(claimed.filePath), { completedAt: Date.now() });
|
|
352
368
|
} else {
|
|
353
369
|
fs.rmSync(claimed.filePath, { recursive: true, force: true });
|
|
354
370
|
}
|
|
@@ -362,15 +378,15 @@ export function serviceExternalJobBridgeRequestFile(asyncDir: string, file: stri
|
|
|
362
378
|
message: error instanceof Error ? error.message : String(error),
|
|
363
379
|
completedAt: Date.now(),
|
|
364
380
|
} satisfies ExternalJobBridgeResponse);
|
|
365
|
-
if (claimedRequest.operation
|
|
381
|
+
if (isDispatchOperation(claimedRequest.operation)) writeAtomicJson(dispatchClaimCompletedPath(claimed.filePath), { completedAt: Date.now() });
|
|
366
382
|
}).finally(() => {
|
|
367
383
|
inFlight.delete(claimedRequest.id);
|
|
368
384
|
});
|
|
369
385
|
}
|
|
370
386
|
|
|
371
|
-
function
|
|
387
|
+
function serviceExternalJobDispatchClaim(asyncDir: string, file: string): void {
|
|
372
388
|
const claimDir = path.join(requestDir(asyncDir), file);
|
|
373
|
-
if (fs.existsSync(
|
|
389
|
+
if (fs.existsSync(dispatchClaimCompletedPath(claimDir))) return;
|
|
374
390
|
const owner = readClaimOwner(claimDir);
|
|
375
391
|
if (!owner && fs.existsSync(requestPath(asyncDir, file.replace(/\.claim$/, ""))) && !fs.existsSync(responsePath(asyncDir, file.replace(/\.claim$/, "")))) {
|
|
376
392
|
fs.rmSync(claimDir, { recursive: true, force: true });
|
|
@@ -383,7 +399,7 @@ function serviceExternalJobStartClaim(asyncDir: string, file: string): void {
|
|
|
383
399
|
} catch {
|
|
384
400
|
return;
|
|
385
401
|
}
|
|
386
|
-
if (request.operation
|
|
402
|
+
if (!isDispatchOperation(request.operation) || fs.existsSync(responsePath(asyncDir, request.id))) return;
|
|
387
403
|
const handle = readClaimHandle(request.provider, claimDir);
|
|
388
404
|
if (handle) {
|
|
389
405
|
writeAtomicJson(responsePath(asyncDir, request.id), {
|
|
@@ -394,7 +410,7 @@ function serviceExternalJobStartClaim(asyncDir: string, file: string): void {
|
|
|
394
410
|
result: handle,
|
|
395
411
|
completedAt: Date.now(),
|
|
396
412
|
} satisfies ExternalJobBridgeResponse);
|
|
397
|
-
writeAtomicJson(
|
|
413
|
+
writeAtomicJson(dispatchClaimCompletedPath(claimDir), { completedAt: Date.now() });
|
|
398
414
|
fs.rmSync(requestPath(asyncDir, request.id), { force: true });
|
|
399
415
|
return;
|
|
400
416
|
}
|
|
@@ -403,8 +419,8 @@ function serviceExternalJobStartClaim(asyncDir: string, file: string): void {
|
|
|
403
419
|
ok: false,
|
|
404
420
|
operation: request.operation,
|
|
405
421
|
provider: request.provider,
|
|
406
|
-
code:
|
|
407
|
-
message: `External-job
|
|
422
|
+
code: `${request.operation}-dispatch-abandoned`,
|
|
423
|
+
message: `External-job ${request.operation} 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
424
|
completedAt: Date.now(),
|
|
409
425
|
} satisfies ExternalJobBridgeResponse);
|
|
410
426
|
fs.rmSync(claimDir, { recursive: true, force: true });
|
|
@@ -417,16 +433,16 @@ export async function requestExternalJobOperation<T extends ExternalJobHandle |
|
|
|
417
433
|
fs.mkdirSync(responseDir(asyncDir), { recursive: true });
|
|
418
434
|
const bridgeRequest = { ...request, id, createdAt: Date.now() } satisfies ExternalJobBridgeRequest;
|
|
419
435
|
writeAtomicJson(requestPath(asyncDir, id), bridgeRequest);
|
|
420
|
-
const deadline = request.operation
|
|
436
|
+
const deadline = isDispatchOperation(request.operation) ? undefined : Date.now() + timeoutMs;
|
|
421
437
|
const outPath = responsePath(asyncDir, id);
|
|
422
438
|
while (!fs.existsSync(outPath)) {
|
|
423
439
|
const canceled = cancel?.();
|
|
424
440
|
if (canceled) {
|
|
425
|
-
if (request.operation
|
|
441
|
+
if (isDispatchOperation(request.operation) && !cancelDispatchRequest(asyncDir, bridgeRequest)) {
|
|
426
442
|
await sleep(POLL_INTERVAL_MS);
|
|
427
443
|
continue;
|
|
428
444
|
}
|
|
429
|
-
if (request.operation
|
|
445
|
+
if (!isDispatchOperation(request.operation)) fs.rmSync(requestPath(asyncDir, id), { force: true });
|
|
430
446
|
throw canceled;
|
|
431
447
|
}
|
|
432
448
|
if (deadline !== undefined && Date.now() >= deadline) {
|
|
@@ -20,6 +20,31 @@ export function externalJobPromptDigest(prompt: string): string {
|
|
|
20
20
|
return createHash("sha256").update(prompt).digest("hex");
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export function externalJobStableJson(value: unknown): string {
|
|
24
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
25
|
+
if (Array.isArray(value)) return `[${value.map(externalJobStableJson).join(",")}]`;
|
|
26
|
+
const record = value as Record<string, unknown>;
|
|
27
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${externalJobStableJson(record[key])}`).join(",")}}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function externalJobFollowUpRequestDigest(input: { provider: string; parentProviderJobId: string; promptDigest: string; options: Record<string, unknown> }): string {
|
|
31
|
+
return createHash("sha256")
|
|
32
|
+
.update(externalJobStableJson({ provider: input.provider, parentProviderJobId: input.parentProviderJobId, promptDigest: input.promptDigest, options: input.options }))
|
|
33
|
+
.digest("hex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function externalJobFollowUpRequestId(requestDigest: string): string {
|
|
37
|
+
return `follow-up-${requestDigest.slice(0, 48)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function externalJobFollowUpRunId(requestDigest: string): string {
|
|
41
|
+
const hex = requestDigest.padEnd(32, "0").slice(0, 32).split("");
|
|
42
|
+
hex[12] = "4";
|
|
43
|
+
hex[16] = ((Number.parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16);
|
|
44
|
+
const value = hex.join("");
|
|
45
|
+
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20, 32)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
23
48
|
function sleep(ms: number): Promise<void> {
|
|
24
49
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
50
|
}
|
|
@@ -70,11 +95,29 @@ function parseExternalJobStatus(value: unknown, statusPath: string): ExternalJob
|
|
|
70
95
|
if (failureCode !== undefined && typeof failureCode !== "string") throw malformedStatus(statusPath);
|
|
71
96
|
if (failureMessage !== undefined && typeof failureMessage !== "string") throw malformedStatus(statusPath);
|
|
72
97
|
if (blockingJobId !== undefined && typeof blockingJobId !== "string") throw malformedStatus(statusPath);
|
|
98
|
+
const operation = value.operation;
|
|
99
|
+
const sourceRunId = value.sourceRunId;
|
|
100
|
+
const sourceStepIndex = value.sourceStepIndex;
|
|
101
|
+
const parentProviderJobId = value.parentProviderJobId;
|
|
102
|
+
const requestId = value.requestId;
|
|
103
|
+
const requestDigest = value.requestDigest;
|
|
104
|
+
if (operation !== undefined && operation !== "start" && operation !== "follow-up") throw malformedStatus(statusPath);
|
|
105
|
+
if (sourceRunId !== undefined && (typeof sourceRunId !== "string" || sourceRunId.length === 0)) throw malformedStatus(statusPath);
|
|
106
|
+
if (sourceStepIndex !== undefined && (typeof sourceStepIndex !== "number" || !Number.isInteger(sourceStepIndex) || sourceStepIndex < 0)) throw malformedStatus(statusPath);
|
|
107
|
+
if (parentProviderJobId !== undefined && (typeof parentProviderJobId !== "string" || parentProviderJobId.length === 0)) throw malformedStatus(statusPath);
|
|
108
|
+
if (requestId !== undefined && (typeof requestId !== "string" || requestId.length === 0)) throw malformedStatus(statusPath);
|
|
109
|
+
if (requestDigest !== undefined && (typeof requestDigest !== "string" || requestDigest.length === 0)) throw malformedStatus(statusPath);
|
|
73
110
|
if (startedAt !== undefined && typeof startedAt !== "number") throw malformedStatus(statusPath);
|
|
74
111
|
if (updatedAt !== undefined && typeof updatedAt !== "number") throw malformedStatus(statusPath);
|
|
75
112
|
return {
|
|
76
113
|
provider,
|
|
77
114
|
promptDigest,
|
|
115
|
+
...(operation ? { operation } : {}),
|
|
116
|
+
...(sourceRunId ? { sourceRunId } : {}),
|
|
117
|
+
...(sourceStepIndex !== undefined ? { sourceStepIndex } : {}),
|
|
118
|
+
...(parentProviderJobId ? { parentProviderJobId } : {}),
|
|
119
|
+
...(requestId ? { requestId } : {}),
|
|
120
|
+
...(requestDigest ? { requestDigest } : {}),
|
|
78
121
|
options: value.options ?? {},
|
|
79
122
|
state,
|
|
80
123
|
...(providerJobId ? { providerJobId } : {}),
|
|
@@ -111,10 +154,30 @@ function readExistingExternalJob(asyncDir: string, stepIndex: number): ExternalJ
|
|
|
111
154
|
return parseExternalJobStatus(step.externalJob, statusPath);
|
|
112
155
|
}
|
|
113
156
|
|
|
157
|
+
function externalJobLineage(followUp: ExternalJobFollowUpDescriptor | undefined, previous: ExternalJobStatus | undefined) {
|
|
158
|
+
return {
|
|
159
|
+
operation: followUp ? "follow-up" as const : previous?.operation,
|
|
160
|
+
...(followUp ? {
|
|
161
|
+
sourceRunId: followUp.sourceRunId,
|
|
162
|
+
sourceStepIndex: followUp.sourceStepIndex,
|
|
163
|
+
parentProviderJobId: followUp.parentProviderJobId,
|
|
164
|
+
requestId: followUp.requestId,
|
|
165
|
+
requestDigest: followUp.requestDigest,
|
|
166
|
+
} : {
|
|
167
|
+
...(previous?.sourceRunId ? { sourceRunId: previous.sourceRunId } : {}),
|
|
168
|
+
...(previous?.sourceStepIndex !== undefined ? { sourceStepIndex: previous.sourceStepIndex } : {}),
|
|
169
|
+
...(previous?.parentProviderJobId ? { parentProviderJobId: previous.parentProviderJobId } : {}),
|
|
170
|
+
...(previous?.requestId ? { requestId: previous.requestId } : {}),
|
|
171
|
+
...(previous?.requestDigest ? { requestDigest: previous.requestDigest } : {}),
|
|
172
|
+
}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
114
176
|
function statusFromHandle(input: {
|
|
115
177
|
provider: string;
|
|
116
178
|
promptDigest: string;
|
|
117
179
|
options: Record<string, unknown>;
|
|
180
|
+
followUp?: ExternalJobFollowUpDescriptor;
|
|
118
181
|
startedAt?: number;
|
|
119
182
|
previous?: ExternalJobStatus;
|
|
120
183
|
handle: ExternalJobHandle;
|
|
@@ -124,6 +187,7 @@ function statusFromHandle(input: {
|
|
|
124
187
|
provider: input.provider,
|
|
125
188
|
providerJobId: input.handle.providerJobId,
|
|
126
189
|
promptDigest: input.promptDigest,
|
|
190
|
+
...externalJobLineage(input.followUp, input.previous),
|
|
127
191
|
options: input.options,
|
|
128
192
|
handleUrl: input.handle.handleUrl ?? input.previous?.handleUrl,
|
|
129
193
|
conversationUrl: input.handle.conversationUrl ?? input.previous?.conversationUrl,
|
|
@@ -141,6 +205,7 @@ function failureStatus(input: {
|
|
|
141
205
|
provider: string;
|
|
142
206
|
promptDigest: string;
|
|
143
207
|
options: Record<string, unknown>;
|
|
208
|
+
followUp?: ExternalJobFollowUpDescriptor;
|
|
144
209
|
previous?: ExternalJobStatus;
|
|
145
210
|
code: string;
|
|
146
211
|
message: string;
|
|
@@ -150,6 +215,7 @@ function failureStatus(input: {
|
|
|
150
215
|
provider: input.provider,
|
|
151
216
|
providerJobId: input.previous?.providerJobId,
|
|
152
217
|
promptDigest: input.promptDigest,
|
|
218
|
+
...externalJobLineage(input.followUp, input.previous),
|
|
153
219
|
options: input.options,
|
|
154
220
|
handleUrl: input.previous?.handleUrl,
|
|
155
221
|
conversationUrl: input.previous?.conversationUrl,
|
|
@@ -176,6 +242,24 @@ function blocksStartRedispatch(status: ExternalJobStatus, provider: string, prom
|
|
|
176
242
|
&& status.failureCode !== "provider-unavailable";
|
|
177
243
|
}
|
|
178
244
|
|
|
245
|
+
interface ExternalJobFollowUpDescriptor {
|
|
246
|
+
sourceRunId: string;
|
|
247
|
+
sourceStepIndex: number;
|
|
248
|
+
parentProviderJobId: string;
|
|
249
|
+
requestId: string;
|
|
250
|
+
requestDigest: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function sameFollowUpLineage(status: ExternalJobStatus, followUp: ExternalJobFollowUpDescriptor | undefined): boolean {
|
|
254
|
+
if (!followUp) return status.operation !== "follow-up";
|
|
255
|
+
return status.operation === "follow-up"
|
|
256
|
+
&& status.sourceRunId === followUp.sourceRunId
|
|
257
|
+
&& status.sourceStepIndex === followUp.sourceStepIndex
|
|
258
|
+
&& status.parentProviderJobId === followUp.parentProviderJobId
|
|
259
|
+
&& status.requestId === followUp.requestId
|
|
260
|
+
&& status.requestDigest === followUp.requestDigest;
|
|
261
|
+
}
|
|
262
|
+
|
|
179
263
|
export async function runExternalJob(input: {
|
|
180
264
|
provider: string;
|
|
181
265
|
options?: Record<string, unknown>;
|
|
@@ -191,6 +275,7 @@ export async function runExternalJob(input: {
|
|
|
191
275
|
timeoutMessage?: string;
|
|
192
276
|
stopMessage?: string;
|
|
193
277
|
onExternalJob?: (status: ExternalJobStatus) => void;
|
|
278
|
+
followUp?: ExternalJobFollowUpDescriptor;
|
|
194
279
|
}): Promise<ExternalJobRunResult> {
|
|
195
280
|
const provider = input.provider;
|
|
196
281
|
const options = input.options ?? {};
|
|
@@ -218,37 +303,55 @@ export async function runExternalJob(input: {
|
|
|
218
303
|
current = readExistingExternalJob(input.asyncDir, input.stepIndex);
|
|
219
304
|
let handle: ExternalJobHandle;
|
|
220
305
|
if (current?.providerJobId) {
|
|
221
|
-
if (current.provider !== provider || current.promptDigest !== promptDigest) {
|
|
222
|
-
const message = `Existing external job '${current.providerJobId}' does not match provider or
|
|
223
|
-
const status = failureStatus({ provider, promptDigest, options, previous: current, code: "recovery-mismatch", message });
|
|
306
|
+
if (current.provider !== provider || current.promptDigest !== promptDigest || !sameFollowUpLineage(current, input.followUp)) {
|
|
307
|
+
const message = `Existing external job '${current.providerJobId}' does not match provider, prompt digest, or follow-up lineage. Refusing to redispatch prompt.`;
|
|
308
|
+
const status = failureStatus({ provider, promptDigest, options, followUp: input.followUp, previous: current, code: "recovery-mismatch", message });
|
|
224
309
|
publish(status);
|
|
225
310
|
return { output: message, exitCode: 1, error: message, externalJob: status };
|
|
226
311
|
}
|
|
227
312
|
handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, { operation: "reattach", provider, providerJobId: current.providerJobId });
|
|
228
313
|
} else {
|
|
229
314
|
if (current && blocksStartRedispatch(current, provider, promptDigest)) {
|
|
230
|
-
const message = `External-job start for provider '${provider}' previously ended without a durable provider job id. Refusing to redispatch the prompt automatically.`;
|
|
231
|
-
const status = failureStatus({ provider, promptDigest, options, previous: current, code: "start-redispatch-blocked", message });
|
|
315
|
+
const message = `External-job ${input.followUp ? "follow-up" : "start"} for provider '${provider}' previously ended without a durable provider job id. Refusing to redispatch the prompt automatically.`;
|
|
316
|
+
const status = failureStatus({ provider, promptDigest, options, followUp: input.followUp, previous: current, code: input.followUp ? "dispatch-redispatch-blocked" : "start-redispatch-blocked", message });
|
|
232
317
|
publish(status);
|
|
233
318
|
return { output: message, exitCode: 1, error: message, externalJob: status };
|
|
234
319
|
}
|
|
235
|
-
publish({ provider, promptDigest, options, state: "queued", startedAt, updatedAt: Date.now() });
|
|
320
|
+
publish({ provider, promptDigest, ...(input.followUp ? { operation: "follow-up" as const, ...input.followUp } : {}), options, state: "queued", startedAt, updatedAt: Date.now() });
|
|
321
|
+
if (input.followUp) {
|
|
236
322
|
handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
323
|
+
operation: "follow-up",
|
|
324
|
+
provider,
|
|
325
|
+
followUp: {
|
|
326
|
+
prompt: input.prompt,
|
|
327
|
+
promptDigest,
|
|
328
|
+
cwd: input.cwd,
|
|
329
|
+
runId: input.runId,
|
|
330
|
+
stepIndex: input.stepIndex,
|
|
331
|
+
agent: input.agent,
|
|
332
|
+
options,
|
|
333
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
334
|
+
...input.followUp,
|
|
335
|
+
},
|
|
336
|
+
}, undefined, localCancellation);
|
|
337
|
+
} else {
|
|
338
|
+
handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, {
|
|
339
|
+
operation: "start",
|
|
340
|
+
provider,
|
|
341
|
+
start: {
|
|
342
|
+
prompt: input.prompt,
|
|
343
|
+
promptDigest,
|
|
344
|
+
cwd: input.cwd,
|
|
345
|
+
runId: input.runId,
|
|
346
|
+
stepIndex: input.stepIndex,
|
|
347
|
+
agent: input.agent,
|
|
348
|
+
options,
|
|
349
|
+
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
350
|
+
},
|
|
351
|
+
}, undefined, localCancellation);
|
|
352
|
+
}
|
|
250
353
|
}
|
|
251
|
-
publish(statusFromHandle({ provider, promptDigest, options, startedAt, previous: current, handle }));
|
|
354
|
+
publish(statusFromHandle({ provider, promptDigest, options, followUp: input.followUp, startedAt, previous: current, handle }));
|
|
252
355
|
while (!terminal(handle.state)) {
|
|
253
356
|
if (timedOut || stopped) {
|
|
254
357
|
const message = stopped
|
|
@@ -258,7 +361,7 @@ export async function runExternalJob(input: {
|
|
|
258
361
|
}
|
|
259
362
|
await sleep(STATUS_POLL_INTERVAL_MS);
|
|
260
363
|
handle = await requestExternalJobOperation<ExternalJobHandle>(input.asyncDir, { operation: "status", provider, providerJobId: handle.providerJobId });
|
|
261
|
-
publish(statusFromHandle({ provider, promptDigest, options, previous: current, handle }));
|
|
364
|
+
publish(statusFromHandle({ provider, promptDigest, options, followUp: input.followUp, previous: current, handle }));
|
|
262
365
|
}
|
|
263
366
|
const result = await requestExternalJobOperation<ExternalJobResult>(input.asyncDir, { operation: "result", provider, providerJobId: handle.providerJobId });
|
|
264
367
|
let artifactPath = result.artifactPath;
|
|
@@ -266,7 +369,7 @@ export async function runExternalJob(input: {
|
|
|
266
369
|
artifactPath = path.join(input.asyncDir, `external-job-${input.stepIndex}.result.md`);
|
|
267
370
|
fs.writeFileSync(artifactPath, result.output, "utf-8");
|
|
268
371
|
}
|
|
269
|
-
const finalStatus = statusFromHandle({ provider, promptDigest, options, previous: current, handle: result, resultArtifactPath: artifactPath });
|
|
372
|
+
const finalStatus = statusFromHandle({ provider, promptDigest, options, followUp: input.followUp, previous: current, handle: result, resultArtifactPath: artifactPath });
|
|
270
373
|
publish(finalStatus);
|
|
271
374
|
const output = resultOutput(result, artifactPath);
|
|
272
375
|
const error = result.state === "completed" ? undefined : result.failureMessage ?? `External job ${result.state}.`;
|
|
@@ -276,7 +379,7 @@ export async function runExternalJob(input: {
|
|
|
276
379
|
? error
|
|
277
380
|
: new ExternalJobProviderError(error instanceof Error ? error.message : String(error), { code: "provider-error", cause: error });
|
|
278
381
|
const message = formatError(providerError, provider);
|
|
279
|
-
const status = failureStatus({ provider, promptDigest, options, previous: current, code: providerError.code, message, ...(providerError.blockingJobId ? { blockingJobId: providerError.blockingJobId } : {}) });
|
|
382
|
+
const status = failureStatus({ provider, promptDigest, options, followUp: input.followUp, previous: current, code: providerError.code, message, ...(providerError.blockingJobId ? { blockingJobId: providerError.blockingJobId } : {}) });
|
|
280
383
|
publish(status);
|
|
281
384
|
return { output: message, exitCode: 1, error: message, ...(timedOut ? { timedOut: true } : {}), ...(stopped ? { stopped: true } : {}), externalJob: status };
|
|
282
385
|
} finally {
|