@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.
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|