57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
170 lines
6.7 KiB
JavaScript
170 lines
6.7 KiB
JavaScript
/**
|
|
* @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/).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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);
|
|
});
|
|
});
|