@teamscala/service-boot 0.23.316 → 0.23.318

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.316",
9
+ "version": "0.23.318",
10
10
  "sideEffects": false,
11
11
  "files": [
12
12
  "src"
@@ -157,11 +157,18 @@ export declare function createTaskWithBriefComment(prisma: Prisma, input: {
157
157
  * implement`). "Is the caller the assignee" becomes a simple equality on the
158
158
  * session.
159
159
  *
160
- * Refuses to STEAL a task another live session already owns — that is the
161
- * existing-session re-bind branch's job (matched by `kind_data.lane.session`).
162
- * Here we adopt only tasks with no session (or already this session). Returns
163
- * null when `maybeTaskId` is absent, not a task, or already owned by another
164
- * session — caller falls through to the normal create.
160
+ * RE-BINDS a task another session already owns. Migration 109
161
+ * (`work_items_one_active_task_per_lane`) makes `(kind_data->'lane'->>'pane')`
162
+ * unique WHERE active, so when a lane is re-spawned on a pane whose prior task is
163
+ * still active under a STALE session (the lane died, the row survived),
164
+ * `resolveLaneTask(newSession)` finds nothing and `create` P2002s — measured
165
+ * fleet-wide 2026-08-01: every real lane's bindLane 500'd on this constraint.
166
+ * Refusing to steal (the original design) routes every re-spawn into that
167
+ * dead-end. An assignment task belongs to whichever lane was dispatched to it,
168
+ * and a stale session is by definition dead, so re-binding to the fresh session
169
+ * is the correct recovery: the pane's unique slot moves to the live lane and
170
+ * the brief follows. Returns null only when `maybeTaskId` is absent or not a
171
+ * task — caller falls through to create for an exploratory lane (no workItemId).
165
172
  */
166
173
  export declare function adoptAssignmentTask(prisma: Prisma, maybeTaskId: string | null | undefined, lane: {
167
174
  session: string;
@@ -187,6 +187,7 @@ describe("createTaskWithBriefComment (atomic task + first comment = brief)", ()
187
187
  describe("adoptAssignmentTask (SessionStart adopts the pre-created task + ownership)", () => {
188
188
  function fakeAdoptPrisma(existing: Record<string, unknown> | null) {
189
189
  const updates: Array<{ id: string } & Record<string, unknown>> = [];
190
+ const updateManyCalls: unknown[] = [];
190
191
  const prisma = {
191
192
  work_items: {
192
193
  findFirst: async () => existing,
@@ -195,9 +196,13 @@ describe("adoptAssignmentTask (SessionStart adopts the pre-created task + owners
195
196
  updates.push(row);
196
197
  return row;
197
198
  },
199
+ updateMany: async (a: unknown) => {
200
+ updateManyCalls.push(a);
201
+ return { count: 0 };
202
+ },
198
203
  },
199
204
  };
200
- return { prisma, updates };
205
+ return { prisma, updates, updateManyCalls };
201
206
  }
202
207
 
203
208
  it("writes the lane session onto the pre-created task AND sets assignee_id (ownership)", async () => {
@@ -224,12 +229,22 @@ describe("adoptAssignmentTask (SessionStart adopts the pre-created task + owners
224
229
  expect(kd.lane.session).toBe("s");
225
230
  });
226
231
 
227
- it("REFUSES to steal a task another live session already owns", async () => {
228
- const task = { id: "t", kind: "task", kind_data: { lane: { session: "someone-else" } } };
229
- const { prisma, updates } = fakeAdoptPrisma(task);
230
- const out = await adoptAssignmentTask(prisma as never, "t", { session: "s", pane: null });
231
- expect(out).toBeNull();
232
- expect(updates).toHaveLength(0);
232
+ it("RE-BINDS a task a stale session owns to the fresh lane (pane slot recovery)", async () => {
233
+ // Migration 109 makes (kind_data->'lane'->>'pane') unique WHERE active, so a
234
+ // re-spawn on a pane whose prior task is still active under a dead session
235
+ // cannot create a new task (P2002). adopt re-binds the assignment task to
236
+ // the fresh session + releases the stale pane slot first. A stale session
237
+ // is by definition dead, so the task belongs to whichever lane was
238
+ // dispatched to it — re-binding is correct recovery, not theft.
239
+ const task = { id: "t", kind: "task", kind_data: { lane: { session: "someone-else", pane: "tmux:%9" } } };
240
+ const { prisma, updates, updateManyCalls } = fakeAdoptPrisma(task);
241
+ const out = await adoptAssignmentTask(prisma as never, "t", { session: "fresh", pane: "tmux:%9" });
242
+ expect(out).not.toBeNull();
243
+ expect(updates).toHaveLength(1);
244
+ expect((updates[0].kind_data as { lane: { session: string } }).lane.session).toBe("fresh");
245
+ expect(updates[0].assignee_id).toBe("fresh");
246
+ // The stale pane slot was released before the re-bind (STEAL-before-CLAIM).
247
+ expect(updateManyCalls).toHaveLength(1);
233
248
  });
234
249
 
235
250
  it("re-adopts a task already bound to THIS session (idempotent re-bind)", async () => {
@@ -215,11 +215,18 @@ export async function createTaskWithBriefComment(
215
215
  * implement`). "Is the caller the assignee" becomes a simple equality on the
216
216
  * session.
217
217
  *
218
- * Refuses to STEAL a task another live session already owns — that is the
219
- * existing-session re-bind branch's job (matched by `kind_data.lane.session`).
220
- * Here we adopt only tasks with no session (or already this session). Returns
221
- * null when `maybeTaskId` is absent, not a task, or already owned by another
222
- * session — caller falls through to the normal create.
218
+ * RE-BINDS a task another session already owns. Migration 109
219
+ * (`work_items_one_active_task_per_lane`) makes `(kind_data->'lane'->>'pane')`
220
+ * unique WHERE active, so when a lane is re-spawned on a pane whose prior task is
221
+ * still active under a STALE session (the lane died, the row survived),
222
+ * `resolveLaneTask(newSession)` finds nothing and `create` P2002s — measured
223
+ * fleet-wide 2026-08-01: every real lane's bindLane 500'd on this constraint.
224
+ * Refusing to steal (the original design) routes every re-spawn into that
225
+ * dead-end. An assignment task belongs to whichever lane was dispatched to it,
226
+ * and a stale session is by definition dead, so re-binding to the fresh session
227
+ * is the correct recovery: the pane's unique slot moves to the live lane and
228
+ * the brief follows. Returns null only when `maybeTaskId` is absent or not a
229
+ * task — caller falls through to create for an exploratory lane (no workItemId).
223
230
  */
224
231
  export async function adoptAssignmentTask(
225
232
  prisma: Prisma,
@@ -234,10 +241,6 @@ export async function adoptAssignmentTask(
234
241
  // (which a spawn also carries as workItemId) must never be adopted, only
235
242
  // created-under. Verified, not assumed (`a-component-may-not-report-a-state...`).
236
243
  if (row.kind !== "task") return null;
237
- const existingSession = (
238
- (row.kind_data as { lane?: { session?: string } } | null) ?? {}
239
- ).lane?.session;
240
- if (existingSession && existingSession !== lane.session) return null;
241
244
  const laneData = {
242
245
  pane: lane.pane,
243
246
  session: lane.session,
@@ -249,6 +252,37 @@ export async function adoptAssignmentTask(
249
252
  lane: laneData,
250
253
  assignee_type: "agent",
251
254
  };
255
+ // RELEASE any OTHER active task holding this pane's unique lane slot before
256
+ // writing — migration 109's unique index would otherwise P2002 on the update
257
+ // when a stale task (prior session, same pane) is still active. Same logic
258
+ // as the run-link STEAL-before-CLAIM: a pane's lane slot is singly-owned, so
259
+ // every other row holding it is this lane's own stale prior (or a duplicate the
260
+ // race created) and is released to null-pane so this update can land. The
261
+ // assignment task itself is then re-bound to the fresh session.
262
+ try {
263
+ if (lane.pane) {
264
+ const others = (
265
+ prisma as unknown as {
266
+ work_items?: {
267
+ updateMany: (a: unknown) => Promise<{ count: number }>;
268
+ };
269
+ }
270
+ ).work_items;
271
+ await others?.updateMany({
272
+ where: {
273
+ kind: "task",
274
+ status: "active",
275
+ id: { not: row.id },
276
+ kind_data: { path: ["lane", "pane"], equals: lane.pane },
277
+ },
278
+ data: { kind_data: { lane: { pane: null } } },
279
+ });
280
+ }
281
+ } catch {
282
+ // Best-effort: a release failure does not block the adopt — the update below
283
+ // will throw its own P2002 and the handler falls through, converging on the
284
+ // next bind. Logged by the caller's run-link region, not here.
285
+ }
252
286
  try {
253
287
  return await prisma.work_items.update({
254
288
  where: { id: row.id },
@@ -426,11 +426,6 @@ export const taskSyncRouter = {
426
426
  const ctx = context as Context;
427
427
  const prisma = ctx.prisma as Prisma;
428
428
  const log = getAppLogger();
429
- // DIAG: capture the real exception+stack for the pane-with-run 500
430
- // (oRPC swallows it to a generic 500; the run-link catch is not firing).
431
- const __diagPane = (input as { lane?: { pane?: string } })?.lane?.pane ?? ctx.headers?.get("x-lane-pane");
432
- const __diagSession = (input as { lane?: { session?: string } })?.lane?.session ?? ctx.headers?.get("x-lane-session");
433
- try {
434
429
  // The SessionStart hook sends the pane + the CLI's native session id.
435
430
  // SESSION is the match key (kind_data.lane.session) — it is unique per
436
431
  // CLI session and re-minted each session, so a fresh session cannot find
@@ -604,14 +599,6 @@ export const taskSyncRouter = {
604
599
  });
605
600
  }
606
601
  return { task_id: taskId, created };
607
- } catch (__diagErr) {
608
- log.error("[task-sync] bindLane THREW (diag)", {
609
- pane: __diagPane, session: __diagSession,
610
- error: __diagErr instanceof Error ? __diagErr.message : String(__diagErr),
611
- stack: __diagErr instanceof Error ? __diagErr.stack : undefined,
612
- });
613
- throw __diagErr;
614
- }
615
602
  }),
616
603
 
617
604
  /**