@teamscala/service-boot 0.23.335 → 0.23.337

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -6,19 +6,17 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.335",
9
+ "version": "0.23.337",
10
10
  "sideEffects": false,
11
11
  "files": [
12
12
  "src"
13
13
  ],
14
- "peerDependencies": {},
15
14
  "dependencies": {
16
15
  "@teamscala/agreement-generation": "*",
17
16
  "@teamscala/api-resend": "*",
18
17
  "@teamscala/auth": "*",
19
18
  "@teamscala/cache": "*",
20
19
  "@teamscala/channel": "*",
21
- "@teamscala/chat-completion": "*",
22
20
  "@teamscala/db": "*",
23
21
  "@teamscala/db-validation": "*",
24
22
  "@teamscala/document-signing": "*",
@@ -50,9 +48,7 @@
50
48
  "devDependencies": {
51
49
  "bun-types": "*",
52
50
  "@happy-dom/global-registrator": "*",
53
- "@testing-library/react": "*",
54
- "hono": "*",
55
- "pg-boss": "*"
51
+ "@testing-library/react": "*"
56
52
  },
57
53
  "exports": {
58
54
  "./agreement-gate": {
@@ -1087,5 +1083,13 @@
1087
1083
  "repository": "https://github.com/teamgbg/service-boot",
1088
1084
  "publishConfig": {
1089
1085
  "registry": "https://registry.npmjs.org"
1086
+ },
1087
+ "peerDependencies": {
1088
+ "@teamscala/chat-completion": "*"
1089
+ },
1090
+ "peerDependenciesMeta": {
1091
+ "@teamscala/chat-completion": {
1092
+ "optional": true
1093
+ }
1090
1094
  }
1091
1095
  }
package/src/auth-gate.ts CHANGED
@@ -114,12 +114,11 @@ const PUBLIC_INFRA_PATHS = new Set([
114
114
  // the other dev-host infra reads.
115
115
  "/api/fleet/claude-sessions",
116
116
  // /api/fleet/ingest-event receives parsed lane-action events
117
- // (message.part.updated etc.) from the cli-session Claude transcript-tail — a
117
+ // (message.part.updated etc.) from the cli-session transcript-tail — a
118
118
  // dev-host INTERNAL machine caller that POSTs with no session cookie, exactly
119
119
  // like the lane-event hook. Without this exemption the page auth-gate 302s
120
120
  // every POST and the lane's tool/text action stream never reaches the
121
- // ingestEvent pipeline (fleet_run_events stays empty → the dashboard console
122
- // shows only lifecycle lines, never the per-tab action feed). A first-class
121
+ // ingestEvent pipeline. A first-class
123
122
  // api_route (sibling to lane-event); security is the handler's loopback gate
124
123
  // on the platform's x-caller-remote-address (set by register-api-routes).
125
124
  "/api/fleet/ingest-event",
@@ -167,7 +167,7 @@ describe("computeInputFingerprint", () => {
167
167
  });
168
168
 
169
169
  // Regression guard for the 2026-07-12 scala-agents stale-bundle bug. A boot
170
- // that builds MID-INSTALL (package-poller restarts after writing the new
170
+ // that builds MID-INSTALL (dep-sync restarts after writing the new
171
171
  // bun.lock but before node_modules extraction finishes) bakes OLD @teamscala/*
172
172
  // JS while the lock-derived fingerprint records "current" — so every later
173
173
  // restart recomputes the same fingerprint, skips the rebuild, and serves the
@@ -84,7 +84,7 @@ export async function readClientBuildStatus(
84
84
 
85
85
  // Single shared host lock serialising the heavy boot-time client build across
86
86
  // ALL kind:ui services (client-asset-builds-serialize-via-lock). A publish of a
87
- // widely-consumed package makes the package-poller restart every consumer at
87
+ // widely-consumed package makes the dep-sync restart every consumer at
88
88
  // once; without this lock each one rebuilds its hydrate bundle + Tailwind CSS
89
89
  // simultaneously and N concurrent builds saturate the host (load 36, each ~15s
90
90
  // build starved to ~10min, services offline — 2026-06-13). The build sibling of
@@ -13,12 +13,24 @@ declare const input: v.ObjectSchema<{
13
13
  readonly runtime_slug: v.StringSchema<undefined>;
14
14
  }, undefined>;
15
15
  export type OrchestratorRegistrationRequest = v.InferOutput<typeof input>;
16
+ export declare function buildOrchestratorSpawnPayload(input: {
17
+ workItemId: string;
18
+ launchCommentId: string;
19
+ cliSessionId: string;
20
+ slug: string;
21
+ }): {
22
+ workItemId: string;
23
+ launchCommentId: string;
24
+ cliSessionId: string;
25
+ slug: string;
26
+ };
16
27
  export declare function ensureOrchestratorRegistration(request: OrchestratorRegistrationRequest, context: Context): Promise<{
17
28
  projectId: string;
18
29
  taskId: string;
19
30
  sessionId: string;
31
+ launchCommentId: string;
20
32
  runtime: string;
21
- pickerOptionSlug: string | undefined;
33
+ slug: string | undefined;
22
34
  }>;
23
35
  export declare const orchestratorRegistrationRouter: {
24
36
  ensure: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
@@ -33,14 +45,16 @@ export declare const orchestratorRegistrationRouter: {
33
45
  projectId: string;
34
46
  taskId: string;
35
47
  sessionId: string;
48
+ launchCommentId: string;
36
49
  runtime: string;
37
- pickerOptionSlug: string | undefined;
50
+ slug: string | undefined;
38
51
  }, {
39
52
  projectId: string;
40
53
  taskId: string;
41
54
  sessionId: string;
55
+ launchCommentId: string;
42
56
  runtime: string;
43
- pickerOptionSlug: string | undefined;
57
+ slug: string | undefined;
44
58
  }>, Record<never, never>, Record<never, never>>;
45
59
  };
46
60
  export {};
@@ -19,6 +19,20 @@ const registryConfig = v.looseObject({
19
19
 
20
20
  export type OrchestratorRegistrationRequest = v.InferOutput<typeof input>;
21
21
 
22
+ export function buildOrchestratorSpawnPayload(input: {
23
+ workItemId: string;
24
+ launchCommentId: string;
25
+ cliSessionId: string;
26
+ slug: string;
27
+ }) {
28
+ return {
29
+ workItemId: input.workItemId,
30
+ launchCommentId: input.launchCommentId,
31
+ cliSessionId: input.cliSessionId,
32
+ slug: input.slug,
33
+ };
34
+ }
35
+
22
36
  export async function ensureOrchestratorRegistration(
23
37
  request: OrchestratorRegistrationRequest,
24
38
  context: Context,
@@ -49,10 +63,10 @@ export async function ensureOrchestratorRegistration(
49
63
  update: { picker_option_slug: runtime.picker_option_slug, runtime_slug: request.runtime_slug, model: runtime.model, closed_at: null, lifecycle_state: "registering" },
50
64
  });
51
65
  const startupBrief = `You are the permanent Orchestrator for developer project “${project.title}” (${project.id}). Coordinate all work through the canonical work_items and comments surfaces. Every tab is a child task, every prompt and reply is a comment, and cli_sessions is runtime identity only. Dispatch implementation through the fleet and reconcile durable outcomes; never create a parallel task, message, or session source.`;
52
- await tx.comments.upsert({ where: { work_item_id_system_key: { work_item_id: task.id, system_key: "orchestrator-startup" } }, update: {}, create: { work_item_id: task.id, content: startupBrief, system_key: "orchestrator-startup", user_id: user.id } });
66
+ const launchComment = await tx.comments.upsert({ where: { work_item_id_system_key: { work_item_id: task.id, system_key: "orchestrator-startup" } }, update: { source_cli_session_id: session.id, sender_role: "orchestrator", sender_name: "Orchestrator" }, create: { work_item_id: task.id, content: startupBrief, system_key: "orchestrator-startup", source_cli_session_id: session.id, sender_role: "orchestrator", sender_name: "Orchestrator" } });
53
67
  await tx.cli_session_transitions.upsert({ where: { session_id_idempotency_key: { session_id: session.id, idempotency_key: `register:${project.id}` } }, create: { session_id: session.id, idempotency_key: `register:${project.id}`, from_state: null, to_state: "registering", trigger_event: "atomic-registration", metadata_json: { work_item_id: task.id } }, update: {} });
54
- await tx.host_commands.upsert({ where: { command_idempotency_key: { command: "picker_spawn_tab", idempotency_key: `orchestrator-visible:${project.id}` } }, update: {}, create: { command: "picker_spawn_tab", idempotency_key: `orchestrator-visible:${project.id}`, payload: { workItemId: task.id, runtimeSlug: request.runtime_slug, pickerOptionSlug: runtime.picker_option_slug, visible: true }, originator: { role: "orchestrator", session_id: session.id } } });
55
- return { projectId: project.id, taskId: task.id, sessionId: session.id, runtime: request.runtime_slug, pickerOptionSlug: runtime.picker_option_slug };
68
+ await tx.host_commands.upsert({ where: { command_idempotency_key: { command: "picker_spawn_tab", idempotency_key: `orchestrator-visible:${project.id}` } }, update: {}, create: { command: "picker_spawn_tab", idempotency_key: `orchestrator-visible:${project.id}`, payload: buildOrchestratorSpawnPayload({ workItemId: task.id, launchCommentId: launchComment.id, cliSessionId: session.id, slug: runtime.picker_option_slug }), originator: { role: "orchestrator", session_id: session.id } } });
69
+ return { projectId: project.id, taskId: task.id, sessionId: session.id, launchCommentId: launchComment.id, runtime: request.runtime_slug, slug: runtime.picker_option_slug };
56
70
  });
57
71
  }
58
72
 
@@ -38,10 +38,31 @@ describe.skipIf(!live)("canonical Orchestrator registration — live database",
38
38
  const sessionId = results[0]?.sessionId;
39
39
  expect(await db.prisma.work_items.count({ where: { id: projectId, parent: { kind: "initiative", kind_data: { path: ["tree"], equals: "agent_work" } } } })).toBe(1);
40
40
  expect(await db.prisma.work_items.count({ where: { id: taskId, parent_id: projectId, kind: "task", title: "Orchestrator" } })).toBe(1);
41
- expect(await db.prisma.comments.count({ where: { work_item_id: taskId, system_key: "orchestrator-startup" } })).toBe(1);
41
+ const startupComments = await db.prisma.comments.findMany({
42
+ where: { work_item_id: taskId, system_key: "orchestrator-startup" },
43
+ select: { id: true, source_cli_session_id: true, sender_role: true, sender_name: true },
44
+ });
45
+ expect(startupComments).toHaveLength(1);
46
+ expect(startupComments[0]).toMatchObject({
47
+ id: results[0]?.launchCommentId,
48
+ source_cli_session_id: sessionId,
49
+ sender_role: "orchestrator",
50
+ sender_name: "Orchestrator",
51
+ });
42
52
  expect(await db.prisma.cli_sessions.count({ where: { id: sessionId, work_item_id: taskId, role: "orchestrator", closed_at: null } })).toBe(1);
43
53
  expect(await db.prisma.cli_session_transitions.count({ where: { session_id: sessionId, idempotency_key: `register:${projectId}` } })).toBe(1);
44
- expect(await db.prisma.host_commands.count({ where: { command: "picker_spawn_tab", idempotency_key: `orchestrator-visible:${projectId}` } })).toBe(1);
54
+ const spawnIntents = await db.prisma.host_commands.findMany({
55
+ where: { command: "picker_spawn_tab", idempotency_key: `orchestrator-visible:${projectId}` },
56
+ select: { payload: true },
57
+ });
58
+ expect(spawnIntents).toHaveLength(1);
59
+ expect(spawnIntents[0]?.payload).toMatchObject({
60
+ workItemId: taskId,
61
+ launchCommentId: results[0]?.launchCommentId,
62
+ cliSessionId: sessionId,
63
+ slug: results[0]?.slug,
64
+ });
65
+ expect(Object.keys(spawnIntents[0]?.payload as Record<string, unknown>).sort()).toEqual(["cliSessionId", "launchCommentId", "slug", "workItemId"]);
45
66
  } finally {
46
67
  await db.disconnect();
47
68
  }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { buildOrchestratorSpawnPayload } from "./orchestrator-registration-router.ts";
3
+
4
+ describe("orchestrator spawn intent", () => {
5
+ it("uses the generated Rust slug contract and carries no alternate launch input", () => {
6
+ const payload = buildOrchestratorSpawnPayload({
7
+ workItemId: "task-1",
8
+ launchCommentId: "comment-1",
9
+ cliSessionId: "session-1",
10
+ slug: "codex-sol-acp-client",
11
+ });
12
+ expect(payload).toEqual({
13
+ workItemId: "task-1",
14
+ launchCommentId: "comment-1",
15
+ cliSessionId: "session-1",
16
+ slug: "codex-sol-acp-client",
17
+ });
18
+ expect(payload).not.toHaveProperty("pickerOptionSlug");
19
+ expect(payload).not.toHaveProperty("initialPrompt");
20
+ expect(payload).not.toHaveProperty("windowName");
21
+ expect(Object.keys(payload).sort()).toEqual(["cliSessionId", "launchCommentId", "slug", "workItemId"]);
22
+ });
23
+ });
@@ -397,6 +397,7 @@ describe("buildAssignmentSpawnPayload (no keystroke brief — row-based self-bri
397
397
  const out = buildAssignmentSpawnPayload(task, { slug: "cc-glm52" });
398
398
  expect("initialPrompt" in out.payload).toBe(false);
399
399
  expect(out.payload).not.toHaveProperty("initialPrompt");
400
+ expect("cliSessionId" in out.payload).toBe(false);
400
401
  });
401
402
 
402
403
  it("omits optional spawn fields when not supplied (no undefined keys)", () => {
@@ -21,7 +21,7 @@ mock.module("@teamscala/db/registry/platform", () => ({
21
21
  getPlatformOrgId: () => "platform-org-test",
22
22
  }));
23
23
 
24
- const { resolveBindOrg, discardApplied, untrackedRunForPane } = await import(
24
+ const { resolveBindOrg, discardApplied } = await import(
25
25
  "./task-sync-router.ts"
26
26
  );
27
27
 
@@ -86,25 +86,25 @@ describe("lane task binding is deterministic and race-proof (source contract)",
86
86
  expect(text).toContain("const winner = await resolveLaneTask(prisma, session)");
87
87
  });
88
88
 
89
- test("bindLane only fills an untracked run and preserves dispatched task identity", () => {
90
- expect(untrackedRunForPane("tmux:%77")).toEqual({
91
- pane: "tmux:%77",
92
- state: { not: "closed" },
93
- tracked_task_id: null,
94
- });
89
+ test("bindLane leaves runtime identity to cli_sessions", async () => {
90
+ const text = await src.text();
91
+ const bindLane = text.slice(text.indexOf("bindLane:"), text.indexOf("upsertFromTaskList:"));
92
+ expect(bindLane).toContain("Runtime identity is owned by cli_sessions");
93
+ expect(bindLane).not.toContain("tracked_task_id");
94
+ });
95
+
96
+ test("task events resolve pane identity through cli_sessions", async () => {
97
+ const text = await src.text();
98
+ const resolver = text.slice(text.indexOf("async function resolveSessionFromPane"), text.indexOf("/** Resolve the task"));
99
+ expect(resolver).toContain("prisma.cli_sessions.findFirst");
100
+ expect(resolver).toContain('path: ["pane_id"]');
95
101
  });
96
102
 
97
- test("bindLane STEALS tracked_task_id before claiming (no two live runs share a task)", async () => {
103
+ test("readTaskList returns the checklist with evidence-derived progress", async () => {
98
104
  const text = await src.text();
99
- // The partial unique index fleet_agent_runs_one_active_run_per_task means a
100
- // task is tracked by at most one LIVE run. Re-adoption on a new pane left the
101
- // old run holding the task; claiming without first releasing it made the next
102
- // state->active transition (upsertLaneRun's UPDATE) violate the index
103
- // (P2002 -> the lane-event 500 + the lost run-link join). Release-then-claim
104
- // steals it: only this lane's untracked run ends up with it.
105
- expect(text).toContain("STEAL before CLAIM");
106
- expect(text).toContain(
107
- "tracked_task_id: taskId, state: { not: \"closed\" }",
108
- );
105
+ const reader = text.slice(text.indexOf("readTaskList:"), text.indexOf("/**\n\t * DB→AGENT self-brief"));
106
+ expect(reader).toContain("deriveEvidenceProgress(rows, evidence)");
107
+ expect(reader).toContain("subtasks");
108
+ expect(reader).not.toContain("percentage from");
109
109
  });
110
110
  });
@@ -112,9 +112,8 @@ describe("readAssignedBrief reads the FIRST launch comment (source contract)", (
112
112
 
113
113
  describe("readFirstTaskComment is a single deterministic launch-comment read", () => {
114
114
  it("returns null when the prisma client has no comments model (feature-detect)", async () => {
115
- // `comments` is a universal shared model, but the access is feature-detected
116
- // like the fleet_agent_runs reads so a client without it degrades to "no
117
- // brief" rather than throwing.
115
+ // `comments` is a universal shared model; a client without it degrades to
116
+ // "no brief" rather than throwing.
118
117
  const out = await readFirstTaskComment({} as never, "task-1");
119
118
  expect(out).toBe(null);
120
119
  });
@@ -3,59 +3,45 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * DB→AGENT dispatch for task-sync — notifies a lane's live pane that its
7
- * work_items task list changed out-of-band (operator toggled a subtask in the
8
- * projects UI, another lane edited a shared task). The pane's adapter then
9
- * re-reads (fn.taskSync.readTaskList) and updates its native list.
10
- *
11
- * MECHANISM — reuses the existing CLI-agnostic channel delivery, no new
12
- * transport: a POST to scala-channel's fleet-events publish entry
13
- * (POST /api/fleet/events — @teamscala/channel/fleet-events-publish) carrying an
14
- * `orchestrator.message` FleetEvent with slotName "cross-pane". That slot is
15
- * the watchEvents bypass for events NOT tied to a fleet_agent_runs row
16
- * (FleetEventOrchestratorMessageSchema comment) — exactly the system-originated
17
- * case. scala-channel re-emits onto its eventBus and streamFleetEvents delivers
18
- * to the subscriber filtered by orchestratorSessionId (the target lane pane).
19
- * Both the Claude Code relay and the OpenCode scala-hooks plugin subscribe to
20
- * that SSE stream for their own pane identity and inject each event natively —
21
- * so ONE publish reaches every CLI that has a live projection, no per-CLI
22
- * dispatch code here.
23
- *
24
- * NOT the dispatch entry (POST /api/channel/send): that path requires a real
25
- * sender pane identity (channel-sender-identity-validated anti-spoof) and so
26
- * cannot carry a system-originated notification. The publish path is
27
- * internal-trusted (localhost super-admin) and accepts a synthetic
28
- * fromOrchestratorId — the correct path for a system→pane message.
29
- *
30
- * scala-channel's address is resolved from its service_routing row (local_port)
31
- * — never hardcoded (service-ports-from-registry). Fire-and-forget: a dispatch
32
- * failure (scala-channel down, port unresolvable) is logged and swallowed; it
33
- * must never block the work_items write that triggered it, and the lane's
34
- * SessionStart/UserPromptSubmit backstop picks up the change on its next turn
35
- * regardless.
36
- */
37
- /**
38
- * The `orchestrator.message` FleetEvent payload that notifies a lane its task
39
- * list changed. Pure (no I/O) so it is unit-testable; notifyLaneOfTaskChange
40
- * posts it. slotName "cross-pane" is REQUIRED — it is the watchEvents bypass for
41
- * events not tied to a fleet_agent_runs row. orchestratorSessionId is the TARGET
42
- * lane pane (the SSE subscriber filter). The message is single-line (the
43
- * <channel> tag renders it inline).
6
+ * DB→AGENT task projection emission. A work_items change is delivered by one
7
+ * canonical comments row addressed to the active cli_sessions runtime; the
8
+ * database emission wakes that runtime adapter, which re-reads readTaskList.
9
+ * This module owns no pane transport, terminal fallback, or second message
10
+ * queue.
44
11
  */
45
- export interface TaskChangedEvent {
46
- type: "orchestrator.message";
47
- message: string;
48
- fromOrchestratorId: string;
49
- runId: string;
50
- slotName: "cross-pane";
51
- ts: number;
52
- orchestratorSessionId: string;
12
+ export interface TaskChangeComment {
13
+ work_item_id: string;
14
+ content: string;
15
+ system_key: string;
16
+ source_cli_session_id: string;
17
+ sender_role: "system";
18
+ sender_name: "TaskSync";
53
19
  }
54
- export declare function buildTaskChangedEvent(lanePane: string, taskId: string, now?: number): TaskChangedEvent;
55
- /**
56
- * Publish a "your task list changed" notification to the owning lane's pane.
57
- * The pane identity is the stable tmux id ("tmux:%42") every CLI relay/plugin
58
- * receives for. `taskId` is carried in the message so the recipient can re-read
59
- * just that task. Fire-and-forget.
60
- */
61
- export declare function notifyLaneOfTaskChange(lanePane: string, taskId: string): Promise<void>;
20
+ export declare function buildTaskChangeComment(taskId: string, sessionId: string, eventKey?: string): TaskChangeComment;
21
+ export interface TaskDispatchPrisma {
22
+ cli_sessions: {
23
+ findFirst(args: unknown): Promise<{
24
+ id: string;
25
+ } | null>;
26
+ };
27
+ comments: {
28
+ upsert(args: {
29
+ where: {
30
+ work_item_id_system_key: {
31
+ work_item_id: string;
32
+ system_key: string;
33
+ };
34
+ };
35
+ create: TaskChangeComment;
36
+ update: Record<string, never>;
37
+ }): Promise<{
38
+ id: string;
39
+ }>;
40
+ };
41
+ }
42
+ /** Write the durable task-change comment for the active runtime, if present. */
43
+ export declare function notifyLaneOfTaskChange(prisma: TaskDispatchPrisma, taskId: string, eventKey?: string): Promise<{
44
+ delivered: boolean;
45
+ sessionId: string | null;
46
+ commentId: string | null;
47
+ }>;
@@ -3,42 +3,60 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Unit tests for the pure event-payload builder in task-sync-dispatch. The HTTP
7
- * POST + registry resolution are side-effectful (covered end-to-end by the
8
- * DB->AGENT direction demo — a published orchestrator.message with slotName
9
- * cross-pane delivers to the target pane's <channel> tag); buildTaskChangedEvent
10
- * is pure and tested here. Satisfies what-must-have-a-test / primitive-api-has-test.
6
+ * Unit tests for the pure durable comment-payload builder in task-sync-dispatch.
7
+ * The runtime path writes this payload through the canonical comments model.
11
8
  */
12
9
  import { describe, it, expect } from "bun:test";
13
- import { buildTaskChangedEvent } from "./task-sync-dispatch.ts";
10
+ import { buildTaskChangeComment, notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
14
11
 
15
- describe("task-sync buildTaskChangedEvent", () => {
16
- it("is an orchestrator.message addressed to the owning lane pane", () => {
17
- const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
18
- expect(e.type).toBe("orchestrator.message");
19
- expect(e.orchestratorSessionId).toBe("tmux:%5");
20
- });
21
-
22
- it("uses slotName cross-pane so watchEvents bypasses the fleet-run-existence check", () => {
23
- const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
24
- expect(e.slotName).toBe("cross-pane");
25
- });
12
+ describe("task-sync buildTaskChangeComment", () => {
13
+ it("addresses the canonical task and runtime with no pane transport", () => {
14
+ const comment = buildTaskChangeComment("task-7", "session-5", "subtask-1:completed");
15
+ expect(comment).toMatchObject({
16
+ work_item_id: "task-7",
17
+ source_cli_session_id: "session-5",
18
+ sender_role: "system",
19
+ sender_name: "TaskSync",
20
+ });
21
+ expect(comment.system_key).toBe("task-sync:task-7:session-5:subtask-1:completed");
22
+ expect(comment.content).toContain("readTaskList");
23
+ expect(comment).not.toHaveProperty("pane");
24
+ });
26
25
 
27
- it("carries the task id in the message so the recipient can re-read just it", () => {
28
- const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
29
- expect(e.message).toContain("task-7");
30
- expect(e.message).toContain("readTaskList");
31
- });
32
-
33
- it("produces a single-line message (the <channel> tag renders it inline)", () => {
34
- const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
35
- expect(e.message.includes("\n")).toBe(false);
36
- });
26
+ it("writes one durable comment addressed by the active cli_session", async () => {
27
+ const calls: unknown[] = [];
28
+ const result = await notifyLaneOfTaskChange(
29
+ {
30
+ cli_sessions: { findFirst: async () => ({ id: "session-5" }) },
31
+ comments: {
32
+ upsert: async (args) => {
33
+ calls.push(args);
34
+ return { id: "comment-1" };
35
+ },
36
+ },
37
+ },
38
+ "task-7",
39
+ "subtask-1:completed",
40
+ );
41
+ expect(result).toMatchObject({ delivered: true, sessionId: "session-5", commentId: "comment-1" });
42
+ expect(calls[0]).toMatchObject({ create: { work_item_id: "task-7", source_cli_session_id: "session-5" } });
43
+ });
37
44
 
38
- it("stamps the system sender + a deterministic runId + the provided epoch-ms ts", () => {
39
- const e = buildTaskChangedEvent("tmux:%5", "task-7", 1700000000000);
40
- expect(e.fromOrchestratorId).toBe("task-sync");
41
- expect(e.runId).toBe("task-sync-task-7-1700000000000");
42
- expect(e.ts).toBe(1700000000000);
45
+ it("does not fabricate a pre-launch session or comment", async () => {
46
+ let writes = 0;
47
+ const result = await notifyLaneOfTaskChange(
48
+ {
49
+ cli_sessions: { findFirst: async () => null },
50
+ comments: {
51
+ upsert: async () => {
52
+ writes++;
53
+ return { id: "unexpected" };
54
+ },
55
+ },
56
+ },
57
+ "task-7",
58
+ );
59
+ expect(result).toEqual({ delivered: false, sessionId: null, commentId: null });
60
+ expect(writes).toBe(0);
61
+ });
43
62
  });
44
- });