@teamscala/service-boot 0.23.334 → 0.23.336

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.
Files changed (30) hide show
  1. package/package.json +1088 -1089
  2. package/src/auth-gate.ts +2 -3
  3. package/src/build-client-assets.test.ts +1 -1
  4. package/src/build-client-assets.ts +1 -1
  5. package/src/fn-routers/orchestrator-registration-router.d.ts +17 -3
  6. package/src/fn-routers/orchestrator-registration-router.ts +17 -3
  7. package/src/fn-routers/orchestrator-registration.live.test.ts +23 -2
  8. package/src/fn-routers/orchestrator-registration.test.ts +23 -0
  9. package/src/fn-routers/task-sync-assign.test.ts +1 -0
  10. package/src/fn-routers/task-sync-bind.test.ts +18 -18
  11. package/src/fn-routers/task-sync-brief.d.ts +7 -8
  12. package/src/fn-routers/task-sync-brief.test.ts +10 -12
  13. package/src/fn-routers/task-sync-brief.ts +9 -10
  14. package/src/fn-routers/task-sync-dispatch.d.ts +40 -54
  15. package/src/fn-routers/task-sync-dispatch.test.ts +51 -33
  16. package/src/fn-routers/task-sync-dispatch.ts +57 -100
  17. package/src/fn-routers/task-sync-lane.test.ts +36 -0
  18. package/src/fn-routers/task-sync-lane.ts +8 -19
  19. package/src/fn-routers/task-sync-projection.d.ts +23 -0
  20. package/src/fn-routers/task-sync-projection.test.ts +26 -0
  21. package/src/fn-routers/task-sync-projection.ts +51 -0
  22. package/src/fn-routers/task-sync-router.d.ts +30 -23
  23. package/src/fn-routers/task-sync-router.test.ts +14 -2
  24. package/src/fn-routers/task-sync-router.ts +72 -114
  25. package/src/handlers/sweep-host-cache.d.ts +21 -1
  26. package/src/handlers/sweep-host-cache.test.ts +85 -0
  27. package/src/handlers/sweep-host-cache.ts +118 -4
  28. package/src/mount-orpc-router.ts +1 -1
  29. package/src/resolve-brand-favicon.d.ts +1 -1
  30. package/src/resolve-brand-favicon.ts +1 -1
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
  });
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Lane BRIEF resolution for task-sync: a spawned lane PULLS its own brief. The
7
7
  * lane's CLI extension reads its own session id on load, resolves the task
8
- * assigned to that session, and reads the brief from that task's LATEST comment.
8
+ * assigned to that session, and reads the brief from that task's FIRST comment.
9
9
  * Nothing is pasted, typed, passed as an argument or sent over a channel
10
10
  * (`scala-docs/reference/action-emission-architecture.md` "The lane PULLS its
11
11
  * brief"). This file owns the readAssignedBrief ORPC handler + its pure helper;
@@ -34,7 +34,7 @@ import { type Context } from "./auth-router.ts";
34
34
  */
35
35
  export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound";
36
36
  /**
37
- * Shape the readAssignedBrief response from a resolved task + its latest
37
+ * Shape the readAssignedBrief response from a resolved task + its first
38
38
  * comment. Pure (no prisma) so the decision — "no task/comment ⇒ nulls, never a
39
39
  * verdict from no data" (`no-verdict-from-no-data`) — is unit-testable.
40
40
  *
@@ -64,13 +64,12 @@ export declare function shapeBriefResponse(task: {
64
64
  status: BriefReadStatus;
65
65
  };
66
66
  /**
67
- * Read the LATEST comment on a work_items task. The comment IS the brief for a
68
- * run: every spawn writes a NEW comment, and the description is never the brief,
69
- * so "what was this lane told" always has exactly one answer
70
- * (`assignment-as-dispatch`). Newest-first by created_at. Returns null when the
71
- * task has no comment yet — the extension treats that as "no brief to inject".
67
+ * Read the immutable FIRST comment on a work_items task. It is the launch brief;
68
+ * later comments are conversation and must never change the launch identity.
69
+ * Oldest-first by created_at with id as a deterministic tie-breaker. Returns null
70
+ * when the task has no comment yet.
72
71
  */
73
- export declare function readLatestTaskComment(prisma: Prisma, workItemId: string): Promise<CommentRow | null>;
72
+ export declare function readFirstTaskComment(prisma: Prisma, workItemId: string): Promise<CommentRow | null>;
74
73
  /**
75
74
  * readAssignedBrief ORPC handler. A lane's CLI extension calls this with its
76
75
  * own session id to pull its brief. Same transport as bindLane/readTaskList: the
@@ -5,11 +5,11 @@
5
5
  *
6
6
  * Unit tests for the self-brief leg: shapeBriefResponse (the pure
7
7
  * no-task/no-comment ⇒ nulls decision) + source-contract assertions that
8
- * readLatestTaskComment reads the LATEST comment and the handler resolves by
8
+ * readFirstTaskComment reads the FIRST launch comment and the handler resolves by
9
9
  * session. Covers `what-must-have-a-test` for the new procedure.
10
10
  */
11
11
  import { describe, it, expect } from "bun:test";
12
- import { shapeBriefResponse, readLatestTaskComment } from "./task-sync-brief.ts";
12
+ import { shapeBriefResponse, readFirstTaskComment } from "./task-sync-brief.ts";
13
13
 
14
14
  describe("shapeBriefResponse (no verdict from no data)", () => {
15
15
  it("classifies a missing task as unbound (work_item_id null)", () => {
@@ -76,14 +76,13 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
76
76
  });
77
77
  });
78
78
 
79
- describe("readAssignedBrief reads the LATEST comment (source contract)", () => {
79
+ describe("readAssignedBrief reads the FIRST launch comment (source contract)", () => {
80
80
  const briefSrc = Bun.file(new URL("./task-sync-brief.ts", import.meta.url).pathname);
81
81
 
82
- it("queries comments newest-first by created_at on work_item_id", async () => {
83
- // The comment IS the brief for a run; ordering by created_at desc makes the
84
- // LATEST comment (this run's) the answer, never a stale prior-run comment.
82
+ it("queries comments oldest-first with an id tie-breaker", async () => {
83
+ // The first comment is immutable launch identity; later comments are dialogue.
85
84
  const text = await briefSrc.text();
86
- expect(text).toContain('orderBy: { created_at: "desc" }');
85
+ expect(text).toContain('orderBy: [{ created_at: "asc" }, { id: "asc" }]');
87
86
  expect(text).toContain("work_item_id: workItemId");
88
87
  });
89
88
 
@@ -111,12 +110,11 @@ describe("readAssignedBrief reads the LATEST comment (source contract)", () => {
111
110
  });
112
111
  });
113
112
 
114
- describe("readLatestTaskComment is a single latest-comment read", () => {
113
+ describe("readFirstTaskComment is a single deterministic launch-comment read", () => {
115
114
  it("returns null when the prisma client has no comments model (feature-detect)", async () => {
116
- // `comments` is a universal shared model, but the access is feature-detected
117
- // like the fleet_agent_runs reads so a client without it degrades to "no
118
- // brief" rather than throwing.
119
- const out = await readLatestTaskComment({} as never, "task-1");
115
+ // `comments` is a universal shared model; a client without it degrades to
116
+ // "no brief" rather than throwing.
117
+ const out = await readFirstTaskComment({} as never, "task-1");
120
118
  expect(out).toBe(null);
121
119
  });
122
120
  });
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Lane BRIEF resolution for task-sync: a spawned lane PULLS its own brief. The
7
7
  * lane's CLI extension reads its own session id on load, resolves the task
8
- * assigned to that session, and reads the brief from that task's LATEST comment.
8
+ * assigned to that session, and reads the brief from that task's FIRST comment.
9
9
  * Nothing is pasted, typed, passed as an argument or sent over a channel
10
10
  * (`scala-docs/reference/action-emission-architecture.md` "The lane PULLS its
11
11
  * brief"). This file owns the readAssignedBrief ORPC handler + its pure helper;
@@ -39,7 +39,7 @@ import { resolveLaneTask, resolveTaskById } from "./task-sync-lane.ts";
39
39
  export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound";
40
40
 
41
41
  /**
42
- * Shape the readAssignedBrief response from a resolved task + its latest
42
+ * Shape the readAssignedBrief response from a resolved task + its first
43
43
  * comment. Pure (no prisma) so the decision — "no task/comment ⇒ nulls, never a
44
44
  * verdict from no data" (`no-verdict-from-no-data`) — is unit-testable.
45
45
  *
@@ -90,13 +90,12 @@ export function shapeBriefResponse(
90
90
  }
91
91
 
92
92
  /**
93
- * Read the LATEST comment on a work_items task. The comment IS the brief for a
94
- * run: every spawn writes a NEW comment, and the description is never the brief,
95
- * so "what was this lane told" always has exactly one answer
96
- * (`assignment-as-dispatch`). Newest-first by created_at. Returns null when the
97
- * task has no comment yet — the extension treats that as "no brief to inject".
93
+ * Read the immutable FIRST comment on a work_items task. It is the launch brief;
94
+ * later comments are conversation and must never change the launch identity.
95
+ * Oldest-first by created_at with id as a deterministic tie-breaker. Returns null
96
+ * when the task has no comment yet.
98
97
  */
99
- export async function readLatestTaskComment(
98
+ export async function readFirstTaskComment(
100
99
  prisma: Prisma,
101
100
  workItemId: string,
102
101
  ): Promise<CommentRow | null> {
@@ -110,7 +109,7 @@ export async function readLatestTaskComment(
110
109
  if (!comments?.findFirst) return null;
111
110
  return comments.findFirst({
112
111
  where: { work_item_id: workItemId },
113
- orderBy: { created_at: "desc" },
112
+ orderBy: [{ created_at: "asc" }, { id: "asc" }],
114
113
  });
115
114
  }
116
115
 
@@ -147,6 +146,6 @@ export const readAssignedBriefHandler = authed.handler(async ({ input, context }
147
146
  task = (await resolveLaneTask(prisma, session)) as (WorkItem & { id: string }) | null;
148
147
  }
149
148
  if (!task) return shapeBriefResponse(null, null);
150
- const comment = await readLatestTaskComment(prisma, task.id);
149
+ const comment = await readFirstTaskComment(prisma, task.id);
151
150
  return shapeBriefResponse(task, comment);
152
151
  });
@@ -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
- });