@vellumai/assistant 0.12.0-staging.1 → 0.12.0

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.
Files changed (92) hide show
  1. package/Dockerfile +5 -0
  2. package/docs/architecture/turn-actor.md +9 -0
  3. package/knip.json +1 -0
  4. package/node_modules/@vellumai/app-icons/package.json +18 -0
  5. package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
  6. package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
  7. package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  12. package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
  13. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  16. package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  17. package/openapi.yaml +628 -0
  18. package/package.json +3 -1
  19. package/scripts/smoke-container-workspace-dependencies.ts +19 -0
  20. package/src/__tests__/app-builder-icon-names.test.ts +29 -0
  21. package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
  22. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +2 -0
  25. package/src/__tests__/conversation-attachments.test.ts +142 -0
  26. package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
  27. package/src/__tests__/conversation-event-sink.test.ts +50 -0
  28. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
  29. package/src/__tests__/conversation-queue.test.ts +297 -0
  30. package/src/__tests__/credential-routes.test.ts +185 -6
  31. package/src/__tests__/drain-kick-guard.test.ts +2 -0
  32. package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
  33. package/src/__tests__/messaging-send-tool.test.ts +42 -0
  34. package/src/__tests__/oauth-commands-routes.test.ts +47 -0
  35. package/src/__tests__/subagent-manager-notify.test.ts +25 -4
  36. package/src/activation/progress-store.test.ts +1564 -0
  37. package/src/activation/progress-store.ts +1296 -0
  38. package/src/activation/turn-hooks.test.ts +246 -0
  39. package/src/activation/turn-hooks.ts +126 -0
  40. package/src/api/events/subagent-status-changed.ts +7 -5
  41. package/src/api/responses/activation.ts +136 -0
  42. package/src/apps/app-store.ts +8 -0
  43. package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
  44. package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
  45. package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
  46. package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
  47. package/src/cli/commands/channels/index.help.ts +70 -18
  48. package/src/cli/commands/channels/index.ts +17 -6
  49. package/src/cli/commands/channels/request.ts +66 -0
  50. package/src/cli/commands/oauth/request.test.ts +2 -0
  51. package/src/cli/commands/oauth/request.ts +227 -186
  52. package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
  53. package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
  54. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
  55. package/src/config/feature-flag-registry.json +35 -5
  56. package/src/daemon/assistant-attachments.ts +11 -0
  57. package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
  58. package/src/daemon/conversation-agent-loop.ts +71 -3
  59. package/src/daemon/conversation-attachments.ts +42 -1
  60. package/src/daemon/conversation-event-sink.ts +25 -0
  61. package/src/daemon/conversation-process.ts +69 -21
  62. package/src/daemon/conversation-store.ts +4 -3
  63. package/src/daemon/conversation-surfaces.ts +19 -4
  64. package/src/daemon/message-types/sync.ts +2 -0
  65. package/src/ipc/assistant-server.ts +2 -0
  66. package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
  67. package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
  68. package/src/notifications/AGENTS.md +1 -1
  69. package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
  70. package/src/notifications/conversation-pairing.ts +47 -5
  71. package/src/notifications/delivered-post-record.ts +3 -0
  72. package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
  73. package/src/persistence/conversation-crud.ts +39 -0
  74. package/src/persistence/delivery-crud.ts +13 -0
  75. package/src/plugins/AGENTS.md +1 -0
  76. package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
  77. package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
  78. package/src/runtime/routes/activation-routes.test.ts +415 -0
  79. package/src/runtime/routes/activation-routes.ts +172 -0
  80. package/src/runtime/routes/credential-routes.ts +46 -3
  81. package/src/runtime/routes/index.ts +2 -0
  82. package/src/runtime/routes/oauth-commands-routes.ts +21 -8
  83. package/src/runtime/routes/platform-managed-credentials.ts +48 -0
  84. package/src/runtime/routes/secret-routes.ts +3 -12
  85. package/src/runtime/routes/user-route-resolution.ts +21 -0
  86. package/src/runtime/routes/user-routes.ts +112 -9
  87. package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
  88. package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
  89. package/src/runtime/sync/resource-sync-events.ts +23 -0
  90. package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
  91. package/src/runtime/sync/worker-daemon-notify.ts +39 -1
  92. package/src/tools/apps/executors.ts +8 -8
@@ -0,0 +1,1296 @@
1
+ /**
2
+ * On-disk store for activation-checklist progress
3
+ * (`<workspace>/data/activation-progress.json`).
4
+ *
5
+ * The checklist itself (task copy, icons, ordering) lives in the web
6
+ * client; the daemon only records which task was launched into which
7
+ * conversation and how far it got, so every client of one assistant sees
8
+ * the same state. Task ids and list ids are therefore opaque strings,
9
+ * constrained to {@link ACTIVATION_ID_PATTERN} so a client cannot turn a
10
+ * JSON key into an unbounded blob.
11
+ *
12
+ * Reads are synchronous and degrade to the empty default: a missing or
13
+ * corrupt file means "nothing started yet", which is the same state a
14
+ * fresh assistant is in. A file stamped with a schema version this build
15
+ * does not know is the one case that does not degrade to a rewrite: it is
16
+ * served read-only so a rollback cannot erase the newer document, and a
17
+ * write against it fails with a 409 rather than reporting a success that
18
+ * never touched disk.
19
+ * Writes are atomic (temp file + rename) and serialized twice over. A
20
+ * single in-process promise chain keeps concurrent route calls and turn
21
+ * hooks from interleaving a read-modify-write, and an exclusive lock file
22
+ * beside the snapshot extends that to the daemon's sidecar workers, which
23
+ * load this module against the same workspace and would otherwise read a
24
+ * snapshot the daemon is about to replace. Serialization is never traded
25
+ * away to make a write go through: a lock another process will not let go
26
+ * of fails the mutation with a 503 rather than racing its rename. A write
27
+ * that cannot land rejects, so a route never answers with state the next
28
+ * read would contradict; the fire-and-forget turn hooks catch, retry within
29
+ * their own bounds, and log.
30
+ *
31
+ * A conversation belongs to at most one task: {@link startActivationTask}
32
+ * unlinks any other `started` task pointing at the same conversation, so
33
+ * the step and completion lookups can resolve one record and never strand
34
+ * another. Which conversations are linked is mirrored in memory
35
+ * ({@link linkIndex}) so the per-tool-call hooks answer for the
36
+ * overwhelmingly common unlinked conversation without reading the file.
37
+ *
38
+ * Every write that changes visible state publishes the
39
+ * `activation:progress` sync tag so sibling clients refetch, carrying the
40
+ * client that made it so that client can suppress its own echo.
41
+ */
42
+
43
+ import { randomUUID } from "node:crypto";
44
+ import {
45
+ closeSync,
46
+ linkSync,
47
+ mkdirSync,
48
+ openSync,
49
+ readFileSync,
50
+ renameSync,
51
+ rmSync,
52
+ statSync,
53
+ writeFileSync,
54
+ writeSync,
55
+ } from "node:fs";
56
+ import { join } from "node:path";
57
+
58
+ import {
59
+ ACTIVATION_CONVERSATION_ID_MAX_LENGTH,
60
+ ACTIVATION_ID_PATTERN,
61
+ ACTIVATION_PROGRESS_VERSION,
62
+ type ActivationArtifact,
63
+ ActivationArtifactSchema,
64
+ type ActivationDismissKind,
65
+ type ActivationProgress,
66
+ ActivationProgressSchema,
67
+ ActivationTaskProgressSchema,
68
+ emptyActivationProgress,
69
+ } from "../api/responses/activation.js";
70
+ import {
71
+ BadRequestError,
72
+ ConflictError,
73
+ InternalError,
74
+ NotFoundError,
75
+ ServiceUnavailableError,
76
+ } from "../runtime/routes/errors.js";
77
+ import { publishActivationProgressChanged } from "../runtime/sync/resource-sync-events.js";
78
+ import { getLogger } from "../util/logger.js";
79
+ import { getDataDir } from "../util/platform.js";
80
+
81
+ const log = getLogger("activation-progress-store");
82
+
83
+ /** Filename for the on-disk snapshot. Lives under the workspace data dir. */
84
+ export const ACTIVATION_PROGRESS_FILENAME = "activation-progress.json";
85
+
86
+ /**
87
+ * Minimum interval between step-count invalidations for one task. Tool
88
+ * calls arrive in bursts, and each one only moves a counter in a pill, so
89
+ * both the disk write and the broadcast coalesce into one per window with
90
+ * a trailing flush that persists the final count.
91
+ */
92
+ export const ACTIVATION_STEP_BUMP_THROTTLE_MS = 2_000;
93
+
94
+ /** Live throttle window. Only {@link resetActivationStepThrottleForTesting} moves it. */
95
+ let stepThrottleMs: number = ACTIVATION_STEP_BUMP_THROTTLE_MS;
96
+
97
+ /** Upper bound on artifacts recorded per task, so a chatty turn cannot grow the file. */
98
+ const MAX_ARTIFACTS_PER_TASK = 10;
99
+
100
+ /**
101
+ * Canonical path to the progress snapshot
102
+ * (`<workspace>/data/activation-progress.json`).
103
+ */
104
+ export function getActivationProgressPath(): string {
105
+ return join(getDataDir(), ACTIVATION_PROGRESS_FILENAME);
106
+ }
107
+
108
+ /** Throw a 400 unless `value` is a well-formed activation identifier. */
109
+ function assertActivationId(value: string, label: string): void {
110
+ if (!ACTIVATION_ID_PATTERN.test(value)) {
111
+ throw new BadRequestError(
112
+ `Invalid ${label}: must match ${ACTIVATION_ID_PATTERN.source}`,
113
+ );
114
+ }
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Reading
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * A snapshot plus what the file said about itself. `readOnly` marks a
123
+ * document written by a schema version this build does not understand: its
124
+ * fields are read as far as they still parse, and nothing is written back.
125
+ */
126
+ interface ActivationSnapshot {
127
+ progress: ActivationProgress;
128
+ readOnly: boolean;
129
+ }
130
+
131
+ function isRecord(value: unknown): value is Record<string, unknown> {
132
+ return typeof value === "object" && value !== null && !Array.isArray(value);
133
+ }
134
+
135
+ /**
136
+ * Read what a newer document still has in common with this schema, field by
137
+ * field, so a client on an older build shows the progress it can understand
138
+ * rather than an empty checklist.
139
+ *
140
+ * Every field this build knows is validated by the same schema the strict
141
+ * read uses, and every task key by the same {@link ACTIVATION_ID_PATTERN}
142
+ * the write path enforces: a newer document is still a document a client
143
+ * renders, so it may not smuggle a value past the bounds an ordinary read
144
+ * applies. Anything else is passed over rather than guessed at.
145
+ */
146
+ function parseForwardCompatible(document: unknown): ActivationProgress {
147
+ const progress = emptyActivationProgress();
148
+ if (!isRecord(document)) {
149
+ return progress;
150
+ }
151
+ const { listId, modalDismissedAt, allDoneShownAt } =
152
+ ActivationProgressSchema.shape;
153
+ const parsedListId = listId.safeParse(document.listId);
154
+ if (parsedListId.success) {
155
+ progress.listId = parsedListId.data;
156
+ }
157
+ const parsedModalDismissedAt = modalDismissedAt.safeParse(
158
+ document.modalDismissedAt,
159
+ );
160
+ if (parsedModalDismissedAt.success) {
161
+ progress.modalDismissedAt = parsedModalDismissedAt.data;
162
+ }
163
+ const parsedAllDoneShownAt = allDoneShownAt.safeParse(
164
+ document.allDoneShownAt,
165
+ );
166
+ if (parsedAllDoneShownAt.success) {
167
+ progress.allDoneShownAt = parsedAllDoneShownAt.data;
168
+ }
169
+ if (isRecord(document.tasks)) {
170
+ for (const [taskId, task] of Object.entries(document.tasks)) {
171
+ if (!ACTIVATION_ID_PATTERN.test(taskId)) {
172
+ continue;
173
+ }
174
+ const parsed = ActivationTaskProgressSchema.safeParse(task);
175
+ if (parsed.success) {
176
+ progress.tasks[taskId] = parsed.data;
177
+ }
178
+ }
179
+ }
180
+ return progress;
181
+ }
182
+
183
+ function readActivationSnapshot(): ActivationSnapshot {
184
+ const path = getActivationProgressPath();
185
+ // Stamped before the bytes are read, so a write that lands between the two
186
+ // leaves the index looking stale rather than fresh: the next miss sees a
187
+ // changed stamp and re-reads.
188
+ const stamp = progressFileStamp();
189
+ let raw: string;
190
+ try {
191
+ raw = readFileSync(path, "utf-8");
192
+ } catch {
193
+ return refreshLinkIndex(
194
+ { progress: emptyActivationProgress(), readOnly: false },
195
+ stamp,
196
+ );
197
+ }
198
+
199
+ let document: unknown;
200
+ try {
201
+ document = JSON.parse(raw);
202
+ } catch (err) {
203
+ log.warn(
204
+ { err, path },
205
+ "Unreadable activation-progress.json; treating as empty",
206
+ );
207
+ return refreshLinkIndex(
208
+ { progress: emptyActivationProgress(), readOnly: false },
209
+ stamp,
210
+ );
211
+ }
212
+
213
+ // The version is read before the shape is, so a document this build cannot
214
+ // parse is still recognized as one it must not overwrite.
215
+ const version = isRecord(document) ? document.version : undefined;
216
+ if (typeof version === "number" && version > ACTIVATION_PROGRESS_VERSION) {
217
+ log.warn(
218
+ { path, version, supported: ACTIVATION_PROGRESS_VERSION },
219
+ "activation-progress.json was written by a newer build; serving it read-only",
220
+ );
221
+ return refreshLinkIndex(
222
+ { progress: parseForwardCompatible(document), readOnly: true },
223
+ stamp,
224
+ );
225
+ }
226
+
227
+ const parsed = ActivationProgressSchema.safeParse(document);
228
+ if (!parsed.success) {
229
+ log.warn(
230
+ { err: parsed.error, path },
231
+ "Unreadable activation-progress.json; treating as empty",
232
+ );
233
+ return refreshLinkIndex(
234
+ { progress: emptyActivationProgress(), readOnly: false },
235
+ stamp,
236
+ );
237
+ }
238
+ return refreshLinkIndex({ progress: parsed.data, readOnly: false }, stamp);
239
+ }
240
+
241
+ /**
242
+ * Read the progress snapshot. A missing file, unreadable file, or one that
243
+ * fails schema validation all degrade to the empty default rather than
244
+ * throwing: the checklist is an onboarding nicety, never a hard failure.
245
+ */
246
+ export function readActivationProgress(): ActivationProgress {
247
+ return readActivationSnapshot().progress;
248
+ }
249
+
250
+ /** Writes the document atomically, replacing whatever was at the path. */
251
+ function writeActivationProgress(progress: ActivationProgress): void {
252
+ const path = getActivationProgressPath();
253
+ mkdirSync(getDataDir(), { recursive: true });
254
+ const tmpPath = `${path}.tmp.${process.pid}`;
255
+ try {
256
+ writeFileSync(tmpPath, JSON.stringify(progress, null, 2), "utf-8");
257
+ renameSync(tmpPath, path);
258
+ } catch (err) {
259
+ // A temp file left behind by a failed write is never picked up again
260
+ // (the next attempt writes its own), so it would only accumulate.
261
+ rmSync(tmpPath, { force: true });
262
+ throw err;
263
+ }
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Linked-conversation index
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /**
271
+ * Conversation id → the `started` task it belongs to, for the conversations
272
+ * one exists for at all.
273
+ *
274
+ * The step hook runs on every tool call in every conversation, so the
275
+ * "nothing points at this conversation" answer has to be free: without this
276
+ * index each of those calls pays a `readFileSync`, a `JSON.parse`, and a
277
+ * schema validation for a snapshot it then discards. Consulting the index
278
+ * costs one map lookup, and at worst one `statSync`.
279
+ *
280
+ * Within one process every read and every mutation rebuilds the index from
281
+ * what it just saw, so the index cannot drift from its own writes. The file
282
+ * has more than one writer though: the daemon, the schedule worker, and the
283
+ * memory worker each load this module against the same workspace, so a link
284
+ * the daemon records is invisible to a worker that already cached its
285
+ * absence. `linkIndexStamp` closes that gap for the answer that would
286
+ * otherwise be wrong forever. It records the file's size and mtime as of the
287
+ * read the index was built from, and a *miss* is confirmed against a fresh
288
+ * `statSync` before it is believed. A hit still costs one map lookup and no
289
+ * syscall, and a miss costs one stat rather than a read, a parse, and a
290
+ * schema validation. An index rebuilt from a mutation carries no stamp,
291
+ * because nothing observable after the rename is provably that mutation's
292
+ * bytes; the first miss after a write therefore re-reads once and stamps the
293
+ * index from that read.
294
+ *
295
+ * `linkIndexPath` pins the index to the workspace it was built from: a
296
+ * process that switches workspaces (tests do) falls back to a read rather
297
+ * than answering from another workspace's links.
298
+ */
299
+ let linkIndex: Map<string, string> | null = null;
300
+ let linkIndexPath: string | null = null;
301
+ let linkIndexStamp: string | null = null;
302
+
303
+ /**
304
+ * Cheap identity of the progress file as it is on disk right now: its size
305
+ * and modification time, or `absent` when there is no file. Two writes
306
+ * inside one filesystem timestamp tick that also land on the same byte
307
+ * length are indistinguishable, which costs a worker one stale miss until
308
+ * the next write; every other rewrite changes the stamp.
309
+ */
310
+ function progressFileStamp(): string {
311
+ try {
312
+ const stats = statSync(getActivationProgressPath());
313
+ return `${stats.size}:${stats.mtimeMs}`;
314
+ } catch {
315
+ return "absent";
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Rebuild the index from `snapshot`, filed under the stamp of the bytes that
321
+ * snapshot came from, or under `null` when no stamp is provably tied to those
322
+ * bytes. The caller supplies the stamp because only the caller knows which
323
+ * bytes those were: a read stamps before it reads, and a write passes `null`
324
+ * because any stat it takes after its rename may describe another writer's
325
+ * file. A `null` stamp matches no stat, so the first miss against the index
326
+ * re-reads once and files the result under a real stamp; hits answer from the
327
+ * map with no syscall either way.
328
+ */
329
+ function refreshLinkIndex(
330
+ snapshot: ActivationSnapshot,
331
+ stamp: string | null,
332
+ ): ActivationSnapshot {
333
+ const next = new Map<string, string>();
334
+ for (const [taskId, task] of Object.entries(snapshot.progress.tasks)) {
335
+ if (task.status === "started") {
336
+ next.set(task.conversationId, taskId);
337
+ }
338
+ }
339
+ linkIndex = next;
340
+ linkIndexPath = getActivationProgressPath();
341
+ linkIndexStamp = stamp;
342
+ return snapshot;
343
+ }
344
+
345
+ /** Drop the index, so the next lookup rebuilds it from disk. */
346
+ function invalidateLinkIndex(): void {
347
+ linkIndex = null;
348
+ linkIndexPath = null;
349
+ linkIndexStamp = null;
350
+ }
351
+
352
+ /**
353
+ * The `started` task this conversation belongs to, or `null`, answered from
354
+ * the in-memory index. Reads the file when the index is cold, and when the
355
+ * index has no link for this conversation but the file has changed since the
356
+ * index was built (another process may have written the link).
357
+ */
358
+ function linkedTaskIdFor(conversationId: string): string | null {
359
+ if (linkIndex === null || linkIndexPath !== getActivationProgressPath()) {
360
+ readActivationSnapshot();
361
+ return linkIndex?.get(conversationId) ?? null;
362
+ }
363
+ const linked = linkIndex?.get(conversationId);
364
+ if (linked !== undefined) {
365
+ return linked;
366
+ }
367
+ if (progressFileStamp() === linkIndexStamp) {
368
+ return null;
369
+ }
370
+ readActivationSnapshot();
371
+ return linkIndex?.get(conversationId) ?? null;
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Interprocess lock
376
+ // ---------------------------------------------------------------------------
377
+
378
+ /** Suffix of the lock file that guards a read-modify-write of the snapshot. */
379
+ const ACTIVATION_PROGRESS_LOCK_SUFFIX = ".lock";
380
+
381
+ /** Suffix of the file that serializes reclaiming an abandoned lock. */
382
+ const ACTIVATION_PROGRESS_RECLAIM_SUFFIX = ".reclaim";
383
+
384
+ /**
385
+ * How long a mutation waits for another process to finish before it gives up
386
+ * and fails. Every holder does one small read and one rename, so a wait this
387
+ * long means the holder is wedged rather than busy.
388
+ */
389
+ const LOCK_WAIT_TIMEOUT_MS = 2_000;
390
+
391
+ /** Live wait bound. Only {@link setActivationLockTimingForTesting} moves it. */
392
+ let lockWaitTimeoutMs: number = LOCK_WAIT_TIMEOUT_MS;
393
+
394
+ /** Pause between attempts while another process holds the lock. */
395
+ const LOCK_RETRY_DELAY_MS = 15;
396
+
397
+ /**
398
+ * Age at which a lock file naming no holder this build can check is treated
399
+ * as abandoned. Generous on purpose: it is the only rule left once a writer
400
+ * has died between creating the file and stamping it, and it must also
401
+ * outlast any garbage file that happens to name a live process.
402
+ */
403
+ const LOCK_UNREADABLE_STALE_MS = 60_000;
404
+
405
+ /** Path of the lock file, beside the snapshot it guards. */
406
+ export function getActivationProgressLockPath(): string {
407
+ return `${getActivationProgressPath()}${ACTIVATION_PROGRESS_LOCK_SUFFIX}`;
408
+ }
409
+
410
+ /** Whether a process id still names a live process. */
411
+ function processIsAlive(pid: number): boolean {
412
+ try {
413
+ process.kill(pid, 0);
414
+ return true;
415
+ } catch (err) {
416
+ // EPERM is a process this user may not signal, which is still a process.
417
+ return (err as NodeJS.ErrnoException).code === "EPERM";
418
+ }
419
+ }
420
+
421
+ /**
422
+ * One attempt at the lock.
423
+ *
424
+ * `held` is the ordinary contended answer: another process created the file
425
+ * first. `unavailable` is everything else (an unwritable or occupied data
426
+ * directory), which the caller treats as "no lock to be had here" rather than
427
+ * as contention: the write that follows reports the real failure, and
428
+ * spinning for it would only delay that report.
429
+ */
430
+ function tryAcquireProgressLock(
431
+ lockPath: string,
432
+ ): "acquired" | "held" | "unavailable" {
433
+ try {
434
+ mkdirSync(getDataDir(), { recursive: true });
435
+ } catch {
436
+ return "unavailable";
437
+ }
438
+ let fd: number;
439
+ try {
440
+ fd = openSync(lockPath, "wx");
441
+ } catch (err) {
442
+ return (err as NodeJS.ErrnoException).code === "EEXIST"
443
+ ? "held"
444
+ : "unavailable";
445
+ }
446
+ try {
447
+ // The pid is what a waiter checks; the timestamp is for whoever reads a
448
+ // lock file that outlived its writer.
449
+ writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
450
+ } catch {
451
+ // The holder identity is what makes a takeover safe, not the lock itself:
452
+ // an empty lock file still excludes every other writer, and reads as
453
+ // abandoned only once it has aged out of
454
+ // {@link LOCK_UNREADABLE_STALE_MS}.
455
+ } finally {
456
+ closeSync(fd);
457
+ }
458
+ return "acquired";
459
+ }
460
+
461
+ /** How long ago the lock file was last written, or `null` when it is gone. */
462
+ function progressLockAgeMs(lockPath: string): number | null {
463
+ try {
464
+ return Date.now() - statSync(lockPath).mtimeMs;
465
+ } catch {
466
+ return null;
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Whether the lock on disk belongs to a writer that is never coming back.
472
+ *
473
+ * A lock that names a process is reclaimed only once that process is gone.
474
+ * Age is never the reason on its own: a holder can be alive and paused (a
475
+ * stopped debugger, a suspended host, a filesystem call that stalled) and
476
+ * still finish its rename, so taking its lock over would let two writers
477
+ * clobber each other, which is the one outcome this lock exists to prevent.
478
+ * A holder that keeps its lock past the wait bound is refused a takeover and
479
+ * fails the waiting mutation instead.
480
+ *
481
+ * A lock file this build cannot read names nobody to check, so it is the one
482
+ * case that falls back to age: empty or unparseable contents older than
483
+ * {@link LOCK_UNREADABLE_STALE_MS} are treated as abandoned.
484
+ *
485
+ * A lock that has vanished by the time it is read is not stale, it is free,
486
+ * and the next create attempt takes it.
487
+ */
488
+ function progressLockIsStale(lockPath: string): boolean {
489
+ let raw: string;
490
+ try {
491
+ raw = readFileSync(lockPath, "utf-8");
492
+ } catch {
493
+ return false;
494
+ }
495
+ let holder: unknown;
496
+ try {
497
+ holder = JSON.parse(raw);
498
+ } catch {
499
+ holder = null;
500
+ }
501
+ const pid = isRecord(holder) ? holder.pid : undefined;
502
+ if (typeof pid === "number" && Number.isInteger(pid) && pid > 0) {
503
+ return pid !== process.pid && !processIsAlive(pid);
504
+ }
505
+ const age = progressLockAgeMs(lockPath);
506
+ return age !== null && age >= LOCK_UNREADABLE_STALE_MS;
507
+ }
508
+
509
+ /**
510
+ * Remove a lock that {@link progressLockIsStale} judged abandoned, and only
511
+ * that lock.
512
+ *
513
+ * Two waiters can judge the same abandoned lock at once. If both simply
514
+ * unlinked the path, the slower unlink would land on whatever the faster
515
+ * waiter created after its own reclaim, and a third writer would then take a
516
+ * lock the faster one believes it holds. Reclaiming is therefore serialized
517
+ * through a second exclusive file, and the lock is judged again inside that
518
+ * section, where nothing else can remove it. The removal itself goes through
519
+ * a rename, so the instance taken off the path is provably the one that was
520
+ * judged: an inode that changed underneath the rename belongs to a live
521
+ * writer that took the path in between, and it is put back.
522
+ *
523
+ * The reclaim file is stamped and judged by the same rules as the lock, so a
524
+ * reclaimer that dies inside this section is aged out the same way.
525
+ */
526
+ function reclaimStaleProgressLock(lockPath: string): void {
527
+ const reclaimPath = `${lockPath}${ACTIVATION_PROGRESS_RECLAIM_SUFFIX}`;
528
+ if (tryAcquireProgressLock(reclaimPath) !== "acquired") {
529
+ if (progressLockIsStale(reclaimPath)) {
530
+ rmSync(reclaimPath, { force: true });
531
+ }
532
+ return;
533
+ }
534
+ try {
535
+ let judgedIno: number;
536
+ try {
537
+ judgedIno = statSync(lockPath).ino;
538
+ } catch {
539
+ return;
540
+ }
541
+ if (!progressLockIsStale(lockPath)) {
542
+ return;
543
+ }
544
+ const claimedPath = `${lockPath}.${process.pid}.${randomUUID()}`;
545
+ try {
546
+ renameSync(lockPath, claimedPath);
547
+ } catch {
548
+ return;
549
+ }
550
+ let claimedIno: number | null = null;
551
+ try {
552
+ claimedIno = statSync(claimedPath).ino;
553
+ } catch {
554
+ return;
555
+ }
556
+ if (claimedIno === judgedIno) {
557
+ rmSync(claimedPath, { force: true });
558
+ return;
559
+ }
560
+ restoreDisplacedProgressLock(claimedPath, lockPath);
561
+ } finally {
562
+ rmSync(reclaimPath, { force: true });
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Put a live lock back on its path after a reclaim moved it by mistake.
568
+ *
569
+ * `link` refuses to clobber, so a lock some other writer created while the
570
+ * path was empty survives; the displaced writer is the one that loses, and
571
+ * that is logged rather than silently doubled up.
572
+ */
573
+ function restoreDisplacedProgressLock(
574
+ claimedPath: string,
575
+ lockPath: string,
576
+ ): void {
577
+ try {
578
+ linkSync(claimedPath, lockPath);
579
+ } catch (err) {
580
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
581
+ log.warn(
582
+ { lockPath },
583
+ "A live activation progress lock was displaced during a reclaim and another writer took its place",
584
+ );
585
+ } else {
586
+ try {
587
+ renameSync(claimedPath, lockPath);
588
+ return;
589
+ } catch (renameErr) {
590
+ log.warn(
591
+ { err: renameErr, lockPath },
592
+ "Failed to restore a displaced activation progress lock",
593
+ );
594
+ }
595
+ }
596
+ }
597
+ rmSync(claimedPath, { force: true });
598
+ }
599
+
600
+ /**
601
+ * Take the lock for the whole read-modify-write below, so a sidecar worker
602
+ * and the daemon cannot both read one snapshot and rename over each other's
603
+ * update.
604
+ *
605
+ * Returns whether the lock is held, and only a caller holding it releases it.
606
+ * `false` is the answer for a data directory that cannot hold a lock at all:
607
+ * there is no other writer to exclude on a path nothing can be written to,
608
+ * and the write that follows reports the real failure.
609
+ *
610
+ * A holder that will not let go is the other case, and it throws. Waiting out
611
+ * {@link LOCK_WAIT_TIMEOUT_MS} and then writing anyway would read a snapshot
612
+ * that holder is about to rename over and silently drop one of the two
613
+ * updates, so the mutation fails with a 503 instead: the caller learns its
614
+ * write did not land, which is the whole contract the routes and the hooks
615
+ * are built on.
616
+ */
617
+ async function acquireProgressLock(): Promise<boolean> {
618
+ const lockPath = getActivationProgressLockPath();
619
+ const deadline = Date.now() + lockWaitTimeoutMs;
620
+ for (;;) {
621
+ const attempt = tryAcquireProgressLock(lockPath);
622
+ if (attempt === "acquired") {
623
+ return true;
624
+ }
625
+ if (attempt === "unavailable") {
626
+ return false;
627
+ }
628
+ if (progressLockIsStale(lockPath)) {
629
+ // Whoever left this behind is gone. Clear it and contend for it again
630
+ // on the next pass rather than assuming the removal was ours.
631
+ reclaimStaleProgressLock(lockPath);
632
+ }
633
+ if (Date.now() >= deadline) {
634
+ log.warn(
635
+ { lockPath },
636
+ "Timed out waiting for the activation progress lock; refusing the write",
637
+ );
638
+ throw new ServiceUnavailableError(
639
+ "Activation progress is being updated by another process; try again",
640
+ );
641
+ }
642
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS));
643
+ }
644
+ }
645
+
646
+ function releaseProgressLock(): void {
647
+ try {
648
+ rmSync(getActivationProgressLockPath(), { force: true });
649
+ } catch (err) {
650
+ log.warn({ err }, "Failed to release the activation progress lock");
651
+ }
652
+ }
653
+
654
+ // ---------------------------------------------------------------------------
655
+ // Serialized mutation
656
+ // ---------------------------------------------------------------------------
657
+
658
+ let writeChain: Promise<unknown> = Promise.resolve();
659
+
660
+ /**
661
+ * Run `mutate` against a freshly read snapshot, persisting and publishing
662
+ * only when it reports a change. Every mutation queues behind the previous
663
+ * one, so a route call and a turn hook can never read the same snapshot and
664
+ * clobber each other, and the whole read-mutate-write transaction runs under
665
+ * the interprocess lock so a sidecar worker cannot either.
666
+ *
667
+ * Rejects when the snapshot cannot be persisted, so a route answers 500
668
+ * rather than echoing state that only ever existed in memory. A lock another
669
+ * process holds past {@link LOCK_WAIT_TIMEOUT_MS} rejects with a 503, which
670
+ * is the same promise kept a different way: the write is refused rather than
671
+ * raced, and the client is told to try again. A snapshot a
672
+ * newer build wrote rejects too, with a 409: the document is left byte for
673
+ * byte alone, and the caller is told the write did not land rather than
674
+ * being handed a snapshot that looks like it did. That distinction is
675
+ * load-bearing for the client: it sends the task's prompt only once the
676
+ * link is recorded, and a silent 200 here would start a conversation no
677
+ * task points at.
678
+ *
679
+ * `precondition` is the seam for state this file does not own but the write
680
+ * depends on, such as whether the conversation being linked still exists. It
681
+ * runs inside the lock, against the same snapshot the mutation will change,
682
+ * so a check made before the wait cannot go out of date while the wait runs;
683
+ * throwing from it aborts the transaction with nothing written.
684
+ */
685
+ async function mutateActivationProgress(
686
+ mutate: (progress: ActivationProgress) => boolean,
687
+ options: {
688
+ originClientId?: string;
689
+ precondition?: () => void | Promise<void>;
690
+ } = {},
691
+ ): Promise<ActivationProgress> {
692
+ const { originClientId, precondition } = options;
693
+ const run = async (): Promise<ActivationProgress> => {
694
+ const locked = await acquireProgressLock();
695
+ try {
696
+ // Read inside the lock: a snapshot taken before it was held could
697
+ // already be the version another process is renaming over.
698
+ const snapshot = readActivationSnapshot();
699
+ const { progress } = snapshot;
700
+ if (snapshot.readOnly) {
701
+ log.warn(
702
+ { path: getActivationProgressPath() },
703
+ "Refusing activation progress write: the stored document is from a newer build",
704
+ );
705
+ throw new ConflictError(
706
+ "Activation progress was written by a newer version of this assistant and cannot be updated by this build",
707
+ );
708
+ }
709
+ await precondition?.();
710
+ if (!mutate(progress)) {
711
+ return progress;
712
+ }
713
+ try {
714
+ writeActivationProgress(progress);
715
+ } catch (err) {
716
+ // The index was built from what was on disk before the mutation, and
717
+ // the in-memory document has moved on from both. Drop it.
718
+ invalidateLinkIndex();
719
+ log.warn({ err }, "Failed to write activation-progress.json");
720
+ throw new InternalError("Failed to persist activation progress");
721
+ }
722
+ refreshLinkIndex(snapshot, null);
723
+ publishActivationProgressChanged(originClientId);
724
+ return progress;
725
+ } finally {
726
+ if (locked) {
727
+ releaseProgressLock();
728
+ }
729
+ }
730
+ };
731
+ const next = writeChain.then(run, run);
732
+ // Keep the chain alive even when a link rejects, so one failure cannot
733
+ // strand every later mutation.
734
+ writeChain = next.catch(() => {});
735
+ return next;
736
+ }
737
+
738
+ /** Freeze the list on the first write that names one. */
739
+ function applyListId(progress: ActivationProgress, listId?: string): boolean {
740
+ if (listId === undefined || progress.listId !== null) {
741
+ return false;
742
+ }
743
+ assertActivationId(listId, "listId");
744
+ progress.listId = listId;
745
+ return true;
746
+ }
747
+
748
+ /**
749
+ * The `started` task this conversation belongs to, or `null`. At most one
750
+ * exists: {@link startActivationTask} unlinks the others as it links.
751
+ */
752
+ function findLinkedTaskId(
753
+ progress: ActivationProgress,
754
+ conversationId: string,
755
+ ): string | null {
756
+ for (const [taskId, task] of Object.entries(progress.tasks)) {
757
+ if (task.conversationId === conversationId && task.status === "started") {
758
+ return taskId;
759
+ }
760
+ }
761
+ return null;
762
+ }
763
+
764
+ /**
765
+ * Drop every `started` task other than `taskId` that points at this
766
+ * conversation. Returns whether anything was dropped.
767
+ *
768
+ * The record is removed rather than kept with a dead link: a task whose
769
+ * conversation now belongs to another task has no progress left to report,
770
+ * and leaving it `started` is exactly the state the transition lookups
771
+ * would ignore. A `done` task keeps its record; its history is finished.
772
+ */
773
+ function unlinkOtherTasks(
774
+ progress: ActivationProgress,
775
+ conversationId: string,
776
+ taskId: string,
777
+ ): boolean {
778
+ let unlinked = false;
779
+ for (const [otherId, other] of Object.entries(progress.tasks)) {
780
+ if (
781
+ otherId !== taskId &&
782
+ other.status === "started" &&
783
+ other.conversationId === conversationId
784
+ ) {
785
+ delete progress.tasks[otherId];
786
+ unlinked = true;
787
+ }
788
+ }
789
+ return unlinked;
790
+ }
791
+
792
+ // ---------------------------------------------------------------------------
793
+ // Transitions
794
+ // ---------------------------------------------------------------------------
795
+
796
+ /**
797
+ * Link a task to the conversation it was launched into.
798
+ *
799
+ * Idempotent: re-starting a task in the same conversation changes nothing.
800
+ * A different conversation replaces the link (and restarts the counters)
801
+ * only while the task is not `done`. A finished task keeps its record, and
802
+ * keeps its hands off the conversation: a task still running there owns it.
803
+ *
804
+ * A conversation carries one task at a time. Launching a second task into
805
+ * a conversation another `started` task points at drops that stale record
806
+ * in the same write, so the step and completion lookups never resolve one
807
+ * task while silently ignoring another.
808
+ *
809
+ * `verify` answers whether the conversation is still there, and is asked
810
+ * inside the lock, immediately before the link is written. The caller owns
811
+ * the lookup because this file does not know what a conversation is; what
812
+ * it does know is that a link written against a row deleted while this
813
+ * mutation queued would leave the task stuck on Working with an action that
814
+ * opens nothing, so a `false` refuses the write with a 404.
815
+ */
816
+ export async function startActivationTask(params: {
817
+ taskId: string;
818
+ conversationId: string;
819
+ listId?: string;
820
+ originClientId?: string;
821
+ verify?: () => boolean | Promise<boolean>;
822
+ }): Promise<ActivationProgress> {
823
+ const { taskId, conversationId, listId, originClientId, verify } = params;
824
+ assertActivationId(taskId, "taskId");
825
+ if (conversationId.trim().length === 0) {
826
+ throw new BadRequestError("conversationId is required");
827
+ }
828
+ if (conversationId.length > ACTIVATION_CONVERSATION_ID_MAX_LENGTH) {
829
+ throw new BadRequestError(
830
+ `conversationId must be at most ${ACTIVATION_CONVERSATION_ID_MAX_LENGTH} characters`,
831
+ );
832
+ }
833
+ if (listId !== undefined) {
834
+ assertActivationId(listId, "listId");
835
+ }
836
+
837
+ return mutateActivationProgress(
838
+ (progress) => {
839
+ const listChanged = applyListId(progress, listId);
840
+ const existing = progress.tasks[taskId];
841
+ if (existing?.status === "done") {
842
+ return listChanged;
843
+ }
844
+ const unlinked = unlinkOtherTasks(progress, conversationId, taskId);
845
+ if (existing?.conversationId === conversationId) {
846
+ return listChanged || unlinked;
847
+ }
848
+ progress.tasks[taskId] = {
849
+ status: "started",
850
+ conversationId,
851
+ startedAt: new Date().toISOString(),
852
+ completedAt: null,
853
+ stepCount: 0,
854
+ artifacts: [],
855
+ };
856
+ return true;
857
+ },
858
+ {
859
+ ...(originClientId !== undefined ? { originClientId } : {}),
860
+ ...(verify !== undefined
861
+ ? {
862
+ precondition: async () => {
863
+ if (!(await verify())) {
864
+ throw new NotFoundError(
865
+ `Conversation ${conversationId} not found`,
866
+ );
867
+ }
868
+ },
869
+ }
870
+ : {}),
871
+ },
872
+ );
873
+ }
874
+
875
+ /**
876
+ * Record that the welcome modal (`modal`) or the celebration modal
877
+ * (`all-done`) was dismissed. The first dismissal timestamp is kept: the
878
+ * field records that the surface was seen, so reopening it from the pill
879
+ * and closing it again does not rewrite history.
880
+ */
881
+ export async function dismissActivation(params: {
882
+ kind: ActivationDismissKind;
883
+ listId?: string;
884
+ originClientId?: string;
885
+ }): Promise<ActivationProgress> {
886
+ const { kind, listId, originClientId } = params;
887
+ if (listId !== undefined) {
888
+ assertActivationId(listId, "listId");
889
+ }
890
+
891
+ return mutateActivationProgress(
892
+ (progress) => {
893
+ let changed = applyListId(progress, listId);
894
+ const field = kind === "modal" ? "modalDismissedAt" : "allDoneShownAt";
895
+ if (progress[field] === null) {
896
+ progress[field] = new Date().toISOString();
897
+ changed = true;
898
+ }
899
+ return changed;
900
+ },
901
+ originClientId === undefined ? {} : { originClientId },
902
+ );
903
+ }
904
+
905
+ // ---------------------------------------------------------------------------
906
+ // Deleted conversations
907
+ // ---------------------------------------------------------------------------
908
+
909
+ /**
910
+ * Drop what the checklist recorded about conversations that no longer exist.
911
+ *
912
+ * A `started` task whose conversation is gone has nothing left to report and
913
+ * nowhere left to send the user, so its record is removed: the row returns to
914
+ * Todo and the task can be launched again.
915
+ *
916
+ * A `done` task keeps its record. The work it describes happened, and the
917
+ * step count and artifacts on it are the user's history whether or not the
918
+ * conversation survived. Only the dead link is cleared, the same thing the
919
+ * home feed does to a "Go to Thread" target it can no longer open, so the
920
+ * finished row stops offering to open a conversation that is not there.
921
+ */
922
+ function forgetLinkedConversations(
923
+ progress: ActivationProgress,
924
+ matches: (conversationId: string) => boolean,
925
+ ): boolean {
926
+ let changed = false;
927
+ for (const [taskId, task] of Object.entries(progress.tasks)) {
928
+ if (!matches(task.conversationId)) {
929
+ continue;
930
+ }
931
+ if (task.status === "started") {
932
+ delete progress.tasks[taskId];
933
+ changed = true;
934
+ } else if (task.conversationId.length > 0) {
935
+ task.conversationId = "";
936
+ changed = true;
937
+ }
938
+ }
939
+ return changed;
940
+ }
941
+
942
+ /**
943
+ * Whether the checklist has ever written anything for this workspace. A
944
+ * deletion in an assistant that never showed the checklist must not create
945
+ * the snapshot (or its lock) just to find nothing to forget.
946
+ */
947
+ function activationProgressFileExists(): boolean {
948
+ return progressFileStamp() !== "absent";
949
+ }
950
+
951
+ /**
952
+ * Release a deleted conversation from the checklist. See
953
+ * {@link forgetLinkedConversations} for what survives.
954
+ */
955
+ export async function forgetActivationConversation(
956
+ conversationId: string,
957
+ ): Promise<void> {
958
+ if (conversationId.length === 0 || !activationProgressFileExists()) {
959
+ return;
960
+ }
961
+ await mutateActivationProgress((progress) =>
962
+ forgetLinkedConversations(progress, (id) => id === conversationId),
963
+ );
964
+ }
965
+
966
+ /**
967
+ * Release every conversation from the checklist, for the clear-all wipe that
968
+ * removes all of them at once.
969
+ */
970
+ export async function forgetAllActivationConversations(): Promise<void> {
971
+ if (!activationProgressFileExists()) {
972
+ return;
973
+ }
974
+ await mutateActivationProgress((progress) =>
975
+ forgetLinkedConversations(progress, () => true),
976
+ );
977
+ }
978
+
979
+ // ---------------------------------------------------------------------------
980
+ // Step counting
981
+ // ---------------------------------------------------------------------------
982
+
983
+ /**
984
+ * Attempts a restored delta gets before the count is abandoned. A turn can
985
+ * end without another tool call, so nothing else would drive the retry; the
986
+ * bound is what keeps a permanently unwritable file from arming a timer per
987
+ * window forever.
988
+ */
989
+ const MAX_STEP_FLUSH_ATTEMPTS = 3;
990
+
991
+ /** Tool calls seen since the last flush, keyed by conversation. */
992
+ const pendingStepBumps = new Map<string, number>();
993
+ const stepBumpTimers = new Map<string, ReturnType<typeof setTimeout>>();
994
+ const lastStepFlushAt = new Map<string, number>();
995
+ /** Consecutive failed flushes per conversation, cleared by the first success. */
996
+ const stepFlushAttempts = new Map<string, number>();
997
+
998
+ function addPendingStepBumps(conversationId: string, delta: number): void {
999
+ pendingStepBumps.set(
1000
+ conversationId,
1001
+ (pendingStepBumps.get(conversationId) ?? 0) + delta,
1002
+ );
1003
+ }
1004
+
1005
+ /** Arm the trailing flush timer, unless one is already armed. */
1006
+ function armStepFlushTimer(conversationId: string, delayMs: number): void {
1007
+ if (stepBumpTimers.has(conversationId)) {
1008
+ return;
1009
+ }
1010
+ const timer = setTimeout(() => {
1011
+ // The timer owns the only reference to this flush, so it swallows the
1012
+ // rejection the awaiting callers would otherwise have handled.
1013
+ void flushStepBumps(conversationId).catch(() => {});
1014
+ }, delayMs);
1015
+ timer.unref?.();
1016
+ stepBumpTimers.set(conversationId, timer);
1017
+ }
1018
+
1019
+ /**
1020
+ * Re-arm a flush for a delta a failed write put back. Bounded: once a
1021
+ * conversation has burned its attempts the count is dropped, because a
1022
+ * completing turn re-establishes `stepCount` from its own tool-call total
1023
+ * anyway and an unbounded retry would outlive the conversation.
1024
+ */
1025
+ function retryStepFlush(conversationId: string): void {
1026
+ const attempts = (stepFlushAttempts.get(conversationId) ?? 0) + 1;
1027
+ if (attempts >= MAX_STEP_FLUSH_ATTEMPTS) {
1028
+ log.warn(
1029
+ { conversationId, attempts },
1030
+ "Giving up on persisting activation step counts after repeated write failures",
1031
+ );
1032
+ pendingStepBumps.delete(conversationId);
1033
+ stepFlushAttempts.delete(conversationId);
1034
+ return;
1035
+ }
1036
+ stepFlushAttempts.set(conversationId, attempts);
1037
+ armStepFlushTimer(conversationId, stepThrottleMs);
1038
+ }
1039
+
1040
+ async function flushStepBumps(conversationId: string): Promise<void> {
1041
+ const timer = stepBumpTimers.get(conversationId);
1042
+ if (timer) {
1043
+ clearTimeout(timer);
1044
+ stepBumpTimers.delete(conversationId);
1045
+ }
1046
+ const delta = pendingStepBumps.get(conversationId) ?? 0;
1047
+ pendingStepBumps.delete(conversationId);
1048
+ if (delta === 0) {
1049
+ return;
1050
+ }
1051
+ lastStepFlushAt.set(conversationId, Date.now());
1052
+ try {
1053
+ await mutateActivationProgress((progress) => {
1054
+ const taskId = findLinkedTaskId(progress, conversationId);
1055
+ if (!taskId) {
1056
+ return false;
1057
+ }
1058
+ const task = progress.tasks[taskId];
1059
+ task.stepCount = (task.stepCount ?? 0) + delta;
1060
+ return true;
1061
+ });
1062
+ stepFlushAttempts.delete(conversationId);
1063
+ } catch (err) {
1064
+ if (err instanceof ConflictError) {
1065
+ // The stored document belongs to a newer build, so no later flush can
1066
+ // land either. Drop the count rather than retrying a write this build
1067
+ // is not allowed to make.
1068
+ stepFlushAttempts.delete(conversationId);
1069
+ throw err;
1070
+ }
1071
+ // The write did not land, whether the file refused it or another process
1072
+ // held the lock past its wait, so the tool calls it carried are still
1073
+ // uncounted. Put them back (behind anything that arrived meanwhile) and
1074
+ // arm a bounded retry: a turn can end here, with no later tool call to
1075
+ // drive the flush.
1076
+ addPendingStepBumps(conversationId, delta);
1077
+ retryStepFlush(conversationId);
1078
+ throw err;
1079
+ }
1080
+ }
1081
+
1082
+ /**
1083
+ * Count one tool call against the task linked to this conversation.
1084
+ *
1085
+ * A no-op for conversations that no `started` task points at, and free for
1086
+ * them: the link is answered from the in-memory index, so a conversation
1087
+ * outside the checklist never touches the progress file. Flushes are
1088
+ * throttled to one per {@link ACTIVATION_STEP_BUMP_THROTTLE_MS} per
1089
+ * conversation, with a trailing flush so the final count still lands.
1090
+ */
1091
+ export async function bumpActivationStepCount(
1092
+ conversationId: string,
1093
+ ): Promise<void> {
1094
+ // A pending bump already proved the link, so a burst skips the lookup
1095
+ // entirely. `flushStepBumps` re-checks against the snapshot it writes,
1096
+ // so a link that disappears mid-burst degrades to a no-op flush.
1097
+ if (
1098
+ !pendingStepBumps.has(conversationId) &&
1099
+ !linkedTaskIdFor(conversationId)
1100
+ ) {
1101
+ return;
1102
+ }
1103
+ addPendingStepBumps(conversationId, 1);
1104
+
1105
+ const now = Date.now();
1106
+ const lastFlush = lastStepFlushAt.get(conversationId) ?? 0;
1107
+ const elapsed = now - lastFlush;
1108
+ if (elapsed >= stepThrottleMs) {
1109
+ await flushStepBumps(conversationId);
1110
+ return;
1111
+ }
1112
+ armStepFlushTimer(conversationId, stepThrottleMs - elapsed);
1113
+ }
1114
+
1115
+ // ---------------------------------------------------------------------------
1116
+ // Completion
1117
+ // ---------------------------------------------------------------------------
1118
+
1119
+ /**
1120
+ * How long a completion waits before its single retry. Long enough for a
1121
+ * holder that was merely slow to finish and let its lock go, short enough
1122
+ * that the row leaves Working while the user is still watching it.
1123
+ */
1124
+ const COMPLETION_RETRY_DELAY_MS = 1_000;
1125
+
1126
+ /** Live retry delay. Only {@link setActivationLockTimingForTesting} moves it. */
1127
+ let completionRetryMs: number = COMPLETION_RETRY_DELAY_MS;
1128
+
1129
+ /** Armed completion retries, keyed by conversation. */
1130
+ const completionRetryTimers = new Map<string, ReturnType<typeof setTimeout>>();
1131
+
1132
+ /**
1133
+ * Try one completion again, once, after {@link COMPLETION_RETRY_DELAY_MS}.
1134
+ *
1135
+ * Bounded on purpose: a second failure is a lock nothing here can break, and
1136
+ * the checklist row is worth one more attempt rather than a timer per
1137
+ * finished turn for the life of the process. The timer is unref'd so a
1138
+ * pending retry never holds the process open, and one conversation arms at
1139
+ * most one, so a second terminal turn cannot stack them.
1140
+ */
1141
+ function armCompletionRetry(
1142
+ conversationId: string,
1143
+ complete: () => Promise<unknown>,
1144
+ ): void {
1145
+ if (completionRetryTimers.has(conversationId)) {
1146
+ return;
1147
+ }
1148
+ const timer = setTimeout(() => {
1149
+ completionRetryTimers.delete(conversationId);
1150
+ void complete().catch((err: unknown) => {
1151
+ log.warn(
1152
+ { err, conversationId },
1153
+ "Giving up on marking an activation task done: the progress file stayed locked",
1154
+ );
1155
+ });
1156
+ }, completionRetryMs);
1157
+ timer.unref?.();
1158
+ completionRetryTimers.set(conversationId, timer);
1159
+ }
1160
+
1161
+ function normalizeArtifacts(
1162
+ artifacts: readonly ActivationArtifact[],
1163
+ ): ActivationArtifact[] {
1164
+ const seen = new Set<string>();
1165
+ const result: ActivationArtifact[] = [];
1166
+ for (const artifact of artifacts) {
1167
+ const parsed = ActivationArtifactSchema.safeParse(artifact);
1168
+ if (!parsed.success || parsed.data.workspacePath.length === 0) {
1169
+ continue;
1170
+ }
1171
+ if (seen.has(parsed.data.workspacePath)) {
1172
+ continue;
1173
+ }
1174
+ seen.add(parsed.data.workspacePath);
1175
+ result.push(parsed.data);
1176
+ if (result.length >= MAX_ARTIFACTS_PER_TASK) {
1177
+ break;
1178
+ }
1179
+ }
1180
+ return result;
1181
+ }
1182
+
1183
+ /**
1184
+ * Mark the task linked to this conversation `done`.
1185
+ *
1186
+ * A terminal turn finishes the task unless it ended waiting on the user:
1187
+ * an open question card or an interactive surface still awaiting an action
1188
+ * means the assistant handed the turn back rather than delivering, so the
1189
+ * task stays `started` and the answer's turn finishes it. Everything else
1190
+ * completes, including a turn that answered entirely in prose with no tool
1191
+ * call and no attached file, which is a perfectly ordinary way to finish a
1192
+ * checklist task.
1193
+ *
1194
+ * The signal is structural, so a clarifying question the assistant asks in
1195
+ * plain prose rather than through a question card still reads as a
1196
+ * completed turn. That is a known v1 limitation: telling the two apart is a
1197
+ * judgement call, and the fix is an assistant-judged outcome at the turn
1198
+ * boundary rather than a heuristic here.
1199
+ *
1200
+ * A no-op for unlinked conversations and idempotent for linked ones: a
1201
+ * second terminal turn in the same conversation finds no `started` task
1202
+ * and changes nothing. `stepCount` never moves backwards, so the number
1203
+ * the user watched climb is the number they end up with.
1204
+ */
1205
+ export async function markActivationTurnComplete(params: {
1206
+ conversationId: string;
1207
+ toolCallCount: number;
1208
+ artifacts: readonly ActivationArtifact[];
1209
+ endedAwaitingUser: boolean;
1210
+ }): Promise<void> {
1211
+ const { conversationId, toolCallCount, artifacts, endedAwaitingUser } =
1212
+ params;
1213
+ if (
1214
+ !pendingStepBumps.has(conversationId) &&
1215
+ !linkedTaskIdFor(conversationId)
1216
+ ) {
1217
+ return;
1218
+ }
1219
+ // A failed flush must not strand the row on Working. The completion
1220
+ // mutation re-establishes `stepCount` from `toolCallCount` via `Math.max`,
1221
+ // and the flush has already re-queued its delta for a bounded retry.
1222
+ await flushStepBumps(conversationId).catch(() => {});
1223
+ if (endedAwaitingUser) {
1224
+ return;
1225
+ }
1226
+ const complete = (): Promise<ActivationProgress> =>
1227
+ mutateActivationProgress((progress) => {
1228
+ const taskId = findLinkedTaskId(progress, conversationId);
1229
+ if (!taskId) {
1230
+ return false;
1231
+ }
1232
+ const task = progress.tasks[taskId];
1233
+ task.status = "done";
1234
+ task.completedAt = new Date().toISOString();
1235
+ task.stepCount = Math.max(
1236
+ task.stepCount ?? 0,
1237
+ Math.max(0, toolCallCount),
1238
+ );
1239
+ task.artifacts = normalizeArtifacts(artifacts);
1240
+ return true;
1241
+ });
1242
+ try {
1243
+ await complete();
1244
+ } catch (err) {
1245
+ if (!(err instanceof ServiceUnavailableError)) {
1246
+ throw err;
1247
+ }
1248
+ // Another process held the lock for the whole wait. Nothing else will
1249
+ // drive this task to `done`: the turn is over, so there is no later tool
1250
+ // call and no later terminal turn to try again from.
1251
+ log.warn(
1252
+ { err, conversationId },
1253
+ "Activation progress was busy when a turn finished; retrying the completion once",
1254
+ );
1255
+ armCompletionRetry(conversationId, complete);
1256
+ }
1257
+ }
1258
+
1259
+ /**
1260
+ * Drop the in-process step-bump throttle state, any armed completion retry,
1261
+ * and the linked-conversation index, optionally shortening the throttle
1262
+ * window. Test-only seam:
1263
+ * production code lets the trailing timers run at
1264
+ * {@link ACTIVATION_STEP_BUMP_THROTTLE_MS}.
1265
+ */
1266
+ export function resetActivationStepThrottleForTesting(
1267
+ overrideMs?: number,
1268
+ ): void {
1269
+ for (const timer of stepBumpTimers.values()) {
1270
+ clearTimeout(timer);
1271
+ }
1272
+ stepBumpTimers.clear();
1273
+ pendingStepBumps.clear();
1274
+ lastStepFlushAt.clear();
1275
+ stepFlushAttempts.clear();
1276
+ for (const timer of completionRetryTimers.values()) {
1277
+ clearTimeout(timer);
1278
+ }
1279
+ completionRetryTimers.clear();
1280
+ invalidateLinkIndex();
1281
+ stepThrottleMs = overrideMs ?? ACTIVATION_STEP_BUMP_THROTTLE_MS;
1282
+ }
1283
+
1284
+ /**
1285
+ * Shorten how long a mutation waits for a lock another process holds, and how
1286
+ * long a busy completion waits before its one retry, so a test can exercise
1287
+ * the contended paths in milliseconds. Test-only seam: production waits
1288
+ * {@link LOCK_WAIT_TIMEOUT_MS} and {@link COMPLETION_RETRY_DELAY_MS}.
1289
+ */
1290
+ export function setActivationLockTimingForTesting(overrides?: {
1291
+ waitMs?: number;
1292
+ retryMs?: number;
1293
+ }): void {
1294
+ lockWaitTimeoutMs = overrides?.waitMs ?? LOCK_WAIT_TIMEOUT_MS;
1295
+ completionRetryMs = overrides?.retryMs ?? COMPLETION_RETRY_DELAY_MS;
1296
+ }