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
9 changes: 5 additions & 4 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 19 additions & 6 deletions src/turn-runner/toolset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 35 additions & 4 deletions test/resident.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions test/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();

Expand Down Expand Up @@ -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;
}
Expand Down
18 changes: 11 additions & 7 deletions test/toolset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ function identity(overrides: Partial<IdentityConfig> = {}): IdentityConfig {

function baseCtx(db: ReturnType<typeof openLedger>, clock: Clock, overrides: Partial<ToolsetContext> = {}): 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(),
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand All @@ -111,7 +115,7 @@ describe("task_create (SPEC §5.3, §11)", () => {
describe("task_steer / task_cancel / task_confirm", () => {
async function activeTask(db: ReturnType<typeof openLedger>, 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" });
}

Expand Down Expand Up @@ -203,7 +207,7 @@ describe("task_query returns the identity's ledger view", () => {

async function activeCreate(db: ReturnType<typeof openLedger>, 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" });
}
});

Expand Down Expand Up @@ -317,7 +321,7 @@ describe("execution_step outcome tools (SPEC §6.3, §17.4)", () => {
async function activeExecutionCtx(db: ReturnType<typeof openLedger>, 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 } });
}
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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);
Expand Down
Loading