fix(lanes): stop the stuck "waiting for your input" banner and idle status
Two lane-card symptoms, one source: touchLaneFromHook.
needs_action was stamped on every Notification hook, including Claude
Code's bare idle nudge ("Claude is waiting for your input"), which fires
~60s AFTER Stop. Nothing later arrives to clear it, so the card kept a
permanent amber "needs you" while the user was simply not typing. The
nudge now clears the flag instead of raising it - it proves the CLI is
parked at an idle prompt. Permission/AskUserQuestion messages are
unchanged.
lane.status only moved through the dashboard run lifecycle (run_id), so
a lane driven by `claude` in a terminal read "idle" for the whole time
Claude was working in it. For lanes the dashboard did not launch, the
turn hooks now mirror it: UserPromptSubmit/PreToolUse/PostToolUse ->
running, Stop/SessionEnd/idle-nudge -> idle. SubagentStop is excluded (a
subagent finishing is not the end of the turn) and provisioning/failed
are never stomped.
Side effect: classifyLiveness treats status=running as "expect live", so
a turn with no hook for more than LANE_DEAD_SEC (300s) now shows the dead
dot. That is the detector working as designed; raise LANE_DEAD_SEC if a
long single Bash trips it.
This commit is contained in:
@@ -336,6 +336,77 @@ describe("hook → lane binding", () => {
|
||||
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
});
|
||||
|
||||
it("the bare idle nudge never raises needs_action and clears a stale one", async () => {
|
||||
const created = await request("POST", "/api/lanes", {
|
||||
cwd: "/tmp/lane-idle-nudge",
|
||||
title: "Idle Nudge",
|
||||
});
|
||||
const id = created.body.lane.id;
|
||||
|
||||
await request("POST", "/api/hooks/event", {
|
||||
hook_type: "SessionStart",
|
||||
data: { session_id: "sess-nudge", cwd: "/tmp/lane-idle-nudge" },
|
||||
});
|
||||
await request("POST", "/api/hooks/event", {
|
||||
hook_type: "Notification",
|
||||
data: {
|
||||
session_id: "sess-nudge",
|
||||
cwd: "/tmp/lane-idle-nudge",
|
||||
message: "Claude needs your permission to use Bash",
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
|
||||
"Claude needs your permission to use Bash"
|
||||
);
|
||||
|
||||
// The 60s idle nudge fires after Stop: it proves the CLI is parked at an
|
||||
// idle prompt, so it must clear rather than pin the banner.
|
||||
await request("POST", "/api/hooks/event", {
|
||||
hook_type: "Notification",
|
||||
data: {
|
||||
session_id: "sess-nudge",
|
||||
cwd: "/tmp/lane-idle-nudge",
|
||||
message: "Claude is waiting for your input",
|
||||
},
|
||||
});
|
||||
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
|
||||
assert.equal(lane.needs_action, null);
|
||||
assert.equal(lane.status, "idle");
|
||||
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
});
|
||||
|
||||
it("mirrors CLI turn state onto lane.status for a lane the dashboard did not launch", async () => {
|
||||
const created = await request("POST", "/api/lanes", {
|
||||
cwd: "/tmp/lane-turn-status",
|
||||
title: "Turn Status",
|
||||
});
|
||||
const id = created.body.lane.id;
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle");
|
||||
|
||||
await request("POST", "/api/hooks/event", {
|
||||
hook_type: "UserPromptSubmit",
|
||||
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
|
||||
});
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running");
|
||||
|
||||
// A subagent finishing is not the end of the turn.
|
||||
await request("POST", "/api/hooks/event", {
|
||||
hook_type: "SubagentStop",
|
||||
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
|
||||
});
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running");
|
||||
|
||||
await request("POST", "/api/hooks/event", {
|
||||
hook_type: "Stop",
|
||||
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
|
||||
});
|
||||
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle");
|
||||
|
||||
await request("DELETE", `/api/lanes/${id}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook → stage detection", () => {
|
||||
@@ -705,7 +776,10 @@ describe("GET /api/lanes/branches", () => {
|
||||
});
|
||||
|
||||
it("400s for a path that does not exist", async () => {
|
||||
const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}`);
|
||||
const r = await request(
|
||||
"GET",
|
||||
`/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}`
|
||||
);
|
||||
assert.equal(r.status, 400);
|
||||
assert.equal(r.body.error.code, "EBADSOURCEREPO");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user