@teamscala/service-boot 0.23.284
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 +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- package/src/ws-boot-self-test.ts +114 -0
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* CLI-AGNOSTIC task-sync CORE — the bridge between a lane's native task list
|
|
7
|
+
* (Claude Code TodoWrite, OpenCode todowrite, future CLIs) and the `work_items`
|
|
8
|
+
* table, which is the single source of truth for "what work does this lane
|
|
9
|
+
* have." Detailed contract + the per-CLI adapter split: apps/agent-fleet.md
|
|
10
|
+
* (task-sync section).
|
|
11
|
+
*
|
|
12
|
+
* Design (operator requirement): work_items is the source; each CLI's task list
|
|
13
|
+
* is a WRITE-THROUGH PROJECTION, never a peer. One source + write-through
|
|
14
|
+
* projection cannot diverge by construction — no two-way sync, no conflict
|
|
15
|
+
* resolution. The lane↔task binding is SYMMETRIC: a lane is a lane whether the
|
|
16
|
+
* pane is an orchestrator or a supervised agent (both have tasks).
|
|
17
|
+
*
|
|
18
|
+
* Mapping: an operator request = a kind='task' work_item; the lane's todo items
|
|
19
|
+
* = kind='subtask' rows under it via parent_id. The task carries the lane
|
|
20
|
+
* identity in kind_data.lane = { pane, session, cli_kind, role } so any surface
|
|
21
|
+
* (fleet UI, another lane) can resolve "who owns this work" and push changes
|
|
22
|
+
* back. Binding is by pane identity (the stable $TMUX_PANE), not by fleet run —
|
|
23
|
+
* so it works before/without the fleet_agent_runs.work_item_id join (that column
|
|
24
|
+
* is a UI convenience for the unified lane+task+subtask view, not a dependency).
|
|
25
|
+
*
|
|
26
|
+
* These procedures are the ONLY CLI-agnostic surface an adapter calls:
|
|
27
|
+
* bindLane — bind (create-or-confirm) a lane's task. SessionStart.
|
|
28
|
+
* upsertFromTaskList — write a lane's current task list through to subtasks (AGENT→DB leg, OpenCode whole-list).
|
|
29
|
+
* upsertFromTaskHook — apply a per-item Claude Code PostToolUse:TaskCreate/Update/Delete (AGENT→DB leg, Claude per-item).
|
|
30
|
+
* readTaskList — read a lane's current subtasks as a normalized list (DB→AGENT backstop).
|
|
31
|
+
* applyExternalChange — record an out-of-band subtask change + notify the owning lane (DB→AGENT realtime leg).
|
|
32
|
+
* A CLI with no native task surface (Codex today — upstream_issue openai-codex-2966)
|
|
33
|
+
* has NO adapter: it degrades to work_items-only. The work is still tracked and
|
|
34
|
+
* visible in the projects UI; the lane simply has no live projection. That is a
|
|
35
|
+
* legitimate end state, not a gap to paper over with a fake adapter.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
39
|
+
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
40
|
+
import { getPlatformOrgId } from "@teamscala/db/registry/platform";
|
|
41
|
+
import { authed, type Context } from "./auth-router.ts";
|
|
42
|
+
import { notifyLaneOfTaskChange } from "./task-sync-dispatch.ts";
|
|
43
|
+
|
|
44
|
+
/** A lane's native task-list item, normalized. Every CLI adapter maps to this. */
|
|
45
|
+
export interface TaskListItem {
|
|
46
|
+
content: string;
|
|
47
|
+
status: "pending" | "in_progress" | "completed";
|
|
48
|
+
/** CLI-native stable id when the CLI provides one (TodoWrite has none; opencode todo row id). */
|
|
49
|
+
mark?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Who a lane IS — symmetric across orchestrator/agent roles and across CLIs. */
|
|
53
|
+
export interface LaneIdentity {
|
|
54
|
+
/** "tmux:%42" — authoritative pane identity ($TMUX_PANE). */
|
|
55
|
+
pane: string;
|
|
56
|
+
session?: string;
|
|
57
|
+
/** pane-inventory inferCliKind: claude-code | opencode | codex | unknown. */
|
|
58
|
+
cli_kind?: string;
|
|
59
|
+
/** agent | orchestrator — both have tasks; the model does not assume a supervised agent. */
|
|
60
|
+
role?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Optional. work_items.organisation_id is NOT NULL; when the caller omits it
|
|
63
|
+
* (auto-bind at SessionStart — an adopted or channel-dispatched lane has no
|
|
64
|
+
* client org), bindLane defaults to the platform org via resolveBindOrg.
|
|
65
|
+
*/
|
|
66
|
+
organisation_id?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface WorkItem {
|
|
70
|
+
id: string;
|
|
71
|
+
kind: string;
|
|
72
|
+
parent_id: string | null;
|
|
73
|
+
organisation_id: string;
|
|
74
|
+
title: string;
|
|
75
|
+
status: string | null;
|
|
76
|
+
position: string | null;
|
|
77
|
+
completed: boolean | null;
|
|
78
|
+
kind_data: Record<string, unknown>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type Prisma = {
|
|
82
|
+
work_items: {
|
|
83
|
+
findFirst: (a: unknown) => Promise<WorkItem | null>;
|
|
84
|
+
findMany: (a: unknown) => Promise<WorkItem[]>;
|
|
85
|
+
create: (a: unknown) => Promise<WorkItem>;
|
|
86
|
+
update: (a: unknown) => Promise<WorkItem>;
|
|
87
|
+
deleteMany: (a: unknown) => Promise<{ count: number }>;
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A dispatched run already points at its fleet_tasks row. Task-sync may fill
|
|
93
|
+
* the convenience join for an adopted lane, but must never replace that
|
|
94
|
+
* dispatch identity with the lane's work_items projection id.
|
|
95
|
+
*/
|
|
96
|
+
export function untrackedRunForPane(pane: string) {
|
|
97
|
+
return {
|
|
98
|
+
pane,
|
|
99
|
+
state: { not: "closed" },
|
|
100
|
+
tracked_task_id: null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// TodoWrite-style status (the normalized form) → work_items.status (DB enum).
|
|
105
|
+
// The DB enum uses a hyphen for the active state ("in-progress"); see
|
|
106
|
+
// COLUMN_ENUMS["work_items.status"] in db-validation.
|
|
107
|
+
export const STATUS_TO_WORK_ITEM: Record<TaskListItem["status"], string> = {
|
|
108
|
+
pending: "todo",
|
|
109
|
+
in_progress: "in-progress",
|
|
110
|
+
completed: "done",
|
|
111
|
+
};
|
|
112
|
+
// work_items.status → normalized. Unknown/internal statuses collapse to pending.
|
|
113
|
+
const WORK_ITEM_TO_STATUS: Record<string, TaskListItem["status"]> = {
|
|
114
|
+
todo: "pending",
|
|
115
|
+
"in-progress": "in_progress",
|
|
116
|
+
active: "in_progress",
|
|
117
|
+
done: "completed",
|
|
118
|
+
completed: "completed",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export function normalizeStatus(s: string | null): TaskListItem["status"] {
|
|
122
|
+
return (s && WORK_ITEM_TO_STATUS[s]) || "pending";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the org a lane's task is created under. Explicit when the lane knows
|
|
127
|
+
* its org (a dispatched fleet lane carries one); defaults to the platform org
|
|
128
|
+
* otherwise — an adopted or channel-dispatched pane has no client org, and the
|
|
129
|
+
* platform org is the home for fleet/internal lane tasks (where the operator's
|
|
130
|
+
* PM view reads), so auto-bind at SessionStart seeds the task there.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveBindOrg(explicit?: string): string {
|
|
133
|
+
return explicit ?? getPlatformOrgId();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Record a task-sync hook DISCARD to event_log so it is observable + queryable,
|
|
138
|
+
* not silently swallowed by the hook command's `>/dev/null 2>&1 || true`. The
|
|
139
|
+
* endpoint records it server-side regardless of what the hook does with stdout.
|
|
140
|
+
* Fire-and-forget per event-log-is-the-only-durable-event-log. The headline
|
|
141
|
+
* discard is "no-task-bound" (lane has no bound parent task — exactly the gap
|
|
142
|
+
* that SessionStart auto-bind closes; once binding is automatic this becomes a
|
|
143
|
+
* rare safety signal rather than the default outcome).
|
|
144
|
+
*/
|
|
145
|
+
export function recordTaskSyncDiscard(
|
|
146
|
+
reason: string,
|
|
147
|
+
pane: string | null,
|
|
148
|
+
extra: Record<string, unknown> = {},
|
|
149
|
+
): void {
|
|
150
|
+
recordEvent({
|
|
151
|
+
kind: "tasksync.hook.discarded",
|
|
152
|
+
payload: { reason, pane: pane ?? null, ...extra },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Record a discard AND return the soft {applied:false} body in one step — every
|
|
158
|
+
* no-op path in upsertFromTaskHook goes through here so none can silently drop
|
|
159
|
+
* an event. The returned shape matches the prior `{applied:false, reason,...}`.
|
|
160
|
+
*/
|
|
161
|
+
export function discardApplied(
|
|
162
|
+
reason: string,
|
|
163
|
+
pane: string | null,
|
|
164
|
+
extra: Record<string, unknown> = {},
|
|
165
|
+
): { applied: false; reason: string } & Record<string, unknown> {
|
|
166
|
+
recordTaskSyncDiscard(reason, pane, extra);
|
|
167
|
+
return { applied: false as const, reason, ...extra };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The lane's human work name, read from its live fleet run (slot_name carries the
|
|
172
|
+
* tmux window label per lanes-named-by-the-spawner). Returns null when no run
|
|
173
|
+
* carries this pane yet — adoption and bind race at SessionStart, and a missing
|
|
174
|
+
* name must not block the bind. Feature-detected like the run-link below, because
|
|
175
|
+
* fleet_agent_runs lives only in the fleet service's schema.
|
|
176
|
+
*/
|
|
177
|
+
async function resolveLaneWorkName(prisma: Prisma, pane: string): Promise<string | null> {
|
|
178
|
+
try {
|
|
179
|
+
const runs = (
|
|
180
|
+
prisma as unknown as {
|
|
181
|
+
fleet_agent_runs?: {
|
|
182
|
+
findFirst: (a: unknown) => Promise<{ slot_name?: string | null } | null>;
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
).fleet_agent_runs;
|
|
186
|
+
if (!runs?.findFirst) return null;
|
|
187
|
+
const run = await runs.findFirst({
|
|
188
|
+
where: { pane, state: { not: "closed" } },
|
|
189
|
+
select: { slot_name: true },
|
|
190
|
+
});
|
|
191
|
+
const name = run?.slot_name?.trim();
|
|
192
|
+
return name ? name : null;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resolve a lane's CLI-native session id from its live run row by pane. Used by
|
|
200
|
+
* the task-event handlers (upsertFromTaskHook) whose payloads lack session_id —
|
|
201
|
+
* the run row's opencode_session_id IS the same CLI-native id the bind stamped
|
|
202
|
+
* on kind_data.lane.session, so this recovers it for session-keyed matching.
|
|
203
|
+
* Feature-detected (fleet_agent_runs lives only in the fleet service's schema).
|
|
204
|
+
*/
|
|
205
|
+
async function resolveSessionFromPane(prisma: Prisma, pane: string): Promise<string | null> {
|
|
206
|
+
try {
|
|
207
|
+
const runs = (
|
|
208
|
+
prisma as unknown as {
|
|
209
|
+
fleet_agent_runs?: {
|
|
210
|
+
findFirst: (a: unknown) => Promise<{ opencode_session_id?: string | null } | null>;
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
).fleet_agent_runs;
|
|
214
|
+
if (!runs?.findFirst) return null;
|
|
215
|
+
const run = await runs.findFirst({
|
|
216
|
+
where: { pane, state: { not: "closed" } },
|
|
217
|
+
select: { opencode_session_id: true },
|
|
218
|
+
});
|
|
219
|
+
return run?.opencode_session_id ?? null;
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Resolve the task a lane owns, by SESSION IDENTITY (kind_data.lane.session).
|
|
226
|
+
*
|
|
227
|
+
* The CLI-native session id is the durable, unique-per-session key (Claude
|
|
228
|
+
* Code's UUIDv7 = transcript filename; OpenCode's ses-prefixed id). A pane id
|
|
229
|
+
* is NOT the key — it outlives the CLI session, so a fresh session in the same
|
|
230
|
+
* pane would inherit the prior session's tasks (the exact bug this re-keys).
|
|
231
|
+
* The pane stays on kind_data.lane.pane as a diagnostic field only. */
|
|
232
|
+
/**
|
|
233
|
+
* The lane's parent task, CREATING the binding when it does not exist yet.
|
|
234
|
+
*
|
|
235
|
+
* Every lane must be able to comply with `lane-labels-and-task-lists` — operator
|
|
236
|
+
* ruling 2026-07-27: "ALL lanes can comply, there is no reason why a tab cant
|
|
237
|
+
* comply." The task hooks previously resolved the parent and, finding none,
|
|
238
|
+
* DISCARDED the lane's task ("no-task-bound"). A lane whose SessionStart bindLane
|
|
239
|
+
* never ran, or ran while the endpoint was failing, therefore lost every task it
|
|
240
|
+
* created, silently, forever — it was told to keep a task list and structurally
|
|
241
|
+
* could not. Measured 2026-07-27: live active lanes with no binding at all, each
|
|
242
|
+
* throwing away its own list.
|
|
243
|
+
*
|
|
244
|
+
* Binding on demand removes the failure mode instead of reporting it: the first
|
|
245
|
+
* task a lane records creates the parent it needs. SessionStart binding still
|
|
246
|
+
* runs and is still preferred (it names the task from the lane's work name); this
|
|
247
|
+
* is the backstop that makes the discard impossible.
|
|
248
|
+
*
|
|
249
|
+
* The create races SessionStart and a sibling hook exactly as bindLane does, so
|
|
250
|
+
* it loses the race the same way — re-read and carry on, never surface an error
|
|
251
|
+
* for a row that now exists.
|
|
252
|
+
*/
|
|
253
|
+
async function ensureLaneTask(
|
|
254
|
+
prisma: Prisma,
|
|
255
|
+
session: string,
|
|
256
|
+
pane: string | null,
|
|
257
|
+
): Promise<WorkItem | null> {
|
|
258
|
+
const existing = await resolveLaneTask(prisma, session);
|
|
259
|
+
if (existing) return existing;
|
|
260
|
+
const lane: LaneIdentity = { pane: pane ?? "", session, cli_kind: "claude-code", role: "agent" };
|
|
261
|
+
try {
|
|
262
|
+
return await prisma.work_items.create({
|
|
263
|
+
data: {
|
|
264
|
+
kind: "task",
|
|
265
|
+
parent_id: null,
|
|
266
|
+
organisation_id: resolveBindOrg(),
|
|
267
|
+
title: (pane ? await resolveLaneWorkName(prisma, pane) : null) ?? "Lane",
|
|
268
|
+
status: "active",
|
|
269
|
+
kind_data: { lane },
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
} catch {
|
|
273
|
+
return resolveLaneTask(prisma, session);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function resolveLaneTask(prisma: Prisma, session: string): Promise<WorkItem | null> {
|
|
278
|
+
return prisma.work_items.findFirst({
|
|
279
|
+
where: {
|
|
280
|
+
kind: "task",
|
|
281
|
+
status: "active",
|
|
282
|
+
kind_data: { path: ["lane", "session"], equals: session },
|
|
283
|
+
},
|
|
284
|
+
// Deterministic by oldest. An unordered findFirst returns whichever row
|
|
285
|
+
// Postgres happens to hand back, so with two parents for one lane (the
|
|
286
|
+
// bindLane race, below) one turn wrote the lane's todos under parent A
|
|
287
|
+
// and the next read parent B and found nothing — the lane's task list
|
|
288
|
+
// appeared to empty itself, and the watchdog accused lanes that were
|
|
289
|
+
// holding a populated list. Migration 109 makes the duplicate
|
|
290
|
+
// unwritable; this makes the read stable regardless.
|
|
291
|
+
orderBy: { created_at: "asc" },
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Match key for a subtask: stable mark when the CLI provides one, else content, else title. Uses || (not ??) so an empty-string content (passed for existing rows that key by title) falls through. */
|
|
296
|
+
/**
|
|
297
|
+
* Which lifecycle leg a Claude task-hook payload represents.
|
|
298
|
+
*
|
|
299
|
+
* The dedicated events `TaskCreated` and `TaskCompleted` BOTH carry
|
|
300
|
+
* `task_subject`, so "has a subject" cannot distinguish them — a completion
|
|
301
|
+
* classified as a creation mints a duplicate subtask instead of closing one,
|
|
302
|
+
* which is precisely the "list only grows, nothing ever completes" symptom
|
|
303
|
+
* (`task-projection-converges`). `hook_event_name` is a common field on every
|
|
304
|
+
* hook event and is the only reliable discriminator, so the decision is made
|
|
305
|
+
* here, once, rather than by branch ordering in the handler.
|
|
306
|
+
*/
|
|
307
|
+
/**
|
|
308
|
+
* Markers that can only appear in a task subject when the CLI's tool-call text
|
|
309
|
+
* leaked into the field. `</subject>` is the closing tag of the parameter
|
|
310
|
+
* itself, and `<parameter name=` is the START of the NEXT parameter — either
|
|
311
|
+
* one means the extraction ran past the end of the value it was reading.
|
|
312
|
+
*/
|
|
313
|
+
const SUBJECT_LEAK_MARKERS = ["</subject>", "<parameter name=", "</antml"];
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Trim a task subject at the first tool-call marker.
|
|
317
|
+
*
|
|
318
|
+
* A subject arriving as `Cut judge false positives</subject>\n<parameter
|
|
319
|
+
* name="description">Live gate blocks ~half...` is not a title with odd
|
|
320
|
+
* punctuation — it is the raw tool call, and it renders on the dashboard as a
|
|
321
|
+
* wall of XML where the operator expects a task name (observed live 2026-07-25,
|
|
322
|
+
* 3 rows). The malformation happens upstream in the CLI's hook payload, which
|
|
323
|
+
* this service does not control, so the defence belongs at the WRITE boundary:
|
|
324
|
+
* `work_items` is the single source of truth for every surface, and once a
|
|
325
|
+
* corrupted title lands there every reader shows it.
|
|
326
|
+
*
|
|
327
|
+
* Truncating rather than rejecting is deliberate: the text BEFORE the marker is
|
|
328
|
+
* the real subject and is worth keeping. Dropping the row instead would lose a
|
|
329
|
+
* task the lane genuinely created.
|
|
330
|
+
*/
|
|
331
|
+
export function sanitizeTaskSubject(raw: string): string {
|
|
332
|
+
let out = raw;
|
|
333
|
+
for (const marker of SUBJECT_LEAK_MARKERS) {
|
|
334
|
+
const i = out.indexOf(marker);
|
|
335
|
+
if (i !== -1) out = out.slice(0, i);
|
|
336
|
+
}
|
|
337
|
+
return out.trim();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function classifyTaskHookEvent(evt: {
|
|
341
|
+
hook_event_name?: unknown;
|
|
342
|
+
task_subject?: unknown;
|
|
343
|
+
}): "completed" | "created" | "tool" {
|
|
344
|
+
if (evt.hook_event_name === "TaskCompleted") return "completed";
|
|
345
|
+
if (typeof evt.task_subject === "string") return "created";
|
|
346
|
+
return "tool";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function subtaskKey(item: { mark?: string; content: string; title: string }): string {
|
|
350
|
+
return item.mark || item.content || item.title;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Pure decision for a TaskUpdate/TaskDelete: which existing subtask does this
|
|
355
|
+
* event address, and what should happen when none does. Extracted for the same
|
|
356
|
+
* reason planReconcile was — the handler should only EXECUTE, so the matching
|
|
357
|
+
* rule is unit-testable.
|
|
358
|
+
*
|
|
359
|
+
* Three resolutions:
|
|
360
|
+
* - `update` — matched by mark, or (mark absent/unstamped) by exact title.
|
|
361
|
+
* - `heal` — nothing matched but the event carries a subject, so the row can
|
|
362
|
+
* be reconstructed. Covers a TaskCreate that silently produced no
|
|
363
|
+
* subtask, which previously orphaned the task forever.
|
|
364
|
+
* - `discard` — nothing matched and there is no subject to rebuild a title from.
|
|
365
|
+
*
|
|
366
|
+
* Measured 2026-07-28: marks "1" and "3" were stamped for one lane while mark
|
|
367
|
+
* "2" was never written at all, and every later update for it was dropped as
|
|
368
|
+
* `no-matching-subtask` — 26 of 30 tasksync discards in six hours, across seven
|
|
369
|
+
* distinct lane sessions, were that one reason.
|
|
370
|
+
*/
|
|
371
|
+
export function resolveTaskUpdateTarget(
|
|
372
|
+
existing: Array<{ id: string; title: string; kind_data?: Record<string, unknown> | null }>,
|
|
373
|
+
evt: { taskId: string; subject?: string },
|
|
374
|
+
): { op: "update"; id: string } | { op: "heal"; title: string } | { op: "discard" } {
|
|
375
|
+
const byMark = existing.find(
|
|
376
|
+
(s) =>
|
|
377
|
+
evt.taskId &&
|
|
378
|
+
(s.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark === evt.taskId,
|
|
379
|
+
);
|
|
380
|
+
if (byMark) return { op: "update", id: byMark.id };
|
|
381
|
+
const subject = (evt.subject ?? "").trim();
|
|
382
|
+
if (subject) {
|
|
383
|
+
const byTitle = existing.find((s) => s.title === subject);
|
|
384
|
+
if (byTitle) return { op: "update", id: byTitle.id };
|
|
385
|
+
return { op: "heal", title: subject };
|
|
386
|
+
}
|
|
387
|
+
return { op: "discard" };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Pure reconciliation plan: given the lane's EXISTING subtasks and its INCOMING
|
|
392
|
+
* (current) task list, compute the create/update/delete ops that make the
|
|
393
|
+
* subtask set match the list — WITHOUT touching prisma. Extracted so the
|
|
394
|
+
* matching/status/positioning logic is unit-testable (the handler only executes
|
|
395
|
+
* the plan). Matching is by stable mark when the CLI provides one, else by
|
|
396
|
+
* content. Position follows incoming index, so a reorder is an update.
|
|
397
|
+
*/
|
|
398
|
+
export interface ReconcilePlan {
|
|
399
|
+
toCreate: Array<{
|
|
400
|
+
content: string;
|
|
401
|
+
status: string;
|
|
402
|
+
completed: boolean;
|
|
403
|
+
position: string;
|
|
404
|
+
kind_data: Record<string, unknown>;
|
|
405
|
+
}>;
|
|
406
|
+
toUpdate: Array<{
|
|
407
|
+
id: string;
|
|
408
|
+
content: string;
|
|
409
|
+
status: string;
|
|
410
|
+
completed: boolean;
|
|
411
|
+
position: string;
|
|
412
|
+
kind_data: Record<string, unknown>;
|
|
413
|
+
}>;
|
|
414
|
+
toDelete: string[];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function planReconcile(
|
|
418
|
+
existing: WorkItem[],
|
|
419
|
+
incoming: TaskListItem[],
|
|
420
|
+
source?: string,
|
|
421
|
+
): ReconcilePlan {
|
|
422
|
+
const byKey = new Map<string, WorkItem>();
|
|
423
|
+
for (const s of existing) {
|
|
424
|
+
const mark = (s.kind_data?.task_sync as { mark?: string } | undefined)?.mark;
|
|
425
|
+
byKey.set(subtaskKey({ mark, content: "", title: s.title }), s);
|
|
426
|
+
}
|
|
427
|
+
const toCreate: ReconcilePlan["toCreate"] = [];
|
|
428
|
+
const toUpdate: ReconcilePlan["toUpdate"] = [];
|
|
429
|
+
const seen = new Set<string>();
|
|
430
|
+
for (let i = 0; i < incoming.length; i++) {
|
|
431
|
+
const item = incoming[i];
|
|
432
|
+
const content = (item.content ?? "").trim();
|
|
433
|
+
if (!content) continue;
|
|
434
|
+
const key = subtaskKey({ mark: item.mark, content, title: content });
|
|
435
|
+
const status = STATUS_TO_WORK_ITEM[item.status] ?? "todo";
|
|
436
|
+
const completed = item.status === "completed";
|
|
437
|
+
const position = String(i);
|
|
438
|
+
const taskSync = { task_sync: { mark: item.mark, source } };
|
|
439
|
+
const match = byKey.get(key);
|
|
440
|
+
if (match) {
|
|
441
|
+
seen.add(match.id);
|
|
442
|
+
const changed =
|
|
443
|
+
match.title !== content ||
|
|
444
|
+
match.status !== status ||
|
|
445
|
+
(match.completed ?? false) !== completed ||
|
|
446
|
+
match.position !== position;
|
|
447
|
+
if (changed) {
|
|
448
|
+
toUpdate.push({
|
|
449
|
+
id: match.id,
|
|
450
|
+
content,
|
|
451
|
+
status,
|
|
452
|
+
completed,
|
|
453
|
+
position,
|
|
454
|
+
kind_data: { ...match.kind_data, ...taskSync },
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
toCreate.push({ content, status, completed, position, kind_data: taskSync });
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const toDelete = existing.filter((s) => !seen.has(s.id)).map((s) => s.id);
|
|
462
|
+
return { toCreate, toUpdate, toDelete };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Execute a reconcile against prisma for a lane's task — the shared write path
|
|
467
|
+
* for upsertFromTaskList (normalized input) and upsertFromTodoWriteHook (raw
|
|
468
|
+
* Claude hook event). Throws an Error ("No task bound to pane …") when the lane
|
|
469
|
+
* has no task; callers map that to their preferred response (ORPC NOT_FOUND or a
|
|
470
|
+
* soft {synced:0}). Does NOT dispatch a change notification — the lane that
|
|
471
|
+
* authored the list already has it.
|
|
472
|
+
*/
|
|
473
|
+
async function reconcileTaskList(
|
|
474
|
+
prisma: Prisma,
|
|
475
|
+
laneSession: string,
|
|
476
|
+
list: TaskListItem[],
|
|
477
|
+
source?: string,
|
|
478
|
+
): Promise<{ task_id: string; created: number; updated: number; deleted: number; total: number }> {
|
|
479
|
+
const task = await resolveLaneTask(prisma, laneSession);
|
|
480
|
+
if (!task) {
|
|
481
|
+
throw new Error(`No task bound to session ${laneSession}. Call taskSync.bindLane first (SessionStart adapter).`);
|
|
482
|
+
}
|
|
483
|
+
const existing = await prisma.work_items.findMany({
|
|
484
|
+
where: { parent_id: task.id, kind: "subtask" },
|
|
485
|
+
});
|
|
486
|
+
const plan = planReconcile(existing, list, source);
|
|
487
|
+
for (const c of plan.toCreate) {
|
|
488
|
+
await prisma.work_items.create({
|
|
489
|
+
data: {
|
|
490
|
+
kind: "subtask",
|
|
491
|
+
parent_id: task.id,
|
|
492
|
+
organisation_id: task.organisation_id,
|
|
493
|
+
title: c.content,
|
|
494
|
+
status: c.status,
|
|
495
|
+
completed: c.completed,
|
|
496
|
+
position: c.position,
|
|
497
|
+
kind_data: c.kind_data,
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
for (const u of plan.toUpdate) {
|
|
502
|
+
await prisma.work_items.update({
|
|
503
|
+
where: { id: u.id },
|
|
504
|
+
data: { title: u.content, status: u.status, completed: u.completed, position: u.position, kind_data: u.kind_data },
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
let deleted = 0;
|
|
508
|
+
if (plan.toDelete.length > 0) {
|
|
509
|
+
const res = await prisma.work_items.deleteMany({ where: { id: { in: plan.toDelete } } });
|
|
510
|
+
deleted = res.count;
|
|
511
|
+
}
|
|
512
|
+
return { task_id: task.id, created: plan.toCreate.length, updated: plan.toUpdate.length, deleted, total: list.length };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export const taskSyncRouter = {
|
|
516
|
+
/**
|
|
517
|
+
* Bind (create-or-confirm) the task a lane owns. Called from a SessionStart
|
|
518
|
+
* adapter so the lane has a durable task row before its first todo write.
|
|
519
|
+
* Idempotent: re-binding updates the identity (role/cli_kind can change) but
|
|
520
|
+
* never orphans existing subtasks. After binding, links the lane's
|
|
521
|
+
* fleet_agent_runs row (matched by pane) to the task via tracked_task_id —
|
|
522
|
+
* the join the operator's lane-labels monitor + the unified fleet view read.
|
|
523
|
+
*/
|
|
524
|
+
bindLane: authed.handler(async ({ input, context }) => {
|
|
525
|
+
const ctx = context as Context;
|
|
526
|
+
const prisma = ctx.prisma as Prisma;
|
|
527
|
+
const log = getAppLogger();
|
|
528
|
+
// The SessionStart hook sends the pane + the CLI's native session id.
|
|
529
|
+
// SESSION is the match key (kind_data.lane.session) — it is unique per
|
|
530
|
+
// CLI session and re-minted each session, so a fresh session cannot find
|
|
531
|
+
// the prior session's tasks. PANE stays as a diagnostic field only
|
|
532
|
+
// (kind_data.lane.pane) — it outlives the CLI session, so matching on it
|
|
533
|
+
// is exactly the inheritance bug this re-keys. Without an .input()
|
|
534
|
+
// valibot schema ORPC does not parse the JSON body into `input`, so the
|
|
535
|
+
// headers (x-lane-session, x-lane-pane) are the reliable transport.
|
|
536
|
+
const raw = (input ?? {}) as {
|
|
537
|
+
lane?: Partial<LaneIdentity>;
|
|
538
|
+
title?: string;
|
|
539
|
+
parent_id?: string;
|
|
540
|
+
};
|
|
541
|
+
const headerPane = ctx.headers?.get("x-lane-pane") ?? null;
|
|
542
|
+
const pane = raw.lane?.pane ?? headerPane;
|
|
543
|
+
const headerSession = ctx.headers?.get("x-lane-session") ?? null;
|
|
544
|
+
const session = raw.lane?.session ?? headerSession;
|
|
545
|
+
if (!session) {
|
|
546
|
+
const { ORPCError } = await import("@orpc/server");
|
|
547
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
548
|
+
message: "bindLane requires lane.session (the CLI's native session id)",
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
const lane: LaneIdentity = {
|
|
552
|
+
pane,
|
|
553
|
+
session,
|
|
554
|
+
cli_kind: raw.lane?.cli_kind ?? "claude-code",
|
|
555
|
+
role: raw.lane?.role ?? "agent",
|
|
556
|
+
organisation_id: raw.lane?.organisation_id,
|
|
557
|
+
};
|
|
558
|
+
// organisation_id optional: defaults to the platform org (resolveBindOrg)
|
|
559
|
+
// so auto-bind at SessionStart works with no client org on the lane.
|
|
560
|
+
const organisation_id = resolveBindOrg(lane.organisation_id);
|
|
561
|
+
const existing = await resolveLaneTask(prisma, session);
|
|
562
|
+
// A human-readable title, resolved from the lane's OWN work name. NEVER a
|
|
563
|
+
// pane id in user-facing text (agent-not-identity): `%9` is a machine
|
|
564
|
+
// address, it renumbers, and it tells the operator nothing about what the
|
|
565
|
+
// lane is doing. The run's slot_name IS the work name
|
|
566
|
+
// (lanes-named-by-the-spawner); fall back to a bare "Lane" rather than
|
|
567
|
+
// leak the pane id when no run resolves a name.
|
|
568
|
+
const laneTitle = raw.title ?? (await resolveLaneWorkName(prisma, pane)) ?? "Lane";
|
|
569
|
+
let taskId: string;
|
|
570
|
+
let created: boolean;
|
|
571
|
+
if (existing) {
|
|
572
|
+
const kindData = { ...existing.kind_data, lane };
|
|
573
|
+
const updated = await prisma.work_items.update({
|
|
574
|
+
where: { id: existing.id },
|
|
575
|
+
data: { kind_data: kindData },
|
|
576
|
+
});
|
|
577
|
+
taskId = updated.id;
|
|
578
|
+
created = false;
|
|
579
|
+
log.info("[task-sync] bindLane: re-bound existing task", { taskId, session });
|
|
580
|
+
} else {
|
|
581
|
+
// Read-then-create is a race: SessionStart and the lane's first
|
|
582
|
+
// TaskCreate arrive seconds apart, both read nothing, and both create.
|
|
583
|
+
// Measured 2026-07-25: 9 lanes with two active parents each, every twin
|
|
584
|
+
// 2-3 seconds behind its sibling. Migration 109's partial unique index
|
|
585
|
+
// makes the second write fail instead of succeed, and losing the race
|
|
586
|
+
// is not an error — the winner's row is exactly what this call wanted,
|
|
587
|
+
// so re-read it and carry on.
|
|
588
|
+
let row: WorkItem;
|
|
589
|
+
try {
|
|
590
|
+
row = await prisma.work_items.create({
|
|
591
|
+
data: {
|
|
592
|
+
kind: "task",
|
|
593
|
+
parent_id: raw.parent_id ?? null,
|
|
594
|
+
organisation_id,
|
|
595
|
+
title: laneTitle,
|
|
596
|
+
status: "active",
|
|
597
|
+
kind_data: { lane },
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
log.info("[task-sync] bindLane: created task", { taskId: row.id, session });
|
|
601
|
+
} catch (err) {
|
|
602
|
+
const winner = await resolveLaneTask(prisma, session);
|
|
603
|
+
if (!winner) throw err;
|
|
604
|
+
row = winner;
|
|
605
|
+
log.info("[task-sync] bindLane: lost the create race, using existing task", {
|
|
606
|
+
taskId: row.id,
|
|
607
|
+
session,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
taskId = row.id;
|
|
611
|
+
created = true;
|
|
612
|
+
}
|
|
613
|
+
// Link the fleet run carrying this pane to the bound task — sets
|
|
614
|
+
// fleet_agent_runs.tracked_task_id. Best-effort by design: fleet_agent_runs
|
|
615
|
+
// is only in the fleet service's schema, so feature-detect the model; and a
|
|
616
|
+
// run not yet carrying its pane (adoption race at SessionStart — adoption
|
|
617
|
+
// fires first, but its pane resolve can come up empty on a tmux hiccup) is
|
|
618
|
+
// simply not matched, converging on re-bind / next adoption. The work_items
|
|
619
|
+
// task is the source of truth; this column is the UI convenience join.
|
|
620
|
+
try {
|
|
621
|
+
const runs = (
|
|
622
|
+
prisma as unknown as {
|
|
623
|
+
fleet_agent_runs?: {
|
|
624
|
+
updateMany: (a: unknown) => Promise<{ count: number }>;
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
).fleet_agent_runs;
|
|
628
|
+
if (runs?.updateMany) {
|
|
629
|
+
// STEAL before CLAIM. A task is tracked by at most one LIVE run — the
|
|
630
|
+
// partial unique index fleet_agent_runs_one_active_run_per_task
|
|
631
|
+
// (UNIQUE(tracked_task_id) WHERE state IN active/committing/waiting)
|
|
632
|
+
// enforces it. Assigning tracked_task_id without first releasing it
|
|
633
|
+
// from any OTHER run holding it leaves two runs sharing the task, and
|
|
634
|
+
// the next state→active transition on either one (upsertLaneRun's
|
|
635
|
+
// UPDATE) violates the index → P2002 → the lane-event 500s and the
|
|
636
|
+
// run-link join is lost. The common trigger is re-adoption: a lane
|
|
637
|
+
// moves to a new pane, bindLane claims the task for the new run while
|
|
638
|
+
// the OLD pane's run still holds it. A task is session-scoped, so every
|
|
639
|
+
// run holding this taskId is this lane's own (or a stale duplicate) —
|
|
640
|
+
// releasing from all non-closed runs is correct, then only this lane's
|
|
641
|
+
// untracked run re-claims it.
|
|
642
|
+
await runs.updateMany({
|
|
643
|
+
where: { tracked_task_id: taskId, state: { not: "closed" } },
|
|
644
|
+
data: { tracked_task_id: null },
|
|
645
|
+
});
|
|
646
|
+
await runs.updateMany({
|
|
647
|
+
where: untrackedRunForPane(pane),
|
|
648
|
+
data: { tracked_task_id: taskId },
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
} catch (err) {
|
|
652
|
+
log.warn("[task-sync] bindLane: run-link by pane failed", {
|
|
653
|
+
pane,
|
|
654
|
+
taskId,
|
|
655
|
+
error: err instanceof Error ? err.message : String(err),
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
return { task_id: taskId, created };
|
|
659
|
+
}),
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* AGENT→DB leg. Write a lane's CURRENT task list through to subtasks. The
|
|
663
|
+
* list is authoritative for this lane (it's what the CLI just rendered), so
|
|
664
|
+
* this is a full reconcile: create/update/delete to match. Matching is by
|
|
665
|
+
* stable mark when the CLI provides one, else by content. Does NOT dispatch
|
|
666
|
+
* a change notification — the lane that authored the list already has it.
|
|
667
|
+
*/
|
|
668
|
+
upsertFromTaskList: authed.handler(async ({ input, context }) => {
|
|
669
|
+
const body = input as { lane_session: string; task_list: TaskListItem[]; source?: string };
|
|
670
|
+
const prisma = (context as Context).prisma as Prisma;
|
|
671
|
+
const log = getAppLogger();
|
|
672
|
+
const session = body?.lane_session;
|
|
673
|
+
const list = Array.isArray(body?.task_list) ? body.task_list : [];
|
|
674
|
+
if (!session) {
|
|
675
|
+
const { ORPCError } = await import("@orpc/server");
|
|
676
|
+
throw new ORPCError("BAD_REQUEST", { message: "upsertFromTaskList requires lane_session" });
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
const res = await reconcileTaskList(prisma, session, list, body.source);
|
|
680
|
+
log.info("[task-sync] upsertFromTaskList reconciled", { session, ...res });
|
|
681
|
+
return res;
|
|
682
|
+
} catch (err) {
|
|
683
|
+
if (err instanceof Error && err.message.includes("No task bound")) {
|
|
684
|
+
const { ORPCError } = await import("@orpc/server");
|
|
685
|
+
throw new ORPCError("NOT_FOUND", { message: err.message });
|
|
686
|
+
}
|
|
687
|
+
throw err;
|
|
688
|
+
}
|
|
689
|
+
}),
|
|
690
|
+
/**
|
|
691
|
+
* Claude Code PostToolUse:TaskCreate/TaskUpdate hook entry. Claude Code's
|
|
692
|
+
* todo tool is the per-item Task* family (TaskCreate/TaskUpdate) — TodoWrite
|
|
693
|
+
* was REMOVED in recent Claude Code; the hook matcher keys on
|
|
694
|
+
* TaskCreate/TaskUpdate, never TodoWrite. The hook command posts the RAW hook
|
|
695
|
+
* event (stdin) + an x-lane-pane header (tmux:$TMUX_PANE). Fired as a
|
|
696
|
+
* PostToolUse:TaskCreate/Update hook (NOT a "TaskCreated" event — that is
|
|
697
|
+
* not a real Claude Code hook event + never fired); this procedure
|
|
698
|
+
* applies the per-item change (TaskCreate → new subtask keyed by the harness
|
|
699
|
+
* task id; TaskUpdate → status/title flip on the matching subtask). Server-
|
|
700
|
+
* side extraction keeps the hook command a trivial jq|curl. Accepts the event
|
|
701
|
+
* raw (input.tool_name) OR oRPC-enveloped (input.json.tool_name). Session-
|
|
702
|
+
* less: fn.taskSync is page-gate-exempt; localhost pane shell authenticates
|
|
703
|
+
* as super-admin via resolveUser. Soft-returns on any no-op (never throws).
|
|
704
|
+
*/
|
|
705
|
+
upsertFromTaskHook: authed.handler(async ({ input, context }) => {
|
|
706
|
+
const raw = (input ?? {}) as {
|
|
707
|
+
tool_name?: string;
|
|
708
|
+
tool_input?: Record<string, unknown>;
|
|
709
|
+
tool_response?: Record<string, unknown>;
|
|
710
|
+
json?: { tool_name?: string; tool_input?: Record<string, unknown>; tool_response?: Record<string, unknown> };
|
|
711
|
+
};
|
|
712
|
+
const evt = raw.tool_name ? raw : (raw.json ?? raw);
|
|
713
|
+
const toolName = typeof evt.tool_name === "string" ? evt.tool_name : "";
|
|
714
|
+
const toolInput = (evt.tool_input ?? {}) as Record<string, unknown>;
|
|
715
|
+
const toolResponse = (evt.tool_response ?? {}) as Record<string, unknown>;
|
|
716
|
+
const headerSession = (context as Context).headers?.get("x-lane-session") ?? null;
|
|
717
|
+
const headerPane = (context as Context).headers?.get("x-lane-pane") ?? null;
|
|
718
|
+
const prisma = (context as Context).prisma as Prisma;
|
|
719
|
+
// TaskCreated/TaskCompleted event payloads do NOT carry session_id (only
|
|
720
|
+
// SessionStart does), so the task hooks cannot populate x-lane-session —
|
|
721
|
+
// the extraction in the hook command yields an empty string. Resolve the
|
|
722
|
+
// session from the lane's run row by pane instead: the bind (at
|
|
723
|
+
// session_start) already stamped kind_data.lane.session, and the run row's
|
|
724
|
+
// opencode_session_id is the same CLI-native id. This keeps matching
|
|
725
|
+
// session-keyed (resolveLaneTask still filters kind_data.lane.session)
|
|
726
|
+
// without requiring every task-event payload to carry the session id.
|
|
727
|
+
const session = headerSession || (headerPane ? await resolveSessionFromPane(prisma, headerPane) : null);
|
|
728
|
+
if (!session) return discardApplied("no-session", null);
|
|
729
|
+
const log = getAppLogger();
|
|
730
|
+
// Claude Code fires the dedicated `TaskCreated` EVENT for the Task* tools,
|
|
731
|
+
// NOT PostToolUse — a PostToolUse:TaskCreate matcher never fired (the prior
|
|
732
|
+
// hook shape silently produced zero subtasks, so Claude lanes showed an
|
|
733
|
+
// empty task panel). The TaskCreated payload carries task_subject + task_id
|
|
734
|
+
// on the event object (evt — already unwrapped from the {json:...} wire
|
|
735
|
+
// envelope by the raw.json ?? raw fallback above). Handle it before the
|
|
736
|
+
// toolName guard so the dedicated event path creates the subtask.
|
|
737
|
+
const createdEvt = evt as {
|
|
738
|
+
task_subject?: unknown;
|
|
739
|
+
task_id?: unknown;
|
|
740
|
+
hook_event_name?: unknown;
|
|
741
|
+
};
|
|
742
|
+
// TaskCompleted BEFORE the TaskCreated branch. Both dedicated events carry
|
|
743
|
+
// `task_subject`, so the subject-presence test below cannot tell them apart
|
|
744
|
+
// — an unguarded TaskCompleted would fall into the create path and mint a
|
|
745
|
+
// DUPLICATE subtask instead of completing the existing one, which reads as
|
|
746
|
+
// "the list only ever grows and nothing is ever done"
|
|
747
|
+
// (`task-projection-converges`). `hook_event_name` is a common input field
|
|
748
|
+
// on every hook event, so it is the reliable discriminator.
|
|
749
|
+
const leg = classifyTaskHookEvent(createdEvt);
|
|
750
|
+
if (leg === "completed") {
|
|
751
|
+
const task3 = await resolveLaneTask(prisma, session);
|
|
752
|
+
if (!task3) return discardApplied("no-task-bound", session, { source: "TaskCompleted" });
|
|
753
|
+
const completedId = String(createdEvt.task_id ?? "");
|
|
754
|
+
const subject =
|
|
755
|
+
typeof createdEvt.task_subject === "string"
|
|
756
|
+
? sanitizeTaskSubject(createdEvt.task_subject)
|
|
757
|
+
: "";
|
|
758
|
+
// Match on the mark stamped at creation; fall back to the title so a
|
|
759
|
+
// subtask created before marks were recorded still closes.
|
|
760
|
+
const siblings = await prisma.work_items.findMany({
|
|
761
|
+
where: { parent_id: task3.id, kind: "subtask" },
|
|
762
|
+
});
|
|
763
|
+
const target =
|
|
764
|
+
siblings.find(
|
|
765
|
+
(s) =>
|
|
766
|
+
completedId &&
|
|
767
|
+
(s.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark ===
|
|
768
|
+
completedId,
|
|
769
|
+
) ?? siblings.find((s) => subject && s.title === subject);
|
|
770
|
+
if (!target) {
|
|
771
|
+
return discardApplied("no-matching-subtask", session, {
|
|
772
|
+
source: "TaskCompleted",
|
|
773
|
+
taskId: completedId,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
await prisma.work_items.update({
|
|
777
|
+
where: { id: target.id },
|
|
778
|
+
data: { status: "done", completed: true },
|
|
779
|
+
});
|
|
780
|
+
log.info("[task-sync] TaskCompleted: closed subtask", {
|
|
781
|
+
session,
|
|
782
|
+
taskId: completedId,
|
|
783
|
+
subject,
|
|
784
|
+
});
|
|
785
|
+
return { applied: true, op: "complete", taskId: completedId };
|
|
786
|
+
}
|
|
787
|
+
if (leg === "created") {
|
|
788
|
+
// Sanitized on BOTH legs: the completion leg matches by title, so a
|
|
789
|
+
// raw subject here and a sanitized one at creation would never match.
|
|
790
|
+
const subject = sanitizeTaskSubject(String(createdEvt.task_subject ?? ""));
|
|
791
|
+
if (!subject) return discardApplied("no-subject", session, { source: "TaskCreated" });
|
|
792
|
+
// Bind on demand rather than discard: a lane must always be able to
|
|
793
|
+
// record the task it was just told to create.
|
|
794
|
+
const task2 = await ensureLaneTask(prisma, session, headerPane);
|
|
795
|
+
if (!task2) return discardApplied("no-task-bound", session, { source: "TaskCreated" });
|
|
796
|
+
const createdId = String(createdEvt.task_id ?? "");
|
|
797
|
+
const existing = await prisma.work_items.findMany({ where: { parent_id: task2.id, kind: "subtask" } });
|
|
798
|
+
// IDEMPOTENT on the CLI's own task id. The hook curls with `--retry 2
|
|
799
|
+
// --retry-connrefused`, so one TaskCreate can arrive twice (observed
|
|
800
|
+
// 2026-07-25: four tasks each duplicated ~4s apart), and this path used
|
|
801
|
+
// `existing` only to compute a position — never to check whether the
|
|
802
|
+
// subtask was already written. A projection that duplicates on retry is
|
|
803
|
+
// not a projection (task-projection-converges); the operator sees a list
|
|
804
|
+
// that only grows. Match on the stored mark, falling back to title for
|
|
805
|
+
// rows written before marks were recorded.
|
|
806
|
+
const already = existing.find((e) => {
|
|
807
|
+
const mark = (e.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark;
|
|
808
|
+
return createdId && mark ? mark === createdId : e.title === subject;
|
|
809
|
+
});
|
|
810
|
+
if (already) {
|
|
811
|
+
return discardApplied("duplicate-create", session, {
|
|
812
|
+
source: "TaskCreated",
|
|
813
|
+
taskId: createdId,
|
|
814
|
+
subtaskId: already.id,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
await prisma.work_items.create({
|
|
819
|
+
data: {
|
|
820
|
+
kind: "subtask",
|
|
821
|
+
parent_id: task2.id,
|
|
822
|
+
organisation_id: task2.organisation_id,
|
|
823
|
+
title: subject,
|
|
824
|
+
status: "todo",
|
|
825
|
+
completed: false,
|
|
826
|
+
position: String(existing.length),
|
|
827
|
+
kind_data: { task_sync: { mark: createdId || undefined, source: "claude-code" } },
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
} catch (err) {
|
|
831
|
+
// P2002 = unique violation on work_items_subtask_task_sync_mark_uniq.
|
|
832
|
+
// The `already` check above is a READ-THEN-WRITE and therefore loses
|
|
833
|
+
// a race by construction: two retries of one TaskCreate both read
|
|
834
|
+
// `existing` before either writes, both see nothing, and both insert.
|
|
835
|
+
// That is why cleaning duplicates by hand kept "fixing" a class that
|
|
836
|
+
// came straight back (31 rows cleaned earlier on 2026-07-25, 13 more
|
|
837
|
+
// by that evening). The index is what makes the second insert
|
|
838
|
+
// impossible; this catch turns losing the race into the CORRECT
|
|
839
|
+
// idempotent outcome instead of a 500 back to the hook.
|
|
840
|
+
if ((err as { code?: string })?.code !== "P2002") throw err;
|
|
841
|
+
return discardApplied("duplicate-create-race", session, {
|
|
842
|
+
source: "TaskCreated",
|
|
843
|
+
taskId: createdId,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
log.info("[task-sync] TaskCreated: created subtask", { session, taskId: createdId, subject });
|
|
847
|
+
return { applied: true, op: "create", taskId: createdId };
|
|
848
|
+
}
|
|
849
|
+
if (toolName !== "TaskCreate" && toolName !== "TaskUpdate" && toolName !== "TaskDelete") {
|
|
850
|
+
return discardApplied("unhandled-tool", session, { toolName });
|
|
851
|
+
}
|
|
852
|
+
const task = await ensureLaneTask(prisma, session, headerPane);
|
|
853
|
+
if (!task) return discardApplied("no-task-bound", session, { toolName });
|
|
854
|
+
|
|
855
|
+
if (toolName === "TaskCreate") {
|
|
856
|
+
const subject = typeof toolInput.subject === "string" ? toolInput.subject.trim() : "";
|
|
857
|
+
if (!subject) return discardApplied("no-subject", session, { toolName });
|
|
858
|
+
const taskResp = toolResponse.task as Record<string, unknown> | undefined;
|
|
859
|
+
const taskId = String(toolResponse.id ?? toolResponse.taskId ?? taskResp?.id ?? toolInput.taskId ?? "");
|
|
860
|
+
const existing = await prisma.work_items.findMany({ where: { parent_id: task.id, kind: "subtask" } });
|
|
861
|
+
await prisma.work_items.create({
|
|
862
|
+
data: {
|
|
863
|
+
kind: "subtask",
|
|
864
|
+
parent_id: task.id,
|
|
865
|
+
organisation_id: task.organisation_id,
|
|
866
|
+
title: subject,
|
|
867
|
+
status: "todo",
|
|
868
|
+
completed: false,
|
|
869
|
+
position: String(existing.length),
|
|
870
|
+
kind_data: { task_sync: { mark: taskId || undefined, source: "claude-code" } },
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
log.info("[task-sync] upsertFromTaskHook: created subtask", { session, taskId, subject });
|
|
874
|
+
return { applied: true, op: "create", taskId };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const taskId = String(toolInput.taskId ?? toolInput.id ?? "");
|
|
878
|
+
if (!taskId) return discardApplied("no-taskId", session, { toolName });
|
|
879
|
+
// The handler only EXECUTES; the matching rule lives in the pure
|
|
880
|
+
// resolveTaskUpdateTarget so it is unit-testable (same split as planReconcile).
|
|
881
|
+
const siblings = await prisma.work_items.findMany({
|
|
882
|
+
where: { parent_id: task.id, kind: "subtask" },
|
|
883
|
+
});
|
|
884
|
+
const subjectRaw = typeof toolInput.subject === "string" ? toolInput.subject : undefined;
|
|
885
|
+
const decision = resolveTaskUpdateTarget(siblings, { taskId, subject: subjectRaw });
|
|
886
|
+
if (decision.op === "heal" && toolName === "TaskUpdate") {
|
|
887
|
+
await prisma.work_items.create({
|
|
888
|
+
data: {
|
|
889
|
+
kind: "subtask",
|
|
890
|
+
parent_id: task.id,
|
|
891
|
+
organisation_id: task.organisation_id,
|
|
892
|
+
title: decision.title,
|
|
893
|
+
status: STATUS_TO_WORK_ITEM[toolInput.status as TaskListItem["status"]] ?? "todo",
|
|
894
|
+
completed: toolInput.status === "completed",
|
|
895
|
+
position: String(siblings.length),
|
|
896
|
+
kind_data: { task_sync: { mark: taskId || undefined, source: "claude-code" } },
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
log.warn("[task-sync] TaskUpdate healed a missing subtask", {
|
|
900
|
+
session,
|
|
901
|
+
taskId,
|
|
902
|
+
subject: decision.title,
|
|
903
|
+
});
|
|
904
|
+
return { applied: true, op: "create", taskId };
|
|
905
|
+
}
|
|
906
|
+
const subtask = decision.op === "update" ? siblings.find((s) => s.id === decision.id) : null;
|
|
907
|
+
if (!subtask) return discardApplied("no-matching-subtask", session, { taskId });
|
|
908
|
+
if (toolName === "TaskDelete") {
|
|
909
|
+
await prisma.work_items.deleteMany({ where: { id: [subtask.id] } });
|
|
910
|
+
return { applied: true, op: "delete", taskId };
|
|
911
|
+
}
|
|
912
|
+
// TaskUpdate
|
|
913
|
+
const data: {
|
|
914
|
+
status?: string;
|
|
915
|
+
completed?: boolean;
|
|
916
|
+
title?: string;
|
|
917
|
+
kind_data?: Record<string, unknown>;
|
|
918
|
+
} = {};
|
|
919
|
+
const statusRaw = typeof toolInput.status === "string" ? toolInput.status : "";
|
|
920
|
+
if (statusRaw) {
|
|
921
|
+
data.status = STATUS_TO_WORK_ITEM[statusRaw as TaskListItem["status"]] ?? "todo";
|
|
922
|
+
data.completed = statusRaw === "completed";
|
|
923
|
+
}
|
|
924
|
+
if (typeof toolInput.subject === "string") data.title = toolInput.subject;
|
|
925
|
+
// STAMP THE MARK WHEN A ROW IS MATCHED BY TITLE.
|
|
926
|
+
//
|
|
927
|
+
// Matching is mark-first, then EXACT TITLE. A row that has never been
|
|
928
|
+
// stamped can therefore only ever be found by its title — so the moment a
|
|
929
|
+
// task is RENAMED, neither match succeeds, the event heals into a NEW row,
|
|
930
|
+
// and the pre-rename row is orphaned at whatever status it held. Forever:
|
|
931
|
+
// nothing else in the single-event path will ever look at it again.
|
|
932
|
+
//
|
|
933
|
+
// Observed 2026-07-28 on this lane — three subtasks stranded at `todo` and
|
|
934
|
+
// `in-progress` under their OLD titles while the CLI's own list read empty,
|
|
935
|
+
// which is what made the stop gate refuse a lane that had genuinely
|
|
936
|
+
// finished. `task-projection-converges` requires the projection to express
|
|
937
|
+
// every state a task reaches; a row it can no longer address expresses
|
|
938
|
+
// nothing.
|
|
939
|
+
//
|
|
940
|
+
// Stamping here closes the class rather than the instance: the FIRST
|
|
941
|
+
// title-match a row ever gets makes it mark-addressable, so every later
|
|
942
|
+
// rename updates it in place. The heal path already stamps on create; this
|
|
943
|
+
// is the half that was missing, and without it an unmarked row stays
|
|
944
|
+
// unmarked no matter how many times it is touched.
|
|
945
|
+
const alreadyMarked =
|
|
946
|
+
(subtask.kind_data as { task_sync?: { mark?: string } } | null)?.task_sync?.mark;
|
|
947
|
+
if (taskId && !alreadyMarked) {
|
|
948
|
+
const priorKindData = (subtask.kind_data ?? {}) as Record<string, unknown>;
|
|
949
|
+
const priorSync = (priorKindData.task_sync ?? {}) as Record<string, unknown>;
|
|
950
|
+
data.kind_data = {
|
|
951
|
+
...priorKindData,
|
|
952
|
+
task_sync: { ...priorSync, mark: taskId, source: "claude-code" },
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
if (Object.keys(data).length === 0) return discardApplied("no-change", session, { taskId });
|
|
956
|
+
await prisma.work_items.update({ where: { id: subtask.id }, data });
|
|
957
|
+
log.info("[task-sync] upsertFromTaskHook: updated subtask", { session, taskId, ...data });
|
|
958
|
+
return { applied: true, op: "update", taskId };
|
|
959
|
+
}),
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* DB→AGENT backstop read. Returns the lane's current subtasks in the
|
|
963
|
+
* normalized shape every CLI adapter maps to its native list. Called by the
|
|
964
|
+
* SessionStart/UserPromptSubmit adapters so a pane never opens or continues
|
|
965
|
+
* on stale tasks, and by a lane after it receives a "your tasks changed"
|
|
966
|
+
* channel notification.
|
|
967
|
+
*/
|
|
968
|
+
readTaskList: authed.handler(async ({ input, context }) => {
|
|
969
|
+
const ctx = context as Context;
|
|
970
|
+
const prisma = ctx.prisma as Prisma;
|
|
971
|
+
const body = (input ?? {}) as { lane_session?: string };
|
|
972
|
+
// Same transport as bindLane/upsertFromTaskHook: the session id arrives
|
|
973
|
+
// on the x-lane-session header (and body.lane_session as fallback).
|
|
974
|
+
const session = body.lane_session ?? ctx.headers?.get("x-lane-session") ?? null;
|
|
975
|
+
if (!session) {
|
|
976
|
+
const { ORPCError } = await import("@orpc/server");
|
|
977
|
+
throw new ORPCError("BAD_REQUEST", { message: "readTaskList requires lane_session" });
|
|
978
|
+
}
|
|
979
|
+
const task = await resolveLaneTask(prisma, session);
|
|
980
|
+
if (!task) return { task_id: null, task_title: null, subtasks: [] };
|
|
981
|
+
const rows = await prisma.work_items.findMany({
|
|
982
|
+
where: { parent_id: task.id, kind: "subtask" },
|
|
983
|
+
});
|
|
984
|
+
rows.sort((a, b) => (a.position ?? "").localeCompare(b.position ?? "", undefined, { numeric: true }));
|
|
985
|
+
const subtasks: TaskListItem[] = rows.map((r) => ({
|
|
986
|
+
content: r.title,
|
|
987
|
+
status: normalizeStatus(r.status),
|
|
988
|
+
mark: (r.kind_data?.task_sync as { mark?: string } | undefined)?.mark,
|
|
989
|
+
}));
|
|
990
|
+
return { task_id: task.id, task_title: task.title, subtasks };
|
|
991
|
+
}),
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* DB→AGENT realtime leg (explicit dispatch path). Record an out-of-band
|
|
995
|
+
* change to a subtask's status (operator toggles it in the projects UI,
|
|
996
|
+
* another lane edits a shared task) and notify the OWNING lane's pane that
|
|
997
|
+
* its projection is stale. The pane's adapter re-reads (readTaskList) and
|
|
998
|
+
* updates its native list. Symmetric counterpart to upsertFromTaskList:
|
|
999
|
+
* there the lane is the originator (no self-echo); here the lane is the
|
|
1000
|
+
* recipient.
|
|
1001
|
+
*
|
|
1002
|
+
* COMPLETION STEP (not MVP): this procedure is the explicit trigger. For
|
|
1003
|
+
* FULL coverage — every work_items subtask write, including the projects
|
|
1004
|
+
* UI's generated CRUD — a Postgres trigger + LISTEN subscriber on
|
|
1005
|
+
* scala-channel fires the same publish, so an operator edit reaches the
|
|
1006
|
+
* lane with no explicit call. Tracked in apps/agent-fleet.md task-sync.
|
|
1007
|
+
*/
|
|
1008
|
+
applyExternalChange: authed.handler(async ({ input, context }) => {
|
|
1009
|
+
const body = input as { task_id: string; subtask_id?: string; status?: TaskListItem["status"] };
|
|
1010
|
+
const prisma = (context as Context).prisma as Prisma;
|
|
1011
|
+
const log = getAppLogger();
|
|
1012
|
+
const { task_id, subtask_id, status } = body ?? {};
|
|
1013
|
+
if (!task_id) {
|
|
1014
|
+
const { ORPCError } = await import("@orpc/server");
|
|
1015
|
+
throw new ORPCError("BAD_REQUEST", { message: "applyExternalChange requires task_id" });
|
|
1016
|
+
}
|
|
1017
|
+
if (subtask_id && status) {
|
|
1018
|
+
await prisma.work_items.update({
|
|
1019
|
+
where: { id: subtask_id },
|
|
1020
|
+
data: { status: STATUS_TO_WORK_ITEM[status], completed: status === "completed" },
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
const task = await prisma.work_items.findFirst({ where: { id: task_id } });
|
|
1024
|
+
const lanePane = (task?.kind_data?.lane as { pane?: string } | undefined)?.pane;
|
|
1025
|
+
// Fire-and-forget: a dispatch failure must never block the work_items write.
|
|
1026
|
+
if (lanePane) {
|
|
1027
|
+
notifyLaneOfTaskChange(lanePane, task_id).catch((err) => {
|
|
1028
|
+
log.warn("[task-sync] applyExternalChange: lane notify failed", {
|
|
1029
|
+
taskId: task_id,
|
|
1030
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
return { task_id, lane_pane: lanePane ?? null, notified: !!lanePane };
|
|
1035
|
+
}),
|
|
1036
|
+
};
|