diff --git a/server/__tests__/awaiting-subagent-guard.test.js b/server/__tests__/awaiting-subagent-guard.test.js new file mode 100644 index 00000000..65c0f883 --- /dev/null +++ b/server/__tests__/awaiting-subagent-guard.test.js @@ -0,0 +1,168 @@ +/** + * @file Regression: a BACKGROUND subagent's tool events must not clear a + * genuine 'notification' waiting flag held by the MAIN agent (blocked on the + * user via AskUserQuestion / permission). Before the fix, PreToolUse/PostToolUse + * cleared awaiting_input_since unconditionally, so a session that was truly + * "waiting for you" oscillated back to active on every subagent tool event — + * AI-Deck (12s poll) and deck-web (5s WS) then disagreed about the same session. + * + * The guard reuses the existing subagent-actor heuristic (findDeepestWorkingAgent + * while main is 'waiting'): when a subagent is the actor, only PASSIVE waits + * (stop/session_start/interrupted) are cleared; a 'notification' wait is + * preserved. When MAIN is the actor, clearing is unconditional (keeps the + * documented permission-mid-tool path intact). + * + * This test lives in the fork's own suite so a future upstream merge that + * silently reverts the guard fails loudly here (home-network monitor fork, + * see decisions/). + */ + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("path"); +const os = require("os"); +const http = require("http"); + +const TEST_DB = path.join(os.tmpdir(), `awaiting-guard-${Date.now()}-${process.pid}.db`); +process.env.DASHBOARD_DB_PATH = TEST_DB; +process.env.DASHBOARD_LIVENESS_PROBE = "0"; + +const { createApp, startServer } = require("../index"); + +let server; +let BASE; + +function fetch(urlPath, options = {}) { + return new Promise((resolve, reject) => { + const url = new URL(urlPath, BASE); + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method: options.method || "GET", + headers: { "Content-Type": "application/json", ...options.headers }, + }, + (res) => { + let body = ""; + res.on("data", (c) => (body += c)); + res.on("end", () => { + let parsed; + try { + parsed = JSON.parse(body); + } catch { + parsed = body; + } + resolve({ status: res.statusCode, body: parsed }); + }); + } + ); + req.on("error", reject); + if (options.body) req.write(JSON.stringify(options.body)); + req.end(); + }); +} + +const post = (p, body) => fetch(p, { method: "POST", body }); +const hook = (hook_type, data) => post("/api/hooks/event", { hook_type, data }); +const sessionOf = async (id) => (await fetch(`/api/sessions/${id}`)).body.session; + +/** + * Drive a session into: main agent 'waiting' with the given reason, AND a live + * working subagent (so findDeepestWorkingAgent returns it). Returns nothing; + * asserts each precondition so a harness regression is obvious. + */ +async function sessionWaitingWithWorkingSubagent(sid, { notification }) { + await hook("SessionStart", { session_id: sid }); + // UserPromptSubmit clears the session_start wait and promotes main → working. + await hook("UserPromptSubmit", { session_id: sid, prompt: "go" }); + // Main (working) spawns a subagent → subagent row inserted with status working. + await hook("PreToolUse", { + session_id: sid, + tool_name: "Agent", + tool_input: { subagent_type: "reviewer", prompt: "review" }, + }); + // Now block the MAIN agent. A waiting-for-user Notification stamps + // reason='notification'; a Stop stamps the passive reason='stop'. + if (notification) { + await hook("Notification", { session_id: sid, message: "Claude is waiting for your input" }); + } else { + await hook("Stop", { session_id: sid }); + } + const sess = await sessionOf(sid); + assert.ok(sess.awaiting_input_since, "precondition: session should be awaiting"); + assert.equal( + sess.awaiting_reason, + notification ? "notification" : "stop", + "precondition: expected awaiting_reason" + ); +} + +before(async () => { + server = await startServer(createApp(), 0); + BASE = `http://127.0.0.1:${server.address().port}`; +}); + +after(() => { + if (server) server.close(); +}); + +describe("awaiting guard: subagent tool events vs. main-agent waiting", () => { + it("PRESERVES a 'notification' wait when a background subagent fires PreToolUse", async () => { + const sid = "guard-notif-pre"; + await sessionWaitingWithWorkingSubagent(sid, { notification: true }); + + // Subagent (deepest working, main is waiting) runs a tool. This must NOT + // clear the main agent's genuine "waiting for you" flag. + await hook("PreToolUse", { session_id: sid, tool_name: "Bash" }); + + const sess = await sessionOf(sid); + assert.ok(sess.awaiting_input_since, "notification wait must survive subagent PreToolUse"); + assert.equal(sess.awaiting_reason, "notification"); + }); + + it("PRESERVES a 'notification' wait when a background subagent fires PostToolUse", async () => { + const sid = "guard-notif-post"; + await sessionWaitingWithWorkingSubagent(sid, { notification: true }); + + await hook("PostToolUse", { session_id: sid, tool_name: "Bash" }); + + const sess = await sessionOf(sid); + assert.ok(sess.awaiting_input_since, "notification wait must survive subagent PostToolUse"); + assert.equal(sess.awaiting_reason, "notification"); + }); + + it("CLEARS a passive 'stop' wait when a background subagent fires a tool event", async () => { + // Passive-clear is desirable: a backgrounded subagent's activity should flip + // a merely-Stopped session back to active ("done/idle only while no agent works"). + const sid = "guard-stop-pre"; + await sessionWaitingWithWorkingSubagent(sid, { notification: false }); + + await hook("PreToolUse", { session_id: sid, tool_name: "Bash" }); + + const sess = await sessionOf(sid); + assert.equal( + sess.awaiting_input_since, + null, + "passive stop wait should be cleared by subagent activity" + ); + assert.equal(sess.awaiting_reason, null); + }); + + it("CLEARS a 'notification' wait when MAIN (no working subagent) resumes with a tool", async () => { + // Control: main was waiting on the user, no subagent running. A PreToolUse + // means main itself resumed — clearing is correct (unchanged behaviour). + const sid = "guard-notif-mainactor"; + await hook("SessionStart", { session_id: sid }); + await hook("UserPromptSubmit", { session_id: sid, prompt: "go" }); + await hook("Notification", { session_id: sid, message: "Claude is waiting for your input" }); + let sess = await sessionOf(sid); + assert.ok(sess.awaiting_input_since && sess.awaiting_reason === "notification", "precondition"); + + await hook("PreToolUse", { session_id: sid, tool_name: "Bash" }); + + sess = await sessionOf(sid); + assert.equal(sess.awaiting_input_since, null, "main resuming must clear its own wait"); + assert.equal(sess.awaiting_reason, null); + }); +}); diff --git a/server/routes/hooks.js b/server/routes/hooks.js index b07b0f90..883009ca 100644 --- a/server/routes/hooks.js +++ b/server/routes/hooks.js @@ -57,6 +57,51 @@ function clearAwaitingInput(sessionId, mainAgentId, broadcastUpdates) { } } +// Reason-aware guarded clear (2026-07-17, AI-Deck/deck-web fork patch — see +// decisions/ in the home-network repo). PreToolUse/PostToolUse clear the +// waiting flag on the assumption that a tool event means the human resumed. +// That assumption breaks when the MAIN agent is blocked on the user +// (AskUserQuestion / permission) while a BACKGROUND subagent keeps firing +// PreToolUse/PostToolUse: the subagent's tool event is not the human +// responding, yet it wiped the flag — so AI-Deck/deck-web lost the "waiting +// for you" signal (it oscillated on/off in seconds). +// +// The existing subagent-actor heuristic (findDeepestWorkingAgent, used just +// below in each case) already tells us when a subagent — not main — is the +// actor. We reuse it here: when a subagent is the actor we clear ONLY passive +// waits (stop/session_start/interrupted); a genuine 'notification' wait (main +// blocked on the user) is preserved. When MAIN is the actor we clear +// unconditionally, exactly as before — this keeps the documented +// permission-mid-tool path (PostToolUse clearing an approved prompt) intact. +// +// Passive-clear-by-subagent is deliberate and desirable: it is what lets a +// backgrounded subagent's activity flip a session that merely Stop-ed +// (reason='stop') back to active — i.e. "done/idle only while no agent works". +function clearAwaitingInputRespectingActor( + sessionId, + mainAgentId, + subagentIsActor, + broadcastUpdates +) { + if (subagentIsActor) { + const sess = stmts.getSession.get(sessionId); + if (sess && sess.awaiting_reason === "notification") { + // Main is blocked on the user; a subagent tool event must not clear it. + return; + } + } + clearAwaitingInput(sessionId, mainAgentId, broadcastUpdates); +} + +// True when the deepest currently-working agent is a subagent while the main +// agent is in 'waiting' — i.e. an incoming tool event is attributable to a +// subagent, not to the main agent resuming. Mirrors the inline heuristic the +// PreToolUse/PostToolUse cases already use for agent attribution. +function subagentIsActorNow(sessionId, mainAgent) { + if (!mainAgent || mainAgent.status !== "waiting") return false; + return !!stmts.findDeepestWorkingAgent.get(sessionId, sessionId); +} + // Land a session that was cancelled with no hook (Esc) in the same // waiting + awaiting-input state a normal Stop produces, and log a timeline // event. Used by both watchdog recovery paths: the transcript-marker path @@ -334,8 +379,17 @@ const processEvent = db.transaction((hookType, data) => { // PreToolUse means Claude is actively running a tool, ergo the user // has resumed (Stop only fires at end of turn — Claude can't start a - // new tool call without fresh user input). Clear waiting now. - clearAwaitingInput(sessionId, mainAgentId, true); + // new tool call without fresh user input). Clear waiting now — UNLESS a + // background subagent is the actor and main is blocked on the user + // (reason='notification'), in which case the flag is preserved + // (clearAwaitingInputRespectingActor). Computed before the clear because + // the Agent-spawn branch below mutates agent rows. + clearAwaitingInputRespectingActor( + sessionId, + mainAgentId, + subagentIsActorNow(sessionId, mainAgent), + true + ); // If the tool is Agent, a subagent is being created if (toolName === "Agent") { @@ -414,7 +468,14 @@ const processEvent = db.transaction((hookType, data) => { // Code prompts the user mid-tool). The Notification stamps waiting, // the user approves, the tool completes, PostToolUse arrives. Without // a clear here, we'd be stuck in waiting until the next PreToolUse. - clearAwaitingInput(sessionId, mainAgentId, true); + // Same actor-guard as PreToolUse: a background subagent's PostToolUse + // must not clear a genuine 'notification' wait held by the main agent. + clearAwaitingInputRespectingActor( + sessionId, + mainAgentId, + subagentIsActorNow(sessionId, mainAgent), + true + ); // NOTE: PostToolUse for "Agent" tool fires immediately when a subagent is // backgrounded — it does NOT mean the subagent finished its work.