@@ -1405,16 +1419,20 @@ export default function AutopilotPanel({ series, onSeriesUpdate, onIssuesUpdate
plan exists, so it survives after the stream closes: a dry-run persists
no marker and completes immediately, and a run that pauses while the
panel is open keeps its map beside the banner. Both halves are
- in-memory on the server, so a reload after the run ended shows the
- persisted banner alone — persisting the map itself is tracked in #4140.
+ in-memory on the server, so a reload after the run ended falls back to
+ the copy the run stamped on its marker (#4140) — see `markerPlan`.
Cleared when the next run starts. `planTotals` carries the #1576
estimated cos-action budget so a large series on a small daily cap can
- see, before starting, whether it will run out before editorial. */}
+ see, before starting, whether it will run out before editorial; it and
+ `mode` are set from the same start frame as the live `plan`, so a
+ marker-drawn map (which only happens when there is no live plan) has
+ neither — no estimate and no dry-run badge, both correct for a run
+ that has already spent its budget. */}
diff --git a/client/src/components/pipeline/AutopilotPanel.test.jsx b/client/src/components/pipeline/AutopilotPanel.test.jsx
index fe4c87ad52..417bcecce8 100644
--- a/client/src/components/pipeline/AutopilotPanel.test.jsx
+++ b/client/src/components/pipeline/AutopilotPanel.test.jsx
@@ -791,6 +791,54 @@ describe('AutopilotPanel', () => {
// The run is over — no Stop/Pause affordances, but the map is still there.
expect(screen.getByRole('button', { name: /run autopilot/i })).toBeInTheDocument();
});
+
+ // #4140 — the live halves die with the run, so a reload the morning after a
+ // pause has to redraw from the marker the run stamped.
+ it('redraws the map from the persisted marker when no run is active', async () => {
+ getPipelineAutopilotStatus.mockResolvedValue({ autopilot: { status: 'paused' }, active: false });
+ renderPanel({
+ id: 's1',
+ targetFormat: 'comic',
+ autopilot: {
+ status: 'paused',
+ plan: PLAN,
+ progress: {
+ currentStep: 'foundationGate',
+ currentStepComplete: false,
+ completed: { verifyArcSpine: 1 },
+ verified: { verifyArcSpine: { round: 2, findings: 5, blocking: 0 } },
+ },
+ },
+ });
+ expect(await screen.findByText(/Story progress/i)).toBeInTheDocument();
+ expect(screen.getByText(/1 of 3 milestone\(s\) · 25%/i)).toBeInTheDocument();
+ expect(screen.getByText(/0 blocking of 5 finding\(s\)/i)).toBeInTheDocument();
+ // A paused marker means the run STOPPED on that step, so the meter reads
+ // as a halted run rather than one still working.
+ expect(screen.getByRole('progressbar', { name: /story progress/i })).toBeInTheDocument();
+ expect(screen.getByText(/Judging foundation/i)).toBeInTheDocument();
+ });
+
+ it('prefers the live run over the marker when both exist', async () => {
+ getPipelineAutopilotStatus.mockResolvedValue({
+ autopilot: { status: 'running', runId: 'r2' },
+ active: true,
+ start: { type: 'start', runId: 'r2', mode: 'execute', plan: PLAN },
+ progress: { currentStep: 'textStages', completed: { verifyArcSpine: 1, foundationGate: 1 } },
+ });
+ // A stale marker from the PREVIOUS run must not win over the run in flight.
+ renderPanel({
+ id: 's1',
+ targetFormat: 'comic',
+ autopilot: {
+ status: 'paused',
+ plan: [{ kind: 'generateArc', count: 1 }],
+ progress: { currentStep: 'generateArc', completed: {} },
+ },
+ });
+ expect(await screen.findByText(/2 of 3 milestone\(s\)/i)).toBeInTheDocument();
+ expect(screen.queryByText(/Generating arc/i)).not.toBeInTheDocument();
+ });
});
// #1578 — per-check editorial telemetry forwarded up the autopilot SSE stream
diff --git a/client/src/lib/README.md b/client/src/lib/README.md
index 1d56056218..0aa32a61fb 100644
--- a/client/src/lib/README.md
+++ b/client/src/lib/README.md
@@ -34,7 +34,7 @@ grep -i "what you want to do" client/src/lib/README.md
| `shotGrammar.js` | Client mirror of the controlled vocabularies in `server/lib/shotGrammar.js` (#1315): `SHOT_TYPES` / `SCREEN_DIRECTIONS` enums (in sync with the server so a hand-set value passes `storyboardShotSchema`) + `SHOT_TYPE_LABELS` / `SCREEN_DIRECTION_LABELS` for the storyboards shot-grammar editor selects (#1468). |
| `universeStylePreset.js` | Build the client-side style preset that `composeStyledPrompt` layers on top. |
| `universeRunTag.js` | `buildUniverseSectionRenderTag(universe, kindKey, entry)` — the durable `universeRun` job tag the canon-render call sites (UniverseCanonSection, NounsStage, Story Builder characters step) pass to `generateImage` so the server auto-files the render into the universe collection AND appends it to the entry's `imageRefs[]` (no client follow-up PATCH). |
-| `autopilotMilestones.js` | Series-Autopilot milestone map. `buildAutopilotMilestones(plan, progress, {terminal})` folds the run's projected plan (the `start` frame) and its live progress snapshot (`progress` frames / the status route) into ordered rows with a `MILESTONE_STATUS` each — a furthest-index cursor, so a gate the run revisits can't un-finish the milestones after it; `summarizeAutopilotMilestones(rows)` rolls them into the header meter (a settled-but-unstepped milestone counts as complete); `describeAutopilotVerification(kind, verification)` renders what a gate actually validated — shared with the panel's `frameLabel`, since the milestone row and the activity log render the same telemetry; `isStoppedTerminal(terminal)` is the one definition of "the run stopped mid-plan". Also owns `AUTOPILOT_STEP_LABELS` / `autopilotStepLabel(kind)`, the one set of human labels for conductor step kinds (shared by the map and the panel's live status line). Used by `components/pipeline/AutopilotMilestones.jsx` + `AutopilotPanel.jsx`. |
+| `autopilotMilestones.js` | Series-Autopilot milestone map. `buildAutopilotMilestones(plan, progress, {terminal})` folds the run's projected plan (the `start` frame) and its live progress snapshot (`progress` frames / the status route) into ordered rows with a `MILESTONE_STATUS` each — a furthest-index cursor, so a gate the run revisits can't un-finish the milestones after it; `summarizeAutopilotMilestones(rows)` rolls them into the header meter (a settled-but-unstepped milestone counts as complete); `describeAutopilotVerification(kind, verification)` renders what a gate actually validated — shared with the panel's `frameLabel`, since the milestone row and the activity log render the same telemetry; `isStoppedTerminal(terminal)` is the one definition of "the run stopped mid-plan"; `autopilotMarkerTerminal(status)` translates a persisted `autopilot.status` into the terminal frame type the fold reads, so a map rebuilt from the marker after a reload (#4140) flags the step a paused run stopped on. Also owns `AUTOPILOT_STEP_LABELS` / `autopilotStepLabel(kind)`, the one set of human labels for conductor step kinds (shared by the map and the panel's live status line). Used by `components/pipeline/AutopilotMilestones.jsx` + `AutopilotPanel.jsx`. |
| `beatColors.js` | `BEAT_KIND_COLORS` + `getBeatKindColor(kind)` — per-kind display colors for reader-map emotional beats (kinds defined server-side in `storyArc.js`). Keeps every beat visualization consistent. |
| `beatGrid.js` | Music-video beat-quantized timeline arranger (#1854). `buildBeatGridPoints(audioAnalysis)` merges beats/downbeats/section edges into one ranked snap-point list; `snapTimeToGrid(timeSec, gridPoints, toleranceSec)` finds the nearest point within tolerance; `computeSceneSpans(scenes, durationSec)` lays out scenes without a persisted `startSec`/`endSec` contiguously as a display-only fallback; `computeDragSpan({kind, startSpan, deltaSec, gridPoints, ...})` resolves a timeline drag gesture (`'move'` or `'right'` — no `'left'`, since the render can only trim from a clip's own frame 0) into a new span; `shouldMarkBeatAligned({kind, snapped, wasPersisted})` gates the `beatAligned` flag so a reposition-only drag can't silently promote an unpersisted scene's placeholder fallback duration into a "saved exactly" render duration; `autoArrangeScenes(scenes, audioAnalysis)` proposes a full `{ sceneId, startSec, endSec, beatAligned }[]` arrangement by distributing scenes across song sections weighted by each section's `energy` (#1915). Used by `components/musicVideo/BeatTimeline.jsx` and `pages/MusicVideo.jsx`. |
| `bibleLimits.js` | Mirror of `server/lib/storyBible.js` `BIBLE_LIMITS`, plus `capImageRefs` / `appendImageRefById` for the optimistic imageRefs-append paths. |
diff --git a/client/src/lib/autopilotMilestones.js b/client/src/lib/autopilotMilestones.js
index 3503c0e45a..394f8742a4 100644
--- a/client/src/lib/autopilotMilestones.js
+++ b/client/src/lib/autopilotMilestones.js
@@ -65,6 +65,16 @@ const STOPPED_TERMINALS = new Set(['paused', 'error', 'canceled']);
/** Did the run stop mid-plan? Drives both the blocked row and the meter's tone. */
export const isStoppedTerminal = (terminal) => STOPPED_TERMINALS.has(terminal);
+// A persisted marker records how the run ENDED as a status, not as the frame
+// type the fold reads — so a map rebuilt from the marker (#4140, after a reload
+// with no live run) needs the same translation the live panel gets for free from
+// the terminal frame. `running` / `idle` map to null: no terminal reached, so
+// the step the run was on still reads as active rather than blocked.
+const MARKER_TERMINALS = Object.freeze({ done: 'complete', paused: 'paused', error: 'error' });
+
+/** Terminal frame type equivalent to a persisted `autopilot.status`, or null. */
+export const autopilotMarkerTerminal = (status) => MARKER_TERMINALS[status] || null;
+
const countOf = (map, key) => {
const n = map?.[key];
return Number.isFinite(n) && n > 0 ? n : 0;
diff --git a/client/src/lib/autopilotMilestones.test.js b/client/src/lib/autopilotMilestones.test.js
index 912082d23e..213936431f 100644
--- a/client/src/lib/autopilotMilestones.test.js
+++ b/client/src/lib/autopilotMilestones.test.js
@@ -4,6 +4,7 @@ import {
summarizeAutopilotMilestones,
describeAutopilotVerification,
autopilotStepLabel,
+ autopilotMarkerTerminal,
MILESTONE_STATUS,
} from './autopilotMilestones';
@@ -151,3 +152,26 @@ describe('autopilotStepLabel', () => {
expect(autopilotStepLabel('somethingNew')).toBe('somethingNew');
});
});
+
+describe('autopilotMarkerTerminal (#4140)', () => {
+ it('translates a persisted marker status into the terminal frame type the fold reads', () => {
+ expect(autopilotMarkerTerminal('done')).toBe('complete');
+ expect(autopilotMarkerTerminal('paused')).toBe('paused');
+ expect(autopilotMarkerTerminal('error')).toBe('error');
+ });
+
+ it('is null while no terminal has been reached, so the step still reads as active', () => {
+ expect(autopilotMarkerTerminal('running')).toBe(null);
+ expect(autopilotMarkerTerminal('idle')).toBe(null);
+ expect(autopilotMarkerTerminal(undefined)).toBe(null);
+ });
+
+ it('turns a marker-drawn paused run\'s step into a blocked row', () => {
+ const rows = buildAutopilotMilestones(
+ PLAN,
+ { currentStep: 'foundationGate', completed: { generateArc: 1, verifyArcSpine: 1 } },
+ { terminal: autopilotMarkerTerminal('paused') },
+ );
+ expect(statuses(rows).foundationGate).toBe(MILESTONE_STATUS.BLOCKED);
+ });
+});
diff --git a/server/services/pipeline/series.js b/server/services/pipeline/series.js
index fb78c9b51b..a6e3eefaca 100644
--- a/server/services/pipeline/series.js
+++ b/server/services/pipeline/series.js
@@ -248,19 +248,113 @@ const sanitizeAutopilotFindings = (raw, limit) => (Array.isArray(raw)
// ever bounds a marker written by a peer that knows dimensions this one doesn't.
const AUTOPILOT_DISCARDED_KEYS_MAX = 12;
+// Bound a keyed marker map (`{ dimension | stepKind: value }`): cap the key
+// count, trim each key, sanitize each value, and DROP a key whose value doesn't
+// survive — an unrecognized blob must never land as an empty bucket. Shared by
+// every keyed autopilot map (per-dimension discarded findings, the milestone
+// map's per-step counts and gate verifications) so they can't drift apart.
+// `value` returns null to drop the key.
+const sanitizeAutopilotKeyedMap = (raw, max, value) => {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
+ const out = {};
+ for (const [key, v] of Object.entries(raw).slice(0, max)) {
+ const name = trimTo(key, AUTOPILOT_STEP_MAX);
+ const bounded = value(v);
+ if (name && bounded !== null) out[name] = bounded;
+ }
+ return out;
+};
+
// The keyed form of the above, for a gate whose repairs are owned by independent
// targets (the foundation gate's dimensions): each key keeps its own bounded
// history, and a key whose findings all fail sanitization is dropped rather than
// persisted as an empty bucket.
-const sanitizeAutopilotKeyedFindings = (raw, limit) => {
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
- const out = {};
- for (const [key, findings] of Object.entries(raw).slice(0, AUTOPILOT_DISCARDED_KEYS_MAX)) {
- const name = trimTo(key, 80);
+const sanitizeAutopilotKeyedFindings = (raw, limit) => sanitizeAutopilotKeyedMap(
+ raw,
+ AUTOPILOT_DISCARDED_KEYS_MAX,
+ (findings) => {
const bounded = sanitizeAutopilotFindings(findings, limit);
- if (name && bounded.length > 0) out[name] = bounded;
- }
- return out;
+ return bounded.length > 0 ? bounded : null;
+ },
+);
+
+// ---------------------------------------------------------------------------
+// Milestone map (#4140). The Autonomous-mode card draws it from two halves that
+// otherwise live ONLY on the in-memory run record — the projected plan on the
+// run's `start` frame and the progress snapshot folded onto the record — so a
+// run that paused overnight showed a resume banner with no map beside it.
+// `persistMarker` stamps both onto the marker; these bound them to a wire shape.
+//
+// Both are naturally small (the resolver's step vocabulary is ~20 kinds, one row
+// each), so the caps here only ever bound a marker written by a PEER whose
+// vocabulary this install doesn't know. Same transient-marker posture as
+// pauseKind / craftGap*: no `pipelineSeries` schema-gate bump — a behind peer
+// that drops the map briefly shows a banner with no milestones until the next
+// run re-stamps it, which is exactly the pre-#4140 behavior.
+// ---------------------------------------------------------------------------
+const AUTOPILOT_PLAN_MAX = 40;
+const AUTOPILOT_PLAN_NOTE_MAX = 300;
+
+// One projected step: what it is, how many times the run expects to take it, the
+// planner's aside, and its estimated cos-action cost.
+const sanitizeAutopilotPlan = (raw) => (Array.isArray(raw)
+ ? raw
+ .map((row) => {
+ if (!row || typeof row !== 'object') return null;
+ const kind = trimTo(row.kind, AUTOPILOT_STEP_MAX);
+ if (!kind) return null;
+ return {
+ kind,
+ count: Number.isInteger(row.count) && row.count > 0 ? row.count : 1,
+ note: trimTo(row.note, AUTOPILOT_PLAN_NOTE_MAX) || null,
+ estActions: toCount(row.estActions),
+ };
+ })
+ .filter(Boolean)
+ .slice(0, AUTOPILOT_PLAN_MAX)
+ : []);
+
+// What one gate last verified. The convergence gates report finding counts and
+// the foundation gate reports a weighted score, so this keeps the union of the
+// numbers `describeAutopilotVerification` reads (each null when that gate
+// doesn't report it) and drops a blob carrying none of them.
+const sanitizeAutopilotVerification = (raw) => {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
+ const num = (v) => (Number.isFinite(v) ? v : null);
+ const out = {
+ round: num(raw.round),
+ findings: num(raw.findings),
+ blocking: num(raw.blocking),
+ errored: num(raw.errored),
+ weightedScore: num(raw.weightedScore),
+ threshold: num(raw.threshold),
+ weakest: trimTo(raw.weakest, AUTOPILOT_STEP_MAX) || null,
+ };
+ return Object.values(out).some((v) => v !== null) ? out : null;
+};
+
+// A `{ stepKind: count }` tally. A zero is dropped rather than persisted — the
+// map reads a missing key and a zero identically, so keeping zeros would only
+// grow the marker.
+const sanitizeAutopilotCounts = (raw) => sanitizeAutopilotKeyedMap(
+ raw,
+ AUTOPILOT_PLAN_MAX,
+ (n) => toCount(n) || null,
+);
+
+// Where the run got to against its plan. Null unless the marker carries an
+// object — a run with a plan but no progress yet draws every row as pending.
+const sanitizeAutopilotProgress = (raw) => {
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
+ return {
+ currentStep: trimTo(raw.currentStep, AUTOPILOT_STEP_MAX) || null,
+ // `currentStep` survives its own completion, so this flag is what separates
+ // "on this step" from "just finished it" — see state.js#noteProgress.
+ currentStepComplete: raw.currentStepComplete === true,
+ completed: sanitizeAutopilotCounts(raw.completed),
+ skipped: sanitizeAutopilotCounts(raw.skipped),
+ verified: sanitizeAutopilotKeyedMap(raw.verified, AUTOPILOT_PLAN_MAX, sanitizeAutopilotVerification),
+ };
};
export const sanitizeAutopilot = (raw) => {
@@ -335,6 +429,12 @@ export const sanitizeAutopilot = (raw) => {
// Observing-orchestrator activity for the run that just ended (null unless
// the run opted in and the observer dispatched at least one fix task).
observer: sanitizeAutopilotObserver(raw.observer),
+ // #4140 — the milestone map's two halves, so a run that paused overnight
+ // still draws its map on reload instead of a bare resume banner. Both are
+ // the run's OWN copies (see persistMarker); a live run's panel keeps reading
+ // the in-memory originals off the status route, which are fresher.
+ plan: sanitizeAutopilotPlan(raw.plan),
+ progress: sanitizeAutopilotProgress(raw.progress),
updatedAt: isStr(raw.updatedAt) ? raw.updatedAt : null,
};
};
diff --git a/server/services/pipeline/series.test.js b/server/services/pipeline/series.test.js
index ab631793f8..d9a09807d5 100644
--- a/server/services/pipeline/series.test.js
+++ b/server/services/pipeline/series.test.js
@@ -879,6 +879,89 @@ describe('pipeline series service', () => {
});
});
+ describe('sanitizeAutopilot milestone map (#4140)', () => {
+ it('keeps the projected plan rows the map renders', () => {
+ const a = svc.sanitizeAutopilot({
+ status: 'paused',
+ plan: [
+ { kind: 'generateArc', count: 1, estActions: 1 },
+ { kind: 'textStages', count: 3, note: 'prose + scripts', estActions: 3 },
+ ],
+ });
+ expect(a.plan).toEqual([
+ { kind: 'generateArc', count: 1, note: null, estActions: 1 },
+ { kind: 'textStages', count: 3, note: 'prose + scripts', estActions: 3 },
+ ]);
+ });
+
+ it('drops a kind-less row and defaults a missing/absurd count to one', () => {
+ const a = svc.sanitizeAutopilot({
+ status: 'paused',
+ plan: [{ count: 2 }, 'nope', { kind: 'verifyArc' }, { kind: 'beatSheet', count: -4 }],
+ });
+ expect(a.plan).toEqual([
+ { kind: 'verifyArc', count: 1, note: null, estActions: 0 },
+ { kind: 'beatSheet', count: 1, note: null, estActions: 0 },
+ ]);
+ });
+
+ it('caps a peer-written plan so an unknown step vocabulary cannot bloat the marker', () => {
+ const many = Array.from({ length: 90 }, (_, i) => ({ kind: `step${i}`, count: 1 }));
+ expect(svc.sanitizeAutopilot({ status: 'paused', plan: many }).plan).toHaveLength(40);
+ });
+
+ it('is an empty plan for a marker that carries none (older peers, idle series)', () => {
+ expect(svc.sanitizeAutopilot({ status: 'paused' }).plan).toEqual([]);
+ expect(svc.sanitizeAutopilot({ status: 'paused', plan: 'nope' }).plan).toEqual([]);
+ });
+
+ it('keeps the progress snapshot, including the step a paused run stopped on', () => {
+ const a = svc.sanitizeAutopilot({
+ status: 'paused',
+ progress: {
+ currentStep: 'editorialReview',
+ currentStepComplete: false,
+ completed: { generateArc: 1, textStages: 3, neverRan: 0 },
+ skipped: { textStages: 1 },
+ verified: { verifyArc: { round: 2, findings: 5, blocking: 1 } },
+ },
+ });
+ expect(a.progress).toEqual({
+ currentStep: 'editorialReview',
+ currentStepComplete: false,
+ // A zero tally reads the same as a missing key, so it is not persisted.
+ completed: { generateArc: 1, textStages: 3 },
+ skipped: { textStages: 1 },
+ verified: {
+ verifyArc: {
+ round: 2, findings: 5, blocking: 1, errored: null, weightedScore: null, threshold: null, weakest: null,
+ },
+ },
+ });
+ });
+
+ it('keeps the foundation gate\'s scoring shape alongside the counting gates\'', () => {
+ const a = svc.sanitizeAutopilot({
+ status: 'paused',
+ progress: { verified: { foundationGate: { round: 1, weightedScore: 62, threshold: 70, weakest: 'motivation' } } },
+ });
+ expect(a.progress.verified.foundationGate).toMatchObject({ weightedScore: 62, threshold: 70, weakest: 'motivation' });
+ });
+
+ it('drops a verification blob carrying none of the numbers the map reads', () => {
+ const a = svc.sanitizeAutopilot({
+ status: 'paused',
+ progress: { verified: { verifyArc: { junk: true }, beatSheet: 'nope' } },
+ });
+ expect(a.progress.verified).toEqual({});
+ });
+
+ it('is a null progress snapshot for a marker that carries none', () => {
+ expect(svc.sanitizeAutopilot({ status: 'paused' }).progress).toBeNull();
+ expect(svc.sanitizeAutopilot({ status: 'paused', progress: [] }).progress).toBeNull();
+ });
+ });
+
describe('sanitizeAutopilot discardedFindings', () => {
it('bounds the rolled-back candidate set the same way as residualFindings', () => {
const a = svc.sanitizeAutopilot({
diff --git a/server/services/pipeline/seriesAutopilot.test.js b/server/services/pipeline/seriesAutopilot.test.js
index 76b1af4c32..98f893af62 100644
--- a/server/services/pipeline/seriesAutopilot.test.js
+++ b/server/services/pipeline/seriesAutopilot.test.js
@@ -2741,6 +2741,42 @@ describe('autopilot conductor', () => {
});
});
+ it('persists the milestone map so a paused run survives a reload (#4140)', async () => {
+ editorialFindings = [{ severity: 'high', problem: 'missing scene', issueNumber: 1 }];
+ const { seriesId } = await seedComplete();
+ await autopilot.startSeriesAutopilot(seriesId, { maxEditorialRounds: 1 });
+ await waitFor(runFinished(seriesId));
+ const series = await seriesSvc.getSeries(seriesId);
+ expect(series.autopilot?.status).toBe('paused');
+ // The plan half: the same projection the run's `start` frame carried, so the
+ // panel can redraw every milestone the run expected to reach.
+ expect(series.autopilot?.plan?.length).toBeGreaterThan(0);
+ expect(series.autopilot.plan.every((r) => typeof r.kind === 'string' && r.count >= 1)).toBe(true);
+ // …and the progress half: where it actually got to, including the step it
+ // stopped on (which is what the map draws as blocked).
+ expect(series.autopilot?.progress?.currentStep).toBe('editorialReview');
+ expect(Object.keys(series.autopilot.progress.completed).length).toBeGreaterThan(0);
+ });
+
+ it('keeps the milestone map on a restart-interrupted run (#4140)', async () => {
+ // The map is stamped on every marker write that has a plan, not only the
+ // terminals — a hard restart never reaches a terminal write, and the boot
+ // recovery demotes `running` → `paused` by SPREADING whatever the marker
+ // already held. Simulate that: complete a run, rewind its marker to the
+ // `running` shape a killed process would have left, and recover.
+ const { seriesId } = await seedComplete();
+ await autopilot.startSeriesAutopilot(seriesId, {});
+ await waitFor(runFinished(seriesId));
+ const done = (await seriesSvc.getSeries(seriesId)).autopilot;
+ expect(done?.plan?.length).toBeGreaterThan(0);
+ await seriesSvc.updateSeries(seriesId, { autopilot: { ...done, status: 'running' } });
+ await autopilot.recoverStuckAutopilots();
+ const recovered = (await seriesSvc.getSeries(seriesId)).autopilot;
+ expect(recovered.status).toBe('paused');
+ expect(recovered.plan).toEqual(done.plan);
+ expect(recovered.progress).toEqual(done.progress);
+ });
+
it('does not notify on a clean complete (#1615)', async () => {
const { seriesId } = await seedComplete();
await autopilot.startSeriesAutopilot(seriesId, {});
diff --git a/server/services/pipeline/seriesAutopilot/session.js b/server/services/pipeline/seriesAutopilot/session.js
index 1e08e0143b..85d60762a5 100644
--- a/server/services/pipeline/seriesAutopilot/session.js
+++ b/server/services/pipeline/seriesAutopilot/session.js
@@ -166,10 +166,27 @@ export async function persistMarker(seriesId, patch) {
overrideStagePins: run.options.overrideStagePins === true,
}
: null;
+ // Milestone map (#4140). Both halves the Autonomous card draws it from live
+ // ONLY on the in-memory run record — the projected plan on the retained
+ // `start` frame, and the progress snapshot folded onto the record — so a run
+ // that paused overnight came back as a resume banner with no map beside it.
+ // Carry them on the marker, which outlives the run.
+ //
+ // Stamped on EVERY marker write that has a plan, not only the terminals: the
+ // marker is wholesale-replaced per write, so a terminals-only stamp would
+ // leave the map missing for the one interruption that skips a terminal write
+ // entirely — a hard restart, whose `running` marker the boot recovery demotes
+ // to `paused` by spreading whatever the marker already held. The plan is ~20
+ // small rows and the snapshot is keyed by the same step vocabulary, so this
+ // costs a couple of KB on a series record that is already tens.
+ const plan = Array.isArray(run?.startPayload?.plan) ? run.startPayload.plan : null;
+ const progress = plan ? snapshotProgress(run) : null;
await updateSeries(seriesId, {
autopilot: {
...patch,
...(resumable ? { resumeOptions: resumable } : {}),
+ ...(plan ? { plan } : {}),
+ ...(progress ? { progress } : {}),
updatedAt: new Date().toISOString(),
},
}).catch((err) => {