fix(auto): make halt watchdog observable
This commit is contained in:
parent
f9c147a08b
commit
d57cd84d9a
3 changed files with 116 additions and 10 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue