diff --git a/src/main/config.ts b/src/main/config.ts index 24b4420d7..011834a8a 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -58,6 +58,16 @@ export interface ScheduledMission { * inbox/outbox mtimes, any PTY output) has moved in this many ms. Default * ~5 min. NOT derived from registry.status (which never transitions in main). */ quietThresholdMs?: number; + /** Heartbeat only. Default true. When the floor is quiet and god's inbox has + * no real (non-scheduler) mail, a beat normally still re-engages god just + * because the floor went quiet. With this on, that re-engage is additionally + * gated on tasks.json having at least one 'doing' card — a quiet floor with + * work still in flight may be a stall worth a look; a quiet floor with + * nothing doing has nothing for god to review, so the beat is a no-op and + * re-arms on the normal (non-back-off) cadence instead of waking god. Set to + * false to restore the original behaviour where any quiet floor re-engages. + * Reversible from config.json without a rebuild. */ + suppressWhenIdle?: boolean; } /** The built-in hourly ops standup: god reviews who's doing what + whether tasks @@ -107,7 +117,8 @@ export const HEARTBEAT_MISSION: ScheduledMission = { 'if the work is genuinely done.', enabled: false, kind: 'heartbeat', - quietThresholdMs: 300_000 + quietThresholdMs: 300_000, + suppressWhenIdle: true }; /** The dedicated auto-compact MAINTENANCE schedule (maint-1). DECOUPLED from the diff --git a/src/main/heartbeatPolicy.ts b/src/main/heartbeatPolicy.ts new file mode 100644 index 000000000..b1218a1be --- /dev/null +++ b/src/main/heartbeatPolicy.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Pure re-engage decision for one heartbeat beat, extracted out of index.ts's + * beat() so it can be tested directly (index.ts pulls in Electron and cannot + * be evaluated outside the main process). Node builtins only, no Electron. + */ + +export interface ReengageInputs { + /** Unread, non-scheduler messages in god's inbox (see godActionableInboxCount + * in index.ts). Always wins: a worker's reply must never sit unread. */ + actionable: number; + /** Whether the floor has gone quiet for at least the mission's + * quietThresholdMs (see isFloorQuiet in index.ts). */ + quiet: boolean; + /** Count of tasks.json cards with status 'doing'. Only meaningful when + * `quiet` is true and `actionable` is 0 — see doingTaskCount below, whose + * read failures are deliberately biased so this is never a false zero. */ + doingCount: number; + /** Heartbeat mission config knob (ScheduledMission.suppressWhenIdle, + * default true). true: a quiet floor with nothing 'doing' has nothing for + * god to review, so the beat does not re-engage. false: restores the + * original behaviour, where any quiet floor re-engages. */ + suppressWhenIdle: boolean; +} + +/** Should this beat re-engage god? Real inbox mail always does. Otherwise: an + * active (non-quiet) floor never does. A quiet floor re-engages unless + * suppressWhenIdle is on AND nothing is 'doing' — a quiet floor with work in + * flight may be a stall worth waking god for; a quiet floor with nothing + * doing has nothing for god to do. */ +export function shouldReengage(inputs: ReengageInputs): boolean { + if (inputs.actionable > 0) return true; + if (!inputs.quiet) return false; + if (!inputs.suppressWhenIdle) return true; + return inputs.doingCount > 0; +} + +/** Count of tasks.json cards with status 'doing', read directly from + * `/tasks.json`. A missing root, a missing file, unparsable JSON, or a + * `tasks` field that is not an array are all treated as "at least one doing + * card" (returns 1) rather than 0 — the read is only ever used to decide + * whether a quiet floor is safe to leave alone, so a ledger we cannot trust + * must fail toward waking god, never toward silence. An empty-but-valid + * tasks array is a genuine, trustworthy zero. */ +export function doingTaskCount(root: string | null): number { + if (!root) return 1; + try { + const raw = readFileSync(join(root, 'tasks.json'), 'utf8'); + const parsed = JSON.parse(raw) as { tasks?: unknown }; + if (!Array.isArray(parsed?.tasks)) return 1; + return parsed.tasks.filter((t: { status?: unknown }) => t && t.status === 'doing').length; + } catch { + return 1; + } +} diff --git a/src/main/index.ts b/src/main/index.ts index cbd984918..65e2c0061 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,6 +30,7 @@ import { saveAskAttachment } from './askAttachments'; import { HiveManager, type AgentMeta, type HiveMessage, type HiveTask } from './hive'; import { HookServer } from './hooks'; import { CircuitBreaker, type BreakerInput } from './breaker'; +import { shouldReengage, doingTaskCount } from './heartbeatPolicy'; import type { UsageProvider } from './usage'; import { MemoryManager } from './memory'; import { testHindsightConnection } from './hindsightAdapter'; @@ -1346,9 +1347,23 @@ function armHeartbeat(m: ScheduledMission): void { // waiting in god's inbox — the latter is independent of floor-quiet so a // worker's reply doesn't sit unread while other agents keep the floor busy. const actionable = godActionableInboxCount(); - if (isFloorQuiet(quiet) || actionable > 0) { + const floorQuiet = isFloorQuiet(quiet); + const suppressWhenIdle = m.suppressWhenIdle ?? true; + // tasks.json is only read when it can actually change the outcome: a + // quiet floor, no actionable mail, and the knob on — every other case is + // decided by actionable/floorQuiet alone, so skip the extra read. + const doingCount = (floorQuiet && actionable === 0 && suppressWhenIdle) + ? doingTaskCount(hive.root()) + : 0; + if (shouldReengage({ actionable, quiet: floorQuiet, doingCount, suppressWhenIdle })) { reengageGod(buildHeartbeatDigest(quiet, actionable)); next = Math.round(base * 2.5); // back off after re-engaging + } else if (floorQuiet) { + // Would have re-engaged under the old (pre-suppressWhenIdle) semantics — + // the floor is quiet and there is no actionable mail — but nothing is + // 'doing', so there is nothing here for god to review. Stay quiet and + // re-arm on the normal cadence (no back-off: this beat did nothing). + hive.appendLog({ kind: 'heartbeat-suppressed', reason: 'quiet-no-doing-cards', actionable, doingCount }); } else if (looksStuck(quiet)) { next = Math.max(30_000, Math.round(base / 4)); // tighten when an agent is wedged } diff --git a/test/heartbeat-policy.test.cjs b/test/heartbeat-policy.test.cjs new file mode 100644 index 000000000..dc736d3e4 --- /dev/null +++ b/test/heartbeat-policy.test.cjs @@ -0,0 +1,97 @@ +'use strict'; + +/** + * Pure decision logic for one heartbeat beat: whether to re-engage god, and how + * many tasks.json cards are 'doing' (the signal that distinguishes "quiet floor, + * nothing in flight" from "quiet floor, a stall in progress"). Extracted out of + * index.ts's beat() so it can be exercised directly instead of only through the + * Electron-only main process. + */ + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const loadTs = require('./load-ts.cjs'); + +const { shouldReengage, doingTaskCount } = loadTs('src/main/heartbeatPolicy.ts'); + +function tmpRoot(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'md-heartbeat-policy-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function writeTasks(root, tasks) { + fs.writeFileSync(path.join(root, 'tasks.json'), JSON.stringify({ tasks }), 'utf8'); +} + +// ─── shouldReengage ──────────────────────────────────────────────────────── + +test('actionable inbox mail always re-engages, regardless of quiet/doing/knob', () => { + assert.equal(shouldReengage({ actionable: 1, quiet: false, doingCount: 0, suppressWhenIdle: true }), true); + assert.equal(shouldReengage({ actionable: 1, quiet: true, doingCount: 0, suppressWhenIdle: false }), true); + assert.equal(shouldReengage({ actionable: 3, quiet: false, doingCount: 5, suppressWhenIdle: true }), true); +}); + +test('an active (non-quiet) floor with no actionable mail never re-engages', () => { + assert.equal(shouldReengage({ actionable: 0, quiet: false, doingCount: 0, suppressWhenIdle: true }), false); + assert.equal(shouldReengage({ actionable: 0, quiet: false, doingCount: 5, suppressWhenIdle: true }), false); + assert.equal(shouldReengage({ actionable: 0, quiet: false, doingCount: 0, suppressWhenIdle: false }), false); +}); + +test('quiet + no doing cards + suppression on => does NOT re-engage (the new behaviour)', () => { + assert.equal(shouldReengage({ actionable: 0, quiet: true, doingCount: 0, suppressWhenIdle: true }), false); +}); + +test('quiet + at least one doing card + suppression on => DOES re-engage (possible stall)', () => { + assert.equal(shouldReengage({ actionable: 0, quiet: true, doingCount: 1, suppressWhenIdle: true }), true); + assert.equal(shouldReengage({ actionable: 0, quiet: true, doingCount: 4, suppressWhenIdle: true }), true); +}); + +test('knob off restores the old behaviour: any quiet floor re-engages regardless of doing cards', () => { + assert.equal(shouldReengage({ actionable: 0, quiet: true, doingCount: 0, suppressWhenIdle: false }), true); + assert.equal(shouldReengage({ actionable: 0, quiet: true, doingCount: 7, suppressWhenIdle: false }), true); +}); + +// ─── doingTaskCount ──────────────────────────────────────────────────────── + +test('counts only cards with status doing, ignoring todo/blocked/done', (t) => { + const root = tmpRoot(t); + writeTasks(root, [ + { id: '1', status: 'todo' }, + { id: '2', status: 'doing' }, + { id: '3', status: 'blocked' }, + { id: '4', status: 'doing' }, + { id: '5', status: 'done' } + ]); + assert.equal(doingTaskCount(root), 2); +}); + +test('an empty (legitimate) task list counts as zero doing cards', (t) => { + const root = tmpRoot(t); + writeTasks(root, []); + assert.equal(doingTaskCount(root), 0); +}); + +test('a missing tasks.json fails toward "has doing cards", never toward silence', (t) => { + const root = tmpRoot(t); // no tasks.json written at all + assert.equal(doingTaskCount(root) > 0, true); +}); + +test('a corrupt tasks.json (unparsable JSON) fails toward "has doing cards"', (t) => { + const root = tmpRoot(t); + fs.writeFileSync(path.join(root, 'tasks.json'), '{ not valid json', 'utf8'); + assert.equal(doingTaskCount(root) > 0, true); +}); + +test('a malformed tasks.json (tasks is not an array) fails toward "has doing cards"', (t) => { + const root = tmpRoot(t); + fs.writeFileSync(path.join(root, 'tasks.json'), JSON.stringify({ tasks: 'nope' }), 'utf8'); + assert.equal(doingTaskCount(root) > 0, true); +}); + +test('a null hive root (hive disabled) fails toward "has doing cards"', () => { + assert.equal(doingTaskCount(null) > 0, true); +});