Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/next/fixed-issue-4140.md
Original file line number Diff line number Diff line change
@@ -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
32 changes: 25 additions & 7 deletions client/src/components/pipeline/AutopilotPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<div className="border border-port-border rounded-lg bg-port-card/40">
<div className="flex items-center gap-2 flex-wrap p-3">
Expand Down Expand Up @@ -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. */}
<AutopilotMilestones
plan={plan}
plan={mapPlan}
planTotals={planTotals}
progress={progress}
terminal={terminal}
progress={mapProgress}
terminal={mapTerminal}
dryRun={mode === 'dry-run'}
/>

Expand Down
48 changes: 48 additions & 0 deletions client/src/components/pipeline/AutopilotPanel.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion client/src/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
10 changes: 10 additions & 0 deletions client/src/lib/autopilotMilestones.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions client/src/lib/autopilotMilestones.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
summarizeAutopilotMilestones,
describeAutopilotVerification,
autopilotStepLabel,
autopilotMarkerTerminal,
MILESTONE_STATUS,
} from './autopilotMilestones';

Expand Down Expand Up @@ -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);
});
});
116 changes: 108 additions & 8 deletions server/services/pipeline/series.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
};
};
Expand Down
Loading