@desplega.ai/agent-swarm 1.92.2 → 1.94.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.
Files changed (122) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +242 -3
  3. package/package.json +5 -5
  4. package/src/be/db.ts +152 -11
  5. package/src/be/memory/boot-reembed.ts +0 -1
  6. package/src/be/memory/providers/sqlite-store.ts +42 -25
  7. package/src/be/memory/raters/llm-client.ts +12 -5
  8. package/src/be/memory/types.ts +3 -0
  9. package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
  10. package/src/be/migrations/089_harness_variant.sql +2 -0
  11. package/src/be/migrations/090_model_tiers.sql +2 -0
  12. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  13. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  14. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  15. package/src/be/migrations/runner.ts +52 -0
  16. package/src/be/modelsdev-cache.json +3264 -1166
  17. package/src/be/scripts/boot-reembed.ts +74 -0
  18. package/src/be/scripts/db.ts +19 -3
  19. package/src/be/seed/index.ts +1 -1
  20. package/src/be/seed/registry.ts +2 -2
  21. package/src/be/seed/runner.ts +5 -5
  22. package/src/be/seed/types.ts +6 -1
  23. package/src/be/seed-pricing.ts +2 -0
  24. package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
  25. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
  26. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
  27. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
  28. package/src/be/seed-scripts/index.ts +8 -7
  29. package/src/be/skill-sync.ts +28 -179
  30. package/src/commands/runner.ts +197 -10
  31. package/src/http/api-keys.ts +42 -0
  32. package/src/http/index.ts +13 -2
  33. package/src/http/mcp-bridge.ts +1 -1
  34. package/src/http/memory.ts +23 -24
  35. package/src/http/metrics.ts +55 -6
  36. package/src/http/schedules.ts +16 -15
  37. package/src/http/script-runs.ts +7 -1
  38. package/src/http/scripts.ts +147 -1
  39. package/src/http/tasks.ts +17 -6
  40. package/src/model-tiers.ts +140 -0
  41. package/src/providers/claude-adapter.ts +33 -1
  42. package/src/providers/claude-managed-adapter.ts +3 -0
  43. package/src/providers/claude-managed-models.ts +16 -0
  44. package/src/providers/codex-adapter.ts +8 -1
  45. package/src/providers/codex-models.ts +1 -0
  46. package/src/providers/codex-oauth/auth-json.ts +1 -0
  47. package/src/providers/harness-version.ts +7 -0
  48. package/src/providers/opencode-adapter.ts +12 -4
  49. package/src/providers/pi-mono-adapter.ts +90 -8
  50. package/src/providers/types.ts +2 -0
  51. package/src/scheduler/scheduler.ts +22 -34
  52. package/src/scripts-runtime/egress-secrets.ts +83 -0
  53. package/src/scripts-runtime/eval-harness.ts +4 -0
  54. package/src/scripts-runtime/executors/types.ts +7 -0
  55. package/src/scripts-runtime/loader.ts +2 -0
  56. package/src/server-user.ts +8 -2
  57. package/src/slack/channel-join.ts +41 -0
  58. package/src/slack/responses.ts +39 -11
  59. package/src/slack/watcher.ts +121 -8
  60. package/src/tests/additive-buffer.test.ts +0 -1
  61. package/src/tests/agents-list-model-display.test.ts +13 -0
  62. package/src/tests/api-key-tracking.test.ts +113 -0
  63. package/src/tests/approval-requests.test.ts +0 -6
  64. package/src/tests/aws-error-classifier.test.ts +148 -0
  65. package/src/tests/claude-managed-adapter.test.ts +12 -0
  66. package/src/tests/claude-managed-setup.test.ts +0 -4
  67. package/src/tests/codex-pool.test.ts +2 -6
  68. package/src/tests/context-window.test.ts +7 -0
  69. package/src/tests/http-api-integration.test.ts +23 -6
  70. package/src/tests/memory-edges.test.ts +0 -2
  71. package/src/tests/memory-rate-endpoint.test.ts +0 -2
  72. package/src/tests/memory-rater-e2e.test.ts +0 -2
  73. package/src/tests/memory-store.test.ts +19 -1
  74. package/src/tests/memory.test.ts +51 -0
  75. package/src/tests/metrics-http.test.ts +137 -3
  76. package/src/tests/migration-046-budgets.test.ts +33 -0
  77. package/src/tests/migration-runner-regressions.test.ts +69 -0
  78. package/src/tests/model-control.test.ts +162 -46
  79. package/src/tests/opencode-adapter.test.ts +9 -0
  80. package/src/tests/pi-mono-adapter.test.ts +319 -0
  81. package/src/tests/providers/pi-cost.test.ts +9 -0
  82. package/src/tests/reload-config.test.ts +33 -17
  83. package/src/tests/runner-fallback-output.test.ts +50 -0
  84. package/src/tests/runner-skills-refresh.test.ts +216 -46
  85. package/src/tests/script-runs-http.test.ts +7 -1
  86. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  87. package/src/tests/scripts-embeddings.test.ts +90 -0
  88. package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
  89. package/src/tests/seed-scripts.test.ts +13 -1
  90. package/src/tests/seed.test.ts +26 -1
  91. package/src/tests/session-attach.test.ts +6 -6
  92. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  93. package/src/tests/skill-fs-writer.test.ts +250 -0
  94. package/src/tests/slack-attachments-block.test.ts +0 -1
  95. package/src/tests/slack-blocks.test.ts +0 -1
  96. package/src/tests/slack-channel-join.test.ts +80 -0
  97. package/src/tests/slack-identity-resolution.test.ts +0 -1
  98. package/src/tests/slack-watcher.test.ts +66 -0
  99. package/src/tests/structured-output.test.ts +0 -2
  100. package/src/tests/use-dismissible-card.test.ts +0 -4
  101. package/src/tests/workflow-agent-task.test.ts +5 -2
  102. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  103. package/src/tools/memory-get.ts +11 -0
  104. package/src/tools/memory-search.ts +18 -0
  105. package/src/tools/schedules/create-schedule.ts +71 -70
  106. package/src/tools/schedules/update-schedule.ts +43 -31
  107. package/src/tools/send-task.ts +16 -5
  108. package/src/tools/slack-post.ts +18 -15
  109. package/src/tools/slack-read.ts +9 -11
  110. package/src/tools/slack-reply.ts +18 -15
  111. package/src/tools/slack-start-thread.ts +17 -14
  112. package/src/tools/task-action.ts +11 -3
  113. package/src/types.ts +40 -0
  114. package/src/utils/aws-error-classifier.ts +97 -0
  115. package/src/utils/context-window.ts +5 -0
  116. package/src/utils/credentials.test.ts +68 -0
  117. package/src/utils/credentials.ts +66 -5
  118. package/src/utils/pretty-print.ts +25 -10
  119. package/src/utils/skill-fs-writer.ts +220 -0
  120. package/src/utils/skills-refresh.ts +123 -40
  121. package/src/workflows/engine.ts +3 -2
  122. package/src/workflows/executors/agent-task.ts +3 -1
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Post-listen backfill: embed scripts that are missing embeddings (e.g. after
3
+ * boot seeding with scriptEmbeddingMode: "skip"). Runs once per boot,
4
+ * async/non-blocking, idempotent, no-op when every non-scratch script already
5
+ * has an embedding row.
6
+ *
7
+ * Mirrors the memory boot-reembed pattern (src/be/memory/boot-reembed.ts).
8
+ */
9
+
10
+ import { getDb } from "@/be/db";
11
+ import type { ScriptScope } from "@/types";
12
+ import { embedScript } from "./embeddings";
13
+
14
+ type ScriptMissingEmbedding = {
15
+ id: string;
16
+ name: string;
17
+ scope: ScriptScope;
18
+ scopeId: string | null;
19
+ source: string;
20
+ description: string;
21
+ intent: string;
22
+ signatureJson: string;
23
+ argsJsonSchema: string | null;
24
+ contentHash: string;
25
+ version: number;
26
+ isScratch: number;
27
+ typeChecked: number;
28
+ fsMode: "none" | "workspace-rw";
29
+ createdByAgentId: string | null;
30
+ createdAt: string;
31
+ updatedAt: string;
32
+ };
33
+
34
+ export async function runBootReembedScripts(): Promise<void> {
35
+ const db = getDb();
36
+
37
+ const missing = db
38
+ .prepare<ScriptMissingEmbedding, []>(
39
+ `SELECT s.* FROM scripts s
40
+ LEFT JOIN script_embeddings e ON e.scriptId = s.id
41
+ WHERE s.isScratch = 0 AND e.scriptId IS NULL`,
42
+ )
43
+ .all();
44
+
45
+ if (missing.length === 0) {
46
+ return;
47
+ }
48
+
49
+ console.log(`[boot-reembed-scripts] starting: ${missing.length} scripts missing embeddings`);
50
+
51
+ let embedded = 0;
52
+ let failed = 0;
53
+
54
+ for (const row of missing) {
55
+ try {
56
+ await embedScript({
57
+ ...row,
58
+ scopeId: row.scopeId ?? null,
59
+ isScratch: row.isScratch === 1,
60
+ typeChecked: row.typeChecked === 1,
61
+ createdByAgentId: row.createdByAgentId ?? null,
62
+ });
63
+ embedded++;
64
+ } catch (err) {
65
+ failed++;
66
+ console.error(
67
+ `[boot-reembed-scripts] failed to embed "${row.name}":`,
68
+ (err as Error).message,
69
+ );
70
+ }
71
+ }
72
+
73
+ console.log(`[boot-reembed-scripts] complete: embedded=${embedded} failed=${failed}`);
74
+ }
@@ -26,6 +26,7 @@ type ScriptWriteArgs = ScriptIdentity & {
26
26
  fsMode?: ScriptFsMode;
27
27
  agentId?: string | null;
28
28
  changeReason?: string | null;
29
+ embeddingMode?: "sync" | "skip";
29
30
  };
30
31
 
31
32
  export type UpsertScriptResult = {
@@ -178,10 +179,11 @@ export function insertScript(args: ScriptWriteArgs): ScriptRecord {
178
179
  * immediately consistent for authored/promoted scripts.
179
180
  */
180
181
  export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertScriptResult> {
182
+ const shouldEmbed = args.embeddingMode !== "skip";
181
183
  const existing = getScript(args);
182
184
  if (!existing) {
183
185
  const script = insertScript(args);
184
- if (!script.isScratch) {
186
+ if (!script.isScratch && shouldEmbed) {
185
187
  await embedScript(script);
186
188
  }
187
189
  return {
@@ -235,7 +237,7 @@ export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertS
235
237
 
236
238
  if (!row) throw new Error("Failed to update script metadata");
237
239
  const script = rowToScript(row);
238
- if (!script.isScratch && (trackedMetadataChanged || promotedFromScratch)) {
240
+ if (!script.isScratch && shouldEmbed && (trackedMetadataChanged || promotedFromScratch)) {
239
241
  await embedScript(script);
240
242
  }
241
243
  return {
@@ -318,7 +320,7 @@ export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertS
318
320
  });
319
321
 
320
322
  const script = txn();
321
- if (!script.isScratch) {
323
+ if (!script.isScratch && shouldEmbed) {
322
324
  await embedScript(script);
323
325
  }
324
326
 
@@ -347,6 +349,11 @@ export function getScript(args: ScriptIdentity): ScriptRecord | null {
347
349
  return row ? rowToScript(row) : null;
348
350
  }
349
351
 
352
+ export function getScriptById(id: string): ScriptRecord | null {
353
+ const row = getDb().prepare<ScriptRow, [string]>("SELECT * FROM scripts WHERE id = ?").get(id);
354
+ return row ? rowToScript(row) : null;
355
+ }
356
+
350
357
  export function getScriptVersion(args: {
351
358
  scriptId: string;
352
359
  version?: number;
@@ -408,6 +415,15 @@ export function listScripts(args?: {
408
415
  .map(rowToScript);
409
416
  }
410
417
 
418
+ export function listScriptVersions(scriptId: string): ScriptVersionRecord[] {
419
+ return getDb()
420
+ .prepare<ScriptVersionRow, [string]>(
421
+ "SELECT * FROM script_versions WHERE scriptId = ? ORDER BY version DESC",
422
+ )
423
+ .all(scriptId)
424
+ .map(rowToScriptVersion);
425
+ }
426
+
411
427
  export function deleteScript(args: ScriptIdentity): boolean {
412
428
  const existing = getScript(args);
413
429
  if (!existing) return false;
@@ -6,4 +6,4 @@
6
6
  export { runAllSeeders, SEEDERS } from "./registry";
7
7
  export { runSeeder, runSeeders } from "./runner";
8
8
  export { getSeedState, recordSeedState } from "./state-db";
9
- export type { SeedAction, Seeder, SeederResult, SeedItem } from "./types";
9
+ export type { SeedAction, Seeder, SeederResult, SeederRunOptions, SeedItem } from "./types";
@@ -9,11 +9,11 @@
9
9
  import { scriptsSeeder } from "../seed-scripts";
10
10
  import { skillsSeeder } from "../seed-skills";
11
11
  import { runSeeders } from "./runner";
12
- import type { Seeder, SeederResult } from "./types";
12
+ import type { Seeder, SeederResult, SeederRunOptions } from "./types";
13
13
 
14
14
  export const SEEDERS: Seeder[] = [scriptsSeeder, skillsSeeder];
15
15
 
16
16
  /** Apply every registered seeder. Called at API boot and by the seed CLI. */
17
- export function runAllSeeders(opts?: { quiet?: boolean }): Promise<SeederResult[]> {
17
+ export function runAllSeeders(opts?: SeederRunOptions): Promise<SeederResult[]> {
18
18
  return runSeeders(SEEDERS, opts);
19
19
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { getSeedState, recordSeedState } from "./state-db";
8
- import type { Seeder, SeederResult } from "./types";
8
+ import type { Seeder, SeederResult, SeederRunOptions } from "./types";
9
9
 
10
10
  /**
11
11
  * Apply one seeder. Idempotent and version-aware:
@@ -14,7 +14,7 @@ import type { Seeder, SeederResult } from "./types";
14
14
  * - upstream pristine, src same -> no-op
15
15
  * - upstream user-modified -> preserve (never overwrite)
16
16
  */
17
- export async function runSeeder(seeder: Seeder, opts?: { quiet?: boolean }): Promise<SeederResult> {
17
+ export async function runSeeder(seeder: Seeder, opts?: SeederRunOptions): Promise<SeederResult> {
18
18
  const result: SeederResult = {
19
19
  kind: seeder.kind,
20
20
  created: 0,
@@ -31,7 +31,7 @@ export async function runSeeder(seeder: Seeder, opts?: { quiet?: boolean }): Pro
31
31
 
32
32
  // Absent upstream -> create.
33
33
  if (upstream === null) {
34
- await seeder.apply(item, "create");
34
+ await seeder.apply(item, "create", opts);
35
35
  recordSeedState(seeder.kind, item.key, item.contentHash);
36
36
  result.created += 1;
37
37
  continue;
@@ -60,7 +60,7 @@ export async function runSeeder(seeder: Seeder, opts?: { quiet?: boolean }): Pro
60
60
  }
61
61
 
62
62
  // Pristine upstream + changed source -> update to the new source version.
63
- await seeder.apply(item, "update");
63
+ await seeder.apply(item, "update", opts);
64
64
  recordSeedState(seeder.kind, item.key, item.contentHash);
65
65
  result.updated += 1;
66
66
  } catch (err) {
@@ -88,7 +88,7 @@ export async function runSeeder(seeder: Seeder, opts?: { quiet?: boolean }): Pro
88
88
  /** Apply a list of seeders in order. */
89
89
  export async function runSeeders(
90
90
  seeders: Seeder[],
91
- opts?: { quiet?: boolean },
91
+ opts?: SeederRunOptions,
92
92
  ): Promise<SeederResult[]> {
93
93
  const results: SeederResult[] = [];
94
94
  for (const seeder of seeders) {
@@ -35,6 +35,11 @@ export interface SeedItem {
35
35
  readonly contentHash: string;
36
36
  }
37
37
 
38
+ export type SeederRunOptions = {
39
+ quiet?: boolean;
40
+ scriptEmbeddingMode?: "sync" | "skip";
41
+ };
42
+
38
43
  export interface Seeder<TItem extends SeedItem = SeedItem> {
39
44
  /** Kind discriminator — namespaces this seeder's rows in `seed_state`. */
40
45
  readonly kind: string;
@@ -46,7 +51,7 @@ export interface Seeder<TItem extends SeedItem = SeedItem> {
46
51
  */
47
52
  upstreamHash(item: TItem): string | null | Promise<string | null>;
48
53
  /** Create or update the upstream entity so it matches the source definition. */
49
- apply(item: TItem, action: "create" | "update"): void | Promise<void>;
54
+ apply(item: TItem, action: "create" | "update", opts?: SeederRunOptions): void | Promise<void>;
50
55
  }
51
56
 
52
57
  export type SeederResult = {
@@ -67,6 +67,8 @@ const MANUAL_PRICING_OVERRIDES: Array<{
67
67
  * fields the models.dev snapshot doesn't index directly; we map them here.
68
68
  */
69
69
  const ANTHROPIC_SHORTNAME_TO_MODELSDEV: Record<string, string> = {
70
+ fable: "claude-fable-5",
71
+ mythos: "claude-mythos-5",
70
72
  opus: "claude-opus-4-8",
71
73
  sonnet: "claude-sonnet-4-6",
72
74
  haiku: "claude-haiku-4-5",
@@ -0,0 +1,221 @@
1
+ import { z } from "zod";
2
+
3
+ export const argsSchema = z.object({
4
+ nowIso: z.string().optional().describe("Triage clock override (default: current time)"),
5
+ failureLookbackMinutes: z
6
+ .number()
7
+ .int()
8
+ .positive()
9
+ .optional()
10
+ .describe("Look back this many minutes for real failures (default 60)"),
11
+ stuckMinutes: z
12
+ .number()
13
+ .int()
14
+ .positive()
15
+ .optional()
16
+ .describe("Flag in-progress tasks older than this on offline agents (default 5)"),
17
+ deployWindowMinutes: z
18
+ .number()
19
+ .int()
20
+ .positive()
21
+ .optional()
22
+ .describe("Window around now for merged agent-swarm PRs (default 15)"),
23
+ repo: z
24
+ .string()
25
+ .optional()
26
+ .describe("Optional GitHub repository in 'owner/name' form for restart/deploy PR detection"),
27
+ });
28
+
29
+ const BENIGN_FAILURE_RE = /^(superseded_workflow_task|cancelled|reboot-sweep)$/i;
30
+
31
+ function rowsToObjects(res: any): any[] {
32
+ const p = res?.data ?? res;
33
+ const cols: string[] = p?.columns ?? [];
34
+ return (p?.rows ?? []).map((r: any) =>
35
+ Array.isArray(r) ? Object.fromEntries(cols.map((c, i) => [c, r[i]])) : r,
36
+ );
37
+ }
38
+
39
+ async function query(ctx: any, sql: string, params?: unknown[]): Promise<any[]> {
40
+ try {
41
+ return rowsToObjects(await ctx.swarm.db_query({ sql, params }));
42
+ } catch (error) {
43
+ return [{ unavailable: error instanceof Error ? error.message : String(error) }];
44
+ }
45
+ }
46
+
47
+ function taskPreview(task: unknown): string {
48
+ return String(task || "").replace(/\s+/g, " ").trim().slice(0, 180);
49
+ }
50
+
51
+ function summarizeTask(row: any): any {
52
+ return {
53
+ id: row.id,
54
+ status: row.status,
55
+ taskType: row.taskType || null,
56
+ agentId: row.agentId || null,
57
+ agentName: row.agentName || null,
58
+ scheduleId: row.scheduleId || null,
59
+ parentTaskId: row.parentTaskId || null,
60
+ failureReason: row.failureReason || null,
61
+ createdAt: row.createdAt,
62
+ lastUpdatedAt: row.lastUpdatedAt,
63
+ taskPreview: taskPreview(row.task),
64
+ };
65
+ }
66
+
67
+ async function recentMergedPrs(
68
+ ctx: any,
69
+ repo: string | undefined,
70
+ nowMs: number,
71
+ windowMinutes: number,
72
+ ): Promise<any> {
73
+ if (!repo) return { skipped: "repo not provided" };
74
+ if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) return { error: "repo must be in 'owner/name' form" };
75
+
76
+ const windowMs = windowMinutes * 60 * 1000;
77
+ try {
78
+ const response = await ctx.stdlib.fetch(
79
+ "https://api.github.com/repos/" +
80
+ repo +
81
+ "/pulls?state=closed&sort=updated&direction=desc&per_page=20",
82
+ {
83
+ headers: {
84
+ Accept: "application/vnd.github+json",
85
+ "User-Agent": "agent-swarm-boot-triage",
86
+ },
87
+ },
88
+ );
89
+ if (!response.ok) return { error: `GitHub API ${response.status}` };
90
+ const prs = (await response.json()) as any[];
91
+ return prs
92
+ .filter((pr) => pr?.merged_at)
93
+ .map((pr) => {
94
+ const mergedAtMs = Date.parse(pr.merged_at);
95
+ return {
96
+ repo,
97
+ number: pr.number,
98
+ title: pr.title,
99
+ url: pr.html_url,
100
+ mergedAt: pr.merged_at,
101
+ minutesFromRestart: Math.round((mergedAtMs - nowMs) / 60000),
102
+ };
103
+ })
104
+ .filter((pr) => Math.abs(pr.minutesFromRestart * 60000) <= windowMs);
105
+ } catch (error) {
106
+ return { error: error instanceof Error ? error.message : String(error) };
107
+ }
108
+ }
109
+
110
+ /** Read-only post-restart triage snapshot for the heartbeat.boot-triage prompt. */
111
+ export default async function bootTriage(args: any, ctx: any) {
112
+ const parsed = argsSchema.safeParse(args || {});
113
+ if (!parsed.success) return { error: "invalid args: " + parsed.error.message };
114
+
115
+ const now = parsed.data.nowIso ? new Date(parsed.data.nowIso) : new Date();
116
+ const nowMs = now.getTime();
117
+ const failureLookbackMinutes = parsed.data.failureLookbackMinutes || 60;
118
+ const stuckMinutes = parsed.data.stuckMinutes || 5;
119
+ const deployWindowMinutes = parsed.data.deployWindowMinutes || 15;
120
+ const repo = parsed.data.repo;
121
+
122
+ const mergedPrs = await recentMergedPrs(ctx, repo, nowMs, deployWindowMinutes);
123
+
124
+ const recentFailureRows = await query(
125
+ ctx,
126
+ `SELECT t.id, t.task, t.status, t.taskType, t.agentId, a.name as agentName,
127
+ t.scheduleId, t.parentTaskId, t.failureReason, t.createdAt, t.lastUpdatedAt
128
+ FROM agent_tasks t
129
+ LEFT JOIN agents a ON a.id = t.agentId
130
+ WHERE t.status = 'failed'
131
+ AND datetime(t.lastUpdatedAt) >= datetime(?, ?)
132
+ ORDER BY datetime(t.lastUpdatedAt) DESC
133
+ LIMIT 50`,
134
+ [now.toISOString(), `-${failureLookbackMinutes} minutes`],
135
+ );
136
+ const recentlyFailedTasks = recentFailureRows
137
+ .filter((row) => !row.unavailable)
138
+ .filter((row) => !BENIGN_FAILURE_RE.test(String(row.failureReason || "")))
139
+ .map(summarizeTask);
140
+
141
+ const stuckOfflineRows = await query(
142
+ ctx,
143
+ `SELECT t.id, t.task, t.status, t.taskType, t.agentId, a.name as agentName,
144
+ t.scheduleId, t.parentTaskId, t.failureReason, t.createdAt, t.lastUpdatedAt
145
+ FROM agent_tasks t
146
+ JOIN agents a ON a.id = t.agentId
147
+ WHERE t.status = 'in_progress'
148
+ AND a.status = 'offline'
149
+ AND datetime(t.lastUpdatedAt) <= datetime(?, ?)
150
+ ORDER BY datetime(t.lastUpdatedAt) ASC
151
+ LIMIT 50`,
152
+ [now.toISOString(), `-${stuckMinutes} minutes`],
153
+ );
154
+ const stuckInProgressOnOfflineAgents = stuckOfflineRows
155
+ .filter((row) => !row.unavailable)
156
+ .map(summarizeTask);
157
+
158
+ const orphanRows = await query(
159
+ ctx,
160
+ `SELECT t.id, t.task, t.status, t.taskType, t.agentId, a.name as agentName,
161
+ t.scheduleId, t.parentTaskId, t.failureReason, t.createdAt, t.lastUpdatedAt
162
+ FROM agent_tasks t
163
+ JOIN agents a ON a.id = t.agentId
164
+ WHERE t.status IN ('pending', 'offered')
165
+ AND a.status = 'offline'
166
+ ORDER BY datetime(t.lastUpdatedAt) ASC
167
+ LIMIT 50`,
168
+ );
169
+ const orphanedPendingOrOfferedOnOfflineWorkers = orphanRows
170
+ .filter((row) => !row.unavailable)
171
+ .map(summarizeTask);
172
+
173
+ const supersededRows = await query(
174
+ ctx,
175
+ `SELECT p.id, p.task, p.status, p.taskType, p.agentId, a.name as agentName,
176
+ p.scheduleId, p.parentTaskId, p.failureReason, p.createdAt, p.lastUpdatedAt
177
+ FROM agent_tasks p
178
+ LEFT JOIN agents a ON a.id = p.agentId
179
+ WHERE p.status = 'superseded'
180
+ AND datetime(p.lastUpdatedAt) >= datetime(?, ?)
181
+ AND NOT EXISTS (
182
+ SELECT 1
183
+ FROM agent_tasks c
184
+ WHERE c.parentTaskId = p.id
185
+ AND c.taskType = 'resume'
186
+ AND c.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
187
+ )
188
+ ORDER BY datetime(p.lastUpdatedAt) DESC
189
+ LIMIT 50`,
190
+ [now.toISOString(), `-${failureLookbackMinutes} minutes`],
191
+ );
192
+ const supersededTasksMissingResumeChild = supersededRows
193
+ .filter((row) => !row.unavailable)
194
+ .map(summarizeTask);
195
+
196
+ return {
197
+ generatedAt: now.toISOString(),
198
+ windows: {
199
+ failureLookbackMinutes,
200
+ stuckMinutes,
201
+ deployWindowMinutes,
202
+ },
203
+ deployRestartDetection: {
204
+ source: repo ? "github:" + repo : null,
205
+ mergedPrsWithinWindow: Array.isArray(mergedPrs) ? mergedPrs : [],
206
+ skipped: Array.isArray(mergedPrs) ? null : mergedPrs.skipped || null,
207
+ error: Array.isArray(mergedPrs) ? null : mergedPrs.error,
208
+ },
209
+ recentlyFailedTasks,
210
+ stuckInProgressOnOfflineAgents,
211
+ orphanedPendingOrOfferedOnOfflineWorkers,
212
+ supersededTasksMissingResumeChild,
213
+ summary: {
214
+ mergedPrsWithinWindow: Array.isArray(mergedPrs) ? mergedPrs.length : 0,
215
+ recentlyFailedTasks: recentlyFailedTasks.length,
216
+ stuckInProgressOnOfflineAgents: stuckInProgressOnOfflineAgents.length,
217
+ orphanedPendingOrOfferedOnOfflineWorkers: orphanedPendingOrOfferedOnOfflineWorkers.length,
218
+ supersededTasksMissingResumeChild: supersededTasksMissingResumeChild.length,
219
+ },
220
+ };
221
+ }