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