@desplega.ai/agent-swarm 1.73.0 → 1.73.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.
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.72.1",
5
+ "version": "1.73.2",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.73.0",
3
+ "version": "1.73.2",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -26,11 +26,35 @@ function getOAuthConfig(provider: string): OAuthProviderConfig | null {
26
26
  * If the token is expiring soon, attempt to refresh it.
27
27
  * Call this before any API interaction with an OAuth-protected service.
28
28
  *
29
+ * Reactive variant — never throws. Refresh failures are logged so a single
30
+ * dead-token incident doesn't tear down an unrelated request path. Use
31
+ * {@link ensureTokenOrThrow} from keepalive contexts where you want a dead
32
+ * refresh token to surface as an alert.
33
+ *
29
34
  * @param bufferMs - How far ahead to check for expiry. Default 5 min (reactive use).
30
35
  * Keepalive callers should pass a larger value (e.g. 13h) to force
31
36
  * a proactive refresh well before the token actually expires.
32
37
  */
33
38
  export async function ensureToken(provider: string, bufferMs?: number): Promise<void> {
39
+ try {
40
+ await ensureTokenOrThrow(provider, bufferMs);
41
+ } catch (err) {
42
+ console.error(
43
+ `[OAuth] Failed to refresh ${provider} token:`,
44
+ err instanceof Error ? err.message : err,
45
+ );
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Strict variant of {@link ensureToken}: throws on refresh failure of a
51
+ * configured provider so callers (keepalive, alerting) can react.
52
+ *
53
+ * Stays silent (no throw) when the provider isn't configured or no refresh
54
+ * token is stored — those are "not connected" states, not failures, and
55
+ * shouldn't page anyone.
56
+ */
57
+ export async function ensureTokenOrThrow(provider: string, bufferMs?: number): Promise<void> {
34
58
  if (!isTokenExpiringSoon(provider, bufferMs)) return;
35
59
 
36
60
  const config = getOAuthConfig(provider);
@@ -42,13 +66,6 @@ export async function ensureToken(provider: string, bufferMs?: number): Promise<
42
66
  return;
43
67
  }
44
68
 
45
- try {
46
- await refreshAccessToken(config, tokens.refreshToken);
47
- console.log(`[OAuth] ${provider} token refreshed successfully`);
48
- } catch (err) {
49
- console.error(
50
- `[OAuth] Failed to refresh ${provider} token:`,
51
- err instanceof Error ? err.message : err,
52
- );
53
- }
69
+ await refreshAccessToken(config, tokens.refreshToken);
70
+ console.log(`[OAuth] ${provider} token refreshed successfully`);
54
71
  }
@@ -1,26 +1,36 @@
1
- import { ensureToken } from "./ensure-token";
1
+ import { ensureTokenOrThrow } from "./ensure-token";
2
2
 
3
3
  const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
4
4
  const THIRTEEN_HOURS_MS = 13 * 60 * 60 * 1000;
5
5
  const SLACK_ALERTS_CHANNEL = process.env.SLACK_ALERTS_CHANNEL || "C08JCRURPBV";
6
6
 
7
+ const KEEPALIVE_PROVIDERS = ["linear", "jira"] as const;
8
+
7
9
  let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
8
10
 
9
11
  /**
10
12
  * Proactively refresh OAuth tokens on a schedule to prevent expiry.
11
13
  * If refresh fails, posts a Slack notification so someone can re-auth manually.
14
+ *
15
+ * Why both Linear and Jira: Atlassian rotates refresh tokens and expires them
16
+ * after 90 days of inactivity, so a swarm that doesn't touch Jira for a long
17
+ * stretch will silently lose the ability to refresh. Touching the token every
18
+ * 12h keeps the refresh token alive and surfaces a dead one as an alert
19
+ * instead of a runtime 401.
12
20
  */
13
21
  async function runKeepalive(): Promise<void> {
14
- console.log("[OAuth Keepalive] Running scheduled token refresh for linear...");
15
- try {
16
- await ensureToken("linear", THIRTEEN_HOURS_MS);
17
- console.log("[OAuth Keepalive] linear token check completed successfully");
18
- } catch (err) {
19
- const message = err instanceof Error ? err.message : String(err);
20
- console.error(`[OAuth Keepalive] Failed to refresh linear token: ${message}`);
21
- await notifySlack(
22
- `⚠️ *OAuth Keepalive Failed*\nProvider: \`linear\`\nError: ${message}\n\nManual re-authorization may be required.`,
23
- );
22
+ for (const provider of KEEPALIVE_PROVIDERS) {
23
+ console.log(`[OAuth Keepalive] Running scheduled token refresh for ${provider}...`);
24
+ try {
25
+ await ensureTokenOrThrow(provider, THIRTEEN_HOURS_MS);
26
+ console.log(`[OAuth Keepalive] ${provider} token check completed successfully`);
27
+ } catch (err) {
28
+ const message = err instanceof Error ? err.message : String(err);
29
+ console.error(`[OAuth Keepalive] Failed to refresh ${provider} token: ${message}`);
30
+ await notifySlack(
31
+ `⚠️ *OAuth Keepalive Failed*\nProvider: \`${provider}\`\nError: ${message}\n\nManual re-authorization may be required.`,
32
+ );
33
+ }
24
34
  }
25
35
  }
26
36
 
@@ -349,12 +349,10 @@ function renderTree(root: TreeNode): string {
349
349
  const visibleChildren = root.children.slice(0, MAX_VISIBLE_CHILDREN);
350
350
  const hiddenCount = root.children.length - visibleChildren.length;
351
351
 
352
- for (let i = 0; i < visibleChildren.length; i++) {
353
- const child = visibleChildren[i] as TreeNode;
354
- const isLast = i === visibleChildren.length - 1 && hiddenCount === 0;
355
- const prefix = isLast ? "└ " : "├ ";
356
- const continuationPrefix = isLast ? " " : "│ ";
352
+ const prefix = "↳ ";
353
+ const continuationPrefix = " ";
357
354
 
355
+ for (const child of visibleChildren) {
358
356
  lines.push(`${prefix}${renderNodeLine(child)}`);
359
357
 
360
358
  for (const detail of renderChildDetail(child, continuationPrefix)) {
@@ -363,7 +361,7 @@ function renderTree(root: TreeNode): string {
363
361
  }
364
362
 
365
363
  if (hiddenCount > 0) {
366
- lines.push(`└ _and ${hiddenCount} more..._`);
364
+ lines.push(`↳ _and ${hiddenCount} more..._`);
367
365
  }
368
366
 
369
367
  return lines.join("\n");
@@ -7,7 +7,7 @@ import {
7
7
  storeOAuthTokens,
8
8
  upsertOAuthApp,
9
9
  } from "../be/db-queries/oauth";
10
- import { ensureToken } from "../oauth/ensure-token";
10
+ import { ensureToken, ensureTokenOrThrow } from "../oauth/ensure-token";
11
11
 
12
12
  const TEST_DB_PATH = "./test-ensure-token.sqlite";
13
13
 
@@ -202,3 +202,35 @@ describe("ensureToken", () => {
202
202
  expect(fetchSpy).not.toHaveBeenCalled();
203
203
  });
204
204
  });
205
+
206
+ describe("ensureTokenOrThrow", () => {
207
+ test("throws when refresh fails for a configured provider (so keepalive can alert)", async () => {
208
+ storeOAuthTokens("test-provider", {
209
+ accessToken: "old-token",
210
+ refreshToken: "refresh-token",
211
+ expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
212
+ });
213
+
214
+ globalThis.fetch = mock(() =>
215
+ Promise.resolve(
216
+ new Response('{"error":"invalid_grant"}', {
217
+ status: 400,
218
+ headers: { "Content-Type": "application/json" },
219
+ }),
220
+ ),
221
+ );
222
+
223
+ await expect(ensureTokenOrThrow("test-provider")).rejects.toThrow(/Token refresh failed/);
224
+ });
225
+
226
+ test("stays silent (no throw) when no refresh token is stored", async () => {
227
+ deleteOAuthTokens("test-provider");
228
+
229
+ // "Not connected" should not page anyone
230
+ await expect(ensureTokenOrThrow("test-provider")).resolves.toBeUndefined();
231
+ });
232
+
233
+ test("stays silent (no throw) when provider is not configured", async () => {
234
+ await expect(ensureTokenOrThrow("nonexistent-provider")).resolves.toBeUndefined();
235
+ });
236
+ });
@@ -616,14 +616,14 @@ describe("buildTreeBlocks", () => {
616
616
 
617
617
  // Root line
618
618
  expect(lines[0]).toContain("⏳ *Lead*");
619
- // Worker1 line with prefix
620
- expect(lines[1]).toMatch(/^├ ⏳ \*Worker1\*/);
621
- // Worker1 progress indented under continuation
622
- expect(lines[2]).toMatch(/^│ {3}Fetching data\.\.\.$/);
623
- // Worker2 line with prefix (last child)
624
- expect(lines[3]).toMatch(/^└ ⏳ \*Worker2\*/);
619
+ // Worker1 line with prefix
620
+ expect(lines[1]).toMatch(/^↳ ⏳ \*Worker1\*/);
621
+ // Worker1 progress indented under continuation (3 spaces, aligned under ↳ )
622
+ expect(lines[2]).toMatch(/^ {3}Fetching data\.\.\.$/);
623
+ // Worker2 line with prefix
624
+ expect(lines[3]).toMatch(/^↳ ⏳ \*Worker2\*/);
625
625
  // Worker2 progress indented
626
- expect(lines[4]).toMatch(/^ {4}Compiling\.\.\.$/);
626
+ expect(lines[4]).toMatch(/^ {3}Compiling\.\.\.$/);
627
627
  });
628
628
 
629
629
  test("max children collapse (9+ children -> 8 shown + 'and 1 more...')", () => {
@@ -654,8 +654,8 @@ describe("buildTreeBlocks", () => {
654
654
  expect(text).toContain("*Worker8*");
655
655
  expect(text).not.toContain("*Worker9*");
656
656
  expect(text).toContain("and 1 more...");
657
- // The "and N more..." line uses prefix
658
- expect(lines[lines.length - 1]).toContain(" _and 1 more..._");
657
+ // The "and N more..." line uses prefix
658
+ expect(lines[lines.length - 1]).toContain(" _and 1 more..._");
659
659
  });
660
660
 
661
661
  test("max children collapse with many hidden", () => {
@@ -837,7 +837,7 @@ describe("buildTreeBlocks", () => {
837
837
  expect(blocks.length).toBe(1);
838
838
  });
839
839
 
840
- test("tree connectors: for non-last, for last child", () => {
840
+ test("tree indent: all children use prefix", () => {
841
841
  const root: TreeNode = {
842
842
  taskId: makeTaskId("nnnn0001"),
843
843
  agentName: "Lead",
@@ -867,10 +867,10 @@ describe("buildTreeBlocks", () => {
867
867
  const blocks = buildTreeBlocks([root]);
868
868
  const lines = blocks[0].text.text.split("\n");
869
869
 
870
- // First two children use ├, last uses
871
- expect(lines[1]).toMatch(/^├ /);
872
- expect(lines[2]).toMatch(/^├ /);
873
- expect(lines[3]).toMatch(/^└ /);
870
+ // All children use (no branching distinction in proportional fonts)
871
+ expect(lines[1]).toMatch(/^↳ /);
872
+ expect(lines[2]).toMatch(/^↳ /);
873
+ expect(lines[3]).toMatch(/^↳ /);
874
874
  });
875
875
 
876
876
  test("completed root with output (no slackReplySent, no children)", () => {