@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 +1 -1
- package/src/fn-routers/task-sync-assign.d.ts +37 -15
- package/src/fn-routers/task-sync-assign.test.ts +99 -0
- package/src/fn-routers/task-sync-assign.ts +58 -5
- package/src/fn-routers/task-sync-brief.d.ts +30 -3
- package/src/fn-routers/task-sync-brief.test.ts +47 -7
- package/src/fn-routers/task-sync-brief.ts +65 -12
- package/src/fn-routers/task-sync-lane.d.ts +21 -0
- package/src/fn-routers/task-sync-lane.ts +35 -1
- package/src/fn-routers/task-sync-router.d.ts +2 -0
- package/src/fn-routers/task-sync-router.ts +82 -18
package/package.json
CHANGED
|
@@ -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 —
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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:
|
|
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.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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("
|
|
16
|
-
// A lane with no
|
|
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("
|
|
25
|
-
// The task
|
|
26
|
-
//
|
|
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:
|
|
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.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
): {
|
|
41
|
-
|
|
42
|
-
|
|
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:
|
|
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
|
-
|
|
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", {
|
|
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
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
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
|
});
|