fix(auto): make halt watchdog observable

This commit is contained in:
Mikael Hugo 2026-05-15 08:09:02 +02:00
parent f9c147a08b
commit d57cd84d9a
3 changed files with 116 additions and 10 deletions

View file

@ -282,12 +282,11 @@ export async function runSubagent(
const promptTask = `Task: ${task}`;
try {
if (timeoutMs === 0 && noOutputTimeoutMs === 0 && !options?.signal) {
// Fast path: no watchdog, no cancellation.
const promptPromise = session.prompt(promptTask, {
runExtensionHooks: false,
});
if (timeoutMs === 0 && !options?.signal) {
// Fast path: no watchdog, no cancellation.
await promptPromise;
cleanup();
return { ok: true, output: extractFinalOutput(), exitCode: 0 };
@ -300,9 +299,7 @@ export async function runSubagent(
cancelled?: true;
error?: unknown;
};
const competitors: Promise<RaceResult>[] = [
promptPromise.then(() => ({}) as RaceResult),
];
const competitors: Promise<RaceResult>[] = [];
if (timeoutMs > 0) {
competitors.push(
@ -364,6 +361,11 @@ export async function runSubagent(
);
}
const promptPromise = session.prompt(promptTask, {
runExtensionHooks: false,
});
competitors.unshift(promptPromise.then(() => ({}) as RaceResult));
const result = await Promise.race(competitors);
cleanup();

View file

@ -117,7 +117,12 @@ const DEFAULT_HALT_THRESHOLD_MS = 10_000;
function haltStatePath(basePath) {
return join(sfRoot(basePath), "runtime", HALT_STATE_FILE);
}
class HaltWatchdog {
/**
* Check whether elapsed time since last heartbeat exceeds the threshold.
* On first detection, emit a BLOCKING_NOTICE, file high-severity self-feedback,
* and log the transition. Returns { stuck, elapsedMs }.
*/
export class HaltWatchdog {
constructor(basePath, thresholdMs = DEFAULT_HALT_THRESHOLD_MS) {
this.basePath = basePath;
this.thresholdMs = thresholdMs;
@ -368,7 +373,7 @@ async function drainSleeptimeQueue(basePath) {
* @param {object} deps
* @param {object} ctx
*/
async function autoTriageTodo(basePath, iteration, deps, ctx) {
async function autoTriageTodo(basePath, iteration, _deps, ctx) {
const todoPath = join(basePath, "TODO.md");
if (!existsSync(todoPath)) return;
const raw = readFileSync(todoPath, "utf-8");

View file

@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, test, vi } from "vitest";
import { HaltWatchdog } from "../auto/loop.js";
import { installNotifyInterceptor } from "../bootstrap/notify-interceptor.js";
import {
_resetNotificationStore,
appendNotification,
getAppendFailureCount,
initNotificationStore,
NOTICE_KIND,
} from "../notification-store.js";
let testDir;
beforeEach(() => {
_resetNotificationStore();
testDir = join(tmpdir(), `sf-watchdog-test-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
initNotificationStore(testDir);
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
_resetNotificationStore();
vi.useRealTimers();
});
function readNotifications() {
const notificationFile = join(testDir, ".sf", "notifications.jsonl");
if (!existsSync(notificationFile)) return [];
const content = readFileSync(notificationFile, "utf-8").trim();
if (!content) return [];
return content.split("\n").map((line) => JSON.parse(line));
}
test("HaltWatchdog.check_when_idle_exceeds_threshold_emits_blocking_notice", () => {
vi.useFakeTimers();
const thresholdMs = 10;
const watchdog = new HaltWatchdog(testDir, thresholdMs);
const originalNotify = vi.fn();
const mockCtx = { ui: { notify: originalNotify }, basePath: testDir };
installNotifyInterceptor(mockCtx);
watchdog.heartbeat();
vi.advanceTimersByTime(thresholdMs + 5);
const result = watchdog.check(mockCtx, 1);
assert.equal(result.stuck, true);
assert.equal(originalNotify.mock.calls.length, 1);
const entries = readNotifications();
assert.equal(entries.length, 1);
assert.equal(entries[0].noticeKind, NOTICE_KIND.BLOCKING_NOTICE);
assert.equal(entries[0].metadata.dedupe_key, "halt-watchdog-stuck");
assert.match(entries[0].message, /idle for/);
});
test("HaltWatchdog.check_when_within_threshold_emits_no_notification", () => {
vi.useFakeTimers();
const thresholdMs = 10_000;
const watchdog = new HaltWatchdog(testDir, thresholdMs);
const originalNotify = vi.fn();
const mockCtx = { ui: { notify: originalNotify } };
installNotifyInterceptor(mockCtx);
watchdog.heartbeat();
vi.advanceTimersByTime(100);
const result = watchdog.check(mockCtx, 1);
assert.equal(result.stuck, false);
assert.equal(originalNotify.mock.calls.length, 0);
assert.deepEqual(readNotifications(), []);
});
test("HaltWatchdog.check_reports_only_once_per_idle_period", () => {
vi.useFakeTimers();
const thresholdMs = 10;
const watchdog = new HaltWatchdog(testDir, thresholdMs);
const originalNotify = vi.fn();
const mockCtx = { ui: { notify: originalNotify }, basePath: testDir };
installNotifyInterceptor(mockCtx);
watchdog.heartbeat();
vi.advanceTimersByTime(thresholdMs + 5);
watchdog.check(mockCtx, 1);
watchdog.check(mockCtx, 2);
assert.equal(readNotifications().length, 1);
});
test("appendNotification_when_store_not_initialized_fails_open", () => {
_resetNotificationStore();
appendNotification("test", "info", "test");
assert.equal(getAppendFailureCount(), 0);
});