@bastani/atomic 0.8.26-alpha.4 → 0.8.26-alpha.6

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/builtin/intercom/CHANGELOG.md +12 -0
  3. package/dist/builtin/intercom/package.json +1 -1
  4. package/dist/builtin/mcp/CHANGELOG.md +12 -0
  5. package/dist/builtin/mcp/package.json +1 -1
  6. package/dist/builtin/subagents/CHANGELOG.md +12 -0
  7. package/dist/builtin/subagents/package.json +1 -1
  8. package/dist/builtin/subagents/src/runs/background/subagent-runner.ts +48 -10
  9. package/dist/builtin/subagents/src/runs/foreground/execution.ts +30 -9
  10. package/dist/builtin/subagents/src/runs/shared/final-drain.ts +34 -0
  11. package/dist/builtin/subagents/src/runs/shared/model-fallback.ts +416 -7
  12. package/dist/builtin/web-access/CHANGELOG.md +12 -0
  13. package/dist/builtin/web-access/package.json +1 -1
  14. package/dist/builtin/workflows/CHANGELOG.md +12 -0
  15. package/dist/builtin/workflows/builtin/deep-research-codebase.ts +6 -6
  16. package/dist/builtin/workflows/builtin/goal.ts +10 -10
  17. package/dist/builtin/workflows/builtin/open-claude-design.ts +4 -4
  18. package/dist/builtin/workflows/builtin/ralph.ts +16 -16
  19. package/dist/builtin/workflows/package.json +1 -1
  20. package/dist/builtin/workflows/src/extension/index.ts +10 -2
  21. package/dist/builtin/workflows/src/extension/runtime.ts +35 -3
  22. package/dist/builtin/workflows/src/runs/background/status.ts +52 -6
  23. package/dist/builtin/workflows/src/runs/foreground/executor.ts +441 -15
  24. package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +69 -8
  25. package/dist/builtin/workflows/src/runs/shared/model-fallback.ts +402 -8
  26. package/dist/builtin/workflows/src/shared/persistence-restore.ts +182 -6
  27. package/dist/builtin/workflows/src/shared/persistence-session-entries.ts +76 -6
  28. package/dist/builtin/workflows/src/shared/stage-prompt.ts +33 -2
  29. package/dist/builtin/workflows/src/shared/store-types.ts +31 -0
  30. package/dist/builtin/workflows/src/shared/store.ts +99 -11
  31. package/dist/builtin/workflows/src/shared/workflow-failures.ts +758 -132
  32. package/dist/builtin/workflows/src/tui/store-widget-installer.ts +74 -74
  33. package/dist/core/tools/ask-user-question/tool/format-answer.d.ts +5 -5
  34. package/dist/core/tools/ask-user-question/tool/format-answer.d.ts.map +1 -1
  35. package/dist/core/tools/ask-user-question/tool/format-answer.js +5 -5
  36. package/dist/core/tools/ask-user-question/tool/format-answer.js.map +1 -1
  37. package/dist/core/tools/ask-user-question/tool/response-envelope.d.ts +16 -3
  38. package/dist/core/tools/ask-user-question/tool/response-envelope.d.ts.map +1 -1
  39. package/dist/core/tools/ask-user-question/tool/response-envelope.js +21 -3
  40. package/dist/core/tools/ask-user-question/tool/response-envelope.js.map +1 -1
  41. package/package.json +1 -1
@@ -7,11 +7,24 @@
7
7
  */
8
8
 
9
9
  import type { Store } from "./store.js";
10
- import type { RunSnapshot, StageSnapshot, StageStatus, WorkflowChildReplaySnapshot } from "./store-types.js";
10
+ import type {
11
+ RunSnapshot,
12
+ StageSnapshot,
13
+ StageStatus,
14
+ WorkflowChildReplaySnapshot,
15
+ WorkflowFailureCode,
16
+ WorkflowFailureDisposition,
17
+ WorkflowFailureKind,
18
+ } from "./store-types.js";
11
19
  import type { WorkflowInputValues, WorkflowOutputValues } from "./types.js";
12
20
  import { workflowSerializableObjectSchema } from "./serializable.js";
13
21
  import { Value } from "typebox/value";
14
- import { isWorkflowFailureKind } from "./workflow-failures.js";
22
+ import {
23
+ isWorkflowFailureCode,
24
+ isWorkflowFailureDisposition,
25
+ isWorkflowFailureKind,
26
+ isWorkflowFailureRecoverability,
27
+ } from "./workflow-failures.js";
15
28
 
16
29
  // ---------------------------------------------------------------------------
17
30
  // Config option
@@ -49,6 +62,19 @@ export interface InFlightRun {
49
62
  readonly stageIds: readonly string[];
50
63
  }
51
64
 
65
+ interface RestoredRunBlockedMetadata {
66
+ readonly failedStageId: string;
67
+ readonly error: string;
68
+ readonly failureKind: WorkflowFailureKind;
69
+ readonly failureCode?: WorkflowFailureCode;
70
+ readonly failureRecoverability: "recoverable";
71
+ readonly failureDisposition?: WorkflowFailureDisposition;
72
+ readonly failureMessage?: string;
73
+ readonly retryAfterMs?: number;
74
+ readonly resumable: true;
75
+ readonly ts: number;
76
+ }
77
+
52
78
  // ---------------------------------------------------------------------------
53
79
  // Scan logic
54
80
  // ---------------------------------------------------------------------------
@@ -170,7 +196,37 @@ export function restoreOnSessionStart(
170
196
 
171
197
  for (const run of inFlight) {
172
198
  const runMeta = findRunStartMetadata(sessionEntries, run.runId);
173
- const stages = _buildStageSnapshots(sessionEntries, run.runId);
199
+ const blockedMeta = findRunBlockedMetadata(sessionEntries, run.runId);
200
+ const stages = _buildStageSnapshots(sessionEntries, run.runId, blockedMeta);
201
+
202
+ if (blockedMeta !== undefined) {
203
+ const runSnapshot: RunSnapshot = {
204
+ id: run.runId,
205
+ name: run.name,
206
+ inputs: run.inputs,
207
+ status: "running",
208
+ stages,
209
+ startedAt: run.startTs,
210
+ ...(runMeta.parentRunId !== undefined ? { parentRunId: runMeta.parentRunId } : {}),
211
+ ...(runMeta.parentStageId !== undefined ? { parentStageId: runMeta.parentStageId } : {}),
212
+ ...(runMeta.rootRunId !== undefined ? { rootRunId: runMeta.rootRunId } : {}),
213
+ ...(runMeta.resumedFromRunId !== undefined ? { resumedFromRunId: runMeta.resumedFromRunId } : {}),
214
+ ...(runMeta.resumeFromStageId !== undefined ? { resumeFromStageId: runMeta.resumeFromStageId } : {}),
215
+ };
216
+ store.recordRunStart(runSnapshot);
217
+ store.recordRunBlocked(run.runId, blockedMeta.error, {
218
+ failureKind: blockedMeta.failureKind,
219
+ ...(blockedMeta.failureCode !== undefined ? { failureCode: blockedMeta.failureCode } : {}),
220
+ failureRecoverability: "recoverable",
221
+ ...(blockedMeta.failureDisposition !== undefined ? { failureDisposition: blockedMeta.failureDisposition } : {}),
222
+ ...(blockedMeta.failureMessage !== undefined ? { failureMessage: blockedMeta.failureMessage } : {}),
223
+ failedStageId: blockedMeta.failedStageId,
224
+ resumable: true,
225
+ ...(blockedMeta.retryAfterMs !== undefined ? { retryAfterMs: blockedMeta.retryAfterMs } : {}),
226
+ blockedAt: blockedMeta.ts,
227
+ });
228
+ continue;
229
+ }
174
230
 
175
231
  if (config.resumeInFlight === "auto") {
176
232
  // Re-hydrate the run into the store as "running"
@@ -223,6 +279,7 @@ export function restoreOnSessionStart(
223
279
  function _buildStageSnapshots(
224
280
  entries: readonly SessionEntry[],
225
281
  runId: string,
282
+ blockedMeta?: RestoredRunBlockedMetadata,
226
283
  ): StageSnapshot[] {
227
284
  const stageMap = new Map<string, StageSnapshot>();
228
285
  const endedStages = new Set<string>();
@@ -256,6 +313,10 @@ function _buildStageSnapshots(
256
313
  const summary = entry.payload["summary"];
257
314
  const error = entry.payload["error"];
258
315
  const failureKind = entry.payload["failureKind"];
316
+ const failureCode = entry.payload["failureCode"];
317
+ const failureRecoverability = entry.payload["failureRecoverability"];
318
+ const failureDisposition = entry.payload["failureDisposition"];
319
+ const retryAfterMs = entry.payload["retryAfterMs"];
259
320
  const failureMessage = entry.payload["failureMessage"];
260
321
  const skippedReason = entry.payload["skippedReason"];
261
322
  if (typeof stageId !== "string") continue;
@@ -267,6 +328,10 @@ function _buildStageSnapshots(
267
328
  if (typeof summary === "string") snap.result = summary;
268
329
  if (typeof error === "string") snap.error = error;
269
330
  if (typeof failureKind === "string" && isWorkflowFailureKind(failureKind)) snap.failureKind = failureKind;
331
+ if (typeof failureCode === "string" && isWorkflowFailureCode(failureCode)) snap.failureCode = failureCode;
332
+ if (typeof failureRecoverability === "string" && isWorkflowFailureRecoverability(failureRecoverability)) snap.failureRecoverability = failureRecoverability;
333
+ if (typeof failureDisposition === "string" && isWorkflowFailureDisposition(failureDisposition)) snap.failureDisposition = failureDisposition;
334
+ if (typeof retryAfterMs === "number") snap.retryAfterMs = retryAfterMs;
270
335
  if (typeof failureMessage === "string") snap.failureMessage = failureMessage;
271
336
  if (typeof skippedReason === "string") snap.skippedReason = skippedReason;
272
337
  Object.assign(snap, replayMetadata(entry.payload), workflowChildMetadata(entry.payload));
@@ -274,9 +339,12 @@ function _buildStageSnapshots(
274
339
  }
275
340
  }
276
341
 
277
- // Mark any stage that didn't get an end entry as "failed" (crashed)
278
- for (const [stageId, snap] of stageMap) {
279
- if (!endedStages.has(stageId)) {
342
+ if (blockedMeta !== undefined) {
343
+ restoreBlockedStageState(stageMap, endedStages, blockedMeta);
344
+ } else {
345
+ // Mark any stage that didn't get an end entry as crashed.
346
+ for (const [stageId, snap] of stageMap) {
347
+ if (endedStages.has(stageId)) continue;
280
348
  snap.status = "failed";
281
349
  snap.error = "Stage did not complete — process was interrupted.";
282
350
  }
@@ -285,6 +353,57 @@ function _buildStageSnapshots(
285
353
  return [...stageMap.values()];
286
354
  }
287
355
 
356
+ function hasRestoredAncestor(
357
+ stageMap: ReadonlyMap<string, StageSnapshot>,
358
+ stage: StageSnapshot,
359
+ ancestorId: string,
360
+ ): boolean {
361
+ const queue = [...stage.parentIds];
362
+ const seen = new Set<string>();
363
+
364
+ while (queue.length > 0) {
365
+ const next = queue.shift();
366
+ if (next === undefined || seen.has(next)) continue;
367
+ if (next === ancestorId) return true;
368
+ seen.add(next);
369
+ queue.push(...(stageMap.get(next)?.parentIds ?? []));
370
+ }
371
+
372
+ return false;
373
+ }
374
+
375
+ function markRestoredBlockedFailureStage(
376
+ snap: StageSnapshot,
377
+ blockedMeta: RestoredRunBlockedMetadata,
378
+ ): void {
379
+ snap.status = "failed";
380
+ snap.error = blockedMeta.error;
381
+ snap.failureKind = blockedMeta.failureKind;
382
+ snap.failureCode = blockedMeta.failureCode;
383
+ snap.failureRecoverability = blockedMeta.failureRecoverability;
384
+ snap.failureDisposition = blockedMeta.failureDisposition;
385
+ snap.failureMessage = blockedMeta.failureMessage;
386
+ snap.retryAfterMs = blockedMeta.retryAfterMs;
387
+ }
388
+
389
+ function restoreBlockedStageState(
390
+ stageMap: Map<string, StageSnapshot>,
391
+ endedStages: ReadonlySet<string>,
392
+ blockedMeta: RestoredRunBlockedMetadata,
393
+ ): void {
394
+ for (const [stageId, snap] of stageMap) {
395
+ if (endedStages.has(stageId)) continue;
396
+ if (stageId === blockedMeta.failedStageId) {
397
+ markRestoredBlockedFailureStage(snap, blockedMeta);
398
+ continue;
399
+ }
400
+ if (hasRestoredAncestor(stageMap, snap, blockedMeta.failedStageId)) {
401
+ snap.status = "blocked";
402
+ snap.blockedByStageId = blockedMeta.failedStageId;
403
+ }
404
+ }
405
+ }
406
+
288
407
  function replayMetadata(payload: Record<string, unknown>): Pick<StageSnapshot, "replayKey" | "replayedFromStageId" | "replayed"> {
289
408
  const replayKey = payload["replayKey"];
290
409
  const replayedFromStageId = payload["replayedFromStageId"];
@@ -361,12 +480,61 @@ function restoreStageStatus(status: unknown): StageStatus {
361
480
  case "completed":
362
481
  case "failed":
363
482
  case "skipped":
483
+ case "blocked":
364
484
  return status;
365
485
  default:
366
486
  return "failed";
367
487
  }
368
488
  }
369
489
 
490
+ function numericRetryAfterMs(value: unknown): number | undefined {
491
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
492
+ }
493
+
494
+ function findRunBlockedMetadata(
495
+ entries: readonly SessionEntry[],
496
+ runId: string,
497
+ ): RestoredRunBlockedMetadata | undefined {
498
+ let latest: RestoredRunBlockedMetadata | undefined;
499
+ for (const entry of entries) {
500
+ if (entry.type !== "workflow.run.blocked" || entry.payload["runId"] !== runId) continue;
501
+ const failedStageId = entry.payload["failedStageId"];
502
+ const error = entry.payload["error"];
503
+ const failureKind = entry.payload["failureKind"];
504
+ const failureCode = entry.payload["failureCode"];
505
+ const failureRecoverability = entry.payload["failureRecoverability"];
506
+ const failureDisposition = entry.payload["failureDisposition"];
507
+ const failureMessage = entry.payload["failureMessage"];
508
+ const retryAfterMs = numericRetryAfterMs(entry.payload["retryAfterMs"]);
509
+ const resumable = entry.payload["resumable"];
510
+ const ts = entry.payload["ts"];
511
+ if (
512
+ typeof failedStageId !== "string" ||
513
+ typeof error !== "string" ||
514
+ typeof failureKind !== "string" ||
515
+ !isWorkflowFailureKind(failureKind) ||
516
+ failureRecoverability !== "recoverable" ||
517
+ resumable !== true ||
518
+ typeof ts !== "number"
519
+ ) {
520
+ continue;
521
+ }
522
+ latest = {
523
+ failedStageId,
524
+ error,
525
+ failureKind,
526
+ ...(typeof failureCode === "string" && isWorkflowFailureCode(failureCode) ? { failureCode } : {}),
527
+ failureRecoverability: "recoverable",
528
+ ...(typeof failureDisposition === "string" && isWorkflowFailureDisposition(failureDisposition) ? { failureDisposition } : {}),
529
+ ...(typeof failureMessage === "string" ? { failureMessage } : {}),
530
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
531
+ resumable: true,
532
+ ts,
533
+ };
534
+ }
535
+ return latest;
536
+ }
537
+
370
538
  function restoreTerminalRuns(entries: readonly SessionEntry[], store: Store): void {
371
539
  const started = new Map<string, { readonly name: string; readonly inputs: Readonly<WorkflowInputValues>; readonly startTs: number }>();
372
540
  const ended = new Map<string, Record<string, unknown>>();
@@ -416,6 +584,10 @@ function restoreTerminalRuns(entries: readonly SessionEntry[], store: Store): vo
416
584
 
417
585
  const error = end["error"];
418
586
  const failureKind = end["failureKind"];
587
+ const failureCode = end["failureCode"];
588
+ const failureRecoverability = end["failureRecoverability"];
589
+ const failureDisposition = end["failureDisposition"];
590
+ const retryAfterMs = numericRetryAfterMs(end["retryAfterMs"]);
419
591
  const failureMessage = end["failureMessage"];
420
592
  const failedStageId = end["failedStageId"];
421
593
  const resumable = end["resumable"];
@@ -426,6 +598,10 @@ function restoreTerminalRuns(entries: readonly SessionEntry[], store: Store): vo
426
598
  typeof error === "string" ? error : undefined,
427
599
  {
428
600
  ...(typeof failureKind === "string" && isWorkflowFailureKind(failureKind) ? { failureKind } : {}),
601
+ ...(typeof failureCode === "string" && isWorkflowFailureCode(failureCode) ? { failureCode } : {}),
602
+ ...(typeof failureRecoverability === "string" && isWorkflowFailureRecoverability(failureRecoverability) ? { failureRecoverability } : {}),
603
+ ...(typeof failureDisposition === "string" && isWorkflowFailureDisposition(failureDisposition) ? { failureDisposition } : {}),
604
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
429
605
  ...(typeof failureMessage === "string" ? { failureMessage } : {}),
430
606
  ...(typeof failedStageId === "string" ? { failedStageId } : {}),
431
607
  ...(typeof resumable === "boolean" ? { resumable } : {}),
@@ -7,6 +7,11 @@
7
7
  */
8
8
 
9
9
  import type { WorkflowInputValues, WorkflowOutputValues } from "./types.js";
10
+ import type {
11
+ WorkflowFailureCode,
12
+ WorkflowFailureDisposition,
13
+ WorkflowFailureKind,
14
+ } from "./store-types.js";
10
15
 
11
16
  // ---------------------------------------------------------------------------
12
17
  // Structural API type (subset of ExtensionAPI needed here)
@@ -76,7 +81,11 @@ export interface StageEndPayload {
76
81
  readonly summary?: string;
77
82
  readonly error?: string;
78
83
  readonly failureKind?: string;
84
+ readonly failureCode?: string;
85
+ readonly failureRecoverability?: string;
86
+ readonly failureDisposition?: string;
79
87
  readonly failureMessage?: string;
88
+ readonly retryAfterMs?: number;
80
89
  readonly skippedReason?: string;
81
90
  readonly replayKey?: string;
82
91
  readonly replayedFromStageId?: string;
@@ -90,9 +99,27 @@ export interface RunEndPayload {
90
99
  readonly result?: WorkflowOutputValues;
91
100
  readonly error?: string;
92
101
  readonly failureKind?: string;
102
+ readonly failureCode?: string;
103
+ readonly failureRecoverability?: string;
104
+ readonly failureDisposition?: string;
93
105
  readonly failureMessage?: string;
94
106
  readonly failedStageId?: string;
95
107
  readonly resumable?: boolean;
108
+ readonly retryAfterMs?: number;
109
+ readonly ts: number;
110
+ }
111
+
112
+ export interface RunBlockedPayload {
113
+ readonly runId: string;
114
+ readonly failedStageId: string;
115
+ readonly error: string;
116
+ readonly failureKind: WorkflowFailureKind;
117
+ readonly failureCode?: WorkflowFailureCode;
118
+ readonly failureMessage?: string;
119
+ readonly failureRecoverability: "recoverable";
120
+ readonly failureDisposition?: WorkflowFailureDisposition;
121
+ readonly retryAfterMs?: number;
122
+ readonly resumable: true;
96
123
  readonly ts: number;
97
124
  }
98
125
 
@@ -165,7 +192,11 @@ export function appendStageEnd(
165
192
  ...(payload.summary !== undefined ? { summary: payload.summary } : {}),
166
193
  ...(payload.error !== undefined ? { error: payload.error } : {}),
167
194
  ...(payload.failureKind !== undefined ? { failureKind: payload.failureKind } : {}),
195
+ ...(payload.failureCode !== undefined ? { failureCode: payload.failureCode } : {}),
196
+ ...(payload.failureRecoverability !== undefined ? { failureRecoverability: payload.failureRecoverability } : {}),
197
+ ...(payload.failureDisposition !== undefined ? { failureDisposition: payload.failureDisposition } : {}),
168
198
  ...(payload.failureMessage !== undefined ? { failureMessage: payload.failureMessage } : {}),
199
+ ...(payload.retryAfterMs !== undefined ? { retryAfterMs: payload.retryAfterMs } : {}),
169
200
  ...(payload.skippedReason !== undefined ? { skippedReason: payload.skippedReason } : {}),
170
201
  ...(payload.replayKey !== undefined ? { replayKey: payload.replayKey } : {}),
171
202
  ...(payload.replayedFromStageId !== undefined ? { replayedFromStageId: payload.replayedFromStageId } : {}),
@@ -180,18 +211,57 @@ export function appendStageEnd(
180
211
  }
181
212
  }
182
213
 
214
+ function sanitizeTerminalRunEndPayload(payload: RunEndPayload): RunEndPayload {
215
+ if (payload.status === "killed") {
216
+ return {
217
+ ...payload,
218
+ failureRecoverability: "non_recoverable",
219
+ failureDisposition: "terminal_killed",
220
+ resumable: false,
221
+ };
222
+ }
223
+
224
+ if (payload.failureDisposition !== "active_blocked") return payload;
225
+ const sanitized = { ...payload };
226
+ delete (sanitized as { failureDisposition?: string }).failureDisposition;
227
+ return sanitized;
228
+ }
229
+
183
230
  /** Appends a `workflow.run.end` entry. */
184
231
  export function appendRunEnd(api: PersistenceAPI, payload: RunEndPayload): void {
185
232
  if (typeof api.appendEntry !== "function") return;
233
+ const terminalPayload = sanitizeTerminalRunEndPayload(payload);
186
234
  api.appendEntry("workflow.run.end", {
235
+ runId: terminalPayload.runId,
236
+ status: terminalPayload.status,
237
+ ...(terminalPayload.result !== undefined ? { result: terminalPayload.result } : {}),
238
+ ...(terminalPayload.error !== undefined ? { error: terminalPayload.error } : {}),
239
+ ...(terminalPayload.failureKind !== undefined ? { failureKind: terminalPayload.failureKind } : {}),
240
+ ...(terminalPayload.failureCode !== undefined ? { failureCode: terminalPayload.failureCode } : {}),
241
+ ...(terminalPayload.failureRecoverability !== undefined ? { failureRecoverability: terminalPayload.failureRecoverability } : {}),
242
+ ...(terminalPayload.failureDisposition !== undefined ? { failureDisposition: terminalPayload.failureDisposition } : {}),
243
+ ...(terminalPayload.failureMessage !== undefined ? { failureMessage: terminalPayload.failureMessage } : {}),
244
+ ...(terminalPayload.failedStageId !== undefined ? { failedStageId: terminalPayload.failedStageId } : {}),
245
+ ...(terminalPayload.resumable !== undefined ? { resumable: terminalPayload.resumable } : {}),
246
+ ...(terminalPayload.retryAfterMs !== undefined ? { retryAfterMs: terminalPayload.retryAfterMs } : {}),
247
+ ts: terminalPayload.ts,
248
+ });
249
+ }
250
+
251
+ /** Appends a `workflow.run.blocked` entry for active recoverable failures. */
252
+ export function appendRunBlocked(api: PersistenceAPI, payload: RunBlockedPayload): void {
253
+ if (typeof api.appendEntry !== "function") return;
254
+ api.appendEntry("workflow.run.blocked", {
187
255
  runId: payload.runId,
188
- status: payload.status,
189
- ...(payload.result !== undefined ? { result: payload.result } : {}),
190
- ...(payload.error !== undefined ? { error: payload.error } : {}),
191
- ...(payload.failureKind !== undefined ? { failureKind: payload.failureKind } : {}),
256
+ failedStageId: payload.failedStageId,
257
+ error: payload.error,
258
+ failureKind: payload.failureKind,
259
+ ...(payload.failureCode !== undefined ? { failureCode: payload.failureCode } : {}),
192
260
  ...(payload.failureMessage !== undefined ? { failureMessage: payload.failureMessage } : {}),
193
- ...(payload.failedStageId !== undefined ? { failedStageId: payload.failedStageId } : {}),
194
- ...(payload.resumable !== undefined ? { resumable: payload.resumable } : {}),
261
+ failureRecoverability: payload.failureRecoverability,
262
+ ...(payload.failureDisposition !== undefined ? { failureDisposition: payload.failureDisposition } : {}),
263
+ ...(payload.retryAfterMs !== undefined ? { retryAfterMs: payload.retryAfterMs } : {}),
264
+ resumable: payload.resumable,
195
265
  ts: payload.ts,
196
266
  });
197
267
  }
@@ -239,13 +239,41 @@ export function parseAskUserQuestionArgs(
239
239
  return questions.length > 0 ? questions : undefined;
240
240
  }
241
241
 
242
+ /**
243
+ * Sentinel label for the chat escape hatch. Matches the `ROW_INTENT_META.chat.label`
244
+ * in the coding-agent package. Duplicated here as a literal to avoid a cross-package
245
+ * import; the reserved-label guard in validate-questionnaire already prevents an
246
+ * authored option from using this label.
247
+ */
248
+ const CHAT_ABOUT_THIS_LABEL = "Chat about this";
249
+ const CHAT_ABOUT_THIS_NORMALIZED = normalizeLabel(CHAT_ABOUT_THIS_LABEL);
250
+
251
+ function isChatSentinel(value: string): boolean {
252
+ return normalizeLabel(value) === CHAT_ABOUT_THIS_NORMALIZED;
253
+ }
254
+
255
+ function answerChat(question: StageInputQuestion): BuiltAnswer {
256
+ return {
257
+ questionIndex: 0,
258
+ question: question.question,
259
+ kind: "chat",
260
+ answer: CHAT_ABOUT_THIS_LABEL,
261
+ };
262
+ }
263
+
242
264
  /**
243
265
  * Resolve a desired answer string against a single-select question's options.
244
- * Matches a case-insensitive option label, then a 1-based option index, then
245
- * falls back to a typed ("custom") answer.
266
+ * Checks the chat sentinel first (case/whitespace insensitive), then matches a
267
+ * case-insensitive option label, then a 1-based option index, then falls back to
268
+ * a typed ("custom") answer.
246
269
  */
247
270
  function answerSingle(question: StageInputQuestion, desired: string): BuiltAnswer {
248
271
  const normalized = normalizeLabel(desired);
272
+ // Chat sentinel takes priority over authored options — the label is reserved so
273
+ // no option can legitimately match it.
274
+ if (isChatSentinel(desired)) {
275
+ return answerChat(question);
276
+ }
249
277
  const byLabel = question.options.find((option) => normalizeLabel(option.label) === normalized);
250
278
  if (byLabel) {
251
279
  return { questionIndex: 0, question: question.question, kind: "option", answer: byLabel.label };
@@ -295,6 +323,9 @@ function buildResult(
295
323
  answer.optionLabels !== undefined
296
324
  ? answer.optionLabels
297
325
  : (answer.text ?? "").split(",").map((part) => part.trim()).filter((part) => part.length > 0);
326
+ if (candidates.some(isChatSentinel)) {
327
+ return { answers: [answerChat(question)], cancelled: false } satisfies BuiltResult;
328
+ }
298
329
  return { answers: [answerMulti(question, candidates)], cancelled: false } satisfies BuiltResult;
299
330
  }
300
331
 
@@ -17,6 +17,19 @@ export type StageStatus =
17
17
  | "skipped";
18
18
 
19
19
  export type WorkflowFailureKind = "auth" | "rate_limit" | "provider" | "cancelled" | "unknown";
20
+ export type WorkflowFailureRecoverability = "recoverable" | "non_recoverable" | "unknown";
21
+ export type WorkflowFailureDisposition = "active_blocked" | "terminal_killed" | "terminal_failed";
22
+ export type WorkflowFailureCode =
23
+ | "login_required"
24
+ | "missing_api_key"
25
+ | "invalid_api_key"
26
+ | "forbidden_config"
27
+ | "unknown_model"
28
+ | "rate_limited"
29
+ | "quota_limited"
30
+ | "provider_unavailable"
31
+ | "cancelled"
32
+ | "unknown";
20
33
 
21
34
  /**
22
35
  * Human-in-the-loop prompt kind. Mirrors the four `WorkflowUIContext` methods.
@@ -127,6 +140,14 @@ export interface StageSnapshot {
127
140
  error?: string;
128
141
  /** Structured workflow failure category for failed stages. */
129
142
  failureKind?: WorkflowFailureKind;
143
+ /** Specific additive workflow failure code within `failureKind`. */
144
+ failureCode?: WorkflowFailureCode;
145
+ /** Whether retry/resume can recover this failed stage without a workflow rerun. */
146
+ failureRecoverability?: WorkflowFailureRecoverability;
147
+ /** Executor lifecycle disposition chosen for the failed stage. */
148
+ failureDisposition?: WorkflowFailureDisposition;
149
+ /** Optional provider retry hint in milliseconds. Informational; blocked stages resume only via explicit user action. */
150
+ retryAfterMs?: number;
130
151
  /** Original unsanitized error text when different from `error`. */
131
152
  failureMessage?: string;
132
153
  /** Reason for stages skipped by fail-fast/cascade handling. */
@@ -218,6 +239,16 @@ export interface RunSnapshot {
218
239
  error?: string;
219
240
  /** Structured workflow failure category for failed runs. */
220
241
  failureKind?: WorkflowFailureKind;
242
+ /** Specific additive workflow failure code within `failureKind`. */
243
+ failureCode?: WorkflowFailureCode;
244
+ /** Whether retry/resume can recover this run without a workflow rerun. */
245
+ failureRecoverability?: WorkflowFailureRecoverability;
246
+ /** Executor lifecycle disposition chosen for this failure. */
247
+ failureDisposition?: WorkflowFailureDisposition;
248
+ /** Optional provider retry hint in milliseconds. Informational; blocked runs resume only via explicit user action. */
249
+ retryAfterMs?: number;
250
+ /** Timestamp when an active run was blocked by a recoverable workflow failure. */
251
+ blockedAt?: number;
221
252
  /** Original unsanitized error text when different from `error`. */
222
253
  failureMessage?: string;
223
254
  failedStageId?: string;
@@ -16,6 +16,9 @@ import type {
16
16
  RunStatus,
17
17
  StageStatus,
18
18
  WorkflowFailureKind,
19
+ WorkflowFailureCode,
20
+ WorkflowFailureRecoverability,
21
+ WorkflowFailureDisposition,
19
22
  WorkflowNotice,
20
23
  WorkflowChildRunRef,
21
24
  } from "./store-types.js";
@@ -43,9 +46,55 @@ function cannotPause(status: StageStatus): boolean {
43
46
 
44
47
  export interface RunEndMetadata {
45
48
  readonly failureKind?: WorkflowFailureKind;
49
+ readonly failureCode?: WorkflowFailureCode;
50
+ readonly failureRecoverability?: WorkflowFailureRecoverability;
51
+ readonly failureDisposition?: WorkflowFailureDisposition;
46
52
  readonly failureMessage?: string;
47
53
  readonly failedStageId?: string;
48
54
  readonly resumable?: boolean;
55
+ readonly retryAfterMs?: number;
56
+ }
57
+
58
+ export interface RunBlockedMetadata extends RunEndMetadata {
59
+ readonly failureRecoverability: "recoverable";
60
+ readonly failedStageId: string;
61
+ readonly resumable: true;
62
+ readonly blockedAt?: number;
63
+ }
64
+
65
+ function clearRunFailureMetadata(run: RunSnapshot): void {
66
+ delete run.error;
67
+ delete run.failureKind;
68
+ delete run.failureCode;
69
+ delete run.failureRecoverability;
70
+ delete run.failureDisposition;
71
+ delete run.failureMessage;
72
+ delete run.failedStageId;
73
+ delete run.resumable;
74
+ delete run.retryAfterMs;
75
+ delete run.blockedAt;
76
+ }
77
+
78
+ function clearStaleBlockedRunMetadata(run: RunSnapshot, metadata: RunEndMetadata | undefined): void {
79
+ if (metadata?.failureKind === undefined) delete run.failureKind;
80
+ if (metadata?.failureCode === undefined) delete run.failureCode;
81
+ if (metadata?.failureRecoverability === undefined) delete run.failureRecoverability;
82
+ if (metadata?.failureDisposition === undefined) delete run.failureDisposition;
83
+ if (metadata?.failureMessage === undefined) delete run.failureMessage;
84
+ if (metadata?.failedStageId === undefined) delete run.failedStageId;
85
+ if (metadata?.resumable === undefined) delete run.resumable;
86
+ if (metadata?.retryAfterMs === undefined) delete run.retryAfterMs;
87
+ }
88
+
89
+ function applyRunEndMetadata(run: RunSnapshot, metadata: RunEndMetadata): void {
90
+ if (metadata.failureKind !== undefined) run.failureKind = metadata.failureKind;
91
+ if (metadata.failureCode !== undefined) run.failureCode = metadata.failureCode;
92
+ if (metadata.failureRecoverability !== undefined) run.failureRecoverability = metadata.failureRecoverability;
93
+ if (metadata.failureDisposition !== undefined) run.failureDisposition = metadata.failureDisposition;
94
+ if (metadata.retryAfterMs !== undefined) run.retryAfterMs = metadata.retryAfterMs;
95
+ if (metadata.failureMessage !== undefined) run.failureMessage = metadata.failureMessage;
96
+ if (metadata.failedStageId !== undefined) run.failedStageId = metadata.failedStageId;
97
+ if (metadata.resumable !== undefined) run.resumable = metadata.resumable;
49
98
  }
50
99
 
51
100
  export type StagePromptAnswerSource = "workflow_ui" | "workflow_tool";
@@ -95,6 +144,12 @@ export interface Store {
95
144
  error?: string,
96
145
  metadata?: RunEndMetadata,
97
146
  ): boolean;
147
+ /**
148
+ * Record an active, recoverable workflow failure without ending the run.
149
+ * The run remains resumable/running and carries failure metadata for status,
150
+ * persistence restore, and continuation decisions.
151
+ */
152
+ recordRunBlocked(runId: string, error: string, metadata: RunBlockedMetadata): boolean;
98
153
  /**
99
154
  * Remove a run from live workflow history/status. Any pending HIL prompt
100
155
  * waiter is rejected because the workflow will not resume through that path.
@@ -448,6 +503,10 @@ export function createStore(): Store {
448
503
  existing.result = stage.result;
449
504
  existing.error = stage.error;
450
505
  existing.failureKind = stage.failureKind;
506
+ existing.failureCode = stage.failureCode;
507
+ existing.failureRecoverability = stage.failureRecoverability;
508
+ existing.failureDisposition = stage.failureDisposition;
509
+ existing.retryAfterMs = stage.retryAfterMs;
451
510
  existing.failureMessage = stage.failureMessage;
452
511
  existing.skippedReason = stage.skippedReason;
453
512
  if (stage.replayKey !== undefined) existing.replayKey = stage.replayKey;
@@ -485,17 +544,26 @@ export function createStore(): Store {
485
544
  run.pausedAt = undefined;
486
545
  }
487
546
  run.durationMs = elapsedRunMs(run, run.endedAt);
488
- if (status === "completed" && result !== undefined) {
489
- run.result = result;
490
- }
491
- if ((status === "failed" || status === "killed") && error !== undefined) {
492
- run.error = error;
493
- }
494
- if (metadata !== undefined) {
495
- if (metadata.failureKind !== undefined) run.failureKind = metadata.failureKind;
496
- if (metadata.failureMessage !== undefined) run.failureMessage = metadata.failureMessage;
497
- if (metadata.failedStageId !== undefined) run.failedStageId = metadata.failedStageId;
498
- if (metadata.resumable !== undefined) run.resumable = metadata.resumable;
547
+ const wasBlocked = run.blockedAt !== undefined || run.failureDisposition === "active_blocked";
548
+ delete run.blockedAt;
549
+ if (status === "completed") {
550
+ if (result !== undefined) {
551
+ run.result = result;
552
+ }
553
+ clearRunFailureMetadata(run);
554
+ } else {
555
+ if (wasBlocked && error === undefined) delete run.error;
556
+ if ((status === "failed" || status === "killed") && error !== undefined) {
557
+ run.error = error;
558
+ }
559
+ if (wasBlocked) clearStaleBlockedRunMetadata(run, metadata);
560
+ if (metadata !== undefined) applyRunEndMetadata(run, metadata);
561
+ if (run.failureDisposition === "active_blocked") delete run.failureDisposition;
562
+ if (status === "killed") {
563
+ run.failureRecoverability = "non_recoverable";
564
+ run.failureDisposition = "terminal_killed";
565
+ run.resumable = false;
566
+ }
499
567
  }
500
568
  // Abandon any waiting HIL prompt — workflow body never resumed past
501
569
  // it, but the awaiter promise must reject so the executor's catch
@@ -511,6 +579,26 @@ export function createStore(): Store {
511
579
  return true;
512
580
  },
513
581
 
582
+ recordRunBlocked(runId: string, error: string, metadata: RunBlockedMetadata): boolean {
583
+ const run = findRun(runId);
584
+ if (!run) return false;
585
+ if (TERMINAL_STATUSES.has(run.status)) return false;
586
+ run.status = "running";
587
+ run.error = error;
588
+ run.failureKind = metadata.failureKind;
589
+ run.failureCode = metadata.failureCode;
590
+ run.failureRecoverability = metadata.failureRecoverability;
591
+ run.failureDisposition = metadata.failureDisposition;
592
+ run.failureMessage = metadata.failureMessage;
593
+ run.failedStageId = metadata.failedStageId;
594
+ run.resumable = metadata.resumable;
595
+ run.blockedAt = metadata.blockedAt ?? Date.now();
596
+ if (metadata.retryAfterMs !== undefined) run.retryAfterMs = metadata.retryAfterMs;
597
+ _version++;
598
+ notify();
599
+ return true;
600
+ },
601
+
514
602
  removeRun(runId: string): boolean {
515
603
  const index = _runs.findIndex((r) => r.id === runId);
516
604
  if (index < 0) return false;