agent-relay-server 0.120.6 → 0.121.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -4
  3. package/public/assets/{MessageBubble-So86mU9-.js → MessageBubble-CLqxCG9b.js} +2 -2
  4. package/public/assets/{MessageBubble-So86mU9-.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-DBZYAsww.js → PlanGraphPanel-BkkToFEb.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DBZYAsww.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
  7. package/public/assets/{activity-CZNwmXg1.js → activity-D-ocGQGs.js} +2 -2
  8. package/public/assets/{activity-CZNwmXg1.js.map → activity-D-ocGQGs.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BhadoGwi.js → agent-profiles-CRPgYTyb.js} +2 -2
  10. package/public/assets/{agent-profiles-BhadoGwi.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
  11. package/public/assets/{agents-DWn1xx9x.js → agents-Chw5E_2q.js} +2 -2
  12. package/public/assets/{agents-DWn1xx9x.js.map → agents-Chw5E_2q.js.map} +1 -1
  13. package/public/assets/{analytics-DifGnk7q.js → analytics-BmNDG0hR.js} +2 -2
  14. package/public/assets/{analytics-DifGnk7q.js.map → analytics-BmNDG0hR.js.map} +1 -1
  15. package/public/assets/{automation-BExrtKkb.js → automation-D7g90kTv.js} +2 -2
  16. package/public/assets/{automation-BExrtKkb.js.map → automation-D7g90kTv.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CyC5Qrhc.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
  18. package/public/assets/{branch-state-badge-CyC5Qrhc.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
  19. package/public/assets/{channels-BLkdt2XR.js → channels-CLwPeEPi.js} +2 -2
  20. package/public/assets/{channels-BLkdt2XR.js.map → channels-CLwPeEPi.js.map} +1 -1
  21. package/public/assets/{chat-C7OhKtr6.js → chat-Cgj7VKzc.js} +2 -2
  22. package/public/assets/{chat-C7OhKtr6.js.map → chat-Cgj7VKzc.js.map} +1 -1
  23. package/public/assets/{connectors-C0n12Je8.js → connectors-Br6fiTny.js} +2 -2
  24. package/public/assets/{connectors-C0n12Je8.js.map → connectors-Br6fiTny.js.map} +1 -1
  25. package/public/assets/{formatted-body-BCQT2qSb.js → formatted-body-CmkuD23M.js} +3 -3
  26. package/public/assets/{formatted-body-BCQT2qSb.js.map → formatted-body-CmkuD23M.js.map} +1 -1
  27. package/public/assets/{formatted-body-impl-Bk7wnESl.js → formatted-body-impl-DbdFmbwW.js} +2 -2
  28. package/public/assets/{formatted-body-impl-Bk7wnESl.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
  29. package/public/assets/{index-CRraCeo_.js → index-D9OogaB5.js} +6 -6
  30. package/public/assets/{index-CRraCeo_.js.map → index-D9OogaB5.js.map} +1 -1
  31. package/public/assets/{insights-DEgES6jz.js → insights-qvaPqWCV.js} +2 -2
  32. package/public/assets/{insights-DEgES6jz.js.map → insights-qvaPqWCV.js.map} +1 -1
  33. package/public/assets/{integrations-v14ehskT.js → integrations-DYEGSqq_.js} +2 -2
  34. package/public/assets/{integrations-v14ehskT.js.map → integrations-DYEGSqq_.js.map} +1 -1
  35. package/public/assets/{maintenance-BgVB6_gO.js → maintenance-C0HIEB-J.js} +2 -2
  36. package/public/assets/{maintenance-BgVB6_gO.js.map → maintenance-C0HIEB-J.js.map} +1 -1
  37. package/public/assets/{managed-agents-8_NKSkhI.js → managed-agents-DdS7aI38.js} +2 -2
  38. package/public/assets/{managed-agents-8_NKSkhI.js.map → managed-agents-DdS7aI38.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-yxQkrd2M.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-yxQkrd2M.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
  41. package/public/assets/{memory-CJD8eekg.js → memory-CSwx7bz8.js} +2 -2
  42. package/public/assets/{memory-CJD8eekg.js.map → memory-CSwx7bz8.js.map} +1 -1
  43. package/public/assets/{messages-DLHMZbHZ.js → messages-Be9CmvDv.js} +2 -2
  44. package/public/assets/{messages-DLHMZbHZ.js.map → messages-Be9CmvDv.js.map} +1 -1
  45. package/public/assets/{orchestrators-DEOep7FX.js → orchestrators-BgtvvHvo.js} +2 -2
  46. package/public/assets/{orchestrators-DEOep7FX.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
  47. package/public/assets/{overview-BgpfO6mt.js → overview-BvhbPWtF.js} +2 -2
  48. package/public/assets/{overview-BgpfO6mt.js.map → overview-BvhbPWtF.js.map} +1 -1
  49. package/public/assets/{pairs-8NVKHh96.js → pairs-CpXSOMx0.js} +2 -2
  50. package/public/assets/{pairs-8NVKHh96.js.map → pairs-CpXSOMx0.js.map} +1 -1
  51. package/public/assets/{projects-CZJW35C7.js → projects-DdPNPFJI.js} +2 -2
  52. package/public/assets/{projects-CZJW35C7.js.map → projects-DdPNPFJI.js.map} +1 -1
  53. package/public/assets/{prompt-settings-fqyLwUa-.js → prompt-settings-BpZse7-X.js} +2 -2
  54. package/public/assets/{prompt-settings-fqyLwUa-.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
  55. package/public/assets/{registry-BKbLL4mO.js → registry-pb1gTZEg.js} +2 -2
  56. package/public/assets/{registry-BKbLL4mO.js.map → registry-pb1gTZEg.js.map} +1 -1
  57. package/public/assets/{security-BlTFTbq0.js → security-BgcX1n9D.js} +2 -2
  58. package/public/assets/{security-BlTFTbq0.js.map → security-BgcX1n9D.js.map} +1 -1
  59. package/public/assets/{select-Zd_qh0uU.js → select-DZPVpKPv.js} +2 -2
  60. package/public/assets/{select-Zd_qh0uU.js.map → select-DZPVpKPv.js.map} +1 -1
  61. package/public/assets/{settings-nHuSVRkL.js → settings-hd5kzdRB.js} +4 -4
  62. package/public/assets/settings-hd5kzdRB.js.map +1 -0
  63. package/public/assets/store-DKTLubBT.js +9 -0
  64. package/public/assets/store-DKTLubBT.js.map +1 -0
  65. package/public/assets/{tasks-C3JhQ8vB.js → tasks-C5d2LU5E.js} +2 -2
  66. package/public/assets/{tasks-C3JhQ8vB.js.map → tasks-C5d2LU5E.js.map} +1 -1
  67. package/public/assets/{teams-C9E9Yt39.js → teams-B8f2pGJe.js} +2 -2
  68. package/public/assets/{teams-C9E9Yt39.js.map → teams-B8f2pGJe.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-FP0JlsvS.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-FP0JlsvS.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-Ema7Hy3s.js → work-queue-9imc7aNK.js} +2 -2
  73. package/public/assets/{work-queue-Ema7Hy3s.js.map → work-queue-9imc7aNK.js.map} +1 -1
  74. package/public/assets/{workspaces-DI317fMk.js → workspaces-CLYc3yF3.js} +2 -2
  75. package/public/assets/{workspaces-DI317fMk.js.map → workspaces-CLYc3yF3.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -0
  79. package/src/agent-card-projection.ts +39 -9
  80. package/src/agent-issue-repo.ts +10 -3
  81. package/src/agent-lifecycle-events.ts +23 -1
  82. package/src/agent-ref.ts +18 -3
  83. package/src/bus-outbox.ts +74 -1
  84. package/src/bus.ts +84 -5
  85. package/src/channel-target.ts +4 -0
  86. package/src/cli/steward.ts +15 -14
  87. package/src/commands-db.ts +63 -9
  88. package/src/config-store.ts +2 -0
  89. package/src/config.ts +54 -0
  90. package/src/db/claim-sql.ts +16 -0
  91. package/src/db/connection.ts +3 -1
  92. package/src/db/continuations.ts +44 -3
  93. package/src/db/index.ts +1 -0
  94. package/src/db/maintenance-worker.ts +91 -0
  95. package/src/db/message-reads.ts +34 -4
  96. package/src/db/messages.ts +147 -8
  97. package/src/db/migrations.ts +7 -0
  98. package/src/db/pairs.ts +1 -0
  99. package/src/db/retention.ts +130 -0
  100. package/src/db/schema.ts +11 -0
  101. package/src/db/stats.ts +22 -0
  102. package/src/event-loop-lag.ts +90 -0
  103. package/src/gate-resolver.ts +124 -0
  104. package/src/lifecycle-manager.ts +41 -10
  105. package/src/lifecycle-signal-registry.ts +87 -0
  106. package/src/maintenance/constants.ts +5 -1
  107. package/src/maintenance/jobs.ts +48 -8
  108. package/src/maintenance/workspaces.ts +75 -44
  109. package/src/managed-policy-escalation.ts +68 -3
  110. package/src/mcp/tools-spawn.ts +16 -6
  111. package/src/mcp-plan-tools.ts +2 -3
  112. package/src/notification-settings.ts +11 -0
  113. package/src/notification-types.ts +4 -0
  114. package/src/operator-alarm.ts +37 -0
  115. package/src/retention-config-store.ts +32 -0
  116. package/src/retention-config.ts +58 -0
  117. package/src/routes/agents.ts +4 -0
  118. package/src/routes/messages.ts +13 -1
  119. package/src/routes/orchestrator-proxy.ts +13 -1
  120. package/src/routes/orchestrator.ts +4 -0
  121. package/src/routing-policy-store.ts +11 -1
  122. package/src/security.ts +20 -0
  123. package/src/self-resume.ts +185 -21
  124. package/src/server.ts +4 -0
  125. package/src/services/plan-graph.ts +42 -12
  126. package/src/services/register-agent.ts +5 -1
  127. package/src/services/send-message.ts +99 -21
  128. package/src/services/spawn-agent.ts +21 -2
  129. package/src/services/spawn-registration-watchdog.ts +74 -9
  130. package/src/services/task-semantic-events.ts +17 -1
  131. package/src/settings/registry.ts +11 -0
  132. package/src/spawn-command.ts +3 -0
  133. package/src/spawn-targets.ts +26 -1
  134. package/src/sse.ts +67 -14
  135. package/src/workspace-auto-merge.ts +90 -34
  136. package/src/workspace-escalation.ts +52 -3
  137. package/src/workspace-human-escalation.ts +68 -0
  138. package/src/workspace-orphans.ts +11 -6
  139. package/src/workspace-phase.ts +22 -1
  140. package/public/assets/settings-nHuSVRkL.js.map +0 -1
  141. package/public/assets/store-nXGxgz2Q.js +0 -9
  142. 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
@@ -15,6 +15,8 @@ import {
15
15
  import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { applyConnectionPragmas, getDb, setDb } from "./connection.ts";
18
+ import { __resetDeferredLineageReports } from "./messages.ts";
19
+ import { __resetLifecycleSignals } from "../lifecycle-signal-registry";
18
20
  import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
19
21
  import { applyMigrations } from "./migrations.ts";
20
22
  import { PROMPT_TEMPLATES_SCHEMA_SQL, seedPromptTemplatesIfNeeded } from "./prompt-templates-schema";
@@ -92,6 +94,10 @@ export function initDb(path: string = "agent-relay.db"): Database {
92
94
  const conn = new Database(path, { create: true });
93
95
  applyConnectionPragmas(conn);
94
96
  setDb(conn, path);
97
+ // A fresh DB is a fresh relay: drop in-memory report-up state keyed by the OLD db's message ids /
98
+ // agent lineage so it can never misfire against reused ids (#1037/#1032b, and per-test isolation).
99
+ __resetDeferredLineageReports();
100
+ __resetLifecycleSignals();
95
101
 
96
102
  getDb().run(`
97
103
  CREATE TABLE IF NOT EXISTS agents (
@@ -114,6 +120,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
114
120
  last_seen INTEGER NOT NULL,
115
121
  created_at INTEGER NOT NULL
116
122
  );
123
+ CREATE INDEX IF NOT EXISTS idx_agents_spawn_request ON agents(json_extract(meta, '$.spawnRequestId'));
117
124
  CREATE TABLE IF NOT EXISTS notification_subscriptions (
118
125
  id TEXT PRIMARY KEY,
119
126
  agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
@@ -865,6 +872,9 @@ export function initDb(path: string = "agent-relay.db"): Database {
865
872
  CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
866
873
  CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
867
874
  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);
875
+ CREATE INDEX IF NOT EXISTS idx_activity_registered_spawn_request
876
+ ON activity_events(json_extract(metadata, '$.spawnRequestId'))
877
+ WHERE kind = 'state' AND title = 'Agent registered';
868
878
  -- 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
879
 
870
880
  CREATE TABLE IF NOT EXISTS channels (
@@ -942,6 +952,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
942
952
  CREATE INDEX IF NOT EXISTS idx_commands_status ON commands(status);
943
953
  CREATE INDEX IF NOT EXISTS idx_commands_created ON commands(created_at);
944
954
  CREATE INDEX IF NOT EXISTS idx_commands_correlation ON commands(correlation_id);
955
+ CREATE INDEX IF NOT EXISTS idx_commands_spawn_watchdog ON commands(type, status, updated_at);
945
956
 
946
957
  CREATE TABLE IF NOT EXISTS recipe_instances (
947
958
  id TEXT PRIMARY KEY,
package/src/db/stats.ts CHANGED
@@ -20,6 +20,7 @@ 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;
@@ -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
+ }
@@ -0,0 +1,124 @@
1
+ // #1036 — resolve the validation/land gate commands for a repo WITHOUT hardcoding
2
+ // `bun test`. agent-relay is a bun repo, and that assumption used to leak into every
3
+ // foreign repo the steward operated on: running `bun test` in a Jest-only repo
4
+ // produced bogus failures and refused a genuinely-green branch. Derive the gate,
5
+ // never assume the home-repo runner.
6
+ //
7
+ // Priority order:
8
+ // 1. Project-config pin — the repo's own `.agent-relay/land-gates.json` (#892) wins
9
+ // outright. A project that declared gates knows better than any heuristic.
10
+ // 2. Derive from `package.json` — run the repo's DECLARED scripts via its detected
11
+ // package manager (from `packageManager` field or lockfile). A repo whose
12
+ // `scripts.test` is `jest` gets `<pm> run test`, never `bun test`.
13
+ // 3. Default — the legacy bun heuristic, only when there's nothing to derive from
14
+ // (no repo path, or no readable package.json).
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { loadRepoLandGates } from "./land-gates";
18
+
19
+ export interface StewardCheck {
20
+ command: string;
21
+ reason: string;
22
+ cwd?: string;
23
+ }
24
+
25
+ export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
26
+
27
+ interface PackageInfo {
28
+ pm: PackageManager;
29
+ scripts: Record<string, string>;
30
+ }
31
+
32
+ /** Detect the repo's package manager from its `packageManager` field, then its
33
+ * lockfile. Defaults to npm — the ecosystem default when nothing else is declared. */
34
+ export function detectPackageManager(repoRoot: string, packageManagerField?: unknown): PackageManager {
35
+ if (typeof packageManagerField === "string") {
36
+ const name = packageManagerField.split("@")[0]?.trim().toLowerCase();
37
+ if (name === "bun" || name === "pnpm" || name === "yarn" || name === "npm") return name;
38
+ }
39
+ if (existsSync(join(repoRoot, "bun.lock")) || existsSync(join(repoRoot, "bun.lockb"))) return "bun";
40
+ if (existsSync(join(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
41
+ if (existsSync(join(repoRoot, "yarn.lock"))) return "yarn";
42
+ if (existsSync(join(repoRoot, "package-lock.json"))) return "npm";
43
+ return "npm";
44
+ }
45
+
46
+ /** Read `package.json` scripts + detect the package manager. Returns null when there
47
+ * is no readable/parseable package.json (→ caller falls back to the default). */
48
+ function readPackageInfo(repoRoot: string): PackageInfo | null {
49
+ let raw: string;
50
+ try {
51
+ raw = readFileSync(join(repoRoot, "package.json"), "utf8");
52
+ } catch {
53
+ return null;
54
+ }
55
+ let parsed: unknown;
56
+ try {
57
+ parsed = JSON.parse(raw);
58
+ } catch {
59
+ return null;
60
+ }
61
+ if (typeof parsed !== "object" || parsed === null) return null;
62
+ const record = parsed as Record<string, unknown>;
63
+ const rawScripts = record.scripts;
64
+ const scripts: Record<string, string> = {};
65
+ if (typeof rawScripts === "object" && rawScripts !== null) {
66
+ for (const [k, v] of Object.entries(rawScripts as Record<string, unknown>)) {
67
+ if (typeof v === "string") scripts[k] = v;
68
+ }
69
+ }
70
+ return { pm: detectPackageManager(repoRoot, record.packageManager), scripts };
71
+ }
72
+
73
+ /**
74
+ * Suggest validation gate commands from a workspace's changed files, deriving the
75
+ * runner from the repo (never hardcoding `bun test`) when `repoRoot` is known.
76
+ *
77
+ * @param files changed file paths (drive which checks are relevant)
78
+ * @param repoRoot the repo/worktree to derive gates from; omit → legacy bun default
79
+ */
80
+ export function resolveStewardChecks(files: string[], repoRoot?: string): StewardCheck[] {
81
+ const has = (re: RegExp) => files.some((f) => re.test(f));
82
+
83
+ // 1. Project-config pin: repo-authored land gates override every heuristic.
84
+ if (repoRoot) {
85
+ try {
86
+ const gates = loadRepoLandGates(repoRoot);
87
+ if (gates.length)
88
+ return gates.map((g) => ({ command: g.command, reason: `pinned land gate "${g.name}"`, ...(g.cwd ? { cwd: g.cwd } : {}) }));
89
+ } catch {
90
+ // A malformed land-gates.json is caught by the real land-gate runner (#902);
91
+ // for a mere check suggestion, fall through to derivation rather than throw.
92
+ }
93
+ }
94
+
95
+ const checks: StewardCheck[] = [];
96
+
97
+ // 2. Derive from package.json: run the repo's DECLARED scripts via its package manager.
98
+ const pkg = repoRoot ? readPackageInfo(repoRoot) : null;
99
+ if (pkg) {
100
+ const runScript = (name: string) => `${pkg.pm} run ${name}`;
101
+ if (has(/\.(ts|tsx|mts|cts)$/) && pkg.scripts.typecheck) {
102
+ checks.push({ command: runScript("typecheck"), reason: "TypeScript files changed" });
103
+ }
104
+ if (pkg.scripts.test) {
105
+ if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: runScript("test"), reason: "test files changed" });
106
+ else if (files.length) checks.push({ command: runScript("test"), reason: "repo default" });
107
+ } else if (pkg.pm === "bun") {
108
+ // bun runs tests with no declared script; a missing scripts.test doesn't mean "no tests".
109
+ if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
110
+ else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
111
+ }
112
+ if (has(/(^|\/)dashboard\//) && pkg.scripts["build:dashboard"]) {
113
+ checks.push({ command: runScript("build:dashboard"), reason: "dashboard sources changed" });
114
+ }
115
+ return checks;
116
+ }
117
+
118
+ // 3. Default (last resort): no package.json to derive from — legacy bun heuristic.
119
+ if (has(/\.(ts|tsx|mts|cts)$/)) checks.push({ command: "bun run typecheck", reason: "TypeScript files changed" });
120
+ if (has(/\.test\.|(^|\/)tests?\//)) checks.push({ command: "bun test", reason: "test files changed" });
121
+ else if (files.length) checks.push({ command: "bun test", reason: "repo default" });
122
+ if (has(/(^|\/)dashboard\//)) checks.push({ command: "bun run build:dashboard", reason: "dashboard sources changed" });
123
+ return checks;
124
+ }
@@ -5,7 +5,7 @@ import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator,
5
5
  import { getManagedAgentState, listSpawnPolicies, updateManagedAgentState, upsertManagedAgentState } from "./config-store";
6
6
  import { emitRelayEvent } from "./events";
7
7
  import { alwaysReloadTags, reconcileLifecycleContextPressure, requestAgentCompact } from "./lifecycle-context-pressure";
8
- import { escalateOfflineOrchestrator } from "./managed-policy-escalation";
8
+ import { escalateManagedSpawnFailure, escalateOfflineOrchestrator } from "./managed-policy-escalation";
9
9
  import { markManagedAgentRunning, emitManagedState } from "./services/managed-running";
10
10
  import { authContextFromSystem } from "./services/auth-context";
11
11
  import { emitCommandEvent } from "./command-events";
@@ -31,6 +31,13 @@ interface LifecycleManagerOptions {
31
31
  now?: () => number;
32
32
  }
33
33
 
34
+ // #1021 A1 — the outcome of trying to wake an on-demand policy via a queued message. A
35
+ // caller (e.g. wakeRepoSteward) must be able to tell "a live agent will handle this" from
36
+ // "nothing spawned" so it never claims a hand-off that didn't happen.
37
+ export type PolicySpawnOutcome =
38
+ | { woke: true; via: "spawned" | "already-active"; command?: Command }
39
+ | { woke: false; reason: string };
40
+
34
41
  export class LifecycleManager {
35
42
  private timer?: Timer;
36
43
  private readonly tickMs: number;
@@ -69,12 +76,19 @@ export class LifecycleManager {
69
76
  }
70
77
  }
71
78
 
72
- onMessageForPolicy(policyName: string): void {
79
+ onMessageForPolicy(policyName: string): PolicySpawnOutcome {
73
80
  const policy = this.loadPolicies().find((item) => item.name === policyName);
74
- if (!policy || policy.mode !== "on-demand" || policy.enabled === false) return;
81
+ if (!policy) return { woke: false, reason: `no spawn policy '${policyName}'` };
82
+ if (policy.mode !== "on-demand") return { woke: false, reason: `spawn policy '${policyName}' is not on-demand` };
83
+ if (policy.enabled === false) return { woke: false, reason: `spawn policy '${policyName}' is disabled` };
75
84
  const state = getManagedAgentState(policy.name);
76
- if (state && ["starting", "running", "stopping"].includes(state.status)) return;
77
- this.spawnAgent(policy, "message-trigger");
85
+ // A live (or coming-up / draining) agent will pick up the queued message — that IS a wake.
86
+ if (state && ["starting", "running", "stopping"].includes(state.status)) return { woke: true, via: "already-active" };
87
+ // #1021 A1 — spawnAgent returns null when the pinned host is offline; that is a spawn
88
+ // FAILURE, not a success. Propagate it so callers don't stamp a false hand-off.
89
+ const command = this.spawnAgent(policy, "message-trigger");
90
+ if (!command) return { woke: false, reason: `steward spawn failed for '${policyName}' (owning host offline)` };
91
+ return { woke: true, via: "spawned", command };
78
92
  }
79
93
 
80
94
  // NOTE: the bus registration path used to call `onAgentRegistered` here for the
@@ -136,7 +150,7 @@ export class LifecycleManager {
136
150
  (state.tmuxSession && agent.tmuxSession === state.tmuxSession) ||
137
151
  (agent.policyName === policy.name && (!state.spawnRequestId || agent.spawnRequestId === state.spawnRequestId))
138
152
  ));
139
- this.markPolicyFailure(policy, state, exited?.lastError ?? "orchestrator session disappeared", exited);
153
+ this.markPolicyFailure(policy, state, exited?.lastError ?? "orchestrator session disappeared", { exited });
140
154
  } else if (state.status === "stopping" && !reported) {
141
155
  const next = updateManagedAgentState(policy.name, {
142
156
  status: "stopped",
@@ -322,7 +336,10 @@ export class LifecycleManager {
322
336
 
323
337
  if (state.status === "starting") {
324
338
  if (state.lastSpawnAt && this.now() - state.lastSpawnAt > START_TIMEOUT_MS) {
325
- this.markPolicyFailure(policy, state, "spawn timed out before registration");
339
+ // #1021 A3 — a never-registered spawn has no exit diagnostics, so isFastFailStorm
340
+ // can never trip and it would back off/retry forever in silence. Flag it so the
341
+ // escalation fires once it has failed repeatedly.
342
+ this.markPolicyFailure(policy, state, "spawn timed out before registration", { registerTimeout: true });
326
343
  }
327
344
  return;
328
345
  }
@@ -410,22 +427,33 @@ export class LifecycleManager {
410
427
  this.emitState(state);
411
428
  }
412
429
 
413
- private markPolicyFailure(policy: SpawnPolicy, state: ManagedAgentState, error: string, exited?: ManagedSessionExitDiagnostics): void {
430
+ private markPolicyFailure(
431
+ policy: SpawnPolicy,
432
+ state: ManagedAgentState,
433
+ error: string,
434
+ opts: { exited?: ManagedSessionExitDiagnostics; registerTimeout?: boolean } = {},
435
+ ): void {
414
436
  if (state.status === "stopping") return;
415
437
  // Read the terminal reason from the adapter-surfaced typed provider state rather
416
438
  // than re-parsing raw provider log text (#745 / #725 §4.4 leak #13).
417
- const agentId = state.agentId || exited?.agentId;
439
+ const agentId = state.agentId || opts.exited?.agentId;
418
440
  const modelUnavailable = agentId ? agentTerminalFailureMessage(getAgent(agentId)) : null;
419
441
  if (modelUnavailable) {
420
442
  this.markFailed(policy, state, modelUnavailable);
421
443
  return;
422
444
  }
423
445
  const failures = state.consecutiveFailures + 1;
424
- if (isFastFailStorm(failures, exited)) {
446
+ if (isFastFailStorm(failures, opts.exited)) {
425
447
  this.markFailed(policy, state, error);
426
448
  return;
427
449
  }
428
450
  this.markBackoff(policy, state, error, failures);
451
+ // #1021 A3 — a register-never spawn never reaches markFailed (no exit diagnostics), so
452
+ // without this its escalation would never fire. Escalate once it has failed repeatedly.
453
+ if (opts.registerTimeout && failures >= MAX_FAST_FAILURES) {
454
+ const next = getManagedAgentState(policy.name);
455
+ if (next) escalateManagedSpawnFailure(policy, next, { phase: "register-timeout", error });
456
+ }
429
457
  }
430
458
 
431
459
  private markBackoff(policy: SpawnPolicy, state: ManagedAgentState, error: string, failures = state.consecutiveFailures + 1): void {
@@ -465,6 +493,9 @@ export class LifecycleManager {
465
493
  lastError: error,
466
494
  });
467
495
  this.emitState(next);
496
+ // #1021 A3 — a failed policy stops retrying (reconcilePolicy returns on `failed`
497
+ // forever). Tell the coordinator/owner instead of leaving only a dashboard state.
498
+ escalateManagedSpawnFailure(policy, next, { phase: "failed", error });
468
499
  }
469
500
 
470
501
  private backoffDelay(policy: SpawnPolicy, state: ManagedAgentState | null): number {
@@ -0,0 +1,87 @@
1
+ // #1032b — cross-subsystem "a first-class lifecycle event already told this spawner about this
2
+ // subject" registry. A spawned child's state reaches its spawner through TWO independent lanes:
3
+ // 1. First-class lifecycle notifications — agent.ready, the lineage report-up (turn-final
4
+ // response), agent.exited, branch.landed/merge.
5
+ // 2. "Task status pulse" digests emitted from task transitions (working / committed-on-branch /
6
+ // landed-on-main / done / …).
7
+ // When lane 1 has just conveyed the state, the lane-2 pulse is redundant noise that wakes the
8
+ // spawner a second time for the same fact. This registry is the shared memory: lane-1 emitters
9
+ // STAMP `(recipient, subjectKey)`; the pulse enqueue/flush path CHECKS it and suppresses.
10
+ //
11
+ // In-memory + short-TTL by design: it only needs to catch the near-simultaneous double-notify
12
+ // (both lanes fire within seconds of the same underlying event). A relay restart wiping it merely
13
+ // means a pulse that would have been suppressed is delivered once — never a lost signal.
14
+
15
+ const DEFAULT_LIFECYCLE_SIGNAL_TTL_MS = 60_000;
16
+
17
+ interface SignalEntry {
18
+ at: number;
19
+ }
20
+
21
+ const signals = new Map<string, SignalEntry>();
22
+
23
+ // Delimiter between recipient and subject key. A pipe can't occur in an agent id or a subject key
24
+ // (subject keys are `task:<n>` / `child:<id>` and ids are dot/dash/colon-only), so the composite is
25
+ // unambiguous. Kept a plain visible character so the source stays grep-friendly.
26
+ const KEY_DELIMITER = " || ";
27
+
28
+ function compositeKey(recipient: string, subjectKey: string): string {
29
+ return `${recipient}${KEY_DELIMITER}${subjectKey}`;
30
+ }
31
+
32
+ // #1038 — the semantic state a child-progress lifecycle signal (a report-up / agent.ready) conveys:
33
+ // "the child is actively working this task". Suppression is state-keyed on this, so a progress
34
+ // signal supersedes only a redundant `working` pulse — a later DISTINCT milestone pulse (committed /
35
+ // landed / done) for the same task still reaches the spawner within the TTL instead of being dropped.
36
+ export const LIFECYCLE_STATE_WORKING = "working";
37
+
38
+ /** Subject key for a task-scoped lifecycle signal, keyed by semantic state so a signal about one
39
+ * state cannot suppress a pulse for a distinct state (#1038). The stamp and the check MUST pass the
40
+ * same `state` (the pulse's transition) to line up; omitting it keeps the legacy subject-only key. */
41
+ export function taskSignalKey(taskId: number | string, state?: string): string {
42
+ return state ? `task:${taskId}::${state}` : `task:${taskId}`;
43
+ }
44
+
45
+ /** Subject key for a child-agent-scoped lifecycle signal (report-up / ready / exit about a child). */
46
+ export function childSignalKey(childId: string): string {
47
+ return `child:${childId}`;
48
+ }
49
+
50
+ /** Record that a first-class lifecycle event just conveyed `subjectKey`'s state to `recipient`. */
51
+ export function stampLifecycleSignal(recipient: string, subjectKey: string, now: number = Date.now()): void {
52
+ const rec = recipient.trim();
53
+ if (!rec || !subjectKey) return;
54
+ signals.set(compositeKey(rec, subjectKey), { at: now });
55
+ pruneExpired(now);
56
+ }
57
+
58
+ /** True when a lifecycle event stamped `subjectKey` for `recipient` within the TTL window. */
59
+ export function hasRecentLifecycleSignal(
60
+ recipient: string,
61
+ subjectKey: string,
62
+ now: number = Date.now(),
63
+ ttlMs: number = DEFAULT_LIFECYCLE_SIGNAL_TTL_MS,
64
+ ): boolean {
65
+ const rec = recipient.trim();
66
+ if (!rec || !subjectKey) return false;
67
+ const key = compositeKey(rec, subjectKey);
68
+ const entry = signals.get(key);
69
+ if (!entry) return false;
70
+ if (now - entry.at > ttlMs) {
71
+ signals.delete(key);
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+
77
+ function pruneExpired(now: number, ttlMs: number = DEFAULT_LIFECYCLE_SIGNAL_TTL_MS): void {
78
+ if (signals.size < 512) return; // amortized cleanup only when the map grows
79
+ for (const [key, entry] of signals) {
80
+ if (now - entry.at > ttlMs) signals.delete(key);
81
+ }
82
+ }
83
+
84
+ /** Reset seam: clear all recorded lifecycle signals (per-test / on a fresh relay DB). */
85
+ export function __resetLifecycleSignals(): void {
86
+ signals.clear();
87
+ }
@@ -30,7 +30,11 @@ export { DAY_MS, REAP_INTERVAL_MS, TERMINAL_WORKSPACE_STATUSES };
30
30
 
31
31
  export const DEFAULT_TIMEOUT_MS = 30_000;
32
32
  export const SCHEDULER_TICK_MS = 10_000;
33
- export const STRANDABLE_STATUSES = new Set<WorkspaceStatus>([...READY_TO_LAND_STATUSES, "conflict"]);
33
+ // #1025 A6 `merge_planned` is included so a merge whose command/result was lost (target
34
+ // host gone, POST dropped) can't wedge forever off every strand path: the in-flight
35
+ // watchdog + pending-command expiry are the fast recovery, and workspace-gc escalation is
36
+ // the last-line backstop. Without it a stuck `merge_planned` was escalation-blind.
37
+ export const STRANDABLE_STATUSES = new Set<WorkspaceStatus>([...READY_TO_LAND_STATUSES, "conflict", "merge_planned"]);
34
38
  export const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
35
39
  export const LANDED_RECONCILE_STATUSES = new Set<WorkspaceStatus>(["merge_planned", "review_requested", "conflict"]);
36
40