@chrok/pi-braid 0.3.6 → 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 +167 -46
- package/dist/command.js +126 -7
- package/dist/display.js +62 -14
- package/dist/drain.js +171 -0
- package/dist/index.js +143 -76
- package/dist/jobs.js +152 -15
- package/dist/runner.js +166 -80
- package/dist/status-text.js +89 -3
- package/package.json +2 -2
package/dist/jobs.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { setImmediate as nextTurn } from "node:timers/promises";
|
|
2
|
-
import { ExecutionActivityTracker, startBraid, validateGraph, } from "@chrok/braid";
|
|
2
|
+
import { ExecutionActivityTracker, formatActivityStatus, startBraid, validateGraph, } from "@chrok/braid";
|
|
3
3
|
import { applyEvent, applyProgress, createLiveState, } from "./display.js";
|
|
4
4
|
import { createPiRunner, sumPiUsage } from "./runner.js";
|
|
5
5
|
import { PiTranscripts } from "./transcript.js";
|
|
6
6
|
import { PiHumanInputs } from "./human-input.js";
|
|
7
7
|
import { BraidAssistant } from "./assistant.js";
|
|
8
|
+
function timedOut(state) {
|
|
9
|
+
const ids = (state?.suspendedExecutionIds ?? []).filter(id => state.executions[id]?.suspension?.source === "node_timeout");
|
|
10
|
+
return ids.length ? { timedOutExecutionIds: ids } : {};
|
|
11
|
+
}
|
|
8
12
|
/** Jobs belong to one extension/session lifetime, independently of foreground turns. */
|
|
9
13
|
export class BraidJobs {
|
|
10
14
|
onFinished;
|
|
11
15
|
onNodeFinished;
|
|
16
|
+
onControl;
|
|
17
|
+
onTimeoutPause;
|
|
12
18
|
jobs = new Map();
|
|
13
19
|
assistants = new Map();
|
|
14
20
|
cwd = process.cwd();
|
|
@@ -16,9 +22,24 @@ export class BraidJobs {
|
|
|
16
22
|
nextHandle = 1;
|
|
17
23
|
listeners = new Set();
|
|
18
24
|
disposed = false;
|
|
19
|
-
constructor(onFinished = () => { }, onNodeFinished = () => { }) {
|
|
25
|
+
constructor(onFinished = () => { }, onNodeFinished = () => { }, onControl = () => { }, onTimeoutPause = () => { }) {
|
|
20
26
|
this.onFinished = onFinished;
|
|
21
27
|
this.onNodeFinished = onNodeFinished;
|
|
28
|
+
this.onControl = onControl;
|
|
29
|
+
this.onTimeoutPause = onTimeoutPause;
|
|
30
|
+
}
|
|
31
|
+
/** The parent made the change itself; anyone else's change becomes a notice for it. */
|
|
32
|
+
notify(job, action, source, executionIds) {
|
|
33
|
+
if (source === "agent" || this.disposed)
|
|
34
|
+
return;
|
|
35
|
+
const state = job.run?.snapshot();
|
|
36
|
+
try {
|
|
37
|
+
this.onControl({
|
|
38
|
+
jobId: job.jobId, handle: job.handle, action, source,
|
|
39
|
+
...(executionIds ? { executions: executionIds.map(executionId => ({ executionId, nodeId: state?.executions[executionId]?.id ?? "?" })) } : {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch { /* Notices are advisory. */ }
|
|
22
43
|
}
|
|
23
44
|
subscribe(listener) {
|
|
24
45
|
this.listeners.add(listener);
|
|
@@ -36,12 +57,10 @@ export class BraidJobs {
|
|
|
36
57
|
}
|
|
37
58
|
}
|
|
38
59
|
}
|
|
39
|
-
start(input, options, ctx
|
|
60
|
+
start(input, options, ctx) {
|
|
40
61
|
if (this.disposed)
|
|
41
62
|
throw new Error("Braid session has closed");
|
|
42
63
|
validateGraph(input);
|
|
43
|
-
if (blocking && input.nodes.some(node => node.pauseAfter))
|
|
44
|
-
throw new Error("Blocking Braid jobs cannot use pauseAfter; remove it or disable blocking with /braid:blocking off.");
|
|
45
64
|
const snapshot = structuredClone(input);
|
|
46
65
|
const settings = { ...options };
|
|
47
66
|
const model = ctx.model
|
|
@@ -51,7 +70,6 @@ export class BraidJobs {
|
|
|
51
70
|
const cwd = ctx.cwd;
|
|
52
71
|
this.cwd = cwd;
|
|
53
72
|
const job = {
|
|
54
|
-
blocking,
|
|
55
73
|
handle: `job-${this.nextHandle++}`,
|
|
56
74
|
jobId: crypto.randomUUID(),
|
|
57
75
|
goal: snapshot.goal,
|
|
@@ -62,6 +80,8 @@ export class BraidJobs {
|
|
|
62
80
|
done: Promise.resolve(),
|
|
63
81
|
usageClaimed: false,
|
|
64
82
|
runningExecutions: new Map(),
|
|
83
|
+
suspendedExecutions: new Map(),
|
|
84
|
+
timeoutActivity: new Map(),
|
|
65
85
|
activity: new ExecutionActivityTracker({ onChange: () => this.changed() }),
|
|
66
86
|
transcripts: new PiTranscripts(() => this.changed()),
|
|
67
87
|
humanInputs: new PiHumanInputs(() => this.changed()),
|
|
@@ -92,6 +112,7 @@ export class BraidJobs {
|
|
|
92
112
|
humanInputs: job.humanInputs,
|
|
93
113
|
maxToolRounds: options.maxToolRounds ?? Infinity,
|
|
94
114
|
maxToolCalls: options.maxToolCalls ?? Infinity,
|
|
115
|
+
...(options.maxModelRetries === undefined ? {} : { maxModelRetries: options.maxModelRetries }),
|
|
95
116
|
onUsage: (usage) => {
|
|
96
117
|
if (job.status === "running" && !job.controller.signal.aborted)
|
|
97
118
|
reports.push(usage);
|
|
@@ -103,7 +124,7 @@ export class BraidJobs {
|
|
|
103
124
|
this.changed();
|
|
104
125
|
},
|
|
105
126
|
});
|
|
106
|
-
return async (request) => {
|
|
127
|
+
return Object.assign(async (request) => {
|
|
107
128
|
try {
|
|
108
129
|
await nextTurn();
|
|
109
130
|
return await invoke(request);
|
|
@@ -116,9 +137,15 @@ export class BraidJobs {
|
|
|
116
137
|
}
|
|
117
138
|
job.transcripts.finish(request.execution.executionId, request.signal.aborted ? "Execution was stopped before this step finished" : undefined);
|
|
118
139
|
}
|
|
119
|
-
};
|
|
140
|
+
}, { pausable: invoke.pausable === true, interruptible: invoke.interruptible === true });
|
|
120
141
|
})(),
|
|
121
142
|
onEvent: (event) => {
|
|
143
|
+
// Capture what the worker was doing before the interruption changes it.
|
|
144
|
+
if (event.type === "execution_suspending" && event.source === "node_timeout" && event.executionId) {
|
|
145
|
+
const activity = job.activity.get(event.executionId);
|
|
146
|
+
if (activity)
|
|
147
|
+
job.timeoutActivity.set(event.executionId, formatActivityStatus(activity, event.timestamp));
|
|
148
|
+
}
|
|
122
149
|
job.activity.observe(event);
|
|
123
150
|
if (event.type === "workspace_updated") {
|
|
124
151
|
job.workspaces ??= {};
|
|
@@ -127,14 +154,25 @@ export class BraidJobs {
|
|
|
127
154
|
});
|
|
128
155
|
}
|
|
129
156
|
// Definition edits can remove nodes whose executions are still running.
|
|
157
|
+
// A suspended execution is not running: drain can then see a stalled job.
|
|
130
158
|
if (event.type === "node_started" && event.executionId) {
|
|
131
159
|
job.runningExecutions.set(event.executionId, event.nodeId);
|
|
132
160
|
}
|
|
161
|
+
else if (event.type === "execution_suspended" && event.executionId) {
|
|
162
|
+
job.runningExecutions.delete(event.executionId);
|
|
163
|
+
job.suspendedExecutions.set(event.executionId, event.nodeId);
|
|
164
|
+
}
|
|
165
|
+
else if (event.type === "execution_resumed" && event.executionId && job.suspendedExecutions.has(event.executionId)) {
|
|
166
|
+
job.runningExecutions.set(event.executionId, event.nodeId);
|
|
167
|
+
job.suspendedExecutions.delete(event.executionId);
|
|
168
|
+
}
|
|
133
169
|
else if ((event.type === "node_completed" || event.type === "node_failed") && event.executionId) {
|
|
134
170
|
job.runningExecutions.delete(event.executionId);
|
|
171
|
+
job.suspendedExecutions.delete(event.executionId);
|
|
135
172
|
}
|
|
136
173
|
else if (event.type === "graph_completed" || event.type === "graph_failed") {
|
|
137
174
|
job.runningExecutions.clear();
|
|
175
|
+
job.suspendedExecutions.clear();
|
|
138
176
|
}
|
|
139
177
|
applyEvent(job.live, event);
|
|
140
178
|
if ("nodeId" in event && Object.hasOwn(job.live.nodes, event.nodeId)) {
|
|
@@ -147,13 +185,31 @@ export class BraidJobs {
|
|
|
147
185
|
});
|
|
148
186
|
}
|
|
149
187
|
this.changed();
|
|
188
|
+
if (event.type === "execution_suspended" && event.source === "node_timeout" && event.executionId) {
|
|
189
|
+
const instance = job.run?.snapshot().executions[event.executionId];
|
|
190
|
+
const timeout = instance?.suspension?.timeout;
|
|
191
|
+
const activity = job.timeoutActivity.get(event.executionId);
|
|
192
|
+
job.timeoutActivity.delete(event.executionId);
|
|
193
|
+
// A timeout pause always wakes the parent, which must resume or cancel it.
|
|
194
|
+
if (!this.disposed && instance && timeout) {
|
|
195
|
+
try {
|
|
196
|
+
void Promise.resolve(this.onTimeoutPause({
|
|
197
|
+
jobId: job.jobId, handle: job.handle, nodeId: event.nodeId, executionId: event.executionId,
|
|
198
|
+
...(instance.iteration ? { iteration: instance.iteration } : {}),
|
|
199
|
+
nodeTimeoutMs: timeout.nodeTimeoutMs, activeMs: timeout.activeMs,
|
|
200
|
+
...(activity ? { activity } : {}), eventSequence: event.sequence,
|
|
201
|
+
})).catch(() => { });
|
|
202
|
+
}
|
|
203
|
+
catch { /* Notification failures must not affect scheduling. */ }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
150
206
|
const terminal = event.type === "execution_paused" || event.type === "node_completed" || event.type === "node_failed";
|
|
151
207
|
const state = terminal ? job.run?.snapshot() : undefined;
|
|
152
208
|
const instance = event.executionId ? state?.executions[event.executionId] : undefined;
|
|
153
209
|
const shouldNotify = event.type === "execution_paused" ||
|
|
154
210
|
((event.type === "node_completed" || event.type === "node_failed") && instance?.node.notifyOnCompletion &&
|
|
155
|
-
(!instance.
|
|
156
|
-
if (!this.disposed &&
|
|
211
|
+
(!instance.holdAfter || state?.error));
|
|
212
|
+
if (!this.disposed && shouldNotify && "nodeId" in event && instance) {
|
|
157
213
|
try {
|
|
158
214
|
void Promise.resolve(this.onNodeFinished({
|
|
159
215
|
jobId: job.jobId,
|
|
@@ -188,12 +244,13 @@ export class BraidJobs {
|
|
|
188
244
|
job.live.status = "failed";
|
|
189
245
|
job.live.pausedExecutionIds = [];
|
|
190
246
|
job.runningExecutions.clear();
|
|
247
|
+
job.suspendedExecutions.clear();
|
|
191
248
|
job.error = error instanceof Error ? error.message : String(error);
|
|
192
249
|
if (reports.length)
|
|
193
250
|
job.usage = sumPiUsage(reports);
|
|
194
251
|
}
|
|
195
252
|
this.changed();
|
|
196
|
-
if (!this.disposed
|
|
253
|
+
if (!this.disposed) {
|
|
197
254
|
try {
|
|
198
255
|
this.onFinished(this.get(job.jobId));
|
|
199
256
|
}
|
|
@@ -213,7 +270,7 @@ export class BraidJobs {
|
|
|
213
270
|
const job = this.lookup(jobId);
|
|
214
271
|
if (!job)
|
|
215
272
|
return undefined;
|
|
216
|
-
const {
|
|
273
|
+
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, suspendedExecutions: _suspendedExecutions, activity: _activity, transcripts: _transcripts, humanInputs: _humanInputs, ...snapshot } = job;
|
|
217
274
|
const copy = structuredClone(snapshot);
|
|
218
275
|
if (job.run)
|
|
219
276
|
copy.execution = job.run.snapshot();
|
|
@@ -292,13 +349,60 @@ export class BraidJobs {
|
|
|
292
349
|
this.changed();
|
|
293
350
|
return this.get(jobId);
|
|
294
351
|
}
|
|
295
|
-
resume
|
|
352
|
+
/** Release held executions or resume paused ones. */
|
|
353
|
+
resume(jobId, executionIds, expectedRevision, source = "agent", options) {
|
|
296
354
|
const job = this.lookup(jobId);
|
|
297
355
|
if (!job)
|
|
298
356
|
throw this.unknownJob(jobId);
|
|
299
357
|
if (!job.run)
|
|
300
358
|
throw new Error("Job is not accepting updates");
|
|
301
|
-
job.run.resume(executionIds, expectedRevision);
|
|
359
|
+
job.run.resume(executionIds, expectedRevision, options);
|
|
360
|
+
this.notify(job, "resumed", source, executionIds);
|
|
361
|
+
this.changed();
|
|
362
|
+
return this.get(jobId);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Pause running executions at their next model request, or the whole job
|
|
366
|
+
* when executionIds is omitted. source records who asked, e.g. "agent" or "user".
|
|
367
|
+
*/
|
|
368
|
+
pause(jobId, executionIds, source) {
|
|
369
|
+
const job = this.lookup(jobId);
|
|
370
|
+
if (!job)
|
|
371
|
+
throw this.unknownJob(jobId);
|
|
372
|
+
if (!job.run || job.status !== "running")
|
|
373
|
+
throw new Error("Job is not accepting updates");
|
|
374
|
+
if (executionIds)
|
|
375
|
+
job.run.pause(executionIds, { source });
|
|
376
|
+
else
|
|
377
|
+
job.run.pauseRun({ source });
|
|
378
|
+
this.notify(job, "paused", source, executionIds);
|
|
379
|
+
this.changed();
|
|
380
|
+
return this.get(jobId);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Hold running executions' results once they finish (hold = true), or remove
|
|
384
|
+
* that hold, without suspending them. source records who asked.
|
|
385
|
+
*/
|
|
386
|
+
holdAfter(jobId, executionIds, hold, source) {
|
|
387
|
+
const job = this.lookup(jobId);
|
|
388
|
+
if (!job)
|
|
389
|
+
throw this.unknownJob(jobId);
|
|
390
|
+
if (!job.run || job.status !== "running")
|
|
391
|
+
throw new Error("Job is not accepting updates");
|
|
392
|
+
job.run.holdAfter(executionIds, hold, { source });
|
|
393
|
+
this.notify(job, hold ? "held" : "unheld", source, executionIds);
|
|
394
|
+
this.changed();
|
|
395
|
+
return this.get(jobId);
|
|
396
|
+
}
|
|
397
|
+
/** Admit work again and resume the executions the job pause paused. */
|
|
398
|
+
resumeJob(jobId, source = "agent") {
|
|
399
|
+
const job = this.lookup(jobId);
|
|
400
|
+
if (!job)
|
|
401
|
+
throw this.unknownJob(jobId);
|
|
402
|
+
if (!job.run || job.status !== "running")
|
|
403
|
+
throw new Error("Job is not accepting updates");
|
|
404
|
+
job.run.resumeRun();
|
|
405
|
+
this.notify(job, "resumed", source);
|
|
302
406
|
this.changed();
|
|
303
407
|
return this.get(jobId);
|
|
304
408
|
}
|
|
@@ -313,6 +417,27 @@ export class BraidJobs {
|
|
|
313
417
|
}))
|
|
314
418
|
.reverse();
|
|
315
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Jobs that have not reached a terminal status. A job is stalled when only
|
|
422
|
+
* held, suspended, or job-paused work remains, so it cannot progress without
|
|
423
|
+
* resume or cancel.
|
|
424
|
+
*/
|
|
425
|
+
outstanding() {
|
|
426
|
+
return [...this.jobs.values()].filter(job => job.status === "running").map(job => {
|
|
427
|
+
// Skip the snapshot copy while executions are visibly running.
|
|
428
|
+
const state = job.runningExecutions.size ? undefined : job.run?.snapshot();
|
|
429
|
+
return {
|
|
430
|
+
jobId: job.jobId,
|
|
431
|
+
handle: job.handle,
|
|
432
|
+
stalled: state?.status === "waiting",
|
|
433
|
+
...(state ? { revision: state.revision } : {}),
|
|
434
|
+
pausedExecutionIds: state?.pausedExecutionIds ?? [],
|
|
435
|
+
suspendedExecutionIds: state?.suspendedExecutionIds ?? [],
|
|
436
|
+
...timedOut(state),
|
|
437
|
+
...(state?.pause ? { paused: true } : {}),
|
|
438
|
+
};
|
|
439
|
+
});
|
|
440
|
+
}
|
|
316
441
|
/** Editor progress without copying outputs, transcripts, or execution snapshots. */
|
|
317
442
|
progress(jobId) {
|
|
318
443
|
const job = this.lookup(jobId);
|
|
@@ -324,7 +449,7 @@ export class BraidJobs {
|
|
|
324
449
|
done: nodes.filter(node => node.status === "completed" || node.status === "failed" || node.status === "skipped").length,
|
|
325
450
|
failed: nodes.filter(node => node.status === "failed").length,
|
|
326
451
|
running: [...new Set(job.runningExecutions.values())],
|
|
327
|
-
paused: job.live.pausedExecutionIds?.length ?? 0,
|
|
452
|
+
paused: (job.live.pausedExecutionIds?.length ?? 0) + job.suspendedExecutions.size,
|
|
328
453
|
};
|
|
329
454
|
}
|
|
330
455
|
cancel(jobId) {
|
|
@@ -336,6 +461,18 @@ export class BraidJobs {
|
|
|
336
461
|
job.controller.abort();
|
|
337
462
|
return true;
|
|
338
463
|
}
|
|
464
|
+
/** Fail running or paused executions only; their successors follow the usual failure semantics. */
|
|
465
|
+
cancelExecutions(jobId, executionIds, source) {
|
|
466
|
+
const job = this.lookup(jobId);
|
|
467
|
+
if (!job)
|
|
468
|
+
throw this.unknownJob(jobId);
|
|
469
|
+
if (!job.run || job.status !== "running")
|
|
470
|
+
throw new Error("Job is not accepting updates");
|
|
471
|
+
job.run.cancelExecutions(executionIds, { source });
|
|
472
|
+
this.notify(job, "cancelled", source, executionIds);
|
|
473
|
+
this.changed();
|
|
474
|
+
return this.get(jobId);
|
|
475
|
+
}
|
|
339
476
|
/** Pi accounts usage only once, on the first retrieval of a terminal result. */
|
|
340
477
|
claimUsage(jobId) {
|
|
341
478
|
const job = this.lookup(jobId);
|