openfleet hooks: no member.end for a member that never started

A first prompt refused by the ceiling still lets the session wind down through Stop and SessionEnd; those handlers now write nothing when the ledger holds no member.start for the member, so a refused member is never drawn as done.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV
This commit is contained in:
Anthony Ettinger 2026-09-13 10:49:46 +00:00
parent ce8fc7c153
commit 85608e5540
2 changed files with 19 additions and 2 deletions

View file

@ -186,6 +186,17 @@ describe("UserPromptSubmit", () => {
expect(runHook("UserPromptSubmit", payload({ permission_mode: "bypassPermissions", prompt: "still" }), fake(home).io).exit).toBe(2);
});
it("writes no end line for a member whose start was refused", () => {
append(home, "team-20260913", { event: "fleet.open", by: "sysop", fleet: "team-20260913", sysop: FLEET, ceiling: { depth: 2, hosts: ["dev"] } }, { host: "dev" });
writeCurrent(home, "team-20260913");
runHook("SessionStart", payload({ source: "startup" }), fake(home).io);
expect(runHook("UserPromptSubmit", payload({ permission_mode: "bypassPermissions", prompt: "go" }), fake(home).io).exit).toBe(2);
// The session still winds down through Stop and SessionEnd; a member that never started has no end.
runHook("Stop", payload({ permission_mode: "bypassPermissions", last_assistant_message: "blocked", background_tasks: [] }), fake(home).io);
runHook("SessionEnd", payload({ reason: "other" }), fake(home).io);
expect(findEvents(readLedger(home, "team-20260913"), "member.end")).toEqual([]);
});
it("does nothing for a session it never saw, or a subagent", () => {
expect(runHook("UserPromptSubmit", payload({ permission_mode: "auto" }), fake(home).io)).toEqual({ exit: 0, stdout: "", stderr: "" });
runHook("SessionStart", payload({ source: "startup" }), fake(home).io);

View file

@ -370,8 +370,11 @@ export function handleStop(payload: Payload, io: HookIo): HookResult {
if (!jobDir || !session.record) return OK;
if (!Array.isArray(payload.background_tasks) || payload.background_tasks.length > 0) return OK;
const record = session.record;
const lines = readLedger(homeDir, record.fleet);
// A member that never started (its first prompt was refused, or none came) has no end line.
if (!claimedBy(lines, record.member)) return OK;
// A `lost` line a sysop tool wrote is superseded by the engine's own end; anything else stands.
const existing = endOf(readLedger(homeDir, record.fleet), record.member);
const existing = endOf(lines, record.member);
if (existing && existing.state !== "lost") return OK;
const state = stateJson(jobDir);
endMember(
@ -402,7 +405,10 @@ export function handleSessionEnd(payload: Payload, io: HookIo): HookResult {
// clear, resume and logout hand the same work to another session; only a real exit ends the member.
if (payload.reason !== undefined && payload.reason !== "other" && payload.reason !== "prompt_input_exit") return OK;
const record = session.record;
const existing = endOf(readLedger(homeDir, record.fleet), record.member);
const lines = readLedger(homeDir, record.fleet);
// A member that never started (its first prompt was refused, or none came) has no end line.
if (!claimedBy(lines, record.member)) return OK;
const existing = endOf(lines, record.member);
if (existing && existing.state !== "lost") return OK;
const jobDir = ownJobDir(env, sessionId);
const state = jobDir ? stateJson(jobDir) : null;