@chrok/pi-braid 0.4.0 → 0.5.1
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 +132 -56
- package/dist/command.js +126 -7
- package/dist/core-version.js +17 -0
- package/dist/display.js +63 -14
- package/dist/drain.js +11 -5
- package/dist/index.js +155 -95
- package/dist/jobs.js +150 -16
- package/dist/runner.js +183 -80
- package/dist/status-text.js +111 -3
- package/package.json +2 -2
package/dist/jobs.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
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;
|
|
18
|
+
onReplanRequested;
|
|
12
19
|
jobs = new Map();
|
|
13
20
|
assistants = new Map();
|
|
14
21
|
cwd = process.cwd();
|
|
@@ -16,9 +23,25 @@ export class BraidJobs {
|
|
|
16
23
|
nextHandle = 1;
|
|
17
24
|
listeners = new Set();
|
|
18
25
|
disposed = false;
|
|
19
|
-
constructor(onFinished = () => { }, onNodeFinished = () => { }) {
|
|
26
|
+
constructor(onFinished = () => { }, onNodeFinished = () => { }, onControl = () => { }, onTimeoutPause = () => { }, onReplanRequested = () => { }) {
|
|
20
27
|
this.onFinished = onFinished;
|
|
21
28
|
this.onNodeFinished = onNodeFinished;
|
|
29
|
+
this.onControl = onControl;
|
|
30
|
+
this.onTimeoutPause = onTimeoutPause;
|
|
31
|
+
this.onReplanRequested = onReplanRequested;
|
|
32
|
+
}
|
|
33
|
+
/** The parent made the change itself; anyone else's change becomes a notice for it. */
|
|
34
|
+
notify(job, action, source, executionIds) {
|
|
35
|
+
if (source === "agent" || this.disposed)
|
|
36
|
+
return;
|
|
37
|
+
const state = job.run?.snapshot();
|
|
38
|
+
try {
|
|
39
|
+
this.onControl({
|
|
40
|
+
jobId: job.jobId, handle: job.handle, action, source,
|
|
41
|
+
...(executionIds ? { executions: executionIds.map(executionId => ({ executionId, nodeId: state?.executions[executionId]?.id ?? "?" })) } : {}),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch { /* Notices are advisory. */ }
|
|
22
45
|
}
|
|
23
46
|
subscribe(listener) {
|
|
24
47
|
this.listeners.add(listener);
|
|
@@ -36,12 +59,10 @@ export class BraidJobs {
|
|
|
36
59
|
}
|
|
37
60
|
}
|
|
38
61
|
}
|
|
39
|
-
start(input, options, ctx
|
|
62
|
+
start(input, options, ctx) {
|
|
40
63
|
if (this.disposed)
|
|
41
64
|
throw new Error("Braid session has closed");
|
|
42
65
|
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
66
|
const snapshot = structuredClone(input);
|
|
46
67
|
const settings = { ...options };
|
|
47
68
|
const model = ctx.model
|
|
@@ -51,7 +72,6 @@ export class BraidJobs {
|
|
|
51
72
|
const cwd = ctx.cwd;
|
|
52
73
|
this.cwd = cwd;
|
|
53
74
|
const job = {
|
|
54
|
-
blocking,
|
|
55
75
|
handle: `job-${this.nextHandle++}`,
|
|
56
76
|
jobId: crypto.randomUUID(),
|
|
57
77
|
goal: snapshot.goal,
|
|
@@ -62,6 +82,8 @@ export class BraidJobs {
|
|
|
62
82
|
done: Promise.resolve(),
|
|
63
83
|
usageClaimed: false,
|
|
64
84
|
runningExecutions: new Map(),
|
|
85
|
+
suspendedExecutions: new Map(),
|
|
86
|
+
timeoutActivity: new Map(),
|
|
65
87
|
activity: new ExecutionActivityTracker({ onChange: () => this.changed() }),
|
|
66
88
|
transcripts: new PiTranscripts(() => this.changed()),
|
|
67
89
|
humanInputs: new PiHumanInputs(() => this.changed()),
|
|
@@ -92,6 +114,7 @@ export class BraidJobs {
|
|
|
92
114
|
humanInputs: job.humanInputs,
|
|
93
115
|
maxToolRounds: options.maxToolRounds ?? Infinity,
|
|
94
116
|
maxToolCalls: options.maxToolCalls ?? Infinity,
|
|
117
|
+
...(options.maxModelRetries === undefined ? {} : { maxModelRetries: options.maxModelRetries }),
|
|
95
118
|
onUsage: (usage) => {
|
|
96
119
|
if (job.status === "running" && !job.controller.signal.aborted)
|
|
97
120
|
reports.push(usage);
|
|
@@ -103,7 +126,7 @@ export class BraidJobs {
|
|
|
103
126
|
this.changed();
|
|
104
127
|
},
|
|
105
128
|
});
|
|
106
|
-
return async (request) => {
|
|
129
|
+
return Object.assign(async (request) => {
|
|
107
130
|
try {
|
|
108
131
|
await nextTurn();
|
|
109
132
|
return await invoke(request);
|
|
@@ -116,9 +139,15 @@ export class BraidJobs {
|
|
|
116
139
|
}
|
|
117
140
|
job.transcripts.finish(request.execution.executionId, request.signal.aborted ? "Execution was stopped before this step finished" : undefined);
|
|
118
141
|
}
|
|
119
|
-
};
|
|
142
|
+
}, { pausable: invoke.pausable === true, interruptible: invoke.interruptible === true });
|
|
120
143
|
})(),
|
|
121
144
|
onEvent: (event) => {
|
|
145
|
+
// Capture what the worker was doing before the interruption changes it.
|
|
146
|
+
if (event.type === "execution_suspending" && event.source === "node_timeout" && event.executionId) {
|
|
147
|
+
const activity = job.activity.get(event.executionId);
|
|
148
|
+
if (activity)
|
|
149
|
+
job.timeoutActivity.set(event.executionId, formatActivityStatus(activity, event.timestamp));
|
|
150
|
+
}
|
|
122
151
|
job.activity.observe(event);
|
|
123
152
|
if (event.type === "workspace_updated") {
|
|
124
153
|
job.workspaces ??= {};
|
|
@@ -127,14 +156,25 @@ export class BraidJobs {
|
|
|
127
156
|
});
|
|
128
157
|
}
|
|
129
158
|
// Definition edits can remove nodes whose executions are still running.
|
|
159
|
+
// A suspended execution is not running: drain can then see a stalled job.
|
|
130
160
|
if (event.type === "node_started" && event.executionId) {
|
|
131
161
|
job.runningExecutions.set(event.executionId, event.nodeId);
|
|
132
162
|
}
|
|
163
|
+
else if (event.type === "execution_suspended" && event.executionId) {
|
|
164
|
+
job.runningExecutions.delete(event.executionId);
|
|
165
|
+
job.suspendedExecutions.set(event.executionId, event.nodeId);
|
|
166
|
+
}
|
|
167
|
+
else if (event.type === "execution_resumed" && event.executionId && job.suspendedExecutions.has(event.executionId)) {
|
|
168
|
+
job.runningExecutions.set(event.executionId, event.nodeId);
|
|
169
|
+
job.suspendedExecutions.delete(event.executionId);
|
|
170
|
+
}
|
|
133
171
|
else if ((event.type === "node_completed" || event.type === "node_failed") && event.executionId) {
|
|
134
172
|
job.runningExecutions.delete(event.executionId);
|
|
173
|
+
job.suspendedExecutions.delete(event.executionId);
|
|
135
174
|
}
|
|
136
175
|
else if (event.type === "graph_completed" || event.type === "graph_failed") {
|
|
137
176
|
job.runningExecutions.clear();
|
|
177
|
+
job.suspendedExecutions.clear();
|
|
138
178
|
}
|
|
139
179
|
applyEvent(job.live, event);
|
|
140
180
|
if ("nodeId" in event && Object.hasOwn(job.live.nodes, event.nodeId)) {
|
|
@@ -147,13 +187,42 @@ export class BraidJobs {
|
|
|
147
187
|
});
|
|
148
188
|
}
|
|
149
189
|
this.changed();
|
|
190
|
+
if (event.type === "execution_suspended" && event.source === "node_timeout" && event.executionId) {
|
|
191
|
+
const instance = job.run?.snapshot().executions[event.executionId];
|
|
192
|
+
const timeout = instance?.suspension?.timeout;
|
|
193
|
+
const activity = job.timeoutActivity.get(event.executionId);
|
|
194
|
+
job.timeoutActivity.delete(event.executionId);
|
|
195
|
+
// A timeout pause always wakes the parent, which must resume or cancel it.
|
|
196
|
+
if (!this.disposed && instance && timeout) {
|
|
197
|
+
try {
|
|
198
|
+
void Promise.resolve(this.onTimeoutPause({
|
|
199
|
+
jobId: job.jobId, handle: job.handle, nodeId: event.nodeId, executionId: event.executionId,
|
|
200
|
+
...(instance.iteration ? { iteration: instance.iteration } : {}),
|
|
201
|
+
nodeTimeoutMs: timeout.nodeTimeoutMs, activeMs: timeout.activeMs,
|
|
202
|
+
...(activity ? { activity } : {}), eventSequence: event.sequence,
|
|
203
|
+
})).catch(() => { });
|
|
204
|
+
}
|
|
205
|
+
catch { /* Notification failures must not affect scheduling. */ }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// A replan request wakes the parent now, while the node keeps working.
|
|
209
|
+
if (!this.disposed && event.type === "replan_requested" && event.executionId) {
|
|
210
|
+
try {
|
|
211
|
+
void Promise.resolve(this.onReplanRequested({
|
|
212
|
+
jobId: job.jobId, handle: job.handle, nodeId: event.nodeId, executionId: event.executionId,
|
|
213
|
+
...(event.iteration ? { iteration: event.iteration } : {}),
|
|
214
|
+
reason: event.reason, suggested: event.suggestion !== undefined, eventSequence: event.sequence,
|
|
215
|
+
})).catch(() => { });
|
|
216
|
+
}
|
|
217
|
+
catch { /* Notification failures must not affect scheduling. */ }
|
|
218
|
+
}
|
|
150
219
|
const terminal = event.type === "execution_paused" || event.type === "node_completed" || event.type === "node_failed";
|
|
151
220
|
const state = terminal ? job.run?.snapshot() : undefined;
|
|
152
221
|
const instance = event.executionId ? state?.executions[event.executionId] : undefined;
|
|
153
222
|
const shouldNotify = event.type === "execution_paused" ||
|
|
154
223
|
((event.type === "node_completed" || event.type === "node_failed") && instance?.node.notifyOnCompletion &&
|
|
155
|
-
(!instance.
|
|
156
|
-
if (!this.disposed &&
|
|
224
|
+
(!instance.holdAfter || state?.error));
|
|
225
|
+
if (!this.disposed && shouldNotify && "nodeId" in event && instance) {
|
|
157
226
|
try {
|
|
158
227
|
void Promise.resolve(this.onNodeFinished({
|
|
159
228
|
jobId: job.jobId,
|
|
@@ -165,6 +234,7 @@ export class BraidJobs {
|
|
|
165
234
|
status: instance.status === "completed" ? "completed" : "failed",
|
|
166
235
|
eventSequence: event.sequence,
|
|
167
236
|
...(instance.error ? { errorCode: instance.error.code } : {}),
|
|
237
|
+
...(instance.replan ? { replanRequested: true } : {}),
|
|
168
238
|
})).catch(() => { });
|
|
169
239
|
}
|
|
170
240
|
catch {
|
|
@@ -188,12 +258,13 @@ export class BraidJobs {
|
|
|
188
258
|
job.live.status = "failed";
|
|
189
259
|
job.live.pausedExecutionIds = [];
|
|
190
260
|
job.runningExecutions.clear();
|
|
261
|
+
job.suspendedExecutions.clear();
|
|
191
262
|
job.error = error instanceof Error ? error.message : String(error);
|
|
192
263
|
if (reports.length)
|
|
193
264
|
job.usage = sumPiUsage(reports);
|
|
194
265
|
}
|
|
195
266
|
this.changed();
|
|
196
|
-
if (!this.disposed
|
|
267
|
+
if (!this.disposed) {
|
|
197
268
|
try {
|
|
198
269
|
this.onFinished(this.get(job.jobId));
|
|
199
270
|
}
|
|
@@ -213,7 +284,7 @@ export class BraidJobs {
|
|
|
213
284
|
const job = this.lookup(jobId);
|
|
214
285
|
if (!job)
|
|
215
286
|
return undefined;
|
|
216
|
-
const {
|
|
287
|
+
const { run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, suspendedExecutions: _suspendedExecutions, activity: _activity, transcripts: _transcripts, humanInputs: _humanInputs, ...snapshot } = job;
|
|
217
288
|
const copy = structuredClone(snapshot);
|
|
218
289
|
if (job.run)
|
|
219
290
|
copy.execution = job.run.snapshot();
|
|
@@ -292,13 +363,60 @@ export class BraidJobs {
|
|
|
292
363
|
this.changed();
|
|
293
364
|
return this.get(jobId);
|
|
294
365
|
}
|
|
295
|
-
resume
|
|
366
|
+
/** Release held executions or resume paused ones. */
|
|
367
|
+
resume(jobId, executionIds, expectedRevision, source = "agent", options) {
|
|
296
368
|
const job = this.lookup(jobId);
|
|
297
369
|
if (!job)
|
|
298
370
|
throw this.unknownJob(jobId);
|
|
299
371
|
if (!job.run)
|
|
300
372
|
throw new Error("Job is not accepting updates");
|
|
301
|
-
job.run.resume(executionIds, expectedRevision);
|
|
373
|
+
job.run.resume(executionIds, expectedRevision, options);
|
|
374
|
+
this.notify(job, "resumed", source, executionIds);
|
|
375
|
+
this.changed();
|
|
376
|
+
return this.get(jobId);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Pause running executions at their next model request, or the whole job
|
|
380
|
+
* when executionIds is omitted. source records who asked, e.g. "agent" or "user".
|
|
381
|
+
*/
|
|
382
|
+
pause(jobId, executionIds, source) {
|
|
383
|
+
const job = this.lookup(jobId);
|
|
384
|
+
if (!job)
|
|
385
|
+
throw this.unknownJob(jobId);
|
|
386
|
+
if (!job.run || job.status !== "running")
|
|
387
|
+
throw new Error("Job is not accepting updates");
|
|
388
|
+
if (executionIds)
|
|
389
|
+
job.run.pause(executionIds, { source });
|
|
390
|
+
else
|
|
391
|
+
job.run.pauseRun({ source });
|
|
392
|
+
this.notify(job, "paused", source, executionIds);
|
|
393
|
+
this.changed();
|
|
394
|
+
return this.get(jobId);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Hold running executions' results once they finish (hold = true), or remove
|
|
398
|
+
* that hold, without suspending them. source records who asked.
|
|
399
|
+
*/
|
|
400
|
+
holdAfter(jobId, executionIds, hold, source) {
|
|
401
|
+
const job = this.lookup(jobId);
|
|
402
|
+
if (!job)
|
|
403
|
+
throw this.unknownJob(jobId);
|
|
404
|
+
if (!job.run || job.status !== "running")
|
|
405
|
+
throw new Error("Job is not accepting updates");
|
|
406
|
+
job.run.holdAfter(executionIds, hold, { source });
|
|
407
|
+
this.notify(job, hold ? "held" : "unheld", source, executionIds);
|
|
408
|
+
this.changed();
|
|
409
|
+
return this.get(jobId);
|
|
410
|
+
}
|
|
411
|
+
/** Admit work again and resume the executions the job pause paused. */
|
|
412
|
+
resumeJob(jobId, source = "agent") {
|
|
413
|
+
const job = this.lookup(jobId);
|
|
414
|
+
if (!job)
|
|
415
|
+
throw this.unknownJob(jobId);
|
|
416
|
+
if (!job.run || job.status !== "running")
|
|
417
|
+
throw new Error("Job is not accepting updates");
|
|
418
|
+
job.run.resumeRun();
|
|
419
|
+
this.notify(job, "resumed", source);
|
|
302
420
|
this.changed();
|
|
303
421
|
return this.get(jobId);
|
|
304
422
|
}
|
|
@@ -315,7 +433,8 @@ export class BraidJobs {
|
|
|
315
433
|
}
|
|
316
434
|
/**
|
|
317
435
|
* Jobs that have not reached a terminal status. A job is stalled when only
|
|
318
|
-
* paused
|
|
436
|
+
* held, suspended, or job-paused work remains, so it cannot progress without
|
|
437
|
+
* resume or cancel.
|
|
319
438
|
*/
|
|
320
439
|
outstanding() {
|
|
321
440
|
return [...this.jobs.values()].filter(job => job.status === "running").map(job => {
|
|
@@ -327,6 +446,9 @@ export class BraidJobs {
|
|
|
327
446
|
stalled: state?.status === "waiting",
|
|
328
447
|
...(state ? { revision: state.revision } : {}),
|
|
329
448
|
pausedExecutionIds: state?.pausedExecutionIds ?? [],
|
|
449
|
+
suspendedExecutionIds: state?.suspendedExecutionIds ?? [],
|
|
450
|
+
...timedOut(state),
|
|
451
|
+
...(state?.pause ? { paused: true } : {}),
|
|
330
452
|
};
|
|
331
453
|
});
|
|
332
454
|
}
|
|
@@ -341,7 +463,7 @@ export class BraidJobs {
|
|
|
341
463
|
done: nodes.filter(node => node.status === "completed" || node.status === "failed" || node.status === "skipped").length,
|
|
342
464
|
failed: nodes.filter(node => node.status === "failed").length,
|
|
343
465
|
running: [...new Set(job.runningExecutions.values())],
|
|
344
|
-
paused: job.live.pausedExecutionIds?.length ?? 0,
|
|
466
|
+
paused: (job.live.pausedExecutionIds?.length ?? 0) + job.suspendedExecutions.size,
|
|
345
467
|
};
|
|
346
468
|
}
|
|
347
469
|
cancel(jobId) {
|
|
@@ -353,6 +475,18 @@ export class BraidJobs {
|
|
|
353
475
|
job.controller.abort();
|
|
354
476
|
return true;
|
|
355
477
|
}
|
|
478
|
+
/** Fail running or paused executions only; their successors follow the usual failure semantics. */
|
|
479
|
+
cancelExecutions(jobId, executionIds, source) {
|
|
480
|
+
const job = this.lookup(jobId);
|
|
481
|
+
if (!job)
|
|
482
|
+
throw this.unknownJob(jobId);
|
|
483
|
+
if (!job.run || job.status !== "running")
|
|
484
|
+
throw new Error("Job is not accepting updates");
|
|
485
|
+
job.run.cancelExecutions(executionIds, { source });
|
|
486
|
+
this.notify(job, "cancelled", source, executionIds);
|
|
487
|
+
this.changed();
|
|
488
|
+
return this.get(jobId);
|
|
489
|
+
}
|
|
356
490
|
/** Pi accounts usage only once, on the first retrieval of a terminal result. */
|
|
357
491
|
claimUsage(jobId) {
|
|
358
492
|
const job = this.lookup(jobId);
|