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.
- package/docs/openapi.json +1 -1
- package/package.json +3 -3
- package/public/assets/{MessageBubble-BVff6PSi.js → MessageBubble-BaPGFJxq.js} +2 -2
- package/public/assets/{MessageBubble-BVff6PSi.js.map → MessageBubble-BaPGFJxq.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BjrVU5Tg.js → PlanGraphPanel-BXCVfmln.js} +2 -2
- package/public/assets/{PlanGraphPanel-BjrVU5Tg.js.map → PlanGraphPanel-BXCVfmln.js.map} +1 -1
- package/public/assets/{activity-Bk9LEUr4.js → activity-D_shwAZt.js} +2 -2
- package/public/assets/{activity-Bk9LEUr4.js.map → activity-D_shwAZt.js.map} +1 -1
- package/public/assets/{agent-profiles-DSVDoQ_N.js → agent-profiles-BrfVz5IV.js} +2 -2
- package/public/assets/{agent-profiles-DSVDoQ_N.js.map → agent-profiles-BrfVz5IV.js.map} +1 -1
- package/public/assets/agents-B5w-Z9Ic.js +2 -0
- package/public/assets/{agents-8rQuJqii.js.map → agents-B5w-Z9Ic.js.map} +1 -1
- package/public/assets/{analytics-DifGnk7q.js → analytics-Bm3D12UN.js} +2 -2
- package/public/assets/{analytics-DifGnk7q.js.map → analytics-Bm3D12UN.js.map} +1 -1
- package/public/assets/{automation-CMxQAf3l.js → automation-DOTL8BFs.js} +2 -2
- package/public/assets/{automation-CMxQAf3l.js.map → automation-DOTL8BFs.js.map} +1 -1
- package/public/assets/{branch-state-badge-CyC5Qrhc.js → branch-state-badge-BlqeJZ5-.js} +2 -2
- package/public/assets/{branch-state-badge-CyC5Qrhc.js.map → branch-state-badge-BlqeJZ5-.js.map} +1 -1
- package/public/assets/{channels-BLkdt2XR.js → channels-BoiKfK6Q.js} +2 -2
- package/public/assets/{channels-BLkdt2XR.js.map → channels-BoiKfK6Q.js.map} +1 -1
- package/public/assets/chat-PeU3iOaa.js +2 -0
- package/public/assets/{chat-jlXywb9G.js.map → chat-PeU3iOaa.js.map} +1 -1
- package/public/assets/{connectors-C0n12Je8.js → connectors-BvV8Hee2.js} +2 -2
- package/public/assets/{connectors-C0n12Je8.js.map → connectors-BvV8Hee2.js.map} +1 -1
- package/public/assets/{formatted-body-CzehtPtx.js → formatted-body-C1FgzlT1.js} +3 -3
- package/public/assets/{formatted-body-CzehtPtx.js.map → formatted-body-C1FgzlT1.js.map} +1 -1
- package/public/assets/{formatted-body-impl-D3Bnt0Fj.js → formatted-body-impl-BTpU1xH4.js} +2 -2
- package/public/assets/{formatted-body-impl-D3Bnt0Fj.js.map → formatted-body-impl-BTpU1xH4.js.map} +1 -1
- package/public/assets/index-DZEahmyc.js +25 -0
- package/public/assets/index-DZEahmyc.js.map +1 -0
- package/public/assets/{insights-3H6KttBi.js → insights-BqmgSpQm.js} +2 -2
- package/public/assets/{insights-3H6KttBi.js.map → insights-BqmgSpQm.js.map} +1 -1
- package/public/assets/{integrations-v14ehskT.js → integrations-CuT92zQi.js} +2 -2
- package/public/assets/{integrations-v14ehskT.js.map → integrations-CuT92zQi.js.map} +1 -1
- package/public/assets/{maintenance-BANYcBcn.js → maintenance-lM7FeXVC.js} +2 -2
- package/public/assets/{maintenance-BANYcBcn.js.map → maintenance-lM7FeXVC.js.map} +1 -1
- package/public/assets/{managed-agents-DCFMWKqt.js → managed-agents-DaPNv3yY.js} +3 -3
- package/public/assets/{managed-agents-DCFMWKqt.js.map → managed-agents-DaPNv3yY.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CfaR-GRX.js → markdown-preview-impl-Wia3Ho1s.js} +2 -2
- package/public/assets/{markdown-preview-impl-CfaR-GRX.js.map → markdown-preview-impl-Wia3Ho1s.js.map} +1 -1
- package/public/assets/{memory-BmkUEyd5.js → memory-B1v_zD-M.js} +2 -2
- package/public/assets/{memory-BmkUEyd5.js.map → memory-B1v_zD-M.js.map} +1 -1
- package/public/assets/{messages-BQ_fVnSA.js → messages-BNOx3yK6.js} +2 -2
- package/public/assets/{messages-BQ_fVnSA.js.map → messages-BNOx3yK6.js.map} +1 -1
- package/public/assets/{orchestrators-DC7y9vQD.js → orchestrators-DWFy4asl.js} +2 -2
- package/public/assets/{orchestrators-DC7y9vQD.js.map → orchestrators-DWFy4asl.js.map} +1 -1
- package/public/assets/{overview-6lzywFHq.js → overview-C_12cFPM.js} +2 -2
- package/public/assets/{overview-6lzywFHq.js.map → overview-C_12cFPM.js.map} +1 -1
- package/public/assets/{pairs-CRrRIaHI.js → pairs-DIc7gX83.js} +2 -2
- package/public/assets/{pairs-CRrRIaHI.js.map → pairs-DIc7gX83.js.map} +1 -1
- package/public/assets/{projects-CZJW35C7.js → projects-DPjvJUzT.js} +2 -2
- package/public/assets/{projects-CZJW35C7.js.map → projects-DPjvJUzT.js.map} +1 -1
- package/public/assets/{prompt-settings-DvlnMq8H.js → prompt-settings-CtumNgG8.js} +2 -2
- package/public/assets/{prompt-settings-DvlnMq8H.js.map → prompt-settings-CtumNgG8.js.map} +1 -1
- package/public/assets/{registry-3TWg4zbE.js → registry-DzUhgMq9.js} +2 -2
- package/public/assets/{registry-3TWg4zbE.js.map → registry-DzUhgMq9.js.map} +1 -1
- package/public/assets/{security-A2VYKHeI.js → security-BJ3_u0uY.js} +2 -2
- package/public/assets/{security-A2VYKHeI.js.map → security-BJ3_u0uY.js.map} +1 -1
- package/public/assets/{select-_gK5Ojjc.js → select-AWvlctyQ.js} +2 -2
- package/public/assets/{select-_gK5Ojjc.js.map → select-AWvlctyQ.js.map} +1 -1
- package/public/assets/{settings-BZ26ewcu.js → settings-5ofqi-bN.js} +4 -4
- package/public/assets/settings-5ofqi-bN.js.map +1 -0
- package/public/assets/store-D5et8685.js +9 -0
- package/public/assets/store-D5et8685.js.map +1 -0
- package/public/assets/{tasks-DlokSl_P.js → tasks-BEE9bBf7.js} +2 -2
- package/public/assets/{tasks-DlokSl_P.js.map → tasks-BEE9bBf7.js.map} +1 -1
- package/public/assets/{teams-t7M1HMgS.js → teams-C27H_Gwo.js} +2 -2
- package/public/assets/{teams-t7M1HMgS.js.map → teams-C27H_Gwo.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DxgrO-jH.js → terminal-viewer-impl-WD2TboA7.js} +22 -22
- package/public/assets/{terminal-viewer-impl-DxgrO-jH.js.map → terminal-viewer-impl-WD2TboA7.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-V8LR-3Gi.js → work-queue-CtmPK2hK.js} +2 -2
- package/public/assets/{work-queue-V8LR-3Gi.js.map → work-queue-CtmPK2hK.js.map} +1 -1
- package/public/assets/{workspaces-DI317fMk.js → workspaces-B26EKv_H.js} +2 -2
- package/public/assets/{workspaces-DI317fMk.js.map → workspaces-B26EKv_H.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/agent-card-projection.ts +39 -9
- package/src/bus-outbox.ts +30 -0
- package/src/bus.ts +57 -1
- package/src/commands-db.ts +14 -0
- package/src/config-store.ts +2 -0
- package/src/config.ts +4 -0
- package/src/db/connection.ts +3 -1
- package/src/db/delivery.ts +85 -3
- package/src/db/index.ts +1 -0
- package/src/db/maintenance-worker.ts +91 -0
- package/src/db/messages.ts +19 -4
- package/src/db/migrations.ts +7 -0
- package/src/db/retention.ts +130 -0
- package/src/db/schema.ts +5 -0
- package/src/db/stats.ts +32 -8
- package/src/db/task-entity.ts +7 -8
- package/src/event-loop-lag.ts +90 -0
- package/src/lifecycle-manager.ts +11 -12
- package/src/maintenance/jobs.ts +23 -7
- package/src/maintenance/workspaces.ts +28 -47
- package/src/managed-policy-escalation.ts +47 -0
- package/src/mcp-task-tools.ts +4 -5
- package/src/retention-config-store.ts +32 -0
- package/src/retention-config.ts +58 -0
- package/src/routes/commands.ts +8 -245
- package/src/routes/messages.ts +13 -1
- package/src/routes/tasks.ts +3 -5
- package/src/routing-policy-store.ts +11 -1
- package/src/server.ts +2 -0
- package/src/services/issue-lifecycle.ts +23 -8
- package/src/services/send-message.ts +63 -21
- package/src/services/spawn-registration-watchdog.ts +74 -9
- package/src/services/task-entity.ts +54 -2
- package/src/settings/registry.ts +11 -0
- package/src/sse.ts +54 -9
- package/src/workspace-auto-merge.ts +94 -34
- package/src/workspace-command-settle.ts +227 -0
- package/src/workspace-escalation.ts +45 -0
- package/src/workspace-merge-progress.ts +16 -0
- package/src/workspace-orphans.ts +91 -3
- package/public/assets/agents-8rQuJqii.js +0 -2
- package/public/assets/chat-jlXywb9G.js +0 -2
- package/public/assets/index-4g3PbGVN.js +0 -25
- package/public/assets/index-4g3PbGVN.js.map +0 -1
- package/public/assets/settings-BZ26ewcu.js.map +0 -1
- package/public/assets/store-nXGxgz2Q.js +0 -9
- 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 {
|
|
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 =
|
|
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
|
-
:
|
|
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
|
-
|
package/src/db/task-entity.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lifecycle-manager.ts
CHANGED
|
@@ -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 =
|
|
188
|
+
const state = getManagedAgentState(policy.name) ?? {
|
|
193
189
|
policyName: policy.name,
|
|
194
|
-
status: "
|
|
190
|
+
status: "stopped" as const,
|
|
195
191
|
orchestratorId: policy.orchestratorId,
|
|
196
192
|
provider: policy.provider,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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();
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -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
|
-
|
|
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: "
|
|
377
|
-
description: "Delete messages
|
|
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
|
|
382
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
"
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
);
|
|
234
|
-
|
|
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${
|
|
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,
|
|
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
|
+
}
|