@desplega.ai/agent-swarm 1.156.0 → 1.158.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/dist/{acp-adapter-c1msptrw.js → acp-adapter-xaq4n6pc.js} +1 -1
- package/dist/{actions-wd41qtdc.js → actions-p9hz5yat.js} +15 -15
- package/dist/{app-tz2qrnah.js → app-g26wdcz6.js} +4 -4
- package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
- package/dist/{assistant-c3q1zj2t.js → assistant-wydfqkb3.js} +16 -16
- package/dist/{boot-reembed-0be1krkm.js → boot-reembed-g5ckg9yb.js} +6 -6
- package/dist/{boot-reembed-r00j03f3.js → boot-reembed-se8zmdcp.js} +5 -5
- package/dist/{boot-scrub-logs-9j4hd0vw.js → boot-scrub-logs-wfxqan61.js} +3 -3
- package/dist/{seed-pricing-ncgxz1r2.js → boot-seeds-s3q01xj3.js} +10 -10
- package/dist/{claude-adapter-112w28qc.js → claude-adapter-tv2x4f36.js} +7 -7
- package/dist/{claude-managed-adapter-ea2aga6w.js → claude-managed-adapter-41b2s7fd.js} +2 -2
- package/dist/{claude-sdk-session-k7pb5kfz.js → claude-sdk-session-t442qz6f.js} +8 -8
- package/dist/{cli-e781c6f9.js → cli-0xjkj1j7.js} +1 -0
- package/dist/{cli-58rgvkt4.js → cli-1hkrch3k.js} +2 -2
- package/dist/{cli-mx3bzbv8.js → cli-20cj37vt.js} +1968 -44
- package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
- package/dist/{cli-24w39x6n.js → cli-47m6dkd3.js} +362 -126
- package/dist/{cli-90x9ewbz.js → cli-53sz8gkz.js} +1 -1
- package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
- package/dist/{cli-08n8jxcw.js → cli-65hjmev5.js} +57 -17
- package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
- package/dist/{cli-7j2098n5.js → cli-7gtvvpbh.js} +4 -4
- package/dist/{cli-b05d7szr.js → cli-7s3syk22.js} +18 -11
- package/dist/{cli-9w4ex8vh.js → cli-8qmme6vd.js} +2 -2
- package/dist/{cli-28wkjpdp.js → cli-987hg0aa.js} +2 -2
- package/dist/{cli-7pgcpcrg.js → cli-9httdpyq.js} +1 -1
- package/dist/{cli-jk26c1sz.js → cli-aky8tctm.js} +26 -93
- package/dist/{cli-865swy5p.js → cli-c07r9a2m.js} +3 -2
- package/dist/{cli-nyqza443.js → cli-fgqxwrpv.js} +291 -12
- package/dist/{cli-9pyah9c4.js → cli-ftfvggzx.js} +633 -166
- package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
- package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
- package/dist/{cli-y8bfxjtw.js → cli-j55n64b6.js} +23 -22
- package/dist/{cli-7vj52450.js → cli-j57qkrpq.js} +86 -28
- package/dist/{cli-tjmghmm5.js → cli-q67y4wzm.js} +6 -3
- package/dist/{cli-n6zq3kbd.js → cli-rj19hc34.js} +1 -1
- package/dist/{cli-tp0d7vzb.js → cli-rjehvrts.js} +4 -4
- package/dist/{cli-s8hpxeg4.js → cli-rqz8qbw7.js} +245 -75
- package/dist/{cli-p37sg0a9.js → cli-s8eet254.js} +1 -1
- package/dist/cli-t1ze64h3.js +811 -0
- package/dist/{cli-hc2fkhdv.js → cli-ws6rqycm.js} +3 -2
- package/dist/{cli-t1cd3mhp.js → cli-wscdvcqz.js} +13 -5
- package/dist/{cli-xa4j64zh.js → cli-x24pshtq.js} +5 -5
- package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
- package/dist/{cli-szcyv1mm.js → cli-xhaeg2nc.js} +1 -1
- package/dist/cli.js +30 -16
- package/dist/{codex-adapter-g4tj1f2g.js → codex-adapter-cnm8wk11.js} +5 -5
- package/dist/{codex-session-runner-ad8ha7zt.js → codex-session-runner-gfg547zj.js} +5 -5
- package/dist/{commands-z3gd8ppc.js → commands-e3bn0p75.js} +3 -3
- package/dist/{db-bezm97r9.js → db-03v9kzr2.js} +17 -3
- package/dist/{dispatcher-a97hc33y.js → dispatcher-nrmg8yht.js} +14 -14
- package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
- package/dist/{handlers-wtdy1x02.js → handlers-aqcedbyb.js} +17 -17
- package/dist/{hook-7edgmzng.js → hook-7vkskkre.js} +19 -11
- package/dist/{hook-fvs58a5b.js → hook-ybvmrmhq.js} +4 -4
- package/dist/{http-phnf93hp.js → http-2eqa8t24.js} +432 -195
- package/dist/{index-7x131mmf.js → index-1yks3qcr.js} +14 -14
- package/dist/{index-1fktvwxc.js → index-a3vn96n8.js} +31 -28
- package/dist/{index-yp8rasxk.js → index-c6zsv1bd.js} +5 -5
- package/dist/{index-50jxgw0d.js → index-chgdkq36.js} +14 -14
- package/dist/{index-7jwk7nsm.js → index-x71bvq0p.js} +15 -15
- package/dist/{keepalive-nnz07ebd.js → keepalive-vtkf5n99.js} +5 -5
- package/dist/{lead-mg7qcgww.js → lead-h3sp357x.js} +26 -26
- package/dist/{maintenance-ywmnqrpx.js → maintenance-jzeqtk3q.js} +6 -6
- package/dist/{oauth-refresh-sweep-c63sqfs1.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
- package/dist/{onboard-2zj8sqkq.js → onboard-tmsp0qbz.js} +278 -81
- package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
- package/dist/{otel-impl-jgdfm26f.js → otel-impl-9mz73q65.js} +416 -301
- package/dist/{pi-mono-adapter-2b7mw16r.js → pi-mono-adapter-m6dvv0dz.js} +2 -2
- package/dist/{pricing-refresh-3whntb69.js → pricing-refresh-v1cbr9te.js} +5 -5
- package/dist/{rbac-roles-3zptcc3p.js → rbac-roles-c9wqz83x.js} +4 -4
- package/dist/{render-v2-dbvpyysc.js → render-v2-gryrh9h8.js} +4 -4
- package/dist/{setup-qwx0j8z9.js → setup-q9720bt6.js} +2 -2
- package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
- package/dist/{work-objects-sjbcmvep.js → work-objects-b76x7deb.js} +17 -17
- package/dist/{worker-xezqdkb7.js → worker-6vrxbv41.js} +26 -26
- package/openapi.json +1626 -629
- package/package.json +3 -2
- package/src/apps/catalog.generated.json +229 -0
- package/src/apps/page-validator.ts +71 -16
- package/src/apps/row-store.ts +66 -11
- package/src/apps/sync.ts +59 -17
- package/src/artifact-sdk/browser-sdk.ts +436 -2
- package/src/be/approval-sweeps.ts +109 -0
- package/src/be/boot-seeds.ts +40 -0
- package/src/be/chunking.ts +1 -1
- package/src/be/db/agents.ts +20 -0
- package/src/be/db/approvals.ts +78 -0
- package/src/be/db/tasks/read.ts +197 -39
- package/src/be/db/tasks/write.ts +41 -9
- package/src/be/db-client.ts +107 -48
- package/src/be/db.ts +219 -76
- package/src/be/events.ts +33 -1
- package/src/be/memory/graph-expansion.ts +10 -3
- package/src/be/memory/providers/sqlite-store.ts +55 -13
- package/src/be/memory/raters/retrieval.ts +5 -2
- package/src/be/memory/reranker.ts +26 -2
- package/src/be/memory/types.ts +2 -0
- package/src/be/migrations/166_approval_request_auto_cancellation.sql +9 -0
- package/src/be/migrations/171_memory_retrieval_relevance.sql +8 -0
- package/src/be/migrations/175_workflow_runs_list_index.sql +11 -0
- package/src/be/migrations/177_events_event_agent_created_index.sql +11 -0
- package/src/be/models-catalog.ts +2 -0
- package/src/be/modelsdev-cache.json +1857 -0
- package/src/be/modelsdev-cache.ts +14 -1
- package/src/be/pricing-normalize.ts +2 -0
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-pricing.ts +3 -1
- package/src/be/seed-skills/bundled-files.generated.json +1 -1
- package/src/be/steering.ts +53 -3
- package/src/be/swarm-config-guard.ts +3 -0
- package/src/be/task-citations.ts +29 -0
- package/src/cli.tsx +14 -1
- package/src/commands/onboard/compose-generator.ts +8 -3
- package/src/commands/onboard/container-engine.ts +220 -0
- package/src/commands/onboard/manifest.ts +1 -0
- package/src/commands/onboard/steps/health-check.tsx +4 -1
- package/src/commands/onboard/steps/prereq-check.tsx +50 -54
- package/src/commands/onboard/steps/start.tsx +31 -16
- package/src/commands/onboard/types.ts +7 -2
- package/src/commands/onboard.tsx +39 -4
- package/src/commands/profile-sync.ts +79 -26
- package/src/commands/runner.ts +8 -1
- package/src/extensions/catalog.generated.json +17 -0
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/heartbeat/heartbeat.ts +263 -103
- package/src/hooks/hook.ts +21 -12
- package/src/http/agents.ts +35 -0
- package/src/http/approval-requests.ts +136 -64
- package/src/http/apps.ts +14 -3
- package/src/http/config.ts +15 -1
- package/src/http/events.ts +62 -2
- package/src/http/index.ts +6 -4
- package/src/http/memory.ts +21 -7
- package/src/http/schedules.ts +1 -1
- package/src/http/session-data.ts +9 -4
- package/src/http/sessions.ts +25 -18
- package/src/http/stats.ts +19 -7
- package/src/http/status.ts +11 -1
- package/src/http/tasks.ts +40 -8
- package/src/http/webhooks.ts +67 -46
- package/src/http/workflows.ts +15 -20
- package/src/linear/outbound.ts +37 -15
- package/src/otel-impl.ts +151 -1
- package/src/otel.ts +14 -3
- package/src/prompts/memories.ts +19 -2
- package/src/providers/claude-managed-models.ts +8 -0
- package/src/providers/codex-agents-md.ts +140 -17
- package/src/providers/modelsdev-reasoning.json +551 -0
- package/src/rbac/legacy-policy.ts +16 -0
- package/src/rbac/permissions.ts +4 -0
- package/src/scheduler/deferred-task-waits.ts +28 -0
- package/src/scheduler/schedule-task.ts +3 -0
- package/src/scheduler/scheduler.ts +14 -0
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/swarm-sdk.ts +9 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-extension.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
- package/src/server.ts +12 -7
- package/src/slack/app.ts +35 -4
- package/src/slack/connection-state.ts +17 -0
- package/src/tasks/context-key.ts +1 -0
- package/src/tasks/worker-follow-up.ts +6 -2
- package/src/tests/agent-task-activity.test.ts +74 -0
- package/src/tests/agents-list-model-display.test.ts +6 -1
- package/src/tests/approval-auto-cancellation-migration.test.ts +104 -0
- package/src/tests/approval-requests.test.ts +395 -0
- package/src/tests/approval-sweeps-config.test.ts +47 -0
- package/src/tests/approval-sweeps.test.ts +508 -0
- package/src/tests/apps-kibo-blocks.test.ts +77 -0
- package/src/tests/apps-spike.test.ts +56 -1
- package/src/tests/apps-sync-engine.test.ts +20 -9
- package/src/tests/boot-seeds.test.ts +170 -0
- package/src/tests/cancel-approval-request-tool.test.ts +115 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/codex-adapter.test.ts +126 -1
- package/src/tests/config-key-filter.test.ts +80 -0
- package/src/tests/context-window.test.ts +1 -0
- package/src/tests/db-client-busy-retry.test.ts +96 -0
- package/src/tests/db-client.test.ts +60 -1
- package/src/tests/db-task-read-contract.test.ts +10 -0
- package/src/tests/db-task-write-contract.test.ts +9 -0
- package/src/tests/deferred-schedule-reaper.test.ts +193 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/events-http.test.ts +103 -0
- package/src/tests/extensions-catalog.test.ts +22 -17
- package/src/tests/extensions-pre-task.test.ts +107 -0
- package/src/tests/fixtures/kibo-blocks-definition.json.txt +105 -0
- package/src/tests/follow-up-config-inheritance.test.ts +260 -0
- package/src/tests/heartbeat-orphan-resume.test.ts +84 -0
- package/src/tests/heartbeat-reboot-live.test.ts +95 -0
- package/src/tests/heartbeat-stall-cas.test.ts +80 -0
- package/src/tests/heartbeat.test.ts +99 -3
- package/src/tests/hook-mcp-config.test.ts +29 -0
- package/src/tests/http-api-integration.test.ts +16 -2
- package/src/tests/kv-storage.test.ts +62 -0
- package/src/tests/linear-outbound-sync.test.ts +120 -5
- package/src/tests/list-endpoint-slimming.test.ts +91 -0
- package/src/tests/memory-graph-organic-route.test.ts +155 -0
- package/src/tests/memory-http-recall-gating.test.ts +14 -4
- package/src/tests/memory-hybrid.test.ts +161 -1
- package/src/tests/memory-prompt-recall-crowdout.test.ts +297 -0
- package/src/tests/memory.test.ts +22 -0
- package/src/tests/onboard-container-engine.test.ts +240 -0
- package/src/tests/otel-event-loop-and-logs.test.ts +99 -0
- package/src/tests/pricing-routes.test.ts +14 -0
- package/src/tests/profile-sync-warning.test.ts +8 -4
- package/src/tests/promote-draft-task-route.test.ts +19 -0
- package/src/tests/provider-registration.test.ts +296 -42
- package/src/tests/rbac-engine.test.ts +30 -0
- package/src/tests/reasoning-effort.test.ts +8 -0
- package/src/tests/script-connections.test.ts +7 -0
- package/src/tests/sdk-allowlist.test.ts +39 -0
- package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
- package/src/tests/sessions-detail-batch.test.ts +250 -0
- package/src/tests/sessions.test.ts +118 -0
- package/src/tests/slack-config.test.ts +22 -0
- package/src/tests/sonnet-5-5-registry.test.ts +44 -0
- package/src/tests/status.test.ts +36 -1
- package/src/tests/swarm-diff.test.ts +316 -10
- package/src/tests/task-completion-idempotency.test.ts +2 -1
- package/src/tests/tasks-requested-by-filter.test.ts +3 -1
- package/src/tests/tool-annotations.test.ts +1 -1
- package/src/tests/ui-list-slim-projections.test.ts +158 -0
- package/src/tests/webhooks-task-blocked.test.ts +127 -0
- package/src/tests/workflow-approval-recovery.test.ts +203 -0
- package/src/tests/workflow-hitl-routing.test.ts +22 -1
- package/src/tests/workflow-hitl-slack-card.test.ts +52 -0
- package/src/tests/workflow-http-v2.test.ts +99 -8
- package/src/tests/workflow-retry-validation.test.ts +56 -0
- package/src/tests/workflow-tla-races.test.ts +389 -0
- package/src/tests/workflow-validation-port-routing.test.ts +113 -1
- package/src/tools/cancel-approval-request.ts +57 -0
- package/src/tools/memory-search.ts +1 -0
- package/src/tools/request-human-input.ts +4 -2
- package/src/tools/schedules/list-schedules.ts +4 -2
- package/src/tools/send-task.ts +4 -0
- package/src/tools/templates.ts +19 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/workflows/list-workflow-runs.ts +1 -0
- package/src/types.ts +38 -0
- package/src/utils/context-window.ts +1 -0
- package/src/workflows/approval-cancel.ts +152 -0
- package/src/workflows/approval-notifications.ts +84 -0
- package/src/workflows/checkpoint.ts +10 -5
- package/src/workflows/definition.ts +21 -0
- package/src/workflows/engine.ts +152 -21
- package/src/workflows/executors/human-in-the-loop.ts +1 -1
- package/src/workflows/recovery.ts +100 -45
- package/src/workflows/resume.ts +49 -18
- package/src/workflows/retry-poller.ts +71 -21
- package/templates/extensions/README.md +1 -0
- package/templates/extensions/github-sender-allowlist/README.md +18 -0
- package/templates/extensions/github-sender-allowlist/hooks.ts +55 -0
- package/templates/extensions/github-sender-allowlist/manifest.json +8 -0
- package/templates/skills/apps/content.md +20 -1
- package/templates/skills/code-quality/content.md +26 -1
- package/templates/skills/pages/content.md +16 -4
- package/templates/skills/pages/files/examples/multiplayer-board.html +3 -12
- package/templates/skills/scheduling/content.md +1 -1
- package/dist/cli-2daksqps.js +0 -378
- package/dist/rbac-roles-3zw68x72.js +0 -37
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counterexamples from the TLA+ heartbeat model (specs/tla/heartbeat/).
|
|
3
|
+
*
|
|
4
|
+
* Each test replays one TLC trace against a real temp SQLite DB, calling the
|
|
5
|
+
* functions ACTIONS.md maps each trace step to. A test that passes on main
|
|
6
|
+
* means the trace was model drift, not a bug.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { unlink } from "node:fs/promises";
|
|
11
|
+
import {
|
|
12
|
+
closeDb,
|
|
13
|
+
createAgent,
|
|
14
|
+
createTaskExtended,
|
|
15
|
+
getChildTasks,
|
|
16
|
+
getDbClient,
|
|
17
|
+
getTaskById,
|
|
18
|
+
initDb,
|
|
19
|
+
startTask,
|
|
20
|
+
updateTaskProgress,
|
|
21
|
+
} from "../be/db";
|
|
22
|
+
import { codeLevelTriage, setBeforeHeartbeatSupersedeForTests } from "../heartbeat/heartbeat";
|
|
23
|
+
|
|
24
|
+
const TEST_DB_PATH = "./test-heartbeat-stall-cas.sqlite";
|
|
25
|
+
|
|
26
|
+
async function backdateTask(taskId: string, isoTime: string): Promise<void> {
|
|
27
|
+
await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
|
|
28
|
+
isoTime,
|
|
29
|
+
taskId,
|
|
30
|
+
]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("Heartbeat stall classifier CAS (TLA+ counterexample)", () => {
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
try {
|
|
36
|
+
await unlink(TEST_DB_PATH);
|
|
37
|
+
} catch {
|
|
38
|
+
// File doesn't exist
|
|
39
|
+
}
|
|
40
|
+
closeDb();
|
|
41
|
+
initDb(TEST_DB_PATH);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(async () => {
|
|
45
|
+
closeDb();
|
|
46
|
+
for (const path of [TEST_DB_PATH, `${TEST_DB_PATH}-wal`, `${TEST_DB_PATH}-shm`]) {
|
|
47
|
+
try {
|
|
48
|
+
await unlink(path);
|
|
49
|
+
} catch {
|
|
50
|
+
// Files may not exist
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
setBeforeHeartbeatSupersedeForTests(null);
|
|
57
|
+
const db = getDbClient();
|
|
58
|
+
await db.run("DELETE FROM agent_tasks");
|
|
59
|
+
await db.run("DELETE FROM agents");
|
|
60
|
+
await db.run("DELETE FROM active_sessions");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Trace: PollStart -> Age -> HbRead -> Progress -> HbWrite (NoLiveKill).
|
|
64
|
+
test("stall classifier does not supersede a task that made progress after the candidate read", async () => {
|
|
65
|
+
const agent = await createAgent({ name: "quiet-worker", isLead: false, status: "busy" });
|
|
66
|
+
const task = await createTaskExtended("Long quiet work", { agentId: agent.id });
|
|
67
|
+
await startTask(task.id);
|
|
68
|
+
await backdateTask(task.id, new Date(Date.now() - 10 * 60 * 1000).toISOString());
|
|
69
|
+
|
|
70
|
+
// The worker reports progress after the sweep read the candidate list.
|
|
71
|
+
setBeforeHeartbeatSupersedeForTests(async (candidate) => {
|
|
72
|
+
await updateTaskProgress(candidate.id, "still working");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await codeLevelTriage();
|
|
76
|
+
|
|
77
|
+
expect((await getTaskById(task.id))?.status).toBe("in_progress");
|
|
78
|
+
expect(await getChildTasks(task.id)).toHaveLength(0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import {
|
|
4
|
+
cancelTask,
|
|
4
5
|
claimTask,
|
|
5
6
|
closeDb,
|
|
6
7
|
createAgent,
|
|
7
8
|
createTaskExtended,
|
|
9
|
+
failTask,
|
|
8
10
|
getActiveSessionForTask,
|
|
9
11
|
getDbClient,
|
|
10
12
|
getIdleWorkersWithCapacity,
|
|
@@ -280,6 +282,13 @@ describe("Heartbeat Triage", () => {
|
|
|
280
282
|
// ==========================================================================
|
|
281
283
|
|
|
282
284
|
describe("Code-Level Triage", () => {
|
|
285
|
+
test("reports approval sweep counts in staleCleanup", async () => {
|
|
286
|
+
const findings = await codeLevelTriage();
|
|
287
|
+
|
|
288
|
+
expect(typeof findings.staleCleanup.approvalAutoCancelled).toBe("number");
|
|
289
|
+
expect(typeof findings.staleCleanup.approvalTimedOut).toBe("number");
|
|
290
|
+
});
|
|
291
|
+
|
|
283
292
|
test("auto-supersedes stalled task with no active session (DES-523)", async () => {
|
|
284
293
|
const agent = await createAgent({ name: "dead-worker", isLead: false, status: "busy" });
|
|
285
294
|
const task = await createTaskExtended("Stalled task", { agentId: agent.id });
|
|
@@ -1037,6 +1046,93 @@ describe("Heartbeat Triage", () => {
|
|
|
1037
1046
|
expect(retryTask!.priority).toBe(90);
|
|
1038
1047
|
expect(retryTask!.source).toBe("slack");
|
|
1039
1048
|
});
|
|
1049
|
+
|
|
1050
|
+
async function sweptTaskWithDependent(taskType?: string) {
|
|
1051
|
+
const agent = await createAgent({ name: "dead-worker", isLead: false, status: "busy" });
|
|
1052
|
+
const other = await createAgent({ name: "dependent-owner", isLead: false, status: "idle" });
|
|
1053
|
+
const parent = await createTaskExtended("Thread root", { agentId: other.id });
|
|
1054
|
+
const swept = await createTaskExtended("Research step", { agentId: agent.id, taskType });
|
|
1055
|
+
await startTask(swept.id);
|
|
1056
|
+
await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ? WHERE id = ?", [
|
|
1057
|
+
new Date(Date.now() - 1000).toISOString(),
|
|
1058
|
+
swept.id,
|
|
1059
|
+
]);
|
|
1060
|
+
const done = await createTaskExtended("Already done", { agentId: other.id });
|
|
1061
|
+
await getDbClient().run("UPDATE agent_tasks SET status = 'completed' WHERE id = ?", [
|
|
1062
|
+
done.id,
|
|
1063
|
+
]);
|
|
1064
|
+
const dependent = await createTaskExtended("Act on the research", {
|
|
1065
|
+
agentId: other.id,
|
|
1066
|
+
dependsOn: [swept.id, done.id],
|
|
1067
|
+
parentTaskId: parent.id,
|
|
1068
|
+
priority: 70,
|
|
1069
|
+
slackChannelId: "C0TEST",
|
|
1070
|
+
slackThreadTs: "1700000000.000100",
|
|
1071
|
+
slackUserId: "U0TEST",
|
|
1072
|
+
followUpConfig: { onCompleted: "Tell the requester" },
|
|
1073
|
+
});
|
|
1074
|
+
return { swept, done, dependent, parent, other };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
test("reboot sweep re-points a never-started dependent to the retry child instead of failing it", async () => {
|
|
1078
|
+
const { swept, done, dependent, parent, other } = await sweptTaskWithDependent();
|
|
1079
|
+
const cancelled = await createTaskExtended("Cancelled for another reason", {
|
|
1080
|
+
agentId: other.id,
|
|
1081
|
+
dependsOn: [swept.id],
|
|
1082
|
+
});
|
|
1083
|
+
await cancelTask(cancelled.id, "not needed");
|
|
1084
|
+
|
|
1085
|
+
await runRebootSweep();
|
|
1086
|
+
|
|
1087
|
+
expect((await getTaskById(swept.id))?.status).toBe("failed");
|
|
1088
|
+
const retryTaskId = getRebootAffectedTasks()[0]!.retryTaskId;
|
|
1089
|
+
expect(retryTaskId).not.toBeNull();
|
|
1090
|
+
|
|
1091
|
+
const survivor = await getTaskById(dependent.id);
|
|
1092
|
+
expect(survivor?.status).toBe("pending");
|
|
1093
|
+
expect(survivor?.failureReason).toBeFalsy();
|
|
1094
|
+
expect(survivor?.dependsOn).toEqual([retryTaskId!, done.id]);
|
|
1095
|
+
expect(survivor?.agentId).toBe(other.id);
|
|
1096
|
+
expect(survivor?.parentTaskId).toBe(parent.id);
|
|
1097
|
+
expect(survivor?.priority).toBe(70);
|
|
1098
|
+
expect(survivor?.slackChannelId).toBe("C0TEST");
|
|
1099
|
+
expect(survivor?.slackThreadTs).toBe("1700000000.000100");
|
|
1100
|
+
expect(survivor?.slackUserId).toBe("U0TEST");
|
|
1101
|
+
expect(survivor?.followUpConfig).toEqual({ onCompleted: "Tell the requester" });
|
|
1102
|
+
|
|
1103
|
+
const log = await getDbClient().get<{ oldValue: string; newValue: string }>(
|
|
1104
|
+
"SELECT oldValue, newValue FROM agent_log WHERE taskId = ? AND eventType = 'task_dependency_repointed'",
|
|
1105
|
+
[dependent.id],
|
|
1106
|
+
);
|
|
1107
|
+
expect(log).toEqual({ oldValue: swept.id, newValue: retryTaskId! });
|
|
1108
|
+
|
|
1109
|
+
// Already terminal for another reason: untouched.
|
|
1110
|
+
const stillCancelled = await getTaskById(cancelled.id);
|
|
1111
|
+
expect(stillCancelled?.status).toBe("cancelled");
|
|
1112
|
+
expect(stillCancelled?.dependsOn).toEqual([swept.id]);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
test("reboot sweep without a retry child still cascade-fails dependents", async () => {
|
|
1116
|
+
const { swept, dependent } = await sweptTaskWithDependent("heartbeat-checklist");
|
|
1117
|
+
|
|
1118
|
+
await runRebootSweep();
|
|
1119
|
+
|
|
1120
|
+
expect(getRebootAffectedTasks()[0]!.retryTaskId).toBeNull();
|
|
1121
|
+
const dead = await getTaskById(dependent.id);
|
|
1122
|
+
expect(dead?.status).toBe("failed");
|
|
1123
|
+
expect(dead?.failureReason).toBe(`Blocked dependency ${swept.id.slice(0, 8)} was failed`);
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
test("a non-reboot failure still cascade-fails dependents", async () => {
|
|
1127
|
+
const { swept, dependent } = await sweptTaskWithDependent();
|
|
1128
|
+
|
|
1129
|
+
await failTask(swept.id, "Worker reported an error");
|
|
1130
|
+
|
|
1131
|
+
const dead = await getTaskById(dependent.id);
|
|
1132
|
+
expect(dead?.status).toBe("failed");
|
|
1133
|
+
expect(dead?.failureReason).toBe(`Blocked dependency ${swept.id.slice(0, 8)} was failed`);
|
|
1134
|
+
expect(dead?.dependsOn).toContain(swept.id);
|
|
1135
|
+
});
|
|
1040
1136
|
});
|
|
1041
1137
|
|
|
1042
1138
|
// ==========================================================================
|
|
@@ -1083,13 +1179,13 @@ describe("Heartbeat Triage", () => {
|
|
|
1083
1179
|
task.id,
|
|
1084
1180
|
]);
|
|
1085
1181
|
|
|
1086
|
-
// Session with pre-boot heartbeat
|
|
1182
|
+
// Session with pre-boot heartbeat, stale past the 15 min threshold
|
|
1087
1183
|
await insertActiveSession({
|
|
1088
1184
|
agentId: agent.id,
|
|
1089
1185
|
taskId: task.id,
|
|
1090
1186
|
triggerType: "task_assigned",
|
|
1091
1187
|
});
|
|
1092
|
-
const preBootHb = new Date(bootTime -
|
|
1188
|
+
const preBootHb = new Date(bootTime - 20 * 60_000).toISOString();
|
|
1093
1189
|
await getDbClient().run("UPDATE active_sessions SET lastHeartbeatAt = ? WHERE taskId = ?", [
|
|
1094
1190
|
preBootHb,
|
|
1095
1191
|
task.id,
|
|
@@ -1189,7 +1285,7 @@ describe("Heartbeat Triage", () => {
|
|
|
1189
1285
|
taskId: staleTask.id,
|
|
1190
1286
|
triggerType: "task_assigned",
|
|
1191
1287
|
});
|
|
1192
|
-
const preBootHb = new Date(bootTime -
|
|
1288
|
+
const preBootHb = new Date(bootTime - 20 * 60_000).toISOString();
|
|
1193
1289
|
await getDbClient().run("UPDATE active_sessions SET lastHeartbeatAt = ? WHERE taskId = ?", [
|
|
1194
1290
|
preBootHb,
|
|
1195
1291
|
staleTask.id,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { loadHookMcpConfig } from "../hooks/hook";
|
|
3
|
+
|
|
4
|
+
describe("loadHookMcpConfig", () => {
|
|
5
|
+
test("falls back to the workspace config when the project has no .mcp.json", async () => {
|
|
6
|
+
const tempDir = `/tmp/hook-mcp-fallback-${crypto.randomUUID()}`;
|
|
7
|
+
const projectDir = `${tempDir}/repo`;
|
|
8
|
+
const workspaceDir = `${tempDir}/workspace`;
|
|
9
|
+
const expected = {
|
|
10
|
+
url: "http://localhost:3013/mcp",
|
|
11
|
+
headers: {
|
|
12
|
+
Authorization: "Bearer example-test-key",
|
|
13
|
+
"X-Agent-ID": "agent-test-id",
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
await Bun.$`mkdir -p ${projectDir} ${workspaceDir}`.quiet();
|
|
18
|
+
try {
|
|
19
|
+
await Bun.write(
|
|
20
|
+
`${workspaceDir}/.mcp.json`,
|
|
21
|
+
JSON.stringify({ mcpServers: { "agent-swarm": expected } }),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
expect(await loadHookMcpConfig(projectDir, workspaceDir)).toEqual(expected);
|
|
25
|
+
} finally {
|
|
26
|
+
await Bun.$`rm -rf ${tempDir}`.quiet();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -573,7 +573,8 @@ describe("Tasks", () => {
|
|
|
573
573
|
expect(status).toBe(200);
|
|
574
574
|
expect(body.tasks).toBeDefined();
|
|
575
575
|
expect(Array.isArray(body.tasks)).toBe(true);
|
|
576
|
-
|
|
576
|
+
// `total` is an opt-in COUNT(*): omitted unless `includeTotal=true`.
|
|
577
|
+
expect(body.total).toBeUndefined();
|
|
577
578
|
});
|
|
578
579
|
|
|
579
580
|
test("GET /api/tasks?status=pending — filter by status", async () => {
|
|
@@ -599,12 +600,25 @@ describe("Tasks", () => {
|
|
|
599
600
|
});
|
|
600
601
|
|
|
601
602
|
test("GET /api/tasks?limit=1 — pagination", async () => {
|
|
602
|
-
const { status, body } = await get("/api/tasks?limit=1");
|
|
603
|
+
const { status, body } = await get("/api/tasks?limit=1&includeTotal=true");
|
|
603
604
|
expect(status).toBe(200);
|
|
604
605
|
expect(body.tasks.length).toBe(1);
|
|
605
606
|
expect(body.total).toBeGreaterThanOrEqual(2);
|
|
606
607
|
});
|
|
607
608
|
|
|
609
|
+
test("GET /api/tasks?fields=timeline — narrow timeline rows", async () => {
|
|
610
|
+
const { status, body } = await get("/api/tasks?fields=timeline&orderBy=createdAt&limit=5");
|
|
611
|
+
expect(status).toBe(200);
|
|
612
|
+
expect(body.tasks.length).toBeGreaterThanOrEqual(1);
|
|
613
|
+
for (const t of body.tasks) {
|
|
614
|
+
expect(typeof t.id).toBe("string");
|
|
615
|
+
expect(typeof t.status).toBe("string");
|
|
616
|
+
expect(t.progress).toBeUndefined();
|
|
617
|
+
expect(t.tags).toBeUndefined();
|
|
618
|
+
}
|
|
619
|
+
expect(body.total).toBeUndefined();
|
|
620
|
+
});
|
|
621
|
+
|
|
608
622
|
test("GET /api/tasks/:id — get specific task", async () => {
|
|
609
623
|
const { status, body } = await get(`/api/tasks/${ids.task}`);
|
|
610
624
|
expect(status).toBe(200);
|
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
getKv,
|
|
10
10
|
incrKv,
|
|
11
11
|
initDb,
|
|
12
|
+
type KvJsonFieldEquals,
|
|
12
13
|
KvTypeCollisionError,
|
|
14
|
+
kvPrefixUpperBound,
|
|
13
15
|
listKv,
|
|
14
16
|
sweepExpiredKv,
|
|
15
17
|
sweepExpiredKvPrefix,
|
|
@@ -200,6 +202,66 @@ describe("kv-storage helpers", () => {
|
|
|
200
202
|
expect(exact.map((e) => e.key)).toEqual(["x_1"]);
|
|
201
203
|
});
|
|
202
204
|
|
|
205
|
+
test("listKv prefix is an exact, case-sensitive range on the primary key", async () => {
|
|
206
|
+
for (const key of ["m/row/1", "m/row/2", "M/row/3", "m/rowx", "m/row", "n/row/1", "m/row/é"]) {
|
|
207
|
+
await upsertKv({ namespace: NS, key, value: 1, valueType: "integer" });
|
|
208
|
+
}
|
|
209
|
+
const rows = await listKv(NS, { prefix: "m/row/", limit: 100, offset: 0 });
|
|
210
|
+
expect(rows.map((e) => e.key)).toEqual(["m/row/1", "m/row/2", "m/row/é"]);
|
|
211
|
+
expect(await countKv(NS, { prefix: "m/row/" })).toBe(3);
|
|
212
|
+
expect(kvPrefixUpperBound("m/row/")).toBe("m/row0");
|
|
213
|
+
expect(kvPrefixUpperBound(`a${String.fromCodePoint(0x10ffff)}`)).toBe("b");
|
|
214
|
+
expect(kvPrefixUpperBound(String.fromCodePoint(0xd7ff))).toBe(String.fromCodePoint(0xe000));
|
|
215
|
+
|
|
216
|
+
const plan = await getDbClient().query<{ detail: string }>(
|
|
217
|
+
`EXPLAIN QUERY PLAN SELECT key FROM kv_entries
|
|
218
|
+
WHERE namespace = ? AND (expires_at IS NULL OR expires_at > ?) AND key >= ? AND key < ?`,
|
|
219
|
+
[NS, 0, "m/row/", "m/row0"],
|
|
220
|
+
);
|
|
221
|
+
expect(plan.map((row) => row.detail).join(" ")).toContain("key>? AND key<?");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("listKv jsonFieldEquals narrows rows in SQL and skips corrupt JSON", async () => {
|
|
225
|
+
await upsertKv({
|
|
226
|
+
namespace: NS,
|
|
227
|
+
key: "r/1",
|
|
228
|
+
value: { run: "a", ok: true, n: 2 },
|
|
229
|
+
valueType: "json",
|
|
230
|
+
});
|
|
231
|
+
await upsertKv({
|
|
232
|
+
namespace: NS,
|
|
233
|
+
key: "r/2",
|
|
234
|
+
value: { run: "b", ok: false, n: 2 },
|
|
235
|
+
valueType: "json",
|
|
236
|
+
});
|
|
237
|
+
await upsertKv({
|
|
238
|
+
namespace: NS,
|
|
239
|
+
key: "r/3",
|
|
240
|
+
value: { run: "a", ok: false, n: 3.5 },
|
|
241
|
+
valueType: "json",
|
|
242
|
+
});
|
|
243
|
+
await getDbClient().run(
|
|
244
|
+
"INSERT INTO kv_entries (namespace, key, value, value_type) VALUES (?, ?, ?, 'json')",
|
|
245
|
+
[NS, "r/4", "{not json"],
|
|
246
|
+
);
|
|
247
|
+
const keys = async (jsonFieldEquals: KvJsonFieldEquals[]) =>
|
|
248
|
+
(await listKv(NS, { prefix: "r/", limit: 100, offset: 0, jsonFieldEquals })).map(
|
|
249
|
+
(e) => e.key,
|
|
250
|
+
);
|
|
251
|
+
expect(await keys([])).toEqual(["r/1", "r/2", "r/3", "r/4"]);
|
|
252
|
+
expect(await keys([{ field: "run", value: "a" }])).toEqual(["r/1", "r/3"]);
|
|
253
|
+
expect(await keys([{ field: "ok", value: true }])).toEqual(["r/1"]);
|
|
254
|
+
// Non-safe-integer numbers are not prefiltered (the caller re-checks).
|
|
255
|
+
expect(await keys([{ field: "n", value: 3.5 }])).toEqual(["r/1", "r/2", "r/3", "r/4"]);
|
|
256
|
+
expect(
|
|
257
|
+
await keys([
|
|
258
|
+
{ field: "run", value: "a" },
|
|
259
|
+
{ field: "n", value: 2 },
|
|
260
|
+
]),
|
|
261
|
+
).toEqual(["r/1"]);
|
|
262
|
+
expect(await keys([{ field: "missing", value: "a" }])).toEqual([]);
|
|
263
|
+
});
|
|
264
|
+
|
|
203
265
|
test("incrKv creates from missing", async () => {
|
|
204
266
|
const entry = await incrKv(NS, "counter", 3);
|
|
205
267
|
expect(entry.value).toBe(3);
|
|
@@ -2,7 +2,12 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, tes
|
|
|
2
2
|
import { unlink } from "node:fs/promises";
|
|
3
3
|
import { closeDb, initDb } from "../be/db";
|
|
4
4
|
import { createTrackerSync, getTrackerSync, updateTrackerSync } from "../be/db-queries/tracker";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
formatLinearOutcomeBody,
|
|
7
|
+
initLinearOutboundSync,
|
|
8
|
+
LINEAR_BODY_MAX,
|
|
9
|
+
teardownLinearOutboundSync,
|
|
10
|
+
} from "../linear/outbound";
|
|
6
11
|
import { taskSessionMap } from "../linear/sync";
|
|
7
12
|
import { workflowEventBus } from "../workflows/event-bus";
|
|
8
13
|
|
|
@@ -79,8 +84,8 @@ describe("Linear Outbound Sync", () => {
|
|
|
79
84
|
const callArgs = mockCreateComment.mock.calls[0] as unknown[];
|
|
80
85
|
const arg = callArgs[0] as { issueId: string; body: string };
|
|
81
86
|
expect(arg.issueId).toBe("LIN-OUT-COMPLETED");
|
|
82
|
-
|
|
83
|
-
expect(arg.body).
|
|
87
|
+
// The output is the whole comment: no boilerplate, no collapsed `+++` section.
|
|
88
|
+
expect(arg.body).toBe("All done!");
|
|
84
89
|
|
|
85
90
|
// Verify sync record updated
|
|
86
91
|
const updated = await getTrackerSync("linear", "task", "outbound-task-completed");
|
|
@@ -107,8 +112,118 @@ describe("Linear Outbound Sync", () => {
|
|
|
107
112
|
const callArgs = mockCreateComment.mock.calls[0] as unknown[];
|
|
108
113
|
const arg = callArgs[0] as { issueId: string; body: string };
|
|
109
114
|
expect(arg.issueId).toBe("LIN-OUT-FAILED");
|
|
110
|
-
expect(arg.body).
|
|
111
|
-
|
|
115
|
+
expect(arg.body).toBe("Task failed.\n\nBuild error in module X");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("task.completed with an AgentSession posts ONE response activity carrying the full output", async () => {
|
|
119
|
+
const taskId = "outbound-session-completed";
|
|
120
|
+
await createTrackerSync({
|
|
121
|
+
provider: "linear",
|
|
122
|
+
entityType: "task",
|
|
123
|
+
swarmId: taskId,
|
|
124
|
+
externalId: "LIN-OUT-SESSION-COMPLETED",
|
|
125
|
+
syncDirection: "bidirectional",
|
|
126
|
+
});
|
|
127
|
+
taskSessionMap.set(taskId, "session-completed");
|
|
128
|
+
// Longer than the old 2000-char cut, shorter than the Linear body cap.
|
|
129
|
+
const output = `## Answer\n\n${"x".repeat(5000)}`;
|
|
130
|
+
|
|
131
|
+
workflowEventBus.emit("task.completed", { taskId, output });
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
133
|
+
|
|
134
|
+
expect(mockEndAgentSession).toHaveBeenCalledTimes(1);
|
|
135
|
+
expect(mockEndAgentSession.mock.calls[0] as unknown[]).toEqual([
|
|
136
|
+
"session-completed",
|
|
137
|
+
output,
|
|
138
|
+
"response",
|
|
139
|
+
]);
|
|
140
|
+
expect(mockCreateComment).not.toHaveBeenCalled();
|
|
141
|
+
expect(taskSessionMap.has(taskId)).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("task.failed with an AgentSession posts ONE error activity with the plain reason", async () => {
|
|
145
|
+
const taskId = "outbound-session-failed";
|
|
146
|
+
await createTrackerSync({
|
|
147
|
+
provider: "linear",
|
|
148
|
+
entityType: "task",
|
|
149
|
+
swarmId: taskId,
|
|
150
|
+
externalId: "LIN-OUT-SESSION-FAILED",
|
|
151
|
+
syncDirection: "bidirectional",
|
|
152
|
+
});
|
|
153
|
+
taskSessionMap.set(taskId, "session-failed");
|
|
154
|
+
|
|
155
|
+
workflowEventBus.emit("task.failed", { taskId, failureReason: "tsc:check failed" });
|
|
156
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
157
|
+
|
|
158
|
+
expect(mockEndAgentSession).toHaveBeenCalledTimes(1);
|
|
159
|
+
expect(mockEndAgentSession.mock.calls[0] as unknown[]).toEqual([
|
|
160
|
+
"session-failed",
|
|
161
|
+
"Task failed.\n\ntsc:check failed",
|
|
162
|
+
"error",
|
|
163
|
+
]);
|
|
164
|
+
expect(mockCreateComment).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("task.completed with no output still ends the session with a short body", async () => {
|
|
168
|
+
const taskId = "outbound-session-empty";
|
|
169
|
+
await createTrackerSync({
|
|
170
|
+
provider: "linear",
|
|
171
|
+
entityType: "task",
|
|
172
|
+
swarmId: taskId,
|
|
173
|
+
externalId: "LIN-OUT-SESSION-EMPTY",
|
|
174
|
+
syncDirection: "bidirectional",
|
|
175
|
+
});
|
|
176
|
+
taskSessionMap.set(taskId, "session-empty");
|
|
177
|
+
|
|
178
|
+
workflowEventBus.emit("task.completed", { taskId, output: " " });
|
|
179
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
180
|
+
|
|
181
|
+
expect(mockEndAgentSession.mock.calls[0] as unknown[]).toEqual([
|
|
182
|
+
"session-empty",
|
|
183
|
+
"Task completed.",
|
|
184
|
+
"response",
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("formatLinearOutcomeBody", () => {
|
|
189
|
+
const savedDashboard = process.env.SWARM_DASHBOARD_URL;
|
|
190
|
+
const savedApp = process.env.APP_URL;
|
|
191
|
+
|
|
192
|
+
afterEach(() => {
|
|
193
|
+
if (savedDashboard === undefined) delete process.env.SWARM_DASHBOARD_URL;
|
|
194
|
+
else process.env.SWARM_DASHBOARD_URL = savedDashboard;
|
|
195
|
+
if (savedApp === undefined) delete process.env.APP_URL;
|
|
196
|
+
else process.env.APP_URL = savedApp;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("returns text at the cap unchanged", () => {
|
|
200
|
+
const text = "a".repeat(LINEAR_BODY_MAX);
|
|
201
|
+
expect(formatLinearOutcomeBody(text, "t1")).toBe(text);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("caps oversized text with a visible note linking the swarm task", () => {
|
|
205
|
+
delete process.env.SWARM_DASHBOARD_URL;
|
|
206
|
+
process.env.APP_URL = "https://swarm.example.com/";
|
|
207
|
+
const text = "b".repeat(LINEAR_BODY_MAX + 500);
|
|
208
|
+
|
|
209
|
+
const body = formatLinearOutcomeBody(text, "task-123");
|
|
210
|
+
|
|
211
|
+
expect(body.length).toBeLessThanOrEqual(LINEAR_BODY_MAX);
|
|
212
|
+
expect(body.startsWith("b".repeat(1000))).toBe(true);
|
|
213
|
+
expect(body).toContain(`of ${text.length} characters`);
|
|
214
|
+
expect(body.endsWith("Full text: https://swarm.example.com/tasks/task-123_")).toBe(true);
|
|
215
|
+
expect(body).not.toContain("+++");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("names the task id when no dashboard URL is configured", () => {
|
|
219
|
+
delete process.env.SWARM_DASHBOARD_URL;
|
|
220
|
+
delete process.env.APP_URL;
|
|
221
|
+
|
|
222
|
+
const body = formatLinearOutcomeBody("c".repeat(LINEAR_BODY_MAX + 1), "task-456");
|
|
223
|
+
|
|
224
|
+
expect(body.length).toBeLessThanOrEqual(LINEAR_BODY_MAX);
|
|
225
|
+
expect(body.endsWith("Full text: swarm task `task-456`_")).toBe(true);
|
|
226
|
+
});
|
|
112
227
|
});
|
|
113
228
|
|
|
114
229
|
test("no-op when no tracker_sync mapping exists", async () => {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getAgentById,
|
|
12
12
|
getAllAgents,
|
|
13
13
|
getAllTasks,
|
|
14
|
+
getDbClient,
|
|
14
15
|
getScheduledTasks,
|
|
15
16
|
initDb,
|
|
16
17
|
listAllPages,
|
|
@@ -227,6 +228,96 @@ describe("list-endpoint slimming", () => {
|
|
|
227
228
|
expect(full?.totalCostUsd).toBeCloseTo(0.0168, 6);
|
|
228
229
|
});
|
|
229
230
|
|
|
231
|
+
test("getAllTasks — SQL-side preview matches truncating the full text", async () => {
|
|
232
|
+
// The SQL cut counts UTF-8 bytes while JS slices UTF-16 units, so pin the
|
|
233
|
+
// boundary with astral (surrogate-pair) and multibyte characters. SQLite's
|
|
234
|
+
// text functions stop at an embedded NUL, so pin that too, plus a leading
|
|
235
|
+
// BOM a default TextDecoder would strip. The BOM goes in as SQL bytes:
|
|
236
|
+
// bun:sqlite drops a leading BOM when it binds a string.
|
|
237
|
+
const agentId = "preview-parity-agent";
|
|
238
|
+
const marker = "preview-parity-";
|
|
239
|
+
const texts = [
|
|
240
|
+
`${marker}${"a".repeat(285)}`, // exactly 300 units
|
|
241
|
+
`${marker}${"a".repeat(286)}`, // 301 units
|
|
242
|
+
`${marker}${"😀".repeat(200)}`,
|
|
243
|
+
`${marker}${"é".repeat(284)}😀`,
|
|
244
|
+
`${marker}${"ñ".repeat(500)}`,
|
|
245
|
+
`${marker}short`,
|
|
246
|
+
"a\u0000b",
|
|
247
|
+
`\u0000${"x".repeat(350)}`,
|
|
248
|
+
];
|
|
249
|
+
for (const text of texts) await createTaskExtended(text, { agentId });
|
|
250
|
+
const bom = await createTaskExtended(`${marker}bom`, { agentId });
|
|
251
|
+
texts.push(`\uFEFF${marker}bom`);
|
|
252
|
+
await getDbClient().run(
|
|
253
|
+
"UPDATE agent_tasks SET task = CAST(x'EFBBBF' AS TEXT) || task WHERE id = ?",
|
|
254
|
+
[bom.id],
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const full = await getAllTasks({ agentId, limit: 50 });
|
|
258
|
+
expect(full.map((t) => t.task).sort()).toEqual([...texts].sort());
|
|
259
|
+
for (const opts of [{ slim: true }, { fields: "timeline" as const }]) {
|
|
260
|
+
const rows = await getAllTasks({ agentId, limit: 50 }, opts);
|
|
261
|
+
expect(rows.map((t) => t.id)).toEqual(full.map((t) => t.id));
|
|
262
|
+
for (const [i, row] of full.entries()) {
|
|
263
|
+
const expected = row.task.length > 300 ? `${row.task.slice(0, 300)}…` : row.task;
|
|
264
|
+
expect(rows[i]!.task).toBe(expected);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("getAllTasks — slim and full pages hold the same rows in the same order", async () => {
|
|
270
|
+
for (let i = 0; i < 12; i++) {
|
|
271
|
+
await createTaskExtended(`page-parity ${i}`, { agentId: "slim-agent-1" });
|
|
272
|
+
}
|
|
273
|
+
for (const orderBy of ["lastUpdatedAt", "createdAt"] as const) {
|
|
274
|
+
for (const offset of [0, 5, 10]) {
|
|
275
|
+
const filters = { search: "page-parity", limit: 5, offset, orderBy };
|
|
276
|
+
const slimIds = (await getAllTasks(filters, { slim: true })).map((t) => t.id);
|
|
277
|
+
const fullIds = (await getAllTasks(filters)).map((t) => t.id);
|
|
278
|
+
const timelineIds = (await getAllTasks(filters, { fields: "timeline" })).map((t) => t.id);
|
|
279
|
+
expect(slimIds).toEqual(fullIds);
|
|
280
|
+
expect(timelineIds).toEqual(fullIds);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("getAllTasks — timeline projection carries only what the timeline draws", async () => {
|
|
286
|
+
const task = await createTaskExtended(`timeline-row ${"T".repeat(1000)}`, {
|
|
287
|
+
agentId: "slim-agent-1",
|
|
288
|
+
tags: ["timeline"],
|
|
289
|
+
});
|
|
290
|
+
await createSessionCost({
|
|
291
|
+
sessionId: "timeline-cost-session",
|
|
292
|
+
taskId: task.id,
|
|
293
|
+
agentId: "slim-agent-1",
|
|
294
|
+
totalCostUsd: 0.25,
|
|
295
|
+
durationMs: 1000,
|
|
296
|
+
numTurns: 1,
|
|
297
|
+
model: "test-model",
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const [row] = await getAllTasks({ search: "timeline-row", limit: 1 }, { fields: "timeline" });
|
|
301
|
+
expect(Object.keys(row!).sort()).toEqual(
|
|
302
|
+
[
|
|
303
|
+
"agentId",
|
|
304
|
+
"createdAt",
|
|
305
|
+
"finishedAt",
|
|
306
|
+
"id",
|
|
307
|
+
"lastUpdatedAt",
|
|
308
|
+
"parentTaskId",
|
|
309
|
+
"peakContextTokens",
|
|
310
|
+
"status",
|
|
311
|
+
"task",
|
|
312
|
+
"title",
|
|
313
|
+
"totalCostUsd",
|
|
314
|
+
].sort(),
|
|
315
|
+
);
|
|
316
|
+
expect(row!.id).toBe(task.id);
|
|
317
|
+
expect(row!.task).toBe(`${task.task.slice(0, 300)}…`);
|
|
318
|
+
expect(row!.totalCostUsd).toBeCloseTo(0.25, 6);
|
|
319
|
+
});
|
|
320
|
+
|
|
230
321
|
test("listRecentSessions — slim root is a truncated task summary", async () => {
|
|
231
322
|
const longText = "Q".repeat(2000);
|
|
232
323
|
await createTaskExtended(longText, { agentId: "slim-agent-1" });
|