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
2 changes: 1 addition & 1 deletion client/src/components/cos/tabs/ScheduleTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export default function ScheduleTab({ apps, providers, providersLoaded, activePr

<Banner size="md" title="Recommended maintenance order">
<p className="text-sm break-words">{MAINTENANCE_ORDER_GUIDANCE}</p>
<p className="text-xs mt-1">Resolve findings between audits, then document the resulting code. Run the whole sequence now from here, or schedule it under quota gates in Quota Burn; both drain claim-issue between steps.</p>
<p className="text-xs mt-1">Resolve findings between audits, then document the resulting code. Run the whole sequence now from here, or schedule it under quota gates in Quota Burn; Run now offers issue filing with claim-issue between audits, or audit and fix with one final claim-issue drain.</p>
<details className="mt-2">
<summary className="cursor-pointer text-sm font-medium">Run maintenance now</summary>
<MaintenanceRunForm schedule={{ ...schedule, tasks }} apps={apps} providers={providers} providersLoaded={providersLoaded} improvementDisabled={improvementDisabled} daemonRunning={daemonRunning} onRefresh={fetchSchedule} />
Expand Down
13 changes: 12 additions & 1 deletion client/src/components/cos/tabs/schedule/MaintenanceRunForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default function MaintenanceRunForm({ schedule, apps = [], providers = []
const [providerId, setProviderId] = useState('');
const [model, setModel] = useState('');
const [effort, setEffort] = useState('');
const [mode, setMode] = useState('file-issues');
const [consent, setConsent] = useState(false);
const [busy, setBusy] = useState(false);
const [preparing, setPreparing] = useState(false);
Expand Down Expand Up @@ -104,7 +105,7 @@ export default function MaintenanceRunForm({ schedule, apps = [], providers = []
if (busy || blocked || !ready || !provider || !model || !consent) return;
setBusy(true);
setMessage('Starting maintenance…');
const response = await api.startMaintenanceRun({ appId, providerId, model, effort: effort || null }, { silent: true }).catch(error => {
const response = await api.startMaintenanceRun({ appId, providerId, model, effort: effort || null, mode }, { silent: true }).catch(error => {
setMessage(`Could not start maintenance: ${error.message}`);
return null;
});
Expand Down Expand Up @@ -152,6 +153,16 @@ export default function MaintenanceRunForm({ schedule, apps = [], providers = []
{apps.filter(app => app.archived !== true).map(app => <option key={app.id} value={app.id}>{app.name}</option>)}
</select>
</label>
<label htmlFor="maintenance-run-mode" className="block">
Audit mode
<select id="maintenance-run-mode" value={mode} disabled={busy} onChange={event => { setMode(event.target.value); setConsent(false); }} className="mt-1 w-full bg-port-bg border border-port-border rounded p-2 text-white">
<option value="file-issues">File issues</option>
<option value="fix">Audit and fix</option>
</select>
</label>
<p className="text-xs">{mode === 'fix'
? 'Audits fix findings directly, then documentation runs, followed by one final claim-issue drain for remaining issues.'
: 'Audits file issues, with a claim-issue drain between audits to resolve findings before the next step.'}</p>
<ProviderModelSelector
providers={availableProviders}
selectedProviderId={providerId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe('maintenance launch', () => {
await select(user);
await user.click(screen.getByRole('button', { name: 'Run now' }));
expect(screen.getByRole('button', { name: 'Starting…' })).toBeDisabled();
expect(api.startMaintenanceRun).toHaveBeenCalledWith({ appId: 'example', providerId: 'claude', model: 'sonnet', effort: 'high' }, { silent: true });
expect(api.startMaintenanceRun).toHaveBeenCalledWith({ appId: 'example', providerId: 'claude', model: 'sonnet', effort: 'high', mode: 'file-issues' }, { silent: true });
finishStart({ run: runRecord(), result: { dispatched: true, taskType: 'better-structural-drift' } });
expect(await screen.findByText(/Maintenance started with better-structural-drift/)).toBeInTheDocument();
const row = screen.getByRole('list', { name: 'Maintenance runs' });
Expand Down Expand Up @@ -174,3 +174,15 @@ it('keeps a newer live update when an older initial fetch resolves late, and ref
await act(async () => socket.on.mock.calls.find(([name]) => name === 'connect')[1]());
expect(screen.getByText(/completed · 1\/13 steps/)).toBeInTheDocument();
});

it('launches fix mode only after renewed consent', async () => {
const user = userEvent.setup();
show();
await select(user);
await user.selectOptions(screen.getByLabelText('Audit mode'), 'fix');
expect(screen.getByRole('button', { name: 'Run now' })).toBeDisabled();
expect(screen.getByText(/one final claim-issue drain/)).toBeInTheDocument();
await user.click(screen.getByRole('checkbox'));
await user.click(screen.getByRole('button', { name: 'Run now' }));
expect(api.startMaintenanceRun).toHaveBeenCalledWith(expect.objectContaining({ mode: 'fix' }), { silent: true });
});
2 changes: 1 addition & 1 deletion server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `mediaModelBuckets.js` | Names and resolves the video registry's two model buckets (#4142). The split is by RUNTIME FAMILY — `mlx` (Apple MLX runtimes) vs `cuda` (plain torch+CUDA, which runs on Windows *and* Linux) — not by operating system, which is why `activeVideoBucket()` keys on `process.platform === 'darwin'` instead of on "is this Windows". `readVideoBucket` / `readVideoDefault` / `matchesVideoBucket` resolve the canonical key first and fall back to the pre-#4142 `macos` / `windows` / `defaultMacos` / `defaultWindows` spellings, so any registry written by an older install still loads untouched; `canonicalizeVideoBuckets` performs the one-time rename (migration 270 and the load-time twin in `mediaModels.js`). Kept separate from `mediaModels.js` so `scripts/migrations/` can share the alias resolution without importing the registry loader, which seeds `data/` as a side effect. |
| `mediaModels.js` | Single source of truth for image/video model metadata. Entry fields are documented in the module docblock — note `repoFiles[]`, which narrows a model's own repo to an explicit file list for an aggregate repo that holds far more than the runner loads (MiniMax H3 CUDA). |
| `videoFailure.js` | `normalizeVideoFailure` classifies and scrubs local video causes; `createVideoDiagnosticTail` retains bounded stdout/stderr diagnostics without grouping bare exit codes. |
| `maintenanceSequence.js` | The maintenance ladder shared by both runners: `MAINTENANCE_TASK_ORDER` / `MAINTENANCE_ORDER_GUIDANCE`, the interleaved `MAINTENANCE_SEQUENCE_TYPES` (a perpetual `claim-issue` drain between every pair of audits), `maintenanceStepParams` (audits file issues, documentation does the work), and `buildMaintenanceSteps` which emits quota-burn-shaped run-once steps so `quotaBurnInvoke.js` can run a step from the Schedule tab's manual run or a Quota Burn family plan alike. Pure. |
| `maintenanceSequence.js` | The maintenance ladder shared by both runners: `MAINTENANCE_TASK_ORDER` / `MAINTENANCE_ORDER_GUIDANCE`, the interleaved `MAINTENANCE_SEQUENCE_TYPES` (a perpetual `claim-issue` drain between every pair of audits), `maintenanceStepParams` (audits file issues, documentation does the work), and `buildMaintenanceSteps` (optional fix mode runs audits directly with one final drain) which emits quota-burn-shaped run-once steps so `quotaBurnInvoke.js` can run a step from the Schedule tab's manual run or a Quota Burn family plan alike. Pure. |
| `meetingUrl.js` | Selects the one cacheable join URL (`selectMeetingUrl`) out of a raw Google Calendar event — first http(s) `video` entry point, else `hangoutLink` — and owns the shared `MEETING_URL_MAX` bound. Three-state: `undefined` = the producer never described conferencing, `null` = described with none usable. |
| `minimaxH3Memory.js` | The declared weight-placement table for the three MiniMax H3 entries (#5420) — H3 is the one video model family whose components fit nowhere unassisted, so "does this box have enough" is a render gate, not a UI fact. `MINIMAX_H3_MEMORY_PROFILES` maps entry id → `{ shippedRepo, shippedRevision, profiles }`, each profile carrying an honest `minMemoryGb` host floor and (CUDA only) a `minVramGb` device floor, ordered best-first. Every capacity number is HOISTED from what already existed — the CUDA tiers are `resolve_offload_profile()`'s own thresholds in `scripts/generate_minimax_h3_cuda.py`, the host floors are the entries' `memoryGb` — the sole new number being `MINIMAX_H3_HOST_RESERVE_GB`, a policy reserve held back for the OS. `applyMiniMaxH3MemoryProfiles(list)` is the load-time backfill (twin of migration 317) and guards BOTH `repo` and `revision`, like the speed-profile decorator. `selectMiniMaxH3MemoryProfile({ model, totalMemoryGb })` picks the best profile the HOST can hold (VRAM is the runner's call — the server has no synchronous device view); `miniMaxH3MemoryDeclineReason()` RETURNS the fail-closed reason so the render path can 400 it and a status surface can show it, and returns `null` on an UNMEASURED host — "not measured" is a deferral to the runner, never the same as zero. `validateMiniMaxH3MemoryProfileTable` / `sanitizeMiniMaxH3MemoryProfiles` warn + strip a hand-edited table (NaN floor, duplicate/reserved id, mis-ordered tiers) at load. |
| `videoContinuity.js` | How chunk N+1 of a chained video render is conditioned on chunk N. `resolveContextFrames(requested)` normalizes the tail-window size (absent → `DEFAULT_CONTEXT_FRAMES` = 22 ≈ 1s @ 24fps; an explicit `0` is preserved as "last frame only", NOT collapsed into the default) and clamps to `MIN_CONTEXT_FRAMES`..`MAX_CONTEXT_FRAMES`. `resolveContinuityStrategy({model, contextFrames})` picks `'window'` (LTX-2 `extend_from_video` conditioned on the prior chunk's last N frames — motion, not just a pose) or `'frame'` (extract the last frame, run i2v), degrading to `'frame'` on any runtime outside `CONTEXT_WINDOW_RUNTIMES` rather than rejecting. `extendLatentFrames` / `extendedPixelFrames` convert across the VAE's `LATENT_FRAME_STRIDE` (8 pixel frames per latent), `contextPrefixFrames({totalFrames, extendLatents})` measures how much of an extend render is echoed context to trim back off before stitching (0 = leave it alone), and `tailWindowStartFrame` gives the cut index for the window itself. Pure — importable from `prepareParams.js` without dragging in `local.js`. Mirrored for the picker in `client/src/lib/videoGenParams.js`, pinned by `videoContinuity.parity.test.js`. |
Expand Down
17 changes: 10 additions & 7 deletions server/lib/maintenanceSequence.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* The maintenance ladder: the ordered audits PortOS recommends running against a
* managed app, with a perpetual `claim-issue` drain between every pair so each
* audit's findings are resolved before the next audit reads the code.
* audit's findings are resolved before the next audit reads the code. Fix mode
* instead resolves findings in each audit and runs one final drain.
*
* ONE definition, shared by both runners. `services/maintenanceRun.js` walks
* these steps directly for the Schedule tab's "Run now" — no quota gates, no
Expand All @@ -27,34 +28,36 @@ export const MAINTENANCE_ORDER_GUIDANCE = 'better-structural-drift → simplify
/** The drain that separates every pair of audits. */
export const MAINTENANCE_DRAIN_TASK = 'claim-issue';

/** Every scheduled task the ladder references, drain included, in ladder order. */
/** The default issue-filing ladder, including interleaved drains. */
export const MAINTENANCE_SEQUENCE_TYPES = Object.freeze(MAINTENANCE_TASK_ORDER.flatMap((type, index) =>
(index ? [MAINTENANCE_DRAIN_TASK, type] : [type])));

/**
* The run params an audit step pins. The first six audits explicitly FILE
* issues (which the drain then claims); documentation explicitly does the work.
* Fix mode runs audits directly and drains once at the end.
* A drain pins nothing — it inherits the app's saved claim filters.
*/
export const maintenanceStepParams = (taskType) => (taskType === MAINTENANCE_DRAIN_TASK
export const maintenanceStepParams = (taskType, mode = 'file-issues') => (taskType === MAINTENANCE_DRAIN_TASK
? {}
: { fileIssues: taskType !== 'documentation' });
: { fileIssues: mode !== 'fix' && taskType !== 'documentation' });

/**
* Build the ladder as run-once steps targeting `appId`, every one pinned to the
* provider/model/effort the user chose. `effort: null` inherits each scheduled
* task's saved effort. Ids are `${idPrefix}-${index}`, so a fresh prefix per
* invocation yields fresh step identities.
*/
export function buildMaintenanceSteps({ appId, idPrefix, providerId = null, model = null, effort = null }) {
return MAINTENANCE_SEQUENCE_TYPES.map((taskType, index) => ({
export function buildMaintenanceSteps({ appId, idPrefix, providerId = null, model = null, effort = null, mode = 'file-issues' }) {
const types = mode === 'fix' ? [...MAINTENANCE_TASK_ORDER, MAINTENANCE_DRAIN_TASK] : MAINTENANCE_SEQUENCE_TYPES;
return types.map((taskType, index) => ({
id: `${idPrefix}-${index}`,
enabled: true,
label: '',
taskRef: { kind: 'builtin', taskType, appId },
jobType: null,
runOnce: true,
drain: taskType === MAINTENANCE_DRAIN_TASK,
overrides: { providerId, model, effort, params: maintenanceStepParams(taskType) },
overrides: { providerId, model, effort, params: maintenanceStepParams(taskType, mode) },
}));
}
1 change: 1 addition & 0 deletions server/routes/cosScheduleRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const suggestedAfterSchema = z.array(z.string()).max(SUGGESTED_AFTER_MAX);
// model up front (AGENTS.md AI-policy: the click IS the consent). Blank effort
// inherits each scheduled task's saved effort.
const maintenanceRunStartSchema = z.object({
mode: z.enum(['file-issues', 'fix']).optional(),
appId: z.string().trim().min(1),
providerId: z.string().trim().min(1),
model: z.string().trim().min(1),
Expand Down
7 changes: 7 additions & 0 deletions server/routes/cosScheduleRoutes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ describe('CoS Schedule Routes', () => {
});

describe('manual maintenance runs', () => {
it('accepts fix mode and rejects unknown modes', async () => {
maintenance.startMaintenanceRun.mockResolvedValue({ run: { id: 'maint-1' } });
const body = { appId: 'app-1', providerId: 'codex', model: 'gpt-5', mode: 'fix' };
expect((await request(app).post('/api/cos/schedule/maintenance-runs').send(body)).status).toBe(201);
expect(maintenance.startMaintenanceRun).toHaveBeenCalledWith(body);
expect((await request(app).post('/api/cos/schedule/maintenance-runs').send({ ...body, mode: 'unknown' })).status).toBe(400);
});
it('starts a run from a validated body and reports the first dispatch', async () => {
maintenance.startMaintenanceRun.mockResolvedValue({ run: { id: 'maint-1', status: 'running' }, result: { dispatched: true, taskType: 'better-structural-drift' } });
const response = await request(app).post('/api/cos/schedule/maintenance-runs').send({ appId: 'app-1', providerId: 'codex', model: 'gpt-5', effort: null });
Expand Down
4 changes: 2 additions & 2 deletions server/services/maintenanceRun.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ async function assertNoRunningRun(appId) {
* The first evaluation runs before this returns, so the caller learns whether
* step one actually went out (or why it is holding) in the same response.
*/
export async function startMaintenanceRun({ appId, providerId, model = null, effort = null }) {
export async function startMaintenanceRun({ appId, providerId, model = null, effort = null, mode = 'file-issues' }) {
const [{ getAppById }, { getProviderById }, { resolveBurnProvider }] = await Promise.all([
import('./apps.js'), import('./providers.js'), import('./scheduledHandlers/providerPick.js'),
]);
Expand All @@ -155,7 +155,7 @@ export async function startMaintenanceRun({ appId, providerId, model = null, eff
const run = await insertRun({
id, appId, familyId, ...pins,
status: MAINTENANCE_RUN_STATUS.RUNNING,
steps: buildMaintenanceSteps({ appId, idPrefix: id, ...pins }),
steps: buildMaintenanceSteps({ appId, idPrefix: id, ...pins, mode }),
completed: {},
active: null,
reason: null,
Expand Down
17 changes: 16 additions & 1 deletion server/services/maintenanceRun.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { rm } from 'fs/promises';
import { join } from 'path';
import { mockPathsDataRoot } from '../lib/mockPathsDataRoot.js';
import { MAINTENANCE_SEQUENCE_TYPES } from '../lib/maintenanceSequence.js';
import { MAINTENANCE_SEQUENCE_TYPES, MAINTENANCE_TASK_ORDER } from '../lib/maintenanceSequence.js';

const { tempRoot, makeProxy, cleanup } = mockPathsDataRoot({ prefix: 'portos-maintenance-run-' });
vi.mock('../lib/fileUtils.js', async () => makeProxy(await vi.importActual('../lib/fileUtils.js')));
Expand Down Expand Up @@ -184,3 +184,18 @@ it('publishes queued, running, and completed progress with the active agent link
expect(updates.at(-1).active).not.toHaveProperty('agentId');
cosEvents.off('maintenance:updated', listener);
});

it('runs fixes consecutively and drains remaining issues only after documentation', async () => {
const { run } = await startMaintenanceRun({ appId: 'app-1', providerId: 'codex', model: 'gpt-5', mode: 'fix' });
expect(run.steps.map(step => step.taskRef.taskType)).toEqual([...MAINTENANCE_TASK_ORDER, 'claim-issue']);
for (let index = 0; index < MAINTENANCE_TASK_ORDER.length; index++) {
expect(state.invoked.at(-1).step.overrides.params).toEqual({ fileIssues: false });
await __onMaintenanceAgentCompleted(agentFor(run, index));
}
expect(dispatchedTypes()).toEqual([...MAINTENANCE_TASK_ORDER, 'claim-issue']);
await __onMaintenanceAgentCompleted(agentFor(run, MAINTENANCE_TASK_ORDER.length));
expect(dispatchedTypes().slice(-2)).toEqual(['claim-issue', 'claim-issue']);
state.probe = { drained: true };
await __onMaintenanceAgentCompleted(agentFor(run, MAINTENANCE_TASK_ORDER.length));
expect(await getMaintenanceRun(run.id)).toMatchObject({ status: 'completed' });
});