@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,1564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the activation progress store.
|
|
3
|
+
*
|
|
4
|
+
* Covers the on-disk round trip, degraded reads, every transition's
|
|
5
|
+
* idempotence and freeze rules, and the step-bump throttle (including its
|
|
6
|
+
* trailing flush).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
chmodSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
utimesSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import * as nodeFs from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
afterEach,
|
|
25
|
+
beforeEach,
|
|
26
|
+
describe,
|
|
27
|
+
expect,
|
|
28
|
+
mock,
|
|
29
|
+
spyOn,
|
|
30
|
+
test,
|
|
31
|
+
} from "bun:test";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The real implementation, captured before any spy replaces it, so a stub
|
|
35
|
+
* that wants the genuine bytes does not call itself.
|
|
36
|
+
*/
|
|
37
|
+
const realReadFileSync = nodeFs.readFileSync;
|
|
38
|
+
|
|
39
|
+
import type { ActivationProgress } from "../api/responses/activation.js";
|
|
40
|
+
import { _resetStreamStateForTesting } from "../runtime/assistant-stream-state.js";
|
|
41
|
+
|
|
42
|
+
// Capture invalidations without standing up SSE infrastructure. Mocking the
|
|
43
|
+
// publisher (one exported function) rather than the event hub keeps the mock
|
|
44
|
+
// harmless for any sibling file bun runs in the same process.
|
|
45
|
+
const publishedTags: string[][] = [];
|
|
46
|
+
const publishedOrigins: (string | undefined)[] = [];
|
|
47
|
+
|
|
48
|
+
mock.module("../runtime/sync/sync-publisher.js", () => ({
|
|
49
|
+
publishSyncInvalidation: async (tags: string[], originClientId?: string) => {
|
|
50
|
+
publishedTags.push(tags);
|
|
51
|
+
publishedOrigins.push(originClientId);
|
|
52
|
+
return { type: "sync_changed", tags };
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const {
|
|
57
|
+
ACTIVATION_PROGRESS_FILENAME,
|
|
58
|
+
bumpActivationStepCount,
|
|
59
|
+
dismissActivation,
|
|
60
|
+
forgetActivationConversation,
|
|
61
|
+
forgetAllActivationConversations,
|
|
62
|
+
getActivationProgressLockPath,
|
|
63
|
+
getActivationProgressPath,
|
|
64
|
+
markActivationTurnComplete,
|
|
65
|
+
readActivationProgress,
|
|
66
|
+
resetActivationStepThrottleForTesting,
|
|
67
|
+
setActivationLockTimingForTesting,
|
|
68
|
+
startActivationTask,
|
|
69
|
+
} = await import("./progress-store.js");
|
|
70
|
+
const { RouteError } = await import("../runtime/routes/errors.js");
|
|
71
|
+
|
|
72
|
+
const THROTTLE_MS = 40;
|
|
73
|
+
|
|
74
|
+
let workspaceDir: string;
|
|
75
|
+
let origWorkspaceDir: string | undefined;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
workspaceDir = mkdtempSync(join(tmpdir(), "vellum-activation-"));
|
|
79
|
+
origWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
|
|
80
|
+
process.env.VELLUM_WORKSPACE_DIR = workspaceDir;
|
|
81
|
+
publishedTags.length = 0;
|
|
82
|
+
publishedOrigins.length = 0;
|
|
83
|
+
// A sibling test file may have left this process pretending to be a
|
|
84
|
+
// sidecar worker, which routes publishes to the daemon instead of the hub.
|
|
85
|
+
_resetStreamStateForTesting();
|
|
86
|
+
resetActivationStepThrottleForTesting(THROTTLE_MS);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
resetActivationStepThrottleForTesting();
|
|
91
|
+
setActivationLockTimingForTesting();
|
|
92
|
+
if (origWorkspaceDir === undefined) {
|
|
93
|
+
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
94
|
+
} else {
|
|
95
|
+
process.env.VELLUM_WORKSPACE_DIR = origWorkspaceDir;
|
|
96
|
+
}
|
|
97
|
+
rmSync(workspaceDir, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
function writeRawProgress(contents: string): void {
|
|
101
|
+
mkdirSync(join(workspaceDir, "data"), { recursive: true });
|
|
102
|
+
writeFileSync(getActivationProgressPath(), contents, "utf-8");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Make every write fail by occupying the data directory's path with a
|
|
107
|
+
* regular file, so `mkdirSync` cannot create it.
|
|
108
|
+
*/
|
|
109
|
+
function breakDataDir(): void {
|
|
110
|
+
writeFileSync(join(workspaceDir, "data"), "not a directory", "utf-8");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read the file directly, without going through the store, so an assertion
|
|
115
|
+
* cannot warm the in-memory link index the test is measuring.
|
|
116
|
+
*/
|
|
117
|
+
function readRawProgress(): ActivationProgress {
|
|
118
|
+
return JSON.parse(
|
|
119
|
+
readFileSync(getActivationProgressPath(), "utf-8"),
|
|
120
|
+
) as ActivationProgress;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Rewrite the file behind the store's back. */
|
|
124
|
+
function writeProgressBehindStore(progress: ActivationProgress): void {
|
|
125
|
+
writeRawProgress(JSON.stringify(progress, null, 2));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A `started` record, as a writer other than the store would leave one. */
|
|
129
|
+
function startedTask(conversationId: string) {
|
|
130
|
+
return {
|
|
131
|
+
status: "started" as const,
|
|
132
|
+
conversationId,
|
|
133
|
+
startedAt: new Date().toISOString(),
|
|
134
|
+
completedAt: null,
|
|
135
|
+
stepCount: 0,
|
|
136
|
+
artifacts: [],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Hold the progress lock the way another process would: a live holder that
|
|
142
|
+
* has just taken it, so a mutation has to wait rather than take it over.
|
|
143
|
+
*/
|
|
144
|
+
function holdProgressLock(
|
|
145
|
+
atMs: number = Date.now(),
|
|
146
|
+
pid = process.pid,
|
|
147
|
+
): string {
|
|
148
|
+
mkdirSync(join(workspaceDir, "data"), { recursive: true });
|
|
149
|
+
const lockPath = getActivationProgressLockPath();
|
|
150
|
+
writeFileSync(lockPath, JSON.stringify({ pid, at: atMs }), "utf-8");
|
|
151
|
+
return lockPath;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A live process that is not this one, for the takeover rules that turn on
|
|
156
|
+
* the holder's identity. The parent of the test runner outlives the test.
|
|
157
|
+
*/
|
|
158
|
+
const OTHER_LIVE_PID = process.ppid > 1 ? process.ppid : process.pid;
|
|
159
|
+
|
|
160
|
+
/** Above every pid_max, so it names no process on any host. */
|
|
161
|
+
const DEAD_PID = 0x40000000;
|
|
162
|
+
|
|
163
|
+
/** Backdate the lock file, the only signal a holderless lock is judged on. */
|
|
164
|
+
function ageLockFile(lockPath: string, ageMs: number): void {
|
|
165
|
+
const when = new Date(Date.now() - ageMs);
|
|
166
|
+
utimesSync(lockPath, when, when);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function syncPublishCount(): number {
|
|
170
|
+
return publishedTags.filter((tags) => tags.includes("activation:progress"))
|
|
171
|
+
.length;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function waitFor(predicate: () => boolean): Promise<void> {
|
|
175
|
+
const deadline = Date.now() + 2_000;
|
|
176
|
+
while (Date.now() < deadline) {
|
|
177
|
+
if (predicate()) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await Bun.sleep(5);
|
|
181
|
+
}
|
|
182
|
+
throw new Error("Timed out waiting for condition");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
describe("activation progress store", () => {
|
|
186
|
+
describe("readActivationProgress", () => {
|
|
187
|
+
test("resolves under <workspace>/data", () => {
|
|
188
|
+
expect(getActivationProgressPath()).toBe(
|
|
189
|
+
join(workspaceDir, "data", ACTIVATION_PROGRESS_FILENAME),
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("missing file reads as the empty default", () => {
|
|
194
|
+
expect(readActivationProgress()).toEqual({
|
|
195
|
+
version: 1,
|
|
196
|
+
listId: null,
|
|
197
|
+
modalDismissedAt: null,
|
|
198
|
+
allDoneShownAt: null,
|
|
199
|
+
tasks: {},
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("corrupt file reads as the empty default", () => {
|
|
204
|
+
writeRawProgress("{ not json");
|
|
205
|
+
expect(readActivationProgress().tasks).toEqual({});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("schema-invalid file reads as the empty default", () => {
|
|
209
|
+
writeRawProgress(JSON.stringify({ version: 99, tasks: "nope" }));
|
|
210
|
+
expect(readActivationProgress().tasks).toEqual({});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("startActivationTask", () => {
|
|
215
|
+
test("persists the link and publishes an invalidation", async () => {
|
|
216
|
+
await startActivationTask({
|
|
217
|
+
taskId: "draft-email",
|
|
218
|
+
conversationId: "conv-1",
|
|
219
|
+
listId: "smb",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const stored = readActivationProgress();
|
|
223
|
+
expect(stored.listId).toBe("smb");
|
|
224
|
+
expect(stored.tasks["draft-email"]).toMatchObject({
|
|
225
|
+
status: "started",
|
|
226
|
+
conversationId: "conv-1",
|
|
227
|
+
completedAt: null,
|
|
228
|
+
stepCount: 0,
|
|
229
|
+
artifacts: [],
|
|
230
|
+
});
|
|
231
|
+
expect(syncPublishCount()).toBe(1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("is idempotent for the same conversation", async () => {
|
|
235
|
+
await startActivationTask({
|
|
236
|
+
taskId: "draft-email",
|
|
237
|
+
conversationId: "conv-1",
|
|
238
|
+
});
|
|
239
|
+
const first = readActivationProgress().tasks["draft-email"].startedAt;
|
|
240
|
+
publishedTags.length = 0;
|
|
241
|
+
|
|
242
|
+
await startActivationTask({
|
|
243
|
+
taskId: "draft-email",
|
|
244
|
+
conversationId: "conv-1",
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(readActivationProgress().tasks["draft-email"].startedAt).toBe(
|
|
248
|
+
first,
|
|
249
|
+
);
|
|
250
|
+
expect(syncPublishCount()).toBe(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("a different conversation replaces the link while not done", async () => {
|
|
254
|
+
await startActivationTask({
|
|
255
|
+
taskId: "draft-email",
|
|
256
|
+
conversationId: "conv-1",
|
|
257
|
+
});
|
|
258
|
+
await startActivationTask({
|
|
259
|
+
taskId: "draft-email",
|
|
260
|
+
conversationId: "conv-2",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(readActivationProgress().tasks["draft-email"]).toMatchObject({
|
|
264
|
+
conversationId: "conv-2",
|
|
265
|
+
stepCount: 0,
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("a done task keeps its conversation link", async () => {
|
|
270
|
+
await startActivationTask({
|
|
271
|
+
taskId: "draft-email",
|
|
272
|
+
conversationId: "conv-1",
|
|
273
|
+
});
|
|
274
|
+
await markActivationTurnComplete({
|
|
275
|
+
conversationId: "conv-1",
|
|
276
|
+
toolCallCount: 2,
|
|
277
|
+
endedAwaitingUser: false,
|
|
278
|
+
artifacts: [],
|
|
279
|
+
});
|
|
280
|
+
await startActivationTask({
|
|
281
|
+
taskId: "draft-email",
|
|
282
|
+
conversationId: "conv-2",
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(readActivationProgress().tasks["draft-email"]).toMatchObject({
|
|
286
|
+
status: "done",
|
|
287
|
+
conversationId: "conv-1",
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("listId freezes on the first write", async () => {
|
|
292
|
+
await startActivationTask({
|
|
293
|
+
taskId: "task-a",
|
|
294
|
+
conversationId: "conv-1",
|
|
295
|
+
listId: "smb",
|
|
296
|
+
});
|
|
297
|
+
await startActivationTask({
|
|
298
|
+
taskId: "task-b",
|
|
299
|
+
conversationId: "conv-2",
|
|
300
|
+
listId: "parent",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
expect(readActivationProgress().listId).toBe("smb");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("rejects malformed ids", async () => {
|
|
307
|
+
await expect(
|
|
308
|
+
startActivationTask({ taskId: "Bad Id", conversationId: "conv-1" }),
|
|
309
|
+
).rejects.toThrow(/Invalid taskId/);
|
|
310
|
+
await expect(
|
|
311
|
+
startActivationTask({
|
|
312
|
+
taskId: "task-a",
|
|
313
|
+
conversationId: "conv-1",
|
|
314
|
+
listId: "SMB",
|
|
315
|
+
}),
|
|
316
|
+
).rejects.toThrow(/Invalid listId/);
|
|
317
|
+
await expect(
|
|
318
|
+
startActivationTask({ taskId: "task-a", conversationId: " " }),
|
|
319
|
+
).rejects.toThrow(/conversationId is required/);
|
|
320
|
+
expect(readActivationProgress().tasks).toEqual({});
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("dismissActivation", () => {
|
|
325
|
+
test("records both kinds and keeps the first timestamp", async () => {
|
|
326
|
+
await dismissActivation({ kind: "modal", listId: "general" });
|
|
327
|
+
const stored = readActivationProgress();
|
|
328
|
+
expect(stored.modalDismissedAt).not.toBeNull();
|
|
329
|
+
expect(stored.allDoneShownAt).toBeNull();
|
|
330
|
+
expect(stored.listId).toBe("general");
|
|
331
|
+
|
|
332
|
+
await dismissActivation({ kind: "all-done" });
|
|
333
|
+
const after = readActivationProgress();
|
|
334
|
+
expect(after.allDoneShownAt).not.toBeNull();
|
|
335
|
+
expect(after.modalDismissedAt).toBe(stored.modalDismissedAt);
|
|
336
|
+
|
|
337
|
+
// The second dismiss of the same kind changes nothing.
|
|
338
|
+
publishedTags.length = 0;
|
|
339
|
+
await dismissActivation({ kind: "modal" });
|
|
340
|
+
expect(syncPublishCount()).toBe(0);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("does not thaw a frozen listId", async () => {
|
|
344
|
+
await dismissActivation({ kind: "modal", listId: "smb" });
|
|
345
|
+
await dismissActivation({ kind: "all-done", listId: "parent" });
|
|
346
|
+
expect(readActivationProgress().listId).toBe("smb");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("bumpActivationStepCount", () => {
|
|
351
|
+
test("is a no-op for an unlinked conversation", async () => {
|
|
352
|
+
await bumpActivationStepCount("conv-unlinked");
|
|
353
|
+
expect(readActivationProgress().tasks).toEqual({});
|
|
354
|
+
expect(syncPublishCount()).toBe(0);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("throttles a burst and flushes the trailing count", async () => {
|
|
358
|
+
await startActivationTask({
|
|
359
|
+
taskId: "draft-email",
|
|
360
|
+
conversationId: "conv-1",
|
|
361
|
+
});
|
|
362
|
+
publishedTags.length = 0;
|
|
363
|
+
|
|
364
|
+
for (let i = 0; i < 5; i++) {
|
|
365
|
+
await bumpActivationStepCount("conv-1");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The leading bump lands immediately; the rest coalesce behind the
|
|
369
|
+
// throttle window.
|
|
370
|
+
expect(readActivationProgress().tasks["draft-email"].stepCount).toBe(1);
|
|
371
|
+
expect(syncPublishCount()).toBe(1);
|
|
372
|
+
|
|
373
|
+
await waitFor(
|
|
374
|
+
() => readActivationProgress().tasks["draft-email"].stepCount === 5,
|
|
375
|
+
);
|
|
376
|
+
expect(syncPublishCount()).toBe(2);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
describe("markActivationTurnComplete", () => {
|
|
381
|
+
test("is a no-op for an unlinked conversation", async () => {
|
|
382
|
+
await markActivationTurnComplete({
|
|
383
|
+
conversationId: "conv-unlinked",
|
|
384
|
+
toolCallCount: 3,
|
|
385
|
+
endedAwaitingUser: false,
|
|
386
|
+
artifacts: [{ workspacePath: "a.md", displayName: "a.md" }],
|
|
387
|
+
});
|
|
388
|
+
expect(readActivationProgress().tasks).toEqual({});
|
|
389
|
+
expect(syncPublishCount()).toBe(0);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("records the step count and artifacts, and is idempotent", async () => {
|
|
393
|
+
await startActivationTask({
|
|
394
|
+
taskId: "draft-email",
|
|
395
|
+
conversationId: "conv-1",
|
|
396
|
+
});
|
|
397
|
+
await markActivationTurnComplete({
|
|
398
|
+
conversationId: "conv-1",
|
|
399
|
+
toolCallCount: 4,
|
|
400
|
+
endedAwaitingUser: false,
|
|
401
|
+
artifacts: [
|
|
402
|
+
{ workspacePath: "notes/plan.md", displayName: "plan.md" },
|
|
403
|
+
{ workspacePath: "notes/plan.md", displayName: "duplicate" },
|
|
404
|
+
{ workspacePath: "", displayName: "blank" },
|
|
405
|
+
],
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const done = readActivationProgress().tasks["draft-email"];
|
|
409
|
+
expect(done.status).toBe("done");
|
|
410
|
+
expect(done.completedAt).not.toBeNull();
|
|
411
|
+
expect(done.stepCount).toBe(4);
|
|
412
|
+
expect(done.artifacts).toEqual([
|
|
413
|
+
{ workspacePath: "notes/plan.md", displayName: "plan.md" },
|
|
414
|
+
]);
|
|
415
|
+
|
|
416
|
+
publishedTags.length = 0;
|
|
417
|
+
await markActivationTurnComplete({
|
|
418
|
+
conversationId: "conv-1",
|
|
419
|
+
toolCallCount: 9,
|
|
420
|
+
endedAwaitingUser: false,
|
|
421
|
+
artifacts: [],
|
|
422
|
+
});
|
|
423
|
+
expect(readActivationProgress().tasks["draft-email"]).toEqual(done);
|
|
424
|
+
expect(syncPublishCount()).toBe(0);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("never lowers a step count the user already saw", async () => {
|
|
428
|
+
await startActivationTask({
|
|
429
|
+
taskId: "draft-email",
|
|
430
|
+
conversationId: "conv-1",
|
|
431
|
+
});
|
|
432
|
+
await bumpActivationStepCount("conv-1");
|
|
433
|
+
await bumpActivationStepCount("conv-1");
|
|
434
|
+
|
|
435
|
+
await markActivationTurnComplete({
|
|
436
|
+
conversationId: "conv-1",
|
|
437
|
+
toolCallCount: 1,
|
|
438
|
+
endedAwaitingUser: false,
|
|
439
|
+
artifacts: [],
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
expect(readActivationProgress().tasks["draft-email"].stepCount).toBe(2);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
describe("linked-conversation index", () => {
|
|
447
|
+
test.skipIf(process.getuid?.() === 0)(
|
|
448
|
+
"an unchanged file is not re-read for an unlinked conversation",
|
|
449
|
+
async () => {
|
|
450
|
+
await startActivationTask({
|
|
451
|
+
taskId: "draft-email",
|
|
452
|
+
conversationId: "conv-1",
|
|
453
|
+
});
|
|
454
|
+
// The write leaves the index unstamped, so the first miss rereads
|
|
455
|
+
// and stamps it. From there a miss is answered by a stat.
|
|
456
|
+
await bumpActivationStepCount("conv-unlinked");
|
|
457
|
+
|
|
458
|
+
// Unreadable but still stat-able, and neither size nor mtime moved:
|
|
459
|
+
// a lookup that re-read here would degrade to empty progress and
|
|
460
|
+
// lose the link the next bump depends on.
|
|
461
|
+
chmodSync(getActivationProgressPath(), 0o000);
|
|
462
|
+
await bumpActivationStepCount("conv-unlinked");
|
|
463
|
+
chmodSync(getActivationProgressPath(), 0o600);
|
|
464
|
+
|
|
465
|
+
await bumpActivationStepCount("conv-1");
|
|
466
|
+
expect(readRawProgress().tasks["draft-email"].stepCount).toBe(1);
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
test("a link another process wrote is picked up by the next bump", async () => {
|
|
471
|
+
await startActivationTask({
|
|
472
|
+
taskId: "draft-email",
|
|
473
|
+
conversationId: "conv-1",
|
|
474
|
+
});
|
|
475
|
+
// Warm a negative entry for the conversation, the way a long-lived
|
|
476
|
+
// schedule or memory worker does before the daemon links it.
|
|
477
|
+
await bumpActivationStepCount("conv-2");
|
|
478
|
+
|
|
479
|
+
const external = readRawProgress();
|
|
480
|
+
external.tasks["book-travel"] = startedTask("conv-2");
|
|
481
|
+
writeProgressBehindStore(external);
|
|
482
|
+
|
|
483
|
+
await bumpActivationStepCount("conv-2");
|
|
484
|
+
await markActivationTurnComplete({
|
|
485
|
+
conversationId: "conv-2",
|
|
486
|
+
toolCallCount: 3,
|
|
487
|
+
endedAwaitingUser: false,
|
|
488
|
+
artifacts: [],
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
expect(readRawProgress().tasks["book-travel"]).toMatchObject({
|
|
492
|
+
status: "done",
|
|
493
|
+
stepCount: 3,
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("a link is visible to the very next bump", async () => {
|
|
498
|
+
await startActivationTask({
|
|
499
|
+
taskId: "draft-email",
|
|
500
|
+
conversationId: "conv-1",
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
await bumpActivationStepCount("conv-1");
|
|
504
|
+
|
|
505
|
+
expect(readRawProgress().tasks["draft-email"].stepCount).toBe(1);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("a second start on the conversation moves the link", async () => {
|
|
509
|
+
await startActivationTask({
|
|
510
|
+
taskId: "draft-email",
|
|
511
|
+
conversationId: "conv-1",
|
|
512
|
+
});
|
|
513
|
+
await startActivationTask({
|
|
514
|
+
taskId: "book-travel",
|
|
515
|
+
conversationId: "conv-1",
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
await bumpActivationStepCount("conv-1");
|
|
519
|
+
|
|
520
|
+
const tasks = readRawProgress().tasks;
|
|
521
|
+
expect(tasks["draft-email"]).toBeUndefined();
|
|
522
|
+
expect(tasks["book-travel"].stepCount).toBe(1);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("completion drops the link", async () => {
|
|
526
|
+
await startActivationTask({
|
|
527
|
+
taskId: "draft-email",
|
|
528
|
+
conversationId: "conv-1",
|
|
529
|
+
});
|
|
530
|
+
await markActivationTurnComplete({
|
|
531
|
+
conversationId: "conv-1",
|
|
532
|
+
toolCallCount: 2,
|
|
533
|
+
endedAwaitingUser: false,
|
|
534
|
+
artifacts: [],
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
await bumpActivationStepCount("conv-1");
|
|
538
|
+
|
|
539
|
+
// No `started` task points at the conversation any more, so the bump
|
|
540
|
+
// has nothing to count against.
|
|
541
|
+
expect(readRawProgress().tasks["draft-email"]).toMatchObject({
|
|
542
|
+
status: "done",
|
|
543
|
+
stepCount: 2,
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("a write during a read is not stamped as already indexed", async () => {
|
|
548
|
+
await startActivationTask({
|
|
549
|
+
taskId: "draft-email",
|
|
550
|
+
conversationId: "conv-1",
|
|
551
|
+
});
|
|
552
|
+
// Cold index, so the next lookup has to go to disk.
|
|
553
|
+
resetActivationStepThrottleForTesting(THROTTLE_MS);
|
|
554
|
+
|
|
555
|
+
const external = readRawProgress();
|
|
556
|
+
external.tasks["book-travel"] = startedTask("conv-2");
|
|
557
|
+
|
|
558
|
+
const spy = spyOn(nodeFs, "readFileSync");
|
|
559
|
+
spy.mockImplementationOnce(((path: never, options: never) => {
|
|
560
|
+
const raw = realReadFileSync(path, options);
|
|
561
|
+
// Another process records the link while this read is in flight.
|
|
562
|
+
writeProgressBehindStore(external);
|
|
563
|
+
return raw;
|
|
564
|
+
}) as typeof nodeFs.readFileSync);
|
|
565
|
+
try {
|
|
566
|
+
await bumpActivationStepCount("conv-unlinked");
|
|
567
|
+
} finally {
|
|
568
|
+
spy.mockRestore();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// The index holds the pre-write bytes. Stamping it after the read
|
|
572
|
+
// would file them under the post-write stamp, and this link would
|
|
573
|
+
// stay invisible for the life of the process.
|
|
574
|
+
await bumpActivationStepCount("conv-2");
|
|
575
|
+
|
|
576
|
+
expect(readRawProgress().tasks["book-travel"].stepCount).toBe(1);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("a write leaves the stamp stale: one reread, then hits stay free", async () => {
|
|
580
|
+
// A window wide enough that only the first bump per conversation
|
|
581
|
+
// flushes, so a later bump exercises the lookup without a write.
|
|
582
|
+
resetActivationStepThrottleForTesting(60_000);
|
|
583
|
+
await startActivationTask({
|
|
584
|
+
taskId: "draft-email",
|
|
585
|
+
conversationId: "conv-1",
|
|
586
|
+
});
|
|
587
|
+
// Flush the first bump, so the index is the one the write rebuilt.
|
|
588
|
+
await bumpActivationStepCount("conv-1");
|
|
589
|
+
|
|
590
|
+
const path = getActivationProgressPath();
|
|
591
|
+
const readSpy = spyOn(nodeFs, "readFileSync");
|
|
592
|
+
const statSpy = spyOn(nodeFs, "statSync");
|
|
593
|
+
const callsFor = (spy: { mock: { calls: unknown[][] } }): number =>
|
|
594
|
+
spy.mock.calls.filter((args) => args[0] === path).length;
|
|
595
|
+
try {
|
|
596
|
+
// A hit answers from the map, so it costs no syscall at all.
|
|
597
|
+
await bumpActivationStepCount("conv-1");
|
|
598
|
+
expect(callsFor(readSpy)).toBe(0);
|
|
599
|
+
expect(callsFor(statSpy)).toBe(0);
|
|
600
|
+
|
|
601
|
+
// The write could not tie a stamp to the bytes it landed, so the
|
|
602
|
+
// first miss against that index rereads.
|
|
603
|
+
await bumpActivationStepCount("conv-unlinked");
|
|
604
|
+
expect(callsFor(readSpy)).toBe(1);
|
|
605
|
+
|
|
606
|
+
// That reread stamped the index, so the next miss is one stat.
|
|
607
|
+
const statsAfterReread = callsFor(statSpy);
|
|
608
|
+
await bumpActivationStepCount("conv-unlinked");
|
|
609
|
+
expect(callsFor(readSpy)).toBe(1);
|
|
610
|
+
expect(callsFor(statSpy)).toBe(statsAfterReread + 1);
|
|
611
|
+
} finally {
|
|
612
|
+
readSpy.mockRestore();
|
|
613
|
+
statSpy.mockRestore();
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("the test-only reset drops the index", async () => {
|
|
618
|
+
await startActivationTask({
|
|
619
|
+
taskId: "draft-email",
|
|
620
|
+
conversationId: "conv-1",
|
|
621
|
+
});
|
|
622
|
+
const external = readRawProgress();
|
|
623
|
+
external.tasks["book-travel"] = startedTask("conv-2");
|
|
624
|
+
writeProgressBehindStore(external);
|
|
625
|
+
|
|
626
|
+
resetActivationStepThrottleForTesting(THROTTLE_MS);
|
|
627
|
+
await bumpActivationStepCount("conv-2");
|
|
628
|
+
|
|
629
|
+
expect(readRawProgress().tasks["book-travel"].stepCount).toBe(1);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe("origin client", () => {
|
|
634
|
+
test("a write carries the client that made it", async () => {
|
|
635
|
+
await startActivationTask({
|
|
636
|
+
taskId: "draft-email",
|
|
637
|
+
conversationId: "conv-1",
|
|
638
|
+
originClientId: "client-a",
|
|
639
|
+
});
|
|
640
|
+
expect(publishedOrigins).toEqual(["client-a"]);
|
|
641
|
+
|
|
642
|
+
publishedOrigins.length = 0;
|
|
643
|
+
await dismissActivation({ kind: "modal", originClientId: "client-b" });
|
|
644
|
+
expect(publishedOrigins).toEqual(["client-b"]);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("a turn-driven write carries no client", async () => {
|
|
648
|
+
await startActivationTask({
|
|
649
|
+
taskId: "draft-email",
|
|
650
|
+
conversationId: "conv-1",
|
|
651
|
+
});
|
|
652
|
+
publishedOrigins.length = 0;
|
|
653
|
+
|
|
654
|
+
await bumpActivationStepCount("conv-1");
|
|
655
|
+
|
|
656
|
+
expect(publishedOrigins).toEqual([undefined]);
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
describe("a newer schema version on disk", () => {
|
|
661
|
+
function writeNewerProgress(): void {
|
|
662
|
+
writeRawProgress(
|
|
663
|
+
JSON.stringify({
|
|
664
|
+
version: 2,
|
|
665
|
+
listId: "smb",
|
|
666
|
+
modalDismissedAt: null,
|
|
667
|
+
allDoneShownAt: null,
|
|
668
|
+
somethingNew: { added: "in v2" },
|
|
669
|
+
tasks: {
|
|
670
|
+
"draft-email": startedTask("conv-1"),
|
|
671
|
+
"book-travel": { status: "started", butMalformed: true },
|
|
672
|
+
},
|
|
673
|
+
}),
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
test("reads the fields this build still understands", () => {
|
|
678
|
+
writeNewerProgress();
|
|
679
|
+
|
|
680
|
+
const stored = readActivationProgress();
|
|
681
|
+
expect(stored.listId).toBe("smb");
|
|
682
|
+
expect(stored.tasks["draft-email"]).toMatchObject({
|
|
683
|
+
status: "started",
|
|
684
|
+
conversationId: "conv-1",
|
|
685
|
+
});
|
|
686
|
+
// The record this build cannot parse is dropped, not guessed at.
|
|
687
|
+
expect(stored.tasks["book-travel"]).toBeUndefined();
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("drops a task key this build would never have written", () => {
|
|
691
|
+
writeRawProgress(
|
|
692
|
+
JSON.stringify({
|
|
693
|
+
version: 2,
|
|
694
|
+
listId: null,
|
|
695
|
+
modalDismissedAt: null,
|
|
696
|
+
allDoneShownAt: null,
|
|
697
|
+
tasks: {
|
|
698
|
+
"draft-email": startedTask("conv-1"),
|
|
699
|
+
[`x-${"y".repeat(200)}`]: startedTask("conv-2"),
|
|
700
|
+
"Not A Task": startedTask("conv-3"),
|
|
701
|
+
},
|
|
702
|
+
}),
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
expect(Object.keys(readActivationProgress().tasks)).toEqual([
|
|
706
|
+
"draft-email",
|
|
707
|
+
]);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("drops a known field whose value does not validate", () => {
|
|
711
|
+
writeRawProgress(
|
|
712
|
+
JSON.stringify({
|
|
713
|
+
version: 2,
|
|
714
|
+
listId: 17,
|
|
715
|
+
modalDismissedAt: { at: "yesterday" },
|
|
716
|
+
allDoneShownAt: "2026-01-01T00:00:00.000Z",
|
|
717
|
+
tasks: {},
|
|
718
|
+
}),
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
const stored = readActivationProgress();
|
|
722
|
+
expect(stored.listId).toBeNull();
|
|
723
|
+
expect(stored.modalDismissedAt).toBeNull();
|
|
724
|
+
expect(stored.allDoneShownAt).toBe("2026-01-01T00:00:00.000Z");
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test("is never written back, so a rollback cannot erase it", async () => {
|
|
728
|
+
writeNewerProgress();
|
|
729
|
+
const before = readFileSync(getActivationProgressPath(), "utf-8");
|
|
730
|
+
|
|
731
|
+
await expect(dismissActivation({ kind: "modal" })).rejects.toMatchObject({
|
|
732
|
+
statusCode: 409,
|
|
733
|
+
});
|
|
734
|
+
await expect(
|
|
735
|
+
startActivationTask({
|
|
736
|
+
taskId: "book-travel",
|
|
737
|
+
conversationId: "conv-2",
|
|
738
|
+
}),
|
|
739
|
+
).rejects.toMatchObject({ statusCode: 409 });
|
|
740
|
+
await expect(
|
|
741
|
+
markActivationTurnComplete({
|
|
742
|
+
conversationId: "conv-1",
|
|
743
|
+
toolCallCount: 4,
|
|
744
|
+
endedAwaitingUser: false,
|
|
745
|
+
artifacts: [],
|
|
746
|
+
}),
|
|
747
|
+
).rejects.toMatchObject({ statusCode: 409 });
|
|
748
|
+
|
|
749
|
+
expect(readFileSync(getActivationProgressPath(), "utf-8")).toBe(before);
|
|
750
|
+
expect(syncPublishCount()).toBe(0);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("a refused step flush is not retried against a document it may not write", async () => {
|
|
754
|
+
writeNewerProgress();
|
|
755
|
+
|
|
756
|
+
await expect(bumpActivationStepCount("conv-1")).rejects.toMatchObject({
|
|
757
|
+
statusCode: 409,
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
// Nothing is left armed to rewrite the newer document later.
|
|
761
|
+
await Bun.sleep(THROTTLE_MS * 3);
|
|
762
|
+
expect(syncPublishCount()).toBe(0);
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
describe("what finishes a task", () => {
|
|
767
|
+
test("a turn that answered in prose alone still finishes it", async () => {
|
|
768
|
+
await startActivationTask({
|
|
769
|
+
taskId: "draft-email",
|
|
770
|
+
conversationId: "conv-1",
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
await markActivationTurnComplete({
|
|
774
|
+
conversationId: "conv-1",
|
|
775
|
+
toolCallCount: 0,
|
|
776
|
+
endedAwaitingUser: false,
|
|
777
|
+
artifacts: [],
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
expect(readActivationProgress().tasks["draft-email"].status).toBe("done");
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("a turn that ended waiting on the user does not, and the next turn does", async () => {
|
|
784
|
+
await startActivationTask({
|
|
785
|
+
taskId: "draft-email",
|
|
786
|
+
conversationId: "conv-1",
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
await markActivationTurnComplete({
|
|
790
|
+
conversationId: "conv-1",
|
|
791
|
+
toolCallCount: 1,
|
|
792
|
+
endedAwaitingUser: true,
|
|
793
|
+
artifacts: [],
|
|
794
|
+
});
|
|
795
|
+
expect(readActivationProgress().tasks["draft-email"].status).toBe(
|
|
796
|
+
"started",
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
await markActivationTurnComplete({
|
|
800
|
+
conversationId: "conv-1",
|
|
801
|
+
toolCallCount: 2,
|
|
802
|
+
endedAwaitingUser: false,
|
|
803
|
+
artifacts: [],
|
|
804
|
+
});
|
|
805
|
+
expect(readActivationProgress().tasks["draft-email"]).toMatchObject({
|
|
806
|
+
status: "done",
|
|
807
|
+
stepCount: 2,
|
|
808
|
+
});
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test("a turn that ended waiting on the user still persists its step count", async () => {
|
|
812
|
+
await startActivationTask({
|
|
813
|
+
taskId: "draft-email",
|
|
814
|
+
conversationId: "conv-1",
|
|
815
|
+
});
|
|
816
|
+
await bumpActivationStepCount("conv-1");
|
|
817
|
+
|
|
818
|
+
await markActivationTurnComplete({
|
|
819
|
+
conversationId: "conv-1",
|
|
820
|
+
toolCallCount: 1,
|
|
821
|
+
endedAwaitingUser: true,
|
|
822
|
+
artifacts: [],
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
expect(readActivationProgress().tasks["draft-email"]).toMatchObject({
|
|
826
|
+
status: "started",
|
|
827
|
+
stepCount: 1,
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test("a turn that only attached a file still finishes the task", async () => {
|
|
832
|
+
await startActivationTask({
|
|
833
|
+
taskId: "draft-email",
|
|
834
|
+
conversationId: "conv-1",
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
await markActivationTurnComplete({
|
|
838
|
+
conversationId: "conv-1",
|
|
839
|
+
toolCallCount: 0,
|
|
840
|
+
endedAwaitingUser: false,
|
|
841
|
+
artifacts: [{ workspacePath: "notes/plan.md", displayName: "plan.md" }],
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
expect(readActivationProgress().tasks["draft-email"].status).toBe("done");
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
describe("persistence failures", () => {
|
|
849
|
+
test("a failed write rejects instead of reporting saved progress", async () => {
|
|
850
|
+
breakDataDir();
|
|
851
|
+
|
|
852
|
+
await expect(
|
|
853
|
+
startActivationTask({
|
|
854
|
+
taskId: "draft-email",
|
|
855
|
+
conversationId: "conv-1",
|
|
856
|
+
}),
|
|
857
|
+
).rejects.toThrow(/Failed to persist activation progress/);
|
|
858
|
+
await expect(dismissActivation({ kind: "modal" })).rejects.toThrow(
|
|
859
|
+
/Failed to persist activation progress/,
|
|
860
|
+
);
|
|
861
|
+
expect(syncPublishCount()).toBe(0);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("a failed write leaves no temp file behind", async () => {
|
|
865
|
+
// Occupy the destination with a directory, so the temp file is written
|
|
866
|
+
// and only the rename fails.
|
|
867
|
+
mkdirSync(getActivationProgressPath(), { recursive: true });
|
|
868
|
+
|
|
869
|
+
await expect(
|
|
870
|
+
startActivationTask({
|
|
871
|
+
taskId: "draft-email",
|
|
872
|
+
conversationId: "conv-1",
|
|
873
|
+
}),
|
|
874
|
+
).rejects.toThrow(/Failed to persist activation progress/);
|
|
875
|
+
|
|
876
|
+
expect(
|
|
877
|
+
readdirSync(join(workspaceDir, "data")).filter((name) =>
|
|
878
|
+
name.includes(".tmp."),
|
|
879
|
+
),
|
|
880
|
+
).toEqual([]);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test.skipIf(process.getuid?.() === 0)(
|
|
884
|
+
"a failed step flush keeps the tool calls it was carrying",
|
|
885
|
+
async () => {
|
|
886
|
+
await startActivationTask({
|
|
887
|
+
taskId: "draft-email",
|
|
888
|
+
conversationId: "conv-1",
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
// Read-only data dir: the snapshot still reads, the temp file cannot
|
|
892
|
+
// be created.
|
|
893
|
+
chmodSync(join(workspaceDir, "data"), 0o500);
|
|
894
|
+
await expect(bumpActivationStepCount("conv-1")).rejects.toThrow(
|
|
895
|
+
/Failed to persist activation progress/,
|
|
896
|
+
);
|
|
897
|
+
chmodSync(join(workspaceDir, "data"), 0o700);
|
|
898
|
+
|
|
899
|
+
await bumpActivationStepCount("conv-1");
|
|
900
|
+
|
|
901
|
+
// Both the bump that failed to land and the one after it.
|
|
902
|
+
await waitFor(
|
|
903
|
+
() => readRawProgress().tasks["draft-email"].stepCount === 2,
|
|
904
|
+
);
|
|
905
|
+
},
|
|
906
|
+
);
|
|
907
|
+
|
|
908
|
+
test.skipIf(process.getuid?.() === 0)(
|
|
909
|
+
"a failed step flush retries itself, with no later bump to drive it",
|
|
910
|
+
async () => {
|
|
911
|
+
await startActivationTask({
|
|
912
|
+
taskId: "draft-email",
|
|
913
|
+
conversationId: "conv-1",
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
chmodSync(join(workspaceDir, "data"), 0o500);
|
|
917
|
+
await expect(bumpActivationStepCount("conv-1")).rejects.toThrow(
|
|
918
|
+
/Failed to persist activation progress/,
|
|
919
|
+
);
|
|
920
|
+
chmodSync(join(workspaceDir, "data"), 0o700);
|
|
921
|
+
|
|
922
|
+
// Nothing else touches the conversation: the armed retry is the only
|
|
923
|
+
// thing left that can land the count.
|
|
924
|
+
await waitFor(
|
|
925
|
+
() => readRawProgress().tasks["draft-email"].stepCount === 1,
|
|
926
|
+
);
|
|
927
|
+
},
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
test.skipIf(process.getuid?.() === 0)(
|
|
931
|
+
"a step flush that keeps failing gives up instead of retrying forever",
|
|
932
|
+
async () => {
|
|
933
|
+
await startActivationTask({
|
|
934
|
+
taskId: "draft-email",
|
|
935
|
+
conversationId: "conv-1",
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
chmodSync(join(workspaceDir, "data"), 0o500);
|
|
939
|
+
await expect(bumpActivationStepCount("conv-1")).rejects.toThrow(
|
|
940
|
+
/Failed to persist activation progress/,
|
|
941
|
+
);
|
|
942
|
+
// Long enough for every attempt the bound allows.
|
|
943
|
+
await Bun.sleep(THROTTLE_MS * 10);
|
|
944
|
+
chmodSync(join(workspaceDir, "data"), 0o700);
|
|
945
|
+
await Bun.sleep(THROTTLE_MS * 4);
|
|
946
|
+
|
|
947
|
+
expect(readRawProgress().tasks["draft-email"].stepCount).toBe(0);
|
|
948
|
+
},
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
test("a failed step flush still lets the turn complete", async () => {
|
|
952
|
+
// A window wide enough that the second bump is certain to be pending
|
|
953
|
+
// when the completion flushes it.
|
|
954
|
+
resetActivationStepThrottleForTesting(5_000);
|
|
955
|
+
await startActivationTask({
|
|
956
|
+
taskId: "draft-email",
|
|
957
|
+
conversationId: "conv-1",
|
|
958
|
+
});
|
|
959
|
+
await bumpActivationStepCount("conv-1");
|
|
960
|
+
await bumpActivationStepCount("conv-1");
|
|
961
|
+
|
|
962
|
+
const spy = spyOn(nodeFs, "writeFileSync");
|
|
963
|
+
spy.mockImplementationOnce(() => {
|
|
964
|
+
throw new Error("transient write failure");
|
|
965
|
+
});
|
|
966
|
+
try {
|
|
967
|
+
await markActivationTurnComplete({
|
|
968
|
+
conversationId: "conv-1",
|
|
969
|
+
toolCallCount: 4,
|
|
970
|
+
endedAwaitingUser: false,
|
|
971
|
+
artifacts: [],
|
|
972
|
+
});
|
|
973
|
+
} finally {
|
|
974
|
+
spy.mockRestore();
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
expect(readRawProgress().tasks["draft-email"]).toMatchObject({
|
|
978
|
+
status: "done",
|
|
979
|
+
stepCount: 4,
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
test("a rejected write leaves later mutations working", async () => {
|
|
984
|
+
breakDataDir();
|
|
985
|
+
await startActivationTask({
|
|
986
|
+
taskId: "draft-email",
|
|
987
|
+
conversationId: "conv-1",
|
|
988
|
+
}).catch(() => {});
|
|
989
|
+
|
|
990
|
+
rmSync(join(workspaceDir, "data"));
|
|
991
|
+
await startActivationTask({
|
|
992
|
+
taskId: "draft-email",
|
|
993
|
+
conversationId: "conv-1",
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
expect(readActivationProgress().tasks["draft-email"]).toMatchObject({
|
|
997
|
+
status: "started",
|
|
998
|
+
conversationId: "conv-1",
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
describe("one task per conversation", () => {
|
|
1004
|
+
test("a second task takes the conversation and the first is unlinked", async () => {
|
|
1005
|
+
await startActivationTask({
|
|
1006
|
+
taskId: "draft-email",
|
|
1007
|
+
conversationId: "conv-1",
|
|
1008
|
+
});
|
|
1009
|
+
await startActivationTask({
|
|
1010
|
+
taskId: "book-travel",
|
|
1011
|
+
conversationId: "conv-1",
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
const tasks = readActivationProgress().tasks;
|
|
1015
|
+
expect(Object.keys(tasks)).toEqual(["book-travel"]);
|
|
1016
|
+
expect(tasks["book-travel"]).toMatchObject({
|
|
1017
|
+
status: "started",
|
|
1018
|
+
conversationId: "conv-1",
|
|
1019
|
+
});
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
test("completion reaches the latest task, not a stranded one", async () => {
|
|
1023
|
+
await startActivationTask({
|
|
1024
|
+
taskId: "draft-email",
|
|
1025
|
+
conversationId: "conv-1",
|
|
1026
|
+
});
|
|
1027
|
+
await startActivationTask({
|
|
1028
|
+
taskId: "book-travel",
|
|
1029
|
+
conversationId: "conv-1",
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
await bumpActivationStepCount("conv-1");
|
|
1033
|
+
await markActivationTurnComplete({
|
|
1034
|
+
conversationId: "conv-1",
|
|
1035
|
+
toolCallCount: 3,
|
|
1036
|
+
endedAwaitingUser: false,
|
|
1037
|
+
artifacts: [{ workspacePath: "trips/plan.md", displayName: "plan.md" }],
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
const tasks = readActivationProgress().tasks;
|
|
1041
|
+
expect(tasks["draft-email"]).toBeUndefined();
|
|
1042
|
+
expect(tasks["book-travel"]).toMatchObject({
|
|
1043
|
+
status: "done",
|
|
1044
|
+
stepCount: 3,
|
|
1045
|
+
artifacts: [{ workspacePath: "trips/plan.md", displayName: "plan.md" }],
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
test("re-starting a done task leaves the task running there alone", async () => {
|
|
1050
|
+
await startActivationTask({
|
|
1051
|
+
taskId: "draft-email",
|
|
1052
|
+
conversationId: "conv-1",
|
|
1053
|
+
});
|
|
1054
|
+
await markActivationTurnComplete({
|
|
1055
|
+
conversationId: "conv-1",
|
|
1056
|
+
toolCallCount: 1,
|
|
1057
|
+
endedAwaitingUser: false,
|
|
1058
|
+
artifacts: [],
|
|
1059
|
+
});
|
|
1060
|
+
await startActivationTask({
|
|
1061
|
+
taskId: "book-travel",
|
|
1062
|
+
conversationId: "conv-1",
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
// The finished task cannot relink, so it has no claim on the
|
|
1066
|
+
// conversation and must not evict the task that is working it.
|
|
1067
|
+
await startActivationTask({
|
|
1068
|
+
taskId: "draft-email",
|
|
1069
|
+
conversationId: "conv-1",
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
const tasks = readActivationProgress().tasks;
|
|
1073
|
+
expect(tasks["draft-email"]).toMatchObject({ status: "done" });
|
|
1074
|
+
expect(tasks["book-travel"]).toMatchObject({
|
|
1075
|
+
status: "started",
|
|
1076
|
+
conversationId: "conv-1",
|
|
1077
|
+
});
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
test("a done task keeps its record when a new task takes the conversation", async () => {
|
|
1081
|
+
await startActivationTask({
|
|
1082
|
+
taskId: "draft-email",
|
|
1083
|
+
conversationId: "conv-1",
|
|
1084
|
+
});
|
|
1085
|
+
await markActivationTurnComplete({
|
|
1086
|
+
conversationId: "conv-1",
|
|
1087
|
+
toolCallCount: 1,
|
|
1088
|
+
endedAwaitingUser: false,
|
|
1089
|
+
artifacts: [],
|
|
1090
|
+
});
|
|
1091
|
+
await startActivationTask({
|
|
1092
|
+
taskId: "book-travel",
|
|
1093
|
+
conversationId: "conv-1",
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
const tasks = readActivationProgress().tasks;
|
|
1097
|
+
expect(tasks["draft-email"]).toMatchObject({
|
|
1098
|
+
status: "done",
|
|
1099
|
+
conversationId: "conv-1",
|
|
1100
|
+
});
|
|
1101
|
+
expect(tasks["book-travel"]).toMatchObject({ status: "started" });
|
|
1102
|
+
});
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
describe("a verified link", () => {
|
|
1106
|
+
test("records the link when the conversation is still there", async () => {
|
|
1107
|
+
await startActivationTask({
|
|
1108
|
+
taskId: "draft-email",
|
|
1109
|
+
conversationId: "conv-1",
|
|
1110
|
+
verify: () => true,
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
expect(readRawProgress().tasks["draft-email"]).toMatchObject({
|
|
1114
|
+
status: "started",
|
|
1115
|
+
conversationId: "conv-1",
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
test("refuses the write with a 404 when the conversation went away", async () => {
|
|
1120
|
+
const err = await startActivationTask({
|
|
1121
|
+
taskId: "draft-email",
|
|
1122
|
+
conversationId: "conv-1",
|
|
1123
|
+
verify: () => false,
|
|
1124
|
+
}).catch((e: unknown) => e);
|
|
1125
|
+
|
|
1126
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
1127
|
+
expect((err as InstanceType<typeof RouteError>).statusCode).toBe(404);
|
|
1128
|
+
expect(existsSync(getActivationProgressPath())).toBe(false);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
test("asks again after the wait, not before it", async () => {
|
|
1132
|
+
// What the route sees: present when the call starts, gone by the time
|
|
1133
|
+
// the mutation reaches the front of the queue.
|
|
1134
|
+
let exists = true;
|
|
1135
|
+
const pending = startActivationTask({
|
|
1136
|
+
taskId: "draft-email",
|
|
1137
|
+
conversationId: "conv-1",
|
|
1138
|
+
verify: () => exists,
|
|
1139
|
+
}).catch((e: unknown) => e);
|
|
1140
|
+
exists = false;
|
|
1141
|
+
|
|
1142
|
+
const err = await pending;
|
|
1143
|
+
expect((err as InstanceType<typeof RouteError>).statusCode).toBe(404);
|
|
1144
|
+
expect(existsSync(getActivationProgressPath())).toBe(false);
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
describe("the interprocess lock", () => {
|
|
1149
|
+
test("waits for a lock another process holds, then lands", async () => {
|
|
1150
|
+
const lockPath = holdProgressLock();
|
|
1151
|
+
|
|
1152
|
+
const pending = startActivationTask({
|
|
1153
|
+
taskId: "draft-email",
|
|
1154
|
+
conversationId: "conv-1",
|
|
1155
|
+
});
|
|
1156
|
+
await Bun.sleep(60);
|
|
1157
|
+
// Still waiting: nothing has been written behind the holder's back.
|
|
1158
|
+
expect(existsSync(getActivationProgressPath())).toBe(false);
|
|
1159
|
+
|
|
1160
|
+
rmSync(lockPath);
|
|
1161
|
+
await pending;
|
|
1162
|
+
|
|
1163
|
+
expect(readRawProgress().tasks["draft-email"]).toMatchObject({
|
|
1164
|
+
status: "started",
|
|
1165
|
+
conversationId: "conv-1",
|
|
1166
|
+
});
|
|
1167
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
test("waits out a live holder rather than expiring it by age", async () => {
|
|
1171
|
+
setActivationLockTimingForTesting({ waitMs: 40 });
|
|
1172
|
+
// Five seconds is a lifetime for a holder that does one read and one
|
|
1173
|
+
// rename, and still no reason to take a running process's lock: it may
|
|
1174
|
+
// be paused rather than gone, and its rename is still coming.
|
|
1175
|
+
const lockPath = holdProgressLock(Date.now() - 5_000, OTHER_LIVE_PID);
|
|
1176
|
+
ageLockFile(lockPath, 5_000);
|
|
1177
|
+
|
|
1178
|
+
const err = await startActivationTask({
|
|
1179
|
+
taskId: "draft-email",
|
|
1180
|
+
conversationId: "conv-1",
|
|
1181
|
+
}).catch((e: unknown) => e);
|
|
1182
|
+
|
|
1183
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
1184
|
+
expect((err as InstanceType<typeof RouteError>).statusCode).toBe(503);
|
|
1185
|
+
expect(existsSync(getActivationProgressPath())).toBe(false);
|
|
1186
|
+
expect(existsSync(lockPath)).toBe(true);
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
test("waits out a live holder however long it has held", async () => {
|
|
1190
|
+
setActivationLockTimingForTesting({ waitMs: 40 });
|
|
1191
|
+
const lockPath = holdProgressLock(Date.now() - 3_600_000, OTHER_LIVE_PID);
|
|
1192
|
+
ageLockFile(lockPath, 3_600_000);
|
|
1193
|
+
|
|
1194
|
+
const err = await startActivationTask({
|
|
1195
|
+
taskId: "draft-email",
|
|
1196
|
+
conversationId: "conv-1",
|
|
1197
|
+
}).catch((e: unknown) => e);
|
|
1198
|
+
|
|
1199
|
+
expect((err as InstanceType<typeof RouteError>).statusCode).toBe(503);
|
|
1200
|
+
expect(existsSync(lockPath)).toBe(true);
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
test("takes over a lock whose holder left nothing to check, once it is old", async () => {
|
|
1204
|
+
// What a writer killed between creating the file and stamping it leaves.
|
|
1205
|
+
mkdirSync(join(workspaceDir, "data"), { recursive: true });
|
|
1206
|
+
const lockPath = getActivationProgressLockPath();
|
|
1207
|
+
writeFileSync(lockPath, "", "utf-8");
|
|
1208
|
+
ageLockFile(lockPath, 120_000);
|
|
1209
|
+
|
|
1210
|
+
await startActivationTask({
|
|
1211
|
+
taskId: "draft-email",
|
|
1212
|
+
conversationId: "conv-1",
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
expect(readRawProgress().tasks["draft-email"]).toMatchObject({
|
|
1216
|
+
status: "started",
|
|
1217
|
+
});
|
|
1218
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
test("waits out a lock whose holder left nothing to check while it is young", async () => {
|
|
1222
|
+
setActivationLockTimingForTesting({ waitMs: 40 });
|
|
1223
|
+
mkdirSync(join(workspaceDir, "data"), { recursive: true });
|
|
1224
|
+
const lockPath = getActivationProgressLockPath();
|
|
1225
|
+
writeFileSync(lockPath, "{ not json", "utf-8");
|
|
1226
|
+
|
|
1227
|
+
const err = await startActivationTask({
|
|
1228
|
+
taskId: "draft-email",
|
|
1229
|
+
conversationId: "conv-1",
|
|
1230
|
+
}).catch((e: unknown) => e);
|
|
1231
|
+
|
|
1232
|
+
expect((err as InstanceType<typeof RouteError>).statusCode).toBe(503);
|
|
1233
|
+
expect(existsSync(lockPath)).toBe(true);
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test("takes over a lock a dead process left behind", async () => {
|
|
1237
|
+
holdProgressLock(Date.now(), DEAD_PID);
|
|
1238
|
+
|
|
1239
|
+
await startActivationTask({
|
|
1240
|
+
taskId: "draft-email",
|
|
1241
|
+
conversationId: "conv-1",
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
expect(readRawProgress().tasks["draft-email"]).toMatchObject({
|
|
1245
|
+
status: "started",
|
|
1246
|
+
});
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
test("leaves a dead process's lock alone while another process is reclaiming it", async () => {
|
|
1250
|
+
setActivationLockTimingForTesting({ waitMs: 40 });
|
|
1251
|
+
const lockPath = holdProgressLock(Date.now(), DEAD_PID);
|
|
1252
|
+
const reclaimPath = `${lockPath}.reclaim`;
|
|
1253
|
+
writeFileSync(
|
|
1254
|
+
reclaimPath,
|
|
1255
|
+
JSON.stringify({ pid: OTHER_LIVE_PID, at: Date.now() }),
|
|
1256
|
+
"utf-8",
|
|
1257
|
+
);
|
|
1258
|
+
|
|
1259
|
+
const err = await startActivationTask({
|
|
1260
|
+
taskId: "draft-email",
|
|
1261
|
+
conversationId: "conv-1",
|
|
1262
|
+
}).catch((e: unknown) => e);
|
|
1263
|
+
|
|
1264
|
+
// The other reclaimer owns the removal; a second unlink here could land
|
|
1265
|
+
// on whatever it creates once its own reclaim is through.
|
|
1266
|
+
expect((err as InstanceType<typeof RouteError>).statusCode).toBe(503);
|
|
1267
|
+
expect(existsSync(lockPath)).toBe(true);
|
|
1268
|
+
expect(existsSync(reclaimPath)).toBe(true);
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
test("clears a reclaim another process died inside of, then takes the lock", async () => {
|
|
1272
|
+
const lockPath = holdProgressLock(Date.now(), DEAD_PID);
|
|
1273
|
+
const reclaimPath = `${lockPath}.reclaim`;
|
|
1274
|
+
writeFileSync(
|
|
1275
|
+
reclaimPath,
|
|
1276
|
+
JSON.stringify({ pid: DEAD_PID, at: Date.now() }),
|
|
1277
|
+
"utf-8",
|
|
1278
|
+
);
|
|
1279
|
+
|
|
1280
|
+
await startActivationTask({
|
|
1281
|
+
taskId: "draft-email",
|
|
1282
|
+
conversationId: "conv-1",
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
expect(readRawProgress().tasks["draft-email"]).toMatchObject({
|
|
1286
|
+
status: "started",
|
|
1287
|
+
});
|
|
1288
|
+
expect(readdirSync(join(workspaceDir, "data"))).toEqual([
|
|
1289
|
+
ACTIVATION_PROGRESS_FILENAME,
|
|
1290
|
+
]);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
test("a reclaim leaves nothing beside the snapshot", async () => {
|
|
1294
|
+
holdProgressLock(Date.now(), DEAD_PID);
|
|
1295
|
+
|
|
1296
|
+
await startActivationTask({
|
|
1297
|
+
taskId: "draft-email",
|
|
1298
|
+
conversationId: "conv-1",
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
expect(readdirSync(join(workspaceDir, "data"))).toEqual([
|
|
1302
|
+
ACTIVATION_PROGRESS_FILENAME,
|
|
1303
|
+
]);
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
test("a write that lands while a mutation waits is not clobbered", async () => {
|
|
1307
|
+
await startActivationTask({
|
|
1308
|
+
taskId: "draft-email",
|
|
1309
|
+
conversationId: "conv-1",
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
const lockPath = holdProgressLock();
|
|
1313
|
+
// Queued behind a lock it cannot take yet, the way a worker's mutation
|
|
1314
|
+
// queues behind the daemon's.
|
|
1315
|
+
const pending = startActivationTask({
|
|
1316
|
+
taskId: "plan-week",
|
|
1317
|
+
conversationId: "conv-3",
|
|
1318
|
+
});
|
|
1319
|
+
await Bun.sleep(60);
|
|
1320
|
+
|
|
1321
|
+
// The holder's own update, landing while the waiter is still spinning.
|
|
1322
|
+
const external = readRawProgress();
|
|
1323
|
+
external.tasks["book-travel"] = startedTask("conv-2");
|
|
1324
|
+
writeProgressBehindStore(external);
|
|
1325
|
+
rmSync(lockPath);
|
|
1326
|
+
|
|
1327
|
+
await pending;
|
|
1328
|
+
|
|
1329
|
+
// The waiter read after it took the lock, so it built on the holder's
|
|
1330
|
+
// document rather than on the one it saw before it started waiting.
|
|
1331
|
+
expect(Object.keys(readRawProgress().tasks).sort()).toEqual([
|
|
1332
|
+
"book-travel",
|
|
1333
|
+
"draft-email",
|
|
1334
|
+
"plan-week",
|
|
1335
|
+
]);
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
test("a change another process made between mutations survives", async () => {
|
|
1339
|
+
await startActivationTask({
|
|
1340
|
+
taskId: "draft-email",
|
|
1341
|
+
conversationId: "conv-1",
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
// A sidecar worker, with its own module state, records a second task
|
|
1345
|
+
// while nothing here holds the lock.
|
|
1346
|
+
const external = readRawProgress();
|
|
1347
|
+
external.tasks["book-travel"] = startedTask("conv-2");
|
|
1348
|
+
writeProgressBehindStore(external);
|
|
1349
|
+
resetActivationStepThrottleForTesting(THROTTLE_MS);
|
|
1350
|
+
|
|
1351
|
+
await dismissActivation({ kind: "modal" });
|
|
1352
|
+
|
|
1353
|
+
const stored = readRawProgress();
|
|
1354
|
+
expect(Object.keys(stored.tasks).sort()).toEqual([
|
|
1355
|
+
"book-travel",
|
|
1356
|
+
"draft-email",
|
|
1357
|
+
]);
|
|
1358
|
+
expect(stored.modalDismissedAt).not.toBeNull();
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
test("fails the write rather than racing a holder that never lets go", async () => {
|
|
1362
|
+
setActivationLockTimingForTesting({ waitMs: 40 });
|
|
1363
|
+
const lockPath = holdProgressLock();
|
|
1364
|
+
|
|
1365
|
+
const err = await startActivationTask({
|
|
1366
|
+
taskId: "draft-email",
|
|
1367
|
+
conversationId: "conv-1",
|
|
1368
|
+
}).catch((e: unknown) => e);
|
|
1369
|
+
|
|
1370
|
+
expect(err).toBeInstanceOf(RouteError);
|
|
1371
|
+
expect((err as InstanceType<typeof RouteError>).statusCode).toBe(503);
|
|
1372
|
+
// Nothing was written behind the holder's back, and the lock is still
|
|
1373
|
+
// the holder's.
|
|
1374
|
+
expect(existsSync(getActivationProgressPath())).toBe(false);
|
|
1375
|
+
expect(existsSync(lockPath)).toBe(true);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
test("a step bump the lock refused is re-queued, and its retry lands it", async () => {
|
|
1379
|
+
await startActivationTask({
|
|
1380
|
+
taskId: "draft-email",
|
|
1381
|
+
conversationId: "conv-1",
|
|
1382
|
+
});
|
|
1383
|
+
setActivationLockTimingForTesting({ waitMs: 20 });
|
|
1384
|
+
const lockPath = holdProgressLock();
|
|
1385
|
+
|
|
1386
|
+
// The hook swallows the rejection the same way the agent loop does.
|
|
1387
|
+
await bumpActivationStepCount("conv-1").catch(() => {});
|
|
1388
|
+
expect(readRawProgress().tasks["draft-email"].stepCount).toBe(0);
|
|
1389
|
+
|
|
1390
|
+
rmSync(lockPath);
|
|
1391
|
+
await waitFor(
|
|
1392
|
+
() => readRawProgress().tasks["draft-email"].stepCount === 1,
|
|
1393
|
+
);
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1396
|
+
test("a completion the lock refused lands on its retry", async () => {
|
|
1397
|
+
await startActivationTask({
|
|
1398
|
+
taskId: "draft-email",
|
|
1399
|
+
conversationId: "conv-1",
|
|
1400
|
+
});
|
|
1401
|
+
setActivationLockTimingForTesting({ waitMs: 20, retryMs: 80 });
|
|
1402
|
+
const lockPath = holdProgressLock();
|
|
1403
|
+
|
|
1404
|
+
await markActivationTurnComplete({
|
|
1405
|
+
conversationId: "conv-1",
|
|
1406
|
+
toolCallCount: 2,
|
|
1407
|
+
endedAwaitingUser: false,
|
|
1408
|
+
artifacts: [],
|
|
1409
|
+
});
|
|
1410
|
+
expect(readRawProgress().tasks["draft-email"].status).toBe("started");
|
|
1411
|
+
|
|
1412
|
+
rmSync(lockPath);
|
|
1413
|
+
await waitFor(
|
|
1414
|
+
() => readRawProgress().tasks["draft-email"].status === "done",
|
|
1415
|
+
);
|
|
1416
|
+
expect(readRawProgress().tasks["draft-email"].stepCount).toBe(2);
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
test("a completion gives up after one retry rather than waiting forever", async () => {
|
|
1420
|
+
await startActivationTask({
|
|
1421
|
+
taskId: "draft-email",
|
|
1422
|
+
conversationId: "conv-1",
|
|
1423
|
+
});
|
|
1424
|
+
setActivationLockTimingForTesting({ waitMs: 20, retryMs: 30 });
|
|
1425
|
+
const lockPath = holdProgressLock();
|
|
1426
|
+
|
|
1427
|
+
await markActivationTurnComplete({
|
|
1428
|
+
conversationId: "conv-1",
|
|
1429
|
+
toolCallCount: 2,
|
|
1430
|
+
endedAwaitingUser: false,
|
|
1431
|
+
artifacts: [],
|
|
1432
|
+
});
|
|
1433
|
+
// Long enough for the one retry to run and fail against the lock too.
|
|
1434
|
+
await Bun.sleep(200);
|
|
1435
|
+
expect(readRawProgress().tasks["draft-email"].status).toBe("started");
|
|
1436
|
+
|
|
1437
|
+
// The retry is spent: a lock that frees afterwards does not revive it.
|
|
1438
|
+
rmSync(lockPath);
|
|
1439
|
+
await Bun.sleep(200);
|
|
1440
|
+
expect(readRawProgress().tasks["draft-email"].status).toBe("started");
|
|
1441
|
+
});
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
describe("a deleted conversation", () => {
|
|
1445
|
+
test("returns its started task to Todo, ready to launch again", async () => {
|
|
1446
|
+
await startActivationTask({
|
|
1447
|
+
taskId: "draft-email",
|
|
1448
|
+
conversationId: "conv-1",
|
|
1449
|
+
});
|
|
1450
|
+
const publishesBefore = syncPublishCount();
|
|
1451
|
+
|
|
1452
|
+
await forgetActivationConversation("conv-1");
|
|
1453
|
+
|
|
1454
|
+
expect(readRawProgress().tasks["draft-email"]).toBeUndefined();
|
|
1455
|
+
expect(syncPublishCount()).toBe(publishesBefore + 1);
|
|
1456
|
+
|
|
1457
|
+
await startActivationTask({
|
|
1458
|
+
taskId: "draft-email",
|
|
1459
|
+
conversationId: "conv-9",
|
|
1460
|
+
});
|
|
1461
|
+
expect(readRawProgress().tasks["draft-email"]).toMatchObject({
|
|
1462
|
+
status: "started",
|
|
1463
|
+
conversationId: "conv-9",
|
|
1464
|
+
});
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
test("keeps a finished task's history and drops only the dead link", async () => {
|
|
1468
|
+
await startActivationTask({
|
|
1469
|
+
taskId: "draft-email",
|
|
1470
|
+
conversationId: "conv-1",
|
|
1471
|
+
});
|
|
1472
|
+
await markActivationTurnComplete({
|
|
1473
|
+
conversationId: "conv-1",
|
|
1474
|
+
toolCallCount: 3,
|
|
1475
|
+
endedAwaitingUser: false,
|
|
1476
|
+
artifacts: [{ workspacePath: "notes.md", displayName: "notes.md" }],
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
await forgetActivationConversation("conv-1");
|
|
1480
|
+
|
|
1481
|
+
expect(readRawProgress().tasks["draft-email"]).toMatchObject({
|
|
1482
|
+
status: "done",
|
|
1483
|
+
conversationId: "",
|
|
1484
|
+
stepCount: 3,
|
|
1485
|
+
artifacts: [{ workspacePath: "notes.md", displayName: "notes.md" }],
|
|
1486
|
+
});
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
test("leaves the tasks other conversations own alone", async () => {
|
|
1490
|
+
await startActivationTask({
|
|
1491
|
+
taskId: "draft-email",
|
|
1492
|
+
conversationId: "conv-1",
|
|
1493
|
+
});
|
|
1494
|
+
await startActivationTask({
|
|
1495
|
+
taskId: "book-travel",
|
|
1496
|
+
conversationId: "conv-2",
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
await forgetActivationConversation("conv-1");
|
|
1500
|
+
|
|
1501
|
+
const tasks = readRawProgress().tasks;
|
|
1502
|
+
expect(tasks["draft-email"]).toBeUndefined();
|
|
1503
|
+
expect(tasks["book-travel"]).toMatchObject({
|
|
1504
|
+
status: "started",
|
|
1505
|
+
conversationId: "conv-2",
|
|
1506
|
+
});
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
test("changes nothing when no task points at it", async () => {
|
|
1510
|
+
await startActivationTask({
|
|
1511
|
+
taskId: "draft-email",
|
|
1512
|
+
conversationId: "conv-1",
|
|
1513
|
+
});
|
|
1514
|
+
const publishesBefore = syncPublishCount();
|
|
1515
|
+
|
|
1516
|
+
await forgetActivationConversation("conv-unknown");
|
|
1517
|
+
|
|
1518
|
+
expect(syncPublishCount()).toBe(publishesBefore);
|
|
1519
|
+
expect(readRawProgress().tasks["draft-email"]).toMatchObject({
|
|
1520
|
+
status: "started",
|
|
1521
|
+
});
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
test("writes nothing for an assistant that never showed the checklist", async () => {
|
|
1525
|
+
await forgetActivationConversation("conv-1");
|
|
1526
|
+
|
|
1527
|
+
expect(existsSync(getActivationProgressPath())).toBe(false);
|
|
1528
|
+
expect(existsSync(getActivationProgressLockPath())).toBe(false);
|
|
1529
|
+
expect(syncPublishCount()).toBe(0);
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
test("the clear-all path releases every conversation at once", async () => {
|
|
1533
|
+
await startActivationTask({
|
|
1534
|
+
taskId: "draft-email",
|
|
1535
|
+
conversationId: "conv-1",
|
|
1536
|
+
});
|
|
1537
|
+
await markActivationTurnComplete({
|
|
1538
|
+
conversationId: "conv-1",
|
|
1539
|
+
toolCallCount: 2,
|
|
1540
|
+
endedAwaitingUser: false,
|
|
1541
|
+
artifacts: [],
|
|
1542
|
+
});
|
|
1543
|
+
await startActivationTask({
|
|
1544
|
+
taskId: "book-travel",
|
|
1545
|
+
conversationId: "conv-2",
|
|
1546
|
+
});
|
|
1547
|
+
await startActivationTask({
|
|
1548
|
+
taskId: "plan-week",
|
|
1549
|
+
conversationId: "conv-3",
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
await forgetAllActivationConversations();
|
|
1553
|
+
|
|
1554
|
+
const tasks = readRawProgress().tasks;
|
|
1555
|
+
expect(tasks["book-travel"]).toBeUndefined();
|
|
1556
|
+
expect(tasks["plan-week"]).toBeUndefined();
|
|
1557
|
+
expect(tasks["draft-email"]).toMatchObject({
|
|
1558
|
+
status: "done",
|
|
1559
|
+
conversationId: "",
|
|
1560
|
+
stepCount: 2,
|
|
1561
|
+
});
|
|
1562
|
+
});
|
|
1563
|
+
});
|
|
1564
|
+
});
|