diff --git a/packages/openfleet/src/hooks.test.ts b/packages/openfleet/src/hooks.test.ts index 9d0bf5c..f165051 100644 --- a/packages/openfleet/src/hooks.test.ts +++ b/packages/openfleet/src/hooks.test.ts @@ -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); diff --git a/packages/openfleet/src/hooks.ts b/packages/openfleet/src/hooks.ts index 5841d0d..0620bcf 100644 --- a/packages/openfleet/src/hooks.ts +++ b/packages/openfleet/src/hooks.ts @@ -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;