@aicopycoders/exodus 2026.10.1700 → 2026.10.1900-beta.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.
@@ -101,7 +101,7 @@ never how it is made. Every answer to them says `"view": "customer"`, and the
101
101
  CLI words it the way the run page does. On their own brands they can:
102
102
 
103
103
  - `video start --script <file> --conceit <key> --style <slug>` with
104
- `--direction`, `--frame`, `--pacing`, `--music`, `--one-shot`, `--review-storyboard`, `--stop-at-script` and `--wait`. Any other start
104
+ `--direction`, `--frame`, `--pacing`, `--music`, `--one-shot`, `--review-storyboard`, `--stop-at-script`, `--rewrite-to-fit` and `--wait`. Any other start
105
105
  flag is refused with one sentence naming it. The answer is the run id and the
106
106
  link to the video's page, plus what the server did to the script: laid a
107
107
  plain transcript out, or wrote a script from a description (see "The script
@@ -158,7 +158,7 @@ exodus workflow run <workflowId|name> --input <field>=@script.txt --voices @voic
158
158
  exodus workflow run <workflowId|name> --input <field>=@script.txt --voice-treatment @voice-treatment.json --wait
159
159
  same, with the video model speaking voices you WROTE
160
160
  exodus video status <runId> where it is, per scene
161
- exodus video start --script <file> --conceit <kind> --style <look> [--direction "…"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice native] [--music] [--one-shot] [--director] [--stop-at-script] [--wait]
161
+ exodus video start --script <file> --conceit <kind> --style <look> [--direction "…"] [--frame pov] [--pacing <jump-cuts|long-take|montage>] [--voice-path <path>] [--video-model <id>] [--voice native] [--music] [--one-shot] [--director] [--stop-at-script] [--rewrite-to-fit] [--wait]
162
162
  start from a script and the kinds the server offers.
163
163
  The file can be a plain transcript, a draft or a
164
164
  description: the server tidies it (see "The script
@@ -397,6 +397,16 @@ What the CLI prints, and what you tell the user:
397
397
  one-person kind of ad): the notes come first, then the refusal, and `--json`
398
398
  carries the tidied script under `script`. Fix that script and send it. It is
399
399
  already in the format, so it is not tidied or paid for again.
400
+ - **The script doesn't fit the kind of ad** (too many speakers, or a rule the
401
+ kind of ad has): the refusal says `--rewrite-to-fit` can fix it, and
402
+ `--json` carries `rewriteOffered: true`. Ask the user before you add the
403
+ flag: the server then writes a new script for that kind of ad from theirs
404
+ (the dashboard's "Rewrite to fit"), which can change every line and the
405
+ names, starts the run on it, and prints it (`rewritten` in `--json`). Show
406
+ the user that script. To let them read it first, add `--stop-at-script` too.
407
+ If the rewrite can't be made, nothing starts and the error says why
408
+ (`SCRIPT_NOT_REWRITTEN`). The other way out is a kind of ad made for the
409
+ script's cast.
400
410
 
401
411
  **`--stop-at-script`** prints the script the run would start on and starts
402
412
  nothing (exit 0). A question or a description too thin to write from answers
@@ -57,6 +57,7 @@ export interface CustomerStart {
57
57
  dashboardUrl: string;
58
58
  tidied?: ScriptTidied;
59
59
  written?: string;
60
+ rewritten?: string;
60
61
  }
61
62
  export interface CustomerStoryboard {
62
63
  view: "customer";
@@ -298,6 +298,7 @@ export type RunStop = {
298
298
  repair?: true;
299
299
  nodeId?: string;
300
300
  step?: string;
301
+ workerStopped?: true;
301
302
  } | {
302
303
  at: "finished";
303
304
  status: string;
@@ -456,6 +457,7 @@ export interface ScriptStartOptions {
456
457
  director?: true;
457
458
  shots?: RunShots;
458
459
  stopAtScript?: true;
460
+ rewriteToFit?: true;
459
461
  wait: boolean;
460
462
  json: boolean;
461
463
  onProgressLine?: (line: string) => void;
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { apiGet, apiPost, getDashboardUrl } from "../lib/client.js";
5
5
  import { displayRunStatus, formatApiError } from "../lib/format.js";
6
+ import { isWorkerStoppedPark } from "../lib/runStatus.js";
6
7
  import { missingRouteLine } from "../lib/route-support.js";
7
8
  import { hasBinary } from "../lib/preflight.js";
8
9
  import { ASSET_UPLOAD_POLICY, pauseAheadLine, } from "./workflow.js";
@@ -167,6 +168,13 @@ Options:
167
168
  --stop-at-script Print the script the run would start on, and start
168
169
  nothing (start, with --conceit). To read a written or
169
170
  laid-out script first; send it back with --script
171
+ --rewrite-to-fit If the script doesn't fit the kind of ad (more speakers
172
+ than it takes, or a rule it has), write a new script for
173
+ it from yours, print it, and start on it (start, with
174
+ --conceit). That can change every line and the names.
175
+ A script that fits starts as it is. With
176
+ --stop-at-script, it prints the new script and starts
177
+ nothing
170
178
  --conceit <kind> Kind of ad (start)
171
179
  --style <look> Look (start)
172
180
  The server lists the kinds and looks it offers. An unknown word prints them.
@@ -206,7 +214,7 @@ Options:
206
214
  Who can use what. Admins get every command here. Beta testers with video
207
215
  switched on get these, on their own brands: start (--script, --conceit,
208
216
  --style, --direction, --frame, --pacing, --music, --one-shot,
209
- --review-storyboard, --stop-at-script),
217
+ --review-storyboard, --stop-at-script, --rewrite-to-fit),
210
218
  status, storyboard, approve (the storyboard), voices, retry-frame --scene <n> (redo one scene's
211
219
  picture while the storyboard waits), pull (the finished video, each scene's
212
220
  clip and voice, and the music) and upload. These stay admin-only:
@@ -411,6 +419,8 @@ export function classifyRun(run) {
411
419
  if (run.isTerminal)
412
420
  return { at: "finished", status: run.status };
413
421
  if (isAwaitingApproval(run.status)) {
422
+ if (isWorkerStoppedPark(run))
423
+ return { at: "failed", repair: true, workerStopped: true };
414
424
  if (run.pauseReason === "repair") {
415
425
  const deadNode = run.nodes.find((n) => n.status === "failed");
416
426
  const why = deadNode?.error ?? run.error;
@@ -591,6 +601,13 @@ export function stopLines(stop, runId, runUrl) {
591
601
  ];
592
602
  }
593
603
  if (stop.at === "failed") {
604
+ if (stop.workerStopped) {
605
+ return [
606
+ "The worker making this ad stopped partway. Finished steps are kept.",
607
+ `Carry on from where it stopped: exodus workflow repair ${runId} retry`,
608
+ `Or open the run: ${runUrl}`,
609
+ ];
610
+ }
594
611
  if (stop.repair) {
595
612
  if (!stop.step) {
596
613
  return [
@@ -1447,6 +1464,20 @@ export function planVideoStart(flags, occurrences) {
1447
1464
  line: "--stop-at-script works with --conceit, not --show. A Show's script is read as you wrote it.",
1448
1465
  };
1449
1466
  }
1467
+ const refitFlags = occurrences.filter((o) => o.flag === "rewrite-to-fit");
1468
+ if (refitFlags.some((o) => o.value !== undefined && !o.value.startsWith("--"))) {
1469
+ return {
1470
+ kind: "usage",
1471
+ line: "--rewrite-to-fit takes no value. Leave it off to start the script as you wrote it.",
1472
+ };
1473
+ }
1474
+ const rewriteToFit = refitFlags.length > 0;
1475
+ if (showId && rewriteToFit) {
1476
+ return {
1477
+ kind: "usage",
1478
+ line: "--rewrite-to-fit works with --conceit, not --show. A Show's script is read as you wrote it.",
1479
+ };
1480
+ }
1450
1481
  if (showId && director) {
1451
1482
  return {
1452
1483
  kind: "usage",
@@ -1521,6 +1552,8 @@ export function planVideoStart(flags, occurrences) {
1521
1552
  opts.director = true;
1522
1553
  if (stopAtScript)
1523
1554
  opts.stopAtScript = true;
1555
+ if (rewriteToFit)
1556
+ opts.rewriteToFit = true;
1524
1557
  if (frameFlag.value || pacingFlag.value) {
1525
1558
  opts.shots = {
1526
1559
  ...(frameFlag.value ? { frame: frameFlag.value } : {}),
@@ -1634,6 +1667,20 @@ function scriptWrittenHead(written, started) {
1634
1667
  function writtenOf(value) {
1635
1668
  return typeof value === "string" && value.trim() !== "" ? value : null;
1636
1669
  }
1670
+ function scriptRewrittenHead(rewritten, started) {
1671
+ if (rewritten === null)
1672
+ return [];
1673
+ return [
1674
+ started
1675
+ ? "Your script didn't fit this kind of ad, so we wrote a new one from it and started the run on it:"
1676
+ : "Your script didn't fit this kind of ad, so we wrote a new one from it:",
1677
+ "",
1678
+ ...rewritten.split("\n").map((line) => (line ? ` ${line}` : "")),
1679
+ "",
1680
+ ];
1681
+ }
1682
+ const REWRITE_OFFER = "To have a new script written from yours for this kind of ad, run the same command with --rewrite-to-fit. " +
1683
+ "That can change every line and the names.";
1637
1684
  function scriptNeedsCallerResult(res, json) {
1638
1685
  const data = res.data;
1639
1686
  if (!isRecord(data) || !isRecord(data.error))
@@ -1657,7 +1704,9 @@ function scriptStartRefused(res, json) {
1657
1704
  const data = isRecord(res.data) ? res.data : {};
1658
1705
  const tidied = data.tidied === undefined ? null : scriptTidiedOf(data.tidied);
1659
1706
  const written = writtenOf(data.written);
1660
- if (!tidied && written === null)
1707
+ const rewritten = writtenOf(data.rewritten);
1708
+ const offered = data.rewriteOffered === true;
1709
+ if (!tidied && written === null && rewritten === null && !offered)
1661
1710
  return errorResult(res, json);
1662
1711
  const script = typeof data.script === "string" ? data.script : null;
1663
1712
  const error = videoApiError(res);
@@ -1671,16 +1720,25 @@ function scriptStartRefused(res, json) {
1671
1720
  error,
1672
1721
  ...(tidied ? { tidied } : {}),
1673
1722
  ...(written !== null ? { written } : {}),
1723
+ ...(rewritten !== null ? { rewritten } : {}),
1674
1724
  ...(script !== null ? { script } : {}),
1725
+ ...(offered ? { rewriteOffered: true } : {}),
1675
1726
  }),
1676
1727
  ]
1677
- : [...scriptTidiedHead(tidied), ...scriptWrittenHead(written, false), error],
1728
+ : [
1729
+ ...scriptTidiedHead(tidied),
1730
+ ...scriptWrittenHead(written, false),
1731
+ ...scriptRewrittenHead(rewritten, false),
1732
+ error,
1733
+ ...(offered ? [REWRITE_OFFER] : []),
1734
+ ],
1678
1735
  };
1679
1736
  }
1680
1737
  function stoppedAtScriptResult(data, json) {
1681
1738
  const script = typeof data.script === "string" ? data.script : "";
1682
1739
  const tidied = data.tidied === undefined ? null : scriptTidiedOf(data.tidied);
1683
1740
  const written = writtenOf(data.written);
1741
+ const rewritten = writtenOf(data.rewritten);
1684
1742
  if (json) {
1685
1743
  return {
1686
1744
  code: 0,
@@ -1690,6 +1748,7 @@ function stoppedAtScriptResult(data, json) {
1690
1748
  script,
1691
1749
  ...(tidied ? { tidied } : {}),
1692
1750
  ...(written !== null ? { written } : {}),
1751
+ ...(rewritten !== null ? { rewritten } : {}),
1693
1752
  }),
1694
1753
  ],
1695
1754
  };
@@ -1698,9 +1757,11 @@ function stoppedAtScriptResult(data, json) {
1698
1757
  code: 0,
1699
1758
  lines: [
1700
1759
  ...scriptTidiedHead(tidied),
1701
- written !== null
1702
- ? "Your file described the ad, so we wrote this script from it. Nothing has started."
1703
- : "This is the script the run would start on. Nothing has started.",
1760
+ rewritten !== null
1761
+ ? "Your script didn't fit this kind of ad, so we wrote a new one from it. Nothing has started."
1762
+ : written !== null
1763
+ ? "Your file described the ad, so we wrote this script from it. Nothing has started."
1764
+ : "This is the script the run would start on. Nothing has started.",
1704
1765
  "",
1705
1766
  ...script.split("\n").map((line) => (line ? ` ${line}` : "")),
1706
1767
  "",
@@ -1782,6 +1843,7 @@ export async function startScriptFlow(opts, deps) {
1782
1843
  ...(opts.director ? { director: true } : {}),
1783
1844
  ...(opts.shots ? { shots: opts.shots } : {}),
1784
1845
  ...(opts.stopAtScript ? { stopAtScript: true } : {}),
1846
+ ...(opts.rewriteToFit ? { rewriteToFit: true } : {}),
1785
1847
  });
1786
1848
  if (!res.ok)
1787
1849
  return scriptNeedsCallerResult(res, opts.json) ?? scriptStartRefused(res, opts.json);
@@ -1801,13 +1863,19 @@ export async function startScriptFlow(opts, deps) {
1801
1863
  const video = startedVideoChoice(started.videoChoice);
1802
1864
  const tidied = started.tidied === undefined ? null : scriptTidiedOf(started.tidied);
1803
1865
  const written = writtenOf(started.written);
1866
+ const rewritten = writtenOf(started.rewritten);
1804
1867
  const facts = {
1805
1868
  card,
1806
1869
  ...(video ? { videoChoice: video } : {}),
1807
1870
  ...(tidied ? { tidied } : {}),
1808
1871
  ...(written !== null ? { written } : {}),
1872
+ ...(rewritten !== null ? { rewritten } : {}),
1809
1873
  };
1810
- const head = [...scriptTidiedHead(tidied), ...scriptWrittenHead(written, true)];
1874
+ const head = [
1875
+ ...scriptTidiedHead(tidied),
1876
+ ...scriptWrittenHead(written, true),
1877
+ ...scriptRewrittenHead(rewritten, true),
1878
+ ];
1811
1879
  if (!opts.wait) {
1812
1880
  return {
1813
1881
  code: 0,
@@ -1834,16 +1902,22 @@ async function customerStartedResult(started, opts, deps) {
1834
1902
  const { runId, dashboardUrl } = started;
1835
1903
  const tidied = started.tidied === undefined ? null : scriptTidiedOf(started.tidied);
1836
1904
  const written = writtenOf(started.written);
1905
+ const rewritten = writtenOf(started.rewritten);
1837
1906
  const head = [
1838
1907
  ...scriptTidiedHead(tidied),
1839
1908
  ...scriptWrittenHead(written, true),
1909
+ ...scriptRewrittenHead(rewritten, true),
1840
1910
  `Started video ${runId}`,
1841
1911
  `Watch it: ${dashboardUrl}`,
1842
1912
  ];
1843
1913
  if (opts.wait) {
1844
1914
  const waited = await startedThenWait(runId, head, { ...opts, url: dashboardUrl }, deps);
1845
- const facts = { ...(tidied ? { tidied } : {}), ...(written !== null ? { written } : {}) };
1846
- return opts.json && (tidied || written !== null) ? waitJsonWith(waited, facts) : waited;
1915
+ const facts = {
1916
+ ...(tidied ? { tidied } : {}),
1917
+ ...(written !== null ? { written } : {}),
1918
+ ...(rewritten !== null ? { rewritten } : {}),
1919
+ };
1920
+ return opts.json && (tidied || written !== null || rewritten !== null) ? waitJsonWith(waited, facts) : waited;
1847
1921
  }
1848
1922
  if (opts.json)
1849
1923
  return { code: 0, lines: [JSON.stringify(started)] };
@@ -2035,7 +2109,7 @@ export async function statusFlow(runId, json, deps) {
2035
2109
  if (item.itemKind !== "clip" && item.itemKind !== "voiceover" && item.itemKind !== "frame") {
2036
2110
  continue;
2037
2111
  }
2038
- if (item.sceneIndex < 0)
2112
+ if (!isPullSceneIndex(item.sceneIndex))
2039
2113
  continue;
2040
2114
  if (item.itemKind === "frame" && !sceneFrameNodeIds.has(item.nodeId))
2041
2115
  continue;
@@ -2043,9 +2117,11 @@ export async function statusFlow(runId, json, deps) {
2043
2117
  row[item.itemKind] = item;
2044
2118
  byScene.set(item.sceneIndex, row);
2045
2119
  }
2046
- const headline = stop.at === "failed" && stop.repair
2047
- ? `Ad run ${runId} — Stopped, a step failed`
2048
- : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
2120
+ const headline = stop.at === "failed" && stop.workerStopped
2121
+ ? `Ad run ${runId} — Stopped, its worker went quiet`
2122
+ : stop.at === "failed" && stop.repair
2123
+ ? `Ad run ${runId} — Stopped, a step failed`
2124
+ : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
2049
2125
  const lines = [
2050
2126
  headline,
2051
2127
  ...(carryingOn(run) ? [CARRYING_ON_LINE] : []),
@@ -508,7 +508,9 @@ interface RunFlowOptions {
508
508
  onProgressLine?: (line: string) => void;
509
509
  onWarningLine?: (line: string) => void;
510
510
  }
511
- export declare function formatPauseNotice(pauseReason: WorkflowPauseReason | undefined, runId: string, dashboardUrl: string): string[];
511
+ export declare function formatPauseNotice(pauseReason: WorkflowPauseReason | undefined, runId: string, dashboardUrl: string, { workerStopped }?: {
512
+ workerStopped?: boolean;
513
+ }): string[];
512
514
  export declare function parseRawInputFlags(occurrences: FlagOccurrence[]): Record<string, string>;
513
515
  export declare function parseInputFlags(occurrences: FlagOccurrence[], readFile?: (path: string) => string): Record<string, string>;
514
516
  export declare const ASSET_UPLOAD_POLICY: Record<WorkflowMediaType, {
@@ -4,7 +4,7 @@ import nodePath from "node:path";
4
4
  import { apiGet, apiGetText, apiPost, apiPostDashboard, getDashboardUrl, } from "../lib/client.js";
5
5
  import { formatApiError } from "../lib/format.js";
6
6
  import { pollUntilDone } from "../lib/poll.js";
7
- import { normalizeRunStatus, isTerminalRunStatus, storedWorkflowStatusForms, TERMINAL_RUN_STATUSES, } from "../lib/runStatus.js";
7
+ import { normalizeRunStatus, isTerminalRunStatus, storedWorkflowStatusForms, isWorkerStoppedPark, TERMINAL_RUN_STATUSES, } from "../lib/runStatus.js";
8
8
  import { runVerdict } from "../lib/runVerdict.js";
9
9
  import { runDisplayName, runHeadingWord, runTypeWord } from "../lib/runNames.js";
10
10
  import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
@@ -354,9 +354,11 @@ Notes:
354
354
  its own box on the canvas, sitting on a wire between two steps, so the diagram
355
355
  shows exactly where a run will stop. A require-all collector that stalled
356
356
  on a dead input is a "repair" park — retry it, skip the dead input, or kill the
357
- run. A nested sub-workflow waiting on inputs is a "slots" park — answer it with
358
- repeatable --slot key=value flags (run "answer" with no --slot to list the slot
359
- ids it wants). The "gate" verbs retired in 2.0 along with the Gate node — the
357
+ run. A run whose worker stopped partway is also a "repair" park, with nothing
358
+ dead in it: retry carries it on from where it stopped and keeps every finished
359
+ step, and skip is refused. (#3820) A nested sub-workflow waiting on inputs is a
360
+ "slots" park — answer it with repeatable --slot key=value flags (run "answer"
361
+ with no --slot to list the slot ids it wants). The "gate" verbs retired in 2.0 along with the Gate node — the
360
362
  command now prints a pointer at "checkpoint" and exits 1.
361
363
  `.trim();
362
364
  export function formatRulesLine(provenance) {
@@ -567,7 +569,7 @@ function gateParkedNodeId(status, pauseReason, pausedNodeId) {
567
569
  }
568
570
  const RETIRED_GATE_VERB_POINTER = "The Gate node retired in 2.0 — runs now pause at a Checkpoint box on the canvas. " +
569
571
  "Use: exodus workflow checkpoint <runId> [approve|edit|retry|cancel]";
570
- export function formatPauseNotice(pauseReason, runId, dashboardUrl) {
572
+ export function formatPauseNotice(pauseReason, runId, dashboardUrl, { workerStopped = false } = {}) {
571
573
  if (!pauseReason) {
572
574
  return [
573
575
  " ⏸ paused at the cost gate — approve or edit the storyboard in the web app to continue.",
@@ -591,6 +593,13 @@ export function formatPauseNotice(pauseReason, runId, dashboardUrl) {
591
593
  " Then run the workflow again.",
592
594
  ];
593
595
  }
596
+ if (pauseReason === "repair" && workerStopped) {
597
+ return [
598
+ " ⏸ the worker stopped partway — retry carries on from where it stopped, finished steps are kept.",
599
+ ` Resolve here: exodus workflow repair ${runId} retry|kill`,
600
+ ` Or in the app: ${dashboardUrl}${RUN_PAGE_PREFIX}${runId}`,
601
+ ];
602
+ }
594
603
  const resolveVerb = pauseReason === "repair"
595
604
  ? `exodus workflow repair ${runId} retry|skip|kill`
596
605
  : `exodus workflow answer ${runId} --slot key=value`;
@@ -2121,7 +2130,12 @@ async function waitForRun(runId, opts, deps) {
2121
2130
  pausedNotified = true;
2122
2131
  const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
2123
2132
  const pauseReason = raw["pauseReason"];
2124
- for (const line of formatPauseNotice(pauseReason, runId, dashboardUrl)) {
2133
+ const workerStopped = isWorkerStoppedPark({
2134
+ status: raw["status"],
2135
+ pauseReason,
2136
+ error: raw["error"],
2137
+ });
2138
+ for (const line of formatPauseNotice(pauseReason, runId, dashboardUrl, { workerStopped })) {
2125
2139
  opts.onProgressLine(line);
2126
2140
  }
2127
2141
  }
@@ -2996,6 +3010,10 @@ export async function repairFlow(runId, action, opts, deps) {
2996
3010
  const pf = await preflightPark(runId, "repair", verb, opts.json, deps);
2997
3011
  if (!pf.ok)
2998
3012
  return pf.result;
3013
+ if (action === "skip" && isWorkerStoppedPark(pf.run)) {
3014
+ return errLine(`Run ${runId} has nothing to skip — its worker stopped partway. ` +
3015
+ `Carry on from where it stopped (finished steps are kept): exodus workflow repair ${runId} retry`, opts.json);
3016
+ }
2999
3017
  if (action === "kill") {
3000
3018
  const res = await deps.post(CANCEL_PATH, { runId });
3001
3019
  if (!res.ok)
@@ -14,9 +14,15 @@ export declare function isActiveRunStatus(raw: string | null | undefined): boole
14
14
  export declare const WORKFLOW_RUN_STATUS_VALUES: readonly ["queued", "running", "awaiting-approval", "succeeded", "succeeded-with-warnings", "failed", "cancelled"];
15
15
  export declare const LEGACY_WORKFLOW_RUN_STATUS_VALUES: readonly ["awaiting-review", "completed", "partial", "canceled"];
16
16
  export declare function storedWorkflowStatusForms(status: RunStatus): string[];
17
- export declare function workflowRunPresentation(rawStatus: string | null | undefined, pauseReason?: string | null): {
17
+ export declare function workflowRunPresentation(rawStatus: string | null | undefined, pauseReason?: string | null, workerStopped?: boolean): {
18
18
  status: RunStatus;
19
19
  label: string;
20
20
  tone: RunStatusTone;
21
21
  detail?: string;
22
22
  };
23
+ export declare const WORKER_STOPPED_PARK_ERROR = "timeout-zombie-sweep: worker stopped heartbeating; finished steps are kept";
24
+ export declare function isWorkerStoppedPark(run: {
25
+ status?: string | null;
26
+ pauseReason?: string | null;
27
+ error?: string | null;
28
+ }): boolean;
@@ -115,7 +115,7 @@ export function storedWorkflowStatusForms(status) {
115
115
  const twin = LEGACY_WORKFLOW_TWIN[status];
116
116
  return twin ? [status, twin] : [status];
117
117
  }
118
- export function workflowRunPresentation(rawStatus, pauseReason) {
118
+ export function workflowRunPresentation(rawStatus, pauseReason, workerStopped) {
119
119
  let status = normalizeRunStatus(rawStatus);
120
120
  let detail;
121
121
  if (status === "awaiting-approval") {
@@ -127,7 +127,7 @@ export function workflowRunPresentation(rawStatus, pauseReason) {
127
127
  detail = "needs inputs";
128
128
  }
129
129
  else if (pauseReason === "repair") {
130
- detail = "needs repair";
130
+ detail = workerStopped ? "worker stopped" : "needs repair";
131
131
  }
132
132
  }
133
133
  return {
@@ -137,3 +137,9 @@ export function workflowRunPresentation(rawStatus, pauseReason) {
137
137
  detail,
138
138
  };
139
139
  }
140
+ export const WORKER_STOPPED_PARK_ERROR = "timeout-zombie-sweep: worker stopped heartbeating; finished steps are kept";
141
+ export function isWorkerStoppedPark(run) {
142
+ return (normalizeRunStatus(run.status) === "awaiting-approval" &&
143
+ run.pauseReason === "repair" &&
144
+ run.error === WORKER_STOPPED_PARK_ERROR);
145
+ }
@@ -8,6 +8,7 @@ export interface RunVerdictDelivery {
8
8
  export interface RunVerdictInput {
9
9
  status?: string | null;
10
10
  pauseReason?: string | null;
11
+ error?: string | null;
11
12
  counts?: {
12
13
  done?: number;
13
14
  failed?: number;
@@ -1,4 +1,4 @@
1
- import { RUN_STATUS_LABELS, workflowRunPresentation } from "./runStatus.js";
1
+ import { RUN_STATUS_LABELS, isWorkerStoppedPark, workflowRunPresentation, } from "./runStatus.js";
2
2
  export function summarizeDeliveries(deliveries) {
3
3
  if (deliveries === undefined || deliveries === null)
4
4
  return undefined;
@@ -8,7 +8,7 @@ export function summarizeDeliveries(deliveries) {
8
8
  };
9
9
  }
10
10
  export function runVerdict(run) {
11
- const presented = workflowRunPresentation(run.status, run.pauseReason);
11
+ const presented = workflowRunPresentation(run.status, run.pauseReason, isWorkerStoppedPark(run));
12
12
  const delivery = summarizeDeliveries(run.deliveries) ?? run.deliverySummary ?? undefined;
13
13
  if ((presented.status === "succeeded" || presented.status === "succeeded-with-warnings") &&
14
14
  (run.counts?.failed ?? 0) > 0 &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.10.1700",
3
+ "version": "2026.10.1900-beta.1",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",