@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/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, blocking = false) {
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.node.pauseAfter || state?.error));
156
- if (!this.disposed && !job.blocking && shouldNotify && "nodeId" in event && instance) {
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 && (!job.blocking || job.status === "cancelled")) {
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 { blocking: _blocking, run: _run, controller: _controller, done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, runningExecutions: _runningExecutions, activity: _activity, transcripts: _transcripts, humanInputs: _humanInputs, ...snapshot } = job;
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(jobId, executionIds, expectedRevision) {
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 executions remain, so it cannot progress without resume or cancel.
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);