@aicopycoders/exodus 2026.10.1700 → 2026.10.1701-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.
@@ -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;
@@ -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";
@@ -411,6 +412,8 @@ export function classifyRun(run) {
411
412
  if (run.isTerminal)
412
413
  return { at: "finished", status: run.status };
413
414
  if (isAwaitingApproval(run.status)) {
415
+ if (isWorkerStoppedPark(run))
416
+ return { at: "failed", repair: true, workerStopped: true };
414
417
  if (run.pauseReason === "repair") {
415
418
  const deadNode = run.nodes.find((n) => n.status === "failed");
416
419
  const why = deadNode?.error ?? run.error;
@@ -591,6 +594,13 @@ export function stopLines(stop, runId, runUrl) {
591
594
  ];
592
595
  }
593
596
  if (stop.at === "failed") {
597
+ if (stop.workerStopped) {
598
+ return [
599
+ "The worker making this ad stopped partway. Finished steps are kept.",
600
+ `Carry on from where it stopped: exodus workflow repair ${runId} retry`,
601
+ `Or open the run: ${runUrl}`,
602
+ ];
603
+ }
594
604
  if (stop.repair) {
595
605
  if (!stop.step) {
596
606
  return [
@@ -2043,9 +2053,11 @@ export async function statusFlow(runId, json, deps) {
2043
2053
  row[item.itemKind] = item;
2044
2054
  byScene.set(item.sceneIndex, row);
2045
2055
  }
2046
- const headline = stop.at === "failed" && stop.repair
2047
- ? `Ad run ${runId} — Stopped, a step failed`
2048
- : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
2056
+ const headline = stop.at === "failed" && stop.workerStopped
2057
+ ? `Ad run ${runId} — Stopped, its worker went quiet`
2058
+ : stop.at === "failed" && stop.repair
2059
+ ? `Ad run ${runId} — Stopped, a step failed`
2060
+ : `Ad run ${runId} — ${displayRunStatus(run.status)}`;
2049
2061
  const lines = [
2050
2062
  headline,
2051
2063
  ...(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)
@@ -20,3 +20,9 @@ export declare function workflowRunPresentation(rawStatus: string | null | undef
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;
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.10.1700",
3
+ "version": "2026.10.1701-beta.1",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",