Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion lib/daemon/__tests__/herd-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HerdDeps> = {}, trustTestBudgets: { registerBudgetMs?: number; settleMs?: number; stepMs?: number } = {}) {
const dir = mkdtempSync(join(tmpdir(), "rt-herd-h-"));
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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" });
Expand Down
19 changes: 19 additions & 0 deletions lib/daemon/handlers/herd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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" });
Expand Down
Loading