@desplega.ai/agent-swarm 1.71.2 → 1.72.1

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.
Files changed (64) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +994 -62
  3. package/package.json +2 -1
  4. package/src/be/budget-admission.ts +121 -0
  5. package/src/be/budget-refusal-notify.ts +145 -0
  6. package/src/be/db.ts +488 -5
  7. package/src/be/migrations/044_provider_meta.sql +2 -0
  8. package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
  9. package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
  10. package/src/cli.tsx +22 -1
  11. package/src/commands/claude-managed-setup.ts +687 -0
  12. package/src/commands/codex-login.ts +1 -1
  13. package/src/commands/runner.ts +175 -28
  14. package/src/commands/templates.ts +10 -6
  15. package/src/http/budgets.ts +219 -0
  16. package/src/http/index.ts +6 -0
  17. package/src/http/integrations.ts +134 -0
  18. package/src/http/poll.ts +161 -3
  19. package/src/http/pricing.ts +245 -0
  20. package/src/http/session-data.ts +54 -6
  21. package/src/http/tasks.ts +23 -2
  22. package/src/prompts/base-prompt.ts +103 -73
  23. package/src/prompts/session-templates.ts +43 -0
  24. package/src/providers/claude-adapter.ts +3 -1
  25. package/src/providers/claude-managed-adapter.ts +871 -0
  26. package/src/providers/claude-managed-models.ts +117 -0
  27. package/src/providers/claude-managed-swarm-events.ts +77 -0
  28. package/src/providers/codex-adapter.ts +3 -1
  29. package/src/providers/codex-skill-resolver.ts +10 -0
  30. package/src/providers/codex-swarm-events.ts +20 -161
  31. package/src/providers/devin-adapter.ts +894 -0
  32. package/src/providers/devin-api.ts +207 -0
  33. package/src/providers/devin-playbooks.ts +91 -0
  34. package/src/providers/devin-skill-resolver.ts +113 -0
  35. package/src/providers/index.ts +10 -1
  36. package/src/providers/pi-mono-adapter.ts +3 -1
  37. package/src/providers/swarm-events-shared.ts +262 -0
  38. package/src/providers/types.ts +26 -1
  39. package/src/tests/base-prompt.test.ts +199 -0
  40. package/src/tests/budget-admission.test.ts +338 -0
  41. package/src/tests/budget-claim-gate.test.ts +288 -0
  42. package/src/tests/budget-refusal-notification.test.ts +324 -0
  43. package/src/tests/budgets-routes.test.ts +331 -0
  44. package/src/tests/claude-managed-adapter.test.ts +1301 -0
  45. package/src/tests/claude-managed-setup.test.ts +325 -0
  46. package/src/tests/devin-adapter.test.ts +677 -0
  47. package/src/tests/devin-api.test.ts +339 -0
  48. package/src/tests/integrations-http.test.ts +211 -0
  49. package/src/tests/migration-046-budgets.test.ts +327 -0
  50. package/src/tests/pricing-routes.test.ts +315 -0
  51. package/src/tests/prompt-template-remaining.test.ts +4 -0
  52. package/src/tests/prompt-template-session.test.ts +2 -2
  53. package/src/tests/provider-adapter.test.ts +1 -1
  54. package/src/tests/runner-budget-refused.test.ts +271 -0
  55. package/src/tests/session-costs-codex-recompute.test.ts +386 -0
  56. package/src/tools/poll-task.ts +13 -2
  57. package/src/tools/task-action.ts +92 -2
  58. package/src/tools/templates.ts +29 -0
  59. package/src/types.ts +116 -0
  60. package/src/utils/budget-backoff.ts +34 -0
  61. package/src/utils/credentials.ts +4 -0
  62. package/src/utils/provider-metadata.ts +9 -0
  63. package/src/workflows/executors/raw-llm.ts +1 -1
  64. package/src/workflows/executors/validate.ts +1 -1
@@ -0,0 +1,338 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { __resetKillSwitchWarnedForTests, canClaim } from "../be/budget-admission";
4
+ import {
5
+ closeDb,
6
+ createAgent,
7
+ createSessionCost,
8
+ getBudgetRefusalNotification,
9
+ getDailySpendForAgent,
10
+ getDb,
11
+ hasBudgetRefusalNotificationToday,
12
+ initDb,
13
+ recordBudgetRefusalNotification,
14
+ } from "../be/db";
15
+
16
+ const TEST_DB_PATH = "./test-budget-admission.sqlite";
17
+
18
+ async function removeDbFiles(path: string): Promise<void> {
19
+ for (const suffix of ["", "-wal", "-shm"]) {
20
+ try {
21
+ await unlink(path + suffix);
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
24
+ throw error;
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ beforeAll(() => {
31
+ initDb(TEST_DB_PATH);
32
+ });
33
+
34
+ afterAll(async () => {
35
+ closeDb();
36
+ await removeDbFiles(TEST_DB_PATH);
37
+ });
38
+
39
+ // Reset DB-side budget / notification / session_cost rows between tests so
40
+ // each test starts from a known empty slate. We don't tear the whole DB down
41
+ // — initDb() in beforeAll is enough.
42
+ //
43
+ // We also wipe `agents` because session_costs has a FK to agents and the test
44
+ // helper `insertSpendForAgent` re-creates agents on demand. Tests don't use
45
+ // any other agent-related rows.
46
+ beforeEach(() => {
47
+ const db = getDb();
48
+ db.prepare("DELETE FROM session_costs").run();
49
+ db.prepare("DELETE FROM budget_refusal_notifications").run();
50
+ db.prepare("DELETE FROM budgets").run();
51
+ db.prepare("DELETE FROM agents").run();
52
+ ensuredAgentIds.clear();
53
+ });
54
+
55
+ // Always restore the kill-switch env var so it doesn't leak across tests.
56
+ afterEach(() => {
57
+ delete process.env.BUDGET_ADMISSION_DISABLED;
58
+ __resetKillSwitchWarnedForTests();
59
+ });
60
+
61
+ // Track which agents we've already created in the current test so we don't
62
+ // hit the PK collision on a second `createAgent`. Cleared in `beforeEach`.
63
+ const ensuredAgentIds = new Set<string>();
64
+
65
+ function ensureAgent(agentId: string): void {
66
+ if (ensuredAgentIds.has(agentId)) return;
67
+ createAgent({
68
+ id: agentId,
69
+ name: `agent-${agentId}`,
70
+ isLead: false,
71
+ status: "idle",
72
+ });
73
+ ensuredAgentIds.add(agentId);
74
+ }
75
+
76
+ function insertBudget(scope: "global" | "agent", scopeId: string, dailyBudgetUsd: number): void {
77
+ getDb()
78
+ .prepare(
79
+ "INSERT INTO budgets (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?)",
80
+ )
81
+ .run(scope, scopeId, dailyBudgetUsd, Date.now(), Date.now());
82
+ }
83
+
84
+ // Pinned to the same UTC day as `NOW` so spend rows fall inside the queried day window regardless of when CI runs.
85
+ const DEFAULT_SPEND_CREATED_AT = "2026-04-28T12:00:00.000Z";
86
+
87
+ function insertSpendForAgent(
88
+ agentId: string,
89
+ totalCostUsd: number,
90
+ opts: { createdAt?: string } = {},
91
+ ): string {
92
+ ensureAgent(agentId);
93
+ const cost = createSessionCost({
94
+ sessionId: `sess-${crypto.randomUUID()}`,
95
+ agentId,
96
+ totalCostUsd,
97
+ durationMs: 1000,
98
+ numTurns: 1,
99
+ model: "test-model",
100
+ });
101
+ const createdAt = opts.createdAt ?? DEFAULT_SPEND_CREATED_AT;
102
+ getDb().prepare("UPDATE session_costs SET createdAt = ? WHERE id = ?").run(createdAt, cost.id);
103
+ return cost.id;
104
+ }
105
+
106
+ describe("canClaim — budget admission predicate", () => {
107
+ const NOW = new Date("2026-04-28T15:30:00.000Z");
108
+ const TODAY = "2026-04-28";
109
+
110
+ test("missing budget rows ⇒ allowed (default unlimited)", () => {
111
+ const result = canClaim("agent-1", NOW);
112
+ expect(result.allowed).toBe(true);
113
+ });
114
+
115
+ test("global budget set, spend below ceiling ⇒ allowed", () => {
116
+ insertBudget("global", "", 10.0);
117
+ insertSpendForAgent("agent-x", 3.5);
118
+
119
+ const result = canClaim("agent-1", NOW);
120
+ expect(result.allowed).toBe(true);
121
+ });
122
+
123
+ test("global budget set, spend at ceiling ⇒ refused with cause='global'", () => {
124
+ insertBudget("global", "", 10.0);
125
+ insertSpendForAgent("agent-x", 7.0);
126
+ insertSpendForAgent("agent-y", 3.0); // exactly hits 10.0
127
+
128
+ const result = canClaim("agent-z", NOW);
129
+ expect(result.allowed).toBe(false);
130
+ if (result.allowed) throw new Error("unreachable");
131
+ expect(result.cause).toBe("global");
132
+ expect(result.globalSpend).toBe(10.0);
133
+ expect(result.globalBudget).toBe(10.0);
134
+ // Global-cause refusals do not populate agent fields.
135
+ expect(result.agentSpend).toBeUndefined();
136
+ expect(result.agentBudget).toBeUndefined();
137
+ });
138
+
139
+ test("agent budget set, agent spend at ceiling ⇒ refused with cause='agent'", () => {
140
+ insertBudget("agent", "agent-1", 5.0);
141
+ insertSpendForAgent("agent-1", 5.0);
142
+
143
+ const result = canClaim("agent-1", NOW);
144
+ expect(result.allowed).toBe(false);
145
+ if (result.allowed) throw new Error("unreachable");
146
+ expect(result.cause).toBe("agent");
147
+ expect(result.agentSpend).toBe(5.0);
148
+ expect(result.agentBudget).toBe(5.0);
149
+ expect(result.globalSpend).toBeUndefined();
150
+ expect(result.globalBudget).toBeUndefined();
151
+ });
152
+
153
+ test("both budgets set + both blown ⇒ refused with cause='global' (global is checked first)", () => {
154
+ insertBudget("global", "", 10.0);
155
+ insertBudget("agent", "agent-1", 2.0);
156
+ insertSpendForAgent("agent-1", 10.0);
157
+
158
+ const result = canClaim("agent-1", NOW);
159
+ expect(result.allowed).toBe(false);
160
+ if (result.allowed) throw new Error("unreachable");
161
+ expect(result.cause).toBe("global");
162
+ });
163
+
164
+ test("spend on a different UTC day does NOT count toward today", () => {
165
+ insertBudget("agent", "agent-1", 5.0);
166
+ // Backdated cost from yesterday — should not contribute to today's total.
167
+ insertSpendForAgent("agent-1", 50.0, { createdAt: "2026-04-27T23:59:59.999Z" });
168
+ // A small cost today that does not blow the budget.
169
+ insertSpendForAgent("agent-1", 1.0, { createdAt: "2026-04-28T01:00:00.000Z" });
170
+
171
+ const todaySpend = getDailySpendForAgent("agent-1", TODAY);
172
+ expect(todaySpend).toBe(1.0);
173
+
174
+ const result = canClaim("agent-1", NOW);
175
+ expect(result.allowed).toBe(true);
176
+ });
177
+
178
+ test("resetAt for nowUtc=15:30Z is the FOLLOWING UTC midnight", () => {
179
+ insertBudget("global", "", 0.0); // force a refusal so resetAt is set
180
+ const result = canClaim("agent-1", new Date("2026-04-28T15:30:00.000Z"));
181
+ expect(result.allowed).toBe(false);
182
+ if (result.allowed) throw new Error("unreachable");
183
+ expect(result.resetAt).toBe("2026-04-29T00:00:00.000Z");
184
+ });
185
+
186
+ test("resetAt at exact UTC midnight rolls forward by +24h, not the current instant", () => {
187
+ insertBudget("global", "", 0.0);
188
+ const result = canClaim("agent-1", new Date("2026-04-28T00:00:00.000Z"));
189
+ expect(result.allowed).toBe(false);
190
+ if (result.allowed) throw new Error("unreachable");
191
+ expect(result.resetAt).toBe("2026-04-29T00:00:00.000Z");
192
+ });
193
+
194
+ test("resetAt rolls over month boundary: 2026-04-30T23:59:59.999Z → 2026-05-01T00:00:00.000Z", () => {
195
+ insertBudget("global", "", 0.0);
196
+ const result = canClaim("agent-1", new Date("2026-04-30T23:59:59.999Z"));
197
+ expect(result.allowed).toBe(false);
198
+ if (result.allowed) throw new Error("unreachable");
199
+ expect(result.resetAt).toBe("2026-05-01T00:00:00.000Z");
200
+ });
201
+
202
+ test("resetAt rolls over year boundary: 2026-12-31T23:59:59.999Z → 2027-01-01T00:00:00.000Z", () => {
203
+ insertBudget("global", "", 0.0);
204
+ const result = canClaim("agent-1", new Date("2026-12-31T23:59:59.999Z"));
205
+ expect(result.allowed).toBe(false);
206
+ if (result.allowed) throw new Error("unreachable");
207
+ expect(result.resetAt).toBe("2027-01-01T00:00:00.000Z");
208
+ });
209
+
210
+ test("BUDGET_ADMISSION_DISABLED=true short-circuits to allowed regardless of budget rows", () => {
211
+ insertBudget("global", "", 0.0); // would refuse without the flag
212
+ insertBudget("agent", "agent-1", 0.0);
213
+ insertSpendForAgent("agent-1", 100.0);
214
+
215
+ process.env.BUDGET_ADMISSION_DISABLED = "true";
216
+ const result = canClaim("agent-1", NOW);
217
+ expect(result.allowed).toBe(true);
218
+ });
219
+ });
220
+
221
+ describe("recordBudgetRefusalNotification — idempotent dedup", () => {
222
+ beforeEach(() => {
223
+ getDb().prepare("DELETE FROM budget_refusal_notifications").run();
224
+ });
225
+
226
+ test("first call inserts; second call with same (taskId, date) returns existing row with inserted=false", () => {
227
+ const args = {
228
+ taskId: "task-1",
229
+ date: "2026-04-28",
230
+ agentId: "agent-1",
231
+ cause: "agent" as const,
232
+ agentSpendUsd: 5.0,
233
+ agentBudgetUsd: 5.0,
234
+ };
235
+
236
+ const first = recordBudgetRefusalNotification(args);
237
+ expect(first.inserted).toBe(true);
238
+ expect(first.row.taskId).toBe("task-1");
239
+ expect(first.row.date).toBe("2026-04-28");
240
+ expect(first.row.cause).toBe("agent");
241
+ expect(first.row.agentSpendUsd).toBe(5.0);
242
+ expect(first.row.agentBudgetUsd).toBe(5.0);
243
+ expect(first.row.followUpTaskId).toBeUndefined();
244
+
245
+ const second = recordBudgetRefusalNotification({
246
+ ...args,
247
+ // Different cause/spend — should still be ignored, returning the original row.
248
+ cause: "global",
249
+ agentSpendUsd: 999,
250
+ agentBudgetUsd: 999,
251
+ });
252
+ expect(second.inserted).toBe(false);
253
+ expect(second.row.cause).toBe("agent"); // original
254
+ expect(second.row.agentSpendUsd).toBe(5.0); // original
255
+ expect(second.row.agentBudgetUsd).toBe(5.0); // original
256
+ expect(second.row.createdAt).toBe(first.row.createdAt);
257
+ });
258
+
259
+ test("same task on a different date inserts a new row", () => {
260
+ const first = recordBudgetRefusalNotification({
261
+ taskId: "task-1",
262
+ date: "2026-04-28",
263
+ agentId: "agent-1",
264
+ cause: "agent",
265
+ });
266
+ expect(first.inserted).toBe(true);
267
+
268
+ const next = recordBudgetRefusalNotification({
269
+ taskId: "task-1",
270
+ date: "2026-04-29",
271
+ agentId: "agent-1",
272
+ cause: "agent",
273
+ });
274
+ expect(next.inserted).toBe(true);
275
+ expect(next.row.date).toBe("2026-04-29");
276
+ });
277
+
278
+ test("hasBudgetRefusalNotificationToday observes presence/absence", () => {
279
+ expect(hasBudgetRefusalNotificationToday("task-1", "2026-04-28")).toBe(false);
280
+
281
+ recordBudgetRefusalNotification({
282
+ taskId: "task-1",
283
+ date: "2026-04-28",
284
+ agentId: "agent-1",
285
+ cause: "global",
286
+ globalSpendUsd: 12.5,
287
+ globalBudgetUsd: 10.0,
288
+ });
289
+
290
+ expect(hasBudgetRefusalNotificationToday("task-1", "2026-04-28")).toBe(true);
291
+ expect(hasBudgetRefusalNotificationToday("task-1", "2026-04-29")).toBe(false);
292
+ });
293
+
294
+ test("getBudgetRefusalNotification round-trips global-cause fields", () => {
295
+ recordBudgetRefusalNotification({
296
+ taskId: "task-2",
297
+ date: "2026-04-28",
298
+ agentId: "agent-1",
299
+ cause: "global",
300
+ globalSpendUsd: 12.5,
301
+ globalBudgetUsd: 10.0,
302
+ });
303
+
304
+ const row = getBudgetRefusalNotification("task-2", "2026-04-28");
305
+ expect(row).not.toBeNull();
306
+ expect(row?.cause).toBe("global");
307
+ expect(row?.globalSpendUsd).toBe(12.5);
308
+ expect(row?.globalBudgetUsd).toBe(10.0);
309
+ expect(row?.agentSpendUsd).toBeUndefined();
310
+ expect(row?.agentBudgetUsd).toBeUndefined();
311
+ });
312
+ });
313
+
314
+ describe("getDailySpendForAgent — uses an agentId-leading index (no full table scan)", () => {
315
+ interface ExplainRow {
316
+ id: number;
317
+ parent: number;
318
+ notused: number;
319
+ detail: string;
320
+ }
321
+
322
+ test("EXPLAIN QUERY PLAN uses an idx_session_costs_agent* index", () => {
323
+ // Both `idx_session_costs_agentId` (single-column) and
324
+ // `idx_session_costs_agent_createdAt` (composite) lead with `agentId`, so
325
+ // either one is a valid plan — SQLite's optimizer picks based on stats.
326
+ // What we MUST avoid is a full table scan ("SCAN session_costs" without
327
+ // "USING INDEX"). The assertion below covers both index choices and
328
+ // explicitly fails on a bare scan.
329
+ const rows = getDb()
330
+ .prepare<ExplainRow, [string, string]>(
331
+ "EXPLAIN QUERY PLAN SELECT COALESCE(SUM(totalCostUsd), 0) as total FROM session_costs WHERE agentId = ? AND substr(createdAt, 1, 10) = ?",
332
+ )
333
+ .all("agent-1", "2026-04-28");
334
+
335
+ const detail = rows.map((r) => r.detail).join(" | ");
336
+ expect(detail).toMatch(/USING INDEX idx_session_costs_agent/);
337
+ });
338
+ });
@@ -0,0 +1,288 @@
1
+ // Phase 3: server-side budget admission gate tests.
2
+ //
3
+ // Exercises `handlePoll` (the `/api/poll` HTTP handler) and the MCP
4
+ // `task-action` `accept` action directly with mocked req/res so we don't have
5
+ // to spin up a full HTTP server. Each test starts from an empty DB.
6
+
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { unlink } from "node:fs/promises";
9
+ import {
10
+ closeDb,
11
+ createAgent,
12
+ createSessionCost,
13
+ createTaskExtended,
14
+ getAgentById,
15
+ getDb,
16
+ incrementEmptyPollCount,
17
+ initDb,
18
+ } from "../be/db";
19
+ import { handlePoll } from "../http/poll";
20
+
21
+ const TEST_DB_PATH = "./test-budget-claim-gate.sqlite";
22
+
23
+ async function removeDbFiles(path: string): Promise<void> {
24
+ for (const suffix of ["", "-wal", "-shm"]) {
25
+ try {
26
+ await unlink(path + suffix);
27
+ } catch (error) {
28
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
29
+ }
30
+ }
31
+ }
32
+
33
+ beforeAll(() => {
34
+ initDb(TEST_DB_PATH);
35
+ });
36
+
37
+ afterAll(async () => {
38
+ closeDb();
39
+ await removeDbFiles(TEST_DB_PATH);
40
+ });
41
+
42
+ beforeEach(() => {
43
+ const db = getDb();
44
+ // Clear data from previous test, leave schema in place. Delete task-graph
45
+ // tables in dependency order so FKs don't trip.
46
+ db.prepare("DELETE FROM session_costs").run();
47
+ db.prepare("DELETE FROM budget_refusal_notifications").run();
48
+ db.prepare("DELETE FROM budgets").run();
49
+ db.prepare("DELETE FROM agent_tasks").run();
50
+ db.prepare("DELETE FROM agents").run();
51
+ });
52
+
53
+ // ─── Helpers ────────────────────────────────────────────────────────────────
54
+
55
+ interface PollResponse {
56
+ status: number;
57
+ body: { trigger: { type: string; [key: string]: unknown } | null } | { error: string };
58
+ }
59
+
60
+ /**
61
+ * Direct invocation of `handlePoll`. We use real Bun-built ServerResponse-ish
62
+ * mocks rather than spawning the API server — much faster and isolates the
63
+ * test surface to the gate itself.
64
+ */
65
+ async function callPoll(agentId: string | undefined): Promise<PollResponse> {
66
+ let status = 200;
67
+ let bodyStr = "";
68
+ const headers: Record<string, string> = {};
69
+
70
+ const req = {
71
+ method: "GET",
72
+ url: "/api/poll",
73
+ headers: agentId ? { "x-agent-id": agentId } : {},
74
+ } as unknown as Parameters<typeof handlePoll>[0];
75
+
76
+ const res = {
77
+ setHeader(name: string, value: string) {
78
+ headers[name.toLowerCase()] = value;
79
+ },
80
+ writeHead(code: number, h?: Record<string, string>) {
81
+ status = code;
82
+ if (h) {
83
+ for (const [k, v] of Object.entries(h)) headers[k.toLowerCase()] = v;
84
+ }
85
+ },
86
+ end(body?: string) {
87
+ bodyStr = body ?? "";
88
+ },
89
+ } as unknown as Parameters<typeof handlePoll>[1];
90
+
91
+ const handled = await handlePoll(req, res, ["api", "poll"], new URLSearchParams(), agentId);
92
+ if (!handled) throw new Error("handlePoll did not handle the request");
93
+
94
+ return { status, body: bodyStr ? JSON.parse(bodyStr) : null };
95
+ }
96
+
97
+ function insertBudget(scope: "global" | "agent", scopeId: string, dailyBudgetUsd: number): void {
98
+ const now = Date.now();
99
+ getDb()
100
+ .prepare(
101
+ "INSERT INTO budgets (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?)",
102
+ )
103
+ .run(scope, scopeId, dailyBudgetUsd, now, now);
104
+ }
105
+
106
+ function insertSpend(agentId: string, totalCostUsd: number): void {
107
+ createSessionCost({
108
+ sessionId: `sess-${crypto.randomUUID()}`,
109
+ agentId,
110
+ totalCostUsd,
111
+ durationMs: 1_000,
112
+ numTurns: 1,
113
+ model: "test-model",
114
+ });
115
+ }
116
+
117
+ // ─── Tests ──────────────────────────────────────────────────────────────────
118
+
119
+ describe("Phase 3 — /api/poll budget admission gate", () => {
120
+ test("no budgets configured + pending task → trigger=task_assigned (existing behavior preserved)", async () => {
121
+ const worker = createAgent({ name: "w1", isLead: false, status: "idle", maxTasks: 1 });
122
+ const task = createTaskExtended("do the thing", { agentId: worker.id });
123
+ expect(task.status).toBe("pending");
124
+
125
+ const { status, body } = await callPoll(worker.id);
126
+ expect(status).toBe(200);
127
+ if ("error" in body) throw new Error("unexpected error response");
128
+ expect(body.trigger?.type).toBe("task_assigned");
129
+ expect((body.trigger as { taskId: string }).taskId).toBe(task.id);
130
+ });
131
+
132
+ test("no budgets configured + no work → trigger=null", async () => {
133
+ const worker = createAgent({ name: "w-empty", isLead: false, status: "idle", maxTasks: 1 });
134
+ const { status, body } = await callPoll(worker.id);
135
+ expect(status).toBe(200);
136
+ if ("error" in body) throw new Error("unexpected error response");
137
+ expect(body.trigger).toBeNull();
138
+ });
139
+
140
+ test("budgets present but spend below ceiling → trigger=task_assigned", async () => {
141
+ const worker = createAgent({ name: "w-below", isLead: false, status: "idle", maxTasks: 1 });
142
+ insertBudget("agent", worker.id, 10.0);
143
+ insertSpend(worker.id, 1.0); // well below 10.0
144
+ const task = createTaskExtended("budgeted task", { agentId: worker.id });
145
+
146
+ const { body } = await callPoll(worker.id);
147
+ if ("error" in body) throw new Error("unexpected error response");
148
+ expect(body.trigger?.type).toBe("task_assigned");
149
+ expect((body.trigger as { taskId: string }).taskId).toBe(task.id);
150
+ });
151
+
152
+ test("agent budget blown → trigger=budget_refused with cause='agent'", async () => {
153
+ const worker = createAgent({ name: "w-over", isLead: false, status: "idle", maxTasks: 1 });
154
+ insertBudget("agent", worker.id, 0.01);
155
+ insertSpend(worker.id, 0.05); // blows the 0.01 budget
156
+ createTaskExtended("blocked task", { agentId: worker.id });
157
+
158
+ const { body } = await callPoll(worker.id);
159
+ if ("error" in body) throw new Error("unexpected error response");
160
+ expect(body.trigger?.type).toBe("budget_refused");
161
+ expect((body.trigger as { cause: string }).cause).toBe("agent");
162
+ expect((body.trigger as { agentSpend: number }).agentSpend).toBe(0.05);
163
+ expect((body.trigger as { agentBudget: number }).agentBudget).toBe(0.01);
164
+ expect((body.trigger as { resetAt: string }).resetAt).toMatch(
165
+ /^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/,
166
+ );
167
+ // Global fields must not be present (cause=agent).
168
+ expect((body.trigger as { globalSpend?: number }).globalSpend).toBeUndefined();
169
+ expect((body.trigger as { globalBudget?: number }).globalBudget).toBeUndefined();
170
+ });
171
+
172
+ test("global budget blown → trigger=budget_refused with cause='global'", async () => {
173
+ const worker = createAgent({ name: "w-glob", isLead: false, status: "idle", maxTasks: 1 });
174
+ insertBudget("global", "", 0.01);
175
+ insertSpend(worker.id, 0.1); // blows the global budget
176
+ createTaskExtended("blocked-by-global", { agentId: worker.id });
177
+
178
+ const { body } = await callPoll(worker.id);
179
+ if ("error" in body) throw new Error("unexpected error response");
180
+ expect(body.trigger?.type).toBe("budget_refused");
181
+ expect((body.trigger as { cause: string }).cause).toBe("global");
182
+ expect((body.trigger as { globalSpend: number }).globalSpend).toBe(0.1);
183
+ expect((body.trigger as { globalBudget: number }).globalBudget).toBe(0.01);
184
+ // Agent fields must not be present (cause=global).
185
+ expect((body.trigger as { agentSpend?: number }).agentSpend).toBeUndefined();
186
+ });
187
+
188
+ test("budget refusal in pool path: blows the gate without claiming an unassigned task", async () => {
189
+ const worker = createAgent({ name: "w-pool", isLead: false, status: "idle", maxTasks: 1 });
190
+ insertBudget("agent", worker.id, 0.01);
191
+ insertSpend(worker.id, 0.5);
192
+ // Unassigned (pool) task — no `agentId`.
193
+ const pooled = createTaskExtended("pool task", {});
194
+ expect(pooled.status).toBe("unassigned");
195
+
196
+ const { body } = await callPoll(worker.id);
197
+ if ("error" in body) throw new Error("unexpected error response");
198
+ expect(body.trigger?.type).toBe("budget_refused");
199
+
200
+ // The pool task must still be unassigned (refusal short-circuited claim).
201
+ const row = getDb()
202
+ .prepare<{ status: string }, [string]>("SELECT status FROM agent_tasks WHERE id = ?")
203
+ .get(pooled.id);
204
+ expect(row?.status).toBe("unassigned");
205
+ });
206
+
207
+ test("refused poll does NOT auto-increment server-side empty-poll counter", async () => {
208
+ const worker = createAgent({ name: "w-emptyp", isLead: false, status: "idle", maxTasks: 1 });
209
+ insertBudget("agent", worker.id, 0.01);
210
+ insertSpend(worker.id, 0.5);
211
+ createTaskExtended("blocked", { agentId: worker.id });
212
+
213
+ const before = getAgentById(worker.id)?.emptyPollCount ?? 0;
214
+ await callPoll(worker.id);
215
+ const after = getAgentById(worker.id)?.emptyPollCount ?? 0;
216
+ // /api/poll never increments emptyPollCount itself — that's the MCP
217
+ // poll-task tool's job — and a refusal must not flip that invariant.
218
+ // We assert "no increment" specifically for the refusal path.
219
+ expect(after).toBe(before);
220
+ // Sanity: the agent's counter is still 0; if a pre-feature regression
221
+ // accidentally bumped it, this would catch it.
222
+ expect(after).toBe(0);
223
+ // Quick sanity that the bookkeeping helper itself still works (regression
224
+ // guard for the wasBudgetRefused flag plumbing in poll-task.ts).
225
+ const newCount = incrementEmptyPollCount(worker.id);
226
+ expect(newCount).toBe(1);
227
+ });
228
+
229
+ test("two-agent race: only one task is claimed; the other gets task_assigned for a different task or null", async () => {
230
+ const w1 = createAgent({ name: "race-1", isLead: false, status: "idle", maxTasks: 1 });
231
+ const w2 = createAgent({ name: "race-2", isLead: false, status: "idle", maxTasks: 1 });
232
+ // One unassigned task in the pool.
233
+ const t = createTaskExtended("race task", {});
234
+ expect(t.status).toBe("unassigned");
235
+
236
+ // Sequentially poll both; race correctness is enforced by SQLite
237
+ // serialization, so even back-to-back the atomic UPDATE WHERE
238
+ // status='unassigned' guarantees only one wins.
239
+ const r1 = await callPoll(w1.id);
240
+ const r2 = await callPoll(w2.id);
241
+
242
+ if ("error" in r1.body || "error" in r2.body) throw new Error("unexpected error");
243
+ const triggers = [r1.body.trigger, r2.body.trigger];
244
+ const claimedTriggers = triggers.filter((t) => t?.type === "task_assigned");
245
+ const nullTriggers = triggers.filter((t) => t === null);
246
+ expect(claimedTriggers).toHaveLength(1);
247
+ expect(nullTriggers).toHaveLength(1);
248
+ });
249
+ });
250
+
251
+ // ─── MCP task-action accept tests ───────────────────────────────────────────
252
+ //
253
+ // The MCP tool registration is hard to invoke without the MCP server harness,
254
+ // so instead we exercise the underlying transaction by importing the same
255
+ // code path via a focused "accept simulation". We can't import the inner
256
+ // switch directly, so we call the same helpers and assert canClaim refusal
257
+ // returns the documented envelope shape.
258
+
259
+ describe("Phase 3 — MCP task-action accept gate (canClaim integration)", () => {
260
+ test("accept refuses when agent budget is blown (returns refusalCause='agent')", async () => {
261
+ // We test the canClaim integration directly because the MCP tool is wired
262
+ // through `createToolRegistrar` and would require a full MCP server boot
263
+ // to invoke. The accept handler's relevant code path in task-action.ts
264
+ // (lines extended in Phase 3) is exercised by the same predicate, so a
265
+ // direct canClaim assertion gives us coverage parity without the
266
+ // server-boot overhead.
267
+ const { canClaim } = await import("../be/budget-admission");
268
+ const worker = createAgent({ name: "accept-w", isLead: false, status: "idle", maxTasks: 1 });
269
+ insertBudget("agent", worker.id, 0.01);
270
+ insertSpend(worker.id, 0.5);
271
+
272
+ const result = canClaim(worker.id, new Date());
273
+ expect(result.allowed).toBe(false);
274
+ if (result.allowed) throw new Error("unreachable");
275
+ expect(result.cause).toBe("agent");
276
+ expect(result.agentSpend).toBe(0.5);
277
+ expect(result.agentBudget).toBe(0.01);
278
+ // Shape parity with the envelope returned by the accept handler.
279
+ expect(result.resetAt).toMatch(/^\d{4}-\d{2}-\d{2}T00:00:00\.000Z$/);
280
+ });
281
+
282
+ test("accept allows when no budgets configured", async () => {
283
+ const { canClaim } = await import("../be/budget-admission");
284
+ const worker = createAgent({ name: "accept-ok", isLead: false, status: "idle", maxTasks: 1 });
285
+ const result = canClaim(worker.id, new Date());
286
+ expect(result.allowed).toBe(true);
287
+ });
288
+ });