@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/runner.js CHANGED
@@ -1,10 +1,28 @@
1
1
  import { resolve } from "node:path";
2
- import { StringEnum, Type, validateToolCall, } from "@earendil-works/pi-ai";
3
- import { formatBudgetReminder, gitToolDefinition, finishMergeToolDefinition, mergeInstructions, parseGitToolArguments, parseFinishMergeArguments, } from "@chrok/braid";
2
+ import { StringEnum, Type, isRetryableAssistantError, retryDelayMs, validateToolCall, } from "@earendil-works/pi-ai";
3
+ import { formatBudgetReminder, interruptedToolResult, isStepInterrupted, notRunToolResult, gitToolDefinition, finishMergeToolDefinition, mergeInstructions, parseGitToolArguments, parseFinishMergeArguments, } from "@chrok/braid";
4
4
  import { createWorktreeWriteTools } from "./write-tools.js";
5
5
  import { createAvailableReadTools } from "./read-tools.js";
6
6
  import { createWorkspaceShellTools } from "./shell-tools.js";
7
7
  import { formatHumanInput } from "./human-input.js";
8
+ const DEFAULT_MODEL_RETRIES = 3;
9
+ /** Caps both Braid's backoff and a provider's Retry-After; longer requested waits fail immediately. */
10
+ const MAX_MODEL_RETRY_DELAY_MS = 60_000;
11
+ function abortableDelay(ms, signal) {
12
+ return new Promise((resolve, reject) => {
13
+ if (signal.aborted)
14
+ return reject(signal.reason);
15
+ const onAbort = () => {
16
+ clearTimeout(timer);
17
+ reject(signal.reason);
18
+ };
19
+ const timer = setTimeout(() => {
20
+ signal.removeEventListener("abort", onAbort);
21
+ resolve();
22
+ }, ms);
23
+ signal.addEventListener("abort", onAbort, { once: true });
24
+ });
25
+ }
8
26
  /**
9
27
  * Present a Git result the way Pi's bash tool presents command output. Output
10
28
  * stays byte-exact, so a diff can be passed back to git apply.
@@ -30,6 +48,14 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
30
48
  throw new TypeError(`${name} must be Infinity or a positive safe integer`);
31
49
  }
32
50
  }
51
+ const maxModelRetries = options.maxModelRetries ?? DEFAULT_MODEL_RETRIES;
52
+ const modelRetryDelayMs = options.modelRetryDelayMs ?? 2000;
53
+ if (!Number.isSafeInteger(maxModelRetries) || maxModelRetries < 0) {
54
+ throw new TypeError("maxModelRetries must be a non-negative safe integer");
55
+ }
56
+ if (!Number.isFinite(modelRetryDelayMs) || modelRetryDelayMs < 0) {
57
+ throw new TypeError("modelRetryDelayMs must be a non-negative finite number");
58
+ }
33
59
  const onUsage = options.onUsage;
34
60
  const onProgress = options.onProgress;
35
61
  const reportProgress = (progress) => {
@@ -45,7 +71,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
45
71
  messages: context.messages,
46
72
  tools: context.tools,
47
73
  }).length / 4);
48
- return async (request) => {
74
+ const runner = async (request) => {
49
75
  const name = request.model;
50
76
  const slash = name?.indexOf("/") ?? -1;
51
77
  if (!name || slash < 1 || slash === name.length - 1) {
@@ -182,8 +208,10 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
182
208
  let decided = false;
183
209
  let actualModel = name;
184
210
  let reportedContextTokens;
185
- const complete = async () => {
211
+ const prepareRequest = async () => {
186
212
  request.signal.throwIfAborted();
213
+ // A pause takes effect here; messages sent while suspended are delivered after resume.
214
+ await request.checkpoint?.();
187
215
  deliverHumanInputs();
188
216
  const budgets = [];
189
217
  if (Number.isFinite(maxToolRounds)) {
@@ -195,6 +223,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
195
223
  if (budgets.length > 0) {
196
224
  budgets.push("When a tool budget reaches zero, make no further tool calls and return your final answer. Reserve budget for decide or finish_merge if required.");
197
225
  }
226
+ // Rebuilt per attempt: a suspension moves the node deadline.
198
227
  context.systemPrompt = systemPrompt + formatBudgetReminder(request, budgets);
199
228
  reportProgress({
200
229
  nodeId: request.node.id,
@@ -206,82 +235,125 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
206
235
  toolRounds,
207
236
  phase: "model",
208
237
  });
209
- // The registry resolves stored API keys, OAuth, custom headers and endpoints.
210
- // complete() is stream().result(); streaming only adds observation.
211
- const requestOptions = {
212
- signal: request.signal,
213
- sessionId,
214
- cacheRetention: "none",
215
- transport: "sse",
216
- maxRetries: 0,
217
- };
218
- activity?.modelRequest();
219
- let response;
220
- try {
221
- if (registry.stream) {
222
- const stream = registry.stream(model, context, requestOptions);
223
- for await (const event of stream) {
224
- if (event.type !== "done" && event.type !== "error")
225
- transcripts?.streaming(executionId, event.partial);
226
- if (event.type === "text_delta")
227
- activity?.modelStream("text", event.delta);
228
- else if (event.type === "thinking_delta")
229
- activity?.modelStream("reasoning", event.delta);
230
- else if (event.type === "toolcall_start" || event.type === "toolcall_delta") {
231
- const block = event.partial.content[event.contentIndex];
232
- activity?.modelStream("tool_call", event.type === "toolcall_delta" ? event.delta : "", block?.type === "toolCall" ? block.name : undefined);
238
+ };
239
+ const complete = async () => {
240
+ let retries = 0;
241
+ while (true) {
242
+ // Retries pass the checkpoint too, so a pause during backoff stops before the resend.
243
+ await prepareRequest();
244
+ // A node timeout pause interrupts only this step; the request is sent again after resume.
245
+ const step = request.stepSignal?.() ?? request.signal;
246
+ // The registry resolves stored API keys, OAuth, custom headers and endpoints.
247
+ // complete() is stream().result(); streaming only adds observation.
248
+ // pi-ai retries rejected requests before a response starts, honoring Retry-After.
249
+ const requestOptions = {
250
+ signal: step,
251
+ sessionId,
252
+ cacheRetention: "none",
253
+ transport: "sse",
254
+ maxRetries: maxModelRetries,
255
+ maxRetryDelayMs: MAX_MODEL_RETRY_DELAY_MS,
256
+ };
257
+ activity?.modelRequest();
258
+ let response;
259
+ // Without a stream we cannot tell where a request failed, so Braid may retry it too.
260
+ let started = !registry.stream;
261
+ try {
262
+ if (registry.stream) {
263
+ const stream = registry.stream(model, context, requestOptions);
264
+ for await (const event of stream) {
265
+ if (event.type === "start")
266
+ started = true;
267
+ if (event.type !== "done" && event.type !== "error")
268
+ transcripts?.streaming(executionId, event.partial);
269
+ if (event.type === "text_delta")
270
+ activity?.modelStream("text", event.delta);
271
+ else if (event.type === "thinking_delta")
272
+ activity?.modelStream("reasoning", event.delta);
273
+ else if (event.type === "toolcall_start" || event.type === "toolcall_delta") {
274
+ const block = event.partial.content[event.contentIndex];
275
+ activity?.modelStream("tool_call", event.type === "toolcall_delta" ? event.delta : "", block?.type === "toolCall" ? block.name : undefined);
276
+ }
233
277
  }
278
+ response = await stream.result();
279
+ }
280
+ else {
281
+ response = await registry.complete(model, context, requestOptions);
234
282
  }
235
- response = await stream.result();
236
283
  }
237
- else {
238
- response = await registry.complete(model, context, requestOptions);
284
+ catch (error) {
285
+ if (isStepInterrupted(request, step)) {
286
+ activity?.modelError("Model request interrupted by the node timeout");
287
+ continue;
288
+ }
289
+ activity?.modelError(error instanceof Error ? error.message : String(error));
290
+ throw error;
239
291
  }
240
- }
241
- catch (error) {
242
- activity?.modelError(error instanceof Error ? error.message : String(error));
243
- throw error;
244
- }
245
- transcripts?.assistant(executionId, response);
246
- activity?.modelResponse({
247
- text: response.content.flatMap(block => block.type === "text" ? [block.text] : []).join("\n\n"),
248
- reasoning: response.content.flatMap(block => block.type === "thinking" ? [block.thinking] : []).join("\n\n"),
249
- toolCalls: response.content.flatMap(block => block.type === "toolCall" ? [{ id: block.id, name: block.name }] : []),
250
- stopReason: response.stopReason,
251
- inputTokens: response.usage.input + response.usage.cacheRead + response.usage.cacheWrite,
252
- outputTokens: response.usage.output,
253
- });
254
- if (response.stopReason !== "stop" && response.stopReason !== "toolUse") {
255
- activity?.modelError(response.errorMessage || `Pi model ended with '${response.stopReason}'`);
256
- }
257
- actualModel = `${response.provider}/${response.responseModel ?? response.model}`;
258
- const providerContextTokens = response.usage.input +
259
- response.usage.cacheRead +
260
- response.usage.cacheWrite;
261
- if (providerContextTokens)
262
- reportedContextTokens = providerContextTokens;
263
- reportProgress({
264
- nodeId: request.node.id,
265
- ...(request.execution.executionId ? { executionId: request.execution.executionId } : {}),
266
- contextTokens: reportedContextTokens ?? estimateContextTokens(context),
267
- contextWindow: model.contextWindow,
268
- contextSource: reportedContextTokens !== undefined ? "reported" : "estimate",
269
- toolCalls,
270
- toolRounds,
271
- phase: "model",
272
- });
273
- onUsage?.(structuredClone(response.usage));
274
- usage.inputTokens +=
275
- response.usage.input +
292
+ // A failed attempt stays visible in the session view but never enters the context.
293
+ transcripts?.assistant(executionId, response);
294
+ activity?.modelResponse({
295
+ text: response.content.flatMap(block => block.type === "text" ? [block.text] : []).join("\n\n"),
296
+ reasoning: response.content.flatMap(block => block.type === "thinking" ? [block.thinking] : []).join("\n\n"),
297
+ toolCalls: response.content.flatMap(block => block.type === "toolCall" ? [{ id: block.id, name: block.name }] : []),
298
+ stopReason: response.stopReason,
299
+ inputTokens: response.usage.input + response.usage.cacheRead + response.usage.cacheWrite,
300
+ outputTokens: response.usage.output,
301
+ });
302
+ if (response.stopReason !== "stop" && response.stopReason !== "toolUse") {
303
+ activity?.modelError(response.errorMessage || `Pi model ended with '${response.stopReason}'`);
304
+ }
305
+ actualModel = `${response.provider}/${response.responseModel ?? response.model}`;
306
+ const providerContextTokens = response.usage.input +
276
307
  response.usage.cacheRead +
277
308
  response.usage.cacheWrite;
278
- usage.outputTokens += response.usage.output;
279
- request.signal.throwIfAborted();
280
- if (response.stopReason !== "stop" && response.stopReason !== "toolUse") {
281
- throw new Error(response.errorMessage ||
282
- `Pi model ended with '${response.stopReason}'`);
309
+ if (providerContextTokens)
310
+ reportedContextTokens = providerContextTokens;
311
+ reportProgress({
312
+ nodeId: request.node.id,
313
+ ...(request.execution.executionId ? { executionId: request.execution.executionId } : {}),
314
+ contextTokens: reportedContextTokens ?? estimateContextTokens(context),
315
+ contextWindow: model.contextWindow,
316
+ contextSource: reportedContextTokens !== undefined ? "reported" : "estimate",
317
+ toolCalls,
318
+ toolRounds,
319
+ phase: "model",
320
+ });
321
+ // Failed attempts are billed too.
322
+ onUsage?.(structuredClone(response.usage));
323
+ usage.inputTokens +=
324
+ response.usage.input +
325
+ response.usage.cacheRead +
326
+ response.usage.cacheWrite;
327
+ usage.outputTokens += response.usage.output;
328
+ request.signal.throwIfAborted();
329
+ // The partial response never enters the context.
330
+ if (isStepInterrupted(request, step))
331
+ continue;
332
+ // A stream that failed after it started is restarted from the same context. Tools run
333
+ // only after a complete response, so nothing from the failed attempt has executed.
334
+ if (response.stopReason === "error" && started && retries < maxModelRetries && isRetryableAssistantError(response)) {
335
+ const delayMs = retryDelayMs({ baseDelayMs: modelRetryDelayMs, maxAgentDelayMs: MAX_MODEL_RETRY_DELAY_MS }, retries + 1);
336
+ const deadline = Math.min(request.deadlines?.node ?? Infinity, request.deadlines?.graph ?? Infinity);
337
+ // Report the provider error rather than a timeout when the wait cannot finish in time.
338
+ if (performance.now() + delayMs < deadline) {
339
+ retries++;
340
+ activity?.modelError(`Retrying model request in ${Math.ceil(delayMs / 1000)}s (${retries}/${maxModelRetries})`);
341
+ try {
342
+ await abortableDelay(delayMs, step);
343
+ }
344
+ catch (error) {
345
+ if (!isStepInterrupted(request, step))
346
+ throw error;
347
+ }
348
+ continue;
349
+ }
350
+ }
351
+ if (response.stopReason !== "stop" && response.stopReason !== "toolUse") {
352
+ throw new Error(response.errorMessage ||
353
+ `Pi model ended with '${response.stopReason}'`);
354
+ }
355
+ return response;
283
356
  }
284
- return response;
285
357
  };
286
358
  const toolResult = (call, content, isError) => ({
287
359
  role: "toolResult",
@@ -291,11 +363,12 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
291
363
  isError,
292
364
  timestamp: Date.now(),
293
365
  });
294
- const executeFileTool = async (call) => {
366
+ const executeFileTool = async (call, signal) => {
295
367
  const tool = toolByName.get(call.name);
296
368
  if (!tool && !(call.name === "git" && request.git) && !(call.name === "finish_merge" && request.merge)) {
297
369
  return toolResult(call, `Tool '${call.name}' is unavailable in this Braid node. Available tools: ${allToolDefinitions.map(tool => tool.name).join(", ")}.`, true);
298
370
  }
371
+ let partialOutput = "";
299
372
  try {
300
373
  request.signal.throwIfAborted();
301
374
  if (call.name === "git" && request.git) {
@@ -309,17 +382,19 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
309
382
  return toolResult(call, "Merge dispositions recorded. Return your final answer.", false);
310
383
  }
311
384
  const args = validateToolCall(allToolDefinitions, call);
312
- const execute = () => tool.execute(call.id, args, request.signal,
385
+ const execute = () => tool.execute(call.id, args, signal,
313
386
  // Bash sends cumulative, throttled output snapshots.
314
387
  (partial) => {
315
- activity?.toolOutput(call.id, partial.content
316
- .flatMap(block => block.type === "text" ? [block.text] : []).join(""), "replace");
388
+ partialOutput = partial.content.flatMap(block => block.type === "text" ? [block.text] : []).join("");
389
+ activity?.toolOutput(call.id, partialOutput, "replace");
317
390
  transcripts?.toolUpdate(executionId, call.id, { content: partial.content, details: partial.details });
318
391
  });
319
392
  const result = ["write", "edit", "bash", "powershell"].includes(call.name) && request.withWorkspaceWrite
320
393
  ? await request.withWorkspaceWrite(execute)
321
394
  : await execute();
322
395
  request.signal.throwIfAborted();
396
+ if (isStepInterrupted(request, signal))
397
+ return toolResult(call, interruptedToolResult(partialOutput), true);
323
398
  return {
324
399
  role: "toolResult",
325
400
  toolCallId: call.id,
@@ -332,12 +407,14 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
332
407
  }
333
408
  catch (error) {
334
409
  request.signal.throwIfAborted();
410
+ if (isStepInterrupted(request, signal))
411
+ return toolResult(call, interruptedToolResult(partialOutput), true);
335
412
  return toolResult(call, error instanceof Error ? error.message : String(error), true);
336
413
  }
337
414
  };
338
- const executeCall = async (call) => {
415
+ const executeCall = async (call, signal) => {
339
416
  if (call.name !== "decide")
340
- return executeFileTool(call);
417
+ return executeFileTool(call, signal);
341
418
  if (request.node.type !== "decision") {
342
419
  return toolResult(call, "decide is available only on decision nodes", true);
343
420
  }
@@ -391,11 +468,19 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
391
468
  const results = [];
392
469
  for (const call of calls) {
393
470
  request.signal.throwIfAborted();
471
+ const step = request.stepSignal?.() ?? request.signal;
472
+ // After an interruption the rest of the batch is not run, but every call still gets a result.
473
+ if (isStepInterrupted(request, step)) {
474
+ const result = toolResult(call, notRunToolResult(), true);
475
+ transcripts?.toolResult(executionId, result);
476
+ results.push(result);
477
+ continue;
478
+ }
394
479
  activity?.toolStart(call.id, call.name, call.arguments);
395
480
  transcripts?.toolStart(executionId, call.id);
396
481
  let result;
397
482
  try {
398
- result = await executeCall(call);
483
+ result = await executeCall(call, step);
399
484
  }
400
485
  catch (error) {
401
486
  activity?.toolEnd(call.id, error instanceof Error ? error.message : String(error), true);
@@ -421,6 +506,7 @@ export function createPiRunner(registry, onUsageOrOptions, cwd = process.cwd())
421
506
  usage,
422
507
  };
423
508
  };
509
+ return Object.assign(runner, { pausable: true, interruptible: true });
424
510
  }
425
511
  /** Includes cache/cost fields for Pi's tool-usage accounting, separate from core token totals. */
426
512
  export function sumPiUsage(reports) {
@@ -58,11 +58,23 @@ function workspaceLine(workspace) {
58
58
  parts.push(`${item.executionId} ${item.disposition}: ${item.reason}`);
59
59
  return parts.join(" · ");
60
60
  }
61
+ /** Who paused an execution; a node timeout pause also says how long it ran. */
62
+ export function pausedBy(suspension) {
63
+ if (suspension.source !== "node_timeout")
64
+ return `paused by ${suspension.source}`;
65
+ return suspension.timeout ? `paused by node timeout after ${duration(suspension.timeout.activeMs)} (budget ${duration(suspension.timeout.nodeTimeoutMs)})` : "paused by node timeout";
66
+ }
67
+ /** A node timeout pause waits for the parent's decision. */
68
+ const TIMEOUT_PAUSE_NOTE = "Its node timeout ran out. Resume it with braid_resume (optionally with nodeTimeoutMs; the default is the job's nodeTimeoutMs again), or fail only this execution with braid_cancel and executionIds. Its successors wait until then.";
61
69
  function executionLine(execution) {
62
70
  const parts = [execution.executionId ?? "(no execution)", `node ${execution.id}`];
63
71
  if (execution.iteration)
64
72
  parts.push(`iteration ${execution.iteration}`);
65
73
  parts.push(execution.skipReason ? `${execution.status} (${execution.skipReason})` : execution.status);
74
+ if (execution.suspension)
75
+ parts.push(`${execution.suspension.state}, ${pausedBy(execution.suspension)}`);
76
+ if (pendingHold(execution))
77
+ parts.push(`holds when finished, set by ${holdSource(execution.holdAfter)}`);
66
78
  if (execution.latencyMs !== undefined)
67
79
  parts.push(duration(execution.latencyMs));
68
80
  if (execution.decision)
@@ -77,8 +89,27 @@ export function formatJobList(jobs) {
77
89
  return [`${jobs.length} Braid job${jobs.length === 1 ? "" : "s"} in this session, newest first:`,
78
90
  ...jobs.map(job => `- ${job.handle} · ${job.status} · started ${iso(job.createdAt)} · id ${job.jobId}\n Goal: ${job.goal}`)].join("\n");
79
91
  }
92
+ /** Held (pauseAfter) and suspended executions are resumed the same way; a job pause has its own resume. */
80
93
  function control(execution) {
81
- return execution ? `Graph ${execution.status} · revision ${execution.revision} · paused executions: ${ids(execution.pausedExecutionIds)}` : "Graph not started";
94
+ if (!execution)
95
+ return "Graph not started";
96
+ const parts = [`Graph ${execution.status}`, `revision ${execution.revision}`, `paused executions: ${ids(execution.pausedExecutionIds)}`];
97
+ if (execution.suspendedExecutionIds?.length)
98
+ parts.push(`suspended executions: ${ids(execution.suspendedExecutionIds)}`);
99
+ if (execution.pause)
100
+ parts.push(`job paused by ${execution.pause.source} at ${iso(execution.pause.pausedAt)}`);
101
+ return parts.join(" · ");
102
+ }
103
+ /** Paused-by-user work is the user's call: say so wherever the parent sees it. */
104
+ const USER_PAUSE_NOTE = "The user paused this; do not resume it unless the user asks.";
105
+ const USER_HOLD_NOTE = "The user set this hold; do not remove it or release the result unless the user asks.";
106
+ /** A hold that applies once a still-unsettled execution finishes. Settled holds are paused executions. */
107
+ function pendingHold(execution) {
108
+ return Boolean(execution.holdAfter) && (execution.status === "running" || execution.status === "runnable");
109
+ }
110
+ const holdSource = (hold) => hold.source === "definition" ? "pauseAfter" : hold.source;
111
+ function userPaused(execution, executions) {
112
+ return execution?.pause?.source === "user" || executions.some(item => item.suspension?.source === "user" || item.holdAfter?.source === "user");
82
113
  }
83
114
  /** The submission receipt. The caller already has the graph it submitted. */
84
115
  export function formatReceipt(job, message) {
@@ -86,10 +117,40 @@ export function formatReceipt(job, message) {
86
117
  }
87
118
  export function formatUpdate(job) {
88
119
  return [`Braid job ${job.handle} updated to revision ${job.execution.revision}.`,
89
- `Paused executions: ${ids(job.execution.pausedExecutionIds)}`, "Current graph:", formatGraph(job.execution.graph)].join("\n");
120
+ `Paused executions: ${ids(job.execution.pausedExecutionIds)}`,
121
+ ...job.execution.suspendedExecutionIds?.length ? [`Suspended executions: ${ids(job.execution.suspendedExecutionIds)}`] : [],
122
+ "Current graph:", formatGraph(job.execution.graph)].join("\n");
90
123
  }
91
124
  export function formatResume(job) {
92
- return `Braid job ${job.handle} resumed at revision ${job.execution.revision}. Still paused: ${ids(job.execution.pausedExecutionIds)}.`;
125
+ const execution = job.execution;
126
+ return `Braid job ${job.handle} resumed at revision ${execution.revision}. Still paused: ${ids(execution.pausedExecutionIds)}. ` +
127
+ `Still suspended: ${ids(execution.suspendedExecutionIds)}.${execution.pause ? ` The job itself is still paused by ${execution.pause.source}.` : ""}`;
128
+ }
129
+ /** The result of braid_pause or braid_resume with after=true. */
130
+ export function formatHold(job, executionIds, hold) {
131
+ const execution = job.execution;
132
+ if (!hold)
133
+ return [`Braid job ${job.handle}: hold removed for ${ids(executionIds)}; their results go downstream when they finish.`, control(execution)].join("\n");
134
+ return [
135
+ `Braid job ${job.handle}: ${ids(executionIds)} keep running and will be held when they finish; downstream work waits until you release them.`,
136
+ control(execution),
137
+ "Each one appears in paused executions once it finishes, and you get a pause reminder. Review it with braid_status, then release it with " +
138
+ `braid_resume({"jobId":"${job.handle}","expectedRevision":<current>,"executionIds":[...]}), or atomically with braid_update resume. ` +
139
+ `To remove the hold before it finishes, use braid_resume({"jobId":"${job.handle}","executionIds":[...],"after":true}).`,
140
+ ].join("\n");
141
+ }
142
+ /** The result of braid_pause: what is pausing now and how to continue. */
143
+ export function formatPause(job, executionIds) {
144
+ const execution = job.execution;
145
+ const pausing = Object.values(execution.executions).filter(item => item.suspension?.state === "pausing").map(item => item.executionId);
146
+ return [
147
+ executionIds ? `Braid job ${job.handle}: pause requested for ${ids(executionIds)}.` : `Braid job ${job.handle} paused: no new executions start, and running ones pause.`,
148
+ control(execution),
149
+ `Pausing (each stops before its next model request; a running tool finishes first): ${ids(pausing)}.`,
150
+ "Suspended executions keep their conversation and workspace and release their concurrency slot. The node timeout stops while suspended; the graph timeout keeps running. " +
151
+ (executionIds ? `Continue with braid_resume({"jobId":"${job.handle}","expectedRevision":${execution.revision},"executionIds":[...]}).`
152
+ : `Continue with braid_resume({"jobId":"${job.handle}"}).`),
153
+ ].join("\n");
93
154
  }
94
155
  export function formatActivity(activity, now, jobId) {
95
156
  const lines = [`Activity: ${activity.phase} for ${duration(now - activity.phaseStartedAt)}; last observed signal ${duration(now - activity.lastActivityAt)} ago (${activity.lastActivity}). Model requests ${activity.modelRequests}, tool calls ${activity.toolCalls}.`];
@@ -124,6 +185,8 @@ export function formatActivity(activity, now, jobId) {
124
185
  /** A focused read: the current control state, then one execution in full. */
125
186
  export function formatNodeStatus({ job, node, activity, now }) {
126
187
  const lines = [`Braid job ${job.handle} · ${job.status} · ${control(job.execution)}`,
188
+ ...node.suspension ? [`Execution is ${node.suspension.state}, ${pausedBy(node.suspension)}${node.suspension.suspendedAt !== undefined ? ` since ${iso(node.suspension.suspendedAt)}` : ""}.${node.suspension.source === "user" ? ` ${USER_PAUSE_NOTE}` : node.suspension.source === "node_timeout" ? ` ${TIMEOUT_PAUSE_NOTE}` : ""}`] : [],
189
+ ...pendingHold(node) ? [`Its result will be held when it finishes (set by ${holdSource(node.holdAfter)}).${node.holdAfter.source === "user" ? ` ${USER_HOLD_NOTE}` : ""}`] : [],
127
190
  `Execution ${node.executionId ?? "(none yet)"} · node ${node.id}${node.node ? ` (${node.node.type})` : ""}${node.iteration ? ` · iteration ${node.iteration}` : ""} · ${node.skipReason ? `${node.status} (${node.skipReason})` : node.status}`];
128
191
  const timing = [node.startedAt !== undefined && `started ${iso(node.startedAt)}`, node.latencyMs !== undefined && `took ${duration(node.latencyMs)}`,
129
192
  node.model && `model ${node.model}`, node.usage && `usage input ${node.usage.inputTokens}, output ${node.usage.outputTokens}`].filter(Boolean);
@@ -148,6 +211,8 @@ export function formatJobStatus(job, now) {
148
211
  const state = job.result ?? job.execution;
149
212
  // Control fields lead: a long goal or output must not push them out of a truncated preview.
150
213
  const header = [`Braid job ${job.handle} · ${job.status}`, `ID: ${job.jobId}`, control(job.execution)];
214
+ if (job.status === "running" && userPaused(job.execution, Object.values(job.execution?.executions ?? {})))
215
+ header.push(USER_PAUSE_NOTE);
151
216
  const error = job.error ?? (job.result?.error ? failure(job.result.error) : job.execution?.error ? failure(job.execution.error) : undefined);
152
217
  if (error)
153
218
  header.push(`Error: ${error}`);
@@ -177,3 +242,24 @@ export function formatJobStatus(job, now) {
177
242
  sections.push(`Current graph (revision ${job.execution.revision}):\n${formatGraph(job.execution.graph)}`);
178
243
  return sections.join("\n\n");
179
244
  }
245
+ /** The waking reminder for an execution its node timeout paused. */
246
+ export function formatTimeoutPause(pause) {
247
+ const execution = `${pause.executionId}${pause.iteration ? ` (iteration ${pause.iteration})` : ""}`;
248
+ return [
249
+ `Braid job ${pause.handle} node ${JSON.stringify(pause.nodeId)} execution ${execution} was paused because it exceeded its node timeout:`,
250
+ `it ran ${duration(pause.activeMs)} against a budget of ${duration(pause.nodeTimeoutMs)}.`,
251
+ pause.activity ? `When the timeout fired it was: ${pause.activity}.` : "",
252
+ "Its conversation, workspace, and tool budgets are kept; the step that was interrupted is reported to it after resume.",
253
+ "Its successors wait until you decide. Check it with",
254
+ `braid_status(${JSON.stringify({ jobId: pause.handle, executionId: pause.executionId })}), then either`,
255
+ `resume it with braid_resume({"jobId":"${pause.handle}","executionIds":["${pause.executionId}"],"expectedRevision":<current>}) — it gets the job's nodeTimeoutMs again, or pass nodeTimeoutMs —`,
256
+ "or resume it in braid_update with resume and resumeOptions together with graph changes,",
257
+ `or fail only this execution with braid_cancel({"jobId":"${pause.handle}","executionIds":["${pause.executionId}"]}).`,
258
+ "The graph timeout keeps running while it is paused.",
259
+ ].filter(Boolean).join(" ");
260
+ }
261
+ /** The result of braid_cancel with executionIds. */
262
+ export function formatCancelExecutions(job, executionIds) {
263
+ return [`Braid job ${job.handle}: cancellation requested for ${ids(executionIds)}. They fail; their successors follow the usual failure rules.`,
264
+ control(job.execution)].join("\n");
265
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@chrok/pi-braid",
3
- "version": "0.3.6",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "description": "Pi extension for Braid: background agent graphs, bounded loops, live updates, and a flow panel",
6
6
  "type": "module",
7
7
  "dependencies": {
8
8
  "grok-mermaid": "^0.2.2",
9
- "@chrok/braid": "0.3.6"
9
+ "@chrok/braid": "0.5.0"
10
10
  },
11
11
  "peerDependencies": {
12
12
  "@earendil-works/pi-ai": "*",