@desplega.ai/agent-swarm 1.147.0 → 1.149.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/README.md +13 -1
- package/dist/{acp-adapter-9n319wqc.js → acp-adapter-4jncb126.js} +12 -5
- package/dist/{actions-2vxqvpr9.js → actions-31vp3ae7.js} +10 -9
- package/dist/{app-w066xfy0.js → app-1hh67x8w.js} +6 -6
- package/dist/{assistant-kt31dj2h.js → assistant-z66dmz6p.js} +13 -11
- package/dist/{boot-reembed-az5rassp.js → boot-reembed-fqzxxzjh.js} +7 -7
- package/dist/{boot-reembed-j3mm3rfz.js → boot-reembed-wxa8ya0s.js} +8 -8
- package/dist/{boot-scrub-logs-b6h54817.js → boot-scrub-logs-jnyndh79.js} +5 -5
- package/dist/{claude-adapter-w1z04ann.js → claude-adapter-3qpa9w0j.js} +9 -9
- package/dist/{claude-managed-adapter-sactwn31.js → claude-managed-adapter-qvsmh6mv.js} +2 -2
- package/dist/{claude-sdk-session-f0cc1kec.js → claude-sdk-session-2yxgjs5d.js} +9 -9
- package/dist/{cli-y6b6fb76.js → cli-08b07b4r.js} +1 -1
- package/dist/{cli-8fc8de14.js → cli-0z2v4nhw.js} +1 -1
- package/dist/{cli-de14znh8.js → cli-19f354qr.js} +2 -2
- package/dist/{cli-hbv0kq6w.js → cli-2qts1hys.js} +3 -3
- package/dist/{cli-kpv03zhj.js → cli-3apbzgyk.js} +27 -15
- package/dist/{cli-1th1728g.js → cli-3q5ejx9p.js} +2 -2
- package/dist/{cli-d8brzxjb.js → cli-4wtq5jjv.js} +2 -2
- package/dist/{cli-ct0et58h.js → cli-5my3bjsd.js} +1 -1
- package/dist/{cli-cn6mmc7f.js → cli-6664w7y4.js} +1 -1
- package/dist/{cli-yz3djrm0.js → cli-6pqcb4q0.js} +53 -13
- package/dist/{cli-719k9j2c.js → cli-7smrsr25.js} +3 -3
- package/dist/{cli-rw51bq3j.js → cli-9fy6nk6g.js} +1 -1
- package/dist/{cli-zhjmqxzh.js → cli-9hspd3dp.js} +2 -2
- package/dist/{cli-3tgwnf43.js → cli-aae6hz6a.js} +4 -4
- package/dist/{cli-89xfd7f9.js → cli-cr12paw2.js} +5 -5
- package/dist/{cli-b5bq9crk.js → cli-d8ftsp62.js} +8 -8
- package/dist/{cli-4nr988h6.js → cli-e95cx1eb.js} +3 -3
- package/dist/{cli-n8508nre.js → cli-gjhjfeeg.js} +1 -1
- package/dist/{cli-p1d5b073.js → cli-gv88e1vf.js} +1 -1
- package/dist/{cli-rvqz34y5.js → cli-jecc01cb.js} +1 -1
- package/dist/{cli-8v0g1yc8.js → cli-k0cr4kat.js} +1 -1
- package/dist/{cli-mmemxxdc.js → cli-kgz4np97.js} +4 -4
- package/dist/{cli-w9kb3bk1.js → cli-kjwt6hdf.js} +3 -1
- package/dist/{cli-f7dsy61y.js → cli-mnmsfd1w.js} +6 -2
- package/dist/{cli-54wwve05.js → cli-n3dz2942.js} +37 -26
- package/dist/{cli-12fz972k.js → cli-nye12xk5.js} +8 -5
- package/dist/{cli-tzvk9haz.js → cli-phsxnkp6.js} +20 -20
- package/dist/{cli-53s590z8.js → cli-q50ef8g0.js} +1 -1
- package/dist/{cli-f146mn5s.js → cli-qaacms9y.js} +7 -6
- package/dist/{cli-xqcq9y5e.js → cli-r2ap2czm.js} +1 -0
- package/dist/{cli-m8yrsg97.js → cli-sd5pv3b1.js} +3 -3
- package/dist/{cli-0b6y7kxm.js → cli-xm87hzbw.js} +1 -1
- package/dist/{cli-ftht3zzj.js → cli-yab68w40.js} +285 -11
- package/dist/{cli-atcve9y8.js → cli-yb9qhqam.js} +227 -59
- package/dist/{cli-smby5m17.js → cli-ygweqcn4.js} +133 -22
- package/dist/{cli-dttph1bw.js → cli-ykxrnd1q.js} +2 -2
- package/dist/{cli-m9sxbkhm.js → cli-zvz62chv.js} +3 -3
- package/dist/cli.js +16 -14
- package/dist/{codex-adapter-zw4dwsx7.js → codex-adapter-8meawydj.js} +4 -4
- package/dist/{codex-hook-bw8p5nh4.js → codex-hook-hfrt9shr.js} +2 -2
- package/dist/{codex-session-runner-k981s65v.js → codex-session-runner-gjgxyp5j.js} +4 -4
- package/dist/{commands-sykrxx7e.js → commands-hz3nb95f.js} +5 -5
- package/dist/{db-sfbsc8wt.js → db-ggz97zbm.js} +5 -5
- package/dist/{e2b-6hb103d8.js → e2b-p04vmtx0.js} +1 -1
- package/dist/{handlers-0aspcejm.js → handlers-nbzdzn9k.js} +14 -11
- package/dist/{hook-hxccs7m5.js → hook-bks2q1f6.js} +7 -7
- package/dist/{hook-4nvqj2sb.js → hook-kq9wp0ep.js} +8 -8
- package/dist/{http-y06z517d.js → http-hrf77v87.js} +96 -45
- package/dist/{index-tce1rss4.js → index-6nsbg88z.js} +443 -144
- package/dist/{index-x5j894zf.js → index-8hmeg8j1.js} +14 -14
- package/dist/{index-k8znfx0z.js → index-cqtebbhb.js} +13 -13
- package/dist/{index-hssww7kj.js → index-n28pw1zt.js} +15 -15
- package/dist/{keepalive-w733ax66.js → keepalive-dxc12gvg.js} +7 -7
- package/dist/{lead-75bjqa9d.js → lead-yckfn18d.js} +32 -32
- package/dist/{maintenance-w4f1zjck.js → maintenance-6kfb2m5e.js} +8 -8
- package/dist/{oauth-refresh-sweep-wdetky26.js → oauth-refresh-sweep-1x1ac722.js} +6 -6
- package/dist/{onboard-bb5net8s.js → onboard-dcyt1609.js} +4 -3
- package/dist/{opencode-adapter-fef1mrn2.js → opencode-adapter-dce4wskd.js} +2 -2
- package/dist/{otel-impl-0w7c14ap.js → otel-impl-tbk3e5dr.js} +2 -2
- package/dist/{pi-mono-adapter-2has4gnp.js → pi-mono-adapter-zvshk5cj.js} +2 -2
- package/dist/{pricing-refresh-9nzsrgcq.js → pricing-refresh-kaqg567e.js} +7 -7
- package/dist/{rbac-roles-z1jrffbt.js → rbac-roles-ammq6rd2.js} +5 -5
- package/dist/{rbac-roles-r3hqjp09.js → rbac-roles-cf97v1vd.js} +6 -6
- package/dist/{render-v2-vbr89fnb.js → render-v2-80w603vy.js} +6 -6
- package/dist/{seed-pricing-g62hy1pk.js → seed-pricing-zbrn83xv.js} +6 -6
- package/dist/{setup-gnfqnptp.js → setup-fe66kczs.js} +2 -2
- package/dist/{worker-kznxp4qm.js → worker-qmb2d1e2.js} +32 -32
- package/dist/{x-n2phr5vm.js → x-n3e0v3xa.js} +2 -2
- package/openapi.json +46 -4
- package/package.json +3 -1
- package/src/agentmail/handlers.ts +5 -0
- package/src/automation-preflight-alert.ts +41 -0
- package/src/be/automation-preflight.ts +3 -3
- package/src/be/budget-refusal-notify.ts +1 -0
- package/src/be/db/tasks/read.ts +5 -0
- package/src/be/db.ts +132 -2
- package/src/be/migrations/150_deferred_task_waits.sql +18 -0
- package/src/be/migrations/151_repair_scheduled_task_required_params.sql +39 -0
- package/src/be/migrations/152_routing_decisions.sql +44 -0
- package/src/be/scripts/typecheck.ts +33 -22
- package/src/be/seed-scripts/catalog/delegate.ts +5 -1
- package/src/be/seed-skills/bundled-files.generated.json +10 -0
- package/src/be/seed-skills/index.ts +3 -0
- package/src/be/steering.ts +1 -0
- package/src/be/swarm-config-guard.ts +11 -0
- package/src/commands/onboard/steps/post-task.tsx +1 -0
- package/src/github/handlers.ts +9 -0
- package/src/gitlab/handlers.ts +4 -0
- package/src/heartbeat/heartbeat.ts +8 -0
- package/src/http/approval-requests.ts +1 -0
- package/src/http/apps.ts +1 -0
- package/src/http/config.ts +15 -6
- package/src/http/mcp.ts +31 -4
- package/src/http/script-runs.ts +6 -0
- package/src/http/tasks.ts +52 -30
- package/src/integrations/kapso/inbound.ts +1 -0
- package/src/jira/sync.ts +2 -0
- package/src/linear/sync.ts +2 -0
- package/src/prompts/session-templates.ts +29 -15
- package/src/scheduler/deferred-task-waits.ts +123 -0
- package/src/scheduler/schedule-task.ts +42 -0
- package/src/scheduler/scheduler.ts +22 -31
- package/src/script-workflows/workflow-ctx.ts +2 -0
- package/src/scripts-runtime/types/stdlib.d.ts +38 -24
- package/src/scripts-runtime/types/swarm-sdk.d.ts +38 -24
- package/src/server.ts +10 -1
- package/src/slack/actions.ts +1 -0
- package/src/slack/assistant.ts +2 -0
- package/src/slack/handlers.ts +3 -0
- package/src/slack/thread-buffer.ts +2 -0
- package/src/tasks/worker-follow-up.ts +9 -0
- package/src/tests/acp-adapter.test.ts +35 -6
- package/src/tests/acp-session-token.test.ts +67 -0
- package/src/tests/asset-key-api.test.ts +15 -2
- package/src/tests/asset-key-mcp.test.ts +2 -0
- package/src/tests/automation-preflight-alert.test.ts +91 -0
- package/src/tests/claude-worker-parity.test.ts +3 -0
- package/src/tests/config-session-auth.test.ts +143 -0
- package/src/tests/defer-task.test.ts +117 -6
- package/src/tests/deferred-task-wake.test.ts +456 -0
- package/src/tests/heartbeat-reroute-decision.test.ts +1 -0
- package/src/tests/http-api-integration.test.ts +33 -4
- package/src/tests/mcp-input-ergonomics.test.ts +386 -0
- package/src/tests/model-control.test.ts +18 -5
- package/src/tests/opencode-adapter.test.ts +2 -2
- package/src/tests/promote-draft-task-route.test.ts +4 -0
- package/src/tests/prompt-template-session.test.ts +13 -4
- package/src/tests/rbac-wire-e2e.test.ts +2 -2
- package/src/tests/routing-decision-persistence.test.ts +520 -0
- package/src/tests/routing-reason-contract.test.ts +89 -0
- package/src/tests/routing-reason-inventory.test.ts +55 -0
- package/src/tests/schedule-target-type.test.ts +48 -1
- package/src/tests/scheduled-task-required-params-migration.test.ts +182 -0
- package/src/tests/scheduled-tasks.test.ts +9 -5
- package/src/tests/script-connections.test.ts +4 -0
- package/src/tests/script-runs-http.test.ts +31 -0
- package/src/tests/scripts-typecheck.test.ts +11 -0
- package/src/tests/secret-scrubber.test.ts +12 -0
- package/src/tests/send-task-output-schema.test.ts +66 -1
- package/src/tests/send-task-requested-by.test.ts +1 -0
- package/src/tests/send-task-slack-routing-guard.test.ts +2 -0
- package/src/tests/store-progress-blocked-waiting-gate.test.ts +151 -0
- package/src/tests/swarm-tool-result-gate.test.ts +24 -0
- package/src/tests/task-tool-manifest.test.ts +47 -0
- package/src/tests/task-tool-preload-mcp.test.ts +125 -0
- package/src/tests/task-tools-ctx.test.ts +2 -0
- package/src/tests/tool-output-agent-id.test.ts +1 -0
- package/src/tests/ui-followup-lead-delegation.test.ts +6 -1
- package/src/tests/workflow-agent-task.test.ts +24 -1
- package/src/tests/workflow-engine-v2.test.ts +58 -2
- package/src/tools/accept-steer.ts +0 -1
- package/src/tools/defer-task.ts +73 -21
- package/src/tools/memory-get.ts +10 -3
- package/src/tools/memory-rate.ts +19 -7
- package/src/tools/memory-search.ts +2 -1
- package/src/tools/memory-store.ts +25 -5
- package/src/tools/send-task.ts +40 -2
- package/src/tools/skills/skill-publish.ts +1 -0
- package/src/tools/store-progress.ts +78 -4
- package/src/tools/templates.ts +2 -1
- package/src/tools/utils.ts +33 -2
- package/src/types.ts +14 -1
- package/src/utils/acp-session-token.ts +14 -4
- package/src/utils/secret-scrubber.ts +2 -0
- package/src/utils/task-tool-manifest.ts +33 -0
- package/src/workflows/engine.ts +4 -1
- package/src/workflows/executors/agent-task.ts +7 -1
- package/templates/ai-toolbox.manifest.json +6 -1
- package/templates/skills/comms/config.json +18 -0
- package/templates/skills/comms/content.md +74 -0
- package/templates/skills/comms/files/references/ste-rules.md +73 -0
- package/templates/skills/comms/files/references/visual-shapes.md +112 -0
- package/templates/skills/swarm-scripts/SKILL.md +3 -2
- package/templates/skills/swarm-scripts/content.md +3 -2
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
createWorkflow,
|
|
23
23
|
createWorkflowRun,
|
|
24
24
|
createWorkflowRunStep,
|
|
25
|
+
getLogsByTaskId,
|
|
25
26
|
getScheduledTasks,
|
|
26
27
|
getTaskById,
|
|
27
28
|
initDb,
|
|
@@ -183,7 +184,9 @@ describe("defer-task handler", () => {
|
|
|
183
184
|
const stored = await getTaskById(task.id);
|
|
184
185
|
expect(stored?.status).toBe("completed");
|
|
185
186
|
// Summary first, then the deferral status line, then the checks.
|
|
186
|
-
expect(stored?.output
|
|
187
|
+
expect(stored?.output).toBe(
|
|
188
|
+
`${SUMMARY}\n\nDeferred until ${schedule.nextRunAt} (schedule ${schedule.id}). Pending: deploy 42 is still running\n\nChecks:\n- deploy 42 status is green\n- smoke tests pass`,
|
|
189
|
+
);
|
|
187
190
|
expect(stored?.output).toContain("Pending: deploy 42 is still running");
|
|
188
191
|
expect(stored?.output).toContain(schedule.nextRunAt!);
|
|
189
192
|
expect(stored?.output).toContain(schedule.id);
|
|
@@ -313,7 +316,7 @@ describe("defer-task handler", () => {
|
|
|
313
316
|
expect((await getTaskById(task.id))?.status).toBe("in_progress");
|
|
314
317
|
});
|
|
315
318
|
|
|
316
|
-
test("a task with an outputSchema
|
|
319
|
+
test("a task with an outputSchema requires explicit output and books no schedule", async () => {
|
|
317
320
|
const task = await startedTask("structured output task", agentId, {
|
|
318
321
|
outputSchema: {
|
|
319
322
|
type: "object",
|
|
@@ -328,13 +331,103 @@ describe("defer-task handler", () => {
|
|
|
328
331
|
)) as DeferTaskResult;
|
|
329
332
|
|
|
330
333
|
expect(result.structuredContent.success).toBe(false);
|
|
331
|
-
expect(result.structuredContent.message).toContain(
|
|
334
|
+
expect(result.structuredContent.message).toContain(
|
|
335
|
+
"Call defer-task with output: a JSON string",
|
|
336
|
+
);
|
|
332
337
|
expect((await schedulesForTask(task.id)).length).toBe(0);
|
|
333
338
|
const stored = await getTaskById(task.id);
|
|
334
339
|
expect(stored?.status).toBe("in_progress");
|
|
335
340
|
expect(stored?.output).toBeFalsy();
|
|
336
341
|
});
|
|
337
342
|
|
|
343
|
+
test("valid schema output is preserved verbatim and deferral details are logged", async () => {
|
|
344
|
+
const task = await startedTask("structured deferral", agentId, {
|
|
345
|
+
outputSchema: {
|
|
346
|
+
type: "object",
|
|
347
|
+
required: ["result"],
|
|
348
|
+
properties: { result: { type: "string" } },
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
const output = ' { "result": "deploy pending" }\n';
|
|
352
|
+
const result = (await buildTool().handler(
|
|
353
|
+
{
|
|
354
|
+
taskId: task.id,
|
|
355
|
+
delayMs: 60_000,
|
|
356
|
+
summary: SUMMARY,
|
|
357
|
+
note: "pending deploy",
|
|
358
|
+
checks: ["check deploy"],
|
|
359
|
+
output,
|
|
360
|
+
},
|
|
361
|
+
meta(),
|
|
362
|
+
)) as DeferTaskResult;
|
|
363
|
+
|
|
364
|
+
expect(result.structuredContent.success).toBe(true);
|
|
365
|
+
const stored = await getTaskById(task.id);
|
|
366
|
+
expect(stored?.status).toBe("completed");
|
|
367
|
+
expect(stored?.output).toBe(output);
|
|
368
|
+
const schedules = await schedulesForTask(task.id);
|
|
369
|
+
expect(schedules).toHaveLength(1);
|
|
370
|
+
const schedule = schedules[0]!;
|
|
371
|
+
expect(schedule.taskTemplate).toBe(
|
|
372
|
+
`Resume task ${task.id}: pending deploy\n\nChecks:\n- check deploy`,
|
|
373
|
+
);
|
|
374
|
+
const logs = (await getLogsByTaskId(task.id)).filter(
|
|
375
|
+
(log) => log.eventType === "task_progress",
|
|
376
|
+
);
|
|
377
|
+
expect(logs).toHaveLength(1);
|
|
378
|
+
expect(logs[0]!.newValue).toBe(
|
|
379
|
+
`${SUMMARY}\n\nDeferred until ${schedule.nextRunAt} (schedule ${schedule.id}). Pending: pending deploy\n\nChecks:\n- check deploy`,
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test.each([
|
|
384
|
+
"not JSON",
|
|
385
|
+
'{"result": 42}',
|
|
386
|
+
])("invalid schema output %s has no side effects", async (output) => {
|
|
387
|
+
const task = await startedTask("invalid structured deferral", agentId, {
|
|
388
|
+
outputSchema: {
|
|
389
|
+
type: "object",
|
|
390
|
+
required: ["result"],
|
|
391
|
+
properties: { result: { type: "string" } },
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
const result = (await buildTool().handler(
|
|
395
|
+
{ taskId: task.id, delayMs: 60_000, summary: SUMMARY, note: "pending", output },
|
|
396
|
+
meta(),
|
|
397
|
+
)) as DeferTaskResult;
|
|
398
|
+
expect(result.structuredContent.success).toBe(false);
|
|
399
|
+
expect(result.structuredContent.message).toContain("outputSchema");
|
|
400
|
+
expect(await schedulesForTask(task.id)).toHaveLength(0);
|
|
401
|
+
expect((await getTaskById(task.id))?.status).toBe("in_progress");
|
|
402
|
+
expect((await getTaskById(task.id))?.output).toBeFalsy();
|
|
403
|
+
expect(
|
|
404
|
+
(await getLogsByTaskId(task.id)).filter((log) => log.eventType === "task_progress"),
|
|
405
|
+
).toHaveLength(0);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("JSON string schema output can defer", async () => {
|
|
409
|
+
const task = await startedTask("string output", agentId, { outputSchema: { type: "string" } });
|
|
410
|
+
const output = '"pending"';
|
|
411
|
+
const result = (await buildTool().handler(
|
|
412
|
+
{ taskId: task.id, delayMs: 60_000, summary: SUMMARY, note: "pending", output },
|
|
413
|
+
meta(),
|
|
414
|
+
)) as DeferTaskResult;
|
|
415
|
+
expect(result.structuredContent.success).toBe(true);
|
|
416
|
+
expect((await getTaskById(task.id))?.output).toBe(output);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("explicit output is ignored without a schema", async () => {
|
|
420
|
+
const task = await startedTask("unstructured output");
|
|
421
|
+
const result = (await buildTool().handler(
|
|
422
|
+
{ taskId: task.id, delayMs: 60_000, summary: SUMMARY, note: "pending", output: '"ignored"' },
|
|
423
|
+
meta(),
|
|
424
|
+
)) as DeferTaskResult;
|
|
425
|
+
expect(result.structuredContent.success).toBe(true);
|
|
426
|
+
expect((await getTaskById(task.id))?.output).toBe(
|
|
427
|
+
`${SUMMARY}\n\nDeferred until ${result.structuredContent.nextRunAt} (schedule ${result.structuredContent.scheduleId}). Pending: pending`,
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
338
431
|
test("the wake-up schedule carries modelTier but never the parent's concrete model", async () => {
|
|
339
432
|
const task = await startedTask("pinned model task", agentId, {
|
|
340
433
|
model: "claude-sonnet-5",
|
|
@@ -353,15 +446,27 @@ describe("defer-task handler", () => {
|
|
|
353
446
|
});
|
|
354
447
|
|
|
355
448
|
test("a competing terminal transition between schedule creation and completion aborts the loser and books no orphan schedule", async () => {
|
|
356
|
-
const task = await startedTask("race target");
|
|
449
|
+
const task = await startedTask("race target", agentId, { outputSchema: { type: "string" } });
|
|
357
450
|
|
|
358
451
|
const [first, second] = (await Promise.all([
|
|
359
452
|
buildTool().handler(
|
|
360
|
-
{
|
|
453
|
+
{
|
|
454
|
+
taskId: task.id,
|
|
455
|
+
delayMs: 60_000,
|
|
456
|
+
summary: "first writer",
|
|
457
|
+
note: "pending A",
|
|
458
|
+
output: '"first"',
|
|
459
|
+
},
|
|
361
460
|
meta(),
|
|
362
461
|
),
|
|
363
462
|
buildTool().handler(
|
|
364
|
-
{
|
|
463
|
+
{
|
|
464
|
+
taskId: task.id,
|
|
465
|
+
delayMs: 90_000,
|
|
466
|
+
summary: "second writer",
|
|
467
|
+
note: "pending B",
|
|
468
|
+
output: '"second"',
|
|
469
|
+
},
|
|
365
470
|
meta(),
|
|
366
471
|
),
|
|
367
472
|
])) as [DeferTaskResult, DeferTaskResult];
|
|
@@ -378,6 +483,12 @@ describe("defer-task handler", () => {
|
|
|
378
483
|
expect(failed.length).toBe(1);
|
|
379
484
|
expect(failed[0]!.structuredContent.message).toContain("terminal state");
|
|
380
485
|
|
|
486
|
+
const logs = (await getLogsByTaskId(task.id)).filter(
|
|
487
|
+
(log) => log.eventType === "task_progress",
|
|
488
|
+
);
|
|
489
|
+
expect(logs).toHaveLength(1);
|
|
490
|
+
expect(logs[0]!.newValue).toContain(succeeded[0]!.structuredContent.scheduleId!);
|
|
491
|
+
|
|
381
492
|
// The loser's schedule INSERT rolled back with its transaction — only
|
|
382
493
|
// the winner's wake-up was committed. No orphan schedule.
|
|
383
494
|
const schedules = await schedulesForTask(task.id);
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import {
|
|
7
|
+
cancelTask,
|
|
8
|
+
closeDb,
|
|
9
|
+
completeTask,
|
|
10
|
+
createAgent,
|
|
11
|
+
createScheduledTask,
|
|
12
|
+
createTaskExtended,
|
|
13
|
+
deleteScheduledTask,
|
|
14
|
+
failTask,
|
|
15
|
+
getDbClient,
|
|
16
|
+
getLogsByTaskId,
|
|
17
|
+
getScheduledTaskById,
|
|
18
|
+
getTaskById,
|
|
19
|
+
initDb,
|
|
20
|
+
startTask,
|
|
21
|
+
updateScheduledTask,
|
|
22
|
+
} from "../be/db";
|
|
23
|
+
import {
|
|
24
|
+
dispatchDeferredTaskWait,
|
|
25
|
+
initDeferredTaskWaits,
|
|
26
|
+
reconcileDeferredTaskWaits,
|
|
27
|
+
stopDeferredTaskWaits,
|
|
28
|
+
type TaskWakeEvent,
|
|
29
|
+
} from "../scheduler/deferred-task-waits";
|
|
30
|
+
import { dispatchScheduleTarget, executeSchedule } from "../scheduler/scheduler";
|
|
31
|
+
import { registerDeferTaskTool } from "../tools/defer-task";
|
|
32
|
+
|
|
33
|
+
type Result = { structuredContent: { success: boolean; message: string; scheduleId?: string } };
|
|
34
|
+
type Tool = {
|
|
35
|
+
handler: (args: unknown, extra: unknown) => Promise<Result>;
|
|
36
|
+
inputSchema: { safeParse: (args: unknown) => { success: boolean } };
|
|
37
|
+
};
|
|
38
|
+
const templateGlobals = globalThis as typeof globalThis & { __testMigrationTemplate?: Uint8Array };
|
|
39
|
+
let savedTemplate: Uint8Array | undefined;
|
|
40
|
+
let dir: string;
|
|
41
|
+
let path: string;
|
|
42
|
+
let agentId: string;
|
|
43
|
+
let tool: Tool;
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
dir = await mkdtemp(join(tmpdir(), "task-wake-"));
|
|
47
|
+
path = join(dir, "test.sqlite");
|
|
48
|
+
// The preload normally deserializes a fresh in-memory DB on every initDb.
|
|
49
|
+
// Use a real file here so close/reopen verifies durable restart recovery.
|
|
50
|
+
savedTemplate = templateGlobals.__testMigrationTemplate;
|
|
51
|
+
if (savedTemplate) await Bun.write(path, savedTemplate);
|
|
52
|
+
delete templateGlobals.__testMigrationTemplate;
|
|
53
|
+
initDb(path);
|
|
54
|
+
agentId = (
|
|
55
|
+
await createAgent({
|
|
56
|
+
name: "Wake worker",
|
|
57
|
+
isLead: false,
|
|
58
|
+
status: "busy",
|
|
59
|
+
capabilities: [],
|
|
60
|
+
maxTasks: 10,
|
|
61
|
+
})
|
|
62
|
+
).id;
|
|
63
|
+
const server = new McpServer({ name: "wake-test", version: "1.0.0" });
|
|
64
|
+
registerDeferTaskTool(server);
|
|
65
|
+
tool = (server as unknown as { _registeredTools: Record<string, Tool> })._registeredTools[
|
|
66
|
+
"defer-task"
|
|
67
|
+
]!;
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => stopDeferredTaskWaits());
|
|
70
|
+
afterAll(async () => {
|
|
71
|
+
closeDb();
|
|
72
|
+
templateGlobals.__testMigrationTemplate = savedTemplate;
|
|
73
|
+
await rm(dir, { recursive: true, force: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
async function activeTask() {
|
|
77
|
+
const task = await createTaskExtended("work in progress", {
|
|
78
|
+
agentId,
|
|
79
|
+
modelTier: "smart",
|
|
80
|
+
priority: 70,
|
|
81
|
+
});
|
|
82
|
+
await startTask(task.id);
|
|
83
|
+
return task;
|
|
84
|
+
}
|
|
85
|
+
async function defer(
|
|
86
|
+
taskId: string,
|
|
87
|
+
watchedId: string,
|
|
88
|
+
event: TaskWakeEvent = "settled",
|
|
89
|
+
extra: Record<string, unknown> = {},
|
|
90
|
+
) {
|
|
91
|
+
return tool.handler(
|
|
92
|
+
{
|
|
93
|
+
taskId,
|
|
94
|
+
wakeOn: { taskId: watchedId, event },
|
|
95
|
+
delayMs: 3600000,
|
|
96
|
+
summary: "Work submitted",
|
|
97
|
+
note: "Check the producer result",
|
|
98
|
+
checks: ["verify output"],
|
|
99
|
+
...extra,
|
|
100
|
+
},
|
|
101
|
+
{ sessionId: crypto.randomUUID(), requestInfo: { headers: { "x-agent-id": agentId } } },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
async function fixture(event: TaskWakeEvent = "settled") {
|
|
105
|
+
const parent = await activeTask();
|
|
106
|
+
const producer = await activeTask();
|
|
107
|
+
const result = await defer(parent.id, producer.id, event);
|
|
108
|
+
expect(result.structuredContent.success).toBe(true);
|
|
109
|
+
const schedule = await getScheduledTaskById(result.structuredContent.scheduleId!);
|
|
110
|
+
return { parent, producer, schedule: schedule! };
|
|
111
|
+
}
|
|
112
|
+
async function children(scheduleId: string) {
|
|
113
|
+
const rows = await getDbClient().query<{ id: string }>(
|
|
114
|
+
"SELECT id FROM agent_tasks WHERE scheduleId = ?",
|
|
115
|
+
[scheduleId],
|
|
116
|
+
);
|
|
117
|
+
return Promise.all(rows.map((row) => getTaskById(row.id)));
|
|
118
|
+
}
|
|
119
|
+
async function awaitChild(scheduleId: string) {
|
|
120
|
+
const deadline = Date.now() + 3000;
|
|
121
|
+
while (Date.now() < deadline) {
|
|
122
|
+
const found = await children(scheduleId);
|
|
123
|
+
if (found.length) return found[0]!;
|
|
124
|
+
await Bun.sleep(5);
|
|
125
|
+
}
|
|
126
|
+
throw new Error("Event bus did not create a wake-up child");
|
|
127
|
+
}
|
|
128
|
+
async function waitState(scheduleId: string) {
|
|
129
|
+
return getDbClient().get<{
|
|
130
|
+
taskId: string;
|
|
131
|
+
status: string;
|
|
132
|
+
firedBy: string | null;
|
|
133
|
+
childTaskId: string | null;
|
|
134
|
+
resolvedAt: string | null;
|
|
135
|
+
updated_at: string;
|
|
136
|
+
}>("SELECT * FROM deferred_task_waits WHERE scheduleId = ?", [scheduleId]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
describe("defer-task wakeOn", () => {
|
|
140
|
+
test("ordinary schedules neither suppress a completion nor retarget its waiters", async () => {
|
|
141
|
+
const { producer, schedule } = await fixture();
|
|
142
|
+
const ordinary = await createScheduledTask({
|
|
143
|
+
name: `ordinary-${crypto.randomUUID()}`,
|
|
144
|
+
taskTemplate: "Related scheduled work",
|
|
145
|
+
taskType: "maintenance",
|
|
146
|
+
intervalMs: 60000,
|
|
147
|
+
parentTaskId: producer.id,
|
|
148
|
+
});
|
|
149
|
+
await dispatchScheduleTarget(ordinary);
|
|
150
|
+
expect(await waitState(schedule.id)).toMatchObject({ taskId: producer.id, status: "pending" });
|
|
151
|
+
await completeTask(producer.id, "done");
|
|
152
|
+
await reconcileDeferredTaskWaits(producer.id);
|
|
153
|
+
expect(await children(schedule.id)).toHaveLength(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("two waiters follow a time-based deferral and fire once on the resume task's completion", async () => {
|
|
157
|
+
await initDeferredTaskWaits();
|
|
158
|
+
const { producer, schedule } = await fixture();
|
|
159
|
+
const second = await defer((await activeTask()).id, producer.id, "task.completed");
|
|
160
|
+
const secondSchedule = (await getScheduledTaskById(second.structuredContent.scheduleId!))!;
|
|
161
|
+
const deferred = await defer(producer.id, producer.id, "settled", { wakeOn: undefined });
|
|
162
|
+
expect(deferred.structuredContent.success).toBe(true);
|
|
163
|
+
const successor = (await getScheduledTaskById(deferred.structuredContent.scheduleId!))!;
|
|
164
|
+
|
|
165
|
+
await reconcileDeferredTaskWaits(producer.id);
|
|
166
|
+
for (const waiter of [schedule, secondSchedule]) {
|
|
167
|
+
expect(await children(waiter.id)).toHaveLength(0);
|
|
168
|
+
expect(await waitState(waiter.id)).toMatchObject({
|
|
169
|
+
taskId: producer.id,
|
|
170
|
+
status: "pending",
|
|
171
|
+
firedBy: null,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await executeSchedule(successor);
|
|
176
|
+
const resumed = (await children(successor.id))[0]!;
|
|
177
|
+
for (const waiter of [schedule, secondSchedule]) {
|
|
178
|
+
expect(await waitState(waiter.id)).toMatchObject({ taskId: resumed.id, status: "pending" });
|
|
179
|
+
expect((await getScheduledTaskById(waiter.id))?.nextRunAt).toBe(waiter.nextRunAt);
|
|
180
|
+
}
|
|
181
|
+
await reconcileDeferredTaskWaits();
|
|
182
|
+
expect(await children(schedule.id)).toHaveLength(0);
|
|
183
|
+
await completeTask(resumed.id, "real result");
|
|
184
|
+
for (const waiter of [schedule, secondSchedule]) {
|
|
185
|
+
const child = await awaitChild(waiter.id);
|
|
186
|
+
expect(child.task).toContain(`Wake-up cause: task.completed for task ${resumed.id}`);
|
|
187
|
+
const fired = await waitState(waiter.id);
|
|
188
|
+
expect(fired).toMatchObject({
|
|
189
|
+
status: "fired",
|
|
190
|
+
firedBy: "task.completed",
|
|
191
|
+
childTaskId: child.id,
|
|
192
|
+
});
|
|
193
|
+
expect(fired?.updated_at).toBe(fired?.resolvedAt);
|
|
194
|
+
await Promise.all([executeSchedule(waiter), reconcileDeferredTaskWaits(resumed.id)]);
|
|
195
|
+
expect(await children(waiter.id)).toHaveLength(1);
|
|
196
|
+
expect(await waitState(waiter.id)).toEqual(fired);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("event-driven deferrals retarget through a chain without refreshing the waiter's ceiling", async () => {
|
|
201
|
+
const { producer, schedule } = await fixture("task.failed");
|
|
202
|
+
let current = producer;
|
|
203
|
+
for (let step = 0; step < 2; step++) {
|
|
204
|
+
const dependency = await activeTask();
|
|
205
|
+
const deferred = await defer(current.id, dependency.id);
|
|
206
|
+
expect(deferred.structuredContent.success).toBe(true);
|
|
207
|
+
await reconcileDeferredTaskWaits(current.id);
|
|
208
|
+
expect(await waitState(schedule.id)).toMatchObject({ taskId: current.id, status: "pending" });
|
|
209
|
+
await completeTask(dependency.id, "dependency ready");
|
|
210
|
+
await reconcileDeferredTaskWaits(dependency.id);
|
|
211
|
+
current = (await children(deferred.structuredContent.scheduleId!))[0]!;
|
|
212
|
+
expect(await waitState(schedule.id)).toMatchObject({ taskId: current.id, status: "pending" });
|
|
213
|
+
expect((await getScheduledTaskById(schedule.id))?.nextRunAt).toBe(schedule.nextRunAt);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await failTask(current.id, "real failure");
|
|
217
|
+
// Reopen to verify the new watched task is durable across restarts.
|
|
218
|
+
closeDb();
|
|
219
|
+
initDb(path);
|
|
220
|
+
await initDeferredTaskWaits();
|
|
221
|
+
const child = await awaitChild(schedule.id);
|
|
222
|
+
expect(child.task).toContain(`Wake-up cause: task.failed for task ${current.id}`);
|
|
223
|
+
expect(await children(schedule.id)).toHaveLength(1);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("the original ceiling still fires during the defer gap and fired waits never retarget", async () => {
|
|
227
|
+
const { producer, schedule } = await fixture();
|
|
228
|
+
const deferred = await defer(producer.id, producer.id, "settled", { wakeOn: undefined });
|
|
229
|
+
const successor = (await getScheduledTaskById(deferred.structuredContent.scheduleId!))!;
|
|
230
|
+
await reconcileDeferredTaskWaits(producer.id);
|
|
231
|
+
expect(await children(schedule.id)).toHaveLength(0);
|
|
232
|
+
expect((await getScheduledTaskById(schedule.id))?.nextRunAt).toBe(schedule.nextRunAt);
|
|
233
|
+
await executeSchedule(schedule);
|
|
234
|
+
const fired = await waitState(schedule.id);
|
|
235
|
+
expect(fired).toMatchObject({ taskId: producer.id, firedBy: "ceiling", status: "fired" });
|
|
236
|
+
expect(fired?.updated_at).toBe(fired?.resolvedAt);
|
|
237
|
+
await executeSchedule(successor);
|
|
238
|
+
const resumed = (await children(successor.id))[0]!;
|
|
239
|
+
await completeTask(resumed.id, "late result");
|
|
240
|
+
await reconcileDeferredTaskWaits();
|
|
241
|
+
expect(await waitState(schedule.id)).toEqual(fired);
|
|
242
|
+
expect(await children(schedule.id)).toHaveLength(1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test.each([
|
|
246
|
+
"disabled",
|
|
247
|
+
"deleted",
|
|
248
|
+
])("a successor schedule that is %s during the gap releases the parent's completion", async (action) => {
|
|
249
|
+
const { producer, schedule } = await fixture();
|
|
250
|
+
const deferred = await defer(producer.id, producer.id, "settled", { wakeOn: undefined });
|
|
251
|
+
const successorId = deferred.structuredContent.scheduleId!;
|
|
252
|
+
await reconcileDeferredTaskWaits(producer.id);
|
|
253
|
+
expect(await children(schedule.id)).toHaveLength(0);
|
|
254
|
+
if (action === "disabled") await updateScheduledTask(successorId, { enabled: false });
|
|
255
|
+
else await deleteScheduledTask(successorId);
|
|
256
|
+
await reconcileDeferredTaskWaits();
|
|
257
|
+
expect(await children(schedule.id)).toHaveLength(1);
|
|
258
|
+
expect(await waitState(schedule.id)).toMatchObject({
|
|
259
|
+
taskId: producer.id,
|
|
260
|
+
status: "fired",
|
|
261
|
+
firedBy: "task.completed",
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("schema output stays verbatim while a task event wakes the continuation", async () => {
|
|
266
|
+
const parent = await createTaskExtended("structured deferral", {
|
|
267
|
+
agentId,
|
|
268
|
+
outputSchema: { type: "string" },
|
|
269
|
+
});
|
|
270
|
+
await startTask(parent.id);
|
|
271
|
+
const producer = await activeTask();
|
|
272
|
+
|
|
273
|
+
for (const output of [undefined, "not JSON", "42"]) {
|
|
274
|
+
const result = await defer(parent.id, producer.id, "settled", { output });
|
|
275
|
+
expect(result.structuredContent.success).toBe(false);
|
|
276
|
+
expect((await getTaskById(parent.id))?.status).toBe("in_progress");
|
|
277
|
+
expect(
|
|
278
|
+
await getDbClient().query("SELECT id FROM scheduled_tasks WHERE parentTaskId = ?", [
|
|
279
|
+
parent.id,
|
|
280
|
+
]),
|
|
281
|
+
).toHaveLength(0);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const output = ' "deploy pending"\n';
|
|
285
|
+
const result = await defer(parent.id, producer.id, "settled", { output });
|
|
286
|
+
expect(result.structuredContent.success).toBe(true);
|
|
287
|
+
expect((await getTaskById(parent.id))?.output).toBe(output);
|
|
288
|
+
const scheduleId = result.structuredContent.scheduleId!;
|
|
289
|
+
const logs = (await getLogsByTaskId(parent.id)).filter(
|
|
290
|
+
(log) => log.eventType === "task_progress",
|
|
291
|
+
);
|
|
292
|
+
expect(logs).toHaveLength(1);
|
|
293
|
+
expect(logs[0]!.newValue).toContain("Work submitted");
|
|
294
|
+
expect(logs[0]!.newValue).toContain(scheduleId);
|
|
295
|
+
|
|
296
|
+
await completeTask(producer.id, "ready");
|
|
297
|
+
await reconcileDeferredTaskWaits(producer.id);
|
|
298
|
+
const found = await children(scheduleId);
|
|
299
|
+
expect(found).toHaveLength(1);
|
|
300
|
+
expect(found[0]!.parentTaskId).toBe(parent.id);
|
|
301
|
+
expect(found[0]!.task).toContain("Wake-up cause: task.completed");
|
|
302
|
+
expect((await getTaskById(parent.id))?.output).toBe(output);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("event fires first: completion bus wakes the normal child and consumes the ceiling", async () => {
|
|
306
|
+
await initDeferredTaskWaits();
|
|
307
|
+
const { parent, producer, schedule } = await fixture("task.completed");
|
|
308
|
+
await completeTask(producer.id, "ready");
|
|
309
|
+
const child = await awaitChild(schedule.id);
|
|
310
|
+
expect(child.parentTaskId).toBe(parent.id);
|
|
311
|
+
expect(child.agentId).toBe(agentId);
|
|
312
|
+
expect(child.modelTier).toBe("smart");
|
|
313
|
+
expect(child.model).toBeUndefined();
|
|
314
|
+
expect(child.priority).toBe(70);
|
|
315
|
+
expect(child.taskType).toBe("deferred");
|
|
316
|
+
expect(child.task).toContain(`Resume task ${parent.id}: Check the producer result`);
|
|
317
|
+
expect(child.task).toContain("- verify output");
|
|
318
|
+
expect(child.task).toContain(`Wake-up cause: task.completed for task ${producer.id}`);
|
|
319
|
+
expect((await getScheduledTaskById(schedule.id))?.enabled).toBe(false);
|
|
320
|
+
await executeSchedule(schedule); // stale poller snapshot after event dispatch
|
|
321
|
+
await reconcileDeferredTaskWaits();
|
|
322
|
+
expect(await children(schedule.id)).toHaveLength(1);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("ceiling fires first: later task failure cannot create a second child", async () => {
|
|
326
|
+
await initDeferredTaskWaits();
|
|
327
|
+
const { producer, schedule } = await fixture();
|
|
328
|
+
await executeSchedule(schedule);
|
|
329
|
+
await failTask(producer.id, "failed later");
|
|
330
|
+
await reconcileDeferredTaskWaits();
|
|
331
|
+
const found = await children(schedule.id);
|
|
332
|
+
expect(found).toHaveLength(1);
|
|
333
|
+
expect(found[0]?.task).toContain("Wake-up cause: ceiling expired");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("both race: event and ceiling dispatch commit exactly one child", async () => {
|
|
337
|
+
const { producer, schedule } = await fixture();
|
|
338
|
+
await completeTask(producer.id, "done");
|
|
339
|
+
await Promise.all([
|
|
340
|
+
reconcileDeferredTaskWaits(producer.id),
|
|
341
|
+
executeSchedule(schedule),
|
|
342
|
+
dispatchScheduleTarget(schedule),
|
|
343
|
+
reconcileDeferredTaskWaits(producer.id),
|
|
344
|
+
]);
|
|
345
|
+
const found = await children(schedule.id);
|
|
346
|
+
expect(found).toHaveLength(1);
|
|
347
|
+
const wait = await getDbClient().get<{ childTaskId: string; status: string }>(
|
|
348
|
+
"SELECT childTaskId, status FROM deferred_task_waits WHERE scheduleId = ?",
|
|
349
|
+
[schedule.id],
|
|
350
|
+
);
|
|
351
|
+
expect(wait?.childTaskId).toBe(found[0]!.id);
|
|
352
|
+
expect(wait?.status).toBe("fired");
|
|
353
|
+
expect((await getScheduledTaskById(schedule.id))?.nextRunAt).toBeUndefined();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("restart mid-wait: persisted waits regain listeners and catch events missed while stopped", async () => {
|
|
357
|
+
const live = await fixture("task.failed");
|
|
358
|
+
const missed = await fixture("settled");
|
|
359
|
+
stopDeferredTaskWaits();
|
|
360
|
+
await completeTask(missed.producer.id, "completed while listener was down");
|
|
361
|
+
closeDb();
|
|
362
|
+
initDb(path);
|
|
363
|
+
await initDeferredTaskWaits();
|
|
364
|
+
expect(await children(missed.schedule.id)).toHaveLength(1);
|
|
365
|
+
expect(await children(live.schedule.id)).toHaveLength(0);
|
|
366
|
+
await failTask(live.producer.id, "failed after restart");
|
|
367
|
+
expect((await awaitChild(live.schedule.id)).task).toContain("Wake-up cause: task.failed");
|
|
368
|
+
await initDeferredTaskWaits();
|
|
369
|
+
expect(await children(missed.schedule.id)).toHaveLength(1);
|
|
370
|
+
expect(await children(live.schedule.id)).toHaveLength(1);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("failed child creation rolls back the claim and preserves the ceiling for retry", async () => {
|
|
374
|
+
const { producer, schedule } = await fixture();
|
|
375
|
+
await completeTask(producer.id, "done");
|
|
376
|
+
await updateScheduledTask(schedule.id, { taskTemplate: "" });
|
|
377
|
+
await expect(dispatchDeferredTaskWait(schedule.id, "task.completed")).rejects.toThrow(
|
|
378
|
+
"no taskTemplate",
|
|
379
|
+
);
|
|
380
|
+
expect(
|
|
381
|
+
(
|
|
382
|
+
await getDbClient().get<{ status: string }>(
|
|
383
|
+
"SELECT status FROM deferred_task_waits WHERE scheduleId = ?",
|
|
384
|
+
[schedule.id],
|
|
385
|
+
)
|
|
386
|
+
)?.status,
|
|
387
|
+
).toBe("pending");
|
|
388
|
+
expect((await getScheduledTaskById(schedule.id))?.enabled).toBe(true);
|
|
389
|
+
expect(await children(schedule.id)).toHaveLength(0);
|
|
390
|
+
await updateScheduledTask(schedule.id, { taskTemplate: schedule.taskTemplate });
|
|
391
|
+
await executeSchedule(schedule);
|
|
392
|
+
expect(await children(schedule.id)).toHaveLength(1);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("unmatched task or event does not wake; cancelled producer still has a ceiling", async () => {
|
|
396
|
+
await initDeferredTaskWaits();
|
|
397
|
+
const { producer, schedule } = await fixture("task.completed");
|
|
398
|
+
const other = await activeTask();
|
|
399
|
+
await completeTask(other.id, "unrelated");
|
|
400
|
+
await failTask(producer.id, "wrong event");
|
|
401
|
+
await reconcileDeferredTaskWaits();
|
|
402
|
+
expect(await children(schedule.id)).toHaveLength(0);
|
|
403
|
+
const cancelled = await fixture();
|
|
404
|
+
await cancelTask(cancelled.producer.id, "cancelled");
|
|
405
|
+
await reconcileDeferredTaskWaits();
|
|
406
|
+
expect(await children(cancelled.schedule.id)).toHaveLength(0);
|
|
407
|
+
await executeSchedule(cancelled.schedule);
|
|
408
|
+
expect(await children(cancelled.schedule.id)).toHaveLength(1);
|
|
409
|
+
await executeSchedule(schedule);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("already terminal, missing, and self-watched tasks reject without completing the parent", async () => {
|
|
413
|
+
const parent = await activeTask();
|
|
414
|
+
const producer = await activeTask();
|
|
415
|
+
await completeTask(producer.id, "already done");
|
|
416
|
+
for (const [id, message] of [
|
|
417
|
+
[producer.id, "already completed"],
|
|
418
|
+
[crypto.randomUUID(), "not found"],
|
|
419
|
+
[parent.id, "Cannot wake on"],
|
|
420
|
+
]) {
|
|
421
|
+
const result = await defer(parent.id, id!);
|
|
422
|
+
expect(result.structuredContent.success).toBe(false);
|
|
423
|
+
expect(result.structuredContent.message).toContain(message!);
|
|
424
|
+
}
|
|
425
|
+
expect((await getTaskById(parent.id))?.status).toBe("in_progress");
|
|
426
|
+
expect(
|
|
427
|
+
await getDbClient().query("SELECT id FROM scheduled_tasks WHERE parentTaskId = ?", [
|
|
428
|
+
parent.id,
|
|
429
|
+
]),
|
|
430
|
+
).toHaveLength(0);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("wakeOn requires exactly one ceiling and schema permits only task event sources", async () => {
|
|
434
|
+
const parent = await activeTask();
|
|
435
|
+
const producer = await activeTask();
|
|
436
|
+
for (const extra of [
|
|
437
|
+
{ delayMs: undefined },
|
|
438
|
+
{ runAt: new Date(Date.now() + 60000).toISOString() },
|
|
439
|
+
]) {
|
|
440
|
+
expect(
|
|
441
|
+
(await defer(parent.id, producer.id, "settled", extra)).structuredContent.success,
|
|
442
|
+
).toBe(false);
|
|
443
|
+
}
|
|
444
|
+
const base = { taskId: parent.id, summary: "done", note: "wait", delayMs: 60000 };
|
|
445
|
+
expect(
|
|
446
|
+
tool.inputSchema.safeParse({
|
|
447
|
+
...base,
|
|
448
|
+
wakeOn: { taskId: producer.id, event: "checks.completed" },
|
|
449
|
+
}).success,
|
|
450
|
+
).toBe(false);
|
|
451
|
+
expect(
|
|
452
|
+
tool.inputSchema.safeParse({ ...base, wakeOn: { taskId: producer.id, event: "settled" } })
|
|
453
|
+
.success,
|
|
454
|
+
).toBe(true);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
@@ -453,6 +453,7 @@ describe("Heartbeat — reroute-decision fallback (DES-523)", () => {
|
|
|
453
453
|
{
|
|
454
454
|
task: "Resume the crashed work on B",
|
|
455
455
|
agentId: agentB.id,
|
|
456
|
+
routingReason: "reroute_fault",
|
|
456
457
|
taskType: "resume",
|
|
457
458
|
parentTaskId: original.id,
|
|
458
459
|
allowDuplicate: true,
|