diff --git a/.changelog/next/fixed-issue-4140.md b/.changelog/next/fixed-issue-4140.md new file mode 100644 index 0000000000..a8d322cf78 --- /dev/null +++ b/.changelog/next/fixed-issue-4140.md @@ -0,0 +1 @@ +- Autopilot milestone map now survives a reload — a paused or completed run stamps its plan and progress onto the persisted marker, so the Autonomous-mode card redraws the map instead of showing a bare resume banner diff --git a/client/src/components/pipeline/AutopilotPanel.jsx b/client/src/components/pipeline/AutopilotPanel.jsx index 3ed906bfe3..3256786e57 100644 --- a/client/src/components/pipeline/AutopilotPanel.jsx +++ b/client/src/components/pipeline/AutopilotPanel.jsx @@ -22,7 +22,7 @@ import { patchSettingsSlice, } from '../../services/api'; import { providerDisplayName, providerModelLabel, assignmentModelOptions, resolveSeriesRunLlm, resolveCliEffort } from '../../utils/providers'; -import { autopilotStepLabel, describeAutopilotVerification } from '../../lib/autopilotMilestones'; +import { autopilotStepLabel, describeAutopilotVerification, autopilotMarkerTerminal } from '../../lib/autopilotMilestones'; import Pill from '../ui/Pill'; import ProviderModelSelector from '../ProviderModelSelector'; import AutopilotMilestones from './AutopilotMilestones'; @@ -960,6 +960,20 @@ export default function AutopilotPanel({ series, onSeriesUpdate, onIssuesUpdate : ap?.status === 'done' ? 'Run autopilot again' : 'Run autopilot'; + // Milestone map (#4140). The live halves — the plan off the run's `start` + // frame and the progress the stream folds — only exist while this panel has a + // run to watch, so a reload the morning after a pause used to show the resume + // banner with nothing beside it. The marker the run stamped carries both; + // fall back to it only when there is no live plan to draw, so a dry-run + // preview and a run in flight both keep the fresher in-memory copy. + const markerPlan = !active && !plan && ap?.plan?.length ? ap.plan : null; + const mapPlan = markerPlan || plan; + const mapProgress = markerPlan ? ap.progress : progress; + // A marker records how the run ended as a STATUS; the fold reads a terminal + // frame type, so translate. Without this the step a paused run stopped on + // would redraw as still running. + const mapTerminal = markerPlan ? autopilotMarkerTerminal(ap.status) : terminal; + return (
@@ -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) => {