From 31e7205582157ca94197d9b7308f65817338f8dc Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sat, 26 Sep 2026 14:35:12 -0500 Subject: [PATCH 1/2] herd: write the chat session file for daemon-signed-in sessions Herd workers and shepherds signed in by the daemon had no session file, so every chat_* MCP tool refused them with 'no signed-in chat session' while Bash rt chat worked. An existing CLI-written file is kept. Co-Authored-By: Claude Opus 5.5 --- lib/daemon/__tests__/herd-handlers.test.ts | 21 ++++++++++++++++++++- lib/daemon/handlers/herd.ts | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/daemon/__tests__/herd-handlers.test.ts b/lib/daemon/__tests__/herd-handlers.test.ts index be9188e0a..64f434b98 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"]) deleteChatSession(s); +}); export function harness(over: Partial = {}, trustTestBudgets: { registerBudgetMs?: number; settleMs?: number; stepMs?: number } = {}) { const dir = mkdtempSync(join(tmpdir(), "rt-herd-h-")); @@ -964,6 +968,21 @@ 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 session file is left alone", async () => { + writeChatSession({ sessionId: "sess-w1", handle: "custom", baseHandle: "custom", 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: "custom", 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..f7dc2f745 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,17 @@ 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. An existing file is the CLI's and is kept. */ +function recordChatSession(log: Logger, sessionId: string, handle: string, baseHandle: string): void { + try { + if (!readChatSession(sessionId)) writeChatSession({ 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 +305,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 +338,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 +447,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" }); From 3587b930d0d799205f3da61cc65810b6c08128ab Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sat, 26 Sep 2026 14:37:22 -0500 Subject: [PATCH 2/2] herd: rewrite a stale chat session file that names another handle; test start, resume and cleanup Co-Authored-By: Claude Opus 5.5 --- lib/daemon/__tests__/herd-handlers.test.ts | 24 ++++++++++++++++++---- lib/daemon/handlers/herd.ts | 8 ++++++-- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/daemon/__tests__/herd-handlers.test.ts b/lib/daemon/__tests__/herd-handlers.test.ts index 64f434b98..4728ef044 100644 --- a/lib/daemon/__tests__/herd-handlers.test.ts +++ b/lib/daemon/__tests__/herd-handlers.test.ts @@ -21,7 +21,7 @@ let dirs: string[] = []; beforeEach(() => { dirs = []; }); afterEach(() => { for (const d of dirs) rmSync(d, { recursive: true, force: true }); - for (const s of ["sess-w1", "sess-w2"]) deleteChatSession(s); + 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 } = {}) { @@ -308,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" }); @@ -975,12 +983,20 @@ describe("herd:spawn", () => { expect(readChatSession("sess-w1")).toMatchObject({ sessionId: "sess-w1", handle: "job-a", baseHandle: "job-a" }); }); - test("an existing session file is left alone", async () => { - writeChatSession({ sessionId: "sess-w1", handle: "custom", baseHandle: "custom", signedInAt: 1, room: "r" }); + 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: "custom", room: "r" }); + 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 () => { diff --git a/lib/daemon/handlers/herd.ts b/lib/daemon/handlers/herd.ts index f7dc2f745..135332dcc 100644 --- a/lib/daemon/handlers/herd.ts +++ b/lib/daemon/handlers/herd.ts @@ -73,10 +73,14 @@ 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. An existing file is the CLI's and is kept. */ + 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 { - if (!readChatSession(sessionId)) writeChatSession({ sessionId, handle, baseHandle, signedInAt: Date.now() }); + 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"); }