@teamscala/service-boot 0.23.318 → 0.23.320

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,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.318",
9
+ "version": "0.23.320",
10
10
  "sideEffects": false,
11
11
  "files": [
12
12
  "src"
@@ -56,21 +56,8 @@
56
56
  import * as v from "valibot";
57
57
  import type { Prisma, WorkItem } from "./task-sync-router.ts";
58
58
  import { type Context } from "./auth-router.ts";
59
- /**
60
- * The assignment input. `name` + `description` are REQUIRED TOGETHER at the
61
- * signature: oRPC parses this schema before the handler runs, so a call missing
62
- * either is rejected with a 400 — an incomplete task is unconstructible, not
63
- * caught after the fact. `description` becomes the task's first comment (the
64
- * brief); it is never stored separately on the task, so "the brief" has exactly
65
- * one home (`task-sync-brief.ts` readLatestTaskComment, newest-first).
66
- *
67
- * `name` → work_items.title; `description` → the first comment. `parent_id` is
68
- * the project the dispatched task is scoped under (omit for an exploratory
69
- * lane). `slug`/`windowName` shape the spawn; `organisation_id` defaults to the
70
- * platform org (fleet/internal lane tasks live there).
71
- */
72
59
  export declare const assignTaskInput: v.ObjectSchema<{
73
- readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "name is required — an incomplete task is unconstructible">]>;
60
+ readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "name is required — the lane's real work label">, v.CheckAction<string, "name must carry the lane's real work name, not the generic sentinel 'Lane'">]>;
74
61
  readonly description: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "description is required — it is the brief (first comment)">]>;
75
62
  readonly parent_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
76
63
  readonly organisation_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
@@ -176,6 +163,41 @@ export declare function adoptAssignmentTask(prisma: Prisma, maybeTaskId: string
176
163
  cli_kind?: string;
177
164
  role?: string;
178
165
  }): Promise<WorkItem | null>;
166
+ /**
167
+ * The bind decision AFTER attempting to adopt the spawn's referenced task. PURE
168
+ * over the adopt result + whether the referenced row exists, so the
169
+ * "a spawn that cannot bind fails loudly rather than orphaning" invariant
170
+ * (`absent-input-reads-as-success`) is unit-testable without standing up the
171
+ * ORPC handler. `bindLane` calls `adoptAssignmentTask` then this, then acts.
172
+ *
173
+ * `use` — adopt succeeded; bind the lane to that task.
174
+ * `create` — no task to adopt: an exploratory lane (no workItemId) or the
175
+ * referenced id is a PROJECT the task is created under.
176
+ * `refuse` — the spawn named a workItemId the lane should adopt, but NO task
177
+ * exists with that id. Silently creating a NEW task here would
178
+ * abandon the spawn's referenced brief and bind the lane to an
179
+ * empty task it was never meant to own — exactly the orphan
180
+ * `absent-input-reads-as-success` forbids (a caller that stated
181
+ * adopt-intent reads "success" while the referenced row rots).
182
+ * Failing loudly surfaces the malformed spawn instead.
183
+ */
184
+ export type BindAction = {
185
+ action: "use";
186
+ task: WorkItem;
187
+ } | {
188
+ action: "create";
189
+ scope: "exploratory" | "project-parent";
190
+ } | {
191
+ action: "refuse";
192
+ reason: "missing-referenced-task";
193
+ workItemId: string;
194
+ };
195
+ export declare function resolveBindAction(args: {
196
+ adopted: WorkItem | null;
197
+ workItemId: string | null;
198
+ /** The kind of the row at workItemId, or null when no such row exists. */
199
+ referencedKind: string | null;
200
+ }): BindAction;
179
201
  /**
180
202
  * Bind a task to a KNOWN session — the RESUME path. Unlike a fresh spawn (the
181
203
  * CLI mints the session at boot, so SessionStart must adopt), a resumed/switched
@@ -248,7 +270,7 @@ export interface AssignmentResult {
248
270
  * idempotencyKey), which belong on the spawn emission, not the mint.
249
271
  */
250
272
  export declare const createBriefTaskInput: v.ObjectSchema<{
251
- readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "name is required — the lane's work label">]>;
273
+ readonly name: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "name is required — the lane's real work label">, v.CheckAction<string, "name must carry the lane's real work name, not the generic sentinel 'Lane'">]>;
252
274
  readonly description: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "description is required — it is the brief (first comment)">]>;
253
275
  readonly parent_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
254
276
  readonly organisation_id: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
@@ -20,7 +20,9 @@ import {
20
20
  bindTaskToKnownSession,
21
21
  buildAssignmentSpawnPayload,
22
22
  resolveAssignOrg,
23
+ resolveBindAction,
23
24
  } from "./task-sync-assign.ts";
25
+ import type { WorkItem } from "./task-sync-router.ts";
24
26
 
25
27
  /** A minimal fake prisma exposing only $transaction + the two creates the fn uses. */
26
28
  function fakePrisma(overrides: { platformOrg?: string } = {}) {
@@ -90,6 +92,10 @@ describe("assignTaskInput (incomplete task is unconstructible)", () => {
90
92
  });
91
93
  expect(out.success).toBe(true);
92
94
  });
95
+
96
+ it("REJECTS the generic 'Lane' title (assignment mint — same gate as createBriefTask)", () => {
97
+ expect(v.safeParse(assignTaskInput, { name: "Lane", description: "d" }).success).toBe(false);
98
+ });
93
99
  });
94
100
 
95
101
  describe("createBriefTaskInput (the spawner's mint entry — incomplete task is unconstructible)", () => {
@@ -121,6 +127,31 @@ describe("createBriefTaskInput (the spawner's mint entry — incomplete task is
121
127
  const out = v.safeParse(createBriefTaskInput, { name: "Explore", description: "d" });
122
128
  expect(out.success).toBe(true);
123
129
  });
130
+
131
+ // "Lane" is the generic sentinel a spawn that forgot to name its lane falls
132
+ // back to. A row titled "Lane" is spawn debris an orchestrator cannot
133
+ // attribute — rejected at the mint chokepoint so the malformed spawn is
134
+ // refused, never written (lanes-named-by-the-spawner).
135
+ it("REJECTS the generic 'Lane' title — spawn debris is unconstructible", () => {
136
+ expect(
137
+ v.safeParse(createBriefTaskInput, { name: "Lane", description: "d" }).success,
138
+ ).toBe(false);
139
+ });
140
+
141
+ it("rejects 'Lane' case- and whitespace-insensitively", () => {
142
+ expect(
143
+ v.safeParse(createBriefTaskInput, { name: " lane ", description: "d" }).success,
144
+ ).toBe(false);
145
+ expect(
146
+ v.safeParse(createBriefTaskInput, { name: "LANE", description: "d" }).success,
147
+ ).toBe(false);
148
+ });
149
+
150
+ it("accepts a real work name (e.g. the lane's slot_name)", () => {
151
+ expect(
152
+ v.safeParse(createBriefTaskInput, { name: "Bus handlers", description: "d" }).success,
153
+ ).toBe(true);
154
+ });
124
155
  });
125
156
 
126
157
  describe("createTaskWithBriefComment (atomic task + first comment = brief)", () => {
@@ -381,3 +412,71 @@ describe("buildAssignmentSpawnPayload (no keystroke brief — row-based self-bri
381
412
  expect(out.payload.idempotencyKey).toBe("k-1");
382
413
  });
383
414
  });
415
+
416
+ describe("resolveBindAction (a spawn that cannot bind fails loudly, never orphans)", () => {
417
+ // The `absent-input-reads-as-success` failure this closes: a spawn carried a
418
+ // workItemId the lane should adopt, but no task with that id exists. The old
419
+ // path silently created a NEW task, abandoning the spawn's referenced brief
420
+ // and binding the lane to an empty task it was never meant to own — an orphan
421
+ // an orchestrator cannot tell from real unowned work. bindLane now REFUSES on
422
+ // this outcome; this is the pure, unit-testable decision behind that refusal.
423
+ function taskRow(id: string): WorkItem {
424
+ return {
425
+ id,
426
+ kind: "task",
427
+ parent_id: null,
428
+ organisation_id: "org",
429
+ title: "T",
430
+ status: "active",
431
+ position: null,
432
+ completed: null,
433
+ kind_data: {},
434
+ };
435
+ }
436
+
437
+ it("uses the adopted task when adopt succeeds", () => {
438
+ const t = taskRow("t1");
439
+ const out = resolveBindAction({ adopted: t, workItemId: "t1", referencedKind: "task" });
440
+ expect(out.action).toBe("use");
441
+ if (out.action === "use") expect(out.task.id).toBe("t1");
442
+ });
443
+
444
+ it("creates for an exploratory lane (no workItemId supplied)", () => {
445
+ const out = resolveBindAction({ adopted: null, workItemId: null, referencedKind: null });
446
+ expect(out.action).toBe("create");
447
+ if (out.action === "create") expect(out.scope).toBe("exploratory");
448
+ });
449
+
450
+ it("creates under a PROJECT parent (workItemId resolves to a project, not a task)", () => {
451
+ const out = resolveBindAction({
452
+ adopted: null,
453
+ workItemId: "proj-1",
454
+ referencedKind: "project",
455
+ });
456
+ expect(out.action).toBe("create");
457
+ if (out.action === "create") expect(out.scope).toBe("project-parent");
458
+ });
459
+
460
+ it("REFUSES when the spawn named a task that does not exist — no orphan created", () => {
461
+ // THE owed test: a spawn carrying a workItemId that resolves to NO task
462
+ // must fail (refuse) instead of silently creating a new orphan task.
463
+ const out = resolveBindAction({
464
+ adopted: null,
465
+ workItemId: "ghost-9",
466
+ referencedKind: null,
467
+ });
468
+ expect(out.action).toBe("refuse");
469
+ if (out.action === "refuse") {
470
+ expect(out.reason).toBe("missing-referenced-task");
471
+ expect(out.workItemId).toBe("ghost-9");
472
+ }
473
+ });
474
+
475
+ it("prefers a successful adopt even when a (stale) referenced kind is present", () => {
476
+ // adopt already writes the session + assignee_id; once it succeeds the
477
+ // referenced-kind probe is irrelevant.
478
+ const t = taskRow("t1");
479
+ const out = resolveBindAction({ adopted: t, workItemId: "t1", referencedKind: null });
480
+ expect(out.action).toBe("use");
481
+ });
482
+ });
@@ -73,11 +73,25 @@ import { getAppLogger } from "@teamscala/logger/app-loggers";
73
73
  * lane). `slug`/`windowName` shape the spawn; `organisation_id` defaults to the
74
74
  * platform org (fleet/internal lane tasks live there).
75
75
  */
76
- export const assignTaskInput = v.object({
77
- name: v.pipe(
78
- v.string(),
79
- v.minLength(1, "name is required — an incomplete task is unconstructible"),
76
+ /**
77
+ * A lane task's NAME — the lane's real work label, never the generic "Lane"
78
+ * sentinel. A row titled "Lane" is spawn debris: it tells the operator nothing
79
+ * about the work, so an orchestrator cannot tell real unowned work from it (the
80
+ * signal the fleet steers by). Rejected at the ORPC input chokepoint so a spawn
81
+ * that forgot to name its lane is REFUSED at the mint, never written
82
+ * (lanes-named-by-the-spawner — the spawner sets the work name at creation).
83
+ */
84
+ const laneWorkName = v.pipe(
85
+ v.string(),
86
+ v.minLength(1, "name is required — the lane's real work label"),
87
+ v.check(
88
+ (n) => n.trim().toLowerCase() !== "lane",
89
+ "name must carry the lane's real work name, not the generic sentinel 'Lane'",
80
90
  ),
91
+ );
92
+
93
+ export const assignTaskInput = v.object({
94
+ name: laneWorkName,
81
95
  description: v.pipe(
82
96
  v.string(),
83
97
  v.minLength(1, "description is required — it is the brief (first comment)"),
@@ -293,6 +307,45 @@ export async function adoptAssignmentTask(
293
307
  }
294
308
  }
295
309
 
310
+ /**
311
+ * The bind decision AFTER attempting to adopt the spawn's referenced task. PURE
312
+ * over the adopt result + whether the referenced row exists, so the
313
+ * "a spawn that cannot bind fails loudly rather than orphaning" invariant
314
+ * (`absent-input-reads-as-success`) is unit-testable without standing up the
315
+ * ORPC handler. `bindLane` calls `adoptAssignmentTask` then this, then acts.
316
+ *
317
+ * `use` — adopt succeeded; bind the lane to that task.
318
+ * `create` — no task to adopt: an exploratory lane (no workItemId) or the
319
+ * referenced id is a PROJECT the task is created under.
320
+ * `refuse` — the spawn named a workItemId the lane should adopt, but NO task
321
+ * exists with that id. Silently creating a NEW task here would
322
+ * abandon the spawn's referenced brief and bind the lane to an
323
+ * empty task it was never meant to own — exactly the orphan
324
+ * `absent-input-reads-as-success` forbids (a caller that stated
325
+ * adopt-intent reads "success" while the referenced row rots).
326
+ * Failing loudly surfaces the malformed spawn instead.
327
+ */
328
+ export type BindAction =
329
+ | { action: "use"; task: WorkItem }
330
+ | { action: "create"; scope: "exploratory" | "project-parent" }
331
+ | { action: "refuse"; reason: "missing-referenced-task"; workItemId: string };
332
+
333
+ export function resolveBindAction(args: {
334
+ adopted: WorkItem | null;
335
+ workItemId: string | null;
336
+ /** The kind of the row at workItemId, or null when no such row exists. */
337
+ referencedKind: string | null;
338
+ }): BindAction {
339
+ if (args.adopted) return { action: "use", task: args.adopted };
340
+ if (!args.workItemId) return { action: "create", scope: "exploratory" };
341
+ // A workItemId was provided but adopt returned null: either it resolves to a
342
+ // non-task (a PROJECT — create a task under it) or to nothing (refuse).
343
+ if (args.referencedKind === null) {
344
+ return { action: "refuse", reason: "missing-referenced-task", workItemId: args.workItemId };
345
+ }
346
+ return { action: "create", scope: "project-parent" };
347
+ }
348
+
296
349
  /**
297
350
  * Bind a task to a KNOWN session — the RESUME path. Unlike a fresh spawn (the
298
351
  * CLI mints the session at boot, so SessionStart must adopt), a resumed/switched
@@ -376,7 +429,7 @@ export interface AssignmentResult {
376
429
  * idempotencyKey), which belong on the spawn emission, not the mint.
377
430
  */
378
431
  export const createBriefTaskInput = v.object({
379
- name: v.pipe(v.string(), v.minLength(1, "name is required — the lane's work label")),
432
+ name: laneWorkName,
380
433
  description: v.pipe(
381
434
  v.string(),
382
435
  v.minLength(1, "description is required — it is the brief (first comment)"),
@@ -23,12 +23,36 @@
23
23
  */
24
24
  import type { Prisma, CommentRow } from "./task-sync-router.ts";
25
25
  import { type Context } from "./auth-router.ts";
26
+ /**
27
+ * The outcome of a brief read, single-sourced here so every CLI's self-brief
28
+ * hook (zerostack/codex shell, opencode/pi TS) branches on ONE field rather
29
+ * than each re-deriving "is the brief missing" from null/empty probes — the
30
+ * seam at which absent-input-reads-as-success hid (`the-severe-bugs-live-at-seams`).
31
+ *
32
+ * The hook decides ACTION from this; the classification lives here. A fleet
33
+ * lane (SCALA_LANE_WORK_ITEM set) treats anything but "ready" as a hard halt.
34
+ */
35
+ export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound";
26
36
  /**
27
37
  * Shape the readAssignedBrief response from a resolved task + its latest
28
38
  * comment. Pure (no prisma) so the decision — "no task/comment ⇒ nulls, never a
29
- * verdict from no data" (`no-verdict-from-no-data`) — is unit-testable. A null
30
- * task or null comment returns all-null rather than an empty/fake brief, so the
31
- * extension can distinguish "nothing to inject" from "inject this".
39
+ * verdict from no data" (`no-verdict-from-no-data`) — is unit-testable.
40
+ *
41
+ * `work_item_id` is set iff the TASK resolved (independent of whether a comment
42
+ * exists), and `status` distinguishes the four states a hook must act on:
43
+ * - "ready" — task + a non-empty comment: inject the brief.
44
+ * - "empty_brief" — task resolved, comment exists but content is blank: the
45
+ * spawn minted a task with no usable brief — broken.
46
+ * - "no_comment" — task resolved, no comment at all: same broken class.
47
+ * - "unbound" — no task resolved: the lane has no brief task (a non-fleet
48
+ * operator tab, or — for the session-only path — a bind
49
+ * that has not yet commit; the work_item_id path cannot
50
+ * reach this for a fleet lane).
51
+ *
52
+ * "empty_brief" / "no_comment" with the task resolved is the state the
53
+ * absent-input fix exists to catch: a fleet lane whose spawn succeeded MUST
54
+ * have a non-empty brief comment, so its absence is a defect the hook halts on
55
+ * rather than reading as success.
32
56
  */
33
57
  export declare function shapeBriefResponse(task: {
34
58
  id: string;
@@ -36,6 +60,7 @@ export declare function shapeBriefResponse(task: {
36
60
  work_item_id: string | null;
37
61
  brief: string | null;
38
62
  comment_created_at: string | null;
63
+ status: BriefReadStatus;
39
64
  };
40
65
  /**
41
66
  * Read the LATEST comment on a work_items task. The comment IS the brief for a
@@ -62,8 +87,10 @@ export declare const readAssignedBriefHandler: import("@orpc/server").DecoratedP
62
87
  work_item_id: string | null;
63
88
  brief: string | null;
64
89
  comment_created_at: string | null;
90
+ status: BriefReadStatus;
65
91
  }, {
66
92
  work_item_id: string | null;
67
93
  brief: string | null;
68
94
  comment_created_at: string | null;
95
+ status: BriefReadStatus;
69
96
  }>, Record<never, never>, Record<never, never>>;
@@ -12,31 +12,55 @@ import { describe, it, expect } from "bun:test";
12
12
  import { shapeBriefResponse, readLatestTaskComment } from "./task-sync-brief.ts";
13
13
 
14
14
  describe("shapeBriefResponse (no verdict from no data)", () => {
15
- it("returns all-null when there is no task", () => {
16
- // A lane with no bound task has no brief — null, never an empty fake.
15
+ it("classifies a missing task as unbound (work_item_id null)", () => {
16
+ // A lane with no resolvable task has no brief — null, never an empty fake.
17
+ // status:"unbound" is the signal a non-fleet lane no-ops on and a fleet
18
+ // lane (which resolves by work_item_id, so cannot reach this) never sees.
17
19
  expect(shapeBriefResponse(null, { content: "x", created_at: new Date() })).toEqual({
18
20
  work_item_id: null,
19
21
  brief: null,
20
22
  comment_created_at: null,
23
+ status: "unbound",
21
24
  });
22
25
  });
23
26
 
24
- it("returns all-null when the task has no comment yet", () => {
25
- // The task exists but no spawn comment landed; the extension must inject
26
- // nothing rather than a fabricated brief.
27
+ it("classifies a task with NO comment as no_comment (work_item_id SET)", () => {
28
+ // The task resolved but no spawn comment landed. work_item_id is now SET
29
+ // (the task IS bound) — distinct from unbound — and status:"no_comment"
30
+ // is the broken state a fleet lane halts on rather than reading as success.
27
31
  expect(shapeBriefResponse({ id: "task-1" }, null)).toEqual({
28
- work_item_id: null,
32
+ work_item_id: "task-1",
33
+ brief: null,
34
+ comment_created_at: null,
35
+ status: "no_comment",
36
+ });
37
+ });
38
+
39
+ it("classifies a task with a BLANK comment as empty_brief (the unreadable case)", () => {
40
+ // The comment exists but its content is empty/whitespace — "loads it
41
+ // empty". A fleet lane whose spawn minted a task MUST have a non-empty
42
+ // 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({
44
+ work_item_id: "task-2",
29
45
  brief: null,
30
46
  comment_created_at: null,
47
+ status: "empty_brief",
48
+ });
49
+ expect(shapeBriefResponse({ id: "task-2b" }, { content: "", created_at: new Date() })).toEqual({
50
+ work_item_id: "task-2b",
51
+ brief: null,
52
+ comment_created_at: null,
53
+ status: "empty_brief",
31
54
  });
32
55
  });
33
56
 
34
- it("returns the comment content + ISO created_at for a resolved task+comment", () => {
57
+ it("returns the comment content + ISO created_at + status ready for a resolved task+comment", () => {
35
58
  const ts = new Date("2026-08-01T03:24:00.000Z");
36
59
  expect(shapeBriefResponse({ id: "task-7" }, { content: "Do the thing", created_at: ts })).toEqual({
37
60
  work_item_id: "task-7",
38
61
  brief: "Do the thing",
39
62
  comment_created_at: "2026-08-01T03:24:00.000Z",
63
+ status: "ready",
40
64
  });
41
65
  });
42
66
 
@@ -64,6 +88,22 @@ describe("readAssignedBrief reads the LATEST comment (source contract)", () => {
64
88
  const text = await briefSrc.text();
65
89
  expect(text).toContain("resolveLaneTask(prisma, session)");
66
90
  });
91
+
92
+ it("resolves by the authoritative work_item_id BEFORE the session (deletes the bind race)", async () => {
93
+ // The spawn's known task id (SCALA_LANE_WORK_ITEM) cannot race — the row
94
+ // exists because the spawn created it — so resolving by it first makes an
95
+ // empty brief for a fleet lane unambiguously broken rather than a
96
+ // transient "retry" the lane can never exit (absent-input-reads-as-success).
97
+ const text = await briefSrc.text();
98
+ expect(text).toContain("workItemId");
99
+ expect(text).toContain("resolveTaskById(prisma, workItemId)");
100
+ // The session bind is no longer load-bearing for brief delivery: it stays
101
+ // only as the fallback for callers without a work_item_id.
102
+ const idIdx = text.indexOf("resolveTaskById(prisma, workItemId)");
103
+ const sessionIdx = text.indexOf("resolveLaneTask(prisma, session)");
104
+ expect(idIdx).toBeGreaterThan(-1);
105
+ expect(sessionIdx).toBeGreaterThan(idIdx);
106
+ });
67
107
  });
68
108
 
69
109
  describe("readLatestTaskComment is a single latest-comment read", () => {
@@ -25,27 +25,65 @@
25
25
  import type { Prisma, CommentRow } from "./task-sync-router.ts";
26
26
  import type { WorkItem } from "./task-sync-router.ts";
27
27
  import { authed, type Context } from "./auth-router.ts";
28
- import { resolveLaneTask } from "./task-sync-lane.ts";
28
+ import { resolveLaneTask, resolveTaskById } from "./task-sync-lane.ts";
29
+
30
+ /**
31
+ * The outcome of a brief read, single-sourced here so every CLI's self-brief
32
+ * hook (zerostack/codex shell, opencode/pi TS) branches on ONE field rather
33
+ * than each re-deriving "is the brief missing" from null/empty probes — the
34
+ * seam at which absent-input-reads-as-success hid (`the-severe-bugs-live-at-seams`).
35
+ *
36
+ * The hook decides ACTION from this; the classification lives here. A fleet
37
+ * lane (SCALA_LANE_WORK_ITEM set) treats anything but "ready" as a hard halt.
38
+ */
39
+ export type BriefReadStatus = "ready" | "empty_brief" | "no_comment" | "unbound";
29
40
 
30
41
  /**
31
42
  * Shape the readAssignedBrief response from a resolved task + its latest
32
43
  * comment. Pure (no prisma) so the decision — "no task/comment ⇒ nulls, never a
33
- * verdict from no data" (`no-verdict-from-no-data`) — is unit-testable. A null
34
- * task or null comment returns all-null rather than an empty/fake brief, so the
35
- * extension can distinguish "nothing to inject" from "inject this".
44
+ * verdict from no data" (`no-verdict-from-no-data`) — is unit-testable.
45
+ *
46
+ * `work_item_id` is set iff the TASK resolved (independent of whether a comment
47
+ * exists), and `status` distinguishes the four states a hook must act on:
48
+ * - "ready" — task + a non-empty comment: inject the brief.
49
+ * - "empty_brief" — task resolved, comment exists but content is blank: the
50
+ * spawn minted a task with no usable brief — broken.
51
+ * - "no_comment" — task resolved, no comment at all: same broken class.
52
+ * - "unbound" — no task resolved: the lane has no brief task (a non-fleet
53
+ * operator tab, or — for the session-only path — a bind
54
+ * that has not yet commit; the work_item_id path cannot
55
+ * reach this for a fleet lane).
56
+ *
57
+ * "empty_brief" / "no_comment" with the task resolved is the state the
58
+ * absent-input fix exists to catch: a fleet lane whose spawn succeeded MUST
59
+ * have a non-empty brief comment, so its absence is a defect the hook halts on
60
+ * rather than reading as success.
36
61
  */
37
62
  export function shapeBriefResponse(
38
63
  task: { id: string } | null,
39
64
  comment: Pick<CommentRow, "content" | "created_at"> | null,
40
- ): { work_item_id: string | null; brief: string | null; comment_created_at: string | null } {
41
- if (!task || !comment) {
42
- return { work_item_id: null, brief: null, comment_created_at: null };
65
+ ): {
66
+ work_item_id: string | null;
67
+ brief: string | null;
68
+ comment_created_at: string | null;
69
+ status: BriefReadStatus;
70
+ } {
71
+ if (!task) {
72
+ return { work_item_id: null, brief: null, comment_created_at: null, status: "unbound" };
73
+ }
74
+ if (!comment) {
75
+ return { work_item_id: task.id, brief: null, comment_created_at: null, status: "no_comment" };
76
+ }
77
+ const text = typeof comment.content === "string" ? comment.content : String(comment.content);
78
+ if (text.trim() === "") {
79
+ return { work_item_id: task.id, brief: null, comment_created_at: null, status: "empty_brief" };
43
80
  }
44
81
  return {
45
82
  work_item_id: task.id,
46
- brief: comment.content,
83
+ brief: text,
47
84
  comment_created_at:
48
85
  comment.created_at instanceof Date ? comment.created_at.toISOString() : String(comment.created_at),
86
+ status: "ready",
49
87
  };
50
88
  }
51
89
 
@@ -84,13 +122,28 @@ export async function readLatestTaskComment(
84
122
  export const readAssignedBriefHandler = authed.handler(async ({ input, context }) => {
85
123
  const ctx = context as Context;
86
124
  const prisma = ctx.prisma as Prisma;
87
- const body = (input ?? {}) as { lane_session?: string };
125
+ const body = (input ?? {}) as { lane_session?: string; work_item_id?: string };
88
126
  const session = body.lane_session ?? ctx.headers?.get("x-lane-session") ?? null;
89
- if (!session) {
127
+ // The spawn's known task id (SCALA_LANE_WORK_ITEM) — AUTHORITATIVE for brief
128
+ // resolution. Resolving by it first deletes the bindLane race that made an
129
+ // empty brief read as a transient "retry" a fleet lane could never exit
130
+ // (absent-input-reads-as-success). Falls back to session for callers without
131
+ // it (operator tabs / pre-id lanes).
132
+ const workItemId =
133
+ body.work_item_id?.trim() || ctx.headers?.get("x-lane-work-item")?.trim() || null;
134
+ if (!session && !workItemId) {
90
135
  const { ORPCError } = await import("@orpc/server");
91
- throw new ORPCError("BAD_REQUEST", { message: "readAssignedBrief requires lane_session" });
136
+ throw new ORPCError("BAD_REQUEST", {
137
+ message: "readAssignedBrief requires lane_session or work_item_id",
138
+ });
139
+ }
140
+ let task: (WorkItem & { id: string }) | null = null;
141
+ if (workItemId) {
142
+ task = (await resolveTaskById(prisma, workItemId)) as (WorkItem & { id: string }) | null;
143
+ }
144
+ if (!task && session) {
145
+ task = (await resolveLaneTask(prisma, session)) as (WorkItem & { id: string }) | null;
92
146
  }
93
- const task = (await resolveLaneTask(prisma, session)) as (WorkItem & { id: string }) | null;
94
147
  if (!task) return shapeBriefResponse(null, null);
95
148
  const comment = await readLatestTaskComment(prisma, task.id);
96
149
  return shapeBriefResponse(task, comment);
@@ -26,3 +26,24 @@
26
26
  import type { Prisma, WorkItem } from "./task-sync-router.ts";
27
27
  export declare function resolveLaneWorkName(prisma: Prisma, pane: string): Promise<string | null>;
28
28
  export declare function resolveLaneTask(prisma: Prisma, session: string): Promise<WorkItem | null>;
29
+ /**
30
+ * Resolve a lane's task by its AUTHORITATIVE work_item id — the id the spawn
31
+ * minted (createBriefTask) and exported into the pane as SCALA_LANE_WORK_ITEM.
32
+ *
33
+ * WHY this exists alongside the session resolution: readAssignedBrief used to
34
+ * resolve ONLY by kind_data.lane.session, which is written by the lane's OWN
35
+ * bindLane hook at boot. That bind is a fire-and-forget POST racing the brief
36
+ * read, so a fleet lane whose bind had not yet commit read "no task" and the
37
+ * self-brief hook silently exited 0 — absent-input-reads-as-success at the
38
+ * dispatch boundary (a lane booted, was told its brief was loaded, had none,
39
+ * ran nothing, and was archived having done nothing — measured 2026-08-02).
40
+ *
41
+ * The work_item id is known to the spawn before the lane even starts
42
+ * (mintBriefTask returns it; the spawn REFUSES if minting fails for a capture
43
+ * CLI), so resolving by it CANNOT race: the row exists because the spawn
44
+ * created it. The session bind stays for task-list writes (upsertFromTaskList)
45
+ * — it is no longer load-bearing for brief delivery. Any status is matched
46
+ * (active OR archived) because the brief is the brief regardless of the run's
47
+ * state — closing a run archives the task, not the brief.
48
+ */
49
+ export declare function resolveTaskById(prisma: Prisma, workItemId: string): Promise<WorkItem | null>;
@@ -36,8 +36,16 @@ export async function resolveLaneWorkName(prisma: Prisma, pane: string): Promise
36
36
  }
37
37
  ).fleet_agent_runs;
38
38
  if (!runs?.findFirst) return null;
39
+ // Most recent run for the pane, REGARDLESS of state. A lane's work name does
40
+ // not vanish when its run closes, and filtering `state != closed` here is
41
+ // exactly why task rows were titled "Lane": a task-list write (ensureLaneTask)
42
+ // that fires after the run closed resolved no name and fell back to the
43
+ // generic sentinel — spawn debris an orchestrator cannot attribute
44
+ // (measured 2026-08-01: 207 "Lane" rows). Newest-first so a reused pane
45
+ // resolves its CURRENT lane, not a prior one.
39
46
  const run = await runs.findFirst({
40
- where: { pane, state: { not: "closed" } },
47
+ where: { pane },
48
+ orderBy: { created_at: "desc" },
41
49
  select: { slot_name: true },
42
50
  });
43
51
  const name = run?.slot_name?.trim();
@@ -89,3 +97,29 @@ export async function resolveLaneTask(prisma: Prisma, session: string): Promise<
89
97
  orderBy: { created_at: "desc" },
90
98
  });
91
99
  }
100
+
101
+ /**
102
+ * Resolve a lane's task by its AUTHORITATIVE work_item id — the id the spawn
103
+ * minted (createBriefTask) and exported into the pane as SCALA_LANE_WORK_ITEM.
104
+ *
105
+ * WHY this exists alongside the session resolution: readAssignedBrief used to
106
+ * resolve ONLY by kind_data.lane.session, which is written by the lane's OWN
107
+ * bindLane hook at boot. That bind is a fire-and-forget POST racing the brief
108
+ * read, so a fleet lane whose bind had not yet commit read "no task" and the
109
+ * self-brief hook silently exited 0 — absent-input-reads-as-success at the
110
+ * dispatch boundary (a lane booted, was told its brief was loaded, had none,
111
+ * ran nothing, and was archived having done nothing — measured 2026-08-02).
112
+ *
113
+ * The work_item id is known to the spawn before the lane even starts
114
+ * (mintBriefTask returns it; the spawn REFUSES if minting fails for a capture
115
+ * CLI), so resolving by it CANNOT race: the row exists because the spawn
116
+ * created it. The session bind stays for task-list writes (upsertFromTaskList)
117
+ * — it is no longer load-bearing for brief delivery. Any status is matched
118
+ * (active OR archived) because the brief is the brief regardless of the run's
119
+ * state — closing a run archives the task, not the brief.
120
+ */
121
+ export async function resolveTaskById(prisma: Prisma, workItemId: string): Promise<WorkItem | null> {
122
+ return prisma.work_items.findFirst({
123
+ where: { id: workItemId, kind: "task" },
124
+ });
125
+ }
@@ -325,10 +325,12 @@ export declare const taskSyncRouter: {
325
325
  work_item_id: string | null;
326
326
  brief: string | null;
327
327
  comment_created_at: string | null;
328
+ status: import("./task-sync-brief.ts").BriefReadStatus;
328
329
  }, {
329
330
  work_item_id: string | null;
330
331
  brief: string | null;
331
332
  comment_created_at: string | null;
333
+ status: import("./task-sync-brief.ts").BriefReadStatus;
332
334
  }>, Record<never, never>, Record<never, never>>;
333
335
  /** SPAWNER→DB brief mint: `spawn_agent_tab` turns an initialPrompt into the task row (brief = first comment) the lane self-briefs from; returns the work_item_id that rides the spawn payload. Handler in task-sync-assign.ts. */
334
336
  createBriefTask: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
@@ -43,7 +43,12 @@ import { authed, type Context } from "./auth-router.ts";
43
43
  import { notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
44
44
  import { resolveLaneTask, resolveLaneWorkName } from "./task-sync-lane.ts";
45
45
  import { readAssignedBriefHandler } from "./task-sync-brief.ts";
46
- import { adoptAssignmentTask, bindLaneInput, createBriefTaskHandler } from "./task-sync-assign.ts";
46
+ import {
47
+ adoptAssignmentTask,
48
+ bindLaneInput,
49
+ createBriefTaskHandler,
50
+ resolveBindAction,
51
+ } from "./task-sync-assign.ts";
47
52
  import * as v from "valibot";
48
53
  import {
49
54
  STATUS_TO_WORK_ITEM,
@@ -269,6 +274,16 @@ async function ensureLaneTask(
269
274
  ): Promise<WorkItem | null> {
270
275
  const existing = await resolveLaneTask(prisma, session);
271
276
  if (existing) return existing;
277
+ // The lane's real work name — its fleet run's slot_name
278
+ // (lanes-named-by-the-spawner). resolveLaneWorkName reads the most recent run
279
+ // for the pane regardless of state, so a lane whose run closed still resolves.
280
+ // A task with NO resolvable name is NOT created: writing the generic "Lane"
281
+ // sentinel is exactly the unmakeable state this backstop must refuse (a row
282
+ // titled "Lane" tells the operator nothing and reads as spawn debris).
283
+ // Return null — reconcileTaskList throws, the TaskCreated hook discards
284
+ // (observably), so the lane never silently produces a "Lane" row.
285
+ const name = pane ? await resolveLaneWorkName(prisma, pane) : null;
286
+ if (!name || name.trim().toLowerCase() === "lane") return null;
272
287
  const lane: LaneIdentity = { pane: pane ?? "", session, cli_kind: "claude-code", role: "agent" };
273
288
  try {
274
289
  return await prisma.work_items.create({
@@ -276,8 +291,11 @@ async function ensureLaneTask(
276
291
  kind: "task",
277
292
  parent_id: null,
278
293
  organisation_id: resolveBindOrg(),
279
- title: (pane ? await resolveLaneWorkName(prisma, pane) : null) ?? "Lane",
294
+ title: name,
280
295
  status: "active",
296
+ // assignee_id on every bind path (see bindLane): without it a bound
297
+ // task reads as unowned to an orchestrator steering by assignee_id.
298
+ assignee_id: session,
281
299
  kind_data: { lane },
282
300
  },
283
301
  });
@@ -478,7 +496,11 @@ export const taskSyncRouter = {
478
496
  // lane is doing. The run's slot_name IS the work name
479
497
  // (lanes-named-by-the-spawner); fall back to a bare "Lane" rather than
480
498
  // leak the pane id when no run resolves a name.
481
- const laneTitle = raw.title ?? (await resolveLaneWorkName(prisma, pane)) ?? "Lane";
499
+ // The lane's work name — NEVER the "Lane" sentinel (spawn debris an
500
+ // orchestrator cannot attribute). null when no run resolves a slot_name;
501
+ // the create path REFUSES rather than write "Lane" (lanes-named-by-the-
502
+ // spawner).
503
+ const laneTitle = raw.title ?? (await resolveLaneWorkName(prisma, pane)) ?? null;
482
504
  let taskId: string;
483
505
  let created: boolean;
484
506
  if (existing) {
@@ -493,6 +515,11 @@ export const taskSyncRouter = {
493
515
  // project cannot orphan a lane that already has one.
494
516
  data: {
495
517
  kind_data: kindData,
518
+ // assignee_id on EVERY bind path, not only adopt: a re-bind that
519
+ // left assignee_id null read as "unowned" to an orchestrator
520
+ // steering by it (measured 2026-08-01: "Dev-mcp bus claim" — a
521
+ // live lane whose task had a session but null assignee_id).
522
+ assignee_id: session,
496
523
  ...(raw.parent_id ? { parent_id: raw.parent_id } : {}),
497
524
  },
498
525
  });
@@ -500,26 +527,57 @@ export const taskSyncRouter = {
500
527
  created = false;
501
528
  log.info("[task-sync] bindLane: re-bound existing task", { taskId, session });
502
529
  } else {
503
- // DISPATCH-BY-ASSIGNMENT adopt. When the spawn carried a workItemId that
504
- // points to an existing kind:"task" (the assignment task whose FIRST
505
- // COMMENT is the brief), ADOPT it — write this lane's freshly-minted
506
- // session onto it + set ownership (assignee_id) — instead of creating a
507
- // new task that orphans the brief and leaves the lane self-briefing to
508
- // nothing. Falls through to create for an exploratory lane (no
509
- // workItemId) or a project parent (kind:"project"), so existing behaviour
510
- // is unchanged on those paths.
511
- const adopted = await adoptAssignmentTask(prisma, raw.parent_id ?? headerWorkItem, {
512
- ...lane,
513
- session,
514
- });
515
- if (adopted) {
516
- taskId = adopted.id;
530
+ // DISPATCH-BY-ASSIGNMENT adopt, then a write-back that NEVER orphans.
531
+ // The spawn may have carried a workItemId the lane should adopt; adopt
532
+ // attempts it, then resolveBindAction decides from the outcome:
533
+ // use — adopt succeeded.
534
+ // create — exploratory lane (no workItemId) or the id is a PROJECT
535
+ // the task is created under.
536
+ // refuse — the spawn named a task that does NOT exist. This is the
537
+ // `absent-input-reads-as-success` failure: silently creating
538
+ // a new task here would abandon the spawn's referenced brief
539
+ // and bind the lane to an empty task it was never meant to
540
+ // own, leaving an orphan an orchestrator cannot tell from
541
+ // real unowned work. Failing loudly surfaces the bad spawn.
542
+ const workItemId = raw.parent_id ?? headerWorkItem;
543
+ const adopted = await adoptAssignmentTask(prisma, workItemId, { ...lane, session });
544
+ let referencedKind: string | null = null;
545
+ if (!adopted && workItemId) {
546
+ const ref = await prisma.work_items.findFirst({
547
+ where: { id: workItemId },
548
+ select: { kind: true },
549
+ });
550
+ referencedKind = ref?.kind ?? null;
551
+ }
552
+ const action = resolveBindAction({ adopted, workItemId, referencedKind });
553
+ if (action.action === "refuse") {
554
+ const { ORPCError } = await import("@orpc/server");
555
+ throw new ORPCError("NOT_FOUND", {
556
+ message:
557
+ `bindLane: the spawn referenced work item ${action.workItemId} to adopt, but no task ` +
558
+ "exists with that id — refusing rather than creating an orphan task and abandoning the spawn's brief.",
559
+ });
560
+ }
561
+ if (action.action === "use") {
562
+ taskId = adopted!.id;
517
563
  created = false;
518
564
  log.info("[task-sync] bindLane: adopted assigned task", {
519
- taskId: adopted.id,
565
+ taskId: adopted!.id,
520
566
  session,
521
567
  });
522
568
  } else {
569
+ // A task's title must carry the lane's REAL work name — never the
570
+ // generic "Lane" sentinel (spawn debris an orchestrator cannot
571
+ // attribute). No resolvable name = refuse, so a "Lane" row is
572
+ // unmakeable on this path (lanes-named-by-the-spawner).
573
+ if (!laneTitle || laneTitle.trim().toLowerCase() === "lane") {
574
+ const { ORPCError } = await import("@orpc/server");
575
+ throw new ORPCError("BAD_REQUEST", {
576
+ message:
577
+ "bindLane: no real work name resolved for this lane (its fleet run has no slot_name). " +
578
+ "A task titled 'Lane' is spawn debris — name the lane at spawn (windowName) or ensure its fleet run resolves a slot_name.",
579
+ });
580
+ }
523
581
  // Read-then-create is a race: SessionStart and the lane's first
524
582
  // TaskCreate arrive seconds apart, both read nothing, and both create.
525
583
  // Measured 2026-07-25: 9 lanes with two active parents each, every twin
@@ -536,6 +594,12 @@ export const taskSyncRouter = {
536
594
  organisation_id,
537
595
  title: laneTitle,
538
596
  status: "active",
597
+ // assignee_id = the lane's session on EVERY bind path, not only
598
+ // adopt. Without it a bound task reads as unowned (assignee_id
599
+ // null) — the signal the fleet steers by. Measured 2026-08-01:
600
+ // 207 task rows with kind_data.lane.session set but assignee_id
601
+ // null, every one reading as unowned.
602
+ assignee_id: session,
539
603
  kind_data: { lane },
540
604
  },
541
605
  });