@desplega.ai/agent-swarm 1.100.2 → 1.100.4

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.
@@ -0,0 +1,570 @@
1
+ /**
2
+ * DES-523: Lead reroute-decision fallback for crash-recovery resumes that were
3
+ * pinned to their original agent but never reclaimed (the agent that looked
4
+ * recoverable never returned).
5
+ *
6
+ * Phase 2 (here) exercises the capability `createRerouteDecisionTask` directly.
7
+ * Phase 3 extends this file with the heartbeat reaper that invokes it.
8
+ *
9
+ * Mirrors heartbeat-supersede-resume.test.ts's own-sqlite-file setup, plus the
10
+ * `../tools/templates` side-effect import so `task.reroute.decision` resolves.
11
+ */
12
+
13
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
+ import { unlink } from "node:fs/promises";
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
17
+ import {
18
+ closeDb,
19
+ createAgent,
20
+ createTaskExtended,
21
+ failPendingResumeIfUnclaimed,
22
+ getChildTasks,
23
+ getDb,
24
+ getLeadAgent,
25
+ getTaskById,
26
+ initDb,
27
+ startTask,
28
+ supersedeTask,
29
+ } from "../be/db";
30
+ import { createTrackerSync, getTrackerSync } from "../be/db-queries/tracker";
31
+ import {
32
+ codeLevelTriage,
33
+ HEARTBEAT_RESUME_PIN_GRACE_MIN,
34
+ MAX_RESUME_GENERATIONS,
35
+ RESUME_BUDGET_EXHAUSTED_REASON,
36
+ } from "../heartbeat/heartbeat";
37
+ import {
38
+ CRASH_RECOVERY_PIN_TAG,
39
+ createRerouteDecisionTask,
40
+ createResumeFollowUp,
41
+ RESUME_GENERATION_TAG_PREFIX,
42
+ } from "../tasks/worker-follow-up";
43
+ import { registerSendTaskTool } from "../tools/send-task";
44
+ // Side-effect import: registers task lifecycle templates (incl. task.reroute.decision).
45
+ import "../tools/templates";
46
+
47
+ const TEST_DB_PATH = "./test-heartbeat-reroute-decision.sqlite";
48
+
49
+ /**
50
+ * Build the post-crash state: a superseded original + a pending resume R1
51
+ * (generation 1) pinned to the recoverable-looking agent.
52
+ */
53
+ function seedPinnedCrash(agentName: string) {
54
+ const agent = createAgent({ name: agentName, isLead: false, status: "idle" });
55
+ const original = createTaskExtended("Crashed worker's original work", { agentId: agent.id });
56
+ startTask(original.id);
57
+ const r1 = createTaskExtended("Resume of crashed work", {
58
+ agentId: agent.id,
59
+ parentTaskId: original.id,
60
+ taskType: "resume",
61
+ // Mirror a genuine same-agent crash pin: the crash-pin tag is what scopes the
62
+ // reaper's getStalePinnedResumes (pooled resumes never get it).
63
+ tags: [
64
+ "auto-resume",
65
+ "reason:crash_recovery",
66
+ `${RESUME_GENERATION_TAG_PREFIX}1`,
67
+ CRASH_RECOVERY_PIN_TAG,
68
+ ],
69
+ });
70
+ supersedeTask(original.id, { reason: "crash", resumeTaskId: r1.id });
71
+ return { agent, original: getTaskById(original.id)!, r1 };
72
+ }
73
+
74
+ type RegisteredTool = {
75
+ handler: (args: unknown, extra: unknown) => Promise<CallToolResult>;
76
+ };
77
+
78
+ /** Invoke the registered send-task MCP tool as `callerAgentId` (mirrors send-task-requested-by.test.ts). */
79
+ function callSendTask(
80
+ server: McpServer,
81
+ args: Record<string, unknown>,
82
+ callerAgentId: string,
83
+ sourceTaskId?: string,
84
+ ): Promise<CallToolResult> {
85
+ const tools = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
86
+ ._registeredTools;
87
+ const tool = tools["send-task"];
88
+ if (!tool) throw new Error("send-task not registered");
89
+ const headers: Record<string, string> = { "x-agent-id": callerAgentId };
90
+ if (sourceTaskId) headers["x-source-task-id"] = sourceTaskId;
91
+ return tool.handler(args, { sessionId: "test-session", requestInfo: { headers } });
92
+ }
93
+
94
+ function structuredOf(result: CallToolResult) {
95
+ return result.structuredContent as { success: boolean; task?: { id: string }; message: string };
96
+ }
97
+
98
+ /** Age a row's createdAt so the reaper's grace window (measured from createdAt) has elapsed. */
99
+ function ageCreatedAtPastGrace(taskId: string) {
100
+ const old = new Date(
101
+ Date.now() - (HEARTBEAT_RESUME_PIN_GRACE_MIN + 10) * 60 * 1000,
102
+ ).toISOString();
103
+ getDb().run("UPDATE agent_tasks SET createdAt = ? WHERE id = ?", [old, taskId]);
104
+ }
105
+
106
+ describe("Heartbeat — reroute-decision fallback (DES-523)", () => {
107
+ const sendTaskServer = new McpServer({ name: "test-reroute-send-task", version: "1.0.0" });
108
+ registerSendTaskTool(sendTaskServer);
109
+
110
+ beforeAll(async () => {
111
+ try {
112
+ await unlink(TEST_DB_PATH);
113
+ } catch {
114
+ // File doesn't exist
115
+ }
116
+ closeDb();
117
+ initDb(TEST_DB_PATH);
118
+ });
119
+
120
+ afterAll(async () => {
121
+ closeDb();
122
+ for (const path of [TEST_DB_PATH, `${TEST_DB_PATH}-wal`, `${TEST_DB_PATH}-shm`]) {
123
+ try {
124
+ await unlink(path);
125
+ } catch {
126
+ // Files may not exist
127
+ }
128
+ }
129
+ });
130
+
131
+ beforeEach(() => {
132
+ getDb().run("DELETE FROM tracker_sync");
133
+ getDb().run("DELETE FROM agent_tasks");
134
+ getDb().run("DELETE FROM agents");
135
+ getDb().run("DELETE FROM active_sessions");
136
+ });
137
+
138
+ // --------------------------------------------------------------------------
139
+ // Phase 2 — capability (createRerouteDecisionTask) invoked directly
140
+ // --------------------------------------------------------------------------
141
+
142
+ test("creates a Lead-owned reroute-decision; resume not pooled, original not reassigned to Lead", () => {
143
+ const lead = createAgent({ name: "lead", isLead: true, status: "busy" });
144
+ const { agent, original, r1 } = seedPinnedCrash("coder-7");
145
+ // Give the crashed agent an identity slice so the template carries context.
146
+ getDb().run("UPDATE agents SET identityMd = ? WHERE id = ?", [
147
+ "Senior backend coder — owns the billing service.",
148
+ agent.id,
149
+ ]);
150
+
151
+ const result = createRerouteDecisionTask({
152
+ original,
153
+ staleResume: r1,
154
+ reason: "crash_recovery",
155
+ maxGenerations: MAX_RESUME_GENERATIONS,
156
+ });
157
+
158
+ expect(result.kind).toBe("created");
159
+ if (result.kind !== "created") throw new Error("expected created");
160
+ const decision = result.task;
161
+
162
+ // Lead-owned decision task, distinct taskType, parented to the original.
163
+ expect(decision.agentId).toBe(lead.id);
164
+ expect(decision.taskType).toBe("reroute-decision");
165
+ expect(decision.parentTaskId).toBe(original.id);
166
+ expect(decision.status).toBe("pending");
167
+
168
+ // Body: references the original, the crashed-agent identity, and the
169
+ // mandatory send-task re-delegation instructions.
170
+ expect(decision.task).toContain(original.id);
171
+ expect(decision.task).toContain("coder-7");
172
+ expect(decision.task).toContain("billing service");
173
+ expect(decision.task).toContain("send-task");
174
+ expect(decision.task).toContain('taskType: "resume"');
175
+ // Generation derived from the FAILED PIN (R1 = gen 1) → next = 2 (of max).
176
+ expect(decision.task).toContain(`${RESUME_GENERATION_TAG_PREFIX}2`);
177
+ expect(decision.task).toContain(`2 of ${MAX_RESUME_GENERATIONS}`);
178
+ // No unresolved template variables leaked into the body.
179
+ expect(decision.task).not.toContain("{{");
180
+
181
+ // The resume R1 is NOT pooled — still pending, still pinned to the agent.
182
+ const reFetchedR1 = getTaskById(r1.id)!;
183
+ expect(reFetchedR1.status).toBe("pending");
184
+ expect(reFetchedR1.agentId).toBe(agent.id);
185
+
186
+ // The original work task was NOT reassigned to the Lead.
187
+ expect(getTaskById(original.id)!.agentId).toBe(agent.id);
188
+ });
189
+
190
+ test("idempotent: a second call does not create a duplicate decision", () => {
191
+ createAgent({ name: "lead", isLead: true, status: "busy" });
192
+ const { original, r1 } = seedPinnedCrash("coder-dup");
193
+
194
+ const first = createRerouteDecisionTask({
195
+ original,
196
+ staleResume: r1,
197
+ reason: "crash_recovery",
198
+ maxGenerations: MAX_RESUME_GENERATIONS,
199
+ });
200
+ expect(first.kind).toBe("created");
201
+
202
+ const second = createRerouteDecisionTask({
203
+ original,
204
+ staleResume: r1,
205
+ reason: "crash_recovery",
206
+ maxGenerations: MAX_RESUME_GENERATIONS,
207
+ });
208
+ expect(second.kind).toBe("skipped");
209
+ if (second.kind === "skipped") expect(second.reason).toBe("duplicate_exists");
210
+
211
+ const decisions = getChildTasks(original.id).filter((c) => c.taskType === "reroute-decision");
212
+ expect(decisions.length).toBe(1);
213
+ });
214
+
215
+ test("no lead agent → no-op (skipped: lead_not_found), no decision created", () => {
216
+ const { original, r1 } = seedPinnedCrash("coder-nolead"); // no lead created
217
+
218
+ const result = createRerouteDecisionTask({
219
+ original,
220
+ staleResume: r1,
221
+ reason: "crash_recovery",
222
+ maxGenerations: MAX_RESUME_GENERATIONS,
223
+ });
224
+ expect(result.kind).toBe("skipped");
225
+ if (result.kind === "skipped") expect(result.reason).toBe("lead_not_found");
226
+ expect(getChildTasks(original.id).filter((c) => c.taskType === "reroute-decision").length).toBe(
227
+ 0,
228
+ );
229
+ });
230
+
231
+ // NOTE: a dedicated "template resolves with no unresolved variables" test was
232
+ // removed — it depended on the shared in-memory template registry (cleared by
233
+ // prompt-template-resolver.test.ts in the same `bun test` process) and flaked.
234
+ // The property is already covered by the first Phase-2 test above, which renders
235
+ // the template via createRerouteDecisionTask and asserts `not.toContain("{{")`.
236
+
237
+ // --------------------------------------------------------------------------
238
+ // Phase 3 — reaper (escalateUnreclaimedResumes, run via codeLevelTriage)
239
+ // --------------------------------------------------------------------------
240
+
241
+ test("pinned resume older than grace → escalated to a Lead decision exactly once (idempotent)", async () => {
242
+ const lead = createAgent({ name: "lead", isLead: true, status: "busy" });
243
+ const { original, r1 } = seedPinnedCrash("coder-grace");
244
+ ageCreatedAtPastGrace(r1.id);
245
+
246
+ const first = await codeLevelTriage();
247
+ expect(first.escalatedReroutes.length).toBe(1);
248
+ expect(first.escalatedReroutes[0]!.originalTaskId).toBe(original.id);
249
+
250
+ // R1 was terminalized (no longer pending) and a Lead-owned decision exists.
251
+ expect(getTaskById(r1.id)!.status).not.toBe("pending");
252
+ const decisions = getChildTasks(original.id).filter((c) => c.taskType === "reroute-decision");
253
+ expect(decisions.length).toBe(1);
254
+ expect(decisions[0]!.agentId).toBe(lead.id);
255
+
256
+ // Idempotent: second sweep — R1 is no longer pending, so it is not re-escalated.
257
+ const second = await codeLevelTriage();
258
+ expect(second.escalatedReroutes.length).toBe(0);
259
+ expect(getChildTasks(original.id).filter((c) => c.taskType === "reroute-decision").length).toBe(
260
+ 1,
261
+ );
262
+ });
263
+
264
+ test("pinned resume reclaimed (in_progress) before the grace window is NOT escalated", async () => {
265
+ createAgent({ name: "lead", isLead: true, status: "busy" });
266
+ const { original, r1 } = seedPinnedCrash("coder-reclaim");
267
+ ageCreatedAtPastGrace(r1.id);
268
+ // The original agent returned and reclaimed it: pending → in_progress. startTask
269
+ // also refreshes lastUpdatedAt, so the stall detector leaves it alone too.
270
+ startTask(r1.id);
271
+ expect(getTaskById(r1.id)!.status).toBe("in_progress");
272
+
273
+ const findings = await codeLevelTriage();
274
+ expect(findings.escalatedReroutes.length).toBe(0);
275
+ expect(getChildTasks(original.id).filter((c) => c.taskType === "reroute-decision").length).toBe(
276
+ 0,
277
+ );
278
+ // The reclaimed resume is untouched — the reaper's status='pending' clause excludes it.
279
+ expect(getTaskById(r1.id)!.status).toBe("in_progress");
280
+ });
281
+
282
+ test("pinned resume at the generation cap → terminalized as budget-exhausted, NOT escalated", async () => {
283
+ createAgent({ name: "lead", isLead: true, status: "busy" });
284
+ const agent = createAgent({ name: "coder-cap", isLead: false, status: "idle" });
285
+ const original = createTaskExtended("capped work", { agentId: agent.id });
286
+ startTask(original.id);
287
+ const capped = createTaskExtended("resume at cap", {
288
+ agentId: agent.id,
289
+ parentTaskId: original.id,
290
+ taskType: "resume",
291
+ tags: [
292
+ "auto-resume",
293
+ "reason:crash_recovery",
294
+ `${RESUME_GENERATION_TAG_PREFIX}${MAX_RESUME_GENERATIONS}`,
295
+ CRASH_RECOVERY_PIN_TAG,
296
+ ],
297
+ });
298
+ supersedeTask(original.id, { reason: "crash", resumeTaskId: capped.id });
299
+ ageCreatedAtPastGrace(capped.id);
300
+
301
+ const findings = await codeLevelTriage();
302
+ expect(findings.escalatedReroutes.length).toBe(0);
303
+ expect(getChildTasks(original.id).filter((c) => c.taskType === "reroute-decision").length).toBe(
304
+ 0,
305
+ );
306
+ const updated = getTaskById(capped.id)!;
307
+ expect(updated.status).toBe("failed");
308
+ expect(updated.failureReason).toBe(RESUME_BUDGET_EXHAUSTED_REASON);
309
+ });
310
+
311
+ test("tracker_sync chain on the gone-agent path: original → R1 → original → R2", async () => {
312
+ const lead = createAgent({ name: "lead", isLead: true, status: "busy" });
313
+ const agentA = createAgent({ name: "coder-A", isLead: false, status: "idle" });
314
+ const agentB = createAgent({ name: "coder-B", isLead: false, status: "idle" });
315
+ const original = createTaskExtended("tracked crashed work", { agentId: agentA.id });
316
+ startTask(original.id);
317
+ createTrackerSync({
318
+ provider: "linear",
319
+ entityType: "task",
320
+ swarmId: original.id,
321
+ externalId: "ENG-900",
322
+ externalIdentifier: "ENG-900",
323
+ externalUrl: "https://linear.app/test/issue/ENG-900",
324
+ });
325
+
326
+ // Pin via the real path: supersede frees capacity, then createResumeFollowUp pins
327
+ // R1 to agentA AND repoints the tracker original → R1.
328
+ supersedeTask(original.id, { reason: "crash", resumeTaskId: null });
329
+ const pin = createResumeFollowUp({ parentId: original.id, reason: "crash_recovery" });
330
+ expect(pin.kind).toBe("created");
331
+ if (pin.kind !== "created") throw new Error("expected pin");
332
+ const r1 = pin.task;
333
+ expect(r1.agentId).toBe(agentA.id);
334
+ expect(getTrackerSync("linear", "task", r1.id)?.externalId).toBe("ENG-900");
335
+ expect(getTrackerSync("linear", "task", original.id)).toBeNull();
336
+
337
+ // Reaper: R1 stale → terminalize + repoint tracker R1 → original + Lead decision.
338
+ ageCreatedAtPastGrace(r1.id);
339
+ const findings = await codeLevelTriage();
340
+ expect(findings.escalatedReroutes.length).toBe(1);
341
+ expect(getTaskById(r1.id)!.status).not.toBe("pending");
342
+ expect(getTrackerSync("linear", "task", original.id)?.externalId).toBe("ENG-900");
343
+ expect(getTrackerSync("linear", "task", r1.id)).toBeNull();
344
+
345
+ const decision = getChildTasks(original.id).find((c) => c.taskType === "reroute-decision");
346
+ expect(decision).toBeDefined();
347
+
348
+ // Lead re-delegates to agent B via send-task (taskType resume, parentTaskId original).
349
+ // transferTrackerSyncToResumeChild repoints the tracker original → R2.
350
+ const result = await callSendTask(
351
+ sendTaskServer,
352
+ {
353
+ task: "Resume the crashed work on B",
354
+ agentId: agentB.id,
355
+ taskType: "resume",
356
+ parentTaskId: original.id,
357
+ allowDuplicate: true,
358
+ },
359
+ lead.id,
360
+ decision!.id,
361
+ );
362
+ const s = structuredOf(result);
363
+ expect(s.success).toBe(true);
364
+ const r2Id = s.task!.id;
365
+ expect(getTrackerSync("linear", "task", r2Id)?.externalId).toBe("ENG-900");
366
+ expect(getTrackerSync("linear", "task", original.id)).toBeNull();
367
+ expect(getTrackerSync("linear", "task", r1.id)).toBeNull();
368
+ });
369
+
370
+ test("a fresh (within-grace) crash pin is NOT escalated by the reaper", async () => {
371
+ createAgent({ name: "lead", isLead: true, status: "busy" });
372
+ const { original, r1 } = seedPinnedCrash("coder-fresh");
373
+ // Deliberately do NOT age createdAt — the pin is well within the grace
374
+ // window, so the reaper must leave it alone. (Guards against a regression
375
+ // that drops/inverts the createdAt cutoff and escalates pins immediately.)
376
+
377
+ const findings = await codeLevelTriage();
378
+
379
+ expect(findings.escalatedReroutes.length).toBe(0);
380
+ expect(getTaskById(r1.id)!.status).toBe("pending"); // still pinned, untouched
381
+ expect(getChildTasks(original.id).filter((c) => c.taskType === "reroute-decision").length).toBe(
382
+ 0,
383
+ );
384
+ });
385
+
386
+ test("failPendingResumeIfUnclaimed cancels a pending resume but no-ops a reclaimed (in_progress) one", () => {
387
+ const agent = createAgent({ name: "toctou-agent", isLead: false, status: "idle" });
388
+ const parent = createTaskExtended("toctou parent", { agentId: agent.id });
389
+ startTask(parent.id);
390
+
391
+ // (a) pending → terminalized, row returned.
392
+ const pendingResume = createTaskExtended("pending resume", {
393
+ agentId: agent.id,
394
+ parentTaskId: parent.id,
395
+ taskType: "resume",
396
+ });
397
+ expect(pendingResume.status).toBe("pending");
398
+ const cancelled = failPendingResumeIfUnclaimed(pendingResume.id, "cancelled", "test_reason");
399
+ expect(cancelled).not.toBeNull();
400
+ expect(cancelled!.status).toBe("cancelled");
401
+ expect(getTaskById(pendingResume.id)!.status).toBe("cancelled");
402
+
403
+ // (b) in_progress (reclaimed in the gap) → null, status untouched. This is
404
+ // the load-bearing TOCTOU guard (AND status='pending') the function exists for.
405
+ const reclaimed = createTaskExtended("reclaimed resume", {
406
+ agentId: agent.id,
407
+ parentTaskId: parent.id,
408
+ taskType: "resume",
409
+ });
410
+ startTask(reclaimed.id);
411
+ expect(getTaskById(reclaimed.id)!.status).toBe("in_progress");
412
+ const result = failPendingResumeIfUnclaimed(reclaimed.id, "cancelled", "test_reason");
413
+ expect(result).toBeNull();
414
+ expect(getTaskById(reclaimed.id)!.status).toBe("in_progress");
415
+ });
416
+
417
+ test("a pooled (untagged) resume auto-assigned in the same sweep is NOT reaped", async () => {
418
+ // Regression for the same-sweep race: getStalePinnedResumes used to match ANY
419
+ // pending resume with an old createdAt. autoAssignPoolTasks (runs before the
420
+ // reaper in the same codeLevelTriage sweep) flips a lingering unassigned
421
+ // resume to `pending` keeping its old createdAt, and the reaper would then
422
+ // cancel it before the assigned worker polls. The crash-pin tag scoping fixes
423
+ // it — a pooled resume never carries the tag.
424
+ createAgent({ name: "lead", isLead: true, status: "busy" });
425
+ const worker = createAgent({ name: "idle-worker", isLead: false, status: "idle" });
426
+ const original = createTaskExtended("pooled original", { agentId: worker.id });
427
+ startTask(original.id);
428
+ const pooled = createTaskExtended("pooled resume (no pin tag)", {
429
+ parentTaskId: original.id,
430
+ taskType: "resume",
431
+ tags: ["auto-resume", "reason:graceful_shutdown", `${RESUME_GENERATION_TAG_PREFIX}1`],
432
+ });
433
+ expect(pooled.status).toBe("unassigned");
434
+ supersedeTask(original.id, { reason: "shutdown", resumeTaskId: pooled.id });
435
+ ageCreatedAtPastGrace(pooled.id);
436
+
437
+ const findings = await codeLevelTriage();
438
+
439
+ // autoAssignPoolTasks assigned it to the idle worker; the reaper left it alone.
440
+ const after = getTaskById(pooled.id)!;
441
+ expect(after.status).toBe("pending");
442
+ expect(after.agentId).toBe(worker.id);
443
+ expect(findings.escalatedReroutes.length).toBe(0);
444
+ expect(getChildTasks(original.id).filter((c) => c.taskType === "reroute-decision").length).toBe(
445
+ 0,
446
+ );
447
+ });
448
+
449
+ test("a pin at generation MAX-1 escalates (not budget-failed) with next-generation = MAX", async () => {
450
+ createAgent({ name: "lead", isLead: true, status: "busy" });
451
+ const agent = createAgent({ name: "coder-genmax", isLead: false, status: "idle" });
452
+ const original = createTaskExtended("near-cap work", { agentId: agent.id });
453
+ startTask(original.id);
454
+ const r = createTaskExtended("resume near cap", {
455
+ agentId: agent.id,
456
+ parentTaskId: original.id,
457
+ taskType: "resume",
458
+ tags: [
459
+ "auto-resume",
460
+ "reason:crash_recovery",
461
+ `${RESUME_GENERATION_TAG_PREFIX}${MAX_RESUME_GENERATIONS - 1}`,
462
+ CRASH_RECOVERY_PIN_TAG,
463
+ ],
464
+ });
465
+ supersedeTask(original.id, { reason: "crash", resumeTaskId: r.id });
466
+ ageCreatedAtPastGrace(r.id);
467
+
468
+ const findings = await codeLevelTriage();
469
+
470
+ expect(findings.escalatedReroutes.length).toBe(1);
471
+ const decision = getChildTasks(original.id).find((c) => c.taskType === "reroute-decision");
472
+ expect(decision).toBeDefined();
473
+ // generation_next derives from the failed pin (MAX-1) → MAX.
474
+ expect(decision!.task).toContain(`${RESUME_GENERATION_TAG_PREFIX}${MAX_RESUME_GENERATIONS}`);
475
+ });
476
+
477
+ // --------------------------------------------------------------------------
478
+ // Review hardening (codex PR review on #791)
479
+ // --------------------------------------------------------------------------
480
+
481
+ test("reroute-decision does NOT inherit the original's outputSchema (control task stays completable)", () => {
482
+ createAgent({ name: "lead", isLead: true, status: "busy" });
483
+ const agent = createAgent({ name: "coder-schema", isLead: false, status: "idle" });
484
+ const original = createTaskExtended("work with a strict output contract", {
485
+ agentId: agent.id,
486
+ outputSchema: {
487
+ type: "object",
488
+ required: ["answer"],
489
+ properties: { answer: { type: "string" } },
490
+ },
491
+ });
492
+ startTask(original.id);
493
+ // A normal resume child DOES inherit the schema (proves inheritance is the default
494
+ // and the decision's opt-out is targeted, not a global change).
495
+ const r1 = createTaskExtended("resume of schema work", {
496
+ agentId: agent.id,
497
+ parentTaskId: original.id,
498
+ taskType: "resume",
499
+ tags: [
500
+ "auto-resume",
501
+ "reason:crash_recovery",
502
+ `${RESUME_GENERATION_TAG_PREFIX}1`,
503
+ CRASH_RECOVERY_PIN_TAG,
504
+ ],
505
+ });
506
+ expect(r1.outputSchema).toBeDefined();
507
+ supersedeTask(original.id, { reason: "crash", resumeTaskId: r1.id });
508
+
509
+ const result = createRerouteDecisionTask({
510
+ original: getTaskById(original.id)!,
511
+ staleResume: r1,
512
+ reason: "crash_recovery",
513
+ maxGenerations: MAX_RESUME_GENERATIONS,
514
+ });
515
+ expect(result.kind).toBe("created");
516
+ if (result.kind !== "created") throw new Error("expected created");
517
+ // The Lead completes this control task by re-delegating, not by producing the
518
+ // original work's structured output — so it must NOT carry the contract.
519
+ expect(result.task.outputSchema).toBeUndefined();
520
+ });
521
+
522
+ test("offline-only Lead → reaper leaves the pin pending (no escalation to an unpollable Lead)", async () => {
523
+ createAgent({ name: "stale-lead", isLead: true, status: "offline" });
524
+ const { original, r1 } = seedPinnedCrash("coder-offlinelead");
525
+ ageCreatedAtPastGrace(r1.id);
526
+
527
+ const findings = await codeLevelTriage();
528
+
529
+ expect(findings.escalatedReroutes.length).toBe(0);
530
+ // Pin is left pending (recoverable when a live Lead returns), NOT cancelled.
531
+ expect(getTaskById(r1.id)!.status).toBe("pending");
532
+ expect(getChildTasks(original.id).filter((c) => c.taskType === "reroute-decision").length).toBe(
533
+ 0,
534
+ );
535
+ });
536
+
537
+ test("getLeadAgent prefers a non-offline lead, falls back to any when all offline", () => {
538
+ const offline = createAgent({ name: "old-lead", isLead: true, status: "offline" });
539
+ const online = createAgent({ name: "new-lead", isLead: true, status: "idle" });
540
+ // The offline lead was registered first but must not shadow the live one.
541
+ expect(getLeadAgent()!.id).toBe(online.id);
542
+ // When every lead is offline, still return one (preserves "is there a lead?" semantics).
543
+ getDb().run("UPDATE agents SET status = 'offline' WHERE id = ?", [online.id]);
544
+ expect(getLeadAgent()!.isLead).toBe(true);
545
+ expect(offline.isLead).toBe(true); // (referenced to keep the binding meaningful)
546
+ });
547
+
548
+ test("a crashed reroute-decision is NOT auto-resumed — failed, no crash-recovery pin (no nested decisions)", async () => {
549
+ createAgent({ name: "lead", isLead: true, status: "busy" });
550
+ const agent = createAgent({ name: "coder-control", isLead: false, status: "idle" });
551
+ const original = createTaskExtended("original user work", { agentId: agent.id });
552
+ // A Lead-owned reroute-decision the Lead started, then crashed on.
553
+ const decision = createTaskExtended("decide where to reroute", {
554
+ agentId: agent.id,
555
+ parentTaskId: original.id,
556
+ taskType: "reroute-decision",
557
+ tags: ["reroute-decision"],
558
+ });
559
+ startTask(decision.id); // in_progress
560
+ // Stale + no active session → the no-session crash branch (Case A).
561
+ const old = new Date(Date.now() - 60 * 60 * 1000).toISOString();
562
+ getDb().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [old, decision.id]);
563
+
564
+ await codeLevelTriage();
565
+
566
+ // Failed via the legacy path (skip-auto-resume), NOT superseded into a resume.
567
+ expect(getTaskById(decision.id)!.status).toBe("failed");
568
+ expect(getChildTasks(decision.id).filter((c) => c.taskType === "resume").length).toBe(0);
569
+ });
570
+ });