@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.
- package/Dockerfile +5 -0
- package/docs/architecture/turn-actor.md +9 -0
- package/knip.json +1 -0
- package/node_modules/@vellumai/app-icons/package.json +18 -0
- package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
- package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
- package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/openapi.yaml +628 -0
- package/package.json +3 -1
- package/scripts/smoke-container-workspace-dependencies.ts +19 -0
- package/src/__tests__/app-builder-icon-names.test.ts +29 -0
- package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop.test.ts +2 -0
- package/src/__tests__/conversation-attachments.test.ts +142 -0
- package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
- package/src/__tests__/conversation-event-sink.test.ts +50 -0
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
- package/src/__tests__/conversation-queue.test.ts +297 -0
- package/src/__tests__/credential-routes.test.ts +185 -6
- package/src/__tests__/drain-kick-guard.test.ts +2 -0
- package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
- package/src/__tests__/messaging-send-tool.test.ts +42 -0
- package/src/__tests__/oauth-commands-routes.test.ts +47 -0
- package/src/__tests__/subagent-manager-notify.test.ts +25 -4
- package/src/activation/progress-store.test.ts +1564 -0
- package/src/activation/progress-store.ts +1296 -0
- package/src/activation/turn-hooks.test.ts +246 -0
- package/src/activation/turn-hooks.ts +126 -0
- package/src/api/events/subagent-status-changed.ts +7 -5
- package/src/api/responses/activation.ts +136 -0
- package/src/apps/app-store.ts +8 -0
- package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
- package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
- package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
- package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
- package/src/cli/commands/channels/index.help.ts +70 -18
- package/src/cli/commands/channels/index.ts +17 -6
- package/src/cli/commands/channels/request.ts +66 -0
- package/src/cli/commands/oauth/request.test.ts +2 -0
- package/src/cli/commands/oauth/request.ts +227 -186
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
- package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
- package/src/config/feature-flag-registry.json +35 -5
- package/src/daemon/assistant-attachments.ts +11 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
- package/src/daemon/conversation-agent-loop.ts +71 -3
- package/src/daemon/conversation-attachments.ts +42 -1
- package/src/daemon/conversation-event-sink.ts +25 -0
- package/src/daemon/conversation-process.ts +69 -21
- package/src/daemon/conversation-store.ts +4 -3
- package/src/daemon/conversation-surfaces.ts +19 -4
- package/src/daemon/message-types/sync.ts +2 -0
- package/src/ipc/assistant-server.ts +2 -0
- package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
- package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
- package/src/notifications/AGENTS.md +1 -1
- package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
- package/src/notifications/conversation-pairing.ts +47 -5
- package/src/notifications/delivered-post-record.ts +3 -0
- package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
- package/src/persistence/conversation-crud.ts +39 -0
- package/src/persistence/delivery-crud.ts +13 -0
- package/src/plugins/AGENTS.md +1 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
- package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
- package/src/runtime/routes/activation-routes.test.ts +415 -0
- package/src/runtime/routes/activation-routes.ts +172 -0
- package/src/runtime/routes/credential-routes.ts +46 -3
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/oauth-commands-routes.ts +21 -8
- package/src/runtime/routes/platform-managed-credentials.ts +48 -0
- package/src/runtime/routes/secret-routes.ts +3 -12
- package/src/runtime/routes/user-route-resolution.ts +21 -0
- package/src/runtime/routes/user-routes.ts +112 -9
- package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
- package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
- package/src/runtime/sync/resource-sync-events.ts +23 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
- package/src/runtime/sync/worker-daemon-notify.ts +39 -1
- 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
|
+
}
|