pi-ultracode 0.1.2 → 0.3.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 +47 -42
- package/examples/workflows/loop-until-dry-bugs.workflow.js +16 -6
- package/extensions/ultracode.ts +0 -1
- package/package.json +7 -7
- package/src/commands.ts +19 -23
- package/src/index.ts +1 -1
- package/src/mode.ts +6 -40
- package/src/prompts.ts +18 -28
- package/src/workflow/admission.ts +318 -0
- package/src/workflow/agent-runner.ts +12 -0
- package/src/workflow/display.ts +17 -13
- package/src/workflow/journal.ts +0 -0
- package/src/workflow/json-schema.ts +39 -17
- package/src/workflow/leases.ts +60 -0
- package/src/workflow/parser.ts +809 -5
- package/src/workflow/registry.ts +39 -22
- package/src/workflow/run-artifacts.ts +231 -0
- package/src/workflow/run-details.ts +106 -28
- package/src/workflow/runtime.ts +640 -303
- package/src/workflow/script-executor.ts +317 -0
- package/src/workflow/script-worker.mjs +678 -0
- package/src/workflow/structured-output.ts +2 -0
- package/src/workflow/tool.ts +207 -74
- package/src/workflow/value-limits.ts +363 -0
- package/src/workflow/worktree.ts +5 -3
- package/types/workflow.d.ts +60 -22
package/src/workflow/tool.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* structured result to the parent assistant.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import * as
|
|
7
|
+
import * as crypto from "node:crypto";
|
|
8
8
|
import * as os from "node:os";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
import { redactCommand, safeDisplayText } from "./display-text.ts";
|
|
19
19
|
import { parseWorkflowScript, normalizeScript } from "./parser.ts";
|
|
20
20
|
import {
|
|
21
|
+
ABSOLUTE_MAX_AGENTS,
|
|
22
|
+
DEFAULT_MAX_AGENTS,
|
|
21
23
|
MAX_WORKFLOW_LOGS,
|
|
22
24
|
WORKFLOW_LOG_OMITTED_TEXT,
|
|
23
25
|
runWorkflow,
|
|
@@ -25,6 +27,18 @@ import {
|
|
|
25
27
|
import type { ModelRuntimeLike, ThinkingLevel } from "./agent-runner.ts";
|
|
26
28
|
import { RunJournal, hashString, type JournalAgentRecord } from "./journal.ts";
|
|
27
29
|
import { getRegistry } from "./registry.ts";
|
|
30
|
+
import { normalizeMaxAgents } from "./admission.ts";
|
|
31
|
+
import { acquireWorkflowLease, type WorkflowLease } from "./leases.ts";
|
|
32
|
+
import {
|
|
33
|
+
artifactPathExists,
|
|
34
|
+
assertRegularArtifactFile,
|
|
35
|
+
ensurePrivateArtifactDirectory,
|
|
36
|
+
readArtifactFile,
|
|
37
|
+
readContainedArtifactFile,
|
|
38
|
+
standaloneWorkflowRunsDir,
|
|
39
|
+
writeArtifactFile,
|
|
40
|
+
} from "./run-artifacts.ts";
|
|
41
|
+
import { assertWorkflowArgsLimit } from "./value-limits.ts";
|
|
28
42
|
import {
|
|
29
43
|
WorkflowRunDetails,
|
|
30
44
|
normalizeTaskUsage,
|
|
@@ -42,7 +56,7 @@ const workflowToolSchema = Type.Object({
|
|
|
42
56
|
script: Type.Optional(
|
|
43
57
|
Type.String({
|
|
44
58
|
description:
|
|
45
|
-
"
|
|
59
|
+
"Inline raw JavaScript workflow script (no Markdown fences). The source must begin with export const meta = { name: 'snake_case', description: '...' }. Should call agent() at least once for useful orchestration. Required unless `name` or `scriptPath` is given.",
|
|
46
60
|
}),
|
|
47
61
|
),
|
|
48
62
|
scriptPath: Type.Optional(
|
|
@@ -54,22 +68,25 @@ const workflowToolSchema = Type.Object({
|
|
|
54
68
|
args: Type.Optional(
|
|
55
69
|
Type.Any({ description: "Optional JSON value exposed to the workflow script as the global `args`." }),
|
|
56
70
|
),
|
|
57
|
-
budget: Type.Optional(
|
|
58
|
-
Type.Number({ description: "Optional output-token ceiling for this run; agent() calls throw once exhausted." }),
|
|
59
|
-
),
|
|
60
71
|
resumeFromRunId: Type.Optional(
|
|
61
72
|
Type.String({
|
|
62
73
|
description:
|
|
63
|
-
"Resume
|
|
74
|
+
"Resume an immutable prior run. Script and args must match exactly; successful agent calls replay by stable call path. maxAgents may only stay the same or increase.",
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
maxAgents: Type.Optional(
|
|
78
|
+
Type.Integer({
|
|
79
|
+
minimum: 1,
|
|
80
|
+
maximum: ABSOLUTE_MAX_AGENTS,
|
|
81
|
+
description:
|
|
82
|
+
`Lifetime live-agent admission cap for this run across resumes. Defaults to ${DEFAULT_MAX_AGENTS}; absolute range is 1..${ABSOLUTE_MAX_AGENTS}. Cache replay is free. This is not a token budget.`,
|
|
64
83
|
}),
|
|
65
84
|
),
|
|
66
|
-
});
|
|
85
|
+
}, { additionalProperties: false });
|
|
67
86
|
|
|
68
87
|
export interface WorkflowToolDeps {
|
|
69
88
|
/** Canonical runtime supplied by an SDK host; shared by all child sessions. */
|
|
70
89
|
modelRuntime?: ModelRuntimeLike;
|
|
71
|
-
/** Default token budget from ultracode mode, if any. */
|
|
72
|
-
getDefaultBudget?: () => number | null;
|
|
73
90
|
/** The ultracode effort level to forward to every workflow subagent as its
|
|
74
91
|
* default thinking level (`max` when ultracode is on, so each subagent's own
|
|
75
92
|
* session clamps it independently; undefined when off). A per-call
|
|
@@ -82,6 +99,8 @@ export interface WorkflowToolDeps {
|
|
|
82
99
|
/** Test seam: override the workflow runtime (lets tests capture the options,
|
|
83
100
|
* including the forwarded thinkingLevel, without spinning up real subagents). */
|
|
84
101
|
runWorkflowFn?: typeof runWorkflow;
|
|
102
|
+
/** Internal test seam for bounded cancellation cleanup. */
|
|
103
|
+
cleanupTimeoutMs?: number;
|
|
85
104
|
}
|
|
86
105
|
|
|
87
106
|
let runCounter = 0;
|
|
@@ -92,7 +111,8 @@ const ACTIVITY_RENDER_INTERVAL_MS = 100;
|
|
|
92
111
|
|
|
93
112
|
function nextRunId(): string {
|
|
94
113
|
runCounter += 1;
|
|
95
|
-
|
|
114
|
+
const nonce = crypto.randomBytes(6).toString("hex");
|
|
115
|
+
return `wf_${Date.now().toString(36)}-${runCounter.toString(36)}-${nonce}`;
|
|
96
116
|
}
|
|
97
117
|
|
|
98
118
|
export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<typeof workflowToolSchema, any> {
|
|
@@ -107,29 +127,57 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
107
127
|
if (deps.isExecutionAllowed?.() === false) {
|
|
108
128
|
throw new Error("The workflow tool is disabled. Run /ultracode on before using it.");
|
|
109
129
|
}
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
let abortRequested = false;
|
|
132
|
+
const requestAbort = () => {
|
|
133
|
+
abortRequested = true;
|
|
134
|
+
controller.abort();
|
|
135
|
+
};
|
|
136
|
+
const onOuterAbort = () => requestAbort();
|
|
137
|
+
signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
138
|
+
try {
|
|
139
|
+
if (signal?.aborted || controller.signal.aborted) throw new Error("Workflow was aborted before it started");
|
|
110
140
|
|
|
111
141
|
const cwd = ctx.cwd;
|
|
112
|
-
|
|
142
|
+
assertWorkflowArgsLimit(params.args);
|
|
143
|
+
const requestedMaxAgents = params.maxAgents === undefined ? undefined : normalizeMaxAgents(params.maxAgents);
|
|
144
|
+
const resolvedScript = resolveScript(params, cwd);
|
|
145
|
+
const script = normalizeScript(resolvedScript.script);
|
|
146
|
+
const sourceLabel = resolvedScript.sourceLabel;
|
|
113
147
|
const parsed = parseWorkflowScript(script);
|
|
114
148
|
const displayName = safeDisplayText(parsed.meta.name, 60) || "workflow";
|
|
115
149
|
|
|
116
150
|
const runsDir = workflowRunsDir(ctx);
|
|
117
151
|
const requestedRunId = params.resumeFromRunId?.trim();
|
|
118
152
|
const runId = requestedRunId ? requireSafeRunId(requestedRunId) : nextRunId();
|
|
119
|
-
const
|
|
153
|
+
const resuming = Boolean(requestedRunId);
|
|
154
|
+
if (resuming && !RunJournal.exists(runsDir, runId)) {
|
|
155
|
+
throw new Error(`workflow: resumeFromRunId ${runId} was not found in this session`);
|
|
156
|
+
}
|
|
120
157
|
// Forward the raw `max` request so each subagent session clamps it against
|
|
121
158
|
// that subagent's model. Undefined when ultracode is off.
|
|
122
159
|
const thinkingLevel = deps.getThinkingLevel?.();
|
|
160
|
+
if (controller.signal.aborted) throw new Error("Workflow was aborted before it started");
|
|
123
161
|
const run = deps.runWorkflowFn ?? runWorkflow;
|
|
124
162
|
const workflowStartedAt = Date.now();
|
|
163
|
+
let lease: WorkflowLease | undefined;
|
|
164
|
+
let journal: RunJournal | undefined;
|
|
165
|
+
let toolSucceeded = false;
|
|
166
|
+
lease = acquireWorkflowLease(runsDir, runId);
|
|
167
|
+
try {
|
|
125
168
|
|
|
126
|
-
// Persist
|
|
169
|
+
// Persist new scripts next to the session. Resume artifacts are immutable:
|
|
170
|
+
// validate the existing file now and compare its content below.
|
|
127
171
|
const scriptPath = path.join(runsDir, `${runId}.workflow.js`);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
172
|
+
ensurePrivateArtifactDirectory(runsDir);
|
|
173
|
+
if (resuming) {
|
|
174
|
+
assertRegularArtifactFile(scriptPath, "workflow resume script");
|
|
175
|
+
const persistedScript = readArtifactFile(scriptPath, "workflow resume script");
|
|
176
|
+
if (persistedScript !== script) {
|
|
177
|
+
throw new Error(`workflow ${runId} has an immutable script; start a new run for changed source`);
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
writeArtifactFile(scriptPath, script, { trustedRoot: runsDir });
|
|
133
181
|
}
|
|
134
182
|
|
|
135
183
|
// Journal (create new, or resume an existing run id).
|
|
@@ -140,34 +188,46 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
140
188
|
scriptHash: hashString(script),
|
|
141
189
|
args: params.args,
|
|
142
190
|
startedAt: Date.now(),
|
|
191
|
+
maxAgents: requestedMaxAgents,
|
|
143
192
|
};
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
: RunJournal.create(runsDir, journalMeta);
|
|
150
|
-
} catch {
|
|
151
|
-
journal = undefined;
|
|
152
|
-
}
|
|
193
|
+
journal = resuming
|
|
194
|
+
? RunJournal.resume(runsDir, runId, journalMeta)
|
|
195
|
+
: RunJournal.create(runsDir, journalMeta);
|
|
196
|
+
const activeJournal = journal;
|
|
197
|
+
const effectiveMaxAgents = activeJournal.effectiveMaxAgents;
|
|
153
198
|
|
|
154
199
|
// Snapshot + registry + abort plumbing.
|
|
155
|
-
let snapshot = createSnapshot(parsed.meta, runId
|
|
200
|
+
let snapshot = createSnapshot(parsed.meta, runId);
|
|
201
|
+
snapshot.maxAgents = effectiveMaxAgents;
|
|
202
|
+
snapshot.agentsUsed = activeJournal.agentsUsed;
|
|
203
|
+
const detailsPath = path.join(runsDir, `${runId}.details.json`);
|
|
204
|
+
if (resuming && artifactPathExists(detailsPath)) {
|
|
205
|
+
assertRegularArtifactFile(detailsPath, "workflow details manifest");
|
|
206
|
+
}
|
|
156
207
|
const restoredDetails = resuming
|
|
157
|
-
? WorkflowRunDetails.restore(
|
|
208
|
+
? WorkflowRunDetails.restore(detailsPath)?.details
|
|
158
209
|
: undefined;
|
|
159
210
|
const runDetails = restoredDetails ?? new WorkflowRunDetails({
|
|
160
211
|
runId,
|
|
161
212
|
name: parsed.meta.name,
|
|
162
213
|
runsDir,
|
|
163
214
|
});
|
|
215
|
+
if (resuming) runDetails.beginGeneration();
|
|
164
216
|
snapshot.detailsManifestPath = runDetails.manifestPath;
|
|
165
|
-
|
|
166
|
-
const onOuterAbort = () => controller.abort();
|
|
167
|
-
signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
217
|
+
if (controller.signal.aborted) throw new Error("Workflow was aborted before it started");
|
|
168
218
|
const registry = getRegistry();
|
|
169
219
|
registry.setScope(runsDir);
|
|
170
|
-
const handle = registry.register(runId, snapshot,
|
|
220
|
+
const handle = registry.register(runId, snapshot, requestAbort, runDetails);
|
|
221
|
+
let updateObserverEnabled = true;
|
|
222
|
+
const emitUpdate = () => {
|
|
223
|
+
if (!onUpdate || !updateObserverEnabled) return;
|
|
224
|
+
try {
|
|
225
|
+
onUpdate({ content: [{ type: "text", text: renderWorkflowText(snapshot) }], details: snapshot });
|
|
226
|
+
} catch (error) {
|
|
227
|
+
updateObserverEnabled = false;
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
171
231
|
|
|
172
232
|
const update = () => {
|
|
173
233
|
const totals = representedUsage(snapshot);
|
|
@@ -177,7 +237,7 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
177
237
|
snapshot = recompute(snapshot);
|
|
178
238
|
handle.snapshot = snapshot;
|
|
179
239
|
registry.notify();
|
|
180
|
-
|
|
240
|
+
emitUpdate();
|
|
181
241
|
};
|
|
182
242
|
|
|
183
243
|
// Throttle state for activity-driven re-renders. Agent fields below are
|
|
@@ -194,21 +254,37 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
194
254
|
};
|
|
195
255
|
|
|
196
256
|
// Heartbeat: keep elapsed/no-event markers live in the compact panel even
|
|
197
|
-
// when a subagent emits no events.
|
|
198
|
-
|
|
257
|
+
// when a subagent emits no events. Detached timer callbacks must never throw
|
|
258
|
+
// past the tool lifecycle; convert observer failures into a controlled run
|
|
259
|
+
// failure and let the runtime finish abort/drain cleanup before releasing the lease.
|
|
260
|
+
let heartbeatFailed = false;
|
|
261
|
+
let heartbeatError: unknown;
|
|
262
|
+
const heartbeat = setInterval(() => {
|
|
263
|
+
try {
|
|
264
|
+
update();
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (!heartbeatFailed) {
|
|
267
|
+
heartbeatFailed = true;
|
|
268
|
+
heartbeatError = error;
|
|
269
|
+
}
|
|
270
|
+
clearInterval(heartbeat);
|
|
271
|
+
controller.abort();
|
|
272
|
+
}
|
|
273
|
+
}, 1000);
|
|
199
274
|
|
|
200
275
|
try {
|
|
201
276
|
const result = await run(script, {
|
|
202
277
|
cwd,
|
|
203
278
|
args: params.args,
|
|
204
279
|
signal: controller.signal,
|
|
205
|
-
tokenBudget: budgetTotal,
|
|
206
280
|
thinkingLevel,
|
|
207
281
|
modelRegistry: ctx.modelRegistry as any,
|
|
208
282
|
modelRuntime: deps.modelRuntime,
|
|
209
283
|
model: ctx.model as any,
|
|
210
284
|
runner: deps.testRunner,
|
|
211
|
-
journal,
|
|
285
|
+
journal: activeJournal,
|
|
286
|
+
maxAgents: effectiveMaxAgents,
|
|
287
|
+
cleanupTimeoutMs: deps.cleanupTimeoutMs,
|
|
212
288
|
onLog(message) {
|
|
213
289
|
if (!acceptingEvents || snapshot.logs.length > MAX_WORKFLOW_LOGS) return;
|
|
214
290
|
const safe = safeDisplayText(message, 512);
|
|
@@ -227,10 +303,12 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
227
303
|
},
|
|
228
304
|
onAgentStart(event) {
|
|
229
305
|
if (!acceptingEvents) return;
|
|
306
|
+
snapshot.agentsUsed = activeJournal.agentsUsed;
|
|
230
307
|
const phase = recordPhase(event.phase);
|
|
231
308
|
const startedAt = Date.now();
|
|
232
309
|
const summary = runDetails.startTask({
|
|
233
310
|
id: event.id,
|
|
311
|
+
callPath: event.callPath,
|
|
234
312
|
label: event.label,
|
|
235
313
|
phase,
|
|
236
314
|
workflowPath: event.workflowPath,
|
|
@@ -245,6 +323,7 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
245
323
|
});
|
|
246
324
|
const agent: WorkflowSnapshot["agents"][number] = {
|
|
247
325
|
id: event.id,
|
|
326
|
+
callPath: event.callPath,
|
|
248
327
|
label: safeDisplayText(event.label, 120) || `agent ${event.id}`,
|
|
249
328
|
phase,
|
|
250
329
|
workflowPath: event.workflowPath,
|
|
@@ -290,9 +369,6 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
290
369
|
if (summary) applyTaskSummary(agent, summary);
|
|
291
370
|
const now = Date.now();
|
|
292
371
|
agent.lastActivityAt = now;
|
|
293
|
-
if (event.kind !== "text_delta" && event.kind !== "thinking_start") {
|
|
294
|
-
runDetails.persist(snapshot);
|
|
295
|
-
}
|
|
296
372
|
if (now - lastActivityRenderMs >= ACTIVITY_RENDER_INTERVAL_MS) {
|
|
297
373
|
lastActivityRenderMs = now;
|
|
298
374
|
update();
|
|
@@ -338,17 +414,28 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
338
414
|
},
|
|
339
415
|
});
|
|
340
416
|
|
|
417
|
+
// The runtime normally rejects after cancellation, but success must not
|
|
418
|
+
// win a race with a detached heartbeat failure or a user/registry abort.
|
|
419
|
+
if (heartbeatFailed) throw heartbeatError;
|
|
420
|
+
if (abortRequested || controller.signal.aborted) throw new Error("Workflow execution ended after cancellation");
|
|
421
|
+
|
|
341
422
|
acceptingEvents = false;
|
|
342
423
|
for (const agent of snapshot.agents) clearAgentTransient(agent);
|
|
343
424
|
snapshot.result = result.result;
|
|
344
425
|
snapshot.spentTokens = result.spentTokens;
|
|
345
|
-
snapshot.newTokens = result.newTokens;
|
|
346
|
-
snapshot.replayedTokens = result.replayedTokens;
|
|
426
|
+
snapshot.newTokens = result.newTokens ?? 0;
|
|
427
|
+
snapshot.replayedTokens = result.replayedTokens ?? 0;
|
|
428
|
+
snapshot.maxAgents = result.maxAgents ?? effectiveMaxAgents;
|
|
429
|
+
snapshot.agentsUsed = result.agentsUsed ?? activeJournal.agentsUsed;
|
|
347
430
|
snapshot.durationMs = result.durationMs;
|
|
348
431
|
snapshot.status = "completed";
|
|
349
432
|
snapshot = recompute(snapshot);
|
|
350
433
|
handle.snapshot = snapshot;
|
|
351
|
-
|
|
434
|
+
try {
|
|
435
|
+
runDetails.close(snapshot);
|
|
436
|
+
} catch {
|
|
437
|
+
// best-effort manifest close
|
|
438
|
+
}
|
|
352
439
|
registry.notify();
|
|
353
440
|
journal?.recordResult({
|
|
354
441
|
ok: true,
|
|
@@ -356,29 +443,32 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
356
443
|
agentCount: result.agentCount,
|
|
357
444
|
durationMs: result.durationMs,
|
|
358
445
|
});
|
|
359
|
-
|
|
446
|
+
emitUpdate();
|
|
360
447
|
|
|
448
|
+
const agentsUsed = result.agentsUsed ?? activeJournal.agentsUsed;
|
|
361
449
|
ctx.ui?.notify(
|
|
362
|
-
`Workflow ${displayName} completed: ${result.
|
|
450
|
+
`Workflow ${displayName} completed: ${agentsUsed}/${result.maxAgents ?? effectiveMaxAgents} lifetime agent slot(s), ~${result.spentTokens} output tokens.`,
|
|
363
451
|
"info",
|
|
364
452
|
);
|
|
365
453
|
|
|
366
454
|
const cachedNote = result.cachedCount ? ` (${result.cachedCount} cached from resume)` : "";
|
|
455
|
+
toolSucceeded = true;
|
|
367
456
|
return {
|
|
368
457
|
content: [
|
|
369
458
|
{
|
|
370
459
|
type: "text",
|
|
371
460
|
text:
|
|
372
|
-
`Workflow ${displayName} completed: ${result.agentCount} agent(s)${cachedNote}, ` +
|
|
461
|
+
`Workflow ${displayName} completed: ${result.agentCount} agent call(s), lifetime slots ${agentsUsed}/${result.maxAgents ?? effectiveMaxAgents}${cachedNote}, ` +
|
|
373
462
|
`~${result.spentTokens} output tokens, ${Math.round(result.durationMs)}ms.\n` +
|
|
374
463
|
`runId: ${runId} (script: ${scriptPath})\n\n` +
|
|
375
464
|
`Result:\n${safeJson(result.result)}`,
|
|
376
465
|
},
|
|
377
466
|
],
|
|
378
|
-
details: { ...snapshot, runId, scriptPath, source: sourceLabel },
|
|
467
|
+
details: { ...snapshot, runId, scriptPath, source: sourceLabel, maxAgents: result.maxAgents ?? effectiveMaxAgents },
|
|
379
468
|
};
|
|
380
469
|
} catch (error) {
|
|
381
|
-
const
|
|
470
|
+
const failure = heartbeatFailed ? heartbeatError : error;
|
|
471
|
+
const aborted = abortRequested;
|
|
382
472
|
acceptingEvents = false;
|
|
383
473
|
if (!controller.signal.aborted) controller.abort();
|
|
384
474
|
for (const agent of snapshot.agents) {
|
|
@@ -401,30 +491,64 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
401
491
|
snapshot.spentTokens = totals.outputTokens;
|
|
402
492
|
snapshot = recompute(snapshot);
|
|
403
493
|
handle.snapshot = snapshot;
|
|
404
|
-
|
|
494
|
+
try {
|
|
495
|
+
runDetails.close(snapshot);
|
|
496
|
+
} catch {
|
|
497
|
+
// best-effort manifest close
|
|
498
|
+
}
|
|
405
499
|
registry.notify();
|
|
406
|
-
const errorText = statusText(
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
500
|
+
const errorText = statusText(failure);
|
|
501
|
+
try {
|
|
502
|
+
journal?.recordResult({
|
|
503
|
+
ok: false,
|
|
504
|
+
error: errorText,
|
|
505
|
+
agentCount: snapshot.agentCount,
|
|
506
|
+
durationMs: snapshot.durationMs ?? 0,
|
|
507
|
+
});
|
|
508
|
+
} catch {
|
|
509
|
+
// A poisoned journal must not append after a partial write; preserve
|
|
510
|
+
// the execution failure that brought us into this path.
|
|
511
|
+
}
|
|
512
|
+
emitUpdate();
|
|
414
513
|
ctx.ui?.notify(
|
|
415
514
|
`Workflow ${displayName} ${aborted ? "was aborted" : "failed"}${aborted ? "" : `: ${errorText}`}`,
|
|
416
515
|
aborted ? "warning" : "error",
|
|
417
516
|
);
|
|
418
517
|
if (aborted) {
|
|
419
|
-
throw new Error(`Workflow ${displayName} was aborted (runId: ${runId})`, { cause:
|
|
518
|
+
throw new Error(`Workflow ${displayName} was aborted (runId: ${runId})`, { cause: failure });
|
|
420
519
|
}
|
|
421
|
-
throw new Error(`Workflow ${displayName} failed: ${errorText}`, { cause:
|
|
520
|
+
throw new Error(`Workflow ${displayName} failed: ${errorText}`, { cause: failure });
|
|
422
521
|
} finally {
|
|
423
522
|
acceptingEvents = false;
|
|
424
|
-
|
|
523
|
+
try {
|
|
524
|
+
clearInterval(heartbeat);
|
|
525
|
+
} catch {
|
|
526
|
+
// best-effort cleanup
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
runDetails.close(snapshot);
|
|
530
|
+
} catch {
|
|
531
|
+
// best-effort cleanup
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
} finally {
|
|
535
|
+
let journalCloseError: unknown;
|
|
536
|
+
try {
|
|
537
|
+
journal?.close();
|
|
538
|
+
} catch (error) {
|
|
539
|
+
journalCloseError = error;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
lease?.release();
|
|
543
|
+
} catch {
|
|
544
|
+
// best-effort cleanup; active-run leases must never mask the original error
|
|
545
|
+
}
|
|
546
|
+
if (toolSucceeded && journalCloseError) {
|
|
547
|
+
throw new Error(`workflow journal close failed: ${statusText(journalCloseError)}`, { cause: journalCloseError });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
} finally {
|
|
425
551
|
signal?.removeEventListener("abort", onOuterAbort);
|
|
426
|
-
runDetails.close(snapshot);
|
|
427
|
-
journal?.close();
|
|
428
552
|
}
|
|
429
553
|
},
|
|
430
554
|
renderCall(_args, theme) {
|
|
@@ -434,7 +558,7 @@ export function createWorkflowTool(deps: WorkflowToolDeps = {}): ToolDefinition<
|
|
|
434
558
|
const snapshot = result.details as WorkflowSnapshot | undefined;
|
|
435
559
|
if (snapshot?.name) {
|
|
436
560
|
return new Text(renderWorkflowText(snapshot, {
|
|
437
|
-
|
|
561
|
+
maxAgentRows: expanded ? Number.MAX_SAFE_INTEGER : undefined,
|
|
438
562
|
maxLogs: expanded ? 12 : undefined,
|
|
439
563
|
showResultPreviews: expanded && !isPartial,
|
|
440
564
|
}), 0, 0);
|
|
@@ -454,17 +578,21 @@ function resolveScript(
|
|
|
454
578
|
}
|
|
455
579
|
if (params.scriptPath) {
|
|
456
580
|
const full = path.isAbsolute(params.scriptPath) ? params.scriptPath : path.join(cwd, params.scriptPath);
|
|
457
|
-
return { script:
|
|
581
|
+
return { script: readArtifactFile(full, "workflow script", 16 * 1024 * 1024), sourceLabel: `scriptPath:${params.scriptPath}` };
|
|
458
582
|
}
|
|
459
583
|
if (params.name) {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
for (const dir of dirs) {
|
|
584
|
+
requireSafeWorkflowName(params.name);
|
|
585
|
+
const roots = [cwd, os.homedir()];
|
|
586
|
+
for (const root of roots) {
|
|
587
|
+
const dir = path.join(root, ".pi", "ultracode", "workflows");
|
|
465
588
|
for (const candidate of [`${params.name}.workflow.js`, `${params.name}.js`]) {
|
|
466
589
|
const full = path.join(dir, candidate);
|
|
467
|
-
if (
|
|
590
|
+
if (artifactPathExists(full)) {
|
|
591
|
+
return {
|
|
592
|
+
script: readContainedArtifactFile(root, full, "saved workflow", 16 * 1024 * 1024),
|
|
593
|
+
sourceLabel: `name:${params.name}`,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
468
596
|
}
|
|
469
597
|
}
|
|
470
598
|
throw new Error(`workflow: no saved workflow named "${params.name}" found under .pi/ultracode/workflows/`);
|
|
@@ -479,7 +607,7 @@ export function workflowRunsDir(ctx: { sessionManager?: { getSessionDir?: () =>
|
|
|
479
607
|
} catch {
|
|
480
608
|
// fall through
|
|
481
609
|
}
|
|
482
|
-
return
|
|
610
|
+
return standaloneWorkflowRunsDir(ctx.cwd);
|
|
483
611
|
}
|
|
484
612
|
|
|
485
613
|
function safeJson(value: unknown): string {
|
|
@@ -490,8 +618,12 @@ function safeJson(value: unknown): string {
|
|
|
490
618
|
}
|
|
491
619
|
}
|
|
492
620
|
|
|
493
|
-
|
|
494
|
-
|
|
621
|
+
|
|
622
|
+
function requireSafeWorkflowName(value: string): string {
|
|
623
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value)) {
|
|
624
|
+
throw new Error("workflow name must contain only letters, numbers, underscore, or hyphen");
|
|
625
|
+
}
|
|
626
|
+
return value;
|
|
495
627
|
}
|
|
496
628
|
|
|
497
629
|
function requireSafeRunId(value: string): string {
|
|
@@ -522,6 +654,7 @@ function representedUsage(snapshot: WorkflowSnapshot): { newTokens: number; repl
|
|
|
522
654
|
function cachedRecordSummary(record: JournalAgentRecord): Partial<WorkflowTaskSummary> {
|
|
523
655
|
return {
|
|
524
656
|
id: record.seq,
|
|
657
|
+
callPath: record.callPath,
|
|
525
658
|
label: record.label,
|
|
526
659
|
status: "cached",
|
|
527
660
|
promptPreview: "",
|