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.
-
-
-
-
- setEnabled(e.target.checked)}
- className="w-4 h-4 accent-port-accent"
- />
- Enable the daily reconciler
-
-
-
-
- Cron schedule
-
-
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) => (
-
- setActions((prev) => ({ ...prev, [a.key]: e.target.checked }))}
- className="w-4 h-4 mt-0.5 accent-port-accent"
- />
-
- {a.label}
- {a.help}
-
-
- ))}
-
-
-
-
- {saving ? : }
- {saving ? 'Saving...' : 'Save'}
-
-
- {running ? : }
- Run now
-
-
-
-
-
- {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', () => {