diff --git a/client/src/components/cos/tabs/schedule/AppTaskCard.jsx b/client/src/components/cos/tabs/schedule/AppTaskCard.jsx
index 9fc526f181..4e1027dfe4 100644
--- a/client/src/components/cos/tabs/schedule/AppTaskCard.jsx
+++ b/client/src/components/cos/tabs/schedule/AppTaskCard.jsx
@@ -92,6 +92,7 @@ export default function AppTaskCard({ taskType, config, apps, onTrigger, onConfi
apps={apps}
onTrigger={onTrigger}
installWide={config.installWide}
+ programmatic={config.programmatic}
disabledReason={improvementDisabled ? IMPROVEMENT_DISABLED_TITLE : (pins.saving ? SAVING_TITLE : '')}
/>
diff --git a/client/src/components/cos/tabs/schedule/PromptEditor.jsx b/client/src/components/cos/tabs/schedule/PromptEditor.jsx
index 00f0cb1154..390b63c255 100644
--- a/client/src/components/cos/tabs/schedule/PromptEditor.jsx
+++ b/client/src/components/cos/tabs/schedule/PromptEditor.jsx
@@ -12,6 +12,22 @@ export default function PromptEditor({ config, promptValue, setPromptValue, edit
}, [stages?.length, activeTab]);
if (!hasPipeline) {
+ // A PROGRAMMATIC task has no prompt at all — PortOS performs the work
+ // itself. Distinct from 'runtime-generated' (a hook renders a real prompt
+ // for a real agent), so it must not offer to show one.
+ if (config.promptMode === 'programmatic') {
+ return (
+
+
+ Task Prompt
+ No prompt — PortOS runs this itself
+
+
+ {config.promptDescription || 'This task is performed by PortOS directly, with no agent and no prompt.'}
+
diff --git a/client/src/components/cos/tabs/schedule/PromptEditor.test.jsx b/client/src/components/cos/tabs/schedule/PromptEditor.test.jsx
index 0782980e59..559a4d9d96 100644
--- a/client/src/components/cos/tabs/schedule/PromptEditor.test.jsx
+++ b/client/src/components/cos/tabs/schedule/PromptEditor.test.jsx
@@ -29,3 +29,30 @@ describe('PromptEditor', () => {
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
});
});
+
+describe('PromptEditor — programmatic tasks', () => {
+ it('offers no prompt at all for work PortOS performs itself', () => {
+ // Distinct from 'runtime-generated': there is no agent and no prompt, so
+ // offering to show or edit one would describe execution that never happens.
+ render(
+ {}}
+ editingPrompt={false}
+ setEditingPrompt={() => {}}
+ handleSavePrompt={() => {}}
+ updating={false}
+ activeApps={[]}
+ />
+ );
+
+ expect(screen.getByText('No prompt — PortOS runs this itself')).toBeInTheDocument();
+ expect(screen.getByText('PortOS enqueues the renders itself.')).toBeInTheDocument();
+ expect(screen.queryByText('Generated at run time')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/cos/tabs/schedule/RunTaskButton.jsx b/client/src/components/cos/tabs/schedule/RunTaskButton.jsx
index 1e78ffb847..114f2a5cf4 100644
--- a/client/src/components/cos/tabs/schedule/RunTaskButton.jsx
+++ b/client/src/components/cos/tabs/schedule/RunTaskButton.jsx
@@ -16,7 +16,7 @@ const MENU_WIDTH = 256; // w-64
// A named boolean per reason would mean every new reason edits this component,
// and — as the pin-saving gate showed — reaching only whichever call site the
// author had in mind.
-export default function RunTaskButton({ taskType, apps, onTrigger, installWide = false, disabledReason = '' }) {
+export default function RunTaskButton({ taskType, apps, onTrigger, installWide = false, programmatic = false, disabledReason = '' }) {
const [open, setOpen] = useState(false);
const [triggering, setTriggering] = useState(false);
const [lastRequest, setLastRequest] = useState('');
@@ -101,7 +101,10 @@ export default function RunTaskButton({ taskType, apps, onTrigger, installWide =
// run is the app-less one. Without this the picker would be the only way to
// start it on any install that has apps, and every click would send an appId —
// silently reducing an install-wide sweep to a single repo.
- if (activeApps.length === 0 || installWide) {
+ // A PROGRAMMATIC task (universe bible descriptions/images) acts on PortOS's
+ // own records, never a managed app's checkout — the server rejects a request
+ // that names one — so it gets the plain button, not the app picker.
+ if (activeApps.length === 0 || installWide || programmatic) {
return (
{
expect(screen.queryByText('Example App')).toBeNull();
});
+ it('runs a programmatic task with NO app, and without the all-apps label', async () => {
+ // A programmatic handler acts on PortOS's own records; the server rejects
+ // a request that names a managed app, so the picker would produce a dead
+ // button on any install that has apps. It is not an install-wide SWEEP
+ // either, so it must not claim to run on all of them.
+ const user = userEvent.setup();
+ const onTrigger = vi.fn();
+ render();
+ expect(screen.queryByRole('button', { name: /Run on All Apps/i })).toBeNull();
+ await user.click(screen.getByRole('button', { name: /Run Now/i }));
+ expect(onTrigger).toHaveBeenCalledWith('universe-bible-images');
+ expect(onTrigger.mock.calls[0]).toHaveLength(1);
+ });
+
it('lists only active apps and runs the task on the picked one', async () => {
const user = userEvent.setup();
const onTrigger = vi.fn();
diff --git a/client/src/hooks/useOnDemandTaskToast.js b/client/src/hooks/useOnDemandTaskToast.js
index 47d07ba2de..16451448cc 100644
--- a/client/src/hooks/useOnDemandTaskToast.js
+++ b/client/src/hooks/useOnDemandTaskToast.js
@@ -128,9 +128,26 @@ export function useOnDemandTaskToast() {
});
};
+ // A PROGRAMMATIC scheduled handler (universe bible descriptions/images)
+ // finishes its work inside the server — no agent task appears in the CoS
+ // queue for the user to watch — so its outcome is the only feedback the Run
+ // Now button can produce. `dispatched: false` is a decline, not a failure
+ // (nothing to do, or a setting that needs picking), so it stays calm and
+ // always names the handler's own reason rather than a generic gloss.
+ const handleHandled = (data) => {
+ const task = data?.taskType || 'task';
+ if (data?.dispatched) {
+ toast(data.summary || `${task}: done.`, { duration: 7000, icon: '✅' });
+ return;
+ }
+ toast(`${task}: ${data?.reason || 'nothing to do right now'}.`, { duration: 7000, icon: '💤' });
+ };
+
socket.on('cos:schedule:on-demand-empty', handleEmpty);
+ socket.on('cos:schedule:on-demand-handled', handleHandled);
return () => {
socket.off('cos:schedule:on-demand-empty', handleEmpty);
+ socket.off('cos:schedule:on-demand-handled', handleHandled);
// Don't unsubscribe from cos — other components share the room.
};
}, []);
diff --git a/client/src/hooks/useOnDemandTaskToast.test.jsx b/client/src/hooks/useOnDemandTaskToast.test.jsx
index d064ea21f3..f144532c6f 100644
--- a/client/src/hooks/useOnDemandTaskToast.test.jsx
+++ b/client/src/hooks/useOnDemandTaskToast.test.jsx
@@ -194,3 +194,34 @@ describe('useOnDemandTaskToast — parked outcome', () => {
expect(msg).toMatch(/0 of 10 open/);
});
});
+
+describe('useOnDemandTaskToast — programmatic handler results', () => {
+ beforeEach(() => { handlers.clear(); toastSpy.mockClear(); });
+ afterEach(cleanup);
+
+ const fireHandled = (payload) => handlers.get('cos:schedule:on-demand-handled')?.(payload);
+
+ it('reports what the handler actually did — no agent task appears to watch', () => {
+ renderHook(() => useOnDemandTaskToast());
+ fireHandled({
+ taskType: 'universe-bible-describe', dispatched: true,
+ summary: 'Described 3 of 3 bible entries (7 fields) in "Example Universe"',
+ });
+ const [msg, opts] = toastSpy.mock.calls[0];
+ expect(msg).toMatch(/Described 3 of 3 bible entries/);
+ expect(opts.icon).toBe('✅');
+ });
+
+ it('names the handler\'s own reason when it declines', () => {
+ // A decline is not a failure — nothing to do, or a setting that needs
+ // picking — so it stays calm but must not be a silent no-op.
+ renderHook(() => useOnDemandTaskToast());
+ fireHandled({
+ taskType: 'universe-bible-images', dispatched: false,
+ summary: null, reason: 'no bible entries are missing images',
+ });
+ const [msg, opts] = toastSpy.mock.calls[0];
+ expect(msg).toMatch(/no bible entries are missing images/);
+ expect(opts.icon).toBe('💤');
+ });
+});
diff --git a/docs/QUOTA-BURN.md b/docs/QUOTA-BURN.md
index 498eecef9d..62bda0be2e 100644
--- a/docs/QUOTA-BURN.md
+++ b/docs/QUOTA-BURN.md
@@ -119,6 +119,15 @@ its own type, optional model/provider pin, and type-specific params.
| `universe-bible-describe` | **Programmatic** — no agent. Sends one headless expand prompt per under-described bible entry, pinned to the burning family's own CLI/TUI provider. |
| `universe-bible-images` | **Programmatic** — no agent. Enqueues renders for universe bible entries whose `imageRefs[]` is empty. Render backend defaults to the burning family's own image mode, so a codex burn spends codex's image quota. |
+Both programmatic types are also ordinary **on-demand scheduled tasks** — they
+appear in CoS → Schedule with their own settings and a Run Now button, and a burn
+step and a manual run go through the same handler
+(`server/services/scheduledHandlers/`). They are never clock-due: a fresh install
+spends nothing on them until someone presses Run or adds one to a burn plan.
+Passing the burning `family` is what pins the provider / render backend to the
+subscription being drained; a manual run has no family and resolves the way any
+other scheduled task does.
+
### Describe before you render
`universe-bible-describe` is the step that belongs **before** `universe-bible-images`
@@ -151,7 +160,7 @@ Locked entries are never picked, and every attempted entry is stamped into the
shared in-flight ledger for its 6-hour TTL. Why picks are ranked by blank
*fraction* rather than raw gap count, and why the stamp covers entries the model
declined to fill, are argued at the code site
-(`server/services/quotaBurnJobs/universeBibleDescribe.js`).
+(`server/services/scheduledHandlers/universeBibleDescribe.js`).
The image job's opt-in `requireDescribed` is the other half of the pairing: with
it on, canon entries with no `core` description are held out of the render
@@ -216,10 +225,23 @@ accord instead of looping.
dispatches nothing — the next cycle still faces every gate.
Adding a job type is three edits: a `QUOTA_BURN_JOB_TYPE` entry + catalog row in
-`server/lib/quotaBurnConfig.js`, a module in `server/services/quotaBurnJobs/`, and
-one line in that directory's `JOB_MODULES`. The config page builds its form from
-the catalog, so no client change is needed unless the job introduces a param kind
-the form doesn't render yet.
+`server/lib/quotaBurnConfig.js`, a module, and one line in
+`server/services/quotaBurnJobs/index.js`'s `JOB_MODULES`. The config page builds
+its form from the catalog, so no client change is needed unless the job
+introduces a param kind the form doesn't render yet.
+
+**A PROGRAMMATIC job's module lives in `server/services/scheduledHandlers/`, not
+in `quotaBurnJobs/`.** The two universe-bible actions are ordinary **on-demand
+scheduled tasks** (CoS → Schedule → Run Now) that Quota Burn also dispatches;
+`JOB_MODULES` points at the same handler, so there is one implementation rather
+than a quota-only copy that drifts. A new burn action that PortOS performs itself
+belongs there too — register it in `SCHEDULED_HANDLER_MODULES`, add its task type
+to `PROGRAMMATIC_SCHEDULED_TASK_TYPES` + `DEFAULT_TASK_INTERVALS` in
+`server/services/taskScheduleRegistry.js` (enabled, `ON_DEMAND`, no interval), and
+allow-list its params in `sanitizeTaskMetadata`. The scheduled task's saved
+`taskMetadata` is the params bag; a burn step passes its own `params` plus the
+burning `family`, which is what pins the provider/render backend to that
+subscription. Listing or probing must spend nothing.
Each job module exports `countPending` (side-effect free — the page calls it on
every load) and `run` (the only thing that may spend quota). `countPending` may
@@ -360,7 +382,8 @@ folds those overrides into the single plan (each app's family prompt becomes an
| `server/services/quotaBurn.js` | `evaluateFamily` — the one gate ladder both selection and the page's skip reasons read — plus the dispatch ledger |
| `server/services/quotaBurnCompletions.js` | The `run once` completion ledger and its re-arm |
| `server/services/quotaBurnDenials.js` | The observed-refusal ledger and its `agent:completed` subscriber |
-| `server/services/quotaBurnJobs/` | The job registry and its modules |
+| `server/services/quotaBurnJobs/` | The burn job registry and the `agent-prompt` executor |
+| `server/services/scheduledHandlers/` | The programmatic handlers (universe bible descriptions/images) — shared by Scheduled Tasks and Quota Burn |
| `server/services/quotaBurnRunner.js` | The loop, the cycle, and the status feed |
| `server/routes/quotaBurn.js` | `/api/quota-burn` |
| `client/src/pages/QuotaBurn.jsx` | The config page |
diff --git a/server/lib/cosValidation.js b/server/lib/cosValidation.js
index 10e19fce70..fd679ce2a7 100644
--- a/server/lib/cosValidation.js
+++ b/server/lib/cosValidation.js
@@ -14,6 +14,7 @@ import { isPlainObject } from './objects.js';
import { EFFORT_LEVELS } from './providerModels.js';
import { isValidSlashdoCommand } from './slashdoInvocation.js';
import { PR_COMPLETION_VALUES } from './prDisposition.js';
+import { QUEUEABLE_IMAGE_MODES } from './generationModes.js';
import { PUBLIC_REVIEW_EXECUTION_PROFILES } from './agentExecutionProfiles.js';
import { ORCHESTRATION_MODES, ORCHESTRATION_ROLES } from './orchestrationProfile.js';
import { AGENT_RUN_EVENT_KINDS, RUN_EVENT_READ_LIMITS } from './agentRunEvents.js';
@@ -781,9 +782,37 @@ const ALLOWED_TASK_METADATA_KEYS = [
// Dispatch gate: when true, the generated system task is always awaiting-
// approve — including an explicit Run Now. Absent/false keeps the default
// (Run Now consents; unattended runs follow confidence/safety-kind).
- 'requireApproval'
+ 'requireApproval',
+ // `universe-bible-images`: hold canon entries that have no description yet
+ // out of the render batch, so image quota isn't spent on a name with nothing
+ // behind it. See services/scheduledHandlers/universeBibleImages.js.
+ 'requireDescribed'
];
+// The params bag for the PROGRAMMATIC scheduled handlers
+// (services/scheduledHandlers/) is stored as taskMetadata, so its non-boolean
+// keys need the same constrained treatment as `verifyMode` / `branchesPerAgent`
+// below — a hand-edited schedule must not smuggle an arbitrary string into a
+// value the handler feeds to a store lookup or a render backend.
+//
+// `scope` accepts the UNION of the two handlers' option lists: task metadata is
+// keyed by task type but sanitized by one type-agnostic function, and each
+// handler already normalizes a scope it doesn't recognize back to 'all'
+// (`SCOPE_KINDS[scope] || SCOPE_KINDS.all`, `wantsScope`). Widening here can
+// therefore only accept a value the wrong handler ignores, never run the wrong
+// work — which is why the enum stays explicit rather than becoming a free string.
+export const BIBLE_HANDLER_SCOPES = ['all', 'characters', 'places', 'objects', 'variations', 'canon', 'sheets'];
+export const BIBLE_HANDLER_DEPTHS = ['core', 'full'];
+// Mirrors QUOTA_BURN_BOUNDS.maxEntries in lib/quotaBurnConfig.js — the same
+// range the burn job form advertises. Both doors bound the same batch.
+export const BIBLE_HANDLER_MAX_ENTRIES = { min: 1, max: 50 };
+// `universeId` is a universe collection-store id, or the literal 'all'. Held to
+// the store's OWN id alphabet (createCollectionStore's `idPattern`) so a
+// hand-edited schedule can't put a path segment where a record id belongs.
+const UNIVERSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
+const isBibleUniverseId = (value) =>
+ value === 'all' || (typeof value === 'string' && UNIVERSE_ID_PATTERN.test(value));
+
// pr-watcher author-gate values. 'self' = PRs opened by the gh-authenticated
// user (the PortOS operator / their automation); 'others' = everyone else;
// 'any' = no gate. Kept here so both the sanitizer and the prWatcher service
@@ -1052,6 +1081,34 @@ export function sanitizeTaskMetadata(raw) {
clean.branchesPerAgent = raw.branchesPerAgent;
hasKeys = true;
}
+ // The programmatic bible handlers' params bag (BIBLE_HANDLER_* above). Each
+ // key is dropped when it doesn't match, so a bad value falls back to the
+ // handler's own default instead of reaching a store lookup or a render
+ // backend. `mode` accepts only a QUEUEABLE image backend: `external` renders
+ // through a remote SD-API that batch rendering rejects downstream anyway, and
+ // storing it would advertise a setting that always fails at dispatch.
+ if (isBibleUniverseId(raw.universeId)) {
+ clean.universeId = raw.universeId;
+ hasKeys = true;
+ }
+ if (BIBLE_HANDLER_SCOPES.includes(raw.scope)) {
+ clean.scope = raw.scope;
+ hasKeys = true;
+ }
+ if (BIBLE_HANDLER_DEPTHS.includes(raw.depth)) {
+ clean.depth = raw.depth;
+ hasKeys = true;
+ }
+ if (Number.isInteger(raw.maxEntries)
+ && raw.maxEntries >= BIBLE_HANDLER_MAX_ENTRIES.min
+ && raw.maxEntries <= BIBLE_HANDLER_MAX_ENTRIES.max) {
+ clean.maxEntries = raw.maxEntries;
+ hasKeys = true;
+ }
+ if (QUEUEABLE_IMAGE_MODES.includes(raw.mode)) {
+ clean.mode = raw.mode;
+ hasKeys = true;
+ }
// Pipeline configuration is the one nested task-metadata shape. Keep only
// known stage fields and fail the whole update when a known field is malformed
// so a bad custom pipeline cannot silently lose its safety posture.
diff --git a/server/services/cos.js b/server/services/cos.js
index 74c1d6d52a..46ee084187 100644
--- a/server/services/cos.js
+++ b/server/services/cos.js
@@ -133,6 +133,7 @@ import {
generateManagedAppImprovementTaskForType,
recordDeferredPerpetualDispatch,
applyOnDemandConsent,
+ drainProgrammaticOnDemandRequests,
emitOnDemandEmpty,
blockIfExceedsMaxSpawns,
selectDryRunAutoApproved,
@@ -996,10 +997,19 @@ async function spawnDequeuePriority0OnDemand(ctx) {
// (avoids a second load).
ctx.taskSchedule = taskSchedule;
+ // Programmatic handlers first, and outside the slot-bounded loop below: they
+ // spawn nothing, so a full spawn budget must not hold a user's Run Now.
+ const handledProgrammatically = await drainProgrammaticOnDemandRequests({
+ taskScheduleMod, requests: onDemandRequests, schedule: taskSchedule, state
+ });
+
// Track apps already marked review-started this cycle so multiple on-demand
// requests for the same app don't each rewrite its activity record.
const reviewStartedApps = new Set();
for (const request of onDemandRequests) {
+ // Already handled above (and its request cleared) — `onDemandRequests` is a
+ // snapshot taken before that drain.
+ if (handledProgrammatically.has(request.id)) continue;
if (capacity.spawned >= capacity.availableSlots) break;
if (!isImprovementEnabled(state)) {
diff --git a/server/services/cosTaskGenerator.js b/server/services/cosTaskGenerator.js
index 9fbcce1948..ff9d939ece 100644
--- a/server/services/cosTaskGenerator.js
+++ b/server/services/cosTaskGenerator.js
@@ -61,7 +61,7 @@ import {
import { TIMED_COOLDOWN_BLOCKED_CATEGORIES } from '../lib/taskBlockCategories.js';
import { ServerError } from '../lib/errorHandler.js';
import { isReconcileDrainTaskType } from './taskScheduleConstants.js';
-import { requiresInstallWideTarget } from './taskScheduleRegistry.js';
+import { isProgrammaticScheduledTaskType, requiresInstallWideTarget } from './taskScheduleRegistry.js';
import {
appendClaimOverrideContext,
appendPrefetchedIssueContext,
@@ -775,10 +775,20 @@ async function spawnPriority0OnDemand(ctx) {
// names an unknown app and get cleared, silently dropping user-initiated
// work. On a failure we leave the requests queued for the next cycle.
const apps = onDemandRequests.length > 0 ? await getActiveApps().catch(() => null) : [];
+
+ // Programmatic handlers first, and outside the slot-bounded loop below: they
+ // spawn nothing, so a busy autonomy budget must not hold a user's Run Now.
+ const handledProgrammatically = await drainProgrammaticOnDemandRequests({
+ taskScheduleMod: taskSchedule, requests: onDemandRequests, schedule: liveSchedule, state
+ });
+
if (!apps) {
emitLog('warn', `On-demand requests deferred — the app registry could not be read this cycle`);
} else if (onDemandRequests.length > 0 && tasksToSpawn.length < availableSlots) {
for (const request of onDemandRequests) {
+ // Already handled above (and its request cleared) — `onDemandRequests` is
+ // a snapshot taken before that drain.
+ if (handledProgrammatically.has(request.id)) continue;
if (tasksToSpawn.length >= availableSlots) break;
if (!isImprovementEnabled(state)) {
@@ -2462,6 +2472,91 @@ function takePerpetualTransient(taskType, appId) {
return verdict;
}
+/**
+ * Drain the on-demand requests for PROGRAMMATIC scheduled task types
+ * (`services/scheduledHandlers/`) — the ones PortOS performs ITSELF.
+ *
+ * They never become a CoS task and never spawn an agent, so they are drained
+ * ahead of (and outside) the slot-bounded loops in the two on-demand engines:
+ * queueing a describe/render batch behind agent capacity it does not use would
+ * leave a user's explicit "Run Now" sitting until an unrelated agent finished.
+ *
+ * Both engines call this once per cycle and then skip the ids it returns in
+ * their own loop, so a request drained here is handled exactly once whichever
+ * engine gets there first (the request is cleared before the handler runs).
+ * Returning the ids rather than re-testing the task type keeps the engines from
+ * importing the handler registry statically — see server/AGENTS.md's import
+ * scoping rule — and covers requests the drain cleared WITHOUT running (the
+ * type was disabled, or Improve is off), which must not fall through either.
+ *
+ * `force: true` — a manual Run is explicit consent for THIS work, so the run
+ * ignores the shared in-flight cooldown that stops the burn rotation re-picking
+ * rows whose render hasn't landed yet. `context` is deliberately omitted: with
+ * no probe to reuse, `run` does its own scan, which is the contract's
+ * `context: undefined` path.
+ *
+ * Returns the ids of the requests it took responsibility for. Never throws —
+ * `runScheduledHandler` converts a handler throw into a non-dispatch, and this
+ * runs outside the request lifecycle.
+ */
+export async function drainProgrammaticOnDemandRequests({ taskScheduleMod, requests, schedule, state }) {
+ const handled = new Set();
+ const pending = (requests || []).filter((request) => isProgrammaticScheduledTaskType(request.taskType));
+ if (pending.length === 0) return handled;
+
+ const { runScheduledHandler } = await import('./scheduledHandlers/index.js');
+ for (const request of pending) {
+ const taskConfig = schedule?.tasks?.[request.taskType];
+ handled.add(request.id);
+ if (!isImprovementEnabled(state)) {
+ emitLog('warn', `On-demand request dropped — improvement is disabled (Config → Improve)`, { requestId: request.id, taskType: request.taskType });
+ await taskScheduleMod.clearOnDemandRequest(request.id);
+ continue;
+ }
+ // Parity with the agent engines: the type may have been disabled after the
+ // request was queued.
+ if (!taskConfig?.enabled) {
+ emitLog('info', `On-demand request skipped — task type '${request.taskType}' is disabled`, { requestId: request.id });
+ await taskScheduleMod.clearOnDemandRequest(request.id);
+ continue;
+ }
+
+ // CLAIM the request, and only run if this drain is the one that took it.
+ // The two engines are independent and each drains its own snapshot, so both
+ // can hold the same request; `clearOnDemandRequest` is a serialized
+ // read-modify-write that returns the record only to the caller that removed
+ // it, and returns null to the loser. Without this check one "Run Now" would
+ // spend two describe/render batches and toast twice — the agent path gets
+ // the same protection from `addTask`'s duplicate detection, which a handler
+ // that queues nothing has no equivalent of.
+ const claimed = await taskScheduleMod.clearOnDemandRequest(request.id);
+ if (!claimed) continue;
+
+ await taskScheduleMod.recordExecution(`task:${request.taskType}`);
+ const outcome = await runScheduledHandler({
+ taskType: request.taskType,
+ params: taskConfig.taskMetadata || {},
+ job: { model: taskConfig.model || null, effort: taskConfig.effort || null, providerId: taskConfig.providerId || null },
+ force: true,
+ });
+
+ emitLog(outcome?.dispatched ? 'info' : 'debug',
+ `${request.taskType}: ${outcome?.summary || outcome?.reason || 'nothing to do'}`,
+ { requestId: request.id });
+ // The client toasts this so an explicit Run isn't a silent no-op. Separate
+ // from `schedule:on-demand-empty`: that channel means "no agent task was
+ // produced", while this one reports work PortOS already finished.
+ cosEvents.emit('schedule:on-demand-handled', {
+ requestId: request.id,
+ taskType: request.taskType,
+ dispatched: outcome?.dispatched === true,
+ summary: outcome?.summary || null,
+ reason: outcome?.reason || null,
+ });
+ }
+ return handled;
+}
+
/**
* Surface WHY a user-initiated on-demand "Run" produced no task, so the trigger
* isn't a silent no-op the user only discovers in the pm2 logs. Emits
diff --git a/server/services/cosTaskGenerator.programmaticOnDemand.test.js b/server/services/cosTaskGenerator.programmaticOnDemand.test.js
new file mode 100644
index 0000000000..f1a3203ea2
--- /dev/null
+++ b/server/services/cosTaskGenerator.programmaticOnDemand.test.js
@@ -0,0 +1,156 @@
+/**
+ * The ORDINARY scheduled-task manual path for programmatic handlers (#6376).
+ *
+ * `universe-bible-describe` / `universe-bible-images` are the only scheduled
+ * types PortOS runs itself. A "Run Now" queues the normal on-demand request; the
+ * two dispatch engines then hand it to `drainProgrammaticOnDemandRequests`
+ * instead of generating an agent task. These cases pin the contract that makes
+ * that safe: the handler is invoked once with the task's own saved settings, a
+ * disabled/gated request is cleared without running anything, and the request is
+ * reported back to the engines so their agent loop skips it.
+ *
+ * Isolated file so the handler double can't leak into the shared
+ * cosTaskGenerator suite.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+const runScheduledHandler = vi.fn(async () => ({ dispatched: true, summary: 'Described 3 bible entries' }));
+vi.mock('./scheduledHandlers/index.js', () => ({
+ SCHEDULED_HANDLER_MODULES: {},
+ runScheduledHandler: (...args) => runScheduledHandler(...args),
+ countScheduledHandlerPending: vi.fn(),
+}));
+
+vi.mock('./cosEvents.js', () => ({
+ cosEvents: { emit: vi.fn(), on: vi.fn() },
+ emitLog: vi.fn(),
+}));
+
+const isImprovementEnabled = vi.fn(() => true);
+vi.mock('./cosState.js', async (importActual) => ({
+ ...(await importActual()),
+ isImprovementEnabled: (...args) => isImprovementEnabled(...args),
+}));
+
+const { drainProgrammaticOnDemandRequests } = await import('./cosTaskGenerator.js');
+const { cosEvents } = await import('./cosEvents.js');
+
+// Returns the removed record to the caller that actually took it, and null to a
+// loser — the claim the drain gates its run on.
+const clearOnDemandRequest = vi.fn(async (id) => ({ id }));
+const recordExecution = vi.fn(async () => {});
+const taskScheduleMod = { clearOnDemandRequest, recordExecution };
+
+const DESCRIBE_SETTINGS = { universeId: 'u1', scope: 'characters', depth: 'core', maxEntries: 4 };
+const schedule = (overrides = {}) => ({
+ tasks: {
+ 'universe-bible-describe': {
+ enabled: true, providerId: 'codex', model: 'gpt-5', effort: 'high',
+ taskMetadata: DESCRIBE_SETTINGS, ...overrides,
+ },
+ security: { enabled: true },
+ },
+});
+const request = (taskType = 'universe-bible-describe') => ({ id: 'demand-1', taskType });
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ isImprovementEnabled.mockReturnValue(true);
+ clearOnDemandRequest.mockImplementation(async (id) => ({ id }));
+ runScheduledHandler.mockResolvedValue({ dispatched: true, summary: 'Described 3 bible entries' });
+});
+
+describe('drainProgrammaticOnDemandRequests', () => {
+ it('runs the handler with the task\'s saved settings and no probe context', async () => {
+ const handled = await drainProgrammaticOnDemandRequests({
+ taskScheduleMod, requests: [request()], schedule: schedule(), state: {},
+ });
+
+ expect(handled.has('demand-1')).toBe(true);
+ expect(runScheduledHandler).toHaveBeenCalledWith({
+ taskType: 'universe-bible-describe',
+ params: DESCRIBE_SETTINGS,
+ job: { model: 'gpt-5', effort: 'high', providerId: 'codex' },
+ // A manual Run is explicit consent for THIS work, so it ignores the
+ // in-flight cooldown; with no probe to reuse the handler does its own scan.
+ force: true,
+ });
+ // The request is cleared BEFORE the handler runs, so a crash mid-run can't
+ // leave a request that re-fires the same batch on the next tick.
+ expect(clearOnDemandRequest).toHaveBeenCalledWith('demand-1');
+ expect(recordExecution).toHaveBeenCalledWith('task:universe-bible-describe');
+ });
+
+ it('reports what it did so an explicit Run is not a silent no-op', async () => {
+ await drainProgrammaticOnDemandRequests({
+ taskScheduleMod, requests: [request()], schedule: schedule(), state: {},
+ });
+ expect(cosEvents.emit).toHaveBeenCalledWith('schedule:on-demand-handled', {
+ requestId: 'demand-1',
+ taskType: 'universe-bible-describe',
+ dispatched: true,
+ summary: 'Described 3 bible entries',
+ reason: null,
+ });
+ });
+
+ it('passes a decline through as the handler\'s own reason', async () => {
+ runScheduledHandler.mockResolvedValue({ dispatched: false, reason: 'every bible entry is already described' });
+ await drainProgrammaticOnDemandRequests({
+ taskScheduleMod, requests: [request()], schedule: schedule(), state: {},
+ });
+ expect(cosEvents.emit).toHaveBeenCalledWith('schedule:on-demand-handled', expect.objectContaining({
+ dispatched: false, reason: 'every bible entry is already described', summary: null,
+ }));
+ });
+
+ it('clears without running when the type was disabled after the request was queued', async () => {
+ const handled = await drainProgrammaticOnDemandRequests({
+ taskScheduleMod, requests: [request()], schedule: schedule({ enabled: false }), state: {},
+ });
+ // Still "handled" — the engines must skip it, not fall through and try to
+ // generate an agent task for a type that has no prompt.
+ expect(handled.has('demand-1')).toBe(true);
+ expect(clearOnDemandRequest).toHaveBeenCalledWith('demand-1');
+ expect(runScheduledHandler).not.toHaveBeenCalled();
+ });
+
+ it('drops the request without spending when Improve is switched off', async () => {
+ isImprovementEnabled.mockReturnValue(false);
+ await drainProgrammaticOnDemandRequests({
+ taskScheduleMod, requests: [request()], schedule: schedule(), state: {},
+ });
+ expect(clearOnDemandRequest).toHaveBeenCalledWith('demand-1');
+ expect(runScheduledHandler).not.toHaveBeenCalled();
+ });
+
+ it('leaves ordinary agent task types to the engines', async () => {
+ const handled = await drainProgrammaticOnDemandRequests({
+ taskScheduleMod, requests: [request('security')], schedule: schedule(), state: {},
+ });
+ expect(handled.size).toBe(0);
+ expect(clearOnDemandRequest).not.toHaveBeenCalled();
+ // Nothing is imported or run for a request this drain does not own.
+ expect(runScheduledHandler).not.toHaveBeenCalled();
+ });
+});
+
+describe('drainProgrammaticOnDemandRequests — cross-engine claim', () => {
+ it('does not run when the sibling engine already took the request', async () => {
+ // Both on-demand engines drain their OWN snapshot with no shared lock, so
+ // the same request can be in both. `clearOnDemandRequest` returns the record
+ // only to the caller that removed it; a drain that ignored that would spend
+ // two describe/render batches (and toast twice) for one Run Now click.
+ clearOnDemandRequest.mockResolvedValue(null);
+
+ const handled = await drainProgrammaticOnDemandRequests({
+ taskScheduleMod, requests: [request()], schedule: schedule(), state: {},
+ });
+
+ expect(handled.has('demand-1')).toBe(true);
+ expect(runScheduledHandler).not.toHaveBeenCalled();
+ expect(recordExecution).not.toHaveBeenCalled();
+ expect(cosEvents.emit).not.toHaveBeenCalled();
+ });
+});
diff --git a/server/services/quotaBurnJobs/agentPrompt.js b/server/services/quotaBurnJobs/agentPrompt.js
index f607bf6ae9..54282b65b4 100644
--- a/server/services/quotaBurnJobs/agentPrompt.js
+++ b/server/services/quotaBurnJobs/agentPrompt.js
@@ -13,7 +13,7 @@
import { addTask } from '../cosTaskStore.js';
import { getAppById } from '../apps.js';
-import { noProviderReason, resolveBurnProvider } from './providerPick.js';
+import { noProviderReason, resolveBurnProvider } from '../scheduledHandlers/providerPick.js';
import { burnTaskDescription, isUnlimitedDispatchCap } from '../../lib/quotaBurnConfig.js';
import { windowLabelOf } from '../../lib/quotaWindows.js';
diff --git a/server/services/quotaBurnJobs/index.js b/server/services/quotaBurnJobs/index.js
index 321b351838..e25e80f7aa 100644
--- a/server/services/quotaBurnJobs/index.js
+++ b/server/services/quotaBurnJobs/index.js
@@ -24,18 +24,27 @@
* store + media job queue, and `agentPrompt` the CoS task store, so a status
* read for an install with no jobs configured should not load either.
*
- * Adding a job type is three edits: a `QUOTA_BURN_JOB_TYPE` entry + catalog row
- * in `lib/quotaBurnConfig.js` (that's what the config page renders), a module
- * here, and one line in `JOB_MODULES`. The client needs no change unless the
+ * The two PROGRAMMATIC job types are no longer implemented here. They are
+ * ordinary on-demand SCHEDULED TASKS (`services/scheduledHandlers/`) that the
+ * user can also run from CoS → Schedule; this registry keeps their entries and
+ * points them at that shared handler, passing the burning `family` so a burn
+ * stays pinned to the subscription it is draining. There is one implementation,
+ * not two. Retiring this registry in favour of scheduled-task references is
+ * #6381's job.
+ *
+ * Adding a burn job type is three edits: a `QUOTA_BURN_JOB_TYPE` entry + catalog
+ * row in `lib/quotaBurnConfig.js` (that's what the config page renders), a
+ * module, and one line in `JOB_MODULES`. The client needs no change unless the
* job introduces a param kind the form doesn't render yet.
*/
import { QUOTA_BURN_JOB_TYPE } from '../../lib/quotaBurnConfig.js';
+import { SCHEDULED_HANDLER_MODULES } from '../scheduledHandlers/index.js';
export const JOB_MODULES = {
[QUOTA_BURN_JOB_TYPE.AGENT_PROMPT]: () => import('./agentPrompt.js'),
- [QUOTA_BURN_JOB_TYPE.UNIVERSE_BIBLE_DESCRIBE]: () => import('./universeBibleDescribe.js'),
- [QUOTA_BURN_JOB_TYPE.UNIVERSE_BIBLE_IMAGES]: () => import('./universeBibleImages.js'),
+ [QUOTA_BURN_JOB_TYPE.UNIVERSE_BIBLE_DESCRIBE]: SCHEDULED_HANDLER_MODULES['universe-bible-describe'],
+ [QUOTA_BURN_JOB_TYPE.UNIVERSE_BIBLE_IMAGES]: SCHEDULED_HANDLER_MODULES['universe-bible-images'],
};
// `Object.hasOwn`, not a truthiness check, so an inherited key like
diff --git a/server/services/scheduledHandlers/index.js b/server/services/scheduledHandlers/index.js
new file mode 100644
index 0000000000..0c31fcb092
--- /dev/null
+++ b/server/services/scheduledHandlers/index.js
@@ -0,0 +1,87 @@
+/**
+ * Programmatic scheduled-task handlers.
+ *
+ * A handler is one unit of work PortOS performs ITSELF — no agent is spawned,
+ * no CoS task is queued, and no spawn slot is consumed. Each module exports:
+ *
+ * countPending({ params, job, family }) → { count, detail, context? }
+ * run({ params, job, family, context, force }) → { dispatched, summary?, reason?, detail? }
+ *
+ * `countPending` must be side-effect free — the Quota Burn config page calls it
+ * for every configured job on every load, and the Schedule page may probe a
+ * handler to show its backlog. It writes nothing, enqueues nothing, and makes NO
+ * AI provider call (AGENTS.md: no cold-bootstrap LLM calls). It may return an
+ * opaque `context` (whatever it already computed) which the caller hands straight
+ * back to `run`, so a probe that scanned every universe bible doesn't make `run`
+ * repeat the scan. `run` must still work with `context: undefined` — the manual
+ * "Run Now" path and Quota Burn's force path both call it without a probe.
+ *
+ * `run` is the only thing that may spend, and reports `dispatched: false` with a
+ * `reason` when it declines (nothing to do, misconfigured target) so a quota
+ * caller does NOT charge its window's dispatch cap for work that never happened.
+ *
+ * ONE implementation serves both invocation paths:
+ *
+ * - The ordinary scheduled-task manual path (CoS → Schedule → Run Now) drains
+ * an on-demand request for the task type and calls `runScheduledHandler`
+ * with the task's saved `taskMetadata` as its params and NO `family`.
+ * - Quota Burn's runner reaches the same modules through
+ * `quotaBurnJobs/index.js`, passing the burning `family` so the work stays
+ * pinned to that subscription (see `providerPick.js` /
+ * `universeBibleImages.resolveRenderMode`).
+ *
+ * `family` is therefore the discriminator between the two: present means "a burn
+ * is spending THIS family's window, never another's", absent means "an ordinary
+ * scheduled run — resolve the provider/backend the way any other task does".
+ *
+ * Modules are lazy-imported: `universeBibleImages` pulls the whole universe
+ * store + media job queue and `universeBibleDescribe` the expand services, so
+ * listing the registered types (or probing a type that isn't registered) must
+ * not load either.
+ */
+
+/**
+ * Task type → module import thunk. The single registration point.
+ *
+ * The matching TASK-TYPE list lives in `taskScheduleRegistry.js`
+ * (`PROGRAMMATIC_SCHEDULED_TASK_TYPES`) rather than being derived from these
+ * keys, because that registry is reached by a large share of the server suite
+ * and must not take on an import for a two-string list (server/AGENTS.md,
+ * "Import scoping"). `taskScheduleRegistry.programmatic.test.js` asserts the two
+ * agree, so the split cannot drift.
+ */
+export const SCHEDULED_HANDLER_MODULES = {
+ 'universe-bible-describe': () => import('./universeBibleDescribe.js'),
+ 'universe-bible-images': () => import('./universeBibleImages.js'),
+};
+
+// `Object.hasOwn`, not a truthiness check, so an inherited key like
+// 'constructor' can't resolve to a module.
+const load = async (taskType) =>
+ (typeof taskType === 'string' && Object.hasOwn(SCHEDULED_HANDLER_MODULES, taskType)
+ ? SCHEDULED_HANDLER_MODULES[taskType]()
+ : null);
+
+/**
+ * Pending-work probe for one handler. Never throws: a handler whose backing
+ * store is unavailable reports zero pending with the error as its detail, so one
+ * broken handler can't wedge a burn family's whole plan or blank a status page.
+ */
+export async function countScheduledHandlerPending({ taskType, params, job, family } = {}) {
+ const mod = await load(taskType);
+ if (!mod) return { count: 0, detail: `unknown scheduled handler: ${taskType}` };
+ return mod.countPending({ params, job, family })
+ .catch((err) => ({ count: 0, detail: `probe failed: ${err.message}` }));
+}
+
+/**
+ * Run one handler. Throws are converted to a non-dispatch: the caller treats it
+ * as "this handler declined", moves on, and logs the reason — work that failed
+ * to start must not charge a quota window's cap.
+ */
+export async function runScheduledHandler({ taskType, params, job, family, context, force = false } = {}) {
+ const mod = await load(taskType);
+ if (!mod) return { dispatched: false, reason: `unknown scheduled handler: ${taskType}` };
+ return mod.run({ params, job, family, context, force })
+ .catch((err) => ({ dispatched: false, reason: `handler failed: ${err.message}` }));
+}
diff --git a/server/services/quotaBurnJobs/providerPick.js b/server/services/scheduledHandlers/providerPick.js
similarity index 100%
rename from server/services/quotaBurnJobs/providerPick.js
rename to server/services/scheduledHandlers/providerPick.js
diff --git a/server/services/quotaBurnJobs/providerPick.test.js b/server/services/scheduledHandlers/providerPick.test.js
similarity index 100%
rename from server/services/quotaBurnJobs/providerPick.test.js
rename to server/services/scheduledHandlers/providerPick.test.js
diff --git a/server/services/quotaBurnJobs/universeBacklog.js b/server/services/scheduledHandlers/universeBacklog.js
similarity index 100%
rename from server/services/quotaBurnJobs/universeBacklog.js
rename to server/services/scheduledHandlers/universeBacklog.js
diff --git a/server/services/quotaBurnJobs/universeBibleDescribe.js b/server/services/scheduledHandlers/universeBibleDescribe.js
similarity index 72%
rename from server/services/quotaBurnJobs/universeBibleDescribe.js
rename to server/services/scheduledHandlers/universeBibleDescribe.js
index 2dbc226313..f62729c051 100644
--- a/server/services/quotaBurnJobs/universeBibleDescribe.js
+++ b/server/services/scheduledHandlers/universeBibleDescribe.js
@@ -1,10 +1,13 @@
/**
- * Burn job — fill in the blanks on universe bible entries that are named but
- * not actually described.
+ * Scheduled handler `universe-bible-describe` — fill in the blanks on universe
+ * bible entries that are named but not actually described.
*
* PROGRAMMATIC: no agent is spawned. PortOS sends one headless expand prompt per
- * entry through the stage runner, pinned to the burning family's own CLI/TUI
- * provider, so a `codex` burn spends CODEX's subscription window.
+ * entry through the stage runner. Run from CoS → Schedule it uses the task's own
+ * provider pin (or, unpinned, the install's active provider, like any other
+ * task); run as a Quota Burn step it is pinned to the burning family's own
+ * CLI/TUI provider, so a `codex` burn spends CODEX's subscription window. Both
+ * paths go through this one implementation — see `scheduledHandlers/index.js`.
*
* This is the step that belongs BEFORE `universe-bible-images` in a plan: an
* image rendered from a character row holding only a name is a generic figure
@@ -108,28 +111,45 @@ async function collect(params, inFlight = new Set()) {
}
/**
- * The provider this job would prompt through, or null when the family has none.
+ * Which provider this handler prompts through, as `{ providerId, reason }`.
*
- * Same rule the image job applies to render backends: a family with no usable
- * provider must NOT fall through to the install's active provider, or the expand
- * calls would spend a DIFFERENT subscription while this family's window expires
- * unused and its dispatch cap is charged for the privilege.
+ * `family` is the discriminator (see `scheduledHandlers/index.js`):
+ *
+ * - A QUOTA BURN passes one, and the pin is the whole point: a family with no
+ * usable provider must NOT fall through to the install's active provider, or
+ * the expand calls would spend a DIFFERENT subscription while this family's
+ * window expires unused and its dispatch cap is charged for the privilege.
+ * No match ⇒ `providerId: null` plus the `reason` the caller reports.
+ * - An ORDINARY SCHEDULED RUN passes none. There is no window to pin to, so
+ * the task's own pin wins and an unpinned run leaves `providerId` null for
+ * the stage runner to resolve the active provider — exactly what every other
+ * scheduled task does. Never a refusal: "no pin" is a valid configuration.
+ *
+ * Returning the reason alongside (rather than a bare null) keeps "this family
+ * cannot burn" distinguishable from "nothing was pinned", which is the same
+ * sentinel-vs-empty rule the rest of the resolution paths follow.
*/
-export const resolveDescribeProvider = ({ job, family }) =>
+export async function resolveDescribeProvider({ job, family } = {}) {
+ if (!family?.id) return { providerId: job?.providerId || null };
// Headless one-shot prompts, not a watchable agent session — see `providerForFamily`.
- resolveBurnProvider({ job, family, prefer: 'cli' });
+ const provider = await resolveBurnProvider({ job, family, prefer: 'cli' });
+ return provider ? { providerId: provider.id } : { providerId: null, reason: noProviderReason(family) };
+}
+
+/** How a run reports the provider it used when nothing was pinned. */
+const providerLabel = (providerId) => providerId || 'the active provider';
export async function countPending({ params, job, family } = {}) {
- const provider = await resolveDescribeProvider({ job, family });
- if (!provider) return { count: 0, detail: noProviderReason(family) };
+ const resolved = await resolveDescribeProvider({ job, family });
+ if (resolved.reason) return { count: 0, detail: resolved.reason };
const collected = await collect(params, await getQuotaBurnInFlight());
const { picked, total, depth } = collected;
const next = picked?.rows.length || 0;
return {
count: total,
- // Handed back to run() by the runner so the bible scan + provider lookup
+ // Handed back to run() by the caller so the bible scan + provider lookup
// happen once per dispatch instead of twice — see the registry's contract.
- context: { ...collected, provider },
+ context: { ...collected, providerId: resolved.providerId, providerResolved: true },
detail: total
? `${total} bible ${total === 1 ? 'entry is' : 'entries are'} under-described (${depth}) — ${next} queued next from "${picked.universeName}"`
: `every bible entry is described (${depth}) or was just attempted`,
@@ -155,8 +175,14 @@ const expandRow = (universeId, row, options) => (row.kind === BIBLE_KIND.CHARACT
* went unreported).
*/
export async function run({ params, job, family, context, force = false } = {}) {
- const provider = context?.provider ?? await resolveDescribeProvider({ job, family });
- if (!provider) return { dispatched: false, reason: noProviderReason(family) };
+ // `providerResolved` is the sentinel, not `providerId` truthiness: an ordinary
+ // scheduled run legitimately resolves to a NULL pin, and reading that as "the
+ // probe didn't resolve one" would repeat the provider lookup on every dispatch.
+ const resolved = context?.providerResolved === true
+ ? { providerId: context.providerId }
+ : await resolveDescribeProvider({ job, family });
+ if (resolved.reason) return { dispatched: false, reason: resolved.reason };
+ const { providerId } = resolved;
// Reuse the probe's scan when the runner supplied it; the page's force path
// calls run() with no probe, so fall back to scanning here. A forced run
@@ -165,7 +191,7 @@ export async function run({ params, job, family, context, force = false } = {})
const { picked, total, max, depth } = collected;
if (!picked) return { dispatched: false, reason: 'every bible entry is already described' };
- const options = { providerId: provider.id, model: job?.model || undefined, effort: job?.effort || undefined };
+ const options = { providerId: providerId || undefined, model: job?.model || undefined, effort: job?.effort || undefined };
const outcome = { described: 0, fields: 0, skipped: 0, failed: 0 };
const failures = [];
for (const row of picked.rows) {
@@ -188,7 +214,7 @@ export async function run({ params, job, family, context, force = false } = {})
// and return BEFORE the cooldown stamp: a provider that was down for one tick
// must not park this batch for the ledger's whole six-hour TTL.
if (outcome.described === 0 && outcome.failed === picked.rows.length) {
- return { dispatched: false, reason: `every expand failed via ${provider.id} — ${failures[0] || 'unknown error'}` };
+ return { dispatched: false, reason: `every expand failed via ${providerLabel(providerId)} — ${failures[0] || 'unknown error'}` };
}
// Stamp EVERY attempted entry of a batch that got somewhere, including the
@@ -199,13 +225,13 @@ export async function run({ params, job, family, context, force = false } = {})
// never reach the rest.
await recordQuotaBurnInFlight(picked.rows.map((row) => describeInFlightKey(picked.universeId, row.kind, row.id)));
- console.log(`🔥 Quota-burn describe "${picked.universeName}" via ${provider.id} — described=${outcome.described} fields=${outcome.fields} skipped=${outcome.skipped} failed=${outcome.failed}`);
+ console.log(`📖 Bible describe "${picked.universeName}" via ${providerLabel(providerId)} — described=${outcome.described} fields=${outcome.fields} skipped=${outcome.skipped} failed=${outcome.failed}`);
return {
dispatched: true,
- summary: `Described ${outcome.described} of ${picked.rows.length} bible entr${picked.rows.length === 1 ? 'y' : 'ies'} (${outcome.fields} field${outcome.fields === 1 ? '' : 's'}) in "${picked.universeName}" via ${provider.id}`,
+ summary: `Described ${outcome.described} of ${picked.rows.length} bible entr${picked.rows.length === 1 ? 'y' : 'ies'} (${outcome.fields} field${outcome.fields === 1 ? '' : 's'}) in "${picked.universeName}" via ${providerLabel(providerId)}`,
detail: {
universeId: picked.universeId,
- providerId: provider.id,
+ providerId: providerId || null,
model: job?.model || null,
effort: job?.effort || null,
depth,
diff --git a/server/services/quotaBurnJobs/universeBibleDescribe.test.js b/server/services/scheduledHandlers/universeBibleDescribe.test.js
similarity index 75%
rename from server/services/quotaBurnJobs/universeBibleDescribe.test.js
rename to server/services/scheduledHandlers/universeBibleDescribe.test.js
index 8f6c32de27..00302c7cef 100644
--- a/server/services/quotaBurnJobs/universeBibleDescribe.test.js
+++ b/server/services/scheduledHandlers/universeBibleDescribe.test.js
@@ -173,3 +173,58 @@ describe('run', () => {
expect(expandUniverseCharacter).toHaveBeenCalledWith('u1', 'c1', { providerId: 'codex-tui', model: 'gpt-5' });
});
});
+
+describe('probe → run context handoff', () => {
+ it('reuses the probe\'s scan instead of walking every universe again', async () => {
+ // The contract in scheduledHandlers/index.js: an expensive probe hands its
+ // result to the run. Counting the universe walk is the only way to see it —
+ // a run that re-scanned would produce the same output and cost twice.
+ const probe = await countPending({ params: { maxEntries: 2 }, family: { id: 'codex' } });
+ listUniverses.mockClear();
+ getAllProviders.mockClear();
+
+ const result = await run({ params: { maxEntries: 2 }, job: {}, family: { id: 'codex' }, context: probe.context });
+
+ expect(result.dispatched).toBe(true);
+ expect(listUniverses).not.toHaveBeenCalled();
+ expect(getAllProviders).not.toHaveBeenCalled();
+ });
+
+ it('still runs when there is no probe context — the manual/force path', async () => {
+ const result = await run({ params: { maxEntries: 2 }, job: {}, family: { id: 'codex' }, context: undefined });
+ expect(result.dispatched).toBe(true);
+ expect(listUniverses).toHaveBeenCalled();
+ });
+
+ it('probes without writing, enqueueing, or calling a provider', async () => {
+ // Listing/probing must spend nothing: the Quota Burn page probes every
+ // configured job on every load (AGENTS.md — no cold-bootstrap LLM calls).
+ await countPending({ params: {}, family: { id: 'codex' } });
+ expect(expandUniverseCharacter).not.toHaveBeenCalled();
+ expect(expandUniverseCanonEntry).not.toHaveBeenCalled();
+ expect(recordQuotaBurnInFlight).not.toHaveBeenCalled();
+ });
+});
+
+describe('ordinary scheduled run (no burning family)', () => {
+ it('probes without refusing when nothing is pinned', async () => {
+ // There is no window to protect, so "no pin" is a valid configuration —
+ // reporting the family refusal here would make Run Now permanently dead.
+ getAllProviders.mockResolvedValue([]);
+ const result = await countPending({ params: { maxEntries: 2 }, job: {} });
+ expect(result.count).toBe(4);
+ expect(result.detail).toContain('2 queued next');
+ });
+
+ it('leaves the provider unset so the stage runner resolves the active one', async () => {
+ const result = await run({ params: { maxEntries: 1 }, job: {} });
+ expect(result.dispatched).toBe(true);
+ expect(expandUniverseCharacter).toHaveBeenCalledWith('u1', 'c1', { providerId: undefined, model: undefined });
+ expect(result.detail.providerId).toBeNull();
+ });
+
+ it('honors the scheduled task\'s own provider and model pin', async () => {
+ await run({ params: { maxEntries: 1 }, job: { providerId: 'codex-tui', model: 'gpt-5' } });
+ expect(expandUniverseCharacter).toHaveBeenCalledWith('u1', 'c1', { providerId: 'codex-tui', model: 'gpt-5' });
+ });
+});
diff --git a/server/services/quotaBurnJobs/universeBibleImages.js b/server/services/scheduledHandlers/universeBibleImages.js
similarity index 74%
rename from server/services/quotaBurnJobs/universeBibleImages.js
rename to server/services/scheduledHandlers/universeBibleImages.js
index 1740468d0c..f46528d145 100644
--- a/server/services/quotaBurnJobs/universeBibleImages.js
+++ b/server/services/scheduledHandlers/universeBibleImages.js
@@ -1,11 +1,16 @@
/**
- * Burn job — render images for universe bible entries that have none.
+ * Scheduled handler `universe-bible-images` — render images for universe bible
+ * entries that have none.
*
* PROGRAMMATIC: no agent is spawned. PortOS compiles the missing entries' render
* prompts itself and enqueues them on the media job queue, exactly as the
- * Universe Builder's "Render" button does. The quota it burns is the CLOUD IMAGE
- * backend's (codex `image_gen`, grok `image_gen`, agy `generate_image`), which is
- * why the job's render backend defaults to the burning family's own mode.
+ * Universe Builder's "Render" button does. The quota a render spends is the
+ * CLOUD IMAGE backend's (codex `image_gen`, grok `image_gen`, agy
+ * `generate_image`), which is why a Quota Burn step's render backend defaults to
+ * the burning family's own mode. Run from CoS → Schedule there is no family to
+ * pin to, so an unset backend falls through to the universe-bible render-target
+ * ladder — see `resolveRenderMode`. Both paths go through this one
+ * implementation; see `scheduledHandlers/index.js`.
*
* "Has no image" means the entry's `imageRefs[]` is empty — the same array the
* collection hook appends a finished render's filename to. An entry that has
@@ -144,25 +149,38 @@ function dedupeByLabel(rows) {
}
/**
- * The image backend this job would render through, or null when it cannot
- * resolve one it is willing to use.
+ * The image backend to render through, as `{ mode, reason }`.
*
- * A family with no cloud image mode of its own (`claude` — it renders no
- * images) must NOT silently fall through to the install default: the renders
- * would spend a DIFFERENT provider's image quota while this family's window
- * expires unused and its dispatch cap is charged for the privilege. Pinning the
- * backend to the burning family is the entire point of the job, so a family
- * that can't be pinned needs an explicit `params.mode` or nothing happens.
+ * `mode` is the backend id, or `undefined` for "let the universe-bible
+ * render-target ladder decide" (what `renderUniverseJobs` does with no `mode`).
+ * `reason` is set — and `mode` left null — only when the handler must REFUSE.
+ * An explicit sentinel rather than a bare null because "nothing pinned, use the
+ * install default" and "this family cannot render, do not spend" are opposite
+ * outcomes that a single falsy value would collapse.
+ *
+ * An explicit `params.mode` always wins. Otherwise `family` decides:
+ *
+ * - A QUOTA BURN passes one, and a family with no cloud image mode of its own
+ * (`claude` — it renders no images) must NOT silently fall through to the
+ * install default: the renders would spend a DIFFERENT provider's image quota
+ * while this family's window expires unused and its dispatch cap is charged
+ * for the privilege. Pinning the backend to the burning family is the entire
+ * point, so a family that can't be pinned needs an explicit `params.mode` or
+ * nothing happens.
+ * - An ORDINARY SCHEDULED RUN passes none. There is no window to protect, so
+ * an unset backend behaves like every other render PortOS enqueues.
*/
-export function resolveRenderMode({ params, family }) {
- if (typeof params?.mode === 'string' && params.mode) return params.mode;
- return CLOUD_IMAGE_GEN_MODES.includes(family?.id) ? family.id : null;
+export function resolveRenderMode({ params, family } = {}) {
+ if (typeof params?.mode === 'string' && params.mode) return { mode: params.mode };
+ if (!family?.id) return { mode: undefined };
+ return CLOUD_IMAGE_GEN_MODES.includes(family.id)
+ ? { mode: family.id }
+ : { mode: null, reason: `${family.id} renders no images — pick a render backend on this job` };
}
export async function countPending({ params, family } = {}) {
- if (!resolveRenderMode({ params, family })) {
- return { count: 0, detail: `${family?.id} renders no images — pick a render backend on this job` };
- }
+ const resolvedMode = resolveRenderMode({ params, family });
+ if (resolvedMode.reason) return { count: 0, detail: resolvedMode.reason };
const inFlight = await getQuotaBurnInFlight();
const collected = await collect(params, inFlight);
const { picked, total, requireDescribed } = collected;
@@ -182,16 +200,17 @@ export async function countPending({ params, family } = {}) {
}
/**
- * Enqueue the next batch. The render backend resolves as
- * `params.mode` → the burning family's own image mode (when it has one) →
- * whatever the universe-bible render-target ladder decides. Pinning to the
- * family by default is the point of the job: a `codex` burn should spend
- * CODEX's image quota, not silently fall through to the install default and
- * burn a different provider's.
+ * Enqueue the next batch. The render backend resolves as `params.mode` → the
+ * burning family's own image mode (a burn with a family that has one) →
+ * whatever the universe-bible render-target ladder decides (an ordinary
+ * scheduled run). Pinning to the family by default is the point of a BURN: a
+ * `codex` burn should spend CODEX's image quota, not silently fall through to
+ * the install default and burn a different provider's — a family with no image
+ * mode is refused rather than redirected. See `resolveRenderMode`.
*/
export async function run({ params, job, family, context, force = false } = {}) {
- const mode = resolveRenderMode({ params, family });
- if (!mode) return { dispatched: false, reason: `${family?.id} renders no images — pick a render backend on this job` };
+ const { mode, reason } = resolveRenderMode({ params, family });
+ if (reason) return { dispatched: false, reason };
// Reuse the probe's scan when the runner supplied it; the page's force path
// calls run() with no probe, so fall back to scanning here.
@@ -218,7 +237,7 @@ export async function run({ params, job, family, context, force = false } = {})
// window's whole cap re-rendering them.
await recordQuotaBurnInFlight(picked.rows.map((row) => inFlightKey(picked.universeId, row)));
- console.log(`🔥 Quota-burn rendered ${result.promptCount} bible image(s) for "${picked.universeName}" via ${result.mode}`);
+ console.log(`🖼️ Bible images: rendered ${result.promptCount} bible image(s) for "${picked.universeName}" via ${result.mode}`);
return {
dispatched: true,
summary: `Queued ${result.promptCount} image render${result.promptCount === 1 ? '' : 's'} for "${picked.universeName}" via ${result.mode}`,
diff --git a/server/services/quotaBurnJobs/universeBibleImages.test.js b/server/services/scheduledHandlers/universeBibleImages.test.js
similarity index 61%
rename from server/services/quotaBurnJobs/universeBibleImages.test.js
rename to server/services/scheduledHandlers/universeBibleImages.test.js
index 7ec0a9f2d0..bfe67555b5 100644
--- a/server/services/quotaBurnJobs/universeBibleImages.test.js
+++ b/server/services/scheduledHandlers/universeBibleImages.test.js
@@ -1,5 +1,26 @@
-import { describe, expect, it } from 'vitest';
-import { buildRenderSelection, countPending, findMissingImageEntries, inFlightKey, resolveRenderMode } from './universeBibleImages.js';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const listUniverses = vi.fn();
+const getUniverse = vi.fn();
+const renderUniverseJobs = vi.fn();
+const getQuotaBurnInFlight = vi.fn(async () => new Set());
+const recordQuotaBurnInFlight = vi.fn(async () => {});
+
+vi.mock('../universeBuilder.js', () => ({
+ listUniverses: (...args) => listUniverses(...args),
+ getUniverse: (...args) => getUniverse(...args),
+}));
+vi.mock('../universeBuilderRender.js', () => ({
+ renderUniverseJobs: (...args) => renderUniverseJobs(...args),
+}));
+vi.mock('../quotaBurnStore.js', () => ({
+ getQuotaBurnInFlight: (...args) => getQuotaBurnInFlight(...args),
+ recordQuotaBurnInFlight: (...args) => recordQuotaBurnInFlight(...args),
+}));
+
+const {
+ buildRenderSelection, countPending, findMissingImageEntries, inFlightKey, resolveRenderMode, run,
+} = await import('./universeBibleImages.js');
const universe = {
id: 'u1',
@@ -84,10 +105,20 @@ describe('resolveRenderMode', () => {
// `claude` renders no images. Falling through would spend a DIFFERENT
// provider's image quota while claude's window expires unused — and charge
// claude's dispatch cap for it, the exact inversion the pin exists to stop.
- expect(resolveRenderMode({ family: { id: 'claude' }, params: {} })).toBeNull();
- expect(resolveRenderMode({ family: { id: 'codex' }, params: {} })).toBe('codex');
+ expect(resolveRenderMode({ family: { id: 'claude' }, params: {} }))
+ .toMatchObject({ mode: null, reason: expect.stringContaining('renders no images') });
+ expect(resolveRenderMode({ family: { id: 'codex' }, params: {} })).toEqual({ mode: 'codex' });
// An explicit pin on the job always wins.
- expect(resolveRenderMode({ family: { id: 'claude' }, params: { mode: 'grok' } })).toBe('grok');
+ expect(resolveRenderMode({ family: { id: 'claude' }, params: { mode: 'grok' } })).toEqual({ mode: 'grok' });
+ });
+
+ it('lets the render-target ladder decide when there is no burning family', () => {
+ // An ordinary scheduled run has no window to protect, so an unset backend
+ // must resolve to "let renderUniverseJobs pick" (mode undefined) rather than
+ // to the family refusal — the two are opposite outcomes, which is why the
+ // helper returns an object instead of a single falsy value.
+ expect(resolveRenderMode({ params: {} })).toEqual({ mode: undefined });
+ expect(resolveRenderMode({ params: { mode: 'codex' } })).toEqual({ mode: 'codex' });
});
});
@@ -121,3 +152,39 @@ describe('label deduping', () => {
expect(inFlightKey('u2', row('Skiff'))).not.toBe(inFlightKey('u2', row('Barge')));
});
});
+
+describe('probe → run context handoff', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getQuotaBurnInFlight.mockResolvedValue(new Set());
+ listUniverses.mockResolvedValue([universe]);
+ renderUniverseJobs.mockResolvedValue({ runId: 'run-1', jobIds: ['j1'], promptCount: 2, mode: 'codex' });
+ });
+
+ it('reuses the probe\'s scan instead of walking every universe again', async () => {
+ const probe = await countPending({ params: { maxEntries: 2 }, family: { id: 'codex' } });
+ listUniverses.mockClear();
+
+ const result = await run({ params: { maxEntries: 2 }, job: {}, family: { id: 'codex' }, context: probe.context });
+
+ expect(result.dispatched).toBe(true);
+ expect(listUniverses).not.toHaveBeenCalled();
+ });
+
+ it('still runs when there is no probe context — the manual/force path', async () => {
+ const result = await run({ params: { maxEntries: 2 }, job: {}, family: { id: 'codex' }, context: undefined });
+ expect(result.dispatched).toBe(true);
+ expect(listUniverses).toHaveBeenCalled();
+ });
+
+ it('probes without writing, enqueueing, or calling a provider', async () => {
+ await countPending({ params: {}, family: { id: 'codex' } });
+ expect(renderUniverseJobs).not.toHaveBeenCalled();
+ expect(recordQuotaBurnInFlight).not.toHaveBeenCalled();
+ });
+
+ it('leaves the render backend to the ladder on an ordinary scheduled run', async () => {
+ await run({ params: { maxEntries: 2 }, job: {} });
+ expect(renderUniverseJobs).toHaveBeenCalledWith('u1', expect.objectContaining({ mode: undefined }), expect.any(Function));
+ });
+});
diff --git a/server/services/socket.js b/server/services/socket.js
index cf9ab832a6..0a0cc6412c 100644
--- a/server/services/socket.js
+++ b/server/services/socket.js
@@ -379,6 +379,9 @@ function setupCosEventForwarding() {
// this so an explicit trigger that finds no actionable work (parked) isn't a
// silent no-op.
cosEvents.on('schedule:on-demand-empty', (data) => broadcastToCos('cos:schedule:on-demand-empty', data));
+ // Programmatic scheduled handlers report what they actually did — no agent
+ // task is created, so there is nothing else for the user to watch.
+ cosEvents.on('schedule:on-demand-handled', (data) => broadcastToCos('cos:schedule:on-demand-handled', data));
}
// Set up error event forwarding
diff --git a/server/services/taskSchedule.js b/server/services/taskSchedule.js
index 0f1ce8c455..7f877effc7 100644
--- a/server/services/taskSchedule.js
+++ b/server/services/taskSchedule.js
@@ -46,6 +46,7 @@ import {
getTaskTypePromptInfo,
requiresManagedAppTarget,
requiresInstallWideTarget,
+ isProgrammaticScheduledTaskType,
enforceBranchReconcileBatch,
enforceManagedAgentOptions
} from './taskScheduleRegistry.js';
@@ -70,6 +71,7 @@ export {
MANAGED_AGENT_OPTIONS, PERPETUAL_DRAIN_DISPATCH_CAP, SELF_IMPROVEMENT_TASK_TYPES,
TASK_TYPE_DESCRIPTIONS, TASK_TYPE_INVOCATION, TASK_TYPE_PROMPT_INFO,
getTaskTypeInvocation, getTaskTypePromptInfo, requiresManagedAppTarget, requiresInstallWideTarget,
+ isProgrammaticScheduledTaskType, PROGRAMMATIC_SCHEDULED_TASK_TYPES,
stripManagedAgentOptionsFromOverride
} from './taskScheduleRegistry.js';
export { loadSchedule } from './taskScheduleStore.js';
@@ -1287,7 +1289,14 @@ export async function getScheduleStatus() {
// managed app in one dispatch). Served from the server registry rather than
// mirrored in client constants, so the UI cannot drift from the set the
// dispatch engines actually treat as install-wide.
- installWide: INSTALL_WIDE_TASK_TYPES.has(taskType)
+ installWide: INSTALL_WIDE_TASK_TYPES.has(taskType),
+ // Whether PortOS executes this type ITSELF (services/scheduledHandlers/)
+ // rather than dispatching an agent. Served from the registry rather than
+ // mirrored in client constants for the same reason as `installWide`: the
+ // UI must not drift from the set the dispatch engines actually treat as
+ // programmatic. It drives the Run Now affordance (no app picker — these
+ // never target a managed app) and the prompt panel (there is no prompt).
+ programmatic: isProgrammaticScheduledTaskType(taskType)
};
// Include default stage prompts for pipeline tasks so UI can display them
diff --git a/server/services/taskSchedule.test.js b/server/services/taskSchedule.test.js
index 7f436812b2..974ebb8abe 100644
--- a/server/services/taskSchedule.test.js
+++ b/server/services/taskSchedule.test.js
@@ -159,6 +159,9 @@ import {
TASK_TYPE_PROMPT_INFO,
getTaskTypeInvocation,
requiresManagedAppTarget,
+ requiresInstallWideTarget,
+ getTaskTypePromptInfo,
+ PROGRAMMATIC_SCHEDULED_TASK_TYPES,
REFERENCE_WATCH_AUDITED_VERSION,
boundParkedUntil
} from './taskSchedule.js'
@@ -371,6 +374,66 @@ describe('taskSchedule', () => {
});
});
+ describe('programmatic scheduled handlers (universe bible)', () => {
+ it('ships both as enabled ON_DEMAND tasks with no interval and no cron', () => {
+ for (const taskType of PROGRAMMATIC_SCHEDULED_TASK_TYPES) {
+ expect(SELF_IMPROVEMENT_TASK_TYPES, taskType).toContain(taskType);
+ expect(TASK_TYPE_DESCRIPTIONS[taskType], taskType).toBeTruthy();
+ const shipped = DEFAULT_TASK_INTERVALS[taskType];
+ expect(shipped, taskType).toMatchObject({ type: INTERVAL_TYPES.ON_DEMAND, enabled: true });
+ // No cadence of any kind: a clock-due default would spend the user's
+ // provider quota on a fresh install before they ever asked for it.
+ expect(shipped, taskType).not.toHaveProperty('intervalMs');
+ expect(shipped, taskType).not.toHaveProperty('cronExpression');
+ expect(shipped, taskType).not.toHaveProperty('recheckCron');
+ expect(shipped.perpetual, taskType).toBeUndefined();
+ }
+ });
+
+ it('never becomes due on the clock, however much time has passed', async () => {
+ // The acceptance guarantee for #6376: enabled + runnable from Run Now, yet
+ // never picked up by the scheduler. `cronDueNow` puts the clock where a
+ // cron task WOULD fire, so a passing assertion here is about the cadence,
+ // not about the time of day.
+ cronDueNow();
+ mockSchedule({
+ tasks: {
+ ...PAUSED_SHIPPED_DRAINS,
+ ...Object.fromEntries(PROGRAMMATIC_SCHEDULED_TASK_TYPES.map((t) => [t, { type: 'on-demand', enabled: true }])),
+ },
+ executions: Object.fromEntries(PROGRAMMATIC_SCHEDULED_TASK_TYPES.map((t) => [
+ `task:${t}`, { lastRun: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(), count: 1 }
+ ])),
+ });
+
+ for (const taskType of PROGRAMMATIC_SCHEDULED_TASK_TYPES) {
+ expect(await shouldRunTask(taskType), taskType).toMatchObject({ shouldRun: false, reason: 'on-demand-only' });
+ }
+ const due = await getDueTasks();
+ expect(due.map((t) => t.taskType)).not.toEqual(expect.arrayContaining([...PROGRAMMATIC_SCHEDULED_TASK_TYPES]));
+ });
+
+ it('refuses a managed-app target — a universe is not a repo', async () => {
+ for (const taskType of PROGRAMMATIC_SCHEDULED_TASK_TYPES) {
+ expect(requiresInstallWideTarget(taskType), taskType).toBe(true);
+ expect(await shouldRunTask(taskType, 'app-1'), taskType)
+ .toMatchObject({ shouldRun: false, reason: 'requires-install-wide-target' });
+ }
+ // The gate is per type, not "everything install-wide" — repo-sync's
+ // whole point is that it CAN be pointed at one app.
+ expect(requiresInstallWideTarget('repo-sync')).toBe(false);
+ });
+
+ it('has no prompt template and is surfaced as programmatic, not runtime-generated', () => {
+ for (const taskType of PROGRAMMATIC_SCHEDULED_TASK_TYPES) {
+ // PortOS performs the work itself: there is no prompt for a hook to
+ // render, so the prompt-version machinery must find nothing to migrate.
+ expect(DEFAULT_TASK_PROMPTS[taskType], taskType).toBeUndefined();
+ expect(getTaskTypePromptInfo(taskType).mode, taskType).toBe('programmatic');
+ }
+ });
+ });
+
describe('layered-intelligence (programmatic-I/O agent task)', () => {
it('is registered as a self-improvement task with a description and an on-demand default', () => {
expect(SELF_IMPROVEMENT_TASK_TYPES).toContain('layered-intelligence');
diff --git a/server/services/taskScheduleRegistry.js b/server/services/taskScheduleRegistry.js
index 6fdced02f8..7c11298cb1 100644
--- a/server/services/taskScheduleRegistry.js
+++ b/server/services/taskScheduleRegistry.js
@@ -13,6 +13,25 @@ import {
} from '../lib/agentExecutionProfiles.js';
import { INTERVAL_TYPES } from './taskScheduleConstants.js';
+/**
+ * Task types PortOS executes ITSELF through a programmatic handler
+ * (`services/scheduledHandlers/`) — no agent, no CoS task, no spawn slot.
+ *
+ * Written out rather than derived from `SCHEDULED_HANDLER_MODULES` on purpose:
+ * this registry is reached by a large share of the server suite, so it must not
+ * pay an import to learn two strings (server/AGENTS.md, "Import scoping").
+ * `taskScheduleRegistry.programmatic.test.js` asserts this list matches the
+ * handler registry exactly, so the two cannot drift.
+ */
+export const PROGRAMMATIC_SCHEDULED_TASK_TYPES = Object.freeze([
+ 'universe-bible-describe',
+ 'universe-bible-images',
+]);
+const PROGRAMMATIC_SCHEDULED_TASK_TYPE_SET = new Set(PROGRAMMATIC_SCHEDULED_TASK_TYPES);
+
+export const isProgrammaticScheduledTaskType = (taskType) =>
+ PROGRAMMATIC_SCHEDULED_TASK_TYPE_SET.has(taskType);
+
export const SELF_IMPROVEMENT_TASK_TYPES = [
'model-comparison-refresh',
'security', 'code-quality', 'test-coverage', 'performance',
@@ -120,6 +139,16 @@ export const SELF_IMPROVEMENT_TASK_TYPES = [
// buildTaskInput hook renders the prompt. See taskTypeHooks.js +
// autonomousJobs/layeredIntelligenceHooks.js.
'layered-intelligence',
+ // The two PROGRAMMATIC handlers (services/scheduledHandlers/) — the only
+ // scheduled types PortOS executes ITSELF, with no agent, no CoS task, and no
+ // spawn slot. They fill blank universe-bible sheets and render the entries
+ // that have no image, using the same domain services the Universe Builder's
+ // own buttons call. Install-wide (a universe is not a managed app's repo —
+ // see `requiresInstallWideTarget`) and ON_DEMAND with no interval, so they are
+ // never clock-due and a fresh install spends nothing until the user runs one.
+ // Quota Burn reaches the SAME handlers through `quotaBurnJobs/index.js`; there
+ // is one implementation, not two.
+ ...PROGRAMMATIC_SCHEDULED_TASK_TYPES,
// NOTE: `quota-burn` used to live here as a per-app perpetual task type. It is
// now ONE install-level loop (services/quotaBurnRunner.js) configured on the
// Quota Burn page — the burn plan is machine-local, and its jobs name which
@@ -215,10 +244,15 @@ export function requiresManagedAppTarget(taskType) {
return MANAGED_APP_TARGET_TASK_TYPES.has(taskType);
}
-// Unlike repo-sync, model research has no meaningful per-app variant: its
-// catalog and API live in the PortOS install, never another app's checkout.
+// Task types that must NOT be pointed at a managed app. Unlike repo-sync, model
+// research has no meaningful per-app variant: its catalog and API live in the
+// PortOS install, never another app's checkout. The programmatic bible handlers
+// are the same shape for a different reason — a universe is PortOS's own record,
+// not any repo's — so an appId on their request is a caller bug, not a scope.
+const INSTALL_WIDE_ONLY_TASK_TYPES = new Set(['model-comparison-refresh', ...PROGRAMMATIC_SCHEDULED_TASK_TYPES]);
+
export function requiresInstallWideTarget(taskType) {
- return taskType === 'model-comparison-refresh';
+ return INSTALL_WIDE_ONLY_TASK_TYPES.has(taskType);
}
// The pr-reviewer pipeline is a trust boundary, not three interchangeable
@@ -481,7 +515,23 @@ export const DEFAULT_TASK_INTERVALS = {
// agent runs in a worktree that is discarded without a commit/merge/PR
// (discardWorktree), so it can't land code — its `.agent-done` payload is the
// only sanctioned output (consumed by the processTaskOutput hook).
- 'layered-intelligence': { type: INTERVAL_TYPES.ON_DEMAND, enabled: true, providerId: null, model: null, prompt: null, taskMetadata: { useWorktree: true, openPR: false, discardWorktree: true } }
+ 'layered-intelligence': { type: INTERVAL_TYPES.ON_DEMAND, enabled: true, providerId: null, model: null, prompt: null, taskMetadata: { useWorktree: true, openPR: false, discardWorktree: true } },
+ // The two PROGRAMMATIC handlers. No `prompt` (PortOS performs the work itself
+ // — there is no template to render and no DEFAULT_TASK_PROMPTS entry), and no
+ // agent posture keys, because no agent, worktree, or PR is ever involved.
+ //
+ // `taskMetadata` here is the handler's PARAMS bag, mirroring the catalog row
+ // Quota Burn renders its job form from (`QUOTA_BURN_JOB_CATALOG` in
+ // lib/quotaBurnConfig.js) so the two doors advertise the same defaults —
+ // taskScheduleRegistry.programmatic.test.js fails when they drift. A param
+ // whose catalog default is null is OMITTED rather than stored as null:
+ // "unset" means the handler resolves it (the image job falls through to the
+ // universe-bible render-target ladder), which is not the same as a value.
+ //
+ // ON_DEMAND with NO interval and NO cron: enabled so the user can press Run
+ // Now, never clock-due, so a fresh install spends nothing until they do.
+ 'universe-bible-describe': { type: INTERVAL_TYPES.ON_DEMAND, enabled: true, providerId: null, model: null, prompt: null, taskMetadata: { universeId: 'all', scope: 'all', depth: 'full', maxEntries: 10 } },
+ 'universe-bible-images': { type: INTERVAL_TYPES.ON_DEMAND, enabled: true, providerId: null, model: null, prompt: null, taskMetadata: { universeId: 'all', scope: 'all', maxEntries: 10, requireDescribed: false } }
};
// Agent-options that a task manages internally — UI locks the toggle, and
@@ -630,7 +680,9 @@ export const TASK_TYPE_DESCRIPTIONS = {
'repo-sync': 'Sync every managed app with origin — back on the default branch, pushed and pulled, merged branches/worktrees and redundant stashes cleared',
'plan-feature': "Brainstorm one feature and file its decision-complete plan to the app's work tracker (no code)",
'user-action-review': 'Review the operator-action log for repeated manual work and propose automations — file issues (default) or queue CoS tasks',
- 'layered-intelligence': "Use app goals + performance metrics to file at most one deduplicated improvement issue; inspect read-only context and file a visibility gap when evidence is insufficient — no code"
+ 'layered-intelligence': "Use app goals + performance metrics to file at most one deduplicated improvement issue; inspect read-only context and file a visibility gap when evidence is insufficient — no code",
+ 'universe-bible-describe': 'Fill in blank universe bible sheets — one expand prompt per entry, emptiest first. No agent',
+ 'universe-bible-images': 'Render images for universe bible entries that have none yet. No agent — PortOS enqueues the renders itself'
};
export function getTaskTypeDescription(taskType) {
@@ -658,6 +710,17 @@ export const TASK_TYPE_PROMPT_INFO = Object.freeze({
'layered-intelligence': Object.freeze({
mode: 'runtime-generated',
description: 'Generated for each run from the app\'s configured goals, metrics, and repository context.'
+ }),
+ // `programmatic` is NOT `runtime-generated`: there is no prompt at all for the
+ // user to read or a hook to render. PortOS performs the work itself, so the
+ // UI shows the settings that bound it instead of a prompt editor.
+ 'universe-bible-describe': Object.freeze({
+ mode: 'programmatic',
+ description: 'PortOS sends one bible-expand prompt per entry itself — no agent and no prompt template. Scope, depth, and the per-run entry cap are the settings that bound it.'
+ }),
+ 'universe-bible-images': Object.freeze({
+ mode: 'programmatic',
+ description: 'PortOS compiles the missing entries\' render prompts and enqueues them on the media job queue itself — no agent and no prompt template.'
})
});
diff --git a/server/services/taskScheduleRegistry.programmatic.test.js b/server/services/taskScheduleRegistry.programmatic.test.js
new file mode 100644
index 0000000000..a5ce6dc7dc
--- /dev/null
+++ b/server/services/taskScheduleRegistry.programmatic.test.js
@@ -0,0 +1,93 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ DEFAULT_TASK_INTERVALS,
+ PROGRAMMATIC_SCHEDULED_TASK_TYPES,
+ isProgrammaticScheduledTaskType,
+} from './taskScheduleRegistry.js';
+import { SCHEDULED_HANDLER_MODULES } from './scheduledHandlers/index.js';
+import { JOB_MODULES } from './quotaBurnJobs/index.js';
+import { QUOTA_BURN_JOB_CATALOG } from '../lib/quotaBurnConfig.js';
+import { sanitizeTaskMetadata } from '../lib/cosValidation.js';
+
+// The catalog row Quota Burn renders its job form from, keyed by param.
+const catalogParams = (taskType) => Object.fromEntries(
+ (QUOTA_BURN_JOB_CATALOG.find((row) => row.id === taskType)?.params || []).map((p) => [p.key, p])
+);
+
+describe('programmatic scheduled handlers — registration parity', () => {
+ // The task-type list is written out in taskScheduleRegistry rather than
+ // derived from the handler module map, so that a registry reached by most of
+ // the server suite doesn't take on an import for two strings (server/AGENTS.md,
+ // "Import scoping"). This is the assertion that makes the split safe.
+ it('names exactly the registered handler modules', () => {
+ expect([...PROGRAMMATIC_SCHEDULED_TASK_TYPES].sort())
+ .toEqual(Object.keys(SCHEDULED_HANDLER_MODULES).sort());
+ for (const taskType of PROGRAMMATIC_SCHEDULED_TASK_TYPES) {
+ expect(isProgrammaticScheduledTaskType(taskType), taskType).toBe(true);
+ }
+ expect(isProgrammaticScheduledTaskType('security')).toBe(false);
+ // An inherited key must not resolve to a handler.
+ expect(isProgrammaticScheduledTaskType('constructor')).toBe(false);
+ });
+
+ it('is the SAME module Quota Burn dispatches — one implementation, not two', () => {
+ // #6376 moved these out of quotaBurnJobs/ and left the burn registry
+ // pointing at the shared handler. A second copy would drift the moment one
+ // door's behavior changed.
+ for (const taskType of PROGRAMMATIC_SCHEDULED_TASK_TYPES) {
+ expect(JOB_MODULES[taskType], taskType).toBe(SCHEDULED_HANDLER_MODULES[taskType]);
+ }
+ });
+
+ it('exports the countPending/run contract from every handler module', async () => {
+ for (const taskType of PROGRAMMATIC_SCHEDULED_TASK_TYPES) {
+ const mod = await SCHEDULED_HANDLER_MODULES[taskType]();
+ expect(typeof mod.countPending, taskType).toBe('function');
+ expect(typeof mod.run, taskType).toBe('function');
+ }
+ });
+});
+
+describe('programmatic scheduled handlers — shipped params', () => {
+ it('ships the same defaults the Quota Burn job form advertises', () => {
+ // Two doors onto one handler: a scheduled default that disagreed with the
+ // burn form would make the same action do different work depending on where
+ // it was started from.
+ for (const taskType of PROGRAMMATIC_SCHEDULED_TASK_TYPES) {
+ const params = catalogParams(taskType);
+ expect(Object.keys(params).length, taskType).toBeGreaterThan(0);
+ const expected = Object.fromEntries(
+ Object.entries(params)
+ // A null catalog default means "unset — the handler resolves it"
+ // (the image job's render backend), which is not a stored value.
+ .filter(([, p]) => p.default !== null && p.default !== undefined)
+ .map(([key, p]) => [key, p.default])
+ );
+ expect(DEFAULT_TASK_INTERVALS[taskType].taskMetadata, taskType).toEqual(expected);
+ }
+ });
+
+ it('survives task-metadata sanitization unchanged', () => {
+ // taskMetadata is allow-listed (lib/cosValidation.js). A param the sanitizer
+ // doesn't know is silently dropped on the first save, so the shipped default
+ // would quietly stop being what the handler runs.
+ for (const taskType of PROGRAMMATIC_SCHEDULED_TASK_TYPES) {
+ const shipped = DEFAULT_TASK_INTERVALS[taskType].taskMetadata;
+ expect(sanitizeTaskMetadata(shipped), taskType).toEqual(shipped);
+ }
+ });
+
+ it('drops out-of-contract param values instead of storing them', () => {
+ // A hand-edited schedule must not put a path segment where a universe id
+ // belongs, an unbounded batch size where the cap belongs, or a backend that
+ // batch rendering rejects downstream.
+ expect(sanitizeTaskMetadata({
+ universeId: '../../etc', scope: 'nonsense', depth: 'deep', maxEntries: 5000, mode: 'external',
+ })).toBeNull();
+ expect(sanitizeTaskMetadata({ universeId: 'u1', scope: 'canon', depth: 'core', maxEntries: 5, mode: 'codex' }))
+ .toEqual({ universeId: 'u1', scope: 'canon', depth: 'core', maxEntries: 5, mode: 'codex' });
+ // 'all' is a value, not an absence — it must survive.
+ expect(sanitizeTaskMetadata({ universeId: 'all' })).toEqual({ universeId: 'all' });
+ });
+});