pi-ultracode 0.3.2 → 0.5.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/README.md +32 -14
- package/README.zh-CN.md +32 -14
- package/extensions/ultracode.ts +11 -10
- package/package.json +17 -7
- package/src/commands.ts +21 -22
- package/src/depth.ts +38 -0
- package/src/index.ts +9 -1
- package/src/mode.ts +82 -58
- package/src/prompts.ts +65 -32
- package/src/thinking.ts +1 -1
- package/src/workflow/agent-runner.ts +23 -8
- package/src/workflow/agent-types.ts +19 -15
- package/src/workflow/hermetic-git.ts +31 -0
- package/src/workflow/journal.ts +134 -3
- package/src/workflow/parser.ts +254 -38
- package/src/workflow/registry.ts +44 -67
- package/src/workflow/repository-context.ts +76 -0
- package/src/workflow/run-artifacts.ts +60 -14
- package/src/workflow/runtime.ts +380 -180
- package/src/workflow/saved-workflow.ts +37 -0
- package/src/workflow/script-executor.ts +56 -10
- package/src/workflow/script-worker.mjs +65 -19
- package/src/workflow/tool.ts +108 -58
- package/src/workflow/workflow-overlay.ts +4 -4
- package/src/workflow/worktree.ts +1965 -244
- package/types/workflow.d.ts +2 -4
package/src/workflow/runtime.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* claim that Node's vm is a security sandbox.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import * as fs from "node:fs";
|
|
12
13
|
import * as os from "node:os";
|
|
13
14
|
import * as path from "node:path";
|
|
14
15
|
import { parseWorkflowScript, type WorkflowMeta } from "./parser.ts";
|
|
@@ -39,12 +40,7 @@ import type {
|
|
|
39
40
|
ThinkingLevel,
|
|
40
41
|
} from "./agent-runner.ts";
|
|
41
42
|
import { safeDisplayText } from "./display-text.ts";
|
|
42
|
-
import {
|
|
43
|
-
artifactPathExists,
|
|
44
|
-
readArtifactFile,
|
|
45
|
-
readContainedArtifactFile,
|
|
46
|
-
standaloneWorkflowRunsDir,
|
|
47
|
-
} from "./run-artifacts.ts";
|
|
43
|
+
import { standaloneWorkflowRunsDir } from "./run-artifacts.ts";
|
|
48
44
|
import {
|
|
49
45
|
assertWorkflowArgsLimit,
|
|
50
46
|
assertWorkflowOutputLimit,
|
|
@@ -52,8 +48,11 @@ import {
|
|
|
52
48
|
} from "./value-limits.ts";
|
|
53
49
|
|
|
54
50
|
import { discoverAgentTypes, resolveAgentType, type AgentTypeDef } from "./agent-types.ts";
|
|
51
|
+
import { readSavedWorkflowByName } from "./saved-workflow.ts";
|
|
52
|
+
import { resolveRepositoryContext } from "./repository-context.ts";
|
|
55
53
|
import {
|
|
56
54
|
agentCallKey,
|
|
55
|
+
hashBytes,
|
|
57
56
|
hashString,
|
|
58
57
|
stableStringify,
|
|
59
58
|
RunJournal,
|
|
@@ -64,8 +63,8 @@ import {
|
|
|
64
63
|
captureWorktreeDiff,
|
|
65
64
|
createWorktree,
|
|
66
65
|
hasChanges,
|
|
67
|
-
isGitRepo,
|
|
68
66
|
removeWorktree,
|
|
67
|
+
verifyAppliedPatch,
|
|
69
68
|
writeRescuePatch,
|
|
70
69
|
type Worktree,
|
|
71
70
|
type WorktreeDiff,
|
|
@@ -73,6 +72,7 @@ import {
|
|
|
73
72
|
|
|
74
73
|
const MAX_CONCURRENCY = 16;
|
|
75
74
|
const DEFAULT_CLEANUP_TIMEOUT_MS = 25_000;
|
|
75
|
+
const REPOSITORY_APPLY_LOCKS = new Map<string, Mutex>();
|
|
76
76
|
export { DEFAULT_MAX_AGENTS, ABSOLUTE_MAX_AGENTS };
|
|
77
77
|
export const MAX_WORKFLOW_LOGS = 256;
|
|
78
78
|
export const WORKFLOW_LOG_OMITTED_TEXT = "additional workflow logs omitted";
|
|
@@ -91,6 +91,10 @@ export type AgentActivityEvent = AgentEventBase & AgentActivityInput;
|
|
|
91
91
|
export interface WorkflowRunOptions {
|
|
92
92
|
cwd?: string;
|
|
93
93
|
args?: unknown;
|
|
94
|
+
/** Parent session's immutable project-trust decision. Defaults to false. */
|
|
95
|
+
projectTrusted?: boolean;
|
|
96
|
+
/** Canonical repository and relative-cwd identity. Computed when omitted. */
|
|
97
|
+
targetIdentity?: string;
|
|
94
98
|
signal?: AbortSignal;
|
|
95
99
|
concurrency?: number;
|
|
96
100
|
/** Logical cap on agent() slots for this workflow. Defaults to 128; absolute range 1..1024. */
|
|
@@ -113,7 +117,7 @@ export interface WorkflowRunOptions {
|
|
|
113
117
|
runner?: { run: WorkflowAgentRunner["run"] };
|
|
114
118
|
journal?: RunJournal;
|
|
115
119
|
/** Loads a saved workflow body by name; defaults to disk discovery. */
|
|
116
|
-
loadSavedWorkflow?: (
|
|
120
|
+
loadSavedWorkflow?: (name: string) => { meta: WorkflowMeta; body: string };
|
|
117
121
|
onLog?: (message: string) => void;
|
|
118
122
|
onPhase?: (title: string) => void;
|
|
119
123
|
onAgentStart?: (event: AgentEventBase & {
|
|
@@ -160,6 +164,8 @@ export interface WorkflowRunResult<T = unknown> {
|
|
|
160
164
|
|
|
161
165
|
interface ActivePanelTrace {
|
|
162
166
|
branchCount: number;
|
|
167
|
+
panelReservationId: string;
|
|
168
|
+
branchReservationIds: string[];
|
|
163
169
|
calls: Array<{ callPath: string; status: "pending" | "success" | "failed" }>;
|
|
164
170
|
}
|
|
165
171
|
|
|
@@ -175,23 +181,41 @@ interface RuntimeState {
|
|
|
175
181
|
maxAgents: number;
|
|
176
182
|
}
|
|
177
183
|
|
|
184
|
+
type WorktreeOutcome =
|
|
185
|
+
| { status: "unchanged" | "applied"; keepWorktree: false }
|
|
186
|
+
| { status: "rescued"; keepWorktree: false; recoveryPath: string }
|
|
187
|
+
| { status: "preserved"; keepWorktree: true; recoveryPath: string };
|
|
188
|
+
|
|
178
189
|
export async function runWorkflow<T = unknown>(
|
|
179
190
|
rawScript: string,
|
|
180
191
|
options: WorkflowRunOptions = {},
|
|
181
192
|
): Promise<WorkflowRunResult<T>> {
|
|
182
193
|
if (options.signal?.aborted) throw new WorkflowAbortError();
|
|
183
194
|
assertWorkflowArgsLimit(options.args);
|
|
195
|
+
if (options.journal && options.journal.projectTrusted !== options.projectTrusted) {
|
|
196
|
+
throw new WorkflowPolicyError("workflow journal project trust context does not match this execution");
|
|
197
|
+
}
|
|
198
|
+
const actualTargetIdentity = resolveRepositoryContext(options.cwd ?? process.cwd()).identity;
|
|
199
|
+
if (options.targetIdentity !== undefined && options.targetIdentity !== actualTargetIdentity) {
|
|
200
|
+
throw new WorkflowPolicyError("workflow target identity does not match the actual cwd");
|
|
201
|
+
}
|
|
202
|
+
const targetIdentity = actualTargetIdentity;
|
|
203
|
+
if (options.journal && options.journal.targetIdentity !== targetIdentity) {
|
|
204
|
+
throw new WorkflowPolicyError("workflow journal repository/cwd target does not match this execution");
|
|
205
|
+
}
|
|
206
|
+
const cleanupTimeoutMs = normalizeCleanupTimeout(options.cleanupTimeoutMs);
|
|
207
|
+
const effectiveOptions: WorkflowRunOptions = { ...options, targetIdentity, cleanupTimeoutMs };
|
|
184
208
|
const started = Date.now();
|
|
185
209
|
const maxAgents = normalizeMaxAgents(options.maxAgents ?? options.journal?.effectiveMaxAgents);
|
|
186
210
|
const { meta, body } = parseWorkflowScript(rawScript);
|
|
187
|
-
const runtime = new Runtime(
|
|
211
|
+
const runtime = new Runtime(effectiveOptions, maxAgents);
|
|
188
212
|
const onOuterAbort = () => runtime.abort();
|
|
189
213
|
options.signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
190
214
|
if (options.signal?.aborted) runtime.abort();
|
|
191
215
|
try {
|
|
192
216
|
const result = await runtime.runBody(body, options.args, meta.name);
|
|
193
217
|
if (runtime.policyError) throw runtime.policyError;
|
|
194
|
-
await runtime.drain(
|
|
218
|
+
await runtime.drain(cleanupTimeoutMs);
|
|
195
219
|
if (runtime.policyError) throw runtime.policyError;
|
|
196
220
|
assertWorkflowOutputLimit(result);
|
|
197
221
|
// Keep the public value detached from the Worker message object.
|
|
@@ -213,10 +237,14 @@ export async function runWorkflow<T = unknown>(
|
|
|
213
237
|
} catch (error) {
|
|
214
238
|
runtime.abort();
|
|
215
239
|
if (error instanceof WorkflowCleanupTimeoutError) throw error;
|
|
216
|
-
const cleanupTimeoutMs = normalizeCleanupTimeout(options.cleanupTimeoutMs);
|
|
217
240
|
try {
|
|
218
241
|
await runtime.drain(cleanupTimeoutMs);
|
|
219
242
|
} catch (cleanupError) {
|
|
243
|
+
const policy = runtime.policyError ?? (isWorkflowPolicyError(error) ? error : undefined);
|
|
244
|
+
if (policy) {
|
|
245
|
+
(policy as WorkflowPolicyError & { cleanupError?: unknown }).cleanupError = cleanupError;
|
|
246
|
+
throw policy;
|
|
247
|
+
}
|
|
220
248
|
throw cleanupError;
|
|
221
249
|
}
|
|
222
250
|
if (runtime.policyError) throw runtime.policyError;
|
|
@@ -237,7 +265,6 @@ class Runtime implements ScriptExecutorHost {
|
|
|
237
265
|
private readonly agentTypes: Map<string, AgentTypeDef>;
|
|
238
266
|
private readonly limiter: <R>(fn: () => Promise<R>) => Promise<R>;
|
|
239
267
|
private readonly pending = new Set<Promise<unknown>>();
|
|
240
|
-
private readonly applyLock = new Mutex();
|
|
241
268
|
private readonly childController = new AbortController();
|
|
242
269
|
private readonly scriptController = new AbortController();
|
|
243
270
|
private readonly admission: AgentAdmission;
|
|
@@ -245,13 +272,14 @@ class Runtime implements ScriptExecutorHost {
|
|
|
245
272
|
private readonly agentRequestOccurrences = new Map<string, number>();
|
|
246
273
|
private readonly nestedRequestOccurrences = new Map<string, number>();
|
|
247
274
|
private readonly activePanelTraces = new Map<string, ActivePanelTrace>();
|
|
275
|
+
private cleanupDeadline?: number;
|
|
248
276
|
policyError: WorkflowPolicyError | undefined;
|
|
249
277
|
|
|
250
278
|
constructor(options: WorkflowRunOptions, maxAgents: number) {
|
|
251
279
|
this.options = options;
|
|
252
280
|
this.cwd = options.cwd ?? process.cwd();
|
|
253
281
|
this.runnerInstance = options.runner;
|
|
254
|
-
this.agentTypes = discoverAgentTypes(this.cwd);
|
|
282
|
+
this.agentTypes = discoverAgentTypes(this.cwd, options.projectTrusted ?? false);
|
|
255
283
|
this.admission = new AgentAdmission(maxAgents, options.journal?.agentsUsed ?? 0);
|
|
256
284
|
this.state = {
|
|
257
285
|
logs: [],
|
|
@@ -274,22 +302,33 @@ class Runtime implements ScriptExecutorHost {
|
|
|
274
302
|
}
|
|
275
303
|
|
|
276
304
|
abort(): void {
|
|
277
|
-
|
|
305
|
+
this.cleanupDeadline ??= Date.now() + (this.options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS);
|
|
306
|
+
this.abortChildren();
|
|
278
307
|
if (!this.scriptController.signal.aborted) this.scriptController.abort();
|
|
279
308
|
}
|
|
280
309
|
|
|
310
|
+
abortChildren(): void {
|
|
311
|
+
this.cleanupDeadline ??= Date.now() + (this.options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS);
|
|
312
|
+
if (!this.childController.signal.aborted) this.childController.abort();
|
|
313
|
+
}
|
|
314
|
+
|
|
281
315
|
async drain(timeoutMs?: number): Promise<void> {
|
|
282
316
|
const configuredTimeoutMs = timeoutMs;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
317
|
+
if (configuredTimeoutMs !== undefined) this.cleanupDeadline ??= Date.now() + configuredTimeoutMs;
|
|
318
|
+
const deadline = configuredTimeoutMs === undefined ? undefined : this.cleanupDeadline;
|
|
319
|
+
try {
|
|
320
|
+
while (this.pending.size > 0) {
|
|
321
|
+
const batch = Promise.allSettled([...this.pending]);
|
|
322
|
+
if (deadline === undefined) {
|
|
323
|
+
await batch;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const remainingMs = deadline - Date.now();
|
|
327
|
+
if (remainingMs <= 0) throw new WorkflowCleanupTimeoutError(configuredTimeoutMs!);
|
|
328
|
+
await raceWithCleanupTimeout(batch, remainingMs, configuredTimeoutMs!);
|
|
289
329
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
await raceWithCleanupTimeout(batch, remainingMs, configuredTimeoutMs!);
|
|
330
|
+
} finally {
|
|
331
|
+
this.releaseOpenPanels();
|
|
293
332
|
}
|
|
294
333
|
}
|
|
295
334
|
|
|
@@ -298,6 +337,7 @@ class Runtime implements ScriptExecutorHost {
|
|
|
298
337
|
if (!this.runnerInstance) {
|
|
299
338
|
this.runnerInstance = new WorkflowAgentRunner({
|
|
300
339
|
cwd: this.cwd,
|
|
340
|
+
projectTrusted: this.options.projectTrusted ?? false,
|
|
301
341
|
modelRegistry: this.options.modelRegistry,
|
|
302
342
|
modelRuntime: this.options.modelRuntime,
|
|
303
343
|
model: this.options.model,
|
|
@@ -317,6 +357,7 @@ class Runtime implements ScriptExecutorHost {
|
|
|
317
357
|
stallTimeoutMs: this.options.stallTimeoutMs,
|
|
318
358
|
hostCallLimit: this.options.hostCallLimit,
|
|
319
359
|
workerMemoryLimitMb: this.options.workerMemoryLimitMb,
|
|
360
|
+
fatalDrainTimeoutMs: this.options.cleanupTimeoutMs,
|
|
320
361
|
});
|
|
321
362
|
}
|
|
322
363
|
|
|
@@ -379,7 +420,12 @@ class Runtime implements ScriptExecutorHost {
|
|
|
379
420
|
this.admission.releasePanel(panel.panelReservationId);
|
|
380
421
|
throw new WorkflowPolicyError(`workflow produced duplicate parallel callPath: ${callPath}`);
|
|
381
422
|
}
|
|
382
|
-
this.activePanelTraces.set(callPath, {
|
|
423
|
+
this.activePanelTraces.set(callPath, {
|
|
424
|
+
branchCount: payload.branchCount,
|
|
425
|
+
panelReservationId: panel.panelReservationId,
|
|
426
|
+
branchReservationIds: [...panel.branchReservationIds],
|
|
427
|
+
calls: [],
|
|
428
|
+
});
|
|
383
429
|
return panel;
|
|
384
430
|
} catch (error) {
|
|
385
431
|
this.recordPolicyError(error);
|
|
@@ -433,17 +479,17 @@ class Runtime implements ScriptExecutorHost {
|
|
|
433
479
|
}): Promise<void> {
|
|
434
480
|
const callPath = typeof payload.callPath === "string" ? payload.callPath : undefined;
|
|
435
481
|
const trace = callPath ? this.activePanelTraces.get(callPath) : undefined;
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
482
|
+
try {
|
|
483
|
+
if (payload.completed && callPath && trace) {
|
|
484
|
+
const branchOutcomes = Array.isArray(payload.branchOutcomes)
|
|
485
|
+
? payload.branchOutcomes
|
|
486
|
+
: [];
|
|
487
|
+
if (
|
|
488
|
+
branchOutcomes.length !== trace.branchCount
|
|
489
|
+
|| branchOutcomes.some((outcome) => outcome !== "success" && outcome !== "failed")
|
|
490
|
+
) {
|
|
491
|
+
throw new WorkflowPolicyError(`workflow returned invalid branch outcomes for ${callPath}`);
|
|
492
|
+
}
|
|
447
493
|
this.options.journal?.recordPanelComplete(
|
|
448
494
|
callPath,
|
|
449
495
|
trace.branchCount,
|
|
@@ -453,14 +499,25 @@ class Runtime implements ScriptExecutorHost {
|
|
|
453
499
|
status: call.status === "success" ? "success" : "failed",
|
|
454
500
|
})),
|
|
455
501
|
);
|
|
456
|
-
} catch (error) {
|
|
457
|
-
const failure = journalPolicyError(error, "workflow panel completion commit failed");
|
|
458
|
-
this.recordPolicyError(failure);
|
|
459
|
-
throw failure;
|
|
460
502
|
}
|
|
503
|
+
} catch (error) {
|
|
504
|
+
const failure = journalPolicyError(error, "workflow panel completion commit failed");
|
|
505
|
+
this.recordPolicyError(failure);
|
|
506
|
+
throw failure;
|
|
507
|
+
} finally {
|
|
508
|
+
this.admission.releasePanel(payload.reservationId);
|
|
509
|
+
if (callPath && trace) this.releasePanelTrace(callPath, trace);
|
|
461
510
|
}
|
|
462
|
-
|
|
463
|
-
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private releasePanelTrace(callPath: string, trace: ActivePanelTrace): void {
|
|
514
|
+
for (const reservationId of trace.branchReservationIds) this.admission.releasePanel(reservationId);
|
|
515
|
+
this.admission.releasePanel(trace.panelReservationId);
|
|
516
|
+
this.activePanelTraces.delete(callPath);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private releaseOpenPanels(): void {
|
|
520
|
+
for (const [callPath, trace] of this.activePanelTraces) this.releasePanelTrace(callPath, trace);
|
|
464
521
|
}
|
|
465
522
|
|
|
466
523
|
private beginPanelAgent(callPath: string): void {
|
|
@@ -494,7 +551,8 @@ class Runtime implements ScriptExecutorHost {
|
|
|
494
551
|
const requestOccurrence = this.nestedRequestOccurrences.get(requestBase) ?? 0;
|
|
495
552
|
this.nestedRequestOccurrences.set(requestBase, requestOccurrence + 1);
|
|
496
553
|
const callPath = `${requestBase}:${requestOccurrence}`;
|
|
497
|
-
const loader = this.options.loadSavedWorkflow
|
|
554
|
+
const loader = this.options.loadSavedWorkflow
|
|
555
|
+
?? ((r) => loadSavedWorkflowFromDisk(r, this.cwd, this.options.projectTrusted ?? false));
|
|
498
556
|
const loaded = loader(ref);
|
|
499
557
|
const sourceHash = hashString(
|
|
500
558
|
`${stableStringify(loaded.meta)}\u0000${loaded.body}\u0000${argsJson}`,
|
|
@@ -552,6 +610,11 @@ class Runtime implements ScriptExecutorHost {
|
|
|
552
610
|
const label = opts.label?.trim() || defaultLabel(assignedPhase, id);
|
|
553
611
|
const workflowPath = Array.isArray(payload.workflowPath) ? [...payload.workflowPath] : [];
|
|
554
612
|
const agentTypeDef = resolveAgentType(opts.agentType, this.agentTypes);
|
|
613
|
+
if (opts.agentType !== undefined && !agentTypeDef) {
|
|
614
|
+
const error = new WorkflowPolicyError(`unknown agent type: ${opts.agentType}`);
|
|
615
|
+
this.recordPolicyError(error);
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
555
618
|
const selection = resolveModelSelection({
|
|
556
619
|
pattern: opts.model,
|
|
557
620
|
roleModel: agentTypeDef?.model,
|
|
@@ -566,10 +629,12 @@ class Runtime implements ScriptExecutorHost {
|
|
|
566
629
|
agentTypeDefinition: agentTypeDef,
|
|
567
630
|
effectiveModel: modelIdentity(selection.model),
|
|
568
631
|
effectiveThinking: selection.thinkingLevel,
|
|
632
|
+
targetIdentity: this.options.targetIdentity,
|
|
569
633
|
});
|
|
570
634
|
|
|
571
635
|
let cached: JournalAgentRecord | undefined;
|
|
572
636
|
try {
|
|
637
|
+
this.assertTargetIdentityCurrent();
|
|
573
638
|
cached = this.options.journal?.lookup(callPath, key);
|
|
574
639
|
} catch (error) {
|
|
575
640
|
const failure = journalPolicyError(error, "workflow cache lookup failed");
|
|
@@ -577,6 +642,7 @@ class Runtime implements ScriptExecutorHost {
|
|
|
577
642
|
throw failure;
|
|
578
643
|
}
|
|
579
644
|
if (cached) {
|
|
645
|
+
await this.verifyCachedDelivery(cached);
|
|
580
646
|
this.beginPanelAgent(callPath);
|
|
581
647
|
this.state.cachedCount++;
|
|
582
648
|
this.state.spent += cached.outputTokens ?? 0;
|
|
@@ -609,7 +675,7 @@ class Runtime implements ScriptExecutorHost {
|
|
|
609
675
|
structuredOutput: opts.schema != null,
|
|
610
676
|
cachedRecord: cloneResult(cached, "cached agent record"),
|
|
611
677
|
}));
|
|
612
|
-
this.
|
|
678
|
+
this.notifyObserverBestEffort(() => this.options.onAgentEnd?.({
|
|
613
679
|
id,
|
|
614
680
|
callPath,
|
|
615
681
|
label,
|
|
@@ -652,12 +718,13 @@ class Runtime implements ScriptExecutorHost {
|
|
|
652
718
|
structuredOutput: opts.schema != null,
|
|
653
719
|
}));
|
|
654
720
|
let worktree: Worktree | undefined;
|
|
655
|
-
let
|
|
721
|
+
let worktreeSettled = false;
|
|
722
|
+
let keepWorktree = true;
|
|
656
723
|
let observedUsage: AgentUsage | undefined;
|
|
657
724
|
try {
|
|
658
725
|
this.throwIfAborted();
|
|
659
726
|
if (opts.isolation === "worktree") {
|
|
660
|
-
worktree = this.
|
|
727
|
+
worktree = this.createIsolatedWorktree(id);
|
|
661
728
|
}
|
|
662
729
|
|
|
663
730
|
const runner = this.getRunner();
|
|
@@ -698,62 +765,87 @@ class Runtime implements ScriptExecutorHost {
|
|
|
698
765
|
throw error;
|
|
699
766
|
}
|
|
700
767
|
|
|
701
|
-
|
|
702
|
-
|
|
768
|
+
const commitAgent = (delivery?: { patchPath: string; patchHash: string }) => {
|
|
769
|
+
this.assertTargetIdentityCurrent();
|
|
770
|
+
try {
|
|
771
|
+
this.options.journal?.recordAgent({
|
|
772
|
+
callPath,
|
|
773
|
+
seq: id,
|
|
774
|
+
key,
|
|
775
|
+
label,
|
|
776
|
+
value: clonedValue,
|
|
777
|
+
inputTokens: usage.inputTokens,
|
|
778
|
+
outputTokens: usage.outputTokens,
|
|
779
|
+
totalTokens: usage.totalTokens,
|
|
780
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
781
|
+
cacheWriteTokens: usage.cacheWriteTokens,
|
|
782
|
+
cost: usage.cost,
|
|
783
|
+
turns: usage.turns,
|
|
784
|
+
toolUses: usage.toolUses,
|
|
785
|
+
retries: usage.retries,
|
|
786
|
+
compactions: usage.compactions,
|
|
787
|
+
requestedModelId: opts.model,
|
|
788
|
+
requestedEffort: this.options.thinkingLevel,
|
|
789
|
+
modelId: result.modelId,
|
|
790
|
+
effort: result.effort,
|
|
791
|
+
agentType: opts.agentType,
|
|
792
|
+
isolation: opts.isolation,
|
|
793
|
+
structuredOutput: opts.schema != null,
|
|
794
|
+
deliveryPatchPath: delivery?.patchPath,
|
|
795
|
+
deliveryPatchHash: delivery?.patchHash,
|
|
796
|
+
startedAt: agentStartedAt,
|
|
797
|
+
durationMs: Date.now() - agentStartedAt,
|
|
798
|
+
});
|
|
799
|
+
} catch (error) {
|
|
800
|
+
const failure = journalPolicyError(error, "workflow agent-result commit failed");
|
|
801
|
+
this.recordPolicyError(failure);
|
|
802
|
+
throw failure;
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
if (worktree) {
|
|
807
|
+
const outcome = await this.integrateWorktree(worktree, id, label, callPath, commitAgent);
|
|
808
|
+
worktreeSettled = true;
|
|
809
|
+
keepWorktree = outcome.keepWorktree;
|
|
810
|
+
if (outcome.status === "rescued" || outcome.status === "preserved") {
|
|
811
|
+
throw new Error(`worktree changes were not delivered; recover from ${outcome.recoveryPath}`);
|
|
812
|
+
}
|
|
813
|
+
} else {
|
|
814
|
+
commitAgent();
|
|
815
|
+
}
|
|
816
|
+
// The durable agent record is the publication point. Completion observers
|
|
817
|
+
// run afterward and cannot veto a committed result.
|
|
818
|
+
this.notifyObserverBestEffort(() => this.options.onAgentEnd?.({
|
|
703
819
|
id,
|
|
704
820
|
callPath,
|
|
705
821
|
label,
|
|
706
822
|
phase: assignedPhase,
|
|
707
823
|
workflowPath,
|
|
708
|
-
result: clonedValue,
|
|
824
|
+
result: cloneResult(clonedValue, "live observer result"),
|
|
709
825
|
status: "done",
|
|
710
826
|
usage,
|
|
711
827
|
modelId: result.modelId,
|
|
712
828
|
effort: result.effort,
|
|
713
829
|
}));
|
|
714
|
-
// Commit resume state only after observers accepted the successful
|
|
715
|
-
// completion. Otherwise a callback failure could return null live but
|
|
716
|
-
// replay a success from the journal on the next generation.
|
|
717
|
-
try {
|
|
718
|
-
this.options.journal?.recordAgent({
|
|
719
|
-
callPath,
|
|
720
|
-
seq: id,
|
|
721
|
-
key,
|
|
722
|
-
label,
|
|
723
|
-
value: clonedValue,
|
|
724
|
-
inputTokens: usage.inputTokens,
|
|
725
|
-
outputTokens: usage.outputTokens,
|
|
726
|
-
totalTokens: usage.totalTokens,
|
|
727
|
-
cacheReadTokens: usage.cacheReadTokens,
|
|
728
|
-
cacheWriteTokens: usage.cacheWriteTokens,
|
|
729
|
-
cost: usage.cost,
|
|
730
|
-
turns: usage.turns,
|
|
731
|
-
toolUses: usage.toolUses,
|
|
732
|
-
retries: usage.retries,
|
|
733
|
-
compactions: usage.compactions,
|
|
734
|
-
requestedModelId: opts.model,
|
|
735
|
-
requestedEffort: this.options.thinkingLevel,
|
|
736
|
-
modelId: result.modelId,
|
|
737
|
-
effort: result.effort,
|
|
738
|
-
agentType: opts.agentType,
|
|
739
|
-
isolation: opts.isolation,
|
|
740
|
-
structuredOutput: opts.schema != null,
|
|
741
|
-
startedAt: agentStartedAt,
|
|
742
|
-
durationMs: Date.now() - agentStartedAt,
|
|
743
|
-
});
|
|
744
|
-
} catch (error) {
|
|
745
|
-
const failure = journalPolicyError(error, "workflow agent-result commit failed");
|
|
746
|
-
this.recordPolicyError(failure);
|
|
747
|
-
throw failure;
|
|
748
|
-
}
|
|
749
830
|
this.finishPanelAgent(callPath, "success");
|
|
750
831
|
return clonedValue;
|
|
751
832
|
} catch (error) {
|
|
833
|
+
if (worktree && !worktreeSettled) {
|
|
834
|
+
const outcome = await this.recoverFailedWorktree(worktree, id, label);
|
|
835
|
+
worktreeSettled = true;
|
|
836
|
+
keepWorktree = outcome.keepWorktree;
|
|
837
|
+
if (this.childController.signal.aborted || this.options.signal?.aborted || isWorkflowPolicyError(error)) {
|
|
838
|
+
// Recursive worktree removal is not synchronously deadline-safe.
|
|
839
|
+
// Preserve it on cancellation/policy cleanup instead of overrunning the budget.
|
|
840
|
+
keepWorktree = true;
|
|
841
|
+
this.logLineBestEffort(`worktree[${label}]: cleanup cancelled; worktree KEPT at ${worktree.path}`);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
752
844
|
if (this.childController.signal.aborted || this.options.signal?.aborted || isWorkflowPolicyError(error)) throw error;
|
|
753
845
|
const message = error instanceof Error ? error.message : String(error);
|
|
754
846
|
this.finishPanelAgent(callPath, "failed");
|
|
755
847
|
this.logLine(`agent ${label} failed: ${message}`);
|
|
756
|
-
this.
|
|
848
|
+
this.notifyObserverBestEffort(() => this.options.onAgentEnd?.({
|
|
757
849
|
id,
|
|
758
850
|
callPath,
|
|
759
851
|
label,
|
|
@@ -766,11 +858,11 @@ class Runtime implements ScriptExecutorHost {
|
|
|
766
858
|
}));
|
|
767
859
|
return null;
|
|
768
860
|
} finally {
|
|
769
|
-
if (worktree && !keepWorktree) {
|
|
861
|
+
if (worktree && worktreeSettled && !keepWorktree) {
|
|
770
862
|
try {
|
|
771
863
|
removeWorktree(worktree);
|
|
772
864
|
} catch {
|
|
773
|
-
//
|
|
865
|
+
// A cleanup failure must not change the delivery outcome.
|
|
774
866
|
}
|
|
775
867
|
}
|
|
776
868
|
}
|
|
@@ -779,6 +871,36 @@ class Runtime implements ScriptExecutorHost {
|
|
|
779
871
|
return await run;
|
|
780
872
|
}
|
|
781
873
|
|
|
874
|
+
private async verifyCachedDelivery(record: JournalAgentRecord): Promise<void> {
|
|
875
|
+
if (!record.deliveryPatchPath || !record.deliveryPatchHash) return;
|
|
876
|
+
const context = resolveRepositoryContext(this.cwd);
|
|
877
|
+
if (!context.repoRoot || !this.options.journal) {
|
|
878
|
+
const error = new WorkflowPolicyError(`cached worktree delivery at ${record.callPath} has no matching Git target`);
|
|
879
|
+
this.recordPolicyError(error);
|
|
880
|
+
throw error;
|
|
881
|
+
}
|
|
882
|
+
const patchRoot = path.join(path.dirname(this.options.journal.filePath), "patches");
|
|
883
|
+
try {
|
|
884
|
+
const valid = await repositoryApplyLock(context.repoRoot).run(async () =>
|
|
885
|
+
verifyAppliedPatch(
|
|
886
|
+
context.repoRoot!,
|
|
887
|
+
record.deliveryPatchPath!,
|
|
888
|
+
record.deliveryPatchHash!,
|
|
889
|
+
patchRoot,
|
|
890
|
+
));
|
|
891
|
+
if (!valid) {
|
|
892
|
+
throw new Error("the delivered patch is no longer present in the shared working tree");
|
|
893
|
+
}
|
|
894
|
+
} catch (error) {
|
|
895
|
+
const failure = new WorkflowPolicyError(
|
|
896
|
+
`cached worktree delivery at ${record.callPath} requires recovery: ${errorMessage(error)}`,
|
|
897
|
+
);
|
|
898
|
+
(failure as Error & { cause?: unknown }).cause = error;
|
|
899
|
+
this.recordPolicyError(failure);
|
|
900
|
+
throw failure;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
782
904
|
private notifyObserver(callback: () => void): void {
|
|
783
905
|
try {
|
|
784
906
|
callback();
|
|
@@ -790,96 +912,150 @@ class Runtime implements ScriptExecutorHost {
|
|
|
790
912
|
}
|
|
791
913
|
}
|
|
792
914
|
|
|
915
|
+
private notifyObserverBestEffort(callback: () => void): void {
|
|
916
|
+
try {
|
|
917
|
+
callback();
|
|
918
|
+
} catch (error) {
|
|
919
|
+
this.logLineBestEffort(`workflow completion observer failed: ${errorMessage(error)}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
793
923
|
private recordPolicyError(error: unknown): void {
|
|
794
924
|
if (isWorkflowPolicyError(error)) {
|
|
795
925
|
this.policyError ??= error;
|
|
796
|
-
|
|
926
|
+
// Let the Worker finish branch/panel finally blocks; only child work stops here.
|
|
927
|
+
this.abortChildren();
|
|
797
928
|
}
|
|
798
929
|
}
|
|
799
930
|
|
|
800
|
-
private
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
return undefined;
|
|
804
|
-
}
|
|
931
|
+
private createIsolatedWorktree(index: number): Worktree {
|
|
932
|
+
this.assertTargetIdentityCurrent();
|
|
933
|
+
const runId = this.options.journal ? path.basename(this.options.journal.filePath, ".jsonl") : "run";
|
|
805
934
|
try {
|
|
806
|
-
const
|
|
807
|
-
|
|
935
|
+
const worktree = createWorktree(this.cwd, runId, index);
|
|
936
|
+
try {
|
|
937
|
+
this.assertTargetIdentityCurrent();
|
|
938
|
+
} catch (error) {
|
|
939
|
+
removeWorktree(worktree);
|
|
940
|
+
throw error;
|
|
941
|
+
}
|
|
942
|
+
return worktree;
|
|
808
943
|
} catch (error) {
|
|
809
|
-
|
|
810
|
-
|
|
944
|
+
if (isWorkflowPolicyError(error)) throw error;
|
|
945
|
+
throw new Error(`agent #${index}: worktree setup failed: ${errorMessage(error)}`);
|
|
811
946
|
}
|
|
812
947
|
}
|
|
813
948
|
|
|
814
|
-
/**
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
// Outer safety net: a writeback/integration failure (including a host
|
|
823
|
-
// onUpdate callback throwing during a log line) must never discard the
|
|
824
|
-
// agent's completed work. Fail-safe toward KEEPING the worktree.
|
|
949
|
+
/** Fold a successful isolated result into the shared repository. */
|
|
950
|
+
private async integrateWorktree(
|
|
951
|
+
worktree: Worktree,
|
|
952
|
+
id: number,
|
|
953
|
+
label: string,
|
|
954
|
+
callPath: string,
|
|
955
|
+
commitAgent: (delivery?: { patchPath: string; patchHash: string }) => void,
|
|
956
|
+
): Promise<WorktreeOutcome> {
|
|
825
957
|
try {
|
|
826
|
-
|
|
827
|
-
try {
|
|
828
|
-
diff = captureWorktreeDiff(worktree);
|
|
829
|
-
} catch (error) {
|
|
830
|
-
this.logLine(
|
|
831
|
-
`worktree[${label}]: diff capture failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} (branch ${worktree.branch}) — recover with: git -C ${worktree.path} diff`,
|
|
832
|
-
);
|
|
833
|
-
return true;
|
|
834
|
-
}
|
|
958
|
+
const diff = captureWorktreeDiff(worktree);
|
|
835
959
|
if (!hasChanges(diff)) {
|
|
836
|
-
|
|
837
|
-
|
|
960
|
+
commitAgent();
|
|
961
|
+
this.logLineBestEffort(`worktree[${label}]: no changes (auto-removed)`);
|
|
962
|
+
return { status: "unchanged", keepWorktree: false };
|
|
838
963
|
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
964
|
+
|
|
965
|
+
// Persist the exact delivery material before the shared repository changes.
|
|
966
|
+
const runId = this.options.journal
|
|
967
|
+
? path.basename(this.options.journal.filePath, ".jsonl")
|
|
968
|
+
: "run";
|
|
969
|
+
const durablePatch = writeRescuePatch(this.rescueDir(), runId, id, label, diff.patch);
|
|
970
|
+
const patchHash = hashBytes(fs.readFileSync(durablePatch));
|
|
971
|
+
const applyLock = repositoryApplyLock(worktree.repoRoot);
|
|
972
|
+
return await applyLock.run(async () => {
|
|
973
|
+
this.throwIfAborted();
|
|
974
|
+
this.assertTargetIdentityCurrent();
|
|
975
|
+
this.options.journal?.recordDeliveryStart(callPath, durablePatch, patchHash);
|
|
976
|
+
if (!applyPatch(worktree.repoRoot, diff.patch)) {
|
|
977
|
+
this.logLineBestEffort(
|
|
978
|
+
`worktree[${label}]: changes could NOT be applied cleanly; patch saved to ${durablePatch} — review and apply manually`,
|
|
846
979
|
);
|
|
847
|
-
return;
|
|
980
|
+
return { status: "rescued", keepWorktree: false, recoveryPath: durablePatch } as const;
|
|
848
981
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
try {
|
|
857
|
-
const rescue = writeRescuePatch(rescueDir, runId, id, label, diff.patch);
|
|
858
|
-
this.logLine(
|
|
859
|
-
`worktree[${label}]: ${diff.filesChanged} file(s), +${diff.insertions}/-${diff.deletions} could NOT be auto-applied (3-way conflict); patch saved to ${rescue} — review and apply with: git apply --3way ${rescue}`,
|
|
860
|
-
);
|
|
861
|
-
} catch (error) {
|
|
862
|
-
// Rescue write failed (disk full / permission / bad path). Keep the
|
|
863
|
-
// worktree so the user can recover the changes manually.
|
|
864
|
-
keep = true;
|
|
865
|
-
this.logLine(
|
|
866
|
-
`worktree[${label}]: ${diff.filesChanged} file(s) could NOT be auto-applied (3-way conflict) AND rescue write failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} (branch ${worktree.branch}) — recover with: git -C ${worktree.path} diff`,
|
|
867
|
-
);
|
|
982
|
+
commitAgent({ patchPath: durablePatch, patchHash });
|
|
983
|
+
if (!this.options.journal) {
|
|
984
|
+
try {
|
|
985
|
+
fs.unlinkSync(durablePatch);
|
|
986
|
+
} catch {
|
|
987
|
+
// Standalone runs have no replay state to verify.
|
|
988
|
+
}
|
|
868
989
|
}
|
|
990
|
+
this.logLineBestEffort(
|
|
991
|
+
`worktree[${label}]: ${diff.filesChanged} file(s), +${diff.insertions}/-${diff.deletions} applied to working tree`,
|
|
992
|
+
);
|
|
993
|
+
return { status: "applied", keepWorktree: false } as const;
|
|
869
994
|
});
|
|
870
|
-
return keep;
|
|
871
995
|
} catch (error) {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
996
|
+
this.logLineBestEffort(
|
|
997
|
+
`worktree[${label}]: integration failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} — recover with: git -C ${worktree.path} diff`,
|
|
998
|
+
);
|
|
999
|
+
if (isWorkflowPolicyError(error)) throw error;
|
|
1000
|
+
return { status: "preserved", keepWorktree: true, recoveryPath: worktree.path };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** Preserve edits from a runner/policy/abort failure without publishing them. */
|
|
1005
|
+
private async recoverFailedWorktree(worktree: Worktree, id: number, label: string): Promise<WorktreeOutcome> {
|
|
1006
|
+
try {
|
|
1007
|
+
const diff = captureWorktreeDiff(worktree, this.remainingCleanupTimeout());
|
|
1008
|
+
if (!hasChanges(diff)) return { status: "unchanged", keepWorktree: false };
|
|
1009
|
+
return this.rescueDiff(worktree, diff, id, label, "agent failed before delivery");
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
this.logLineBestEffort(
|
|
1012
|
+
`worktree[${label}]: recovery capture failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} — recover with: git -C ${worktree.path} diff`,
|
|
1013
|
+
);
|
|
1014
|
+
return { status: "preserved", keepWorktree: true, recoveryPath: worktree.path };
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
private rescueDiff(
|
|
1019
|
+
worktree: Worktree,
|
|
1020
|
+
diff: WorktreeDiff,
|
|
1021
|
+
id: number,
|
|
1022
|
+
label: string,
|
|
1023
|
+
reason: string,
|
|
1024
|
+
): WorktreeOutcome {
|
|
1025
|
+
const runId = this.options.journal
|
|
1026
|
+
? path.basename(this.options.journal.filePath, ".jsonl")
|
|
1027
|
+
: "run";
|
|
1028
|
+
try {
|
|
1029
|
+
const rescue = writeRescuePatch(this.rescueDir(), runId, id, label, diff.patch);
|
|
1030
|
+
this.logLineBestEffort(
|
|
1031
|
+
`worktree[${label}]: ${reason}; patch saved to ${rescue} — review and apply manually`,
|
|
1032
|
+
);
|
|
1033
|
+
return { status: "rescued", keepWorktree: false, recoveryPath: rescue };
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
this.logLineBestEffort(
|
|
1036
|
+
`worktree[${label}]: ${reason} AND rescue write failed (${errorMessage(error)}); worktree KEPT at ${worktree.path} — recover with: git -C ${worktree.path} diff`,
|
|
1037
|
+
);
|
|
1038
|
+
return { status: "preserved", keepWorktree: true, recoveryPath: worktree.path };
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
private assertTargetIdentityCurrent(): void {
|
|
1043
|
+
const expected = this.options.targetIdentity;
|
|
1044
|
+
const actual = resolveRepositoryContext(this.cwd).identity;
|
|
1045
|
+
if (expected !== actual) {
|
|
1046
|
+
const error = new WorkflowPolicyError("workflow repository/cwd target changed during execution");
|
|
1047
|
+
this.recordPolicyError(error);
|
|
1048
|
+
throw error;
|
|
880
1049
|
}
|
|
881
1050
|
}
|
|
882
1051
|
|
|
1052
|
+
private remainingCleanupTimeout(): number {
|
|
1053
|
+
if (this.cleanupDeadline === undefined) return DEFAULT_CLEANUP_TIMEOUT_MS;
|
|
1054
|
+
const remaining = this.cleanupDeadline - Date.now();
|
|
1055
|
+
if (remaining <= 0) throw new WorkflowCleanupTimeoutError(this.options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS);
|
|
1056
|
+
return remaining;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
883
1059
|
/** Write rescue patches beside the journal or in a user-owned cwd-hash scope. */
|
|
884
1060
|
private rescueDir(): string {
|
|
885
1061
|
const journalDir = this.options.journal ? path.dirname(this.options.journal.filePath) : undefined;
|
|
@@ -895,6 +1071,14 @@ class Runtime implements ScriptExecutorHost {
|
|
|
895
1071
|
);
|
|
896
1072
|
}
|
|
897
1073
|
|
|
1074
|
+
private logLineBestEffort(text: string): void {
|
|
1075
|
+
try {
|
|
1076
|
+
this.logLine(text);
|
|
1077
|
+
} catch {
|
|
1078
|
+
// Durable runtime state is authoritative; display observers cannot veto it.
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
898
1082
|
private logLine(text: string): void {
|
|
899
1083
|
if (this.state.logs.length > MAX_WORKFLOW_LOGS) return;
|
|
900
1084
|
const safe = safeDisplayText(text, 512);
|
|
@@ -921,47 +1105,37 @@ function normalizeAgentOptions(value: unknown): AgentOptions {
|
|
|
921
1105
|
if (typeof value !== "object") throw new TypeError("agent options must be an object");
|
|
922
1106
|
const options = value as AgentOptions;
|
|
923
1107
|
assertWorkflowSchemaLimit(options.schema);
|
|
1108
|
+
if (options.isolation !== undefined && options.isolation !== "worktree") {
|
|
1109
|
+
throw new TypeError("agent isolation must be 'worktree' when provided");
|
|
1110
|
+
}
|
|
924
1111
|
return {
|
|
925
1112
|
label: optionalString(options.label, "agent label"),
|
|
926
1113
|
phase: optionalString(options.phase, "agent phase"),
|
|
927
1114
|
schema: options.schema,
|
|
928
1115
|
model: optionalString(options.model, "agent model"),
|
|
929
|
-
isolation: options.isolation
|
|
930
|
-
agentType:
|
|
1116
|
+
isolation: options.isolation,
|
|
1117
|
+
agentType: optionalNonEmptyTrimmedString(options.agentType, "agent type"),
|
|
931
1118
|
};
|
|
932
1119
|
}
|
|
933
1120
|
|
|
934
|
-
function normalizeWorkflowRef(value: unknown): string
|
|
935
|
-
if (typeof value
|
|
936
|
-
|
|
937
|
-
throw new TypeError("workflow() name must contain only letters, numbers, underscore, or hyphen");
|
|
938
|
-
}
|
|
939
|
-
return value;
|
|
1121
|
+
function normalizeWorkflowRef(value: unknown): string {
|
|
1122
|
+
if (typeof value !== "string") {
|
|
1123
|
+
throw new TypeError("workflow() expects a saved workflow name string; scriptPath is not supported");
|
|
940
1124
|
}
|
|
941
|
-
if (
|
|
942
|
-
|
|
1125
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value)) {
|
|
1126
|
+
throw new TypeError("workflow() name must contain only letters, numbers, underscore, or hyphen");
|
|
943
1127
|
}
|
|
944
|
-
|
|
1128
|
+
return value;
|
|
945
1129
|
}
|
|
946
1130
|
|
|
947
1131
|
export function loadSavedWorkflowFromDisk(
|
|
948
|
-
ref: string
|
|
1132
|
+
ref: string,
|
|
949
1133
|
cwd: string,
|
|
1134
|
+
projectTrusted = false,
|
|
950
1135
|
): { meta: WorkflowMeta; body: string } {
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
}
|
|
955
|
-
for (const root of [cwd, os.homedir()]) {
|
|
956
|
-
const dir = path.join(root, ".pi", "ultracode", "workflows");
|
|
957
|
-
for (const candidate of [`${ref}.workflow.js`, `${ref}.js`]) {
|
|
958
|
-
const full = path.join(dir, candidate);
|
|
959
|
-
if (artifactPathExists(full)) {
|
|
960
|
-
return parseWorkflowScript(readContainedArtifactFile(root, full, "saved nested workflow", 16 * 1024 * 1024));
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
throw new Error(`workflow() could not find a saved workflow for ${JSON.stringify(ref)}`);
|
|
1136
|
+
const saved = readSavedWorkflowByName(ref, cwd, projectTrusted);
|
|
1137
|
+
if (saved) return parseWorkflowScript(saved.script);
|
|
1138
|
+
throw new Error(`workflow() could not find an accessible saved workflow for ${JSON.stringify(ref)}`);
|
|
965
1139
|
}
|
|
966
1140
|
|
|
967
1141
|
function buildInstructions(phase: string | undefined, opts: AgentOptions): string | undefined {
|
|
@@ -1094,6 +1268,24 @@ function createLimiter(limit: number, signal: AbortSignal): <T>(fn: () => Promis
|
|
|
1094
1268
|
};
|
|
1095
1269
|
}
|
|
1096
1270
|
|
|
1271
|
+
function repositoryApplyLock(repoRoot: string): Mutex {
|
|
1272
|
+
const key = fsRealpathOrResolve(repoRoot);
|
|
1273
|
+
let lock = REPOSITORY_APPLY_LOCKS.get(key);
|
|
1274
|
+
if (!lock) {
|
|
1275
|
+
lock = new Mutex();
|
|
1276
|
+
REPOSITORY_APPLY_LOCKS.set(key, lock);
|
|
1277
|
+
}
|
|
1278
|
+
return lock;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function fsRealpathOrResolve(value: string): string {
|
|
1282
|
+
try {
|
|
1283
|
+
return path.resolve(value);
|
|
1284
|
+
} catch {
|
|
1285
|
+
return value;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1097
1289
|
class Mutex {
|
|
1098
1290
|
private tail: Promise<unknown> = Promise.resolve();
|
|
1099
1291
|
run<T>(fn: () => Promise<T>): Promise<T> {
|
|
@@ -1116,6 +1308,14 @@ function optionalString(value: unknown, name: string): string | undefined {
|
|
|
1116
1308
|
return requireString(value, name);
|
|
1117
1309
|
}
|
|
1118
1310
|
|
|
1311
|
+
function optionalNonEmptyTrimmedString(value: unknown, name: string): string | undefined {
|
|
1312
|
+
const string = optionalString(value, name);
|
|
1313
|
+
if (string === undefined) return undefined;
|
|
1314
|
+
const trimmed = string.trim();
|
|
1315
|
+
if (!trimmed) throw new TypeError(`${name} must not be empty`);
|
|
1316
|
+
return trimmed;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1119
1319
|
function journalPolicyError(error: unknown, operation: string): WorkflowPolicyError {
|
|
1120
1320
|
if (isWorkflowPolicyError(error)) return error;
|
|
1121
1321
|
const failure = new WorkflowPolicyError(`${operation}: ${errorMessage(error)}`);
|