@teamscala/service-boot 0.23.330 → 0.23.332

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.330",
9
+ "version": "0.23.332",
10
10
  "sideEffects": false,
11
11
  "files": [
12
12
  "src"
@@ -38,6 +38,7 @@
38
38
  "@teamscala/proc-walker": "*",
39
39
  "@teamscala/realtime-utils": "*",
40
40
  "@teamscala/resolver-engine": "*",
41
+ "@teamscala/safe-fallback": "*",
41
42
  "@teamscala/timing": "*",
42
43
  "@teamscala/tool-executor": "*",
43
44
  "@teamscala/watchdog": "*",
@@ -402,6 +403,14 @@
402
403
  "types": "./src/fn-routers/task-sync-brief.d.ts",
403
404
  "default": "./src/fn-routers/task-sync-brief.ts"
404
405
  },
406
+ "./fn-routers/task-sync-close": {
407
+ "types": "./src/fn-routers/task-sync-close.d.ts",
408
+ "default": "./src/fn-routers/task-sync-close.ts"
409
+ },
410
+ "./fn-routers/task-sync-close.ts": {
411
+ "types": "./src/fn-routers/task-sync-close.d.ts",
412
+ "default": "./src/fn-routers/task-sync-close.ts"
413
+ },
405
414
  "./fn-routers/task-sync-dispatch": {
406
415
  "types": "./src/fn-routers/task-sync-dispatch.d.ts",
407
416
  "default": "./src/fn-routers/task-sync-dispatch.ts"
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Lane-close write-through for task-sync. The session-owning fleet runtime
7
+ * calls this ORPC procedure before releasing an ACP lane, so work_items remains
8
+ * the durable lifecycle source without a second raw-SQL writer in the host.
9
+ */
10
+ import * as v from "valibot";
11
+ import { type Context } from "./auth-router.ts";
12
+ import type { WorkItem } from "./task-sync-router.ts";
13
+ export declare const closeLaneInput: v.ObjectSchema<{
14
+ readonly lane_session: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "lane_session is required">]>;
15
+ readonly disposition: v.PicklistSchema<["completed", "needs_retry", "failed", "orchestrator_closed", "orphaned"], undefined>;
16
+ }, undefined>;
17
+ export interface ClosedTaskState {
18
+ task_id: string;
19
+ task_status: "done" | "archived";
20
+ completed: boolean;
21
+ kind_data: Record<string, unknown>;
22
+ }
23
+ export declare function closedTaskState(task: Pick<WorkItem, "id" | "kind_data">, disposition: v.InferOutput<typeof closeLaneInput>["disposition"]): ClosedTaskState;
24
+ export declare const closeLaneHandler: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
25
+ headers: Headers;
26
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
27
+ prisma: any;
28
+ user: import("./auth-router.ts").User;
29
+ orgId: string | null | undefined;
30
+ }>, v.ObjectSchema<{
31
+ readonly lane_session: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "lane_session is required">]>;
32
+ readonly disposition: v.PicklistSchema<["completed", "needs_retry", "failed", "orchestrator_closed", "orphaned"], undefined>;
33
+ }, undefined>, import("@orpc/contract").Schema<{
34
+ task_id: string;
35
+ task_status: "archived" | "done";
36
+ reconciled: boolean;
37
+ }, {
38
+ task_id: string;
39
+ task_status: "archived" | "done";
40
+ reconciled: boolean;
41
+ }>, Record<never, never>, Record<never, never>>;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ */
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import { closedTaskState } from "./task-sync-close.ts";
9
+
10
+ const task = {
11
+ id: "task-1",
12
+ kind_data: {
13
+ lane: { session: "acp-session", cli_kind: "zerostack", live: true },
14
+ assignee_type: "agent",
15
+ },
16
+ };
17
+
18
+ describe("closedTaskState", () => {
19
+ it("marks completed work done and the lane non-live", () => {
20
+ expect(closedTaskState(task, "completed")).toEqual({
21
+ task_id: "task-1",
22
+ task_status: "done",
23
+ completed: true,
24
+ kind_data: {
25
+ lane: { session: "acp-session", cli_kind: "zerostack", live: false },
26
+ assignee_type: "agent",
27
+ },
28
+ });
29
+ });
30
+
31
+ it("archives every disposition that does not prove completion", () => {
32
+ for (const disposition of [
33
+ "needs_retry",
34
+ "failed",
35
+ "orchestrator_closed",
36
+ "orphaned",
37
+ ] as const) {
38
+ const state = closedTaskState(task, disposition);
39
+ expect(state.task_status).toBe("archived");
40
+ expect(state.completed).toBe(false);
41
+ expect((state.kind_data.lane as { live: boolean }).live).toBe(false);
42
+ }
43
+ });
44
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Lane-close write-through for task-sync. The session-owning fleet runtime
7
+ * calls this ORPC procedure before releasing an ACP lane, so work_items remains
8
+ * the durable lifecycle source without a second raw-SQL writer in the host.
9
+ */
10
+
11
+ import { ORPCError } from "@orpc/server";
12
+ import * as v from "valibot";
13
+ import { authed, type Context } from "./auth-router.ts";
14
+ import type { Prisma, WorkItem } from "./task-sync-router.ts";
15
+
16
+ export const closeLaneInput = v.object({
17
+ lane_session: v.pipe(v.string(), v.minLength(1, "lane_session is required")),
18
+ disposition: v.picklist([
19
+ "completed",
20
+ "needs_retry",
21
+ "failed",
22
+ "orchestrator_closed",
23
+ "orphaned",
24
+ ]),
25
+ });
26
+
27
+ export interface ClosedTaskState {
28
+ task_id: string;
29
+ task_status: "done" | "archived";
30
+ completed: boolean;
31
+ kind_data: Record<string, unknown>;
32
+ }
33
+
34
+ export function closedTaskState(
35
+ task: Pick<WorkItem, "id" | "kind_data">,
36
+ disposition: v.InferOutput<typeof closeLaneInput>["disposition"],
37
+ ): ClosedTaskState {
38
+ const completed = disposition === "completed";
39
+ const lane =
40
+ task.kind_data.lane && typeof task.kind_data.lane === "object"
41
+ ? (task.kind_data.lane as Record<string, unknown>)
42
+ : {};
43
+ return {
44
+ task_id: task.id,
45
+ task_status: completed ? "done" : "archived",
46
+ completed,
47
+ kind_data: { ...task.kind_data, lane: { ...lane, live: false } },
48
+ };
49
+ }
50
+
51
+ export const closeLaneHandler = authed
52
+ .input(closeLaneInput)
53
+ .handler(async ({ input, context }) => {
54
+ const prisma = (context as Context).prisma as Prisma;
55
+ const task = await prisma.work_items.findFirst({
56
+ where: {
57
+ kind: "task",
58
+ kind_data: { path: ["lane", "session"], equals: input.lane_session },
59
+ },
60
+ orderBy: { created_at: "desc" },
61
+ });
62
+ if (!task) {
63
+ throw new ORPCError("NOT_FOUND", {
64
+ message: `No task is bound to ACP lane ${input.lane_session}`,
65
+ });
66
+ }
67
+ const state = closedTaskState(task, input.disposition);
68
+ await prisma.work_items.update({
69
+ where: { id: task.id },
70
+ data: {
71
+ kind_data: state.kind_data,
72
+ status: state.task_status,
73
+ completed: state.completed,
74
+ },
75
+ select: { id: true },
76
+ });
77
+ return {
78
+ task_id: state.task_id,
79
+ task_status: state.task_status,
80
+ reconciled: true,
81
+ };
82
+ });
@@ -26,6 +26,7 @@
26
26
  *
27
27
  * These procedures are the ONLY CLI-agnostic surface an adapter calls:
28
28
  * bindLane — bind (create-or-confirm) a lane's task. SessionStart.
29
+ * closeLane — reconcile a session-owned lane before its runtime releases it.
29
30
  * upsertFromTaskList — write a lane's current task list through to subtasks (AGENT→DB leg, OpenCode whole-list).
30
31
  * upsertFromTaskHook — apply a per-item Claude Code PostToolUse:TaskCreate/Update/Delete (AGENT→DB leg, Claude per-item).
31
32
  * readTaskList — read a lane's current subtasks as a normalized list (DB→AGENT backstop).
@@ -344,6 +345,25 @@ export declare const taskSyncRouter: {
344
345
  }, {
345
346
  work_item_id: string;
346
347
  }>, Record<never, never>, Record<never, never>>;
348
+ /** RUNTIME→DB lifecycle close: mark lane.live=false and reconcile task status before the ACP owner releases the session. */
349
+ closeLane: import("@orpc/server").DecoratedProcedure<import("@orpc/server").MergedInitialContext<Context & Record<never, never>, Context, Context>, import("@orpc/server").MergedCurrentContext<Context, {
350
+ headers: Headers;
351
+ resolveUser?: (headers: Headers, prisma: any) => Promise<import("./auth-router.ts").User | null>;
352
+ prisma: any;
353
+ user: import("./auth-router.ts").User;
354
+ orgId: string | null | undefined;
355
+ }>, v.ObjectSchema<{
356
+ readonly lane_session: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.MinLengthAction<string, 1, "lane_session is required">]>;
357
+ readonly disposition: v.PicklistSchema<["completed", "needs_retry", "failed", "orchestrator_closed", "orphaned"], undefined>;
358
+ }, undefined>, import("@orpc/contract").Schema<{
359
+ task_id: string;
360
+ task_status: "archived" | "done";
361
+ reconciled: boolean;
362
+ }, {
363
+ task_id: string;
364
+ task_status: "archived" | "done";
365
+ reconciled: boolean;
366
+ }>, Record<never, never>, Record<never, never>>;
347
367
  /**
348
368
  * TAB→DB rename back-write: the session-picker `/rpc/rename` calls this after
349
369
  * renaming a tmux window to update the bound task's title BY SESSION (never
@@ -26,6 +26,7 @@
26
26
  *
27
27
  * These procedures are the ONLY CLI-agnostic surface an adapter calls:
28
28
  * bindLane — bind (create-or-confirm) a lane's task. SessionStart.
29
+ * closeLane — reconcile a session-owned lane before its runtime releases it.
29
30
  * upsertFromTaskList — write a lane's current task list through to subtasks (AGENT→DB leg, OpenCode whole-list).
30
31
  * upsertFromTaskHook — apply a per-item Claude Code PostToolUse:TaskCreate/Update/Delete (AGENT→DB leg, Claude per-item).
31
32
  * readTaskList — read a lane's current subtasks as a normalized list (DB→AGENT backstop).
@@ -39,11 +40,13 @@
39
40
  import { getAppLogger } from "@teamscala/logger/app-loggers";
40
41
  import { recordEvent } from "@teamscala/event-log/record-event";
41
42
  import { getPlatformOrgId } from "@teamscala/db/registry/platform";
43
+ import { createSafeFallback } from "@teamscala/safe-fallback/create-safe-fallback";
42
44
  import { authed, type Context } from "./auth-router.ts";
43
45
  import { notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
44
46
  import { resolveLaneTask, resolveLaneWorkName } from "./task-sync-lane.ts";
45
47
  import { readAssignedBriefHandler } from "./task-sync-brief.ts";
46
48
  import { renameLaneTaskHandler } from "./task-sync-rename.ts";
49
+ import { closeLaneHandler } from "./task-sync-close.ts";
47
50
  import {
48
51
  adoptAssignmentTask,
49
52
  bindLaneInput,
@@ -171,6 +174,12 @@ export function resolveBindOrg(explicit?: string): string {
171
174
  return explicit ?? getPlatformOrgId();
172
175
  }
173
176
 
177
+ const laneTaskCreateFallback = createSafeFallback<WorkItem | null>({
178
+ name: "task-sync:lane-task-create-race",
179
+ onFailure: "fail-open-noisy",
180
+ degradedResult: null,
181
+ });
182
+
174
183
  /**
175
184
  * Record a task-sync hook DISCARD to event_log so it is observable + queryable,
176
185
  * not silently swallowed by the hook command's `>/dev/null 2>&1 || true`. The
@@ -286,8 +295,8 @@ async function ensureLaneTask(
286
295
  const name = pane ? await resolveLaneWorkName(prisma, pane) : null;
287
296
  if (!name || name.trim().toLowerCase() === "lane") return null;
288
297
  const lane: LaneIdentity = { pane: pane ?? "", session, cli_kind: "claude-code", role: "agent" };
289
- try {
290
- return await prisma.work_items.create({
298
+ const created = await laneTaskCreateFallback.call(() =>
299
+ prisma.work_items.create({
291
300
  data: {
292
301
  kind: "task",
293
302
  parent_id: null,
@@ -299,10 +308,9 @@ async function ensureLaneTask(
299
308
  assignee_id: session,
300
309
  kind_data: { lane },
301
310
  },
302
- });
303
- } catch {
304
- return resolveLaneTask(prisma, session);
305
- }
311
+ }),
312
+ );
313
+ return created ?? resolveLaneTask(prisma, session);
306
314
  }
307
315
 
308
316
 
@@ -773,6 +781,9 @@ export const taskSyncRouter = {
773
781
  /** 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. */
774
782
  createBriefTask: createBriefTaskHandler,
775
783
 
784
+ /** RUNTIME→DB lifecycle close: mark lane.live=false and reconcile task status before the ACP owner releases the session. */
785
+ closeLane: closeLaneHandler,
786
+
776
787
  /**
777
788
  * TAB→DB rename back-write: the session-picker `/rpc/rename` calls this after
778
789
  * renaming a tmux window to update the bound task's title BY SESSION (never