diff --git a/packages/coding-agent/src/core/subagent-runner.ts b/packages/coding-agent/src/core/subagent-runner.ts index 1aa2b24df..8ce6da0db 100644 --- a/packages/coding-agent/src/core/subagent-runner.ts +++ b/packages/coding-agent/src/core/subagent-runner.ts @@ -282,12 +282,11 @@ export async function runSubagent( const promptTask = `Task: ${task}`; try { - const promptPromise = session.prompt(promptTask, { - runExtensionHooks: false, - }); - - if (timeoutMs === 0 && !options?.signal) { + if (timeoutMs === 0 && noOutputTimeoutMs === 0 && !options?.signal) { // Fast path: no watchdog, no cancellation. + const promptPromise = session.prompt(promptTask, { + runExtensionHooks: false, + }); 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[] = [ - promptPromise.then(() => ({}) as RaceResult), - ]; + const competitors: Promise[] = []; 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(); diff --git a/src/resources/extensions/sf/auto/loop.js b/src/resources/extensions/sf/auto/loop.js index 8f5524df8..ed9197bf9 100644 --- a/src/resources/extensions/sf/auto/loop.js +++ b/src/resources/extensions/sf/auto/loop.js @@ -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"); diff --git a/src/resources/extensions/sf/tests/auto-halt-watchdog-notify.test.mjs b/src/resources/extensions/sf/tests/auto-halt-watchdog-notify.test.mjs new file mode 100644 index 000000000..d22e0798c --- /dev/null +++ b/src/resources/extensions/sf/tests/auto-halt-watchdog-notify.test.mjs @@ -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); +});