agent-relay-server 0.120.5 → 0.121.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 (124) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-BVff6PSi.js → MessageBubble-BaPGFJxq.js} +2 -2
  4. package/public/assets/{MessageBubble-BVff6PSi.js.map → MessageBubble-BaPGFJxq.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BjrVU5Tg.js → PlanGraphPanel-BXCVfmln.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BjrVU5Tg.js.map → PlanGraphPanel-BXCVfmln.js.map} +1 -1
  7. package/public/assets/{activity-Bk9LEUr4.js → activity-D_shwAZt.js} +2 -2
  8. package/public/assets/{activity-Bk9LEUr4.js.map → activity-D_shwAZt.js.map} +1 -1
  9. package/public/assets/{agent-profiles-DSVDoQ_N.js → agent-profiles-BrfVz5IV.js} +2 -2
  10. package/public/assets/{agent-profiles-DSVDoQ_N.js.map → agent-profiles-BrfVz5IV.js.map} +1 -1
  11. package/public/assets/agents-B5w-Z9Ic.js +2 -0
  12. package/public/assets/{agents-8rQuJqii.js.map → agents-B5w-Z9Ic.js.map} +1 -1
  13. package/public/assets/{analytics-DifGnk7q.js → analytics-Bm3D12UN.js} +2 -2
  14. package/public/assets/{analytics-DifGnk7q.js.map → analytics-Bm3D12UN.js.map} +1 -1
  15. package/public/assets/{automation-CMxQAf3l.js → automation-DOTL8BFs.js} +2 -2
  16. package/public/assets/{automation-CMxQAf3l.js.map → automation-DOTL8BFs.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CyC5Qrhc.js → branch-state-badge-BlqeJZ5-.js} +2 -2
  18. package/public/assets/{branch-state-badge-CyC5Qrhc.js.map → branch-state-badge-BlqeJZ5-.js.map} +1 -1
  19. package/public/assets/{channels-BLkdt2XR.js → channels-BoiKfK6Q.js} +2 -2
  20. package/public/assets/{channels-BLkdt2XR.js.map → channels-BoiKfK6Q.js.map} +1 -1
  21. package/public/assets/chat-PeU3iOaa.js +2 -0
  22. package/public/assets/{chat-jlXywb9G.js.map → chat-PeU3iOaa.js.map} +1 -1
  23. package/public/assets/{connectors-C0n12Je8.js → connectors-BvV8Hee2.js} +2 -2
  24. package/public/assets/{connectors-C0n12Je8.js.map → connectors-BvV8Hee2.js.map} +1 -1
  25. package/public/assets/{formatted-body-CzehtPtx.js → formatted-body-C1FgzlT1.js} +3 -3
  26. package/public/assets/{formatted-body-CzehtPtx.js.map → formatted-body-C1FgzlT1.js.map} +1 -1
  27. package/public/assets/{formatted-body-impl-D3Bnt0Fj.js → formatted-body-impl-BTpU1xH4.js} +2 -2
  28. package/public/assets/{formatted-body-impl-D3Bnt0Fj.js.map → formatted-body-impl-BTpU1xH4.js.map} +1 -1
  29. package/public/assets/index-DZEahmyc.js +25 -0
  30. package/public/assets/index-DZEahmyc.js.map +1 -0
  31. package/public/assets/{insights-3H6KttBi.js → insights-BqmgSpQm.js} +2 -2
  32. package/public/assets/{insights-3H6KttBi.js.map → insights-BqmgSpQm.js.map} +1 -1
  33. package/public/assets/{integrations-v14ehskT.js → integrations-CuT92zQi.js} +2 -2
  34. package/public/assets/{integrations-v14ehskT.js.map → integrations-CuT92zQi.js.map} +1 -1
  35. package/public/assets/{maintenance-BANYcBcn.js → maintenance-lM7FeXVC.js} +2 -2
  36. package/public/assets/{maintenance-BANYcBcn.js.map → maintenance-lM7FeXVC.js.map} +1 -1
  37. package/public/assets/{managed-agents-DCFMWKqt.js → managed-agents-DaPNv3yY.js} +3 -3
  38. package/public/assets/{managed-agents-DCFMWKqt.js.map → managed-agents-DaPNv3yY.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-CfaR-GRX.js → markdown-preview-impl-Wia3Ho1s.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-CfaR-GRX.js.map → markdown-preview-impl-Wia3Ho1s.js.map} +1 -1
  41. package/public/assets/{memory-BmkUEyd5.js → memory-B1v_zD-M.js} +2 -2
  42. package/public/assets/{memory-BmkUEyd5.js.map → memory-B1v_zD-M.js.map} +1 -1
  43. package/public/assets/{messages-BQ_fVnSA.js → messages-BNOx3yK6.js} +2 -2
  44. package/public/assets/{messages-BQ_fVnSA.js.map → messages-BNOx3yK6.js.map} +1 -1
  45. package/public/assets/{orchestrators-DC7y9vQD.js → orchestrators-DWFy4asl.js} +2 -2
  46. package/public/assets/{orchestrators-DC7y9vQD.js.map → orchestrators-DWFy4asl.js.map} +1 -1
  47. package/public/assets/{overview-6lzywFHq.js → overview-C_12cFPM.js} +2 -2
  48. package/public/assets/{overview-6lzywFHq.js.map → overview-C_12cFPM.js.map} +1 -1
  49. package/public/assets/{pairs-CRrRIaHI.js → pairs-DIc7gX83.js} +2 -2
  50. package/public/assets/{pairs-CRrRIaHI.js.map → pairs-DIc7gX83.js.map} +1 -1
  51. package/public/assets/{projects-CZJW35C7.js → projects-DPjvJUzT.js} +2 -2
  52. package/public/assets/{projects-CZJW35C7.js.map → projects-DPjvJUzT.js.map} +1 -1
  53. package/public/assets/{prompt-settings-DvlnMq8H.js → prompt-settings-CtumNgG8.js} +2 -2
  54. package/public/assets/{prompt-settings-DvlnMq8H.js.map → prompt-settings-CtumNgG8.js.map} +1 -1
  55. package/public/assets/{registry-3TWg4zbE.js → registry-DzUhgMq9.js} +2 -2
  56. package/public/assets/{registry-3TWg4zbE.js.map → registry-DzUhgMq9.js.map} +1 -1
  57. package/public/assets/{security-A2VYKHeI.js → security-BJ3_u0uY.js} +2 -2
  58. package/public/assets/{security-A2VYKHeI.js.map → security-BJ3_u0uY.js.map} +1 -1
  59. package/public/assets/{select-_gK5Ojjc.js → select-AWvlctyQ.js} +2 -2
  60. package/public/assets/{select-_gK5Ojjc.js.map → select-AWvlctyQ.js.map} +1 -1
  61. package/public/assets/{settings-BZ26ewcu.js → settings-5ofqi-bN.js} +4 -4
  62. package/public/assets/settings-5ofqi-bN.js.map +1 -0
  63. package/public/assets/store-D5et8685.js +9 -0
  64. package/public/assets/store-D5et8685.js.map +1 -0
  65. package/public/assets/{tasks-DlokSl_P.js → tasks-BEE9bBf7.js} +2 -2
  66. package/public/assets/{tasks-DlokSl_P.js.map → tasks-BEE9bBf7.js.map} +1 -1
  67. package/public/assets/{teams-t7M1HMgS.js → teams-C27H_Gwo.js} +2 -2
  68. package/public/assets/{teams-t7M1HMgS.js.map → teams-C27H_Gwo.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-DxgrO-jH.js → terminal-viewer-impl-WD2TboA7.js} +22 -22
  70. package/public/assets/{terminal-viewer-impl-DxgrO-jH.js.map → terminal-viewer-impl-WD2TboA7.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-V8LR-3Gi.js → work-queue-CtmPK2hK.js} +2 -2
  73. package/public/assets/{work-queue-V8LR-3Gi.js.map → work-queue-CtmPK2hK.js.map} +1 -1
  74. package/public/assets/{workspaces-DI317fMk.js → workspaces-B26EKv_H.js} +2 -2
  75. package/public/assets/{workspaces-DI317fMk.js.map → workspaces-B26EKv_H.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/src/agent-card-projection.ts +39 -9
  79. package/src/bus-outbox.ts +30 -0
  80. package/src/bus.ts +57 -1
  81. package/src/commands-db.ts +14 -0
  82. package/src/config-store.ts +2 -0
  83. package/src/config.ts +4 -0
  84. package/src/db/connection.ts +3 -1
  85. package/src/db/delivery.ts +85 -3
  86. package/src/db/index.ts +1 -0
  87. package/src/db/maintenance-worker.ts +91 -0
  88. package/src/db/messages.ts +19 -4
  89. package/src/db/migrations.ts +7 -0
  90. package/src/db/retention.ts +130 -0
  91. package/src/db/schema.ts +5 -0
  92. package/src/db/stats.ts +32 -8
  93. package/src/db/task-entity.ts +7 -8
  94. package/src/event-loop-lag.ts +90 -0
  95. package/src/lifecycle-manager.ts +11 -12
  96. package/src/maintenance/jobs.ts +23 -7
  97. package/src/maintenance/workspaces.ts +28 -47
  98. package/src/managed-policy-escalation.ts +47 -0
  99. package/src/mcp-task-tools.ts +4 -5
  100. package/src/retention-config-store.ts +32 -0
  101. package/src/retention-config.ts +58 -0
  102. package/src/routes/commands.ts +8 -245
  103. package/src/routes/messages.ts +13 -1
  104. package/src/routes/tasks.ts +3 -5
  105. package/src/routing-policy-store.ts +11 -1
  106. package/src/server.ts +2 -0
  107. package/src/services/issue-lifecycle.ts +23 -8
  108. package/src/services/send-message.ts +63 -21
  109. package/src/services/spawn-registration-watchdog.ts +74 -9
  110. package/src/services/task-entity.ts +54 -2
  111. package/src/settings/registry.ts +11 -0
  112. package/src/sse.ts +54 -9
  113. package/src/workspace-auto-merge.ts +94 -34
  114. package/src/workspace-command-settle.ts +227 -0
  115. package/src/workspace-escalation.ts +45 -0
  116. package/src/workspace-merge-progress.ts +16 -0
  117. package/src/workspace-orphans.ts +91 -3
  118. package/public/assets/agents-8rQuJqii.js +0 -2
  119. package/public/assets/chat-jlXywb9G.js +0 -2
  120. package/public/assets/index-4g3PbGVN.js +0 -25
  121. package/public/assets/index-4g3PbGVN.js.map +0 -1
  122. package/public/assets/settings-BZ26ewcu.js.map +0 -1
  123. package/public/assets/store-nXGxgz2Q.js +0 -9
  124. package/public/assets/store-nXGxgz2Q.js.map +0 -1
@@ -0,0 +1,130 @@
1
+ import { getDb, getDbPath, runDbMaintenanceWorker } from "./connection.ts";
2
+
3
+ interface RetentionPruneInput {
4
+ messageCutoff: number;
5
+ activityCutoff: number;
6
+ activityMaxRows: number;
7
+ spawnCommandCutoff: number;
8
+ batchSize: number;
9
+ }
10
+
11
+ interface RetentionPruneResult {
12
+ prunedMessages: number;
13
+ prunedActivityEvents: number;
14
+ prunedSpawnCommands: number;
15
+ offThread: boolean;
16
+ }
17
+
18
+ function boundedBatchSize(batchSize: number): number {
19
+ return Math.min(Math.max(Math.trunc(batchSize), 1), 50_000);
20
+ }
21
+
22
+ function pruneMessagesBefore(cutoff: number, batchSize: number): number {
23
+ return getDb().transaction(() => {
24
+ getDb().query(`
25
+ UPDATE messages
26
+ SET reply_to = NULL
27
+ WHERE reply_to IN (
28
+ SELECT id FROM messages
29
+ WHERE created_at < ?
30
+ ORDER BY created_at ASC, id ASC
31
+ LIMIT ?
32
+ )
33
+ `).run(cutoff, batchSize);
34
+ return getDb().query(`
35
+ DELETE FROM messages
36
+ WHERE id IN (
37
+ SELECT id FROM messages
38
+ WHERE created_at < ?
39
+ ORDER BY created_at ASC, id ASC
40
+ LIMIT ?
41
+ )
42
+ `).run(cutoff, batchSize).changes;
43
+ })();
44
+ }
45
+
46
+ function pruneActivityEvents(input: RetentionPruneInput): number {
47
+ return getDb().query(`
48
+ DELETE FROM activity_events
49
+ WHERE id IN (
50
+ SELECT ae.id
51
+ FROM activity_events ae
52
+ WHERE (
53
+ ae.created_at < ?
54
+ OR ae.id NOT IN (
55
+ SELECT id
56
+ FROM activity_events
57
+ ORDER BY created_at DESC, id DESC
58
+ LIMIT ?
59
+ )
60
+ )
61
+ AND NOT (
62
+ ae.kind = 'state'
63
+ AND ae.title = 'Agent registered'
64
+ AND EXISTS (
65
+ SELECT 1
66
+ FROM commands c
67
+ WHERE c.type = 'agent.spawn'
68
+ AND c.status = 'succeeded'
69
+ AND coalesce(json_extract(c.params, '$.registrationConfirmed'), 0) != 1
70
+ AND (
71
+ c.correlation_id = json_extract(ae.metadata, '$.spawnRequestId')
72
+ OR json_extract(c.params, '$.spawnRequestId') = json_extract(ae.metadata, '$.spawnRequestId')
73
+ )
74
+ )
75
+ )
76
+ ORDER BY ae.created_at ASC, ae.id ASC
77
+ LIMIT ?
78
+ )
79
+ `).run(input.activityCutoff, input.activityMaxRows, input.batchSize).changes;
80
+ }
81
+
82
+ function pruneTerminalSpawnCommands(cutoff: number, batchSize: number): number {
83
+ return getDb().query(`
84
+ DELETE FROM commands
85
+ WHERE id IN (
86
+ SELECT c.id
87
+ FROM commands c
88
+ WHERE c.type = 'agent.spawn'
89
+ AND c.status IN ('succeeded', 'failed', 'canceled', 'timed_out')
90
+ AND c.updated_at < ?
91
+ AND NOT (
92
+ c.status = 'succeeded'
93
+ AND coalesce(json_extract(c.params, '$.registrationConfirmed'), 0) != 1
94
+ )
95
+ AND NOT EXISTS (
96
+ SELECT 1
97
+ FROM managed_agent_state mas
98
+ WHERE mas.spawn_request_id IS NOT NULL
99
+ AND (
100
+ mas.spawn_request_id = c.correlation_id
101
+ OR mas.spawn_request_id = json_extract(c.params, '$.spawnRequestId')
102
+ )
103
+ )
104
+ ORDER BY c.updated_at ASC, c.created_at ASC
105
+ LIMIT ?
106
+ )
107
+ `).run(cutoff, batchSize).changes;
108
+ }
109
+
110
+ export function pruneRetentionBatch(input: RetentionPruneInput): Omit<RetentionPruneResult, "offThread"> {
111
+ const batchSize = boundedBatchSize(input.batchSize);
112
+ return getDb().transaction(() => ({
113
+ prunedMessages: pruneMessagesBefore(input.messageCutoff, batchSize),
114
+ prunedActivityEvents: pruneActivityEvents({ ...input, batchSize }),
115
+ prunedSpawnCommands: pruneTerminalSpawnCommands(input.spawnCommandCutoff, batchSize),
116
+ }))();
117
+ }
118
+
119
+ export async function pruneRetentionBatchAsync(input: RetentionPruneInput, options: { signal?: AbortSignal } = {}): Promise<RetentionPruneResult> {
120
+ const normalized = { ...input, batchSize: boundedBatchSize(input.batchSize) };
121
+ if (!getDbPath() || getDbPath() === ":memory:") {
122
+ return { ...pruneRetentionBatch(normalized), offThread: false };
123
+ }
124
+ const result = await runDbMaintenanceWorker<Omit<RetentionPruneResult, "offThread">>(
125
+ "retention-prune",
126
+ [JSON.stringify(normalized)],
127
+ options.signal,
128
+ );
129
+ return { ...result, offThread: true };
130
+ }
package/src/db/schema.ts CHANGED
@@ -114,6 +114,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
114
114
  last_seen INTEGER NOT NULL,
115
115
  created_at INTEGER NOT NULL
116
116
  );
117
+ CREATE INDEX IF NOT EXISTS idx_agents_spawn_request ON agents(json_extract(meta, '$.spawnRequestId'));
117
118
  CREATE TABLE IF NOT EXISTS notification_subscriptions (
118
119
  id TEXT PRIMARY KEY,
119
120
  agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
@@ -865,6 +866,9 @@ export function initDb(path: string = "agent-relay.db"): Database {
865
866
  CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
866
867
  CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
867
868
  CREATE INDEX IF NOT EXISTS idx_activity_agent ON activity_events(agent_id, created_at); CREATE INDEX IF NOT EXISTS idx_activity_policy_created ON activity_events(json_extract(metadata, '$.policyName'), created_at DESC, id DESC);
869
+ CREATE INDEX IF NOT EXISTS idx_activity_registered_spawn_request
870
+ ON activity_events(json_extract(metadata, '$.spawnRequestId'))
871
+ WHERE kind = 'state' AND title = 'Agent registered';
868
872
  -- issue_ref_id/issue_action indexes are created in applyMigrations() AFTER the ALTER — never inline here (#483: inline index over a not-yet-migrated column crashes initDb on existing DBs).
869
873
 
870
874
  CREATE TABLE IF NOT EXISTS channels (
@@ -942,6 +946,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
942
946
  CREATE INDEX IF NOT EXISTS idx_commands_status ON commands(status);
943
947
  CREATE INDEX IF NOT EXISTS idx_commands_created ON commands(created_at);
944
948
  CREATE INDEX IF NOT EXISTS idx_commands_correlation ON commands(correlation_id);
949
+ CREATE INDEX IF NOT EXISTS idx_commands_spawn_watchdog ON commands(type, status, updated_at);
945
950
 
946
951
  CREATE TABLE IF NOT EXISTS recipe_instances (
947
952
  id TEXT PRIMARY KEY,
package/src/db/stats.ts CHANGED
@@ -16,10 +16,11 @@ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_ME
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { listAgents } from "./agents.ts";
18
18
  import { listChannels } from "./channels.ts";
19
- import { countExpiredUndeliveredMessages } from "./delivery.ts";
19
+ import { summarizeExpiredUndeliveredMessages } from "./delivery.ts";
20
20
  import { messageTaskClosed } from "./claim-sql.ts";
21
21
  import { claimTaskWhere } from "./task-core.ts";
22
22
  import { getDb } from "./connection.ts";
23
+ import { getEventLoopLagSnapshot } from "../event-loop-lag";
23
24
  import type {
24
25
  AgentCard,
25
26
  ActivityEvent,
@@ -408,6 +409,27 @@ export function getHealth(now: number = Date.now()): HealthReport {
408
409
  checks.push({ name: "database", status: "error", detail: e instanceof Error ? e.message : "database check failed" });
409
410
  }
410
411
 
412
+ const eventLoopLag = getEventLoopLagSnapshot();
413
+ const eventLoopLagHealthMaxMs = Math.max(eventLoopLag.maxMs, eventLoopLag.maxSinceLastReadMs);
414
+ const eventLoopLagWarn = eventLoopLag.warnThresholdMs > 0 && eventLoopLag.sampleCount > 0 && eventLoopLagHealthMaxMs >= eventLoopLag.warnThresholdMs;
415
+ checks.push({
416
+ name: "event-loop-lag",
417
+ status: eventLoopLagWarn ? "warn" : "ok",
418
+ detail: eventLoopLagWarn
419
+ ? `event loop lag max ${Math.round(eventLoopLagHealthMaxMs)}ms over ${eventLoopLag.sampleCount}/${eventLoopLag.windowSamples} sample(s)`
420
+ : undefined,
421
+ metrics: {
422
+ currentMs: eventLoopLag.currentMs,
423
+ maxMs: eventLoopLag.maxMs,
424
+ maxSinceLastReadMs: eventLoopLag.maxSinceLastReadMs,
425
+ sampleCount: eventLoopLag.sampleCount,
426
+ windowSamples: eventLoopLag.windowSamples,
427
+ sampleIntervalMs: eventLoopLag.sampleIntervalMs,
428
+ warnThresholdMs: eventLoopLag.warnThresholdMs,
429
+ ...(eventLoopLag.lastSampleAt === undefined ? {} : { lastSampleAt: eventLoopLag.lastSampleAt }),
430
+ },
431
+ });
432
+
411
433
  const staleLiveAgents = (getDb()
412
434
  .query("SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND last_seen <= ? AND id NOT IN ('user', 'system')")
413
435
  .get(now - STALE_TTL_MS) as any).c as number;
@@ -489,14 +511,17 @@ export function getHealth(now: number = Date.now()): HealthReport {
489
511
  : undefined,
490
512
  });
491
513
 
492
- const expiredUndelivered = countExpiredUndeliveredMessages(now);
514
+ const expiredUndelivered = summarizeExpiredUndeliveredMessages(now);
493
515
  checks.push({
494
516
  name: "expired-undelivered-messages",
495
- status: expiredUndelivered > 0 ? "warn" : "ok",
496
- count: expiredUndelivered,
497
- detail: expiredUndelivered > 0
498
- ? `${expiredUndelivered} queued message(s) expired undelivered at max_age in the last 24h (silent data loss)`
499
- : undefined,
517
+ status: expiredUndelivered.unexplainedCount > 0 ? "warn" : "ok",
518
+ count: expiredUndelivered.unexplainedCount,
519
+ detail: expiredUndelivered.unexplainedCount > 0
520
+ ? `${expiredUndelivered.unexplainedCount} queued message(s) expired undelivered at max_age in the last 24h (silent data loss)${expiredUndelivered.explainedManagedPolicyCount > 0 ? `; ${expiredUndelivered.explainedManagedPolicyCount} managed-policy expiry(s) explained by policy health` : ""}`
521
+ : expiredUndelivered.explainedManagedPolicyCount > 0
522
+ ? `${expiredUndelivered.explainedManagedPolicyCount} queued managed-policy message(s) expired while the policy target was unavailable; tracked by spawn-policy health, not silent data loss`
523
+ : undefined,
524
+ subjects: expiredUndelivered.subjects.length > 0 ? expiredUndelivered.subjects : undefined,
500
525
  });
501
526
 
502
527
  const rootTokenRuntimeAgents = listAgents().filter((agent) => {
@@ -539,4 +564,3 @@ export function getHealth(now: number = Date.now()): HealthReport {
539
564
  : "ok";
540
565
  return { status, version: VERSION, generatedAt: now, checks };
541
566
  }
542
-
@@ -152,7 +152,7 @@ export interface CreateDeliverableTaskInput {
152
152
  intent: string;
153
153
  complexity?: TaskComplexity;
154
154
  context?: string;
155
- doneWhen?: string;
155
+ doneWhen?: string; branch?: string;
156
156
  teamId?: string;
157
157
  projectId?: string;
158
158
  repo?: string;
@@ -171,25 +171,24 @@ export function createDeliverableTask(input: CreateDeliverableTaskInput): TaskDe
171
171
  const dispatch = cleanDispatch(input.dispatch);
172
172
  const complexity = cleanTaskComplexity(input.complexity ?? dispatch?.complexity, "complexity") ?? DEFAULT_TASK_COMPLEXITY;
173
173
  const context = optionalText(input.context, "context", 1_000_000);
174
+ const branch = optionalText(input.branch, "branch", 500);
174
175
  const taskProject = cleanTaskProject(input);
175
176
  // Legacy NOT-NULL columns have no DB defaults (#834 finding 6) — set them explicitly.
176
177
  const result = getDb().query(`
177
178
  INSERT INTO tasks (
178
179
  source, title, body, severity, status, target, occurrence_count, metadata,
179
180
  created_at, updated_at, last_seen_at,
180
- kind, intent, complexity, context, latest_steer, deliverable_stage, done_when, team_id, project_id, repo, created_by, dispatch
181
- ) VALUES (?, ?, ?, 'info', 'open', ?, 1, '{}', ?, ?, ?, '${DELIVERABLE_TASK_KIND}', ?, ?, ?, ?, 'none', ?, ?, ?, ?, ?, ?)
181
+ kind, intent, complexity, context, latest_steer, deliverable_stage, done_when, branch, team_id, project_id, repo, created_by, dispatch
182
+ ) VALUES (?, ?, ?, 'info', 'open', ?, 1, '{}', ?, ?, ?, '${DELIVERABLE_TASK_KIND}', ?, ?, ?, ?, 'none', ?, ?, ?, ?, ?, ?, ?)
182
183
  `).run(
183
184
  optionalText(input.source, "source", 200) ?? "coordinator",
184
185
  title,
185
186
  intent,
186
187
  optionalText(input.target, "target", 200) ?? createdBy,
187
- now, now, now,
188
- intent,
189
- complexity,
190
- context ?? null,
188
+ now, now, now, intent, complexity, context ?? null,
191
189
  optionalText(input.latestSteer, "latestSteer", 1_000_000) ?? null,
192
190
  optionalText(input.doneWhen, "doneWhen", 1_000_000) ?? null,
191
+ branch ?? null,
193
192
  optionalText(input.teamId, "teamId", 200) ?? null,
194
193
  taskProject.projectId ?? null,
195
194
  taskProject.repo ?? null,
@@ -203,7 +202,7 @@ export function createDeliverableTask(input: CreateDeliverableTaskInput): TaskDe
203
202
  severity: "info",
204
203
  title: `Task created: ${title}`,
205
204
  body: intent,
206
- metadata: { createdBy, complexity, ...(context ? { context } : {}), ...(taskProject.projectId ? { projectId: taskProject.projectId } : {}), ...(taskProject.repo ? { repo: taskProject.repo } : {}), ...(dispatch ? { dispatch } : {}) },
205
+ metadata: { createdBy, complexity, ...(context ? { context } : {}), ...(branch ? { branch } : {}), ...(taskProject.projectId ? { projectId: taskProject.projectId } : {}), ...(taskProject.repo ? { repo: taskProject.repo } : {}), ...(dispatch ? { dispatch } : {}) },
207
206
  }, now);
208
207
  return getTaskDetail(id)!;
209
208
  }
@@ -0,0 +1,90 @@
1
+ import {
2
+ EVENT_LOOP_LAG_LOG_MS,
3
+ EVENT_LOOP_LAG_SAMPLE_MS,
4
+ EVENT_LOOP_LAG_WARN_MS,
5
+ EVENT_LOOP_LAG_WINDOW_SAMPLES,
6
+ } from "./config";
7
+
8
+ export interface EventLoopLagSnapshot {
9
+ currentMs: number;
10
+ maxMs: number;
11
+ maxSinceLastReadMs: number;
12
+ sampleCount: number;
13
+ windowSamples: number;
14
+ sampleIntervalMs: number;
15
+ warnThresholdMs: number;
16
+ lastSampleAt?: number;
17
+ }
18
+
19
+ const samples = new Array<number>(EVENT_LOOP_LAG_WINDOW_SAMPLES).fill(0);
20
+ let sampleIndex = 0;
21
+ let sampleCount = 0;
22
+ let currentMs = 0;
23
+ let maxSinceLastReadMs = 0;
24
+ let lastSampleAt: number | undefined;
25
+ let timer: ReturnType<typeof setInterval> | undefined;
26
+ let lastTickAt = 0;
27
+
28
+ export function computeEventLoopLagMs(elapsedMs: number, expectedMs: number): number {
29
+ if (!Number.isFinite(elapsedMs) || !Number.isFinite(expectedMs)) return 0;
30
+ return Math.max(0, elapsedMs - expectedMs);
31
+ }
32
+
33
+ export function recordEventLoopLagSample(lagMs: number, sampledAt: number = Date.now(), logSpike = false): void {
34
+ const normalized = Number.isFinite(lagMs) ? Math.max(0, lagMs) : 0;
35
+ currentMs = normalized;
36
+ maxSinceLastReadMs = Math.max(maxSinceLastReadMs, normalized);
37
+ lastSampleAt = sampledAt;
38
+ samples[sampleIndex] = normalized;
39
+ sampleIndex = (sampleIndex + 1) % samples.length;
40
+ sampleCount = Math.min(sampleCount + 1, samples.length);
41
+ if (logSpike && EVENT_LOOP_LAG_LOG_MS > 0 && normalized >= EVENT_LOOP_LAG_LOG_MS) {
42
+ console.warn(`[health] event-loop lag ${Math.round(normalized)}ms`);
43
+ }
44
+ }
45
+
46
+ export function getEventLoopLagSnapshot(): EventLoopLagSnapshot {
47
+ let maxMs = 0;
48
+ for (let i = 0; i < sampleCount; i += 1) maxMs = Math.max(maxMs, samples[i] ?? 0);
49
+ const maxSinceLastRead = maxSinceLastReadMs;
50
+ maxSinceLastReadMs = 0;
51
+ return {
52
+ currentMs,
53
+ maxMs,
54
+ maxSinceLastReadMs: maxSinceLastRead,
55
+ sampleCount,
56
+ windowSamples: EVENT_LOOP_LAG_WINDOW_SAMPLES,
57
+ sampleIntervalMs: EVENT_LOOP_LAG_SAMPLE_MS,
58
+ warnThresholdMs: EVENT_LOOP_LAG_WARN_MS,
59
+ lastSampleAt,
60
+ };
61
+ }
62
+
63
+ export function startEventLoopLagSampler(): void {
64
+ if (timer) return;
65
+ lastTickAt = performance.now();
66
+ timer = setInterval(() => {
67
+ const now = performance.now();
68
+ const lagMs = computeEventLoopLagMs(now - lastTickAt, EVENT_LOOP_LAG_SAMPLE_MS);
69
+ lastTickAt = now;
70
+ recordEventLoopLagSample(lagMs, Date.now(), true);
71
+ }, EVENT_LOOP_LAG_SAMPLE_MS);
72
+ timer.unref?.();
73
+ }
74
+
75
+ export function stopEventLoopLagSampler(): void {
76
+ if (!timer) return;
77
+ clearInterval(timer);
78
+ timer = undefined;
79
+ }
80
+
81
+ export function resetEventLoopLagForTests(): void {
82
+ stopEventLoopLagSampler();
83
+ samples.fill(0);
84
+ sampleIndex = 0;
85
+ sampleCount = 0;
86
+ currentMs = 0;
87
+ maxSinceLastReadMs = 0;
88
+ lastSampleAt = undefined;
89
+ lastTickAt = 0;
90
+ }
@@ -2,14 +2,10 @@ import { createCommand } from "./commands-db";
2
2
  import { workspaceOwnerOrchestrator } from "./workspace-host";
3
3
  import { agentProviderState } from "./provider-state";
4
4
  import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces } from "./db";
5
- import {
6
- getManagedAgentState,
7
- listSpawnPolicies,
8
- updateManagedAgentState,
9
- upsertManagedAgentState,
10
- } from "./config-store";
5
+ import { getManagedAgentState, listSpawnPolicies, updateManagedAgentState, upsertManagedAgentState } from "./config-store";
11
6
  import { emitRelayEvent } from "./events";
12
7
  import { alwaysReloadTags, reconcileLifecycleContextPressure, requestAgentCompact } from "./lifecycle-context-pressure";
8
+ import { escalateOfflineOrchestrator } from "./managed-policy-escalation";
13
9
  import { markManagedAgentRunning, emitManagedState } from "./services/managed-running";
14
10
  import { authContextFromSystem } from "./services/auth-context";
15
11
  import { emitCommandEvent } from "./command-events";
@@ -189,15 +185,18 @@ export class LifecycleManager {
189
185
  spawnAgent(policy: SpawnPolicy, reason = "reconcile"): Command | null {
190
186
  const orch = getOrchestrator(policy.orchestratorId);
191
187
  if (!orch || orch.status !== "online") {
192
- const state = upsertManagedAgentState({
188
+ const state = getManagedAgentState(policy.name) ?? {
193
189
  policyName: policy.name,
194
- status: "backoff",
190
+ status: "stopped" as const,
195
191
  orchestratorId: policy.orchestratorId,
196
192
  provider: policy.provider,
197
- backoffUntil: this.now() + this.backoffDelay(policy, getManagedAgentState(policy.name)) * 1000,
198
- lastError: "orchestrator offline",
199
- });
200
- this.emitState(state);
193
+ restartCount: 0,
194
+ consecutiveFailures: 0,
195
+ updatedAt: this.now(),
196
+ };
197
+ this.markBackoff(policy, state, "orchestrator offline");
198
+ const next = getManagedAgentState(policy.name);
199
+ if (next) escalateOfflineOrchestrator(policy, next, orch);
201
200
  return null;
202
201
  }
203
202
  const spawnRequestId = generateSpawnRequestId();
@@ -3,6 +3,7 @@ import { deriveAgentBusyHealth } from "../agent-busy-health";
3
3
  import { expireStaleBusAgents } from "../bus";
4
4
  import { pruneOutbox } from "../bus-outbox";
5
5
  import { expireCommands } from "../commands-db";
6
+ import { getRetentionConfig } from "../retention-config-store";
6
7
  import { OFFLINE_PRUNE_MS, REAP_INTERVAL_MS, STALE_TTL_MS } from "../config";
7
8
  import {
8
9
  createActivityEvent,
@@ -13,7 +14,7 @@ import {
13
14
  listStaleQueuedDirectTargets,
14
15
  pruneOrphanedSharedWorkspaces,
15
16
  pruneOfflineAgents,
16
- pruneOldMessagesAsync,
17
+ pruneRetentionBatchAsync,
17
18
  pruneProviderQuotaHistory,
18
19
  reapStaleAgents,
19
20
  reapStaleOrchestrators,
@@ -27,10 +28,11 @@ import { isAgentUnavailable } from "../db/agent-predicates";
27
28
  import { flushQueuedDirectMessages } from "../services/managed-running";
28
29
  import { notifyAgentOffline } from "../agent-lifecycle-events";
29
30
  import { diagnoseAgentDeath } from "../agent-death-diagnosis";
30
- import { autoMergeCleanFastForwards, runAutoMergeWatchdog } from "../workspace-auto-merge";
31
+ import { autoMergeCleanFastForwards, runAutoMergeWatchdog, scheduleRepoMerge } from "../workspace-auto-merge";
31
32
  import { reapOrphanedWorktrees } from "../workspace-orphans";
32
33
  import { getLifecycleManager } from "../lifecycle-manager";
33
34
  import { applyCommandToRecipe } from "../recipe-runner";
35
+ import { settleWorkspaceCommand } from "../workspace-command-settle";
34
36
  import {
35
37
  emitAgentRemoved,
36
38
  emitAgentStatus,
@@ -237,6 +239,7 @@ export const definitions: MaintenanceJobDefinition[] = [
237
239
  handler() {
238
240
  const commands = expireCommands();
239
241
  for (const command of commands) {
242
+ settleWorkspaceCommand(command, { onMergeSettled: scheduleRepoMerge });
240
243
  applyCommandToRecipe(command);
241
244
  emitCommand(command);
242
245
  }
@@ -373,13 +376,26 @@ export const definitions: MaintenanceJobDefinition[] = [
373
376
  },
374
377
  {
375
378
  id: "old-message-prune",
376
- title: "Old message prune",
377
- description: "Delete messages outside the configured retention window.",
379
+ title: "Data retention prune",
380
+ description: "Delete old messages, activity events, and terminal spawn commands in bounded batches.",
378
381
  intervalMs: DAY_MS,
379
382
  runOnStart: false,
380
- handler(context) {
381
- const retentionDays = Number(process.env.RETENTION_DAYS) || 30;
382
- return pruneOldMessagesAsync(retentionDays * DAY_MS, { signal: context?.signal });
383
+ async handler(context) {
384
+ const retention = getRetentionConfig();
385
+ const now = Date.now();
386
+ const result = await pruneRetentionBatchAsync({
387
+ messageCutoff: now - retention.messageRetentionDays * DAY_MS,
388
+ activityCutoff: now - retention.activityEventRetentionDays * DAY_MS,
389
+ activityMaxRows: retention.activityEventMaxRows,
390
+ spawnCommandCutoff: now - retention.spawnCommandTerminalRetentionDays * DAY_MS,
391
+ batchSize: retention.pruneBatchSize,
392
+ }, { signal: context?.signal });
393
+ return {
394
+ prunedMessages: result.prunedMessages,
395
+ prunedActivityEvents: result.prunedActivityEvents,
396
+ prunedSpawnCommands: result.prunedSpawnCommands,
397
+ offThread: result.offThread,
398
+ };
383
399
  },
384
400
  },
385
401
  {
@@ -21,6 +21,7 @@ import { preparePrCompletionScan, reconcileLandedPr } from "../workspace-pr-comp
21
21
  import { applyBranchFreshness, fetchHostMergePreview, workspacePathWithinBase } from "../workspace-probe";
22
22
  import { wakeRepoSteward } from "../workspace-auto-merge";
23
23
  import { workspaceOwnerOrchestrator } from "../workspace-host";
24
+ import { routeWorkspaceCustodyEscalation } from "../workspace-escalation";
24
25
  import {
25
26
  CONFLICT_SCAN_STATUSES,
26
27
  DAY_MS,
@@ -175,74 +176,53 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
175
176
  const escalationTargets: string[] = [];
176
177
  const reconciledLandedIds: string[] = [];
177
178
  const escalationMs = stewardEscalationMs();
178
- const fallbackTarget = stewardFallbackTarget();
179
179
  for (const ws of all) {
180
180
  if (!STRANDABLE_STATUSES.has(ws.status) || ws.mode !== "isolated" || !ws.worktreePath) continue;
181
181
  reelectRepoSteward(ws.repoRoot);
182
182
  const fresh = getWorkspace(ws.id);
183
183
  if (!fresh || !STRANDABLE_STATUSES.has(fresh.status)) continue;
184
184
  const meta = fresh.metadata as Record<string, unknown>;
185
- const steward = fresh.stewardAgentId;
186
- const stewardOnline = Boolean(steward && getAgent(steward) && getAgent(steward)!.status !== "offline");
187
185
  const strandedAt = typeof meta.strandedAt === "number" ? meta.strandedAt : undefined;
188
186
 
189
- if (!stewardOnline) {
190
- const orch = workspaceOwnerOrchestrator(fresh, listOrchestrators().filter((o) => o.status === "online" && o.apiUrl), { requireApiUrl: true });
191
- const preview = orch?.apiUrl ? await fetchHostMergePreview(orch.apiUrl, fresh, { checkPr: true }) : null;
192
- if (preview && !("available" in preview) && !preview.error && !preview.missing) {
193
- const landed = previewProvesLanded(preview);
194
- if (landed) {
195
- reconcileLandedWorkspace(fresh, preview);
196
- patchWorkspaceMetadata(fresh.id, { strandedAt: undefined, escalatedAt: undefined, strandedNotifiedSteward: undefined });
197
- reconciledLandedIds.push(fresh.id);
198
- continue;
199
- }
187
+ const orch = workspaceOwnerOrchestrator(fresh, listOrchestrators().filter((o) => o.status === "online" && o.apiUrl), { requireApiUrl: true });
188
+ const preview = orch?.apiUrl ? await fetchHostMergePreview(orch.apiUrl, fresh, { checkPr: true }) : null;
189
+ if (preview && !("available" in preview) && !preview.error && !preview.missing) {
190
+ const landed = previewProvesLanded(preview);
191
+ if (landed) {
192
+ reconcileLandedWorkspace(fresh, preview);
193
+ patchWorkspaceMetadata(fresh.id, { strandedAt: undefined, escalatedAt: undefined, strandedNotifiedSteward: undefined });
194
+ reconciledLandedIds.push(fresh.id);
195
+ continue;
200
196
  }
201
197
  }
202
198
 
203
- if (stewardOnline) {
204
- if (strandedAt !== undefined && meta.strandedNotifiedSteward !== steward) {
205
- const sent = notifyTarget(
206
- "workspace.steward_reassigned",
207
- { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
208
- steward!,
209
- "Workspace stewardship reassigned",
210
- `You are now steward for ${fresh.repoRoot}. Workspace \`${fresh.branch ?? fresh.id}\` is ${fresh.status} and was stranded without an online steward - please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"}.`,
211
- { kind: "workspace.steward-reassigned", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status },
212
- );
213
- if (sent) reassignedIds.push(fresh.id);
214
- }
215
- patchWorkspaceMetadata(fresh.id, { strandedAt: undefined, escalatedAt: undefined, strandedNotifiedSteward: steward });
216
- continue;
217
- }
218
-
219
199
  if (strandedAt === undefined) { patchWorkspaceMetadata(fresh.id, { strandedAt: now }); continue; }
220
- if (now - strandedAt < escalationMs || meta.escalatedAt) continue;
221
- // #642 escalate to the branch agent's coordinator (owner's `spawnedBy`), falling back to
222
- // the configured `stewardFallbackTarget` only when no coordinator resolves. The bare
223
- // fallback was an unset `system` dead-end in the live incident.
224
- const { coordinatorAgentId } = resolveStewardIdentity(fresh);
225
- const escalationTarget = coordinatorAgentId ?? fallbackTarget;
226
- const sent = notifyTarget(
227
- "workspace.stranded_escalation",
228
- { workspaceId: fresh.id, repoRoot: fresh.repoRoot, author: fresh.ownerAgentId, parent: coordinatorAgentId ?? undefined },
229
- escalationTarget,
230
- "Stranded workspace needs an owner",
231
- `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} with no online steward (all repo agents offline) for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h.${coordinatorAgentId ? ` You are the branch agent \`${fresh.ownerAgentId ?? "?"}\`'s coordinator.` : ""} Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
232
- { kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt, coordinatorAgentId },
233
- );
234
- if (sent) escalationTargets.push(sent);
200
+ const escalatedAt = typeof meta.escalatedAt === "number" ? meta.escalatedAt : 0;
201
+ if (now - strandedAt < escalationMs) continue;
202
+ if (escalatedAt && now - escalatedAt < escalationMs) continue;
203
+
204
+ if (getStewardConfig().enabled) {
205
+ wakeRepoSteward(fresh, `workspace ${fresh.status} stranded for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h`);
206
+ }
207
+ const targets = routeWorkspaceCustodyEscalation({
208
+ workspace: fresh,
209
+ subject: "Stranded workspace needs an owner",
210
+ body: `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} and has been stranded for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
211
+ payload: { kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt },
212
+ idempotencyKey: `workspace-stranded:${fresh.id}:${strandedAt}:${Math.floor(now / escalationMs)}`,
213
+ });
214
+ escalationTargets.push(...targets);
235
215
  patchWorkspaceMetadata(fresh.id, { escalatedAt: now });
236
216
  escalatedIds.push(fresh.id);
237
217
  createActivityEvent({
238
218
  clientId: `workspace-gc-escalate-${fresh.id}-${now}`,
239
219
  kind: "state",
240
220
  title: "Workspace escalated",
241
- body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${escalationTarget ? ` to ${escalationTarget}${coordinatorAgentId ? " (coordinator)" : ""}` : " (no coordinator or fallback configured)"}`,
221
+ body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${targets.length ? ` to ${targets.join(", ")}` : " (no owner, coordinator, or fallback configured)"}`,
242
222
  meta: fresh.branch ?? fresh.id,
243
223
  icon: "ti-alert-octagon",
244
224
  view: "orchestrators",
245
- metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, escalationTarget: escalationTarget || null, coordinatorAgentId: coordinatorAgentId ?? null },
225
+ metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, escalationTargets: targets },
246
226
  });
247
227
  }
248
228
 
@@ -250,6 +230,7 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
250
230
  const autoAbandonDeferred: string[] = [];
251
231
  const notifiedStewards: string[] = [];
252
232
  const reviewOrchestrators = listOrchestrators();
233
+ const fallbackTarget = stewardFallbackTarget();
253
234
  for (const ws of all) {
254
235
  if (ws.status === "review_requested" && ws.updatedAt < reviewCutoff) {
255
236
  const fresh = getWorkspace(ws.id);
@@ -0,0 +1,47 @@
1
+ import { getAgent, getDb } from "./db";
2
+ import { getSpawnPolicy } from "./config-store";
3
+ import { stewardFallbackTarget } from "./config";
4
+ import { routeNotification } from "./notification-router";
5
+ import type { ManagedAgentState, Orchestrator, SpawnPolicy } from "./types";
6
+
7
+ const ORCHESTRATOR_OFFLINE_ESCALATION_FAILURES = 3;
8
+
9
+ export function escalateOfflineOrchestrator(policy: SpawnPolicy, state: ManagedAgentState, orch: Orchestrator | null | undefined): void {
10
+ if (state.consecutiveFailures < ORCHESTRATOR_OFFLINE_ESCALATION_FAILURES) return;
11
+ const updatedBy = getSpawnPolicy(policy.name)?.updatedBy?.trim();
12
+ const previousParent = state.agentId ? getAgent(state.agentId)?.spawnedBy?.trim() : undefined;
13
+ const fallback = stewardFallbackTarget();
14
+ const recipients = [...new Set([updatedBy, previousParent, fallback].filter((item): item is string => Boolean(item && item !== "system")))];
15
+ if (!recipients.length) return;
16
+ const host = orch?.hostname ? `${orch.hostname} (${policy.orchestratorId})` : policy.orchestratorId;
17
+ const queuedMessages = queuedPolicyMessageCount(policy.name);
18
+ const nextRetry = state.backoffUntil ? new Date(state.backoffUntil).toISOString() : "not scheduled";
19
+ routeNotification({
20
+ type: "agent.spawn_failed",
21
+ scope: { target: `policy:${policy.name}` },
22
+ recipients,
23
+ message: {
24
+ subject: "Managed policy pinned host offline",
25
+ body: [
26
+ `Managed policy \`${policy.name}\` cannot spawn because pinned orchestrator \`${host}\` is offline.`,
27
+ `Queued messages: ${queuedMessages}. Consecutive failures: ${state.consecutiveFailures}. Next retry: ${nextRetry}.`,
28
+ ].join("\n"),
29
+ payload: {
30
+ kind: "managed-policy.orchestrator-offline",
31
+ policyName: policy.name,
32
+ orchestratorId: policy.orchestratorId,
33
+ orchestratorHostname: orch?.hostname,
34
+ queuedMessages,
35
+ consecutiveFailures: state.consecutiveFailures,
36
+ backoffUntil: state.backoffUntil,
37
+ },
38
+ idempotencyKey: `managed-policy-orchestrator-offline:${policy.name}:${policy.orchestratorId}`,
39
+ replyExpected: false,
40
+ },
41
+ });
42
+ }
43
+
44
+ function queuedPolicyMessageCount(policyName: string): number {
45
+ const row = getDb().query("SELECT COUNT(*) AS count FROM messages WHERE to_target = ? AND delivery_status = 'queued'").get(`policy:${policyName}`) as { count?: number } | undefined;
46
+ return row?.count ?? 0;
47
+ }