Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions src/main/heartbeatPolicy.ts
Original file line number Diff line number Diff line change
@@ -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
* `<root>/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;
}
}
17 changes: 16 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
}
Expand Down
97 changes: 97 additions & 0 deletions test/heartbeat-policy.test.cjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading