@desplega.ai/agent-swarm 1.83.0 → 1.83.2

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 (67) hide show
  1. package/openapi.json +177 -10
  2. package/package.json +6 -6
  3. package/src/artifact-sdk/server.ts +23 -1
  4. package/src/be/budget-admission.ts +28 -4
  5. package/src/be/budget-refusal-notify.ts +19 -3
  6. package/src/be/db-queries/oauth.ts +43 -0
  7. package/src/be/db.ts +37 -4
  8. package/src/be/migrations/074_user_budget_scope.sql +85 -0
  9. package/src/be/schedules/validate.ts +21 -0
  10. package/src/be/skill-sync.ts +65 -15
  11. package/src/commands/resume-session.ts +118 -0
  12. package/src/commands/runner.ts +178 -121
  13. package/src/http/core.ts +4 -1
  14. package/src/http/index.ts +16 -0
  15. package/src/http/integrations.ts +26 -0
  16. package/src/http/mcp-user.ts +111 -0
  17. package/src/http/poll.ts +19 -5
  18. package/src/http/schedules.ts +35 -10
  19. package/src/http/skills.ts +27 -2
  20. package/src/http/users.ts +107 -2
  21. package/src/jira/client.ts +3 -5
  22. package/src/jira/oauth.ts +1 -0
  23. package/src/jira/sync.ts +2 -2
  24. package/src/oauth/ensure-token.ts +1 -0
  25. package/src/oauth/wrapper.ts +38 -7
  26. package/src/providers/claude-adapter.ts +7 -2
  27. package/src/providers/claude-managed-adapter.ts +1 -1
  28. package/src/providers/codex-adapter.ts +30 -0
  29. package/src/providers/opencode-adapter.ts +149 -14
  30. package/src/providers/pi-mono-adapter.ts +41 -1
  31. package/src/providers/types.ts +1 -1
  32. package/src/server-user.ts +117 -0
  33. package/src/tests/artifact-sdk.test.ts +23 -19
  34. package/src/tests/budget-user-scope.test.ts +376 -0
  35. package/src/tests/claude-managed-adapter.test.ts +6 -0
  36. package/src/tests/codex-adapter.test.ts +192 -0
  37. package/src/tests/codex-rate-limit-parse.test.ts +256 -0
  38. package/src/tests/db-queries-oauth.test.ts +43 -0
  39. package/src/tests/ensure-token.test.ts +93 -0
  40. package/src/tests/error-tracker.test.ts +52 -0
  41. package/src/tests/fetch-resolved-env.test.ts +33 -20
  42. package/src/tests/http-api-integration.test.ts +36 -0
  43. package/src/tests/http-users.test.ts +29 -1
  44. package/src/tests/mcp-user-route.test.ts +325 -0
  45. package/src/tests/opencode-adapter.test.ts +75 -0
  46. package/src/tests/pi-mono-adapter.test.ts +21 -1
  47. package/src/tests/rate-limit-event.test.ts +69 -6
  48. package/src/tests/resume-session.test.ts +93 -0
  49. package/src/tests/runner-skills-refresh.test.ts +200 -0
  50. package/src/tests/schedule-validation-helper.test.ts +51 -0
  51. package/src/tests/skill-sync.test.ts +73 -9
  52. package/src/tests/skills-signature.test.ts +141 -0
  53. package/src/tests/task-tools-ctx.test.ts +100 -0
  54. package/src/tests/task-tools-ownership.test.ts +167 -0
  55. package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
  56. package/src/tests/user-token-routes.test.ts +221 -0
  57. package/src/tools/cancel-task.ts +137 -83
  58. package/src/tools/get-task-details.ts +73 -59
  59. package/src/tools/get-tasks.ts +134 -126
  60. package/src/tools/schedules/update-schedule.ts +48 -8
  61. package/src/tools/send-task.ts +312 -312
  62. package/src/tools/slack-upload-file.ts +17 -5
  63. package/src/tools/task-action.ts +464 -367
  64. package/src/tools/task-tool-ctx.ts +43 -0
  65. package/src/types.ts +6 -2
  66. package/src/utils/error-tracker.ts +122 -9
  67. package/src/utils/skills-refresh.ts +123 -0
package/src/http/poll.ts CHANGED
@@ -42,11 +42,13 @@ import { json, jsonError } from "./utils";
42
42
  * + workflow bus emit. See `src/be/budget-refusal-notify.ts`.
43
43
  */
44
44
  function buildBudgetRefusedTrigger(refusal: {
45
- cause: "agent" | "global";
45
+ cause: "agent" | "global" | "user";
46
46
  agentSpend?: number;
47
47
  agentBudget?: number;
48
48
  globalSpend?: number;
49
49
  globalBudget?: number;
50
+ userSpend?: number;
51
+ userBudget?: number;
50
52
  resetAt: string;
51
53
  }): { type: "budget_refused"; [key: string]: unknown } {
52
54
  const trigger: { type: "budget_refused"; [key: string]: unknown } = {
@@ -58,6 +60,8 @@ function buildBudgetRefusedTrigger(refusal: {
58
60
  if (refusal.agentBudget !== undefined) trigger.agentBudget = refusal.agentBudget;
59
61
  if (refusal.globalSpend !== undefined) trigger.globalSpend = refusal.globalSpend;
60
62
  if (refusal.globalBudget !== undefined) trigger.globalBudget = refusal.globalBudget;
63
+ if (refusal.userSpend !== undefined) trigger.userSpend = refusal.userSpend;
64
+ if (refusal.userBudget !== undefined) trigger.userBudget = refusal.userBudget;
61
65
  return trigger;
62
66
  }
63
67
 
@@ -180,7 +184,7 @@ export async function handlePoll(
180
184
  // the capacity check so capacity AND budget gates share atomicity.
181
185
  // Phase 5 also records the dedup row + captures the side-effect
182
186
  // context here so the after-commit step can notify the lead.
183
- const admission = canClaim(myAgentId, new Date());
187
+ const admission = canClaim(myAgentId, new Date(), pendingTask.requestedByUserId);
184
188
  if (!admission.allowed) {
185
189
  const utcDate = new Date().toISOString().slice(0, 10);
186
190
  const dedup = recordBudgetRefusalNotification({
@@ -192,6 +196,8 @@ export async function handlePoll(
192
196
  agentBudgetUsd: admission.agentBudget,
193
197
  globalSpendUsd: admission.globalSpend,
194
198
  globalBudgetUsd: admission.globalBudget,
199
+ userSpendUsd: admission.userSpend,
200
+ userBudgetUsd: admission.userBudget,
195
201
  });
196
202
  return {
197
203
  trigger: buildBudgetRefusedTrigger(admission),
@@ -200,6 +206,7 @@ export async function handlePoll(
200
206
  task: {
201
207
  id: pendingTask.id,
202
208
  task: pendingTask.task,
209
+ requestedByUserId: pendingTask.requestedByUserId,
203
210
  slackChannelId: pendingTask.slackChannelId,
204
211
  slackThreadTs: pendingTask.slackThreadTs,
205
212
  slackUserId: pendingTask.slackUserId,
@@ -211,6 +218,8 @@ export async function handlePoll(
211
218
  agentBudgetUsd: admission.agentBudget,
212
219
  globalSpendUsd: admission.globalSpend,
213
220
  globalBudgetUsd: admission.globalBudget,
221
+ userSpendUsd: admission.userSpend,
222
+ userBudgetUsd: admission.userBudget,
214
223
  resetAt: admission.resetAt,
215
224
  },
216
225
  inserted: dedup.inserted,
@@ -303,10 +312,10 @@ export async function handlePoll(
303
312
  // refusal, and the dedup is per-(task,date) so subsequent same-day
304
313
  // refusals on the same lead-candidate are suppressed.
305
314
  if (unassignedIds.length > 0) {
306
- const admission = canClaim(myAgentId, new Date());
315
+ const candidateId = unassignedIds[0]!;
316
+ const candidateTask = getTaskById(candidateId);
317
+ const admission = canClaim(myAgentId, new Date(), candidateTask?.requestedByUserId);
307
318
  if (!admission.allowed) {
308
- const candidateId = unassignedIds[0]!;
309
- const candidateTask = getTaskById(candidateId);
310
319
  const utcDate = new Date().toISOString().slice(0, 10);
311
320
  const dedup = recordBudgetRefusalNotification({
312
321
  taskId: candidateId,
@@ -317,6 +326,8 @@ export async function handlePoll(
317
326
  agentBudgetUsd: admission.agentBudget,
318
327
  globalSpendUsd: admission.globalSpend,
319
328
  globalBudgetUsd: admission.globalBudget,
329
+ userSpendUsd: admission.userSpend,
330
+ userBudgetUsd: admission.userBudget,
320
331
  });
321
332
  return {
322
333
  trigger: buildBudgetRefusedTrigger(admission),
@@ -326,6 +337,7 @@ export async function handlePoll(
326
337
  task: {
327
338
  id: candidateTask.id,
328
339
  task: candidateTask.task,
340
+ requestedByUserId: candidateTask.requestedByUserId,
329
341
  slackChannelId: candidateTask.slackChannelId,
330
342
  slackThreadTs: candidateTask.slackThreadTs,
331
343
  slackUserId: candidateTask.slackUserId,
@@ -337,6 +349,8 @@ export async function handlePoll(
337
349
  agentBudgetUsd: admission.agentBudget,
338
350
  globalSpendUsd: admission.globalSpend,
339
351
  globalBudgetUsd: admission.globalBudget,
352
+ userSpendUsd: admission.userSpend,
353
+ userBudgetUsd: admission.userBudget,
340
354
  resetAt: admission.resetAt,
341
355
  },
342
356
  inserted: dedup.inserted,
@@ -11,6 +11,7 @@ import {
11
11
  getScheduledTasks,
12
12
  updateScheduledTask,
13
13
  } from "../be/db";
14
+ import { mergeScheduleTiming, validateRecurringTiming } from "../be/schedules/validate";
14
15
  import { calculateNextRun } from "../scheduler/scheduler";
15
16
  import { scheduleContextKey } from "../tasks/context-key";
16
17
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
@@ -115,8 +116,8 @@ const updateSchedule = route({
115
116
  body: z.object({
116
117
  name: z.string().optional(),
117
118
  description: z.string().optional(),
118
- cronExpression: z.string().optional(),
119
- intervalMs: z.number().int().optional(),
119
+ cronExpression: z.string().nullable().optional(),
120
+ intervalMs: z.number().int().positive().nullable().optional(),
120
121
  taskTemplate: z.string().optional(),
121
122
  taskType: z.string().optional(),
122
123
  tags: z.array(z.string()).optional(),
@@ -400,6 +401,22 @@ export async function handleSchedules(
400
401
  return true;
401
402
  }
402
403
 
404
+ // Validate merged timing state — catches cases where one side is null in the DB
405
+ // and the patch nulls the other, which the schema-level check cannot see.
406
+ if (existing.scheduleType !== "one_time") {
407
+ const timing = mergeScheduleTiming(
408
+ {
409
+ cronExpression: existing.cronExpression ?? null,
410
+ intervalMs: existing.intervalMs ?? null,
411
+ },
412
+ { cronExpression: parsed.body.cronExpression, intervalMs: parsed.body.intervalMs },
413
+ );
414
+ if (validateRecurringTiming(timing)) {
415
+ jsonError(res, "At least one of intervalMs or cronExpression must be set", 400);
416
+ return true;
417
+ }
418
+ }
419
+
403
420
  if (parsed.body.cronExpression) {
404
421
  try {
405
422
  CronExpressionParser.parse(parsed.body.cronExpression);
@@ -439,14 +456,22 @@ export async function handleSchedules(
439
456
  parsed.body.intervalMs !== undefined ||
440
457
  (parsed.body.enabled === true && !existing.enabled)
441
458
  ) {
442
- const merged = {
443
- cronExpression: parsed.body.cronExpression ?? existing.cronExpression,
444
- intervalMs: parsed.body.intervalMs ?? existing.intervalMs,
445
- timezone: parsed.body.timezone ?? existing.timezone,
446
- };
447
- if (merged.cronExpression || merged.intervalMs) {
448
- // biome-ignore lint/suspicious/noExplicitAny: need partial ScheduledTask for calculateNextRun
449
- body.nextRunAt = calculateNextRun(merged as any);
459
+ const timing = mergeScheduleTiming(
460
+ {
461
+ cronExpression: existing.cronExpression ?? null,
462
+ intervalMs: existing.intervalMs ?? null,
463
+ },
464
+ { cronExpression: parsed.body.cronExpression, intervalMs: parsed.body.intervalMs },
465
+ );
466
+ const mergedTimezone =
467
+ parsed.body.timezone !== undefined ? parsed.body.timezone : existing.timezone;
468
+ if (timing.mergedCron || timing.mergedInterval) {
469
+ body.nextRunAt = calculateNextRun({
470
+ cronExpression: timing.mergedCron,
471
+ intervalMs: timing.mergedInterval,
472
+ timezone: mergedTimezone,
473
+ // biome-ignore lint/suspicious/noExplicitAny: need partial ScheduledTask for calculateNextRun
474
+ } as any);
450
475
  }
451
476
  }
452
477
  }
@@ -12,7 +12,7 @@ import {
12
12
  updateSkill,
13
13
  } from "../be/db";
14
14
  import { parseSkillContent } from "../be/skill-parser";
15
- import { syncSkillsToFilesystem } from "../be/skill-sync";
15
+ import { computeAgentSkillsSignature, syncSkillsToFilesystem } from "../be/skill-sync";
16
16
  import { route } from "./route-def";
17
17
  import { json, jsonError } from "./utils";
18
18
 
@@ -193,6 +193,21 @@ const getAgentSkillsRoute = route({
193
193
  },
194
194
  });
195
195
 
196
+ const getAgentSkillsSignatureRoute = route({
197
+ method: "get",
198
+ path: "/api/agents/{id}/skills/signature",
199
+ pattern: ["api", "agents", null, "skills", "signature"],
200
+ summary: "Compute a stable signature over an agent's installed skills",
201
+ description:
202
+ "Returns a sha256 hash over per-row mutation fields of the agent's active+enabled skill set. Workers poll this to detect skill changes cheaply without fetching the full list.",
203
+ tags: ["Skills"],
204
+ auth: { apiKey: true },
205
+ params: z.object({ id: z.string() }),
206
+ responses: {
207
+ 200: { description: "Skills signature" },
208
+ },
209
+ });
210
+
196
211
  // ─── Handler ─────────────────────────────────────────────────────────────────
197
212
 
198
213
  export async function handleSkills(
@@ -202,12 +217,22 @@ export async function handleSkills(
202
217
  queryParams: URLSearchParams,
203
218
  myAgentId: string | undefined,
204
219
  ): Promise<boolean> {
220
+ // GET /api/agents/:id/skills/signature (must come before the shorter pattern)
221
+ if (getAgentSkillsSignatureRoute.match(req.method, pathSegments)) {
222
+ const parsed = await getAgentSkillsSignatureRoute.parse(req, res, pathSegments, queryParams);
223
+ if (!parsed) return true;
224
+ const sig = computeAgentSkillsSignature(parsed.params.id);
225
+ json(res, { hash: sig.hash, count: sig.count, generatedAt: new Date().toISOString() });
226
+ return true;
227
+ }
228
+
205
229
  // GET /api/agents/:id/skills (must be before /api/skills routes)
206
230
  if (getAgentSkillsRoute.match(req.method, pathSegments)) {
207
231
  const parsed = await getAgentSkillsRoute.parse(req, res, pathSegments, queryParams);
208
232
  if (!parsed) return true;
209
233
  const skills = getAgentSkills(parsed.params.id);
210
- json(res, { skills, total: skills.length });
234
+ const signature = computeAgentSkillsSignature(parsed.params.id).hash;
235
+ json(res, { skills, total: skills.length, signature });
211
236
  return true;
212
237
  }
213
238
 
package/src/http/users.ts CHANGED
@@ -7,8 +7,7 @@
7
7
  * pass it as the `IdentityActor` arg to the helpers in `src/be/users.ts` — so
8
8
  * every identity mutation lands an event row tagged `op:<sha256-16>`.
9
9
  *
10
- * Endpoint set (Core Req #6, minus `POST/DELETE /users/:id/mcp-tokens` which
11
- * are deferred to the MCP plan):
10
+ * Endpoint set:
12
11
  *
13
12
  * GET /api/users
14
13
  * POST /api/users
@@ -16,6 +15,8 @@
16
15
  * POST /api/users/unmapped/:kind/:externalId/resolve
17
16
  * GET /api/users/:id
18
17
  * PATCH /api/users/:id
18
+ * POST /api/users/:id/mcp-tokens
19
+ * DELETE /api/users/:id/mcp-tokens/:tokenId
19
20
  * POST /api/users/:id/merge
20
21
  * GET /api/users/:id/events
21
22
  * POST /api/users/:id/identities
@@ -26,12 +27,14 @@ import type { IncomingMessage, ServerResponse } from "node:http";
26
27
  import { z } from "zod";
27
28
  import {
28
29
  createUser,
30
+ deleteBudget,
29
31
  deleteKv,
30
32
  deleteUser,
31
33
  getAllUsers,
32
34
  getUserById,
33
35
  listKv,
34
36
  updateUser,
37
+ upsertBudget,
35
38
  } from "../be/db";
36
39
  import {
37
40
  getUserIdentities,
@@ -39,7 +42,9 @@ import {
39
42
  linkIdentity,
40
43
  listUserEvents,
41
44
  listUserTokens,
45
+ mintToken,
42
46
  recordIdentityEvent,
47
+ revokeToken,
43
48
  unlinkIdentity,
44
49
  } from "../be/users";
45
50
  import { getOperatorActor } from "./operator-actor";
@@ -64,6 +69,15 @@ function composeUser(userId: string, recentEventLimit = 5) {
64
69
  };
65
70
  }
66
71
 
72
+ function syncUserBudgetMirror(userId: string, dailyBudgetUsd: number | null | undefined): void {
73
+ if (dailyBudgetUsd === undefined) return;
74
+ if (dailyBudgetUsd === null) {
75
+ deleteBudget("user", userId);
76
+ return;
77
+ }
78
+ upsertBudget("user", userId, dailyBudgetUsd);
79
+ }
80
+
67
81
  // ─── Route Definitions ───────────────────────────────────────────────────────
68
82
 
69
83
  const listUsers = route({
@@ -203,6 +217,41 @@ const updateUserRoute = route({
203
217
  auth: { apiKey: true },
204
218
  });
205
219
 
220
+ const mintUserMcpTokenRoute = route({
221
+ method: "post",
222
+ path: "/api/users/{id}/mcp-tokens",
223
+ pattern: ["api", "users", null, "mcp-tokens"],
224
+ summary: "Mint a one-time plaintext MCP token for a user",
225
+ description:
226
+ "Returns the plaintext token exactly once. Subsequent reads only expose token summaries.",
227
+ tags: ["Users"],
228
+ params: z.object({ id: z.string() }),
229
+ body: z.object({
230
+ label: z.string().nullable().optional(),
231
+ }),
232
+ responses: {
233
+ 200: { description: "Minted token plaintext, token summary and composed user" },
234
+ 401: { description: "Unauthorized" },
235
+ 404: { description: "User not found" },
236
+ },
237
+ auth: { apiKey: true },
238
+ });
239
+
240
+ const revokeUserMcpTokenRoute = route({
241
+ method: "delete",
242
+ path: "/api/users/{id}/mcp-tokens/{tokenId}",
243
+ pattern: ["api", "users", null, "mcp-tokens", null],
244
+ summary: "Revoke a user's MCP token",
245
+ tags: ["Users"],
246
+ params: z.object({ id: z.string(), tokenId: z.string() }),
247
+ responses: {
248
+ 200: { description: "Composed user after token revocation" },
249
+ 401: { description: "Unauthorized" },
250
+ 404: { description: "User or token not found" },
251
+ },
252
+ auth: { apiKey: true },
253
+ });
254
+
206
255
  const mergeUsersRoute = route({
207
256
  method: "post",
208
257
  path: "/api/users/{id}/merge",
@@ -370,6 +419,7 @@ export async function handleUsers(
370
419
  try {
371
420
  const { identities, ...userFields } = parsed.body;
372
421
  const user = createUser(userFields);
422
+ syncUserBudgetMirror(user.id, userFields.dailyBudgetUsd);
373
423
  for (const ident of identities ?? []) {
374
424
  linkIdentity(user.id, ident.kind, ident.externalId, actor);
375
425
  }
@@ -459,6 +509,60 @@ export async function handleUsers(
459
509
  return true;
460
510
  }
461
511
 
512
+ // ─── POST /api/users/:id/mcp-tokens ───────────────────────────────────────
513
+ if (mintUserMcpTokenRoute.match(req.method, pathSegments)) {
514
+ const parsed = await mintUserMcpTokenRoute.parse(req, res, pathSegments, queryParams);
515
+ if (!parsed) return true;
516
+ const actor = getOperatorActor(req, res);
517
+ if (!actor) return true;
518
+ if (!getUserById(parsed.params.id)) {
519
+ jsonError(res, "User not found", 404);
520
+ return true;
521
+ }
522
+
523
+ try {
524
+ const { tokenId, plaintext } = mintToken(parsed.params.id, parsed.body.label ?? null, actor);
525
+ const token = listUserTokens(parsed.params.id).find((t) => t.id === tokenId);
526
+ json(res, { plaintext, token, user: composeUser(parsed.params.id) });
527
+ } catch (err) {
528
+ jsonError(res, err instanceof Error ? err.message : "Failed to mint token", 500);
529
+ }
530
+ return true;
531
+ }
532
+
533
+ // ─── DELETE /api/users/:id/mcp-tokens/:tokenId ────────────────────────────
534
+ if (revokeUserMcpTokenRoute.match(req.method, pathSegments)) {
535
+ const parsed = await revokeUserMcpTokenRoute.parse(req, res, pathSegments, queryParams);
536
+ if (!parsed) return true;
537
+ const actor = getOperatorActor(req, res);
538
+ if (!actor) return true;
539
+ if (!getUserById(parsed.params.id)) {
540
+ jsonError(res, "User not found", 404);
541
+ return true;
542
+ }
543
+
544
+ const tokenBelongsToUser = listUserTokens(parsed.params.id).some(
545
+ (token) => token.id === parsed.params.tokenId,
546
+ );
547
+ if (!tokenBelongsToUser) {
548
+ jsonError(res, "Token not found", 404);
549
+ return true;
550
+ }
551
+
552
+ try {
553
+ revokeToken(parsed.params.tokenId, actor);
554
+ json(res, { user: composeUser(parsed.params.id) });
555
+ } catch (err) {
556
+ const message = err instanceof Error ? err.message : "Failed to revoke token";
557
+ if (message.includes("Token not found")) {
558
+ jsonError(res, "Token not found", 404);
559
+ } else {
560
+ jsonError(res, message, 500);
561
+ }
562
+ }
563
+ return true;
564
+ }
565
+
462
566
  // ─── POST /api/users/:id/identities ────────────────────────────────────────
463
567
  if (addIdentityRoute.match(req.method, pathSegments)) {
464
568
  const parsed = await addIdentityRoute.parse(req, res, pathSegments, queryParams);
@@ -625,6 +729,7 @@ export async function handleUsers(
625
729
  jsonError(res, "User not found", 404);
626
730
  return true;
627
731
  }
732
+ syncUserBudgetMirror(parsed.params.id, parsed.body.dailyBudgetUsd);
628
733
 
629
734
  // Budget event
630
735
  if (
@@ -1,5 +1,5 @@
1
1
  import { getOAuthTokens } from "../be/db-queries/oauth";
2
- import { ensureToken } from "../oauth/ensure-token";
2
+ import { ensureToken, ensureTokenOrThrow } from "../oauth/ensure-token";
3
3
  import { getJiraMetadata } from "./metadata";
4
4
 
5
5
  /**
@@ -36,8 +36,7 @@ export function getJiraCloudId(): string {
36
36
  * - Prepends `https://api.atlassian.com/ex/jira/{cloudId}` to `path`.
37
37
  * - Sets `Authorization: Bearer <token>` and `Accept: application/json`.
38
38
  * - Sets `Content-Type: application/json` when a body is provided.
39
- * - On 401: refreshes the token (forced via `ensureToken("jira", 0)`) and
40
- * retries once.
39
+ * - On 401: forces a token refresh and retries once.
41
40
  * - On 429: respects `Retry-After` (in seconds) with a single retry.
42
41
  *
43
42
  * Returns the raw `Response` — callers handle `response.json()`/`response.text()`
@@ -64,8 +63,7 @@ export async function jiraFetch(path: string, init?: RequestInit): Promise<Respo
64
63
  let response = await send(token);
65
64
 
66
65
  if (response.status === 401) {
67
- // Force refresh — bufferMs=0 means "always refresh if any expiry is set"
68
- await ensureToken("jira", 0);
66
+ await ensureTokenOrThrow("jira", Number.MAX_SAFE_INTEGER);
69
67
  token = await getJiraAccessToken();
70
68
  response = await send(token);
71
69
  }
package/src/jira/oauth.ts CHANGED
@@ -29,6 +29,7 @@ export function getJiraOAuthConfig(): OAuthProviderConfig | null {
29
29
  scopes: app.scopes.split(","),
30
30
  scopeSeparator: " ",
31
31
  extraParams: { audience: "api.atlassian.com" },
32
+ requiresRefreshTokenRotation: true,
32
33
  };
33
34
  }
34
35
 
package/src/jira/sync.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  getTrackerSyncByExternalId,
22
22
  updateTrackerSyncSwarmId,
23
23
  } from "../be/db-queries/tracker";
24
- import { ensureToken } from "../oauth/ensure-token";
24
+ import { ensureToken, ensureTokenOrThrow } from "../oauth/ensure-token";
25
25
  import { resolveTemplate } from "../prompts/resolver";
26
26
  import { buildJiraContextKey } from "../tasks/context-key";
27
27
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
@@ -91,7 +91,7 @@ export async function resolveBotAccountId(): Promise<string | null> {
91
91
  // Mirror jiraFetch's 401-retry pattern: a token may go stale between the
92
92
  // proactive ensureToken call and the request reaching Atlassian.
93
93
  if (res.status === 401) {
94
- await ensureToken("jira", 0);
94
+ await ensureTokenOrThrow("jira", Number.MAX_SAFE_INTEGER);
95
95
  tokens = getOAuthTokens("jira");
96
96
  if (!tokens?.accessToken) {
97
97
  console.warn("[Jira Sync] /me returned 401 and refresh produced no token");
@@ -18,6 +18,7 @@ function getOAuthConfig(provider: string): OAuthProviderConfig | null {
18
18
  redirectUri: app.redirectUri,
19
19
  scopes: app.scopes.split(","),
20
20
  extraParams: metadata.extraParams ?? (metadata.actor ? { actor: metadata.actor } : undefined),
21
+ requiresRefreshTokenRotation: provider === "jira",
21
22
  };
22
23
  }
23
24
 
@@ -1,5 +1,5 @@
1
1
  import * as oauth from "oauth4webapi";
2
- import { storeOAuthTokens } from "../be/db-queries/oauth";
2
+ import { storeOAuthTokens, updateOAuthTokensAfterRefresh } from "../be/db-queries/oauth";
3
3
 
4
4
  // ─── Types ───────────────────────────────────────────────────────────────────
5
5
 
@@ -13,6 +13,12 @@ export interface OAuthProviderConfig {
13
13
  scopes: string[];
14
14
  /** Extra query params appended to the authorization URL (e.g. { actor: "app" } for Linear) */
15
15
  extraParams?: Record<string, string>;
16
+ /**
17
+ * Provider rotates refresh tokens on every refresh. When true, a refresh
18
+ * response without a new refresh token is unusable because the old one may
19
+ * already be invalidated server-side.
20
+ */
21
+ requiresRefreshTokenRotation?: boolean;
16
22
  /**
17
23
  * How to join `scopes` in the authorization URL.
18
24
  *
@@ -160,7 +166,7 @@ export async function exchangeCode(
160
166
  export async function refreshAccessToken(
161
167
  config: OAuthProviderConfig,
162
168
  refreshToken: string,
163
- ): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> {
169
+ ): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number; scope?: string }> {
164
170
  const body = new URLSearchParams({
165
171
  grant_type: "refresh_token",
166
172
  client_id: config.clientId,
@@ -183,23 +189,48 @@ export async function refreshAccessToken(
183
189
  access_token: string;
184
190
  token_type: string;
185
191
  expires_in?: number;
192
+ scope?: string;
186
193
  refresh_token?: string;
187
194
  };
188
195
 
196
+ if (typeof data.access_token !== "string" || data.access_token.length === 0) {
197
+ throw new Error(`Token refresh failed: ${config.provider} response missing access_token`);
198
+ }
199
+
200
+ if (
201
+ config.requiresRefreshTokenRotation &&
202
+ (typeof data.refresh_token !== "string" || data.refresh_token.length === 0)
203
+ ) {
204
+ throw new Error(
205
+ `Token refresh failed: ${config.provider} response did not include a rotated refresh_token`,
206
+ );
207
+ }
208
+
189
209
  const expiresAt = data.expires_in
190
210
  ? new Date(Date.now() + data.expires_in * 1000).toISOString()
191
211
  : new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
192
212
 
193
- storeOAuthTokens(config.provider, {
194
- accessToken: data.access_token,
195
- refreshToken: data.refresh_token ?? null,
196
- expiresAt,
197
- });
213
+ const nextRefreshToken = data.refresh_token ?? refreshToken;
214
+ try {
215
+ updateOAuthTokensAfterRefresh(config.provider, refreshToken, {
216
+ accessToken: data.access_token,
217
+ refreshToken: nextRefreshToken,
218
+ expiresAt,
219
+ scope: data.scope ?? null,
220
+ });
221
+ } catch (err) {
222
+ const message = err instanceof Error ? err.message : String(err);
223
+ console.warn(
224
+ `[OAuth] Refusing to use refreshed ${config.provider} access token because persistence failed: ${message}`,
225
+ );
226
+ throw err;
227
+ }
198
228
 
199
229
  return {
200
230
  accessToken: data.access_token,
201
231
  refreshToken: data.refresh_token,
202
232
  expiresIn: data.expires_in,
233
+ scope: data.scope,
203
234
  };
204
235
  }
205
236
 
@@ -395,6 +395,10 @@ class ClaudeSession implements ProviderSession {
395
395
  this.config.prompt,
396
396
  ];
397
397
 
398
+ if (this.config.resumeSessionId) {
399
+ cmd.push("--resume", this.config.resumeSessionId);
400
+ }
401
+
398
402
  if (this.config.additionalArgs?.length) {
399
403
  cmd.push(...this.config.additionalArgs);
400
404
  }
@@ -688,10 +692,11 @@ class ClaudeSession implements ProviderSession {
688
692
  // Stale session retry: if process failed because session not found and we used --resume,
689
693
  // strip --resume and retry with a fresh session
690
694
  if (result.exitCode !== 0 && this.errorTracker.isSessionNotFound()) {
691
- const hasResume = (this.config.additionalArgs || []).includes("--resume");
695
+ const hasResume =
696
+ !!this.config.resumeSessionId || (this.config.additionalArgs || []).includes("--resume");
692
697
  if (hasResume) {
693
698
  console.log(
694
- `\x1b[33m[${this.config.role}] Session not found for task ${this.config.taskId.slice(0, 8)} — retrying without --resume\x1b[0m`,
699
+ `\x1b[33m[${this.config.role}] Session resume failed for task ${this.config.taskId.slice(0, 8)} — retrying without --resume\x1b[0m`,
695
700
  );
696
701
 
697
702
  const freshArgs = (this.config.additionalArgs || []).filter((arg, idx, arr) => {
@@ -642,7 +642,7 @@ class ClaudeManagedSession implements ProviderSession {
642
642
  this.emit({
643
643
  type: "session_init",
644
644
  sessionId: this._sessionId,
645
- provider: "claude" as const,
645
+ provider: "claude-managed",
646
646
  providerMeta: { managed: true },
647
647
  });
648
648
 
@@ -71,6 +71,7 @@ import {
71
71
  clampContextPercent,
72
72
  computeContextUsedUnified,
73
73
  } from "../utils/context-window";
74
+ import { SessionErrorTracker } from "../utils/error-tracker";
74
75
  import { summarizeSession as runSummarize } from "../utils/internal-ai";
75
76
  import { scrubSecrets } from "../utils/secret-scrubber";
76
77
  import { type CodexAgentsMdHandle, writeCodexAgentsMd } from "./codex-agents-md";
@@ -413,6 +414,7 @@ class CodexSession implements ProviderSession {
413
414
  private lastUsage: Usage | null = null;
414
415
  private aborted = false;
415
416
  private settled = false;
417
+ private readonly errorTracker = new SessionErrorTracker();
416
418
  /**
417
419
  * Result captured by `settle` but held back from `resolveCompletion` until
418
420
  * `runSession`'s `finally` block has fully cleaned up (log writer flush,
@@ -951,9 +953,11 @@ class CodexSession implements ProviderSession {
951
953
  }
952
954
  if (event.type === "turn.failed" && !terminalError) {
953
955
  terminalError = this.formatTerminalError(event.error.message);
956
+ this.errorTracker.processCodexUsageLimitMessage(event.error.message);
954
957
  }
955
958
  if (event.type === "error" && !terminalError) {
956
959
  terminalError = this.formatTerminalError(event.message);
960
+ this.errorTracker.processCodexUsageLimitMessage(event.message);
957
961
  }
958
962
  }
959
963
  } catch (err) {
@@ -970,6 +974,30 @@ class CodexSession implements ProviderSession {
970
974
  });
971
975
  return;
972
976
  }
977
+ // The Codex CLI exits with code 1 after emitting a UsageLimitReached or
978
+ // other terminal error event. The SDK then throws "Codex Exec exited with
979
+ // code 1: Reading prompt from stdin" AFTER the event loop ends, which
980
+ // would overwrite the structured terminalError we already captured above.
981
+ // Preserve the structured error so the [usage-limit] prefix survives to
982
+ // the runner's rate-limit resolver.
983
+ if (terminalError) {
984
+ const cost = this.buildCostData(this.lastUsage, true);
985
+ this.emit({
986
+ type: "result",
987
+ cost,
988
+ isError: true,
989
+ errorCategory: terminalError.category ?? "turn_failed",
990
+ });
991
+ this.settle({
992
+ exitCode: 1,
993
+ sessionId: this._sessionId,
994
+ cost,
995
+ isError: true,
996
+ failureReason: terminalError.message,
997
+ rateLimitResetAt: this.errorTracker.getRateLimitResetAt(),
998
+ });
999
+ return;
1000
+ }
973
1001
  throw err;
974
1002
  }
975
1003
 
@@ -987,6 +1015,7 @@ class CodexSession implements ProviderSession {
987
1015
  cost,
988
1016
  isError,
989
1017
  failureReason: terminalError?.message,
1018
+ rateLimitResetAt: this.errorTracker.getRateLimitResetAt(),
990
1019
  });
991
1020
  } catch (err) {
992
1021
  const message = err instanceof Error ? err.message : String(err);
@@ -1000,6 +1029,7 @@ class CodexSession implements ProviderSession {
1000
1029
  cost,
1001
1030
  isError: true,
1002
1031
  failureReason: message,
1032
+ rateLimitResetAt: this.errorTracker.getRateLimitResetAt(),
1003
1033
  });
1004
1034
  } finally {
1005
1035
  // Session-end summarization. Pure addition for codex — no behavior to