agent-relay-server 0.62.3 → 0.63.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 +2 -2
- package/public/assets/{MessageBubble-DRrxfjyT.js → MessageBubble-Cl6ufPHw.js} +2 -2
- package/public/assets/MessageBubble-Cl6ufPHw.js.map +1 -0
- package/public/assets/{PlanGraphPanel-Dp2eYazz.js → PlanGraphPanel-BnfjmkQ8.js} +2 -2
- package/public/assets/{PlanGraphPanel-Dp2eYazz.js.map → PlanGraphPanel-BnfjmkQ8.js.map} +1 -1
- package/public/assets/{activity--t0U2yzq.js → activity-aFxvGLtd.js} +2 -2
- package/public/assets/{activity--t0U2yzq.js.map → activity-aFxvGLtd.js.map} +1 -1
- package/public/assets/{agent-profiles-BMtJYzxn.js → agent-profiles-B4mleUDz.js} +2 -2
- package/public/assets/{agent-profiles-BMtJYzxn.js.map → agent-profiles-B4mleUDz.js.map} +1 -1
- package/public/assets/{agents-Ck9AePlc.js → agents-DW5djToQ.js} +2 -2
- package/public/assets/{agents-Ck9AePlc.js.map → agents-DW5djToQ.js.map} +1 -1
- package/public/assets/{analytics-CzfK387L.js → analytics-CRi6thtg.js} +2 -2
- package/public/assets/{analytics-CzfK387L.js.map → analytics-CRi6thtg.js.map} +1 -1
- package/public/assets/{automation-Cgw8vtE8.js → automation-Do4OpauI.js} +2 -2
- package/public/assets/{automation-Cgw8vtE8.js.map → automation-Do4OpauI.js.map} +1 -1
- package/public/assets/{branch-state-badge-DnEEPtOP.js → branch-state-badge-D3W3w7zz.js} +2 -2
- package/public/assets/{branch-state-badge-DnEEPtOP.js.map → branch-state-badge-D3W3w7zz.js.map} +1 -1
- package/public/assets/{channels-Dc3L2MQs.js → channels-BytdR9NS.js} +2 -2
- package/public/assets/{channels-Dc3L2MQs.js.map → channels-BytdR9NS.js.map} +1 -1
- package/public/assets/{chat-Fc6iJ7sa.js → chat-CvaQ2czL.js} +2 -2
- package/public/assets/{chat-Fc6iJ7sa.js.map → chat-CvaQ2czL.js.map} +1 -1
- package/public/assets/{connectors-Dc2ttGWs.js → connectors-BLQDp8Bc.js} +2 -2
- package/public/assets/{connectors-Dc2ttGWs.js.map → connectors-BLQDp8Bc.js.map} +1 -1
- package/public/assets/{display-DzNvOu1j.js → display-B06I5sqa.js} +3 -3
- package/public/assets/display-B06I5sqa.js.map +1 -0
- package/public/assets/{formatted-body-impl-Bg-gx7rG.js → formatted-body-impl-0Lf8e1Ic.js} +2 -2
- package/public/assets/{formatted-body-impl-Bg-gx7rG.js.map → formatted-body-impl-0Lf8e1Ic.js.map} +1 -1
- package/public/assets/{index-BeDncjN4.js → index-C3su8ezq.js} +6 -6
- package/public/assets/{index-BeDncjN4.js.map → index-C3su8ezq.js.map} +1 -1
- package/public/assets/{insights-c08aW-7n.js → insights-64uExH1E.js} +2 -2
- package/public/assets/{insights-c08aW-7n.js.map → insights-64uExH1E.js.map} +1 -1
- package/public/assets/{integrations-BwpgcqnF.js → integrations-HyC1EaIp.js} +2 -2
- package/public/assets/{integrations-BwpgcqnF.js.map → integrations-HyC1EaIp.js.map} +1 -1
- package/public/assets/{maintenance-CTKP0kWB.js → maintenance-EVBpKH07.js} +2 -2
- package/public/assets/{maintenance-CTKP0kWB.js.map → maintenance-EVBpKH07.js.map} +1 -1
- package/public/assets/{managed-agents-BofCxo8d.js → managed-agents-BLZPxGuw.js} +2 -2
- package/public/assets/{managed-agents-BofCxo8d.js.map → managed-agents-BLZPxGuw.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CQ-6BIb8.js → markdown-preview-impl-Bg6rCWYN.js} +2 -2
- package/public/assets/{markdown-preview-impl-CQ-6BIb8.js.map → markdown-preview-impl-Bg6rCWYN.js.map} +1 -1
- package/public/assets/{memory-DvfXjMoi.js → memory-Bjr8bgjj.js} +2 -2
- package/public/assets/{memory-DvfXjMoi.js.map → memory-Bjr8bgjj.js.map} +1 -1
- package/public/assets/{messages-C9VsPzZ_.js → messages-CRYw1F4z.js} +2 -2
- package/public/assets/{messages-C9VsPzZ_.js.map → messages-CRYw1F4z.js.map} +1 -1
- package/public/assets/{orchestrators-MffVueeE.js → orchestrators-D-co1vdp.js} +2 -2
- package/public/assets/{orchestrators-MffVueeE.js.map → orchestrators-D-co1vdp.js.map} +1 -1
- package/public/assets/{overview-BSVajefM.js → overview-Ddy5czri.js} +2 -2
- package/public/assets/{overview-BSVajefM.js.map → overview-Ddy5czri.js.map} +1 -1
- package/public/assets/{pairs-B5711-Cs.js → pairs-CFVM8uHL.js} +2 -2
- package/public/assets/{pairs-B5711-Cs.js.map → pairs-CFVM8uHL.js.map} +1 -1
- package/public/assets/{security-DkUwK9Vj.js → security-Df4_76bh.js} +2 -2
- package/public/assets/{security-DkUwK9Vj.js.map → security-Df4_76bh.js.map} +1 -1
- package/public/assets/{select-DrM2RRiT.js → select-Cq3BBnL9.js} +2 -2
- package/public/assets/{select-DrM2RRiT.js.map → select-Cq3BBnL9.js.map} +1 -1
- package/public/assets/{settings-DdKCb-vy.js → settings-DOLEYXZL.js} +2 -2
- package/public/assets/{settings-DdKCb-vy.js.map → settings-DOLEYXZL.js.map} +1 -1
- package/public/assets/{store-iTiheuTD.js → store-C1E8jmNE.js} +4 -4
- package/public/assets/store-C1E8jmNE.js.map +1 -0
- package/public/assets/{tasks-e1y1PFfO.js → tasks-BcsmPi9_.js} +2 -2
- package/public/assets/{tasks-e1y1PFfO.js.map → tasks-BcsmPi9_.js.map} +1 -1
- package/public/assets/{teams-BUcxoGC_.js → teams-BQrV3eKI.js} +2 -2
- package/public/assets/{teams-BUcxoGC_.js.map → teams-BQrV3eKI.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CmZjQOB2.js → terminal-viewer-impl-Dle04qpH.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CmZjQOB2.js.map → terminal-viewer-impl-Dle04qpH.js.map} +1 -1
- package/public/assets/{work-queue-CXBQx43n.js → work-queue-BLhTLg4o.js} +2 -2
- package/public/assets/{work-queue-CXBQx43n.js.map → work-queue-BLhTLg4o.js.map} +1 -1
- package/public/assets/{workspaces-D2EfZh9a.js → workspaces-4NCNTmRQ.js} +3 -3
- package/public/assets/{workspaces-D2EfZh9a.js.map → workspaces-4NCNTmRQ.js.map} +1 -1
- package/public/index.html +3 -3
- package/src/agent-ref.ts +8 -6
- package/src/automations/constants.ts +12 -0
- package/src/automations/cron.ts +129 -0
- package/src/automations/crud.ts +84 -0
- package/src/automations/dispatch.ts +275 -0
- package/src/automations/index.ts +5 -0
- package/src/automations/reconcile.ts +245 -0
- package/src/automations/storage.ts +181 -0
- package/src/automations/types.ts +16 -0
- package/src/automations/validation.ts +140 -0
- package/src/automations.ts +1 -1045
- package/src/db/activity.ts +1 -1
- package/src/db/agent-predicates.ts +9 -0
- package/src/db/agent-search.ts +2 -2
- package/src/db/agents.ts +13 -41
- package/src/db/artifacts.ts +8 -8
- package/src/db/channel-identity.ts +38 -0
- package/src/db/channels.ts +37 -61
- package/src/db/continuation-archives.ts +4 -4
- package/src/db/delivery.ts +7 -12
- package/src/db/inbox.ts +1 -1
- package/src/db/integrations.ts +4 -4
- package/src/db/issue-lifecycle.ts +6 -6
- package/src/db/mappers.ts +1 -2
- package/src/db/merge-lease.ts +3 -3
- package/src/db/message-claims.ts +16 -0
- package/src/db/message-queries.ts +8 -0
- package/src/db/message-reads.ts +26 -9
- package/src/db/messages.ts +17 -41
- package/src/db/notification-subscriptions.ts +2 -2
- package/src/db/orchestrators.ts +4 -4
- package/src/db/pair-maintenance.ts +9 -0
- package/src/db/pairs.ts +10 -18
- package/src/db/plans.ts +1 -1
- package/src/db/policy-runtime.ts +14 -0
- package/src/db/project-entries.ts +1 -1
- package/src/db/projects.ts +2 -2
- package/src/db/shared.ts +7 -0
- package/src/db/stats.ts +2 -2
- package/src/db/task-core.ts +18 -0
- package/src/db/task-maintenance.ts +24 -0
- package/src/db/tasks.ts +5 -17
- package/src/db/teams.ts +3 -3
- package/src/db/workspaces.ts +6 -6
- package/src/issue-tracker/github.ts +3 -3
- package/src/maintenance/constants.ts +35 -0
- package/src/maintenance/events.ts +32 -0
- package/src/maintenance/index.ts +7 -0
- package/src/maintenance/jobs.ts +340 -0
- package/src/maintenance/orphaned-sessions.ts +86 -0
- package/src/maintenance/scheduler.ts +231 -0
- package/src/maintenance/teams.ts +65 -0
- package/src/maintenance/types.ts +31 -0
- package/src/maintenance/workspaces.ts +251 -0
- package/src/maintenance.ts +1 -1099
- package/src/mcp/identity.ts +131 -0
- package/src/mcp/index.ts +208 -0
- package/src/mcp/tools-messaging.ts +459 -0
- package/src/mcp/tools-spawn.ts +145 -0
- package/src/mcp/tools-workspace.ts +227 -0
- package/src/mcp/types.ts +24 -0
- package/src/mcp/validation.ts +104 -0
- package/src/mcp.ts +1 -1319
- package/src/notification-router.ts +2 -2
- package/src/notification-rules.ts +2 -2
- package/src/project-ingest.ts +3 -3
- package/src/project-instructions.ts +1 -1
- package/src/provider-config-store.ts +3 -3
- package/src/provider-state.ts +1 -1
- package/src/reviewer-pipeline.ts +1 -1
- package/src/self-resume.ts +3 -7
- package/src/services/agent-action.ts +2 -2
- package/src/services/issue-activity.ts +0 -4
- package/src/services/issue-associations.ts +2 -2
- package/src/services/issue-lifecycle.ts +7 -7
- package/src/services/managed-running.ts +1 -1
- package/src/services/parity-harness.ts +3 -3
- package/src/services/plan-graph.ts +3 -3
- package/src/services/send-message.ts +16 -12
- package/src/services/shutdown-agent.ts +2 -2
- package/src/services/team-templates.ts +2 -2
- package/src/services/team.ts +2 -2
- package/src/settings/modules.ts +1 -1
- package/src/settings/registry.ts +3 -3
- package/src/spawn-command.ts +1 -1
- package/src/sse.ts +0 -4
- package/src/workspace-actions.ts +1 -1
- package/src/workspace-probe.ts +3 -7
- package/public/assets/MessageBubble-DRrxfjyT.js.map +0 -1
- package/public/assets/display-DzNvOu1j.js.map +0 -1
- package/public/assets/store-iTiheuTD.js.map +0 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { errMessage } from "agent-relay-sdk";
|
|
3
|
+
import { createActivityEvent, getDb } from "../db";
|
|
4
|
+
import { resetRateLimitResumeTrackerForTests } from "../services/rate-limit-resume";
|
|
5
|
+
import { resetTransientTrackerForTests } from "../services/transient-agent-reaper";
|
|
6
|
+
import { DEFAULT_TIMEOUT_MS, SCHEDULER_TICK_MS } from "./constants";
|
|
7
|
+
import { LEGACY_REAPER_JOB_IDS, definitions } from "./jobs";
|
|
8
|
+
import { resetOrphanSessionTrackerForTests } from "./orphaned-sessions";
|
|
9
|
+
import type { MaintenanceJob, MaintenanceJobRun } from "../types";
|
|
10
|
+
import type { MaintenanceJobDefinition, MaintenanceJobRow } from "./types";
|
|
11
|
+
|
|
12
|
+
let timer: Timer | null = null;
|
|
13
|
+
|
|
14
|
+
export function resetOrphanTrackerForTests(): void {
|
|
15
|
+
resetOrphanSessionTrackerForTests();
|
|
16
|
+
resetTransientTrackerForTests();
|
|
17
|
+
resetRateLimitResumeTrackerForTests();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function startMaintenanceScheduler(): void {
|
|
21
|
+
ensureMaintenanceJobs();
|
|
22
|
+
void runDueMaintenanceJobs();
|
|
23
|
+
if (timer) return;
|
|
24
|
+
timer = setInterval(() => void runDueMaintenanceJobs(), SCHEDULER_TICK_MS);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function listMaintenanceJobs(): MaintenanceJob[] {
|
|
28
|
+
ensureMaintenanceJobs();
|
|
29
|
+
const rows = getDb().query("SELECT * FROM maintenance_jobs ORDER BY title COLLATE NOCASE ASC").all() as MaintenanceJobRow[];
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
return rows.map((row) => rowToJob(row, now));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runMaintenanceJobNow(id: string): Promise<MaintenanceJobRun> {
|
|
35
|
+
ensureMaintenanceJobs();
|
|
36
|
+
const definition = definitions.find((job) => job.id === id);
|
|
37
|
+
if (!definition) throw new Error("maintenance job not found");
|
|
38
|
+
return runJob(definition, { force: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function runLegacyMaintenanceReaper(): Promise<Record<string, unknown>> {
|
|
42
|
+
const runs = await runMaintenanceJobsNow(LEGACY_REAPER_JOB_IDS);
|
|
43
|
+
const result = aggregateRunResults(runs);
|
|
44
|
+
auditMaintenanceReaperRun(result);
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function runMaintenanceJobsNow(ids: string[]): Promise<MaintenanceJobRun[]> {
|
|
49
|
+
const runs: MaintenanceJobRun[] = [];
|
|
50
|
+
for (const id of ids) runs.push(await runMaintenanceJobNow(id));
|
|
51
|
+
return runs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function runDueMaintenanceJobs(): Promise<void> {
|
|
55
|
+
ensureMaintenanceJobs();
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const rows = getDb().query(`
|
|
58
|
+
SELECT id FROM maintenance_jobs
|
|
59
|
+
WHERE enabled = 1
|
|
60
|
+
AND next_run_at IS NOT NULL
|
|
61
|
+
AND next_run_at <= ?
|
|
62
|
+
AND (lease_until IS NULL OR lease_until <= ?)
|
|
63
|
+
ORDER BY next_run_at ASC
|
|
64
|
+
`).all(now, now) as Array<{ id: string }>;
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
const definition = definitions.find((job) => job.id === row.id);
|
|
67
|
+
if (definition) void runJob(definition).catch((error) => {
|
|
68
|
+
console.warn(`maintenance job ${definition.id} failed: ${errMessage(error)}`);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ensureMaintenanceJobs(): void {
|
|
74
|
+
const db = getDb();
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const stmt = db.query(`
|
|
77
|
+
INSERT INTO maintenance_jobs (id, title, description, interval_ms, timeout_ms, enabled, run_on_start, next_run_at, updated_at)
|
|
78
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)
|
|
79
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
80
|
+
title = excluded.title,
|
|
81
|
+
description = excluded.description,
|
|
82
|
+
interval_ms = excluded.interval_ms,
|
|
83
|
+
timeout_ms = excluded.timeout_ms,
|
|
84
|
+
run_on_start = excluded.run_on_start,
|
|
85
|
+
next_run_at = COALESCE(maintenance_jobs.next_run_at, excluded.next_run_at),
|
|
86
|
+
updated_at = excluded.updated_at
|
|
87
|
+
`);
|
|
88
|
+
for (const definition of definitions) {
|
|
89
|
+
stmt.run(
|
|
90
|
+
definition.id,
|
|
91
|
+
definition.title,
|
|
92
|
+
definition.description,
|
|
93
|
+
definition.intervalMs,
|
|
94
|
+
definition.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
95
|
+
definition.runOnStart ? 1 : 0,
|
|
96
|
+
definition.runOnStart ? now : now + definition.intervalMs,
|
|
97
|
+
now,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function runJob(definition: MaintenanceJobDefinition, options: { force?: boolean } = {}): Promise<MaintenanceJobRun> {
|
|
103
|
+
const db = getDb();
|
|
104
|
+
const startedAt = Date.now();
|
|
105
|
+
const owner = `${process.pid}-${randomUUID()}`;
|
|
106
|
+
const timeoutMs = definition.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
107
|
+
const leaseUntil = startedAt + timeoutMs + 5_000;
|
|
108
|
+
const claim = db.query(`
|
|
109
|
+
UPDATE maintenance_jobs
|
|
110
|
+
SET lease_owner = ?, lease_until = ?, last_status = 'running', updated_at = ?
|
|
111
|
+
WHERE id = ?
|
|
112
|
+
AND enabled = 1
|
|
113
|
+
AND (lease_until IS NULL OR lease_until <= ?)
|
|
114
|
+
${options.force ? "" : "AND (next_run_at IS NULL OR next_run_at <= ?)"}
|
|
115
|
+
`).run(owner, leaseUntil, startedAt, definition.id, startedAt, ...(options.force ? [] : [startedAt]));
|
|
116
|
+
if (claim.changes === 0) {
|
|
117
|
+
const state = listMaintenanceJobs().find((job) => job.id === definition.id);
|
|
118
|
+
return { id: definition.id, status: "skipped", startedAt, finishedAt: Date.now(), durationMs: 0, result: { reason: "not due or already running", state } };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const result = await withTimeout(Promise.resolve(definition.handler()), timeoutMs);
|
|
123
|
+
const finishedAt = Date.now();
|
|
124
|
+
const durationMs = finishedAt - startedAt;
|
|
125
|
+
db.query(`
|
|
126
|
+
UPDATE maintenance_jobs
|
|
127
|
+
SET last_run_at = ?, next_run_at = ?, last_duration_ms = ?, last_status = 'succeeded',
|
|
128
|
+
last_error = NULL, last_result = ?, consecutive_failures = 0,
|
|
129
|
+
lease_owner = NULL, lease_until = NULL, updated_at = ?
|
|
130
|
+
WHERE id = ?
|
|
131
|
+
`).run(finishedAt, finishedAt + definition.intervalMs, durationMs, JSON.stringify(result), finishedAt, definition.id);
|
|
132
|
+
return { id: definition.id, status: "succeeded", startedAt, finishedAt, durationMs, result };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
const finishedAt = Date.now();
|
|
135
|
+
const durationMs = finishedAt - startedAt;
|
|
136
|
+
const message = errMessage(error);
|
|
137
|
+
db.query(`
|
|
138
|
+
UPDATE maintenance_jobs
|
|
139
|
+
SET last_run_at = ?, next_run_at = ?, last_duration_ms = ?, last_status = 'failed',
|
|
140
|
+
last_error = ?, consecutive_failures = consecutive_failures + 1,
|
|
141
|
+
lease_owner = NULL, lease_until = NULL, updated_at = ?
|
|
142
|
+
WHERE id = ?
|
|
143
|
+
`).run(finishedAt, finishedAt + definition.intervalMs, durationMs, message, finishedAt, definition.id);
|
|
144
|
+
return { id: definition.id, status: "failed", startedAt, finishedAt, durationMs, error: message };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
149
|
+
let timeout: Timer | undefined;
|
|
150
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
151
|
+
timeout = setTimeout(() => reject(new Error(`maintenance job timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
152
|
+
});
|
|
153
|
+
try {
|
|
154
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
155
|
+
} finally {
|
|
156
|
+
if (timeout) clearTimeout(timeout);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function rowToJob(row: MaintenanceJobRow, now: number): MaintenanceJob {
|
|
161
|
+
const leaseUntil = row.lease_until ?? undefined;
|
|
162
|
+
return {
|
|
163
|
+
id: row.id,
|
|
164
|
+
title: row.title,
|
|
165
|
+
description: row.description ?? undefined,
|
|
166
|
+
intervalMs: row.interval_ms,
|
|
167
|
+
timeoutMs: row.timeout_ms,
|
|
168
|
+
enabled: row.enabled === 1,
|
|
169
|
+
runOnStart: row.run_on_start === 1,
|
|
170
|
+
lastRunAt: row.last_run_at ?? undefined,
|
|
171
|
+
nextRunAt: row.next_run_at ?? undefined,
|
|
172
|
+
lastDurationMs: row.last_duration_ms ?? undefined,
|
|
173
|
+
lastStatus: leaseUntil && leaseUntil > now ? "running" : row.last_status as MaintenanceJob["lastStatus"],
|
|
174
|
+
lastError: row.last_error ?? undefined,
|
|
175
|
+
lastResult: parseJsonObject(row.last_result),
|
|
176
|
+
consecutiveFailures: row.consecutive_failures,
|
|
177
|
+
running: Boolean(leaseUntil && leaseUntil > now),
|
|
178
|
+
leaseUntil,
|
|
179
|
+
updatedAt: row.updated_at,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function aggregateRunResults(runs: MaintenanceJobRun[]): Record<string, unknown> {
|
|
184
|
+
const aggregate: Record<string, unknown> = { ok: true, runs };
|
|
185
|
+
for (const run of runs) {
|
|
186
|
+
if (!run.result) continue;
|
|
187
|
+
for (const [key, value] of Object.entries(run.result)) {
|
|
188
|
+
if (Array.isArray(value)) {
|
|
189
|
+
const current = Array.isArray(aggregate[key]) ? aggregate[key] as unknown[] : [];
|
|
190
|
+
aggregate[key] = [...current, ...value];
|
|
191
|
+
} else if (typeof value === "number") {
|
|
192
|
+
aggregate[key] = Number(aggregate[key] ?? 0) + value;
|
|
193
|
+
} else if (value !== undefined) {
|
|
194
|
+
aggregate[key] = value;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return aggregate;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function auditMaintenanceReaperRun(result: Record<string, unknown>): void {
|
|
202
|
+
const reapedAgentIds = Array.isArray(result.reapedAgentIds) ? result.reapedAgentIds : [];
|
|
203
|
+
const reapedOrchestratorIds = Array.isArray(result.reapedOrchestratorIds) ? result.reapedOrchestratorIds : [];
|
|
204
|
+
const releasedMessageIds = Array.isArray(result.releasedMessageIds) ? result.releasedMessageIds : [];
|
|
205
|
+
const releasedTaskIds = Array.isArray(result.releasedTaskIds) ? result.releasedTaskIds : [];
|
|
206
|
+
const releasedOrphanedTaskIds = Array.isArray(result.releasedOrphanedTaskIds) ? result.releasedOrphanedTaskIds : [];
|
|
207
|
+
const expiredQueuedMessageIds = Array.isArray(result.expiredQueuedMessageIds) ? result.expiredQueuedMessageIds : [];
|
|
208
|
+
const expiredCommandIds = Array.isArray(result.expiredCommandIds) ? result.expiredCommandIds : [];
|
|
209
|
+
const expiredArtifactIds = Array.isArray(result.expiredArtifactIds) ? result.expiredArtifactIds : [];
|
|
210
|
+
try {
|
|
211
|
+
createActivityEvent({
|
|
212
|
+
clientId: "server-maintenance-reaper-" + Date.now(),
|
|
213
|
+
kind: "state",
|
|
214
|
+
title: "Maintenance reaper run",
|
|
215
|
+
body: `${reapedAgentIds.length} stale agent(s), ${reapedOrchestratorIds.length} stale orchestrator(s), ${releasedMessageIds.length} message claim(s), ${releasedTaskIds.length} task claim(s), ${releasedOrphanedTaskIds.length} orphaned task(s), ${expiredQueuedMessageIds.length} queued message(s), ${expiredCommandIds.length} command(s), ${expiredArtifactIds.length} artifact(s)`,
|
|
216
|
+
icon: "ti-broom",
|
|
217
|
+
view: "activity",
|
|
218
|
+
metadata: { source: "server", result },
|
|
219
|
+
});
|
|
220
|
+
} catch {}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseJsonObject(raw: string | null): Record<string, unknown> | undefined {
|
|
224
|
+
if (!raw) return undefined;
|
|
225
|
+
try {
|
|
226
|
+
const value = JSON.parse(raw);
|
|
227
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
228
|
+
} catch {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearTeamZeroMembersSince,
|
|
3
|
+
countLiveTeamMembers,
|
|
4
|
+
createActivityEvent,
|
|
5
|
+
listActiveTeams,
|
|
6
|
+
setTeamZeroMembersSince,
|
|
7
|
+
} from "../db";
|
|
8
|
+
import { STALE_TTL_MS } from "../config";
|
|
9
|
+
import { dissolveTeamById } from "../services/team";
|
|
10
|
+
import { teamReapGraceMs } from "./constants";
|
|
11
|
+
|
|
12
|
+
export function reapZeroMemberTeams(): Record<string, unknown> {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
const cutoff = now - STALE_TTL_MS;
|
|
15
|
+
const grace = teamReapGraceMs();
|
|
16
|
+
const reapedTeamIds: string[] = [];
|
|
17
|
+
const trackedTeamIds: string[] = [];
|
|
18
|
+
const clearedTeamIds: string[] = [];
|
|
19
|
+
let activeTeams = 0;
|
|
20
|
+
let zeroMemberTeams = 0;
|
|
21
|
+
|
|
22
|
+
for (const team of listActiveTeams()) {
|
|
23
|
+
activeTeams++;
|
|
24
|
+
const live = countLiveTeamMembers(team.id, cutoff);
|
|
25
|
+
if (live > 0) {
|
|
26
|
+
if (team.zeroMembersSince !== undefined) {
|
|
27
|
+
clearTeamZeroMembersSince(team.id);
|
|
28
|
+
clearedTeamIds.push(team.id);
|
|
29
|
+
}
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
zeroMemberTeams++;
|
|
34
|
+
if (team.zeroMembersSince === undefined) {
|
|
35
|
+
setTeamZeroMembersSince(team.id, now);
|
|
36
|
+
trackedTeamIds.push(team.id);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (now - team.zeroMembersSince < grace) continue;
|
|
40
|
+
|
|
41
|
+
const outcomeSummary = `auto-reaped: zero live members for ${grace}ms`;
|
|
42
|
+
// Leave outcomeStatus null (unknown): an abandoned team is NOT a failed one.
|
|
43
|
+
const dissolved = dissolveTeamById(team.id, { outcomeSummary });
|
|
44
|
+
createActivityEvent({
|
|
45
|
+
clientId: `team-reap-${team.id}-${now}`,
|
|
46
|
+
kind: "state",
|
|
47
|
+
title: "Team auto-reaped",
|
|
48
|
+
body: outcomeSummary,
|
|
49
|
+
meta: team.id,
|
|
50
|
+
icon: "ti-users-minus",
|
|
51
|
+
view: "teams",
|
|
52
|
+
agentId: dissolved.coordinatorId,
|
|
53
|
+
metadata: {
|
|
54
|
+
source: "server",
|
|
55
|
+
maintenanceJobId: "team-reap",
|
|
56
|
+
teamId: team.id,
|
|
57
|
+
zeroMembersSince: team.zeroMembersSince,
|
|
58
|
+
graceMs: grace,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
reapedTeamIds.push(team.id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { activeTeams, zeroMemberTeams, trackedTeamIds, clearedTeamIds, reapedTeamIds, graceMs: grace };
|
|
65
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { MaintenanceJob } from "../types";
|
|
2
|
+
|
|
3
|
+
export interface MaintenanceJobDefinition {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
intervalMs: number;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
runOnStart?: boolean;
|
|
10
|
+
handler: () => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MaintenanceJobRow {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
description: string | null;
|
|
17
|
+
interval_ms: number;
|
|
18
|
+
timeout_ms: number;
|
|
19
|
+
enabled: number;
|
|
20
|
+
run_on_start: number;
|
|
21
|
+
last_run_at: number | null;
|
|
22
|
+
next_run_at: number | null;
|
|
23
|
+
last_duration_ms: number | null;
|
|
24
|
+
last_status: string;
|
|
25
|
+
last_error: string | null;
|
|
26
|
+
last_result: string | null;
|
|
27
|
+
consecutive_failures: number;
|
|
28
|
+
lease_owner: string | null;
|
|
29
|
+
lease_until: number | null;
|
|
30
|
+
updated_at: number;
|
|
31
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { createCommand } from "../commands-db";
|
|
2
|
+
import {
|
|
3
|
+
createActivityEvent,
|
|
4
|
+
deleteWorkspace,
|
|
5
|
+
getAgent,
|
|
6
|
+
getRepoSteward,
|
|
7
|
+
getWorkspace,
|
|
8
|
+
listOrchestrators,
|
|
9
|
+
listWorkspaces,
|
|
10
|
+
patchWorkspaceMetadata,
|
|
11
|
+
reelectRepoSteward,
|
|
12
|
+
releaseExpiredMergeLeases,
|
|
13
|
+
updateWorkspaceStatus,
|
|
14
|
+
} from "../db";
|
|
15
|
+
import type { WorkspaceMergePreview, WorkspaceStatus } from "../types";
|
|
16
|
+
import { reconcileLandedWorkspace } from "../branch-landed";
|
|
17
|
+
import { getStewardConfig } from "../config-store";
|
|
18
|
+
import { preparePrCompletionScan, reconcileLandedPr } from "../workspace-pr-completion";
|
|
19
|
+
import { applyBranchFreshness, fetchHostMergePreview, workspacePathWithinBase } from "../workspace-probe";
|
|
20
|
+
import { wakeRepoSteward } from "../workspace-auto-merge";
|
|
21
|
+
import {
|
|
22
|
+
CONFLICT_SCAN_STATUSES,
|
|
23
|
+
DAY_MS,
|
|
24
|
+
LANDED_RECONCILE_STATUSES,
|
|
25
|
+
STRANDABLE_STATUSES,
|
|
26
|
+
TERMINAL_WORKSPACE_STATUSES,
|
|
27
|
+
WORKSPACE_RETENTION_MS,
|
|
28
|
+
WORKSPACE_REVIEW_TTL_MS,
|
|
29
|
+
stewardEscalationMs,
|
|
30
|
+
stewardFallbackTarget,
|
|
31
|
+
} from "./constants";
|
|
32
|
+
import { emitCommand, notifyTarget } from "./events";
|
|
33
|
+
|
|
34
|
+
export async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
35
|
+
const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
|
|
36
|
+
if (!orchestrators.length) return { scanned: 0, skipped: "no online orchestrators" };
|
|
37
|
+
|
|
38
|
+
const candidates = listWorkspaces().filter(
|
|
39
|
+
(ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && CONFLICT_SCAN_STATUSES.has(ws.status),
|
|
40
|
+
);
|
|
41
|
+
const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [], prRecycled: string[] = [];
|
|
42
|
+
const reviewerArmed: string[] = [], reviewerChangesRequested: string[] = [], relayPrMerged: string[] = [], relayPrWaiting: string[] = [];
|
|
43
|
+
|
|
44
|
+
for (const ws of candidates) {
|
|
45
|
+
const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
|
|
46
|
+
if (!orch?.apiUrl) continue;
|
|
47
|
+
const preview = await fetchHostMergePreview(orch.apiUrl, ws, { checkPr: LANDED_RECONCILE_STATUSES.has(ws.status) });
|
|
48
|
+
if (!preview || (preview as { available?: false }).available === false) continue;
|
|
49
|
+
const p = preview as WorkspaceMergePreview;
|
|
50
|
+
if (p.error || p.missing) continue;
|
|
51
|
+
|
|
52
|
+
const meta = ws.metadata as Record<string, unknown>;
|
|
53
|
+
applyBranchFreshness(ws, p);
|
|
54
|
+
|
|
55
|
+
const landed = p.landed === true || p.prMerged === true;
|
|
56
|
+
if (landed && LANDED_RECONCILE_STATUSES.has(ws.status)) {
|
|
57
|
+
const outcome = reconcileLandedPr(ws, p);
|
|
58
|
+
if (outcome.kind === "recycle") { emitCommand(outcome.command); prRecycled.push(ws.id); continue; }
|
|
59
|
+
if (outcome.kind === "wait") { relayPrWaiting.push(`${ws.id}:${outcome.reason}`); continue; }
|
|
60
|
+
reconcileLandedWorkspace(ws, p);
|
|
61
|
+
merged.push(ws.id);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const prAction = preparePrCompletionScan(ws, p, orch.agentId);
|
|
66
|
+
if (prAction.armCommand) { emitCommand(prAction.armCommand); reviewerArmed.push(ws.id); continue; }
|
|
67
|
+
if (prAction.notifiedOwner) { reviewerChangesRequested.push(ws.id); continue; }
|
|
68
|
+
if (prAction.refreshCommand) { emitCommand(prAction.refreshCommand); continue; }
|
|
69
|
+
if (prAction.mergeCommand) { emitCommand(prAction.mergeCommand); relayPrMerged.push(ws.id); continue; }
|
|
70
|
+
if (prAction.waitReason) relayPrWaiting.push(`${ws.id}:${prAction.waitReason}`);
|
|
71
|
+
if (prAction.stop) continue;
|
|
72
|
+
|
|
73
|
+
if (p.conflict === undefined) continue;
|
|
74
|
+
|
|
75
|
+
if (p.conflict === true && ws.status !== "conflict") {
|
|
76
|
+
updateWorkspaceStatus(ws.id, "conflict", {
|
|
77
|
+
autoConflict: true,
|
|
78
|
+
preConflictStatus: ws.status,
|
|
79
|
+
conflictAhead: p.ahead,
|
|
80
|
+
conflictBehind: p.behind,
|
|
81
|
+
conflictBaseRef: p.baseRef,
|
|
82
|
+
conflictDetectedAt: Date.now(),
|
|
83
|
+
});
|
|
84
|
+
flagged.push(ws.id);
|
|
85
|
+
createActivityEvent({
|
|
86
|
+
clientId: "server-workspace-" + ws.id + "-conflict-" + Date.now(),
|
|
87
|
+
kind: "state",
|
|
88
|
+
title: "Merge conflict detected",
|
|
89
|
+
body: `${ws.branch ?? ws.id} can no longer merge cleanly into ${p.baseRef ?? "base"}`,
|
|
90
|
+
meta: ws.branch ?? ws.id,
|
|
91
|
+
icon: "ti-alert-triangle",
|
|
92
|
+
view: "orchestrators",
|
|
93
|
+
metadata: { source: "server", maintenanceJobId: "workspace-conflict-scan", workspaceId: ws.id, ahead: p.ahead, behind: p.behind },
|
|
94
|
+
});
|
|
95
|
+
if (getStewardConfig().enabled) {
|
|
96
|
+
const woke = wakeRepoSteward(getWorkspace(ws.id) ?? ws, "conflict");
|
|
97
|
+
if (woke) notifiedStewards.push(woke);
|
|
98
|
+
} else if (ws.stewardAgentId) {
|
|
99
|
+
try {
|
|
100
|
+
notifyTarget(
|
|
101
|
+
"workspace.conflict",
|
|
102
|
+
{ workspaceId: ws.id, repoRoot: ws.repoRoot },
|
|
103
|
+
ws.stewardAgentId,
|
|
104
|
+
"Workspace merge conflict",
|
|
105
|
+
`Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
|
|
106
|
+
{ kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
|
|
107
|
+
);
|
|
108
|
+
notifiedStewards.push(ws.stewardAgentId);
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
111
|
+
} else if (p.conflict === false && ws.status === "conflict" && meta.autoConflict === true) {
|
|
112
|
+
const prior = typeof meta.preConflictStatus === "string" && meta.preConflictStatus !== "conflict"
|
|
113
|
+
? meta.preConflictStatus as WorkspaceStatus
|
|
114
|
+
: "active";
|
|
115
|
+
updateWorkspaceStatus(ws.id, prior, { autoConflict: false, conflictClearedAt: Date.now() });
|
|
116
|
+
cleared.push(ws.id);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { scanned: candidates.length, flagged, cleared, merged, prRecycled, notifiedStewards, reviewerArmed, reviewerChangesRequested, relayPrMerged, relayPrWaiting };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function workspaceGC(): Promise<Record<string, unknown>> {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const cutoff = now - WORKSPACE_RETENTION_MS;
|
|
126
|
+
const reviewCutoff = now - WORKSPACE_REVIEW_TTL_MS;
|
|
127
|
+
const releasedLeaseRepos = releaseExpiredMergeLeases(now);
|
|
128
|
+
|
|
129
|
+
const all = listWorkspaces();
|
|
130
|
+
const terminalIds: string[] = [];
|
|
131
|
+
for (const ws of all) {
|
|
132
|
+
if (TERMINAL_WORKSPACE_STATUSES.has(ws.status) && ws.updatedAt < cutoff) {
|
|
133
|
+
deleteWorkspace(ws.id);
|
|
134
|
+
terminalIds.push(ws.id);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const escalatedIds: string[] = [];
|
|
139
|
+
const reassignedIds: string[] = [];
|
|
140
|
+
const escalationTargets: string[] = [];
|
|
141
|
+
const escalationMs = stewardEscalationMs();
|
|
142
|
+
const fallbackTarget = stewardFallbackTarget();
|
|
143
|
+
for (const ws of all) {
|
|
144
|
+
if (!STRANDABLE_STATUSES.has(ws.status) || ws.mode !== "isolated" || !ws.worktreePath) continue;
|
|
145
|
+
reelectRepoSteward(ws.repoRoot);
|
|
146
|
+
const fresh = getWorkspace(ws.id);
|
|
147
|
+
if (!fresh || !STRANDABLE_STATUSES.has(fresh.status)) continue;
|
|
148
|
+
const meta = fresh.metadata as Record<string, unknown>;
|
|
149
|
+
const steward = fresh.stewardAgentId;
|
|
150
|
+
const stewardOnline = Boolean(steward && getAgent(steward) && getAgent(steward)!.status !== "offline");
|
|
151
|
+
const strandedAt = typeof meta.strandedAt === "number" ? meta.strandedAt : undefined;
|
|
152
|
+
|
|
153
|
+
if (stewardOnline) {
|
|
154
|
+
if (strandedAt !== undefined && meta.strandedNotifiedSteward !== steward) {
|
|
155
|
+
const sent = notifyTarget(
|
|
156
|
+
"workspace.steward_reassigned",
|
|
157
|
+
{ workspaceId: fresh.id, repoRoot: fresh.repoRoot },
|
|
158
|
+
steward!,
|
|
159
|
+
"Workspace stewardship reassigned",
|
|
160
|
+
`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"}.`,
|
|
161
|
+
{ kind: "workspace.steward-reassigned", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status },
|
|
162
|
+
);
|
|
163
|
+
if (sent) reassignedIds.push(fresh.id);
|
|
164
|
+
}
|
|
165
|
+
patchWorkspaceMetadata(fresh.id, { strandedAt: undefined, escalatedAt: undefined, strandedNotifiedSteward: steward });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (strandedAt === undefined) { patchWorkspaceMetadata(fresh.id, { strandedAt: now }); continue; }
|
|
170
|
+
if (now - strandedAt < escalationMs || meta.escalatedAt) continue;
|
|
171
|
+
const sent = notifyTarget(
|
|
172
|
+
"workspace.stranded_escalation",
|
|
173
|
+
{ workspaceId: fresh.id, repoRoot: fresh.repoRoot },
|
|
174
|
+
fallbackTarget,
|
|
175
|
+
"Stranded workspace needs an owner",
|
|
176
|
+
`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. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
|
|
177
|
+
{ kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt },
|
|
178
|
+
);
|
|
179
|
+
if (sent) escalationTargets.push(sent);
|
|
180
|
+
patchWorkspaceMetadata(fresh.id, { escalatedAt: now });
|
|
181
|
+
escalatedIds.push(fresh.id);
|
|
182
|
+
createActivityEvent({
|
|
183
|
+
clientId: `workspace-gc-escalate-${fresh.id}-${now}`,
|
|
184
|
+
kind: "state",
|
|
185
|
+
title: "Workspace escalated",
|
|
186
|
+
body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${fallbackTarget ? ` to ${fallbackTarget}` : " (no fallback configured)"}`,
|
|
187
|
+
meta: fresh.branch ?? fresh.id,
|
|
188
|
+
icon: "ti-alert-octagon",
|
|
189
|
+
view: "orchestrators",
|
|
190
|
+
metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, fallback: fallbackTarget || null },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const abandonedIds: string[] = [];
|
|
195
|
+
const notifiedStewards: string[] = [];
|
|
196
|
+
for (const ws of all) {
|
|
197
|
+
if (ws.status === "review_requested" && ws.updatedAt < reviewCutoff) {
|
|
198
|
+
updateWorkspaceStatus(ws.id, "abandoned", { autoAbandoned: true, abandonedReason: "review_requested TTL exceeded", abandonedAt: now });
|
|
199
|
+
abandonedIds.push(ws.id);
|
|
200
|
+
const target = ws.stewardAgentId ?? fallbackTarget;
|
|
201
|
+
const sent = notifyTarget(
|
|
202
|
+
"workspace.auto_abandoned",
|
|
203
|
+
{ workspaceId: ws.id, repoRoot: ws.repoRoot },
|
|
204
|
+
target,
|
|
205
|
+
"Workspace auto-abandoned",
|
|
206
|
+
`Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d without steward action. Run workspace cleanup to reclaim the worktree.`,
|
|
207
|
+
{ kind: "workspace.auto-abandoned", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch },
|
|
208
|
+
);
|
|
209
|
+
if (sent) notifiedStewards.push(sent);
|
|
210
|
+
createActivityEvent({
|
|
211
|
+
clientId: `workspace-gc-abandon-${ws.id}-${now}`,
|
|
212
|
+
kind: "state",
|
|
213
|
+
title: "Workspace auto-abandoned",
|
|
214
|
+
body: `${ws.branch ?? ws.id} in ${ws.repoRoot} - review_requested for ${Math.round((now - ws.updatedAt) / DAY_MS)}d`,
|
|
215
|
+
meta: ws.branch ?? ws.id,
|
|
216
|
+
icon: "ti-clock-x",
|
|
217
|
+
view: "orchestrators",
|
|
218
|
+
metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: ws.id },
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.agentId);
|
|
224
|
+
const reposWithWorkspaces = new Set(
|
|
225
|
+
all.filter((ws) => ws.mode === "isolated" && Boolean(ws.worktreePath)).map((ws) => ws.repoRoot),
|
|
226
|
+
);
|
|
227
|
+
const pruneCommands: string[] = [];
|
|
228
|
+
for (const repoRoot of reposWithWorkspaces) {
|
|
229
|
+
const orch = orchestrators.find((candidate) => workspacePathWithinBase(repoRoot, candidate.baseDir));
|
|
230
|
+
if (!orch?.agentId) continue;
|
|
231
|
+
const command = createCommand({
|
|
232
|
+
type: "workspace.prune",
|
|
233
|
+
source: "system",
|
|
234
|
+
target: orch.agentId,
|
|
235
|
+
params: { repoRoot, requestedBy: "workspace-gc", requestedAt: now },
|
|
236
|
+
});
|
|
237
|
+
emitCommand(command);
|
|
238
|
+
pruneCommands.push(command.id);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
prunedTerminal: terminalIds,
|
|
243
|
+
autoAbandoned: abandonedIds,
|
|
244
|
+
notifiedStewards,
|
|
245
|
+
pruneCommands,
|
|
246
|
+
releasedLeaseRepos,
|
|
247
|
+
escalated: escalatedIds,
|
|
248
|
+
reassigned: reassignedIds,
|
|
249
|
+
escalationTargets,
|
|
250
|
+
};
|
|
251
|
+
}
|