diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index 651a4a467..8ddf04c6d 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -116,3 +116,5 @@ - **[issue-2220] Creative render tools with route-level persistence (CDO Phase 2 slice).** The Creative Director orchestrator can now render comic-issue and volume (season) covers — `pipeline_renderComicCover`, `pipeline_renderComicBackCover`, `pipeline_renderVolumeCover`, `pipeline_renderVolumeBackCover` are registered creative tools. These render jobs need more than a bare enqueue: the media-job filename hook attaches the finished image only if the target cover slot already carries the returned `jobId`, and that slot write used to live in the route factory (`routes/pipeline/covers.js#makeCoverRenderHandler`), so wrapping the bare `enqueueComicCover`-style service in a tool would silently drop orchestrated covers. The enqueue+persist flow (build the in-flight render slot, script-gate, write it onto the record) now lives in shared service entry points — `renderComicCover` / `renderComicBackCover` (→ `stages.comicPages.{cover|backCover}` via the serialized `updateStageWithLatest` series write tail) and `renderVolumeCover` / `renderVolumeBackCover` (→ `series.seasons[].{cover|backCover}` via `updateSeasonOnSeries`) in `visualStages.js` — so the four cover-render routes and the four orchestrator tools share ONE code path. The persist keeps the script-gate semantics (absent `coverScript` preserves the persisted concept, empty string clears it) and serializes against a concurrent blur-save on the same record. Tools charge as `render` against the daily action budget. Cover render is the primary deliverable; wrapping the remaining route-persisted render actions (comic page render / refine-render) as tools stays tracked on #2220. - **`getSettings()` now serves from an in-memory cache instead of re-reading settings.json on every call.** `getSettings()` is invoked 100+ times across the codebase (several per request), each previously doing a fresh filesystem read + JSON.parse of app-wide, rarely-changing data. It now memoizes the parsed settings, populated lazily on first read and refreshed off the `settings:updated` event that every `save()`/`updateSettings()`/`updateSettingsWith()` already emits — so app-driven writes keep the cache coherent while write paths still read fresh off disk inside their write queue for the read-merge-write, preserving write-ordering guarantees. Mirrors the existing `settings:updated`-invalidated cache in `sharing/annotationIdentity.js`. Alongside it, a few independent per-file read loops are now parallelized (`digital-twin-analysis.validateCompleteness`, editorial series-health's series+settings load) and hot page derivations memoized (`Review` grouping/sorting, `Tribe` care-queue), cutting redundant work on socket-driven re-renders. + +- **Branch & PR reconciliation now runs on any managed app as a Chief-of-Staff scheduled task — not just PortOS.** The branch reconciler (finish this machine's unfinished local git work: delete fully-merged orphaned branches + their worktrees, open PRs for pushed-but-unopened work, resolve merge conflicts, drive the review loop, and auto-merge when a PR is green) used to be a PortOS-only feature with its own daily cron, `/api/branch-reconcile` route, `settings.branchReconcile` toggle, and a dedicated Settings tab — hardwired to the PortOS checkout. It is now a first-class per-app CoS task type, **`branch-reconcile`**, configured like every other improvement task (per-app enable, provider/model, cadence, cooldown, budget) under Chief of Staff → task scheduling. It runs `perpetual` (drain-until-done): each dispatch runs the deterministic, peer-safe cleanup + classification against the *app's* repo, dispatches the coordinator agent only while actionable in-flight branches remain, then parks on a daily recheck — so no agent is burned on a no-op run. A progress-signature convergence guard ensures the drain only re-dispatches when the actionable set actually *changed* since the last run (a branch advanced NEEDS_PR→IN_REVIEW→merged, or a new/removed branch): a cycle that leaves the same branches in the same states (a branch judged "not ready", a PR blocked on human review or red CI) parks instead of re-spawning an identical coordinator back-to-back. The peer-safety guarantee is unchanged (only local `refs/heads/` are ever touched; a federated peer's branch exists here only as a remote-tracking ref and is structurally invisible), and the per-behavior toggles (`cleanupMerged` / `openPr` / `resolveConflicts` / `autoMerge`) are now per-app task-metadata overrides. Off by default; enabling it on an app is the user's explicit consent to let it drive PRs on a schedule. The old dedicated scheduler, route, settings key, and Settings tab are removed; a migration (`scripts/migrations/170-branch-reconcile-to-cos-task.js`) carries a previously-enabled `branchReconcile` opt-in into the new task (cron→recheckCron, actions→per-app metadata) scoped to the PortOS app, and drops the dead settings key — so an install that had it on keeps reconciling instead of silently stopping. (`server/services/branchReconcile.js`, `server/services/cosTaskGenerator.js`, `server/services/taskSchedule.js`, `server/services/taskPromptDefaults/prompts.js`) diff --git a/client/src/components/settings/BranchReconcileTab.jsx b/client/src/components/settings/BranchReconcileTab.jsx deleted file mode 100644 index 001864751..000000000 --- a/client/src/components/settings/BranchReconcileTab.jsx +++ /dev/null @@ -1,205 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Save, Loader2, GitPullRequest, RefreshCw } from 'lucide-react'; -import toast from '../ui/Toast'; -import BrailleSpinner from '../BrailleSpinner'; -import { - getSettings, - updateSettings, - getBranchReconcileStatus, - runBranchReconcile, -} from '../../services/api'; - -// Actions follow an opt-out model: absent/undefined means ON. Only an explicit -// `false` disables. Mirrors the server's `actionOn` helper. -const on = (v) => v !== false; - -const ACTIONS = [ - { key: 'cleanupMerged', label: 'Clean up merged/orphaned branches + worktrees', help: 'Deterministic — no agent. Removes a local branch whose work is already merged, plus its lingering worktree.' }, - { key: 'openPr', label: 'Open a PR for a finished branch without one', help: 'Agent verifies the work is complete, then runs /do:pr.' }, - { key: 'resolveConflicts', label: 'Resolve conflicts on open PRs', help: 'Agent rebases onto the default branch and resolves conflicts.' }, - { key: 'autoMerge', label: 'Auto-merge PRs that are fully green', help: 'Merges only when MERGEABLE + CI green + latest Copilot review has 0 comments.' }, -]; - -// Settings → Branch Reconcile. Opt-in daily automation that finishes THIS -// machine's unfinished local branches/PRs (cleanup merged, open PRs, resolve -// conflicts, merge) without touching federated peers' branches. OFF by default. -export function BranchReconcileTab() { - const [loading, setLoading] = useState(true); - const [enabled, setEnabled] = useState(false); - const [cron, setCron] = useState('0 3 * * *'); - const [actions, setActions] = useState({ cleanupMerged: true, openPr: true, resolveConflicts: true, autoMerge: true }); - - const [savedEnabled, setSavedEnabled] = useState(false); - const [savedCron, setSavedCron] = useState('0 3 * * *'); - const [savedActions, setSavedActions] = useState({ cleanupMerged: true, openPr: true, resolveConflicts: true, autoMerge: true }); - - const [saving, setSaving] = useState(false); - const [running, setRunning] = useState(false); - const [lastRun, setLastRun] = useState(null); - - useEffect(() => { - Promise.all([ - getSettings({ silent: true }).catch(() => ({})), - getBranchReconcileStatus({ silent: true }).catch(() => null), - ]).then(([settings, status]) => { - const c = settings?.branchReconcile || {}; - const en = c.enabled === true; - const cr = typeof c.cron === 'string' && c.cron ? c.cron : '0 3 * * *'; - const acts = { - cleanupMerged: on(c.actions?.cleanupMerged), - openPr: on(c.actions?.openPr), - resolveConflicts: on(c.actions?.resolveConflicts), - autoMerge: on(c.actions?.autoMerge), - }; - setEnabled(en); setCron(cr); setActions(acts); - setSavedEnabled(en); setSavedCron(cr); setSavedActions(acts); - setLastRun(status?.lastRun || null); - }).finally(() => setLoading(false)); - }, []); - - const actionsDirty = ACTIONS.some((a) => actions[a.key] !== savedActions[a.key]); - const dirty = enabled !== savedEnabled || cron.trim() !== savedCron || actionsDirty; - - const handleSave = async () => { - const cr = cron.trim() || '0 3 * * *'; - setSaving(true); - const merged = await updateSettings({ branchReconcile: { enabled, cron: cr, actions } }).catch(() => null); - setSaving(false); - if (!merged) return; - setCron(cr); - setSavedEnabled(enabled); setSavedCron(cr); setSavedActions(actions); - toast.success('Saved — schedule applies on next server restart'); - }; - - // Run Now gates on the SAVED enabled state (not the in-memory toggle) and is - // disabled while the form is dirty or a save/run is in flight — so the user - // can't trigger a run against a config they haven't persisted. - const handleRunNow = async () => { - setRunning(true); - const summary = await runBranchReconcile({ silent: true }).catch(() => null); - setRunning(false); - if (!summary) { toast.error('Reconcile run failed'); return; } - setLastRun(summary); - // The server catches run failures and returns a 200 `{ error }` summary - // (the run happens outside the request lifecycle), so a resolved promise is - // NOT proof of success — check the failure fields before toasting success. - if (summary.error) toast.error(`Reconcile failed: ${summary.error}`); - else if (summary.skipped === 'disabled') toast.error('Reconciler is disabled — enable and save first'); - else toast.success(`Reconcile: cleaned ${summary.cleaned?.length ?? 0}, coordinator ${summary.queued ? 'queued' : 'not queued'}`); - }; - - if (loading) return ; - - return ( -
-
-
- -

Branch & PR Reconciler

-
-

- Daily automation that finishes this machine's unfinished git work: cleans up branches - whose PRs already merged, and dispatches an agent to open PRs, resolve conflicts, and merge in-flight ones. - Only local branches are touched — branches created on federated peers are never affected. OFF by default. -

- -
- - -
- - setCron(e.target.value)} - placeholder="0 3 * * *" - className="w-48 px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm font-mono" - /> -

Default 0 3 * * * = daily at 3am.

-
- -
- Actions - {ACTIONS.map((a) => ( - - ))} -
- -
- - -
-
-
- - {lastRun && ( -
-

Last run

- {lastRun.skipped === 'disabled' ? ( -

Skipped — reconciler disabled.

- ) : lastRun.error ? ( -

{lastRun.error}

- ) : ( -
-
When
{lastRun.at ? new Date(lastRun.at).toLocaleString() : '—'}
-
Cleaned
{lastRun.cleaned?.length ?? 0}
-
Coordinator queued
{lastRun.queued ? 'yes' : 'no'}
-
Actionable
{lastRun.actionable?.join(', ') || '—'}
-
WIP (left)
{lastRun.wip?.join(', ') || '—'}
-
- )} - {/* The coordinator is queued but only RUNS when CoS auto-run is in Execute mode. - Warn honestly so the queued state isn't mistaken for "an agent is running". */} - {lastRun.queued && lastRun.coordinatorWillRun === false && ( -

- Coordinator queued but CoS auto-run is {lastRun.cosAutonomy || 'not execute'} — it will spawn once you set CoS auto-run to Execute. -

- )} -
- )} -
- ); -} - -export default BranchReconcileTab; diff --git a/client/src/components/settings/BranchReconcileTab.test.jsx b/client/src/components/settings/BranchReconcileTab.test.jsx deleted file mode 100644 index 8db3509e0..000000000 --- a/client/src/components/settings/BranchReconcileTab.test.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; - -vi.mock('../../services/api', () => ({ - getSettings: vi.fn(), - updateSettings: vi.fn(), - getBranchReconcileStatus: vi.fn(), - runBranchReconcile: vi.fn(), -})); -vi.mock('../ui/Toast', () => ({ - default: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }), -})); -vi.mock('../BrailleSpinner', () => ({ default: () =>
loading
})); - -import { - getSettings, updateSettings, getBranchReconcileStatus, runBranchReconcile, -} from '../../services/api'; -import { BranchReconcileTab } from './BranchReconcileTab'; - -beforeEach(() => { - vi.clearAllMocks(); - getBranchReconcileStatus.mockResolvedValue({ lastRun: null }); -}); - -describe('BranchReconcileTab', () => { - it('disables Run Now when the reconciler is saved-disabled', async () => { - getSettings.mockResolvedValue({ branchReconcile: { enabled: false } }); - render(); - const runBtn = await screen.findByRole('button', { name: /run now/i }); - expect(runBtn).toBeDisabled(); - }); - - it('disables Run Now while the form is dirty even if saved-enabled', async () => { - getSettings.mockResolvedValue({ branchReconcile: { enabled: true, cron: '0 3 * * *' } }); - render(); - const runBtn = await screen.findByRole('button', { name: /run now/i }); - expect(runBtn).not.toBeDisabled(); // saved-enabled, clean - - // Make the form dirty by toggling an action. - fireEvent.click(screen.getByLabelText(/Auto-merge PRs/i)); - expect(runBtn).toBeDisabled(); - }); - - it('runs a reconcile pass when Run Now is clicked (saved-enabled + clean)', async () => { - getSettings.mockResolvedValue({ branchReconcile: { enabled: true, cron: '0 3 * * *' } }); - runBranchReconcile.mockResolvedValue({ at: 't', cleaned: ['a'], dispatched: true }); - render(); - const runBtn = await screen.findByRole('button', { name: /run now/i }); - fireEvent.click(runBtn); - await waitFor(() => expect(runBranchReconcile).toHaveBeenCalledWith({ silent: true })); - }); - - it('saves the config with the branchReconcile slice', async () => { - getSettings.mockResolvedValue({ branchReconcile: { enabled: false, cron: '0 3 * * *' } }); - updateSettings.mockResolvedValue({ branchReconcile: {} }); - render(); - await screen.findByRole('button', { name: /run now/i }); - fireEvent.click(screen.getByLabelText(/Enable the daily reconciler/i)); - fireEvent.click(screen.getByRole('button', { name: /^save$/i })); - await waitFor(() => expect(updateSettings).toHaveBeenCalledWith( - expect.objectContaining({ branchReconcile: expect.objectContaining({ enabled: true }) }) - )); - }); -}); diff --git a/client/src/components/settings/SettingsTabsHeader.jsx b/client/src/components/settings/SettingsTabsHeader.jsx index 0beefb9fd..b0bd1ce3c 100644 --- a/client/src/components/settings/SettingsTabsHeader.jsx +++ b/client/src/components/settings/SettingsTabsHeader.jsx @@ -14,7 +14,6 @@ export const TABS = [ { id: 'api-access', label: 'API Access', to: '/settings/api-access' }, { id: 'autofixer', label: 'Autofixer', to: '/settings/autofixer' }, { id: 'backup', label: 'Backup', to: '/settings/backup' }, - { id: 'branch-reconcile', label: 'Branch Reconcile', to: '/settings/branch-reconcile' }, { id: 'catalog', label: 'Catalog', to: '/settings/catalog' }, { id: 'database', label: 'Database', to: '/settings/database' }, { id: 'embeddings', label: 'Embeddings', to: '/settings/embeddings' }, diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx index e3dd912df..6768a81f6 100644 --- a/client/src/pages/Settings.jsx +++ b/client/src/pages/Settings.jsx @@ -5,7 +5,6 @@ import { ApiAccessTab } from '../components/settings/ApiAccessTab'; import { AutofixerTab } from '../components/settings/AutofixerTab'; import AiAssignmentsTab from '../components/settings/AiAssignmentsTab'; import { BackupTab } from '../components/settings/BackupTab'; -import { BranchReconcileTab } from '../components/settings/BranchReconcileTab'; import { CatalogTypesTab } from '../components/settings/CatalogTypesTab'; import { DatabaseTab } from '../components/settings/DatabaseTab'; import EmbeddingsTab from '../components/settings/EmbeddingsTab'; @@ -42,7 +41,6 @@ export default function Settings() { case 'api-access': return ; case 'autofixer': return ; case 'backup': return ; - case 'branch-reconcile': return ; case 'catalog': return ; case 'database': return ; case 'embeddings': return ; diff --git a/client/src/services/apiSystem.js b/client/src/services/apiSystem.js index 467705540..825e3bc98 100644 --- a/client/src/services/apiSystem.js +++ b/client/src/services/apiSystem.js @@ -101,10 +101,6 @@ export const getBackupSnapshots = (options) => request('/backup/snapshots', opti export const restoreBackup = (data, options = {}) => request('/backup/restore', { method: 'POST', body: JSON.stringify(data), ...options }); export const restoreDatabase = (data, options) => request('/backup/restore-db', { method: 'POST', body: JSON.stringify(data), ...options }); -// Branch & PR Reconciler — finishes this machine's unfinished local branches/PRs. -export const getBranchReconcileStatus = (options) => request('/branch-reconcile/status', options); -export const runBranchReconcile = (options = {}) => request('/branch-reconcile/run', { method: 'POST', ...options }); - // Data Manager export const getDataOverview = () => request('/data'); export const getDataCategory = (key) => request(`/data/${key}`); diff --git a/data.reference/settings.json b/data.reference/settings.json index f25de010a..3fd040e6c 100644 --- a/data.reference/settings.json +++ b/data.reference/settings.json @@ -54,15 +54,5 @@ "apiAccess": { "voice": { "exposed": false, "requireAuth": false }, "sdapi": { "exposed": false, "requireAuth": false } - }, - "branchReconcile": { - "enabled": false, - "cron": "0 3 * * *", - "actions": { - "cleanupMerged": true, - "openPr": true, - "resolveConflicts": true, - "autoMerge": true - } } } \ No newline at end of file diff --git a/docs/plans/2026-07-06-branch-pr-reconciler.md b/docs/plans/2026-07-06-branch-pr-reconciler.md index 037c79a9e..64458f751 100644 --- a/docs/plans/2026-07-06-branch-pr-reconciler.md +++ b/docs/plans/2026-07-06-branch-pr-reconciler.md @@ -1,7 +1,19 @@ # Branch & PR Reconciler — Design Spec **Date:** 2026-07-06 -**Status:** Approved (brainstorm) — pending implementation plan +**Status:** Implemented, then **migrated** — the deterministic Tier-1 core and the +Tier-2 coordinator prompt described below shipped as designed, but the wiring in +"Architecture" and "Wiring" (a PortOS-only `branchReconcileScheduler.js` cron + +`settings.branchReconcile` + `/api/branch-reconcile` route + a dedicated Settings +tab) was **superseded on 2026-07-06** by folding the feature into the Chief-of-Staff +per-app scheduled-task system as the **`branch-reconcile`** task type. It now runs +on *any* managed app (not just PortOS): the deterministic `reconcile()` runs as the +generator's per-app pre-step (like `pr-watcher`/`reference-watch`), the coordinator +body is `DEFAULT_TASK_PROMPTS['branch-reconcile']` with an injected `{inFlightBranches}` +block, and it drains `perpetual`ly (dispatch while in-flight branches remain, else +park on a daily recheck). Read the two sections below as the design of the *core* +logic, not the current wiring. See `server/services/cosTaskGenerator.js` (the +`branch-reconcile` block) and `server/services/taskSchedule.js`. **Branch:** `feat/branch-pr-reconciler` ## Problem diff --git a/scripts/migrations/170-branch-reconcile-to-cos-task.js b/scripts/migrations/170-branch-reconcile-to-cos-task.js new file mode 100644 index 000000000..78044c5d4 --- /dev/null +++ b/scripts/migrations/170-branch-reconcile-to-cos-task.js @@ -0,0 +1,117 @@ +/** + * Migrate the retired PortOS-only Branch & PR Reconciler into the new per-app + * `branch-reconcile` Chief-of-Staff scheduled task. + * + * The old feature was a bespoke daily cron gated by `settings.branchReconcile` + * (enabled/cron/actions), hardwired to the PortOS checkout. Its scheduler, + * `/api/branch-reconcile` route, and settings schema were removed when the + * feature became the per-app `branch-reconcile` task type — which is disabled by + * default. Without this migration, an install that had `branchReconcile.enabled: + * true` would silently STOP reconciling after the upgrade. + * + * This migration: + * 1. If the old reconciler was ENABLED, enables the new `branch-reconcile` + * task in the schedule, carrying `cron` → `recheckCron` and `actions` → + * per-app action `taskMetadata` (cleanupMerged/openPr/resolveConflicts/ + * autoMerge). It preserves the old PortOS-only scope by disabling the task + * (via `taskTypeOverrides`) on every OTHER managed app — the global enable + * would otherwise run it everywhere. The PortOS baseline app (which owns the + * install repo the old reconciler ran on) inherits the enable. + * 2. Removes the now-dead `settings.branchReconcile` key (its scheduler/route/ + * schema are gone; leaving it would just be re-imported and renamed aside on + * each boot). + * + * Idempotent: once the key is removed a re-run finds nothing and no-ops. When the + * old reconciler was disabled, only the dead key is removed (no task is enabled). + */ + +import { readFile, writeFile } from 'fs/promises'; +import { join } from 'path'; + +const SETTINGS_REL = 'data/settings.json'; +const SCHEDULE_REL = 'data/cos/task-schedule.json'; +const APPS_REL = 'data/apps.json'; +const PORTOS_APP_ID = 'portos-default'; + +async function readJson(path) { + const raw = await readFile(path, 'utf-8').catch((err) => { + if (err.code === 'ENOENT') return null; + throw err; + }); + if (raw == null) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +const isObject = (v) => v && typeof v === 'object' && !Array.isArray(v); +/** ON unless explicitly false — mirrors the runtime `actionOn` opt-out semantics. */ +const on = (actions, key) => actions?.[key] !== false; + +export default { + async up({ rootDir }) { + const settingsPath = join(rootDir, SETTINGS_REL); + const settings = await readJson(settingsPath); + if (!isObject(settings) || !('branchReconcile' in settings)) { + return { updated: 0, reason: 'no-branchReconcile-settings' }; + } + + const old = isObject(settings.branchReconcile) ? settings.branchReconcile : {}; + const wasEnabled = old.enabled === true; + + if (wasEnabled) { + // 1a. Enable + configure the new task from the old settings. + const schedulePath = join(rootDir, SCHEDULE_REL); + const schedule = await readJson(schedulePath); + if (isObject(schedule) && isObject(schedule.tasks)) { + const task = isObject(schedule.tasks['branch-reconcile']) ? schedule.tasks['branch-reconcile'] : {}; + task.enabled = true; + if (typeof old.cron === 'string' && old.cron.trim().split(/\s+/).length === 5) { + task.recheckCron = old.cron; + } + const actions = isObject(old.actions) ? old.actions : {}; + task.taskMetadata = { + ...(isObject(task.taskMetadata) ? task.taskMetadata : {}), + useWorktree: false, + openPR: false, + cleanupMerged: on(actions, 'cleanupMerged'), + openPr: on(actions, 'openPr'), + resolveConflicts: on(actions, 'resolveConflicts'), + autoMerge: on(actions, 'autoMerge') + }; + schedule.tasks['branch-reconcile'] = task; + await writeFile(schedulePath, `${JSON.stringify(schedule, null, 2)}\n`); + console.log('📝 branch-reconcile: carried the enabled PortOS reconciler into the new per-app CoS task'); + } else { + console.log('⚠️ branch-reconcile: no task-schedule.json to enable — loadSchedule will backfill the disabled default; enable it under Chief of Staff'); + } + + // 1b. Preserve PortOS-only scope: disable the task on every non-PortOS app. + const appsPath = join(rootDir, APPS_REL); + const appsData = await readJson(appsPath); + if (isObject(appsData) && isObject(appsData.apps)) { + let scoped = 0; + for (const [appId, app] of Object.entries(appsData.apps)) { + if (appId === PORTOS_APP_ID || !isObject(app)) continue; + const overrides = isObject(app.taskTypeOverrides) ? app.taskTypeOverrides : {}; + if (overrides['branch-reconcile']?.enabled === false) continue; + overrides['branch-reconcile'] = { ...(isObject(overrides['branch-reconcile']) ? overrides['branch-reconcile'] : {}), enabled: false }; + app.taskTypeOverrides = overrides; + scoped += 1; + } + if (scoped > 0) { + await writeFile(appsPath, `${JSON.stringify(appsData, null, 2)}\n`); + console.log(`📝 branch-reconcile: preserved PortOS-only scope — disabled on ${scoped} other managed app(s) (re-enable per app under Chief of Staff)`); + } + } + } + + // 2. Drop the dead settings key. + delete settings.branchReconcile; + await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`); + console.log(`✅ branch-reconcile: removed dead settings.branchReconcile key (was ${wasEnabled ? 'enabled → migrated to CoS task' : 'disabled'})`); + return { updated: 1, wasEnabled }; + } +}; diff --git a/scripts/migrations/170-branch-reconcile-to-cos-task.test.js b/scripts/migrations/170-branch-reconcile-to-cos-task.test.js new file mode 100644 index 000000000..61a5a31e6 --- /dev/null +++ b/scripts/migrations/170-branch-reconcile-to-cos-task.test.js @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import migration from './170-branch-reconcile-to-cos-task.js'; + +const writeJson = (path, value) => writeFileSync(path, JSON.stringify(value, null, 2) + '\n'); +const readJson = (path) => JSON.parse(readFileSync(path, 'utf-8')); + +describe('migration 170 — branch reconciler → per-app CoS task', () => { + let rootDir, settingsPath, schedulePath, appsPath; + + beforeEach(() => { + rootDir = mkdtempSync(join(tmpdir(), 'migration-170-')); + mkdirSync(join(rootDir, 'data', 'cos'), { recursive: true }); + settingsPath = join(rootDir, 'data', 'settings.json'); + schedulePath = join(rootDir, 'data', 'cos', 'task-schedule.json'); + appsPath = join(rootDir, 'data', 'apps.json'); + }); + + afterEach(() => { + rmSync(rootDir, { recursive: true, force: true }); + }); + + it('no-ops when there is no settings.json (fresh install)', async () => { + const result = await migration.up({ rootDir }); + expect(result.updated).toBe(0); + expect(result.reason).toBe('no-branchReconcile-settings'); + }); + + it('no-ops when settings has no branchReconcile key', async () => { + writeJson(settingsPath, { theme: 'dark' }); + const result = await migration.up({ rootDir }); + expect(result.updated).toBe(0); + expect(result.reason).toBe('no-branchReconcile-settings'); + expect(readJson(settingsPath)).toEqual({ theme: 'dark' }); + }); + + it('removes the dead key without enabling anything when the old reconciler was disabled', async () => { + writeJson(settingsPath, { branchReconcile: { enabled: false, cron: '0 3 * * *' }, theme: 'dark' }); + writeJson(schedulePath, { tasks: { 'branch-reconcile': { enabled: false } } }); + const result = await migration.up({ rootDir }); + expect(result).toEqual({ updated: 1, wasEnabled: false }); + expect('branchReconcile' in readJson(settingsPath)).toBe(false); + expect(readJson(settingsPath).theme).toBe('dark'); + // Task stays disabled — user never opted in. + expect(readJson(schedulePath).tasks['branch-reconcile'].enabled).toBe(false); + }); + + it('carries an enabled reconciler into the new task: cron→recheckCron, actions→taskMetadata', async () => { + writeJson(settingsPath, { + branchReconcile: { enabled: true, cron: '30 4 * * *', actions: { cleanupMerged: true, openPr: false, resolveConflicts: true, autoMerge: false } } + }); + writeJson(schedulePath, { tasks: { 'branch-reconcile': { type: 'perpetual', enabled: false, taskMetadata: { useWorktree: false, openPR: false, cleanupMerged: true, openPr: true, resolveConflicts: true, autoMerge: true } } } }); + const result = await migration.up({ rootDir }); + expect(result.wasEnabled).toBe(true); + + const task = readJson(schedulePath).tasks['branch-reconcile']; + expect(task.enabled).toBe(true); + expect(task.recheckCron).toBe('30 4 * * *'); + expect(task.taskMetadata.openPr).toBe(false); // carried from actions + expect(task.taskMetadata.autoMerge).toBe(false); // carried from actions + expect(task.taskMetadata.cleanupMerged).toBe(true); + expect(task.taskMetadata.resolveConflicts).toBe(true); + // Managed isolation flags preserved. + expect(task.taskMetadata.useWorktree).toBe(false); + expect(task.taskMetadata.openPR).toBe(false); + // Dead key gone. + expect('branchReconcile' in readJson(settingsPath)).toBe(false); + }); + + it('absent action keys default to ON (opt-out semantics)', async () => { + writeJson(settingsPath, { branchReconcile: { enabled: true, actions: {} } }); + writeJson(schedulePath, { tasks: { 'branch-reconcile': {} } }); + await migration.up({ rootDir }); + const meta = readJson(schedulePath).tasks['branch-reconcile'].taskMetadata; + expect(meta).toMatchObject({ cleanupMerged: true, openPr: true, resolveConflicts: true, autoMerge: true }); + }); + + it('preserves PortOS-only scope by disabling the task on other managed apps', async () => { + writeJson(settingsPath, { branchReconcile: { enabled: true } }); + writeJson(schedulePath, { tasks: { 'branch-reconcile': {} } }); + writeJson(appsPath, { + apps: { + 'portos-default': { name: 'PortOS' }, + 'other-app': { name: 'Other', taskTypeOverrides: { security: { enabled: true } } }, + 'third-app': { name: 'Third' } + } + }); + await migration.up({ rootDir }); + const apps = readJson(appsPath).apps; + // PortOS inherits the enable — no override added. + expect(apps['portos-default'].taskTypeOverrides?.['branch-reconcile']).toBeUndefined(); + // Others are explicitly disabled, preserving unrelated overrides. + expect(apps['other-app'].taskTypeOverrides['branch-reconcile'].enabled).toBe(false); + expect(apps['other-app'].taskTypeOverrides.security.enabled).toBe(true); + expect(apps['third-app'].taskTypeOverrides['branch-reconcile'].enabled).toBe(false); + }); + + it('is idempotent — re-running after the key is gone no-ops', async () => { + writeJson(settingsPath, { branchReconcile: { enabled: true } }); + writeJson(schedulePath, { tasks: { 'branch-reconcile': {} } }); + await migration.up({ rootDir }); + const second = await migration.up({ rootDir }); + expect(second.updated).toBe(0); + expect(second.reason).toBe('no-branchReconcile-settings'); + }); +}); diff --git a/server/index.js b/server/index.js index 15fce5ab6..0a5d1db42 100644 --- a/server/index.js +++ b/server/index.js @@ -76,7 +76,6 @@ import dataManagerRoutes from './routes/dataManager.js'; import jiraRoutes from './routes/jira.js'; import autobiographyRoutes from './routes/autobiography.js'; import backupRoutes from './routes/backup.js'; -import branchReconcileRoutes from './routes/branchReconcile.js'; import legacyExportRoutes from './routes/legacyExport.js'; import cityRoutes from './routes/cityRoutes.js'; import databaseRoutes from './routes/database.js'; @@ -171,7 +170,6 @@ import * as cos from './services/cos.js'; import { startBackupScheduler } from './services/backupScheduler.js'; import { startCitySnapshotScheduler } from './services/citySnapshotScheduler.js'; import { startImessageScheduler } from './services/imessageScheduler.js'; -import { startBranchReconcileScheduler } from './services/branchReconcileScheduler.js'; import * as telegram from './services/telegram.js'; import * as telegramBridge from './services/telegramBridge.js'; import { getSettings as getInitSettings } from './services/settings.js'; @@ -454,7 +452,6 @@ app.use('/api/media/sketches', mediaSketchesRoutes); app.use('/api/attachments', attachmentsRoutes); app.use('/api/client-errors', clientErrorsRoutes); app.use('/api/backup', backupRoutes); -app.use('/api/branch-reconcile', branchReconcileRoutes); app.use('/api/legacy-export', legacyExportRoutes); app.use('/api/city', cityRoutes); app.use('/api/database', databaseRoutes); @@ -606,10 +603,6 @@ startCitySnapshotScheduler().catch(err => console.error(`❌ City snapshot sched // Initialize iMessage sync scheduler — OFF by default; only polls chat.db when // the user opts in via Settings → iMessage (needs macOS Full Disk Access) (#2151). startImessageScheduler().catch(err => console.error(`❌ iMessage sync scheduler init failed: ${err.message}`)); -// Initialize Branch & PR Reconciler — OFF by default; only registers a daily -// cron when the user opts in via Settings. Reconciles this machine's unfinished -// local branches/PRs (cleanup merged, finish in-flight) without touching peers. -startBranchReconcileScheduler().catch(err => console.error(`❌ Branch reconcile scheduler init failed: ${err.message}`)); // Periodically GC orphan zero-issue/zero-canon importer shells left by an // abandoned analyze (issue #727). startOrphanShellGc(); diff --git a/server/lib/cosValidation.js b/server/lib/cosValidation.js index 9afd4d695..a76188c1c 100644 --- a/server/lib/cosValidation.js +++ b/server/lib/cosValidation.js @@ -265,7 +265,15 @@ export const PIPELINE_BEHAVIOR_FLAGS = ['useWorktree', 'openPR', 'simplify', 're // Absolute cap on total agent spawns per task (across all retry types) export const MAX_TOTAL_SPAWNS = 5; -const ALLOWED_TASK_METADATA_KEYS = [...PIPELINE_BEHAVIOR_FLAGS, 'readOnly']; +// `cleanupMerged` / `openPr` / `resolveConflicts` / `autoMerge` are the +// per-app action toggles for the `branch-reconcile` task type (each ON unless +// explicitly false). They live in the shared task-metadata allowlist — like +// `prAuthorFilter` / `issueAuthorFilter` — so a per-app override can disable an +// individual rectification behavior and survive sanitizeTaskMetadata. +const ALLOWED_TASK_METADATA_KEYS = [ + ...PIPELINE_BEHAVIOR_FLAGS, 'readOnly', + 'cleanupMerged', 'openPr', 'resolveConflicts', 'autoMerge' +]; // pr-watcher author-gate values. 'self' = PRs opened by the gh-authenticated // user (the PortOS operator / their automation); 'others' = everyone else; diff --git a/server/lib/navManifest.js b/server/lib/navManifest.js index a259763dd..52075e51b 100644 --- a/server/lib/navManifest.js +++ b/server/lib/navManifest.js @@ -171,7 +171,6 @@ export const NAV_COMMANDS = [ { id: 'nav.settings.api-access', path: '/settings/api-access', label: 'API Access', section: 'Settings', aliases: ['api-access', 'settings-api-access', 'public-api', 'swagger', 'openapi'], keywords: ['rest', 'external', 'tts api', 'sdapi', 'voice api', 'docs', 'curl', 'expose', 'passwordless', 'auth gating'] }, { id: 'nav.settings.autofixer', path: '/settings/autofixer', label: 'Autofixer', section: 'Settings', aliases: ['autofixer', 'settings-autofixer', 'auto-fixer'], keywords: ['crash', 'fix', 'pm2', 'repair', 'ai provider', 'restart'] }, { id: 'nav.settings.backup', path: '/settings/backup', label: 'Backup', section: 'Settings', aliases: ['backup', 'settings-backup'] }, - { id: 'nav.settings.branch-reconcile', path: '/settings/branch-reconcile', label: 'Branch Reconcile', section: 'Settings', aliases: ['branch-reconcile', 'reconcile', 'settings-branch-reconcile', 'branch-pr-reconciler'], keywords: ['branch', 'pr', 'pull request', 'merge', 'worktree', 'cleanup', 'unfinished', 'orphaned', 'stale branches'] }, { id: 'nav.settings.catalog', path: '/settings/catalog', label: 'Catalog Types', section: 'Settings', aliases: ['settings-catalog', 'catalog-types'], keywords: ['catalog', 'types', 'character', 'place', 'object', 'taxonomy'] }, { id: 'nav.settings.database', path: '/settings/database', label: 'Database', section: 'Settings', aliases: ['settings-database', 'database'] }, { id: 'nav.settings.embeddings', path: '/settings/embeddings', label: 'Embeddings', section: 'Settings', aliases: ['settings-embeddings', 'embeddings', 'embedding'], keywords: ['vector', 'pgvector', 'semantic search', 'nomic', 'ollama', 'lm studio'] }, diff --git a/server/lib/validation.js b/server/lib/validation.js index 792477652..dced04409 100644 --- a/server/lib/validation.js +++ b/server/lib/validation.js @@ -319,23 +319,6 @@ export const backupConfigSchema = z.object({ disabledDefaultExcludes: z.array(z.string()).optional().default([]) }); -// Branch & PR Reconciler config (opt-in, disabled by default so a fresh install -// never spawns agents on a schedule without the user turning it on). `actions` -// are independent toggles for each rectification behavior. Used by the settings -// PUT route (.partial()) and re-read on every scheduled run. -export const branchReconcileConfigSchema = z.object({ - enabled: z.boolean().optional().default(false), - // Exactly 5 whitespace-separated fields — the only shape `eventScheduler`'s - // parser accepts (a 6-field or arbitrary string would register a cron with no - // next run, silently never firing). This regex is a cheap field-count gate; - // the settings route additionally range-validates via the scheduler's own - // `isValidCron` so `99 99 * * *` is rejected too. - cron: z.string().regex(/^\S+(\s+\S+){4}$/, 'cron must have exactly 5 whitespace-separated fields').optional().default('0 3 * * *'), - actions: z.object(optionalBooleanMap([ - 'cleanupMerged', 'openPr', 'resolveConflicts', 'autoMerge' - ])).optional().default({}) -}); - // Per-API external-access flags (issue: public API surface). Stored under the // top-level `apiAccess` settings key (client-readable — NOT under `secrets`). // Drives `server/lib/apiRegistry.js`: an entry that is `exposed && !requireAuth` diff --git a/server/lib/validation.test.js b/server/lib/validation.test.js index 6e00fcd44..a56d02819 100644 --- a/server/lib/validation.test.js +++ b/server/lib/validation.test.js @@ -26,44 +26,9 @@ import { subdirFilterSchema, isPaginationRequested, paginateArray, - branchReconcileConfigSchema, } from './validation.js'; describe('validation.js', () => { - describe('branchReconcileConfigSchema', () => { - it('accepts the shipped default block', () => { - const parsed = branchReconcileConfigSchema.parse({ - enabled: false, - cron: '0 3 * * *', - actions: { cleanupMerged: true, openPr: true, resolveConflicts: true, autoMerge: true } - }); - expect(parsed.enabled).toBe(false); - expect(parsed.actions.autoMerge).toBe(true); - }); - - it('defaults enabled to false and cron to the daily 3am expression', () => { - const parsed = branchReconcileConfigSchema.parse({}); - expect(parsed.enabled).toBe(false); - expect(parsed.cron).toBe('0 3 * * *'); - expect(parsed.actions).toEqual({}); - }); - - it('rejects a non-boolean enabled', () => { - expect(branchReconcileConfigSchema.safeParse({ enabled: 'yes' }).success).toBe(false); - }); - - it('accepts a 5-field cron and rejects other field counts', () => { - expect(branchReconcileConfigSchema.safeParse({ cron: 'not a cron' }).success).toBe(false); - expect(branchReconcileConfigSchema.safeParse({ cron: '0 3 * *' }).success).toBe(false); // 4 fields - expect(branchReconcileConfigSchema.safeParse({ cron: '0 0 3 * * *' }).success).toBe(false); // 6 fields — scheduler only parses 5 - expect(branchReconcileConfigSchema.safeParse({ cron: '0 3 * * *' }).success).toBe(true); - }); - - it('.partial() allows a single-field toggle patch', () => { - expect(branchReconcileConfigSchema.partial().safeParse({ enabled: true }).success).toBe(true); - }); - }); - describe('isPaginationRequested', () => { it('is false when neither limit nor offset is present', () => { expect(isPaginationRequested({})).toBe(false); diff --git a/server/routes/branchReconcile.js b/server/routes/branchReconcile.js deleted file mode 100644 index a7f6b5ef9..000000000 --- a/server/routes/branchReconcile.js +++ /dev/null @@ -1,19 +0,0 @@ -import { Router } from 'express'; -import { asyncHandler } from '../lib/errorHandler.js'; -import { runBranchReconcile, getLastRun } from '../services/branchReconcileScheduler.js'; - -const router = Router(); - -// GET /api/branch-reconcile/status — last run summary (null until first run). -router.get('/status', asyncHandler(async (req, res) => { - res.json({ lastRun: getLastRun() }); -})); - -// POST /api/branch-reconcile/run — run one reconcile pass on demand. An explicit -// user click is its own consent, so it bypasses the enabled gate (force:true). -router.post('/run', asyncHandler(async (req, res) => { - const summary = await runBranchReconcile({ force: true }); - res.json(summary); -})); - -export default router; diff --git a/server/routes/branchReconcile.test.js b/server/routes/branchReconcile.test.js deleted file mode 100644 index e37d56e1a..000000000 --- a/server/routes/branchReconcile.test.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Route tests for /api/branch-reconcile. - * The service is mocked — we only verify routing (POST /run forces a run, - * GET /status returns the last summary). - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import express from 'express'; -import { request } from '../lib/testHelper.js'; -import { errorMiddleware } from '../lib/errorHandler.js'; - -vi.mock('../services/branchReconcileScheduler.js', () => ({ - runBranchReconcile: vi.fn(async () => ({ cleaned: ['old'], dispatched: true })), - getLastRun: vi.fn(() => ({ at: '2026-07-06T00:00:00.000Z', dispatched: false })) -})); - -import branchReconcileRoutes from './branchReconcile.js'; -import { runBranchReconcile, getLastRun } from '../services/branchReconcileScheduler.js'; - -const makeApp = () => { - const app = express(); - app.use(express.json()); - app.use('/api/branch-reconcile', branchReconcileRoutes); - app.use(errorMiddleware); - return app; -}; - -beforeEach(() => vi.clearAllMocks()); - -describe('POST /api/branch-reconcile/run', () => { - it('forces a run and returns the summary', async () => { - const res = await request(makeApp()).post('/api/branch-reconcile/run').send({}); - expect(res.status).toBe(200); - expect(res.body).toEqual({ cleaned: ['old'], dispatched: true }); - expect(runBranchReconcile).toHaveBeenCalledWith({ force: true }); - }); -}); - -describe('GET /api/branch-reconcile/status', () => { - it('returns the last run summary', async () => { - const res = await request(makeApp()).get('/api/branch-reconcile/status'); - expect(res.status).toBe(200); - expect(res.body.lastRun.dispatched).toBe(false); - expect(getLastRun).toHaveBeenCalled(); - }); -}); diff --git a/server/routes/settings.js b/server/routes/settings.js index 621e1ea5a..9dbb00889 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -8,10 +8,9 @@ import { CODEX_PARALLEL_MAX, CODEX_PARALLEL_DEFAULT, } from '../services/mediaJobQueue/index.js'; -import { asyncHandler, ServerError } from '../lib/errorHandler.js'; -import { isValidCron } from '../services/eventScheduler.js'; +import { asyncHandler } from '../lib/errorHandler.js'; import { isPlainObject } from '../lib/objects.js'; -import { backupConfigSchema, sharingSettingsPatchSchema, featureProviderConfigSchema, codeReviewSettingsSchema, locationSettingsSchema, settingsEmbeddingsSchema, citySnapshotConfigSchema, imessageConfigSchema, apiAccessSettingsSchema, loraTrainingConfigSchema, pipelineEditorialChecksSettingsSchema, creativeDirectorSettingsSchema, branchReconcileConfigSchema, validateRequest } from '../lib/validation.js'; +import { backupConfigSchema, sharingSettingsPatchSchema, featureProviderConfigSchema, codeReviewSettingsSchema, locationSettingsSchema, settingsEmbeddingsSchema, citySnapshotConfigSchema, imessageConfigSchema, apiAccessSettingsSchema, loraTrainingConfigSchema, pipelineEditorialChecksSettingsSchema, creativeDirectorSettingsSchema, validateRequest } from '../lib/validation.js'; const router = Router(); @@ -177,19 +176,6 @@ router.put('/', asyncHandler(async (req, res) => { if (req.body?.pipelineEditorialChecks !== undefined) { validateRequest(pipelineEditorialChecksSettingsSchema.partial(), req.body.pipelineEditorialChecks); } - // Branch & PR Reconciler slice (#branch-reconcile) — validate when present so a - // malformed toggle save can't write a non-boolean enabled / bad cron the - // scheduler would then choke on. - if (req.body?.branchReconcile !== undefined) { - validateRequest(branchReconcileConfigSchema.partial(), req.body.branchReconcile); - // Range-check the cron against the scheduler's own parser (the schema regex - // only counts fields — it can't reject `99 99 * * *`, which would register a - // job that never fires). - const cron = req.body.branchReconcile.cron; - if (cron !== undefined && !isValidCron(cron)) { - throw new ServerError(`Invalid cron expression: ${cron}`, { status: 400, code: 'INVALID_CRON' }); - } - } // User-defined catalog types moved out of settings.json into PostgreSQL // (`catalog_user_types`, #1001). The `/api/catalog/types` routes are the only // write path; a `catalogUserTypes` key in a PUT /api/settings body (legacy diff --git a/server/routes/settings.test.js b/server/routes/settings.test.js index 94ec56403..963cd5691 100644 --- a/server/routes/settings.test.js +++ b/server/routes/settings.test.js @@ -81,31 +81,3 @@ describe('Settings routes — apiAccess slice', () => { expect(res.body.apiAccess.sdapi.exposed).toBe(true); }); }); - -describe('Settings routes — branchReconcile slice', () => { - beforeEach(() => { store = {}; }); - - it('accepts a valid branchReconcile patch', async () => { - const res = await request(buildApp()) - .put('/api/settings') - .send({ branchReconcile: { enabled: true, cron: '0 3 * * *' } }); - expect(res.status).toBe(200); - expect(res.body.branchReconcile.enabled).toBe(true); - }); - - it('rejects a cron with valid field count but out-of-range values (scheduler cannot run it)', async () => { - const res = await request(buildApp()) - .put('/api/settings') - .send({ branchReconcile: { enabled: true, cron: '99 99 * * *' } }); - expect(res.status).toBe(400); - expect(res.body.code).toBe('INVALID_CRON'); - }); - - it('rejects a malformed cron field count via the schema', async () => { - const res = await request(buildApp()) - .put('/api/settings') - .send({ branchReconcile: { cron: '0 0 3 * * *' } }); // 6 fields - expect(res.status).toBe(400); - expect(res.body.code).toBe('VALIDATION_ERROR'); - }); -}); diff --git a/server/services/branchReconcile.js b/server/services/branchReconcile.js index 8134b0815..d7d6d600c 100644 --- a/server/services/branchReconcile.js +++ b/server/services/branchReconcile.js @@ -259,3 +259,85 @@ export async function reconcile(repoPath = PATHS.root, { cleanup = true, activeA return { defaultBranch, cleaned, inFlight, wip, skipped }; } + +// ============================================================ +// Coordinator prompt helpers (Tier-2 dispatch) +// +// These turn the classified `inFlight` set into the actionable subset + the +// Markdown block injected into the `branch-reconcile` CoS task prompt. They live +// here (next to the classifier that produces their input) rather than in the +// scheduler/generator so both the perpetual-drain gate and any prompt builder +// share one source of truth. The `actions` object mirrors the per-app task +// metadata toggles (cleanupMerged / openPr / resolveConflicts / autoMerge). +// ============================================================ + +/** An action is ON unless the config explicitly set it to false (opt-out). */ +export const actionOn = (actions, key) => actions?.[key] !== false; + +/** + * Which in-flight branches have an enabled action? Pure — drives both the + * drain gate (dispatch nothing when empty → park) and the prompt payload. + * @param {object[]} inFlight - reconcile()'s inFlight entries (with `state`) + * @param {object} actions - the per-app action toggles + */ +export function filterActionable(inFlight, actions) { + return inFlight.filter((b) => { + if (b.state === 'NEEDS_PR') return actionOn(actions, 'openPr'); + if (b.state === 'CONFLICTED') return actionOn(actions, 'resolveConflicts'); + if (b.state === 'IN_REVIEW') return actionOn(actions, 'resolveConflicts') || actionOn(actions, 'autoMerge'); + return false; + }); +} + +/** Per-state one-line instruction to the coordinator/sub-agent. */ +export function desiredEndState(state, actions) { + if (state === 'NEEDS_PR') { + return 'Verify the branch\'s work is complete and ready (tests pass, no stubs/TODO markers, changelog present). If ready, run `/do:pr`. If NOT ready, report it as incomplete and leave the branch untouched — do not open a half-baked PR.'; + } + if (state === 'CONFLICTED') { + return 'Rebase the branch onto the default branch, resolve all conflicts, run the tests, and push.'; + } + // IN_REVIEW + const canMerge = actionOn(actions, 'autoMerge'); + return `Drive the open PR toward green: request/await the Copilot review and address feedback.${canMerge + ? ' Then MERGE it (`gh pr merge --merge --delete-branch`) ONLY when it is MERGEABLE, CI is fully green, and the LATEST Copilot review reports "0 comments" (pre-resolved threads do NOT count; a PR over 20k lines is exempt from the Copilot check and needs only CI-green + mergeable). After merging, delete the local branch and remove its worktree.' + : ' Do NOT merge (auto-merge is disabled) — stop once the PR is green and ready for the user to merge.'}`; +} + +/** + * Stable signature of an actionable set — used by the perpetual drain to detect + * PROGRESS between dispatches. A productive coordinator run advances branches + * through states (NEEDS_PR → IN_REVIEW → merged/cleaned) or removes them, all of + * which change this signature; a run that leaves the SAME branches in the SAME + * states (a `NEEDS_PR` branch the agent judged "not ready", an `IN_REVIEW` PR + * blocked on human review / red CI) produces an identical signature, which the + * generator treats as "no progress → park" instead of re-dispatching an + * identical coordinator back-to-back. Order-independent (sorted). + * @param {object[]} actionable - post-filterActionable branches + * @returns {string} + */ +export function actionableSignature(actionable) { + return actionable + .map((b) => `${b.branch}:${b.state}:${b.openPr?.number ?? 'none'}:${b.openPr?.mergeable ?? 'none'}`) + .sort() + .join('|'); +} + +/** + * Render the actionable in-flight branch set into the coordinator prompt body + * (injected as `{inFlightBranches}`). + * @param {object[]} inFlight - actionable branches (post-filterActionable) + * @param {{ defaultBranch:string, actions:object }} ctx + * @returns {string} + */ +export function formatInFlightForPrompt(inFlight, { defaultBranch, actions }) { + const lines = [`Default branch: \`${defaultBranch}\`. Branches to reconcile (${inFlight.length}):`, '']; + for (const b of inFlight) { + const pr = b.openPr ? ` — PR #${b.openPr.number} (${b.openPr.mergeable})${b.openPr.url ? ` ${b.openPr.url}` : ''}` : ' — no PR'; + lines.push(`### \`${b.branch}\` [${b.state}]${pr}`); + if (b.worktreePath) lines.push(`- Worktree: \`${b.worktreePath}\``); + lines.push(`- Do: ${desiredEndState(b.state, actions)}`); + lines.push(''); + } + return lines.join('\n'); +} diff --git a/server/services/branchReconcile.test.js b/server/services/branchReconcile.test.js index dabb7fbbf..829f4629a 100644 --- a/server/services/branchReconcile.test.js +++ b/server/services/branchReconcile.test.js @@ -36,7 +36,8 @@ vi.mock('../lib/fileUtils.js', () => ({ })); import { - classifyBranch, classifyBranches, cleanupMerged, reconcile, gatherBranchState, worktreeProtectionReason + classifyBranch, classifyBranches, cleanupMerged, reconcile, gatherBranchState, worktreeProtectionReason, + actionOn, filterActionable, desiredEndState, formatInFlightForPrompt, actionableSignature } from './branchReconcile.js'; import * as git from './git.js'; import * as wt from './worktreeManager.js'; @@ -199,3 +200,99 @@ describe('reconcile', () => { expect(res.wip.map((i) => i.branch)).toEqual(['wip-local']); }); }); + +describe('actionOn', () => { + it('is ON for absent/true, OFF only for explicit false', () => { + expect(actionOn(undefined, 'openPr')).toBe(true); + expect(actionOn({}, 'openPr')).toBe(true); + expect(actionOn({ openPr: true }, 'openPr')).toBe(true); + expect(actionOn({ openPr: false }, 'openPr')).toBe(false); + }); +}); + +describe('filterActionable', () => { + const inFlight = [ + { branch: 'a', state: 'NEEDS_PR' }, + { branch: 'b', state: 'CONFLICTED' }, + { branch: 'c', state: 'IN_REVIEW' } + ]; + + it('keeps every state when all actions are on (defaults)', () => { + expect(filterActionable(inFlight, {}).map((b) => b.branch)).toEqual(['a', 'b', 'c']); + }); + + it('drops NEEDS_PR when openPr is off', () => { + expect(filterActionable(inFlight, { openPr: false }).map((b) => b.branch)).toEqual(['b', 'c']); + }); + + it('drops CONFLICTED when resolveConflicts is off, and IN_REVIEW only when BOTH resolveConflicts and autoMerge are off', () => { + expect(filterActionable(inFlight, { resolveConflicts: false }).map((b) => b.branch)).toEqual(['a', 'c']); + expect(filterActionable(inFlight, { resolveConflicts: false, autoMerge: false }).map((b) => b.branch)).toEqual(['a']); + }); + + it('never surfaces a non-actionable state (MERGED/WIP)', () => { + expect(filterActionable([{ branch: 'm', state: 'MERGED' }, { branch: 'w', state: 'WIP' }], {})).toEqual([]); + }); +}); + +describe('desiredEndState', () => { + it('tells IN_REVIEW to merge only when autoMerge is on', () => { + expect(desiredEndState('IN_REVIEW', {})).toContain('gh pr merge --merge --delete-branch'); + expect(desiredEndState('IN_REVIEW', { autoMerge: false })).toContain('Do NOT merge'); + }); + + it('gives NEEDS_PR a completeness gate and CONFLICTED a rebase instruction', () => { + expect(desiredEndState('NEEDS_PR', {})).toContain('/do:pr'); + expect(desiredEndState('CONFLICTED', {})).toContain('Rebase'); + }); +}); + +describe('actionableSignature', () => { + it('is order-independent', () => { + const a = [{ branch: 'x', state: 'NEEDS_PR', openPr: null }, { branch: 'y', state: 'IN_REVIEW', openPr: { number: 5 } }]; + const b = [{ branch: 'y', state: 'IN_REVIEW', openPr: { number: 5 } }, { branch: 'x', state: 'NEEDS_PR', openPr: null }]; + expect(actionableSignature(a)).toBe(actionableSignature(b)); + }); + + it('changes when a branch advances state (progress) — drives the drain forward', () => { + const before = [{ branch: 'x', state: 'NEEDS_PR', openPr: null }]; + const after = [{ branch: 'x', state: 'IN_REVIEW', openPr: { number: 9 } }]; + expect(actionableSignature(before)).not.toBe(actionableSignature(after)); + }); + + it('is identical for the same stuck set (no progress) — the park trigger', () => { + const set = [{ branch: 'stuck', state: 'NEEDS_PR', openPr: null }]; + expect(actionableSignature(set)).toBe(actionableSignature([{ branch: 'stuck', state: 'NEEDS_PR', openPr: null }])); + }); + + it('changes when a branch leaves the set (merged/cleaned)', () => { + const two = [{ branch: 'a', state: 'IN_REVIEW', openPr: { number: 1 } }, { branch: 'b', state: 'NEEDS_PR', openPr: null }]; + const one = [{ branch: 'b', state: 'NEEDS_PR', openPr: null }]; + expect(actionableSignature(two)).not.toBe(actionableSignature(one)); + }); + + it('changes when an IN_REVIEW PR becomes mergeable (readiness advanced within the same state)', () => { + const unknown = [{ branch: 'a', state: 'IN_REVIEW', openPr: { number: 3, mergeable: 'UNKNOWN' } }]; + const mergeable = [{ branch: 'a', state: 'IN_REVIEW', openPr: { number: 3, mergeable: 'MERGEABLE' } }]; + expect(actionableSignature(unknown)).not.toBe(actionableSignature(mergeable)); + }); + + it('empty set yields an empty signature', () => { + expect(actionableSignature([])).toBe(''); + }); +}); + +describe('formatInFlightForPrompt', () => { + it('renders the default branch, each branch with its PR + worktree + Do line', () => { + const block = formatInFlightForPrompt([ + { branch: 'next/issue-1', state: 'IN_REVIEW', worktreePath: '/wt/1', openPr: { number: 42, mergeable: 'MERGEABLE', url: 'https://pr/42' } }, + { branch: 'next/issue-2', state: 'NEEDS_PR' } + ], { defaultBranch: 'main', actions: {} }); + expect(block).toContain('Default branch: `main`'); + expect(block).toContain('Branches to reconcile (2)'); + expect(block).toContain('### `next/issue-1` [IN_REVIEW] — PR #42 (MERGEABLE) https://pr/42'); + expect(block).toContain('- Worktree: `/wt/1`'); + expect(block).toContain('### `next/issue-2` [NEEDS_PR] — no PR'); + expect(block).toContain('- Do: '); + }); +}); diff --git a/server/services/branchReconcileScheduler.js b/server/services/branchReconcileScheduler.js deleted file mode 100644 index 7d8d41879..000000000 --- a/server/services/branchReconcileScheduler.js +++ /dev/null @@ -1,224 +0,0 @@ -/** - * Branch & PR Reconciler — scheduler + coordinator dispatch (Tier 2). - * - * Registers a daily cron (via eventScheduler, the backupScheduler.js pattern) - * that runs the deterministic Tier-1 `reconcile()` and, when in-flight branches - * remain, enqueues ONE coordinator CoS task. The coordinator agent orchestrates - * one sub-agent per in-flight branch (each in that branch's existing worktree) - * to open PRs, resolve conflicts, drive review loops, and merge when fully green. - * - * The whole feature is opt-in: the cron handler no-ops unless - * `settings.branchReconcile.enabled` is true. The API "Run now" path passes - * `{ force: true }` — an explicit user action is its own consent, so it runs even - * while the schedule is off (letting the user test on demand). - */ - -import { schedule, cancel } from './eventScheduler.js'; -import { getSettings } from './settings.js'; -import { getUserTimezone } from '../lib/timezone.js'; -import { PATHS } from '../lib/fileUtils.js'; -import { PRIORITY_VALUES, addTask } from './cosTaskStore.js'; -import { reconcile } from './branchReconcile.js'; -import { getDomainMode } from '../lib/domainAutonomy.js'; -import { getActiveAgentIds } from './agentState.js'; - -const CRON_ID = 'branch-reconcile'; - -// Stable first-line description → addTask dedups it against any pending/in_progress -// coordinator, so a later daily run can't stack a second coordinator on top of one -// still finishing (the re-entrancy guard, for free). -const COORDINATOR_DESCRIPTION = 'Branch & PR reconcile: finish this machine\'s in-flight local branches'; - -// Last run summary, surfaced by GET /api/branch-reconcile/status. -let lastRun = null; -export function getLastRun() { return lastRun; } - -/** An action is ON unless the settings explicitly set it to false (opt-out). */ -const actionOn = (actions, key) => actions?.[key] !== false; - -/** - * Which in-flight branches have an enabled action? Pure — drives both the - * dispatch gate and the prompt payload. - * @param {object[]} inFlight - reconcile()'s inFlight entries (with `state`) - * @param {object} actions - settings.branchReconcile.actions - */ -export function filterActionable(inFlight, actions) { - return inFlight.filter((b) => { - if (b.state === 'NEEDS_PR') return actionOn(actions, 'openPr'); - if (b.state === 'CONFLICTED') return actionOn(actions, 'resolveConflicts'); - if (b.state === 'IN_REVIEW') return actionOn(actions, 'resolveConflicts') || actionOn(actions, 'autoMerge'); - return false; - }); -} - -/** Per-state one-line instruction to the coordinator/sub-agent. */ -function desiredEndState(state, actions) { - if (state === 'NEEDS_PR') { - return 'Verify the branch\'s work is complete and ready (tests pass, no stubs/TODO markers, changelog present). If ready, run `/do:pr`. If NOT ready, report it as incomplete and leave the branch untouched — do not open a half-baked PR.'; - } - if (state === 'CONFLICTED') { - return 'Rebase the branch onto the default branch, resolve all conflicts, run the tests, and push.'; - } - // IN_REVIEW - const canMerge = actionOn(actions, 'autoMerge'); - return `Drive the open PR toward green: request/await the Copilot review and address feedback.${canMerge - ? ' Then MERGE it (`gh pr merge --merge --delete-branch`) ONLY when it is MERGEABLE, CI is fully green, and the LATEST Copilot review reports "0 comments" (pre-resolved threads do NOT count; a PR over 20k lines is exempt from the Copilot check and needs only CI-green + mergeable). After merging, delete the local branch and remove its worktree.' - : ' Do NOT merge (auto-merge is disabled) — stop once the PR is green and ready for the user to merge.'}`; -} - -/** - * Render the in-flight branch set into the coordinator prompt body. - * @param {object[]} inFlight - actionable branches - * @param {{ defaultBranch:string, actions:object }} ctx - * @returns {string} - */ -export function formatInFlightForPrompt(inFlight, { defaultBranch, actions }) { - const lines = [`Default branch: \`${defaultBranch}\`. Branches to reconcile (${inFlight.length}):`, '']; - for (const b of inFlight) { - const pr = b.openPr ? ` — PR #${b.openPr.number} (${b.openPr.mergeable})${b.openPr.url ? ` ${b.openPr.url}` : ''}` : ' — no PR'; - lines.push(`### \`${b.branch}\` [${b.state}]${pr}`); - if (b.worktreePath) lines.push(`- Worktree: \`${b.worktreePath}\``); - lines.push(`- Do: ${desiredEndState(b.state, actions)}`); - lines.push(''); - } - return lines.join('\n'); -} - -/** - * Build the coordinator CoS task (raw shape, ready for addTask({raw:true})). - * Multi-line instructions live in `metadata.context` (survives the TASKS.md - * round-trip); `description` is a stable single line for dedup. - * - * @param {object[]} inFlight - actionable in-flight branches - * @param {{ defaultBranch:string, actions:object, now?:number }} ctx - * @returns {object} task object - */ -export function buildCoordinatorTask(inFlight, { defaultBranch, actions, now = 0 }) { - const body = [ - '# Branch & PR Reconciliation', - '', - 'You are the coordinator for finishing this machine\'s unfinished local git work. Each branch below is a LOCAL branch on this machine (peer branches are never included). Spawn ONE sub-agent per branch (they are independent — run them in parallel) to carry out the "Do:" instruction for that branch, each working in the branch\'s existing worktree when it has one. Never touch any branch not listed here.', - '', - formatInFlightForPrompt(inFlight, { defaultBranch, actions }), - '## Rules', - '- Work only on the branches listed above.', - '- Never force-push the default branch and never merge unreviewed work.', - '- If a sub-agent reports a branch is incomplete or blocked, leave it and note it in your summary.', - '- Summarize what each branch ended up doing (PR opened / conflicts resolved / merged / left incomplete).' - ].join('\n'); - - return { - id: `sys-branch-reconcile-${now.toString(36)}`, - status: 'pending', - priority: 'LOW', - priorityValue: PRIORITY_VALUES.LOW, - description: COORDINATOR_DESCRIPTION, - metadata: { - context: body, - // Runs on the PortOS checkout itself (it orchestrates across sibling - // worktrees), so no isolated worktree for the coordinator. - useWorktree: false, - source: 'branchReconcile', - updatedAt: new Date(now).toISOString() - }, - approvalRequired: false, - autoApproved: true, - section: 'pending' - }; -} - -/** - * Run one reconcile pass. Wrapped in try/catch — it runs from a cron handler and - * the API route, both outside the Express request lifecycle. - * - * @param {{ force?: boolean, now?: number }} [opts] - * @returns {Promise} summary - */ -export async function runBranchReconcile({ force = false, now = Date.now() } = {}) { - try { - const settings = await getSettings(); - const cfg = settings.branchReconcile || {}; - if (!force && !cfg.enabled) { - lastRun = { at: new Date(now).toISOString(), skipped: 'disabled' }; - return lastRun; - } - const actions = cfg.actions || {}; - - const result = await reconcile(PATHS.root, { - cleanup: actionOn(actions, 'cleanupMerged'), - activeAgentIds: new Set(getActiveAgentIds()) - }); - const actionable = filterActionable(result.inFlight, actions); - - let queued = false; - // The coordinator is an auto-approved INTERNAL task, which the CoS runner - // only actually spawns when its autonomy mode for the `cos` domain is - // `execute` (off/dry-run leave it queued). Surface the mode so the summary - // can't claim an agent will run when it won't — the reconciler's enable - // toggle is independent of the CoS auto-run setting. - let cosAutonomy = 'unknown'; - if (actionable.length > 0) { - const task = buildCoordinatorTask(actionable, { - defaultBranch: result.defaultBranch, actions, now - }); - const added = await addTask(task, 'internal', { raw: true }); - queued = !added?.duplicate; - if (added?.duplicate) { - console.log('🔀 branch-reconcile: a coordinator is already in flight — skipping dispatch'); - } - const { getConfig } = await import('./cos.js'); - const config = await getConfig().catch(() => null); - cosAutonomy = config ? getDomainMode(config, 'cos') : 'unknown'; - } - - const summary = { - at: new Date(now).toISOString(), - cleaned: result.cleaned, - inFlight: result.inFlight.map((b) => ({ branch: b.branch, state: b.state })), - actionable: actionable.map((b) => b.branch), - wip: result.wip.map((b) => b.branch), - skipped: result.skipped, - queued, - // true only when the coordinator was queued AND the CoS runner will spawn it. - coordinatorWillRun: queued && cosAutonomy === 'execute', - cosAutonomy - }; - lastRun = summary; - console.log(`🔀 branch-reconcile: cleaned ${result.cleaned.length}, ${actionable.length} actionable, queued=${queued}, cosAutonomy=${cosAutonomy}`); - return summary; - } catch (err) { - console.error(`❌ branch-reconcile run failed: ${err.message}`); - lastRun = { at: new Date(now).toISOString(), error: err.message }; - return lastRun; - } -} - -/** - * Register the daily cron. No-ops (leaves the job unregistered) when disabled; - * the handler re-reads settings each run so action toggles take effect live, but - * changing the cron expression itself needs a restart (matches backupScheduler). - */ -export async function startBranchReconcileScheduler() { - const settings = await getSettings(); - const cfg = settings.branchReconcile || {}; - if (!cfg.enabled) { - console.log('🔀 branch-reconcile scheduler: disabled in settings — skipping'); - return; - } - const cron = cfg.cron || '0 3 * * *'; - const timezone = await getUserTimezone(); - schedule({ - id: CRON_ID, - type: 'cron', - cron, - timezone, - handler: () => runBranchReconcile(), - metadata: { source: 'branchReconcileScheduler' } - }); - console.log(`🔀 branch-reconcile scheduler: registered at cron "${cron}"`); -} - -export function stopBranchReconcileScheduler() { - cancel(CRON_ID); - console.log('🔀 branch-reconcile scheduler: stopped'); -} diff --git a/server/services/branchReconcileScheduler.test.js b/server/services/branchReconcileScheduler.test.js deleted file mode 100644 index c9bdc43c5..000000000 --- a/server/services/branchReconcileScheduler.test.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Unit tests for the Branch & PR Reconciler scheduler + coordinator dispatch. - * - * - filterActionable — action toggles gate which in-flight branches dispatch. - * - buildCoordinatorTask — stable dedup description, multi-line body in - * metadata.context, auto-approved, no worktree. - * - runBranchReconcile — disabled short-circuit, force bypass, cleanup toggle, - * dispatch only when actionable, duplicate handling. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -vi.mock('./eventScheduler.js', () => ({ schedule: vi.fn(), cancel: vi.fn() })); -vi.mock('./settings.js', () => ({ getSettings: vi.fn() })); -vi.mock('../lib/timezone.js', () => ({ getUserTimezone: vi.fn(async () => 'UTC') })); -vi.mock('../lib/fileUtils.js', () => ({ PATHS: { root: '/repo' } })); -vi.mock('./cosTaskStore.js', () => ({ - PRIORITY_VALUES: { LOW: 1, MEDIUM: 2, HIGH: 3 }, - addTask: vi.fn(async () => ({ id: 'sys-branch-reconcile-x' })) -})); -vi.mock('./branchReconcile.js', () => ({ reconcile: vi.fn() })); -vi.mock('./cos.js', () => ({ getConfig: vi.fn(async () => ({ domainAutonomy: { cos: 'execute' } })) })); -vi.mock('./agentState.js', () => ({ getActiveAgentIds: vi.fn(() => []) })); -vi.mock('../lib/domainAutonomy.js', () => ({ getDomainMode: vi.fn((config) => config?.domainAutonomy?.cos || 'off') })); - -import { - filterActionable, formatInFlightForPrompt, buildCoordinatorTask, runBranchReconcile, getLastRun -} from './branchReconcileScheduler.js'; -import { getSettings } from './settings.js'; -import { addTask } from './cosTaskStore.js'; -import { reconcile } from './branchReconcile.js'; -import { getConfig } from './cos.js'; - -beforeEach(() => { - vi.clearAllMocks(); - addTask.mockResolvedValue({ id: 'sys-branch-reconcile-x' }); - getConfig.mockResolvedValue({ domainAutonomy: { cos: 'execute' } }); -}); - -const IN_FLIGHT = [ - { branch: 'needspr', state: 'NEEDS_PR', openPr: null }, - { branch: 'conflicted', state: 'CONFLICTED', openPr: { number: 1, mergeable: 'CONFLICTING' } }, - { branch: 'inreview', state: 'IN_REVIEW', openPr: { number: 2, mergeable: 'MERGEABLE', url: 'u' } } -]; - -describe('filterActionable', () => { - it('all states pass when all actions default on', () => { - expect(filterActionable(IN_FLIGHT, {}).map((b) => b.branch)) - .toEqual(['needspr', 'conflicted', 'inreview']); - }); - it('drops NEEDS_PR when openPr disabled', () => { - expect(filterActionable(IN_FLIGHT, { openPr: false }).map((b) => b.branch)) - .toEqual(['conflicted', 'inreview']); - }); - it('drops CONFLICTED when resolveConflicts disabled', () => { - expect(filterActionable(IN_FLIGHT, { resolveConflicts: false }).map((b) => b.branch)) - .toEqual(['needspr', 'inreview']); // IN_REVIEW still kept via autoMerge - }); - it('drops IN_REVIEW only when both resolveConflicts and autoMerge disabled', () => { - expect(filterActionable(IN_FLIGHT, { resolveConflicts: false, autoMerge: false }).map((b) => b.branch)) - .toEqual(['needspr']); - }); -}); - -describe('formatInFlightForPrompt', () => { - it('renders a section per branch with state, PR, and a Do line', () => { - const out = formatInFlightForPrompt(IN_FLIGHT, { defaultBranch: 'main', actions: {} }); - expect(out).toContain('`needspr` [NEEDS_PR]'); - expect(out).toContain('PR #2 (MERGEABLE)'); - expect(out).toContain('Do:'); - }); - it('IN_REVIEW omits merge instruction when autoMerge is off', () => { - const out = formatInFlightForPrompt([IN_FLIGHT[2]], { defaultBranch: 'main', actions: { autoMerge: false } }); - expect(out).toContain('Do NOT merge'); - }); -}); - -describe('buildCoordinatorTask', () => { - it('produces a raw, auto-approved task with body in metadata.context', () => { - const task = buildCoordinatorTask(IN_FLIGHT, { defaultBranch: 'main', actions: {}, now: 0 }); - expect(task.autoApproved).toBe(true); - expect(task.status).toBe('pending'); - expect(task.metadata.useWorktree).toBe(false); - expect(task.metadata.context).toContain('Branch & PR Reconciliation'); - expect(task.metadata.context).toContain('`conflicted`'); - // Stable description = dedup key across daily runs. - expect(task.description).toBe("Branch & PR reconcile: finish this machine's in-flight local branches"); - }); -}); - -describe('runBranchReconcile', () => { - it('short-circuits when disabled (no reconcile)', async () => { - getSettings.mockResolvedValue({ branchReconcile: { enabled: false } }); - const res = await runBranchReconcile({ now: 0 }); - expect(res.skipped).toBe('disabled'); - expect(reconcile).not.toHaveBeenCalled(); - }); - - it('force bypasses the disabled gate', async () => { - getSettings.mockResolvedValue({ branchReconcile: { enabled: false } }); - reconcile.mockResolvedValue({ defaultBranch: 'main', cleaned: [], inFlight: [], wip: [], skipped: [] }); - const res = await runBranchReconcile({ force: true, now: 0 }); - expect(res.skipped).not.toBe('disabled'); // ran, not short-circuited - expect(reconcile).toHaveBeenCalledWith('/repo', { cleanup: true, activeAgentIds: expect.any(Set) }); - }); - - it('passes cleanup:false when cleanupMerged disabled', async () => { - getSettings.mockResolvedValue({ branchReconcile: { enabled: true, actions: { cleanupMerged: false } } }); - reconcile.mockResolvedValue({ defaultBranch: 'main', cleaned: [], inFlight: [], wip: [], skipped: [] }); - await runBranchReconcile({ now: 0 }); - expect(reconcile).toHaveBeenCalledWith('/repo', { cleanup: false, activeAgentIds: expect.any(Set) }); - }); - - it('dispatches a coordinator when actionable branches exist', async () => { - getSettings.mockResolvedValue({ branchReconcile: { enabled: true, actions: {} } }); - reconcile.mockResolvedValue({ - defaultBranch: 'main', cleaned: ['old'], inFlight: IN_FLIGHT, wip: [], skipped: [] - }); - const res = await runBranchReconcile({ now: 0 }); - expect(addTask).toHaveBeenCalledTimes(1); - expect(addTask.mock.calls[0][1]).toBe('internal'); - expect(res.queued).toBe(true); - expect(res.coordinatorWillRun).toBe(true); // cos autonomy mocked to 'execute' - expect(res.cosAutonomy).toBe('execute'); - expect(res.actionable).toEqual(['needspr', 'conflicted', 'inreview']); - }); - - it('reports coordinatorWillRun=false when CoS autonomy is not execute', async () => { - getSettings.mockResolvedValue({ branchReconcile: { enabled: true, actions: {} } }); - reconcile.mockResolvedValue({ defaultBranch: 'main', cleaned: [], inFlight: IN_FLIGHT, wip: [], skipped: [] }); - getConfig.mockResolvedValue({ domainAutonomy: { cos: 'off' } }); - const res = await runBranchReconcile({ now: 0 }); - expect(res.queued).toBe(true); - expect(res.coordinatorWillRun).toBe(false); - expect(res.cosAutonomy).toBe('off'); - }); - - it('does not queue when nothing is actionable', async () => { - getSettings.mockResolvedValue({ branchReconcile: { enabled: true, actions: {} } }); - reconcile.mockResolvedValue({ defaultBranch: 'main', cleaned: [], inFlight: [], wip: ['x'], skipped: [] }); - const res = await runBranchReconcile({ now: 0 }); - expect(addTask).not.toHaveBeenCalled(); - expect(res.queued).toBe(false); - }); - - it('reports queued=false when addTask says duplicate', async () => { - getSettings.mockResolvedValue({ branchReconcile: { enabled: true, actions: {} } }); - reconcile.mockResolvedValue({ defaultBranch: 'main', cleaned: [], inFlight: IN_FLIGHT, wip: [], skipped: [] }); - addTask.mockResolvedValue({ id: 'existing', duplicate: true }); - const res = await runBranchReconcile({ now: 0 }); - expect(res.queued).toBe(false); - expect(getLastRun()).toBe(res); - }); -}); diff --git a/server/services/cosTaskGenerator.js b/server/services/cosTaskGenerator.js index 8ac952118..e4403d335 100644 --- a/server/services/cosTaskGenerator.js +++ b/server/services/cosTaskGenerator.js @@ -1582,7 +1582,11 @@ export async function generateManagedAppImprovementTaskForType(taskType, app, st // tick retries instead of waiting out a full recheck cadence. // The detector keys on the RESOLVED promptTaskType so a claim-work router run // probes the concrete tracker (claim-issue → GitHub issues, plan-task → PLAN.md). - if (interval.type === taskSchedule.INTERVAL_TYPES.PERPETUAL) { + // branch-reconcile is PERPETUAL but is NOT gated by the generic work-detector + // registry — its "detector" and its actual work are the SAME scan (the + // deterministic reconcile below), so splitting them would double the git/gh + // I/O. Its dedicated block further down does the park/clear itself. + if (interval.type === taskSchedule.INTERVAL_TYPES.PERPETUAL && taskType !== 'branch-reconcile') { const { detectActionableWork } = await import('./perpetualWork.js'); const detection = await detectActionableWork(promptTaskType, app, { issueAuthorFilter: metadata.issueAuthorFilter || 'self' @@ -1600,6 +1604,84 @@ export async function generateManagedAppImprovementTaskForType(taskType, app, st } } + // branch-reconcile: deterministic pre-step (the migrated Tier-1 core). Run the + // peer-safe reconcile on THIS app's repo — remove fully-merged orphaned local + // branches + their worktrees, then classify the rest. The coordinator agent is + // dispatched only while actionable in-flight branches remain; when none do, the + // perpetual drain PARKS on the recheck cadence. `{inFlightBranches}` carries the + // actionable set into the prompt. Runs in the app's live checkout (no LLM here — + // git/gh only), mirroring how pr-watcher/reference-watch do their programmatic + // work in the generator before dispatch. + let inFlightBranchesBlock = ''; + if (taskType === 'branch-reconcile') { + const { reconcile, filterActionable, formatInFlightForPrompt, actionableSignature } = await import('./branchReconcile.js'); + const { getActiveAgentIds } = await import('./agentState.js'); + // Action toggles were merged (global → per-app override) + value-constrained + // by sanitizeTaskMetadata into `metadata`; each is ON unless explicitly false. + const actions = { + cleanupMerged: metadata.cleanupMerged, + openPr: metadata.openPr, + resolveConflicts: metadata.resolveConflicts, + autoMerge: metadata.autoMerge + }; + const result = await reconcile(app.repoPath, { + cleanup: actions.cleanupMerged !== false, + activeAgentIds: new Set(getActiveAgentIds()) + }).catch((err) => { + emitLog('warn', `branch-reconcile pre-step failed for ${app.name}: ${err.message}`, { appId: app.id }); + return null; + }); + // A failed scan is treated as transient (git/gh blip) — skip WITHOUT parking + // so the next tick retries instead of waiting out a full recheck cadence. + // Trade-off: a PERSISTENT failure (bad repoPath, broken gh auth, non-git dir) + // re-probes git every tick. That's git/gh-only cost (no LLM), and parking on + // the first failure would delay recovery from a genuine blip by a full + // recheck cadence — so transient-skip is the intentional choice here. + if (!result) return null; + if (result.cleaned.length) { + emitLog('info', `🔀 branch-reconcile ${app.name}: cleaned ${result.cleaned.length} merged branch(es)`, { appId: app.id, analysisType: taskType }); + } + const actionable = filterActionable(result.inFlight, actions); + if (actionable.length === 0) { + // Definitive idle: nothing in-flight to drive. Park on the recheck cadence + // and clear the progress signature so a fresh set later dispatches. + await taskSchedule.parkPerpetual(taskType, app.id, { reason: 'no-in-flight-branches', actionableCount: 0, signature: null }); + emitLog('info', `🔀 branch-reconcile parked for ${app.name}: nothing in-flight (cleaned ${result.cleaned.length})`, { appId: app.id }); + return null; + } + // Convergence guard — kills the back-to-back re-dispatch loop WITHOUT stalling + // slow PRs. The signature (branch:state:pr#:mergeable) is stored at dispatch; + // when the perpetual-drain refill fires right after the coordinator completes + // (metadata.perpetual bypasses the post-completion cooldown), we compare: + // - signature CHANGED → the run made progress (branch advanced + // NEEDS_PR→IN_REVIEW→merged/cleaned, conflicts resolved, PR became + // mergeable) → keep draining back-to-back. + // - signature UNCHANGED → the run left the same branches in the same states + // (a NEEDS_PR branch judged "not ready", an IN_REVIEW PR blocked on human + // review / red CI). Park on the recheck cadence AND CLEAR the stored + // signature — so the next DAILY recheck re-dispatches unconditionally + // (re-driving the PR in case CI turned green / review cleared overnight), + // rather than seeing the same signature and re-parking forever. The + // signature only suppresses the immediate back-to-back refill; it never + // suppresses a recheck. + const signature = actionableSignature(actionable); + const lastSignature = await taskSchedule.getPerpetualSignature(taskType, app.id); + if (signature === lastSignature) { + await taskSchedule.parkPerpetual(taskType, app.id, { reason: 'no-progress', actionableCount: actionable.length, signature: null }); + emitLog('info', `🔀 branch-reconcile parked for ${app.name}: ${actionable.length} branch(es) unchanged since last run (no progress — will re-drive on next recheck)`, { appId: app.id }); + return null; + } + // New or advanced actionable set — drive it. Resume the drain (clear any park) + // and record the signature so an unproductive back-to-back refill parks instead + // of looping. Skip the post-completion cooldown so productive progress drains + // back-to-back. + await taskSchedule.clearPerpetualPark(taskType, app.id); + await taskSchedule.setPerpetualSignature(taskType, app.id, signature); + metadata.perpetual = true; + inFlightBranchesBlock = formatInFlightForPrompt(actionable, { defaultBranch: result.defaultBranch, actions }); + emitLog('info', `🔀 branch-reconcile dispatching for ${app.name}: ${actionable.length} in-flight branch(es)`, { appId: app.id, analysisType: taskType }); + } + // Honor a direct claim-work prompt customization if the user set one; // otherwise delegate to the resolved tracker's prompt body via // getTaskPrompt(promptTaskType), which reads THAT type's interval.prompt @@ -1763,6 +1845,7 @@ export async function generateManagedAppImprovementTaskForType(taskType, app, st // would get mangled. The function form passes the value verbatim. .replace(/\{referenceData\}/g, () => referenceDataBlock) .replace(/\{prData\}/g, () => prDataBlock) + .replace(/\{inFlightBranches\}/g, () => inFlightBranchesBlock) .replace(/\{repoFullName\}/g, () => prRepoFullName) .replace(/\{defaultBranch\}/g, () => prDefaultBranch) .replace(/\{planConstraint\}/g, () => planConstraintBlock); diff --git a/server/services/taskPromptDefaults/integrity.snapshot.json b/server/services/taskPromptDefaults/integrity.snapshot.json index 0f28eb657..b49156726 100644 --- a/server/services/taskPromptDefaults/integrity.snapshot.json +++ b/server/services/taskPromptDefaults/integrity.snapshot.json @@ -24,6 +24,7 @@ "do-replan": "59a57f1bb445138c30ab4fa23630c6f8", "jira-status-report": "9d374a9d8ccb92c5c8fd0aa9899e79a5", "branch-cleanup": "8fdeb4acc2749816a47ca318b6602721", + "branch-reconcile": "7ef2b5f7d8f05937d4912b26ad45f42e", "pr-reviewer": "add27b67daa6aa2c75717ac96a6bd625", "pr-reviewer-security": "2050297d1cdb081a8576a781828ee44f", "pr-reviewer-review": "21ccd5d3a6f3eea16c479db9ed9aedaa", @@ -42,6 +43,7 @@ "code-reviewer-b": 1, "reference-watch": 3, "pr-watcher": 1, + "branch-reconcile": 1, "refresh-local-llm-catalog": 1, "security": 2, "code-quality": 2, diff --git a/server/services/taskPromptDefaults/prompts.js b/server/services/taskPromptDefaults/prompts.js index b5c125961..996bbb790 100644 --- a/server/services/taskPromptDefaults/prompts.js +++ b/server/services/taskPromptDefaults/prompts.js @@ -13,7 +13,7 @@ import { PORTOS_API_URL } from '../../lib/ports.js'; // ============================================================ -// Unified DEFAULT_TASK_PROMPTS (17 task types) +// Unified DEFAULT_TASK_PROMPTS — one entry per scheduled task type / pipeline stage // All prompts use {appName} and {repoPath} template variables // ============================================================ @@ -1234,6 +1234,24 @@ Repository: {repoPath} IMPORTANT: Never delete unmerged branches. Only delete branches fully merged into the default branch. Use \`git branch -d\` (not -D) for local branches to ensure safety.`, + 'branch-reconcile': `[Improvement: {appName}] Branch & PR Reconciliation + +You are the coordinator for finishing {appName}'s unfinished local git work. The scheduler has already run the deterministic pass (removed fully-merged, orphaned local branches + their worktrees) and handed you ONLY the branches that need judgment. + +Repository: {repoPath} + +Each branch listed below is a LOCAL branch in THIS clone of {appName}. On a machine that is a federated sync peer, branches created on OTHER machines exist here only as remote-tracking refs (\`origin/*\`) and are deliberately NOT listed — never open, rebase, or merge anything that is not in the list below. + +{inFlightBranches} + +Spawn ONE sub-agent per branch (they are independent — run them in parallel) to carry out that branch's "Do:" instruction, each working in the branch's existing worktree when it has one. + +## Rules +- Work ONLY on the branches listed above. Never touch a branch that is not listed. +- Never force-push the default branch and never merge unreviewed work. +- If a sub-agent reports a branch is incomplete or blocked, leave it as-is and note it in your summary. +- Summarize what each branch ended up doing (PR opened / conflicts resolved / merged / left incomplete).`, + // pr-reviewer is now a pipeline — this prompt is kept as fallback for non-pipeline mode 'pr-reviewer': `[Improvement: {appName}] PR Review — Security Scan & Code Review Pipeline diff --git a/server/services/taskPromptDefaults/versions.js b/server/services/taskPromptDefaults/versions.js index 776756e05..ec05013c1 100644 --- a/server/services/taskPromptDefaults/versions.js +++ b/server/services/taskPromptDefaults/versions.js @@ -17,6 +17,7 @@ export const PROMPT_VERSIONS = { 'code-reviewer-b': 1, // v1: 2-stage pipeline (codebase review → triage & implement) 'reference-watch': 3, // v3: record proposals in the app's RESOLVED work tracker (PLAN.md / GitHub / GitLab / JIRA) via the {trackerInstructions} block — no longer hardcodes PLAN.md, so an app configured for GitHub issues gets `gh issue create` proposals. v2: append slug-tagged checklist items to PLAN.md (Adopt + Maybe) instead of writing REFERENCE_REVIEW.md; security-flagged commits get no PLAN entry (mentioned only in final summary) 'pr-watcher': 1, // v1: review-and-comment default for newly-opened PRs on the app's default branch + 'branch-reconcile': 1, // v1: per-app coordinator that finishes in-flight LOCAL branches (open PR / resolve conflicts / drive review / auto-merge) after the deterministic merged-branch cleanup pass. Peer-safe (local refs only). Replaced the PortOS-only branchReconcileScheduler. 'refresh-local-llm-catalog': 1, // v1: research current local models, refresh LOCAL_LLM_CATALOG + EDITORIAL_FAMILY_RANK, PR (PortOS repo only) // Basic self-improvement tasks — versioned so installs created before the diff --git a/server/services/taskSchedule.js b/server/services/taskSchedule.js index 97a6d99bc..799536044 100644 --- a/server/services/taskSchedule.js +++ b/server/services/taskSchedule.js @@ -148,7 +148,7 @@ async function getPerformanceAdjustedInterval(taskType, baseIntervalMs) { // Unified default interval settings for all task types export const SELF_IMPROVEMENT_TASK_TYPES = [ 'security', 'code-quality', 'test-coverage', 'performance', - 'accessibility', 'branch-cleanup', 'console-errors', 'dependency-updates', 'documentation', + 'accessibility', 'branch-cleanup', 'branch-reconcile', 'console-errors', 'dependency-updates', 'documentation', 'ui-bugs', 'mobile-responsive', 'feature-ideas', 'plan-task', 'claim-issue', 'claim-work', 'error-handling', 'typing', 'release-check', 'pr-reviewer', 'code-reviewer-a', 'code-reviewer-b', 'jira-sprint-manager', 'jira-status-report', 'do-replan', @@ -180,6 +180,20 @@ export const DEFAULT_TASK_INTERVALS = { 'performance': { type: INTERVAL_TYPES.WEEKLY, enabled: false, providerId: null, model: null, prompt: null }, 'accessibility': { type: INTERVAL_TYPES.ONCE, enabled: false, providerId: null, model: null, prompt: null }, 'branch-cleanup': { type: INTERVAL_TYPES.WEEKLY, enabled: false, providerId: null, model: null, prompt: null }, + // branch-reconcile finishes THIS machine's in-flight LOCAL branches per app + // (open a PR for pushed-but-unopened work, resolve merge conflicts, drive the + // review loop, auto-merge when green) AFTER a deterministic pass that removes + // fully-merged orphaned branches + their worktrees. PERPETUAL (drain-until-done): + // the generator runs the deterministic reconcile every dispatch, and dispatches + // the coordinator agent only while actionable in-flight branches remain — then + // PARKS on the daily recheckCron. The action toggles (cleanupMerged / openPr / + // resolveConflicts / autoMerge) are per-app taskMetadata booleans (each ON + // unless explicitly false). useWorktree/openPR are LOCKED off (MANAGED_AGENT_OPTIONS): + // the coordinator runs in the app's live checkout so it can see + operate on the + // sibling worktrees; a CoS-managed worktree would hide the branches and could + // trigger cleanupAgentWorktree's auto-merge. Off by default — enabling it is the + // user's explicit consent to let it drive PRs on a schedule. + 'branch-reconcile': { type: INTERVAL_TYPES.PERPETUAL, enabled: false, providerId: null, model: null, prompt: null, recheckCron: '0 3 * * *', taskMetadata: { useWorktree: false, openPR: false, cleanupMerged: true, openPr: true, resolveConflicts: true, autoMerge: true } }, 'console-errors': { type: INTERVAL_TYPES.ROTATION, enabled: false, providerId: null, model: null, prompt: null }, 'dependency-updates': { type: INTERVAL_TYPES.WEEKLY, enabled: false, providerId: null, model: null, prompt: null }, 'documentation': { type: INTERVAL_TYPES.ONCE, enabled: false, providerId: null, model: null, prompt: null }, @@ -271,6 +285,11 @@ export const DEFAULT_TASK_INTERVALS = { // CoS-managed worktree would clobber it). export const MANAGED_AGENT_OPTIONS = { 'plan-task': ['useWorktree', 'openPR'], + // branch-reconcile's coordinator MUST run in the app's live checkout (never a + // CoS-managed worktree) so it can enumerate + operate on the sibling worktrees + // of the in-flight branches; a managed worktree would hide those branches from + // the scan AND could trip cleanupAgentWorktree's auto-merge. Lock both off. + 'branch-reconcile': ['useWorktree', 'openPR'], // claim-issue's prompt creates its own claim/issue- worktree (same // rationale as plan-task), so CoS must not pre-create one or open the PR. 'claim-issue': ['useWorktree', 'openPR'], @@ -723,7 +742,7 @@ function ensureExecutionRecord(schedule, taskType, appId) { * draining and wait until `parkedUntil` before re-probing. Stamps the park * fields on the (per-app or global) execution record. */ -export async function parkPerpetual(taskType, appId = null, { reason = null, actionableCount = 0 } = {}) { +export async function parkPerpetual(taskType, appId = null, { reason = null, actionableCount = 0, signature } = {}) { const schedule = await loadSchedule(); const interval = schedule.tasks[taskType] || {}; const parkedUntil = await computePerpetualRecheckAt(interval); @@ -732,12 +751,45 @@ export async function parkPerpetual(taskType, appId = null, { reason = null, act record.parkReason = reason; record.parkActionableCount = actionableCount; record.parkedAt = new Date().toISOString(); + // A drain that parks because a full cycle made NO progress (branch-reconcile's + // 'no-progress' park) records the actionable signature it was stuck on, so the + // next recheck can tell "same stuck set" (park again) from "the set changed" + // (resume). `null` clears it (an idle park with nothing actionable); `undefined` + // (the default) leaves any prior signature untouched. + if (signature !== undefined) { + if (signature === null) delete record.lastActionableSignature; + else record.lastActionableSignature = signature; + } await saveSchedule(schedule); emitLog('info', `Perpetual ${taskType} parked until ${parkedUntil} (${reason || 'idle'})`, { taskType, appId, parkedUntil }, '📅 TaskSchedule'); cosEvents.emit('schedule:perpetual-parked', { taskType, appId, parkedUntil, reason }); return record; } +/** Read the last actionable signature recorded for a perpetual drain (or null). */ +export async function getPerpetualSignature(taskType, appId = null) { + const schedule = await loadSchedule(); + const key = taskType.startsWith('task:') ? taskType : `task:${taskType}`; + const top = schedule.executions[key]; + if (!top) return null; + const record = appId ? top.perApp?.[appId] : top; + return record?.lastActionableSignature ?? null; +} + +/** + * Record the actionable signature a perpetual drain is dispatching against, so a + * later cycle that finds the same signature can recognize "no progress" and park + * instead of re-dispatching an identical run. `null` clears it. + */ +export async function setPerpetualSignature(taskType, appId = null, signature) { + const schedule = await loadSchedule(); + const record = ensureExecutionRecord(schedule, taskType, appId); + if (signature == null) delete record.lastActionableSignature; + else record.lastActionableSignature = signature; + await saveSchedule(schedule); + return record; +} + /** * Clear a perpetual task's park so the drain resumes (its work-detector found * actionable work). No-op (and no write) when there's nothing parked. @@ -1451,6 +1503,7 @@ export const TASK_TYPE_DESCRIPTIONS = { 'claim-work': "Ship the next work item from the app's configured tracker (PLAN.md, GitHub/GitLab issues, or JIRA), routed automatically", 'accessibility': 'Accessibility audit', 'branch-cleanup': 'Clean up merged branches', + 'branch-reconcile': "Finish this machine's in-flight local branches: clean up merged ones, open PRs, resolve conflicts, drive review, auto-merge when green", 'dependency-updates': 'Update dependencies', 'release-check': 'Check for release readiness', 'error-handling': 'Improve error handling', diff --git a/server/services/taskSchedule.test.js b/server/services/taskSchedule.test.js index ee9ad99f6..d1465763a 100644 --- a/server/services/taskSchedule.test.js +++ b/server/services/taskSchedule.test.js @@ -102,6 +102,8 @@ import { computePerpetualRecheckAt, parkPerpetual, clearPerpetualPark, + getPerpetualSignature, + setPerpetualSignature, PROMPT_VERSIONS, DEFAULT_TASK_INTERVALS, MANAGED_AGENT_OPTIONS, @@ -1293,6 +1295,47 @@ describe('taskSchedule', () => { }) expect(await clearPerpetualPark('claim-issue', 'app-1')).toBe(false) }) + + it('parkPerpetual stores an actionable signature and getPerpetualSignature reads it back', async () => { + mockSchedule({ tasks: { 'branch-reconcile': { type: 'perpetual', enabled: true, recheckIntervalMs: 3600000 } } }) + await parkPerpetual('branch-reconcile', 'app-1', { reason: 'no-progress', actionableCount: 2, signature: 'a:NEEDS_PR:none|b:IN_REVIEW:5' }) + const saved = JSON.parse(writeFile.mock.calls.at(-1)[1]) + expect(saved.executions['task:branch-reconcile'].perApp['app-1'].lastActionableSignature).toBe('a:NEEDS_PR:none|b:IN_REVIEW:5') + }) + + it('getPerpetualSignature returns the stored signature (and null when absent)', async () => { + mockSchedule({ + tasks: { 'branch-reconcile': { type: 'perpetual', enabled: true } }, + executions: { 'task:branch-reconcile': { lastRun: null, count: 0, perApp: { 'app-1': { lastRun: null, count: 0, lastActionableSignature: 'sig-1' } } } } + }) + expect(await getPerpetualSignature('branch-reconcile', 'app-1')).toBe('sig-1') + expect(await getPerpetualSignature('branch-reconcile', 'app-2')).toBeNull() + }) + + it('setPerpetualSignature writes it, and null clears it', async () => { + mockSchedule({ tasks: { 'branch-reconcile': { type: 'perpetual', enabled: true } } }) + await setPerpetualSignature('branch-reconcile', 'app-1', 'sig-2') + let saved = JSON.parse(writeFile.mock.calls.at(-1)[1]) + expect(saved.executions['task:branch-reconcile'].perApp['app-1'].lastActionableSignature).toBe('sig-2') + + mockSchedule({ + tasks: { 'branch-reconcile': { type: 'perpetual', enabled: true } }, + executions: { 'task:branch-reconcile': { lastRun: null, count: 0, perApp: { 'app-1': { lastRun: null, count: 0, lastActionableSignature: 'sig-2' } } } } + }) + await setPerpetualSignature('branch-reconcile', 'app-1', null) + saved = JSON.parse(writeFile.mock.calls.at(-1)[1]) + expect(saved.executions['task:branch-reconcile'].perApp['app-1'].lastActionableSignature).toBeUndefined() + }) + + it('parkPerpetual with signature:null clears a prior signature (idle park)', async () => { + mockSchedule({ + tasks: { 'branch-reconcile': { type: 'perpetual', enabled: true, recheckIntervalMs: 3600000 } }, + executions: { 'task:branch-reconcile': { lastRun: null, count: 0, perApp: { 'app-1': { lastRun: null, count: 0, lastActionableSignature: 'old-sig' } } } } + }) + await parkPerpetual('branch-reconcile', 'app-1', { reason: 'no-in-flight-branches', actionableCount: 0, signature: null }) + const saved = JSON.parse(writeFile.mock.calls.at(-1)[1]) + expect(saved.executions['task:branch-reconcile'].perApp['app-1'].lastActionableSignature).toBeUndefined() + }) }) describe('updateTaskInterval recompute-on-cadence-change', () => {