diff --git a/src/service.ts b/src/service.ts index e11f96c..e220cc0 100644 --- a/src/service.ts +++ b/src/service.ts @@ -571,10 +571,11 @@ export class Service { // not spoken TO her — a dead wake over thread chatter fails into the log, never the room // (SPEC §18: "a thread-follow turn's failure is ledger/log-only"). const direct = pending.filter(isDirectAddress); - // Tasks born in this wake home to the conversation that most recently engaged her (the - // last addressed message, else the last overheard one) — its thread gets the checklist - // and progress posts. Posting is never homed: reply/react take explicit coordinates - // (SPEC §11) because a batch can span conversations and a guessed destination misroutes. + // The wake's primary conversation (last addressed, else last overheard) — where the + // native reply stream rides and where a §14.2 death fallback lands. NOTHING routes by + // this guess: replies/reacts address by ref (SPEC §11), and tasks home to a required + // ref too (2026-08-13 live: the batch-level guess homed an incident task to an + // adjacent thread and its report answered the wrong incident). const homeMsg = addressed.at(-1) ?? pending.at(-1)!; const anchorObj: Anchor = { venueId: homeMsg.venueId ?? "", threadRootId: homeMsg.threadRootId ?? homeMsg.ts }; // The home thread's reply is ONE native streamed message (reply-stream.ts): checklist diff --git a/src/turn-runner/toolset.ts b/src/turn-runner/toolset.ts index 64dc01d..18e7784 100644 --- a/src/turn-runner/toolset.ts +++ b/src/turn-runner/toolset.ts @@ -182,24 +182,37 @@ function taskCreateTool(ctx: ToolsetContext): ToolFactory { spec: { name: "task_create", description: - "Record a new delegated task; a worker runs it and reports back to you. Input: { title, spec, tier? }. tier is how hard the worker thinks: 'low' for routine mechanical work (tailing a ticket, fetching status), 'medium' for normal work, 'high' (default) for problems that need real thought. Write the spec as a full handoff — the worker starts with none of this conversation.", + "Record a new delegated task; a worker runs it and reports back to you. Input: { title, spec, ref, tier? }. ref is the [rN] tag of the conversation (or a message in it) this task is FOR — the worker's report comes home to that conversation, so pick the room that asked for the work, not whoever spoke last. tier is how hard the worker thinks: 'low' for routine mechanical work (tailing a ticket, fetching status), 'medium' for normal work, 'high' (default) for problems that need real thought. Write the spec as a full handoff — the worker starts with none of this conversation.", inputSchema: { type: "object", additionalProperties: false, - required: ["title", "spec"], - properties: { title: { type: "string" }, spec: { type: "string" }, tier: { type: "string", enum: ["low", "medium", "high"] } }, + required: ["title", "spec", "ref"], + properties: { + title: { type: "string" }, + spec: { type: "string" }, + ref: { type: "string", pattern: "^r\\d+$" }, + tier: { type: "string", enum: ["low", "medium", "high"] }, + }, }, }, impl: async (args) => { - const a = args as { title: string; spec: string; tier?: "low" | "medium" | "high" }; - if (!ctx.anchor || !ctx.principal || !ctx.originEventId) return { success: false, output: "missing turn context for task_create" }; + const a = args as { title: string; spec: string; ref?: string; tier?: "low" | "medium" | "high" }; + if (!ctx.principal || !ctx.originEventId) return { success: false, output: "missing turn context for task_create" }; + // The task's home is HER call, bound to a rendered conversation — never a batch-level + // guess (live 2026-08-13: a task about an alert burst homed to the last thread that + // happened to address her, and its report answered an adjacent incident). + const target = a.ref ? ctx.refs?.get(a.ref) : undefined; + if (!target) { + return { success: false, output: `"${a.ref ?? ""}" is not a ref — home the task with the [rN] tag of the conversation its report belongs in` }; + } + const home = conversationOf(target); const task = createTask(ctx.db, ctx.clock, { id: nextTaskId(ctx.db), identityId: ctx.identity.id, title: a.title, spec: a.spec, sponsorId: ctx.principal.id, - homeAnchor: ctx.anchor, + homeAnchor: { venueId: home.venueId, threadRootId: home.threadRootId }, originEventId: ctx.originEventId, tier: a.tier, sponsorIsOperator: ctx.principal.isOperator, diff --git a/test/resident.test.ts b/test/resident.test.ts index 294db80..f5ce954 100644 --- a/test/resident.test.ts +++ b/test/resident.test.ts @@ -287,7 +287,7 @@ describe("resident delivery", () => { // outcome-report wake included. Act exactly once, and let the spawned execution finish // its task cleanly, or the test loops (task_create per wake / yield-redispatch forever). let acted = false; - const { adapter, service, db } = harness(async (_turn, tools) => { + const { adapter, service, db } = harness(async (_turn, tools, _act, prompt) => { const complete = tools.get("task_complete"); if (complete) { await complete.run({ report: "done" }); @@ -296,7 +296,7 @@ describe("resident delivery", () => { const taskCreate = tools.get("task_create"); if (!taskCreate || acted) return; acted = true; - await taskCreate.run({ title: "file the export bug", spec: "repro + ticket" }); + await taskCreate.run({ title: "file the export bug", spec: "repro + ticket", ref: refIn(prompt, "file this") }); throw new Error("died after acting"); }); await service.start(); @@ -373,10 +373,10 @@ describe("resident delivery", () => { test("a task born in a wake homes to the conversation that addressed her", async () => { let sessions = 0; - const { adapter, service, db } = harness(async (_n, t) => { + const { adapter, service, db } = harness(async (_n, t, _act, prompt) => { // 1: the wake that delegates; 2: the worker; 3+: the report wake (does nothing) const which = ++sessions; - if (which === 1) await t.get("task_create")!.run({ title: "dig", spec: "dig in" }); + if (which === 1) await t.get("task_create")!.run({ title: "dig", spec: "dig in", ref: refIn(prompt, /<#C1>/) }); if (which === 2) await t.get("task_complete")!.run({ report: "done" }); }); await service.start(); @@ -423,6 +423,37 @@ describe("resident delivery", () => { await service.stop(); }); + // Same live defect, task edition (2026-08-13, T-354): a wake batch spanning two conversations, + // and the task homed to whichever one the harness guessed (the batch's last address) — so the + // worker's report answered an adjacent incident. task_create homes by HER ref or not at all. + test("§11: a task homes to the ref'd conversation, not the batch's last address — a refless task_create is rejected", async () => { + const db = openLedger(":memory:"); + const seed = db.query( + `INSERT INTO events (id, dedup_key, kind, identity_id, venue_id, thread_root_id, principal_id, payload, received_at) + VALUES (?, ?, 'addressed_message', 'eng', ?, ?, 'U1', ?, '2026-07-01T00:00:00Z')`, + ); + seed.run("e1", "k1", "C1", "1.0", JSON.stringify({ text: "<@BOT1> alert burst, investigate", ts: "1.1", addressMode: "mention" })); + seed.run("e2", "k2", "C2", null, JSON.stringify({ text: "<@BOT1> pull it together blacksmith", ts: "2.0", addressMode: "mention" })); + + const rejected: string[] = []; + const { service } = harness(async (_turn, tools, _mark, prompt) => { + const taskCreate = tools.get("task_create"); + if (!taskCreate) return; // the ear / the worker (which never reaches its report here) + const bare = await taskCreate.run({ title: "dig", spec: "s" }); + expect(bare.success).toBe(false); + rejected.push(bare.output); + await taskCreate.run({ title: "dig", spec: "s", ref: refIn(prompt, "alert burst") }); + }, db); + await service.start(); + await service.idle(); // flushes the boot wake carrying both conversations + + expect(rejected[0]).toContain("is not a ref"); + const row = db.query("SELECT home_venue_id, home_thread_root_id FROM tasks").get() as { home_venue_id: string; home_thread_root_id: string | null } | null; + expect(row?.home_venue_id).toBe("C1"); // the incident's thread... + expect(row?.home_thread_root_id).toBe("1.0"); // ...not C2, the batch's last-addressed guess + await service.stop(); + }); + // The reply-stream contract (reply-stream.ts): checklist cards alone must never create (and // notify on) a message — they buffer until her first words materialize the stream, then ride // the SAME message as native task cards. Live defect 2026-07-20: the resident wake never wired diff --git a/test/service.test.ts b/test/service.test.ts index eebdb7e..b5993e3 100644 --- a/test/service.test.ts +++ b/test/service.test.ts @@ -273,8 +273,8 @@ describe("Service dispatch driver (SPEC §6.2, §17.3, §17.4)", () => { const { db, adapter, service } = makeService({ // Kind-aware script (the ear shifted session ordering; indices were a trap): the ear holds, // the worker completes, the first wake delegates, later wakes choose silence. - sessionFactory: (tools) => - new FakeAgentRuntimeSession(tools, async (_turn, t) => { + sessionFactory: (tools) => { + const sess: FakeAgentRuntimeSession = new FakeAgentRuntimeSession(tools, async (_turn, t) => { if (t.get("verdict")) return; // the ear: nothing needs her const complete = t.get("task_complete"); if (complete) { @@ -283,10 +283,12 @@ describe("Service dispatch driver (SPEC §6.2, §17.3, §17.4)", () => { } if (!delegated) { delegated = true; - await t.get("task_create")!.run({ title: "dig in", spec: "why slow" }); + await t.get("task_create")!.run({ title: "dig in", spec: "why slow", ref: firstRef(sess) }); } // later wakes (the worker's report) — she chooses silence - }), + }); + return sess; + }, }); await service.start(); @@ -403,7 +405,7 @@ describe("Service workers report to the mind (2026-07-13)", () => { } if (!delegated) { delegated = true; - await t.get("task_create")!.run({ title: "dig", spec: "dig into the export bug", tier: "low" }); + await t.get("task_create")!.run({ title: "dig", spec: "dig into the export bug", tier: "low", ref: firstRef(sess) }); await t.get("reply")!.run({ text: "on it", ref: firstRef(sess) }); return; } diff --git a/test/toolset.test.ts b/test/toolset.test.ts index 5834aaf..49856d7 100644 --- a/test/toolset.test.ts +++ b/test/toolset.test.ts @@ -39,7 +39,11 @@ function identity(overrides: Partial = {}): IdentityConfig { function baseCtx(db: ReturnType, clock: Clock, overrides: Partial = {}): ToolsetContext { const posts: { anchor: any; text: string }[] = []; + // A standing rendered ref for the wake's home conversation — what task_create homes to. + const refs = makeRefTable(); + refs.mint({ venueId: "C1", threadRootId: null, via: "rendered" }); // r1 return { + refs, db, clock, identity: identity(), @@ -72,7 +76,7 @@ describe("task_create (SPEC §5.3, §11)", () => { const ctx = baseCtx(db, clock); const tools = buildToolset(ctx); - const result = await tool(tools, "task_create").run({ title: "dig in", spec: "why is it slow" }); + const result = await tool(tools, "task_create").run({ title: "dig in", spec: "why is it slow", ref: "r1" }); expect(result.success).toBe(true); const parsed = JSON.parse(result.output); expect(parsed.taskId).toBe("T-1"); @@ -100,7 +104,7 @@ describe("task_create (SPEC §5.3, §11)", () => { const tools = buildToolset(baseCtx(db, clock)); const create = tool(tools, "task_create"); expect(JSON.stringify(create.spec.inputSchema)).not.toContain("recurrence"); - const result = await create.run({ title: "t", spec: "s", recurrence: "every day" }); + const result = await create.run({ title: "t", spec: "s", ref: "r1", recurrence: "every day" }); expect(result.success).toBe(true); // the stray arg is ignored, never stored const row = db.query("SELECT recurrence FROM tasks WHERE id = 'T-1'").get() as { recurrence: string | null }; expect(row.recurrence).toBeNull(); @@ -111,7 +115,7 @@ describe("task_create (SPEC §5.3, §11)", () => { describe("task_steer / task_cancel / task_confirm", () => { async function activeTask(db: ReturnType, clock: Clock, ctx: ToolsetContext) { seedEvent(db, "e1", clock); - await tool(buildToolset(ctx), "task_create").run({ title: "t", spec: "s" }); + await tool(buildToolset(ctx), "task_create").run({ title: "t", spec: "s", ref: "r1" }); transition(db, clock, "T-1", "active", { type: "dispatch", executionId: "x1" }); } @@ -203,7 +207,7 @@ describe("task_query returns the identity's ledger view", () => { async function activeCreate(db: ReturnType, clock: Clock, ctx: ToolsetContext) { seedEvent(db, "e1", clock); - await tool(buildToolset(ctx), "task_create").run({ title: "t", spec: "s" }); + await tool(buildToolset(ctx), "task_create").run({ title: "t", spec: "s", ref: "r1" }); } }); @@ -317,7 +321,7 @@ describe("execution_step outcome tools (SPEC §6.3, §17.4)", () => { async function activeExecutionCtx(db: ReturnType, clock: Clock) { const createCtx = baseCtx(db, clock); seedEvent(db, "e1", clock); - await tool(buildToolset(createCtx), "task_create").run({ title: "t", spec: "s" }); + await tool(buildToolset(createCtx), "task_create").run({ title: "t", spec: "s", ref: "r1" }); transition(db, clock, "T-1", "active", { type: "dispatch", executionId: "x1" }); return baseCtx(db, clock, { turnKind: "execution_step", taskId: "T-1", anchor: { venueId: "C1", threadRootId: null } }); } @@ -402,7 +406,7 @@ describe("external tool: grant + scope + action-class confirmation flow", () => const clock = fakeClock(); seedEvent(db, "e1", clock); const createCtx = baseCtx(db, clock); - await tool(buildToolset(createCtx), "task_create").run({ title: "t", spec: "s" }); + await tool(buildToolset(createCtx), "task_create").run({ title: "t", spec: "s", ref: "r1" }); transition(db, clock, "T-1", "active", { type: "dispatch", executionId: "x1" }); const execCtx = baseCtx(db, clock, { @@ -565,7 +569,7 @@ describe("audit_query (SPEC §15: granted per identity, scoped to that identity) const tools = buildToolset(ctx); expect(tools.some((t) => t.spec.name === "audit_query")).toBe(true); - await tool(tools, "task_create").run({ title: "t", spec: "s" }); + await tool(tools, "task_create").run({ title: "t", spec: "s", ref: "r1" }); const result = await tool(tools, "audit_query").run({ kind: "task_created" }); const records = JSON.parse(result.output); expect(records).toHaveLength(1);