@teamscala/service-boot 0.23.333 → 0.23.335

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.
@@ -37,6 +37,7 @@ import { tasksRouter } from "./tasks-router.ts";
37
37
  import { userPreferencesRouter } from "./user-preferences-router.ts";
38
38
  import { userPreferencesRouter as portalPreferencesRouter } from "../page-server-orpc/user-preferences-router.ts";
39
39
  import { workItemsRouter } from "./work-items-router.ts";
40
+ import { orchestratorRegistrationRouter } from "./orchestrator-registration-router.ts";
40
41
 
41
42
  export function registerFnRouters(): void {
42
43
  registerCustomRouter("fn", {
@@ -53,6 +54,7 @@ export function registerFnRouters(): void {
53
54
  tasks: tasksRouter,
54
55
  taskSync: taskSyncRouter,
55
56
  workItems: workItemsRouter,
57
+ orchestratorRegistration: orchestratorRegistrationRouter,
56
58
  userPreferences: userPreferencesRouter,
57
59
  portalPreferences: portalPreferencesRouter,
58
60
  plans: plansRouter,
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * One transaction for the permanent Orchestrator registration. Database
7
+ * uniqueness is the correctness boundary; every identity row is upserted
8
+ * inside one transaction and callers cannot supply a project/session id.
9
+ */
10
+ import { type Context } from "./auth-router.ts";
11
+ import * as v from "valibot";
12
+ declare const input: v.ObjectSchema<{
13
+ readonly runtime_slug: v.StringSchema<undefined>;
14
+ }, undefined>;
15
+ export type OrchestratorRegistrationRequest = v.InferOutput<typeof input>;
16
+ export declare function ensureOrchestratorRegistration(request: OrchestratorRegistrationRequest, context: Context): Promise<{
17
+ projectId: string;
18
+ taskId: string;
19
+ sessionId: string;
20
+ runtime: string;
21
+ pickerOptionSlug: string | undefined;
22
+ }>;
23
+ export declare const orchestratorRegistrationRouter: {
24
+ ensure: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
25
+ headers: Headers;
26
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
27
+ prisma: any;
28
+ user: import("./auth-router.ts").User;
29
+ orgId: string | null | undefined;
30
+ }>, v.ObjectSchema<{
31
+ readonly runtime_slug: v.StringSchema<undefined>;
32
+ }, undefined>, import("@orpc/contract").Schema<{
33
+ projectId: string;
34
+ taskId: string;
35
+ sessionId: string;
36
+ runtime: string;
37
+ pickerOptionSlug: string | undefined;
38
+ }, {
39
+ projectId: string;
40
+ taskId: string;
41
+ sessionId: string;
42
+ runtime: string;
43
+ pickerOptionSlug: string | undefined;
44
+ }>, Record<never, never>, Record<never, never>>;
45
+ };
46
+ export {};
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * One transaction for the permanent Orchestrator registration. Database
7
+ * uniqueness is the correctness boundary; every identity row is upserted
8
+ * inside one transaction and callers cannot supply a project/session id.
9
+ */
10
+ import { authed, type Context } from "./auth-router.ts";
11
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
12
+ import type { PrismaClient } from "@teamscala/db/client";
13
+ import * as v from "valibot";
14
+
15
+ const input = v.object({ runtime_slug: v.string() });
16
+ const registryConfig = v.looseObject({
17
+ enabled: v.optional(v.boolean()), model: v.optional(v.string()), reasoning_effort: v.optional(v.string()), picker_option_slug: v.optional(v.string()),
18
+ });
19
+
20
+ export type OrchestratorRegistrationRequest = v.InferOutput<typeof input>;
21
+
22
+ export async function ensureOrchestratorRegistration(
23
+ request: OrchestratorRegistrationRequest,
24
+ context: Context,
25
+ ) {
26
+ const runtime = await loadRegistryConfig("agent_runtime", request.runtime_slug, registryConfig);
27
+ if (runtime.enabled === false || runtime.model !== "gpt-5.6-sol" || runtime.reasoning_effort !== "high" || !runtime.picker_option_slug) throw new Error("requested runtime is not the registry-backed Sol High Orchestrator runtime");
28
+ const user = context.user as { id: string; name?: string | null; displayName?: string | null; organisationId?: string | null };
29
+ const organisationId = context.orgId || user.organisationId;
30
+ if (!organisationId) throw new Error("Orchestrator registration requires an organisation");
31
+ return await (context.prisma as PrismaClient).$transaction(async (tx) => {
32
+ const initiative = await tx.work_items.findFirst({ where: { organisation_id: organisationId, kind: "initiative", kind_data: { path: ["tree"], equals: "agent_work" } } });
33
+ if (!initiative) throw new Error("agent-work initiative is not registered");
34
+ const developerName = user.displayName?.trim() || user.name?.trim() || "Developer";
35
+ const project = await tx.work_items.upsert({
36
+ where: { organisation_id_parent_id_assignee_id: { organisation_id: organisationId, parent_id: initiative.id, assignee_id: user.id } },
37
+ create: { kind: "project", parent_id: initiative.id, organisation_id: organisationId, title: `Developer session — ${developerName}`, owner_id: user.id, assignee_id: user.id, created_by: user.id, kind_data: { project_type: "developer_session" } },
38
+ update: { title: `Developer session — ${developerName}`, owner_id: user.id, assignee_id: user.id },
39
+ });
40
+ await tx.work_item_roles.upsert({ where: { work_item_id_user_id_role: { work_item_id: project.id, user_id: user.id, role: "assignee" } }, create: { work_item_id: project.id, user_id: user.id, role: "assignee", organisation_id: organisationId, created_by: user.id }, update: {} });
41
+ const task = await tx.work_items.upsert({
42
+ where: { parent_id_title: { parent_id: project.id, title: "Orchestrator" } },
43
+ create: { kind: "task", parent_id: project.id, organisation_id: organisationId, title: "Orchestrator", owner_id: user.id, assignee_id: user.id, created_by: user.id },
44
+ update: { owner_id: user.id, assignee_id: user.id },
45
+ });
46
+ const session = await tx.cli_sessions.upsert({
47
+ where: { work_item_id: task.id },
48
+ create: { authority: "operator", role: "orchestrator", cli_kind: "codex", picker_option_slug: runtime.picker_option_slug, runtime_slug: request.runtime_slug, provider: "codex", model: runtime.model, source_adapter: "orchestrator", label: "Orchestrator", lifecycle_state: "registering", host_route: {}, restore_metadata: {}, adapter_metadata: { project_id: project.id, work_item_id: task.id }, work_item_id: task.id },
49
+ update: { picker_option_slug: runtime.picker_option_slug, runtime_slug: request.runtime_slug, model: runtime.model, closed_at: null, lifecycle_state: "registering" },
50
+ });
51
+ 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 } });
53
+ 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 };
56
+ });
57
+ }
58
+
59
+ export const orchestratorRegistrationRouter = {
60
+ ensure: authed.input(input).handler(async ({ input: request, context }) => {
61
+ return ensureOrchestratorRegistration(request, context);
62
+ }),
63
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Real database concurrency acceptance for the canonical developer-session
7
+ * registration. Run with PM_FLEET_LIVE_TEST=1 and a PM_FLEET_TEST_USER_ID
8
+ * (plus PM_FLEET_TEST_ORG_ID when the user has more than one membership).
9
+ */
10
+ import { describe, expect, it } from "bun:test";
11
+ import { createDbClient } from "@teamscala/db/create-db-client";
12
+ import { registerPrismaClient } from "@teamscala/db/prisma-registry";
13
+ import { ensureOrchestratorRegistration } from "./orchestrator-registration-router.ts";
14
+ import type { Context } from "./auth-router.ts";
15
+
16
+ const live = process.env.PM_FLEET_LIVE_TEST === "1";
17
+
18
+ describe.skipIf(!live)("canonical Orchestrator registration — live database", () => {
19
+ it("concurrent callers converge on one project, task, comment, runtime, transition, and spawn intent", async () => {
20
+ const userId = process.env.PM_FLEET_TEST_USER_ID;
21
+ if (!userId) throw new Error("PM_FLEET_TEST_USER_ID is required for the live concurrency acceptance");
22
+ const db = await createDbClient({ serviceName: "service-boot-pm-fleet-live", maxConnections: 10 });
23
+ try {
24
+ registerPrismaClient(db.prisma as never);
25
+ const user = await db.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { id: true, name: true } });
26
+ const organisationId = process.env.PM_FLEET_TEST_ORG_ID;
27
+ if (!organisationId) throw new Error("PM_FLEET_TEST_ORG_ID is required for the live concurrency acceptance");
28
+ const context: Context = { user: { id: user.id, name: user.name, organisationId }, orgId: organisationId, prisma: db.prisma, headers: new Headers() };
29
+ const results = await Promise.all(Array.from({ length: 8 }, () => ensureOrchestratorRegistration({ runtime_slug: "codex-acp" }, context)));
30
+ const projectIds = new Set(results.map((result) => result.projectId));
31
+ const taskIds = new Set(results.map((result) => result.taskId));
32
+ const sessionIds = new Set(results.map((result) => result.sessionId));
33
+ expect(projectIds.size).toBe(1);
34
+ expect(taskIds.size).toBe(1);
35
+ expect(sessionIds.size).toBe(1);
36
+ const projectId = results[0]?.projectId;
37
+ const taskId = results[0]?.taskId;
38
+ const sessionId = results[0]?.sessionId;
39
+ expect(await db.prisma.work_items.count({ where: { id: projectId, parent: { kind: "initiative", kind_data: { path: ["tree"], equals: "agent_work" } } } })).toBe(1);
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);
42
+ expect(await db.prisma.cli_sessions.count({ where: { id: sessionId, work_item_id: taskId, role: "orchestrator", closed_at: null } })).toBe(1);
43
+ 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);
45
+ } finally {
46
+ await db.disconnect();
47
+ }
48
+ });
49
+ });
@@ -300,6 +300,8 @@ export declare const createBriefTaskHandler: import("@orpc/server").DecoratedPro
300
300
  orgId: string | null | undefined;
301
301
  }>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
302
302
  work_item_id: string;
303
+ comment_id: string;
303
304
  }, {
304
305
  work_item_id: string;
306
+ comment_id: string;
305
307
  }>, Record<never, never>, Record<never, never>>;
@@ -49,7 +49,7 @@ function fakePrisma(overrides: { platformOrg?: string } = {}) {
49
49
  comments: {
50
50
  create: async (a: { data: Record<string, unknown> }) => {
51
51
  calls.push({ workItem: null, comment: a.data });
52
- return a.data;
52
+ return { ...a.data, id: "comment-new-1" };
53
53
  },
54
54
  },
55
55
  };
@@ -181,7 +181,7 @@ export async function createTaskWithBriefComment(
181
181
  }).$transaction(async (tx) => {
182
182
  const t = tx as {
183
183
  work_items: { create: (a: unknown) => Promise<WorkItem> };
184
- comments: { create: (a: unknown) => Promise<unknown> };
184
+ comments: { create: (a: unknown) => Promise<{ id: string }> };
185
185
  };
186
186
  const task = await t.work_items.create({
187
187
  data: {
@@ -203,14 +203,14 @@ export async function createTaskWithBriefComment(
203
203
  },
204
204
  });
205
205
  // The description IS the first comment IS the brief — one write, one source.
206
- await t.comments.create({
206
+ const firstComment = await t.comments.create({
207
207
  data: {
208
208
  work_item_id: task.id,
209
209
  content: input.description,
210
210
  user_id: userId,
211
211
  },
212
212
  });
213
- return task;
213
+ return { ...task, first_comment_id: firstComment.id } as WorkItem & { first_comment_id: string };
214
214
  });
215
215
  }
216
216
 
@@ -471,7 +471,7 @@ export const createBriefTaskHandler = authed.handler(async ({ input, context })
471
471
  },
472
472
  userId,
473
473
  );
474
- return { work_item_id: task.id };
474
+ return { work_item_id: task.id, comment_id: (task as WorkItem & { first_comment_id: string }).first_comment_id };
475
475
  } catch (err) {
476
476
  // oRPC's default swallows a handler throw into a generic 500 with no stack;
477
477
  // emit it so a mint failure is diagnosable, not a black box
@@ -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
  *
@@ -56,20 +56,20 @@ export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound"
56
56
  */
57
57
  export declare function shapeBriefResponse(task: {
58
58
  id: string;
59
- } | null, comment: Pick<CommentRow, "content" | "created_at"> | null): {
59
+ } | null, comment: Pick<CommentRow, "id" | "content" | "created_at"> | null): {
60
60
  work_item_id: string | null;
61
+ comment_id: string | null;
61
62
  brief: string | null;
62
63
  comment_created_at: string | null;
63
64
  status: BriefReadStatus;
64
65
  };
65
66
  /**
66
- * Read the LATEST comment on a work_items task. The comment IS the brief for a
67
- * run: every spawn writes a NEW comment, and the description is never the brief,
68
- * so "what was this lane told" always has exactly one answer
69
- * (`assignment-as-dispatch`). Newest-first by created_at. Returns null when the
70
- * 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.
71
71
  */
72
- export declare function readLatestTaskComment(prisma: Prisma, workItemId: string): Promise<CommentRow | null>;
72
+ export declare function readFirstTaskComment(prisma: Prisma, workItemId: string): Promise<CommentRow | null>;
73
73
  /**
74
74
  * readAssignedBrief ORPC handler. A lane's CLI extension calls this with its
75
75
  * own session id to pull its brief. Same transport as bindLane/readTaskList: the
@@ -85,11 +85,13 @@ export declare const readAssignedBriefHandler: import("@orpc/server").DecoratedP
85
85
  orgId: string | null | undefined;
86
86
  }>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
87
87
  work_item_id: string | null;
88
+ comment_id: string | null;
88
89
  brief: string | null;
89
90
  comment_created_at: string | null;
90
91
  status: BriefReadStatus;
91
92
  }, {
92
93
  work_item_id: string | null;
94
+ comment_id: string | null;
93
95
  brief: string | null;
94
96
  comment_created_at: string | null;
95
97
  status: BriefReadStatus;
@@ -5,19 +5,20 @@
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)", () => {
16
16
  // A lane with no resolvable task has no brief — null, never an empty fake.
17
17
  // status:"unbound" is the signal a non-fleet lane no-ops on and a fleet
18
18
  // lane (which resolves by work_item_id, so cannot reach this) never sees.
19
- expect(shapeBriefResponse(null, { content: "x", created_at: new Date() })).toEqual({
19
+ expect(shapeBriefResponse(null, { id: "c-0", content: "x", created_at: new Date() })).toEqual({
20
20
  work_item_id: null,
21
+ comment_id: null,
21
22
  brief: null,
22
23
  comment_created_at: null,
23
24
  status: "unbound",
@@ -30,6 +31,7 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
30
31
  // is the broken state a fleet lane halts on rather than reading as success.
31
32
  expect(shapeBriefResponse({ id: "task-1" }, null)).toEqual({
32
33
  work_item_id: "task-1",
34
+ comment_id: null,
33
35
  brief: null,
34
36
  comment_created_at: null,
35
37
  status: "no_comment",
@@ -40,14 +42,16 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
40
42
  // The comment exists but its content is empty/whitespace — "loads it
41
43
  // empty". A fleet lane whose spawn minted a task MUST have a non-empty
42
44
  // brief, so a blank comment is a defect the hook halts on, not a success.
43
- expect(shapeBriefResponse({ id: "task-2" }, { content: " ", created_at: new Date() })).toEqual({
45
+ expect(shapeBriefResponse({ id: "task-2" }, { id: "c-2", content: " ", created_at: new Date() })).toEqual({
44
46
  work_item_id: "task-2",
47
+ comment_id: "c-2",
45
48
  brief: null,
46
49
  comment_created_at: null,
47
50
  status: "empty_brief",
48
51
  });
49
- expect(shapeBriefResponse({ id: "task-2b" }, { content: "", created_at: new Date() })).toEqual({
52
+ expect(shapeBriefResponse({ id: "task-2b" }, { id: "c-2b", content: "", created_at: new Date() })).toEqual({
50
53
  work_item_id: "task-2b",
54
+ comment_id: "c-2b",
51
55
  brief: null,
52
56
  comment_created_at: null,
53
57
  status: "empty_brief",
@@ -56,8 +60,9 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
56
60
 
57
61
  it("returns the comment content + ISO created_at + status ready for a resolved task+comment", () => {
58
62
  const ts = new Date("2026-08-01T03:24:00.000Z");
59
- expect(shapeBriefResponse({ id: "task-7" }, { content: "Do the thing", created_at: ts })).toEqual({
63
+ expect(shapeBriefResponse({ id: "task-7" }, { id: "c-7", content: "Do the thing", created_at: ts })).toEqual({
60
64
  work_item_id: "task-7",
65
+ comment_id: "c-7",
61
66
  brief: "Do the thing",
62
67
  comment_created_at: "2026-08-01T03:24:00.000Z",
63
68
  status: "ready",
@@ -66,19 +71,18 @@ describe("shapeBriefResponse (no verdict from no data)", () => {
66
71
 
67
72
  it("serializes the comment created_at deterministically (Date → ISO)", () => {
68
73
  const ts = new Date(0);
69
- const out = shapeBriefResponse({ id: "t" }, { content: "c", created_at: ts });
74
+ const out = shapeBriefResponse({ id: "t" }, { id: "c", content: "c", created_at: ts });
70
75
  expect(out.comment_created_at).toBe("1970-01-01T00:00:00.000Z");
71
76
  });
72
77
  });
73
78
 
74
- describe("readAssignedBrief reads the LATEST comment (source contract)", () => {
79
+ describe("readAssignedBrief reads the FIRST launch comment (source contract)", () => {
75
80
  const briefSrc = Bun.file(new URL("./task-sync-brief.ts", import.meta.url).pathname);
76
81
 
77
- it("queries comments newest-first by created_at on work_item_id", async () => {
78
- // The comment IS the brief for a run; ordering by created_at desc makes the
79
- // 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.
80
84
  const text = await briefSrc.text();
81
- expect(text).toContain('orderBy: { created_at: "desc" }');
85
+ expect(text).toContain('orderBy: [{ created_at: "asc" }, { id: "asc" }]');
82
86
  expect(text).toContain("work_item_id: workItemId");
83
87
  });
84
88
 
@@ -106,12 +110,12 @@ describe("readAssignedBrief reads the LATEST comment (source contract)", () => {
106
110
  });
107
111
  });
108
112
 
109
- describe("readLatestTaskComment is a single latest-comment read", () => {
113
+ describe("readFirstTaskComment is a single deterministic launch-comment read", () => {
110
114
  it("returns null when the prisma client has no comments model (feature-detect)", async () => {
111
115
  // `comments` is a universal shared model, but the access is feature-detected
112
116
  // like the fleet_agent_runs reads so a client without it degrades to "no
113
117
  // brief" rather than throwing.
114
- const out = await readLatestTaskComment({} as never, "task-1");
118
+ const out = await readFirstTaskComment({} as never, "task-1");
115
119
  expect(out).toBe(null);
116
120
  });
117
121
  });
@@ -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
  *
@@ -61,25 +61,27 @@ export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound"
61
61
  */
62
62
  export function shapeBriefResponse(
63
63
  task: { id: string } | null,
64
- comment: Pick<CommentRow, "content" | "created_at"> | null,
64
+ comment: Pick<CommentRow, "id" | "content" | "created_at"> | null,
65
65
  ): {
66
66
  work_item_id: string | null;
67
+ comment_id: string | null;
67
68
  brief: string | null;
68
69
  comment_created_at: string | null;
69
70
  status: BriefReadStatus;
70
71
  } {
71
72
  if (!task) {
72
- return { work_item_id: null, brief: null, comment_created_at: null, status: "unbound" };
73
+ return { work_item_id: null, comment_id: null, brief: null, comment_created_at: null, status: "unbound" };
73
74
  }
74
75
  if (!comment) {
75
- return { work_item_id: task.id, brief: null, comment_created_at: null, status: "no_comment" };
76
+ return { work_item_id: task.id, comment_id: null, brief: null, comment_created_at: null, status: "no_comment" };
76
77
  }
77
78
  const text = typeof comment.content === "string" ? comment.content : String(comment.content);
78
79
  if (text.trim() === "") {
79
- return { work_item_id: task.id, brief: null, comment_created_at: null, status: "empty_brief" };
80
+ return { work_item_id: task.id, comment_id: comment.id, brief: null, comment_created_at: null, status: "empty_brief" };
80
81
  }
81
82
  return {
82
83
  work_item_id: task.id,
84
+ comment_id: comment.id,
83
85
  brief: text,
84
86
  comment_created_at:
85
87
  comment.created_at instanceof Date ? comment.created_at.toISOString() : String(comment.created_at),
@@ -88,13 +90,12 @@ export function shapeBriefResponse(
88
90
  }
89
91
 
90
92
  /**
91
- * Read the LATEST comment on a work_items task. The comment IS the brief for a
92
- * run: every spawn writes a NEW comment, and the description is never the brief,
93
- * so "what was this lane told" always has exactly one answer
94
- * (`assignment-as-dispatch`). Newest-first by created_at. Returns null when the
95
- * 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.
96
97
  */
97
- export async function readLatestTaskComment(
98
+ export async function readFirstTaskComment(
98
99
  prisma: Prisma,
99
100
  workItemId: string,
100
101
  ): Promise<CommentRow | null> {
@@ -108,7 +109,7 @@ export async function readLatestTaskComment(
108
109
  if (!comments?.findFirst) return null;
109
110
  return comments.findFirst({
110
111
  where: { work_item_id: workItemId },
111
- orderBy: { created_at: "desc" },
112
+ orderBy: [{ created_at: "asc" }, { id: "asc" }],
112
113
  });
113
114
  }
114
115
 
@@ -145,6 +146,6 @@ export const readAssignedBriefHandler = authed.handler(async ({ input, context }
145
146
  task = (await resolveLaneTask(prisma, session)) as (WorkItem & { id: string }) | null;
146
147
  }
147
148
  if (!task) return shapeBriefResponse(null, null);
148
- const comment = await readLatestTaskComment(prisma, task.id);
149
+ const comment = await readFirstTaskComment(prisma, task.id);
149
150
  return shapeBriefResponse(task, comment);
150
151
  });
@@ -324,11 +324,13 @@ export declare const taskSyncRouter: {
324
324
  orgId: string | null | undefined;
325
325
  }>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
326
326
  work_item_id: string | null;
327
+ comment_id: string | null;
327
328
  brief: string | null;
328
329
  comment_created_at: string | null;
329
330
  status: import("./task-sync-brief.ts").BriefReadStatus;
330
331
  }, {
331
332
  work_item_id: string | null;
333
+ comment_id: string | null;
332
334
  brief: string | null;
333
335
  comment_created_at: string | null;
334
336
  status: import("./task-sync-brief.ts").BriefReadStatus;
@@ -342,8 +344,10 @@ export declare const taskSyncRouter: {
342
344
  orgId: string | null | undefined;
343
345
  }>, import("@orpc/contract").Schema<unknown, unknown>, import("@orpc/contract").Schema<{
344
346
  work_item_id: string;
347
+ comment_id: string;
345
348
  }, {
346
349
  work_item_id: string;
350
+ comment_id: string;
347
351
  }>, Record<never, never>, Record<never, never>>;
348
352
  /** RUNTIME→DB lifecycle close: mark lane.live=false and reconcile task status before the ACP owner releases the session. */
349
353
  closeLane: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {