diff --git a/lib/daemon/__tests__/herd-handlers.test.ts b/lib/daemon/__tests__/herd-handlers.test.ts index be9188e0a..4728ef044 100644 --- a/lib/daemon/__tests__/herd-handlers.test.ts +++ b/lib/daemon/__tests__/herd-handlers.test.ts @@ -14,11 +14,15 @@ import { createBgHandlers } from "../handlers/bg.ts"; import { createEscapeInjector } from "../gate-escape.ts"; import type { herdrRequest } from "../../herdr/client.ts"; import { acceptTrustOnPane } from "../trust-accept.ts"; +import { deleteChatSession, readChatSession, writeChatSession } from "../../chat-session.ts"; const log = pino({ level: "silent" }); let dirs: string[] = []; beforeEach(() => { dirs = []; }); -afterEach(() => { for (const d of dirs) rmSync(d, { recursive: true, force: true }); }); +afterEach(() => { + for (const d of dirs) rmSync(d, { recursive: true, force: true }); + for (const s of ["sess-w1", "sess-w2", "sess-shep", "sess-shep-2", "sess-shep-3"]) deleteChatSession(s); +}); export function harness(over: Partial = {}, trustTestBudgets: { registerBudgetMs?: number; settleMs?: number; stepMs?: number } = {}) { const dir = mkdtempSync(join(tmpdir(), "rt-herd-h-")); @@ -304,6 +308,14 @@ describe("herd:resume / status / close", () => { return { ...hx, herd: res.data.herd, room: res.data.room }; } + test("start and resume write the shepherd's chat session file", async () => { + const { h, herd } = await started(); + expect(readChatSession("sess-shep")).toMatchObject({ handle: "shepherd", baseHandle: "shepherd" }); + const res = await h["herd:resume"]({ herd, session: "sess-shep-2" }); + if (!res.ok) throw new Error(res.error); + expect(readChatSession("sess-shep-2")).toMatchObject({ handle: "shepherd", baseHandle: "shepherd" }); + }); + test("resume re-subscribes with the new session and returns gates, unread, status", async () => { const { h, store, gateStore, herd } = await started(); store.upsertJob({ herd, name: "job-a", worktree: "/w/job-a", handle: "job-a", status: "active", pane: "w9:p1" }); @@ -964,6 +976,29 @@ describe("herd:spawn", () => { expect(readFileSync(join(dir, "herds", herd, "job-a", "job.md"), "utf8")).toContain("do the thing"); }); + test("a spawned worker gets a chat session file, so the chat_* MCP tools resolve its handle", async () => { + const { h, herd } = await started(); + const res = await h["herd:spawn"]({ herd, job: "job-a", brief: "b" }); + if (!res.ok) throw new Error(res.error); + expect(readChatSession("sess-w1")).toMatchObject({ sessionId: "sess-w1", handle: "job-a", baseHandle: "job-a" }); + }); + + test("an existing file naming the same handle is kept, room and all", async () => { + writeChatSession({ sessionId: "sess-w1", handle: "job-a", baseHandle: "job-a", signedInAt: 1, room: "r" }); + const { h, herd } = await started(); + const res = await h["herd:spawn"]({ herd, job: "job-a", brief: "b" }); + if (!res.ok) throw new Error(res.error); + expect(readChatSession("sess-w1")).toMatchObject({ handle: "job-a", room: "r", signedInAt: 1 }); + }); + + test("a stale file naming another handle is rewritten to the signed-in handle, keeping its room", async () => { + writeChatSession({ sessionId: "sess-w1", handle: "old-2", baseHandle: "old", signedInAt: 1, room: "r" }); + const { h, herd } = await started(); + const res = await h["herd:spawn"]({ herd, job: "job-a", brief: "b" }); + if (!res.ok) throw new Error(res.error); + expect(readChatSession("sess-w1")).toMatchObject({ handle: "job-a", baseHandle: "job-a", room: "r" }); + }); + test("--dir skips provisioning; a respawn closes the old pane first and reuses the stored job.md", async () => { const { h, store, agentCalls, worktreeCalls, herdrCalls, herd } = await started(); const first = await h["herd:spawn"]({ herd, job: "job-a", brief: "the brief", dir: "/existing/tree" }); diff --git a/lib/daemon/handlers/herd.ts b/lib/daemon/handlers/herd.ts index 6921d75d2..135332dcc 100644 --- a/lib/daemon/handlers/herd.ts +++ b/lib/daemon/handlers/herd.ts @@ -20,6 +20,7 @@ import type { createAgentHandlers } from "./agent.ts"; import type { herdrRequest } from "../../herdr/client.ts"; import type { HerdrRunner } from "../../agent-herdr.ts"; import { slugifyChatName } from "../../chat-room-name.ts"; +import { readChatSession, writeChatSession } from "../../chat-session.ts"; import { attendPane } from "../attend.ts"; import type { TrustOutcome } from "../trust-accept.ts"; import { BG_SESSION } from "../bg-service.ts"; @@ -69,6 +70,21 @@ export const MILESTONE_OPTIONS = ["Approve", "Revise", "Spawn a reviewer"] as co rich option text through the shared gate store, so this cap never moves into gate:open or normalizeGateQuestions. */ export const HERD_OPTION_LABEL_MAX = 60; + +/** The chat_* MCP tools resolve identity only from the session file, which + only the CLI's sign-in writes; a session the daemon signed in has no file + unless it is written here. A file already naming this handle is kept (it + may carry the CLI's room and cwd); one naming another handle is stale and + would make every chat_* call act as a handle the session no longer owns. */ +function recordChatSession(log: Logger, sessionId: string, handle: string, baseHandle: string): void { + try { + const existing = readChatSession(sessionId); + if (existing?.handle === handle) return; + writeChatSession({ ...existing, sessionId, handle, baseHandle, signedInAt: Date.now() }); + } catch (err) { + log.warn({ err, sessionId }, "herd: could not write the chat session file; chat_* tools will not resolve this session"); + } +} const RECOMMENDED_TAIL = /\s*\(\s*recommended\s*\)\s*$/i; function overlongOptionLabel(questions: GateQuestion[]): string | undefined { @@ -293,6 +309,7 @@ export function createHerdHandlers(deps: HerdDeps) { if (!signIn.ok) return signIn; handle = signIn.data.handle; } + recordChatSession(log, session, handle, SHEPHERD_HANDLE); const join = await deps.chat["chat:join"]({ room, handle }); if (!join.ok) return join; @@ -325,6 +342,7 @@ export function createHerdHandlers(deps: HerdDeps) { if (!signIn.ok) return signIn; handle = signIn.data.handle; } + recordChatSession(log, session, handle, baseHandleOf(herd.shepherdHandle)); const join = await deps.chat["chat:join"]({ room: herd.room, handle }); if (!join.ok) return join; store.setShepherd(herdId, { session, handle, pane: p?.callerPane ?? null }); @@ -433,6 +451,7 @@ export function createHerdHandlers(deps: HerdDeps) { const signIn = await deps.chat["chat:sign-in"]({ sessionId: rec.sessionId, baseHandle: name, pane: rec.paneId, cwd: worktree, noRoom: true }); if (!signIn.ok) log.warn({ herd: herdId, job: name, error: signIn.error }, "herd: worker chat sign-in failed; reports will not deliver until it signs in"); const handle = signIn.ok ? signIn.data.handle : name; + if (signIn.ok) recordChatSession(log, rec.sessionId, handle, signIn.data.baseHandle); const joined = await deps.chat["chat:join"]({ room: herd.room, handle, pane: rec.paneId, cwd: worktree }); if (!joined.ok) log.warn({ herd: herdId, job: name, error: joined.error }, "herd: worker room join failed"); if (handle !== name) store.upsertJob({ herd: herdId, name, worktree, branch, tree, handle, status: "spawning" });