agent-relay-server 0.119.7 → 0.119.10
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-Azda2GIf.js → MessageBubble-U5sX3SNZ.js} +2 -2
- package/public/assets/{MessageBubble-Azda2GIf.js.map → MessageBubble-U5sX3SNZ.js.map} +1 -1
- package/public/assets/PlanGraphPanel-Bi2gvXeE.js +2 -0
- package/public/assets/PlanGraphPanel-Bi2gvXeE.js.map +1 -0
- package/public/assets/{activity-B0hz4XPy.js → activity-Cwkz51pG.js} +2 -2
- package/public/assets/{activity-B0hz4XPy.js.map → activity-Cwkz51pG.js.map} +1 -1
- package/public/assets/{agent-profiles-BF0oSOTq.js → agent-profiles-BgddVqRp.js} +2 -2
- package/public/assets/{agent-profiles-BF0oSOTq.js.map → agent-profiles-BgddVqRp.js.map} +1 -1
- package/public/assets/{agents-CW4--j3Q.js → agents-CiGYBw0h.js} +2 -2
- package/public/assets/{agents-CW4--j3Q.js.map → agents-CiGYBw0h.js.map} +1 -1
- package/public/assets/{automation-S6_bQ2t-.js → automation-BBJB4RaE.js} +2 -2
- package/public/assets/{automation-S6_bQ2t-.js.map → automation-BBJB4RaE.js.map} +1 -1
- package/public/assets/{chat-hoFhhvgy.js → chat-DS578gZy.js} +2 -2
- package/public/assets/{chat-hoFhhvgy.js.map → chat-DS578gZy.js.map} +1 -1
- package/public/assets/{formatted-body-BHm145gP.js → formatted-body-DYUVqWym.js} +3 -3
- package/public/assets/{formatted-body-BHm145gP.js.map → formatted-body-DYUVqWym.js.map} +1 -1
- package/public/assets/{formatted-body-impl-CvE31j9F.js → formatted-body-impl-DdcyxVO0.js} +2 -2
- package/public/assets/{formatted-body-impl-CvE31j9F.js.map → formatted-body-impl-DdcyxVO0.js.map} +1 -1
- package/public/assets/{index-TamtghSr.js → index-CqcAqQKW.js} +6 -6
- package/public/assets/{index-TamtghSr.js.map → index-CqcAqQKW.js.map} +1 -1
- package/public/assets/{insights-Ci9ftVfl.js → insights-BunHbsTA.js} +2 -2
- package/public/assets/{insights-Ci9ftVfl.js.map → insights-BunHbsTA.js.map} +1 -1
- package/public/assets/{maintenance-bVhNbr33.js → maintenance-cICBVcpJ.js} +2 -2
- package/public/assets/{maintenance-bVhNbr33.js.map → maintenance-cICBVcpJ.js.map} +1 -1
- package/public/assets/{managed-agents-D5HO4dhI.js → managed-agents-BK9_Gi-M.js} +2 -2
- package/public/assets/{managed-agents-D5HO4dhI.js.map → managed-agents-BK9_Gi-M.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DO-p83dz.js → markdown-preview-impl-EZfcTHG9.js} +2 -2
- package/public/assets/{markdown-preview-impl-DO-p83dz.js.map → markdown-preview-impl-EZfcTHG9.js.map} +1 -1
- package/public/assets/{memory-D72o8umQ.js → memory-Vi-8HvK3.js} +2 -2
- package/public/assets/{memory-D72o8umQ.js.map → memory-Vi-8HvK3.js.map} +1 -1
- package/public/assets/{messages-5L_Bc_9w.js → messages-DScIiBMI.js} +2 -2
- package/public/assets/{messages-5L_Bc_9w.js.map → messages-DScIiBMI.js.map} +1 -1
- package/public/assets/{orchestrators-CnfgkQpl.js → orchestrators-DLogldXx.js} +2 -2
- package/public/assets/{orchestrators-CnfgkQpl.js.map → orchestrators-DLogldXx.js.map} +1 -1
- package/public/assets/{overview-Bu39NIyR.js → overview-B_RQAQR8.js} +2 -2
- package/public/assets/{overview-Bu39NIyR.js.map → overview-B_RQAQR8.js.map} +1 -1
- package/public/assets/{pairs-WuamP5W5.js → pairs-CSiPQ7E2.js} +2 -2
- package/public/assets/{pairs-WuamP5W5.js.map → pairs-CSiPQ7E2.js.map} +1 -1
- package/public/assets/{prompt-settings-BCrQreU6.js → prompt-settings-DsDi_Xpd.js} +2 -2
- package/public/assets/{prompt-settings-BCrQreU6.js.map → prompt-settings-DsDi_Xpd.js.map} +1 -1
- package/public/assets/{registry-kHKn0xwg.js → registry-DHFUWIdb.js} +2 -2
- package/public/assets/{registry-kHKn0xwg.js.map → registry-DHFUWIdb.js.map} +1 -1
- package/public/assets/{security-Dk5t3r_K.js → security-D2nHkJH6.js} +2 -2
- package/public/assets/{security-Dk5t3r_K.js.map → security-D2nHkJH6.js.map} +1 -1
- package/public/assets/{select-BdsZQfU2.js → select-BjNcommA.js} +2 -2
- package/public/assets/{select-BdsZQfU2.js.map → select-BjNcommA.js.map} +1 -1
- package/public/assets/{settings-EpjzQHnZ.js → settings-DiUEq54n.js} +2 -2
- package/public/assets/{settings-EpjzQHnZ.js.map → settings-DiUEq54n.js.map} +1 -1
- package/public/assets/{tasks-CedMQHbg.js → tasks-Dx8GVQiR.js} +2 -2
- package/public/assets/{tasks-CedMQHbg.js.map → tasks-Dx8GVQiR.js.map} +1 -1
- package/public/assets/{teams-BlHBo5Qt.js → teams-DQtf7bha.js} +2 -2
- package/public/assets/{teams-BlHBo5Qt.js.map → teams-DQtf7bha.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-gGqYpsjq.js → terminal-viewer-impl-D5rhXV4u.js} +2 -2
- package/public/assets/{terminal-viewer-impl-gGqYpsjq.js.map → terminal-viewer-impl-D5rhXV4u.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-D7opMqtU.js → work-queue-BfWNDp7U.js} +2 -2
- package/public/assets/{work-queue-D7opMqtU.js.map → work-queue-BfWNDp7U.js.map} +1 -1
- package/public/index.html +2 -2
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +13 -0
- package/src/db/orchestrators.ts +1 -1
- package/src/db/provider-quotas.ts +2 -34
- package/src/db/schema.ts +3 -5
- package/src/db/work-evidence.ts +38 -0
- package/src/db/workspaces.ts +7 -3
- package/src/lifecycle-manager.ts +2 -4
- package/src/maintenance/jobs.ts +10 -1
- package/src/maintenance/workspaces.ts +71 -13
- package/src/routes/commands.ts +20 -4
- package/src/routes/workspaces.ts +4 -7
- package/src/services/transient-agent-reaper.ts +54 -9
- package/src/workspace-actions.ts +3 -4
- package/src/workspace-auto-merge.ts +95 -13
- package/src/workspace-host.ts +55 -0
- package/src/workspace-merge.ts +3 -4
- package/src/workspace-orphans.ts +78 -55
- package/src/workspace-probe.ts +2 -3
- package/public/assets/PlanGraphPanel-B9TN_KQr.js +0 -2
- package/public/assets/PlanGraphPanel-B9TN_KQr.js.map +0 -1
package/src/routes/workspaces.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { AUTO_MERGE_POLICIES } from "../workspace-merge";
|
|
|
15
15
|
import { workspaceActiveClaim } from "../workspace-claim";
|
|
16
16
|
import { resolveCoordinatorAgentId } from "../steward-identity";
|
|
17
17
|
import { relayToken } from "../config";
|
|
18
|
+
import { workspaceOwnerOrchestrator } from "../workspace-host";
|
|
18
19
|
|
|
19
20
|
export const getWorkspaces: Handler = (req) => {
|
|
20
21
|
try {
|
|
@@ -44,9 +45,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
|
|
|
44
45
|
if (workspace.mode !== "isolated" || !workspace.worktreePath) {
|
|
45
46
|
return json({ available: false, reason: "no isolated worktree" });
|
|
46
47
|
}
|
|
47
|
-
const orch = listOrchestrators()
|
|
48
|
-
(candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
|
|
49
|
-
);
|
|
48
|
+
const orch = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { requireApiUrl: true });
|
|
50
49
|
if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
|
|
51
50
|
const query = new URLSearchParams({ path: workspace.worktreePath });
|
|
52
51
|
if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
|
|
@@ -287,9 +286,7 @@ export const postWorkspaceOrphanReclaim: Handler = async (req) => {
|
|
|
287
286
|
|
|
288
287
|
async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ state: WorkspaceGitState } | { unavailable: string }> {
|
|
289
288
|
if (workspace.mode !== "isolated" || !workspace.worktreePath) return { unavailable: "no isolated worktree" };
|
|
290
|
-
const orch = listOrchestrators()
|
|
291
|
-
(candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
|
|
292
|
-
);
|
|
289
|
+
const orch = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { requireApiUrl: true });
|
|
293
290
|
if (!orch?.apiUrl) return { unavailable: "owning orchestrator offline" };
|
|
294
291
|
const query = new URLSearchParams({ path: workspace.worktreePath });
|
|
295
292
|
if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
|
|
@@ -342,7 +339,7 @@ export const getWorkspaceDiagnostics: Handler = async (_req, params) => {
|
|
|
342
339
|
// #640 — surface the branch agent's coordinator (owner's `spawnedBy` lineage) so a steward
|
|
343
340
|
// inspecting the workspace knows who to escalate to, not just `repoRoot`.
|
|
344
341
|
const coordinatorAgentId = resolveCoordinatorAgentId(workspace.ownerAgentId);
|
|
345
|
-
const orch = listOrchestrators()
|
|
342
|
+
const orch = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { onlineOnly: false });
|
|
346
343
|
const orchOnline = Boolean(orch) && orch!.status === "online";
|
|
347
344
|
const fetched = await fetchWorkspaceGitState(workspace);
|
|
348
345
|
const gitState = "state" in fetched ? fetched.state : undefined;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
2
2
|
import { createCommand } from "../commands-db";
|
|
3
3
|
import { emitRelayEvent } from "../events";
|
|
4
|
-
import { createActivityEvent, getAgent, listAgents, listOrchestrators, listWorkspaces, recordTerminalEvent } from "../db";
|
|
4
|
+
import { createActivityEvent, getAgent, hasArtifactCreatedBy, listAgents, listFinalResponseCaptureMessages, listOrchestrators, listWorkspaces, recordTerminalEvent } from "../db";
|
|
5
5
|
import { routeNotification } from "../notification-router";
|
|
6
6
|
import { isProviderBlocked } from "../provider-state";
|
|
7
7
|
import { relayToken } from "../config";
|
|
8
|
-
import {
|
|
8
|
+
import { workspaceOwnerOrchestrator } from "../workspace-host";
|
|
9
9
|
import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
|
|
10
10
|
import { finalizeEphemeralTasksOnReap } from "./task-entity";
|
|
11
11
|
import type { AgentCard, Command, WorkspaceGitState, WorkspaceRecord } from "../types";
|
|
@@ -72,9 +72,7 @@ export function resetTransientTrackerForTests(): void {
|
|
|
72
72
|
// "unknown" and preserve the safe #614 hold-pending bias: only 0 is fast-reaped.
|
|
73
73
|
async function defaultCommitsAhead(ws: WorkspaceRecord): Promise<number | null> {
|
|
74
74
|
if (!ws.branch || !ws.worktreePath) return null;
|
|
75
|
-
const orch = listOrchestrators()
|
|
76
|
-
(candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(ws.sourceCwd, candidate.baseDir),
|
|
77
|
-
);
|
|
75
|
+
const orch = workspaceOwnerOrchestrator(ws, listOrchestrators(), { requireApiUrl: true });
|
|
78
76
|
if (!orch?.apiUrl) return null;
|
|
79
77
|
const query = new URLSearchParams({ path: ws.worktreePath });
|
|
80
78
|
if (ws.baseRef) query.set("baseRef", ws.baseRef);
|
|
@@ -130,14 +128,61 @@ function workspaceProducedWork(ws: WorkspaceRecord | undefined): boolean {
|
|
|
130
128
|
return typeof ws.metadata.mergedAt === "number";
|
|
131
129
|
}
|
|
132
130
|
|
|
131
|
+
const ACK_ONLY_RESPONSES = new Set([
|
|
132
|
+
"ok",
|
|
133
|
+
"okay",
|
|
134
|
+
"done",
|
|
135
|
+
"finished",
|
|
136
|
+
"complete",
|
|
137
|
+
"completed",
|
|
138
|
+
"ack",
|
|
139
|
+
"acknowledged",
|
|
140
|
+
"thanks",
|
|
141
|
+
"thank you",
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const REPORT_SIGNAL_RE = /\b(ship|hold|pass|fail|finding|findings|issue|issues|blocker|blockers|reviewed|verified|tested|tests?|risk|regression|artifact|summary|audited|inspected)\b/i;
|
|
145
|
+
const GENERIC_COMPLETION_RE = /\b(?:i(?:'m| am)|we(?:'re| are))?\s*(?:done|finished|complete|completed)\b|\b(?:task|work)\s+(?:is\s+)?(?:done|finished|complete|completed)\b|\b(?:all set|nothing else|nothing more|no further action|no more to do|do not need to do anything else|don't need to do anything else)\b/i;
|
|
146
|
+
|
|
147
|
+
function genericCompletionOnly(normalized: string): boolean {
|
|
148
|
+
return GENERIC_COMPLETION_RE.test(normalized) && !REPORT_SIGNAL_RE.test(normalized);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function substantiveFinalReport(body: string): boolean {
|
|
152
|
+
const normalized = body.trim().replace(/\s+/g, " ");
|
|
153
|
+
if (!normalized) return false;
|
|
154
|
+
const ackKey = normalized.toLowerCase().replace(/[.!?]+$/g, "");
|
|
155
|
+
if (ACK_ONLY_RESPONSES.has(ackKey)) return false;
|
|
156
|
+
if (genericCompletionOnly(normalized)) return false;
|
|
157
|
+
const words = normalized.match(/[A-Za-z0-9][A-Za-z0-9'_-]*/g) ?? [];
|
|
158
|
+
if (normalized.length >= 80 && words.length >= 6) return true;
|
|
159
|
+
if (normalized.length >= 40 && words.length >= 8) return true;
|
|
160
|
+
return normalized.length >= 30
|
|
161
|
+
&& words.length >= 5
|
|
162
|
+
&& REPORT_SIGNAL_RE.test(normalized);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function reportOnlyWorkEvidence(agent: AgentCard): { producedWork: boolean; reason?: string } {
|
|
166
|
+
const since = typeof agent.createdAt === "number" && Number.isFinite(agent.createdAt) ? agent.createdAt : 0;
|
|
167
|
+
if (hasArtifactCreatedBy(agent.id, since)) return { producedWork: true, reason: "artifact-upload" };
|
|
168
|
+
const reports = listFinalResponseCaptureMessages(agent.id, since);
|
|
169
|
+
const hasSubstantiveReport = reports.some((message) => {
|
|
170
|
+
if (agent.spawnedBy && message.to !== agent.spawnedBy) return false;
|
|
171
|
+
return substantiveFinalReport(message.body);
|
|
172
|
+
});
|
|
173
|
+
return hasSubstantiveReport ? { producedWork: true, reason: "final-response-report" } : { producedWork: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
133
176
|
async function transientLandComplete(agent: AgentCard): Promise<{ complete: boolean; producedWork: boolean; workspaceId?: string; status?: string; reason?: string }> {
|
|
134
177
|
const agentId = agent.id;
|
|
178
|
+
const reportEvidence = reportOnlyWorkEvidence(agent);
|
|
135
179
|
const isolated = listWorkspaces({ ownerAgentId: agentId }).filter((ws) => ws.mode === "isolated");
|
|
136
|
-
if (!isolated.length) return { complete: true, producedWork:
|
|
180
|
+
if (!isolated.length) return { complete: true, producedWork: reportEvidence.producedWork, reason: reportEvidence.reason ?? "no-isolated-workspace" };
|
|
137
181
|
const live = isolated.find((ws) => !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
|
|
138
182
|
if (!live) {
|
|
139
183
|
const latest = isolated[0];
|
|
140
|
-
|
|
184
|
+
const producedWork = workspaceProducedWork(latest);
|
|
185
|
+
return { complete: true, producedWork: producedWork || reportEvidence.producedWork, workspaceId: latest?.id, status: latest?.status, reason: producedWork ? "terminal-workspace" : reportEvidence.reason ?? "terminal-workspace" };
|
|
141
186
|
}
|
|
142
187
|
const mergedAt = typeof live.metadata.mergedAt === "number" ? live.metadata.mergedAt : undefined;
|
|
143
188
|
const landReportedAt = typeof live.metadata.landReportedAt === "number" ? live.metadata.landReportedAt : undefined;
|
|
@@ -149,7 +194,7 @@ async function transientLandComplete(agent: AgentCard): Promise<{ complete: bool
|
|
|
149
194
|
// branch is provably empty (count === 0); null (unknown/git error) falls through to
|
|
150
195
|
// the safe #614 hold-pending bias below.
|
|
151
196
|
if (live.status === "active" && await _commitsAheadFn(live) === 0) {
|
|
152
|
-
return { complete: true, producedWork:
|
|
197
|
+
return { complete: true, producedWork: reportEvidence.producedWork, workspaceId: live.id, status: live.status, reason: reportEvidence.reason ?? "empty-branch" };
|
|
153
198
|
}
|
|
154
199
|
return { complete: false, producedWork: false, workspaceId: live.id, status: live.status, reason: "land-pending" };
|
|
155
200
|
}
|
|
@@ -226,7 +271,7 @@ export async function reapTransientAgents(): Promise<Record<string, unknown>> {
|
|
|
226
271
|
workspaceId: land.workspaceId,
|
|
227
272
|
workspaceStatus: land.status,
|
|
228
273
|
activityTitle: "Transient agent complete",
|
|
229
|
-
activityBody: "Agent idle grace elapsed and
|
|
274
|
+
activityBody: "Agent idle grace elapsed and transient work is complete; shutdown requested",
|
|
230
275
|
landReason: land.reason,
|
|
231
276
|
now,
|
|
232
277
|
});
|
package/src/workspace-actions.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// the Command and the caller emits via its own `emitCommand` (kept module-local).
|
|
11
11
|
|
|
12
12
|
import { createCommand } from "./commands-db";
|
|
13
|
-
import {
|
|
13
|
+
import { workspaceOwnerOrchestrator } from "./workspace-host";
|
|
14
14
|
import {
|
|
15
15
|
createActivityEvent,
|
|
16
16
|
getWorkspace,
|
|
@@ -301,7 +301,7 @@ export function buildWorkspaceCleanupCommand(
|
|
|
301
301
|
error: `workspace ${workspace.id} owner is still online; pass force:true to clean it up intentionally`,
|
|
302
302
|
};
|
|
303
303
|
}
|
|
304
|
-
const owner =
|
|
304
|
+
const owner = workspaceOwnerOrchestrator(workspace);
|
|
305
305
|
if (!owner) return { ok: false, status: 409, error: "no online orchestrator owns this workspace path; retry when the host is online or use DELETE /api/workspaces/:id to purge the record" };
|
|
306
306
|
const command = createCommand({
|
|
307
307
|
type: "workspace.cleanup",
|
|
@@ -332,8 +332,7 @@ function buildWorkspaceDepsRefreshCommand(
|
|
|
332
332
|
requestedBy: string,
|
|
333
333
|
checkOnly: boolean,
|
|
334
334
|
): { ok: true; command: Command } | { ok: false; status: number; error: string } {
|
|
335
|
-
const
|
|
336
|
-
const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
|
|
335
|
+
const owner = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { onlineOnly: false });
|
|
337
336
|
if (!owner) return { ok: false, status: 409, error: "no online orchestrator owns this workspace path" };
|
|
338
337
|
const command = createCommand({
|
|
339
338
|
type: "workspace.deps-refresh",
|
|
@@ -154,6 +154,8 @@ export const MAX_NO_PROGRESS_MERGE_ATTEMPTS = 3;
|
|
|
154
154
|
|
|
155
155
|
// Don't re-escalate a backed-off lane more than once per this window.
|
|
156
156
|
const NO_PROGRESS_ESCALATION_COOLDOWN_MS = 30 * 60 * 1000;
|
|
157
|
+
export const AUTO_MERGE_ERROR_BACKOFF_MS = 3_000;
|
|
158
|
+
export const AUTO_MERGE_WATCHDOG_STALE_MS = 5 * 60 * 1000;
|
|
157
159
|
|
|
158
160
|
/** The recorded count of consecutive no-progress merge attempts for a workspace (#638). */
|
|
159
161
|
export function mergeNoProgressCount(metadata: Record<string, unknown>): number {
|
|
@@ -377,30 +379,105 @@ async function landCandidates(
|
|
|
377
379
|
// in flight for the repo just flags a rerun, so concurrent readies don't fan out
|
|
378
380
|
// redundant host probes. Errors are swallowed: the runOnStart backstop is the
|
|
379
381
|
// self-healer for anything a dropped run strands.
|
|
380
|
-
|
|
382
|
+
type RepoMergeRunState = {
|
|
383
|
+
rerun: boolean;
|
|
384
|
+
lastProgressAt: number;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const repoMergeRunning = new Map<string, RepoMergeRunState>();
|
|
388
|
+
const repoMergeLastCompletedAt = new Map<string, number>();
|
|
389
|
+
let autoMergeWatchdogStartedAt = Date.now();
|
|
390
|
+
|
|
391
|
+
function errMessage(error: unknown): string {
|
|
392
|
+
if (error instanceof Error) return `${error.name}: ${error.message}`;
|
|
393
|
+
return String(error);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function landableWorkspaces(repoRoot?: string): WorkspaceRecord[] {
|
|
397
|
+
return listWorkspaces(repoRoot ? { repoRoot } : undefined).filter(
|
|
398
|
+
(ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && READY_TO_LAND_STATUSES.has(ws.status),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function noteAutoMergeCycleCompleted(repoRoot: string): void {
|
|
403
|
+
const completedAt = Date.now();
|
|
404
|
+
repoMergeLastCompletedAt.set(repoRoot, completedAt);
|
|
405
|
+
const state = repoMergeRunning.get(repoRoot);
|
|
406
|
+
if (state) state.lastProgressAt = completedAt;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function sleep(ms: number): Promise<void> {
|
|
410
|
+
await new Promise<void>((resolve) => {
|
|
411
|
+
const timer = setTimeout(resolve, ms);
|
|
412
|
+
timer.unref?.();
|
|
413
|
+
});
|
|
414
|
+
}
|
|
381
415
|
|
|
382
416
|
export function scheduleRepoMerge(repoRoot: string | undefined): void {
|
|
383
417
|
if (!repoRoot) return;
|
|
384
418
|
if (!workspaceAutoMergeEnabled()) return;
|
|
385
419
|
const existing = repoMergeRunning.get(repoRoot);
|
|
386
420
|
if (existing) { existing.rerun = true; return; }
|
|
387
|
-
const state = { rerun: false };
|
|
421
|
+
const state: RepoMergeRunState = { rerun: false, lastProgressAt: Date.now() };
|
|
388
422
|
repoMergeRunning.set(repoRoot, state);
|
|
389
423
|
void (async () => {
|
|
424
|
+
let retriedAfterError = false;
|
|
390
425
|
try {
|
|
391
426
|
do {
|
|
392
427
|
state.rerun = false;
|
|
393
|
-
|
|
428
|
+
state.lastProgressAt = Date.now();
|
|
429
|
+
try {
|
|
430
|
+
await autoMergeForRepo(repoRoot);
|
|
431
|
+
} catch (err) {
|
|
432
|
+
console.warn(`[workspace-auto-merge] Repo auto-merge cycle failed for ${repoRoot}: ${errMessage(err)}`);
|
|
433
|
+
if (!retriedAfterError) {
|
|
434
|
+
retriedAfterError = true;
|
|
435
|
+
await sleep(AUTO_MERGE_ERROR_BACKOFF_MS);
|
|
436
|
+
state.rerun = true;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
394
439
|
} while (state.rerun);
|
|
395
440
|
} catch {
|
|
396
441
|
// Swallow — the periodic backstop (runOnStart + interval) is the self-healer.
|
|
397
442
|
} finally {
|
|
398
|
-
repoMergeRunning.delete(repoRoot);
|
|
443
|
+
if (repoMergeRunning.get(repoRoot) === state) repoMergeRunning.delete(repoRoot);
|
|
399
444
|
}
|
|
400
445
|
})();
|
|
401
446
|
}
|
|
402
447
|
|
|
403
|
-
export function resetRepoMergeSchedulerForTests(): void {
|
|
448
|
+
export function resetRepoMergeSchedulerForTests(): void {
|
|
449
|
+
repoMergeRunning.clear();
|
|
450
|
+
repoMergeLastCompletedAt.clear();
|
|
451
|
+
autoMergeWatchdogStartedAt = Date.now();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function runAutoMergeWatchdog(
|
|
455
|
+
opts: { now?: number; staleMs?: number; log?: (message: string) => void } = {},
|
|
456
|
+
): Record<string, unknown> {
|
|
457
|
+
const now = opts.now ?? Date.now();
|
|
458
|
+
const staleMs = opts.staleMs ?? AUTO_MERGE_WATCHDOG_STALE_MS;
|
|
459
|
+
const ready = landableWorkspaces();
|
|
460
|
+
const repoRoots = [...new Set(ready.map((ws) => ws.repoRoot).filter(Boolean))];
|
|
461
|
+
const repoStaleForMs = Object.fromEntries(repoRoots.map((repoRoot) => {
|
|
462
|
+
const state = repoMergeRunning.get(repoRoot);
|
|
463
|
+
const lastProgressAt = state?.lastProgressAt ?? repoMergeLastCompletedAt.get(repoRoot) ?? autoMergeWatchdogStartedAt;
|
|
464
|
+
return [repoRoot, now - lastProgressAt];
|
|
465
|
+
}));
|
|
466
|
+
const staleForMs = Object.values(repoStaleForMs).reduce((max, value) => Math.max(max, value), 0);
|
|
467
|
+
if (!ready.length) return { readyCount: 0, staleForMs, repoStaleForMs, restartedRepoRoots: [] };
|
|
468
|
+
|
|
469
|
+
const restartedRepoRoots = repoRoots.filter((repoRoot) => repoStaleForMs[repoRoot]! >= staleMs);
|
|
470
|
+
if (!restartedRepoRoots.length) return { readyCount: ready.length, staleForMs, repoStaleForMs, restartedRepoRoots: [] };
|
|
471
|
+
|
|
472
|
+
const log = opts.log ?? console.warn;
|
|
473
|
+
log(`[workspace-auto-merge] Watchdog: no auto-merge cycle completed for ${staleForMs}ms while ${ready.length} ready workspace(s) exist; restarting ${restartedRepoRoots.length} repo merge loop(s)`);
|
|
474
|
+
for (const repoRoot of restartedRepoRoots) {
|
|
475
|
+
const existing = repoMergeRunning.get(repoRoot);
|
|
476
|
+
if (existing) existing.rerun = true;
|
|
477
|
+
else scheduleRepoMerge(repoRoot);
|
|
478
|
+
}
|
|
479
|
+
return { readyCount: ready.length, staleForMs, repoStaleForMs, restartedRepoRoots };
|
|
480
|
+
}
|
|
404
481
|
|
|
405
482
|
// Drain the landable rows for ONE repo (the event-driven fast path). Scoped to the
|
|
406
483
|
// repo whose ready/settle event fired so it doesn't re-probe every other repo's worktrees.
|
|
@@ -408,10 +485,12 @@ async function autoMergeForRepo(repoRoot: string): Promise<LandSweepResult> {
|
|
|
408
485
|
if (!workspaceAutoMergeEnabled()) return emptyLandSweep();
|
|
409
486
|
const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
|
|
410
487
|
if (!orchestrators.length) return emptyLandSweep();
|
|
411
|
-
const candidates =
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
488
|
+
const candidates = landableWorkspaces(repoRoot);
|
|
489
|
+
try {
|
|
490
|
+
return await landCandidates(candidates, orchestrators);
|
|
491
|
+
} finally {
|
|
492
|
+
noteAutoMergeCycleCompleted(repoRoot);
|
|
493
|
+
}
|
|
415
494
|
}
|
|
416
495
|
|
|
417
496
|
// Self-healing backstop (#237): the periodic resync (workspace-auto-merge maintenance
|
|
@@ -424,10 +503,13 @@ export async function autoMergeCleanFastForwards(): Promise<Record<string, unkno
|
|
|
424
503
|
const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.apiUrl);
|
|
425
504
|
if (!orchestrators.length) return { scanned: 0, skipped: "no online orchestrators" };
|
|
426
505
|
|
|
427
|
-
const candidates =
|
|
428
|
-
|
|
429
|
-
);
|
|
506
|
+
const candidates = landableWorkspaces();
|
|
507
|
+
const repoRoots = [...new Set(candidates.map((ws) => ws.repoRoot).filter(Boolean))];
|
|
430
508
|
// Spread to a plain Record for the maintenance-job result contract (the named
|
|
431
509
|
// LandSweepResult interface has no index signature).
|
|
432
|
-
|
|
510
|
+
try {
|
|
511
|
+
return { ...(await landCandidates(candidates, orchestrators)) };
|
|
512
|
+
} finally {
|
|
513
|
+
for (const repoRoot of repoRoots) noteAutoMergeCycleCompleted(repoRoot);
|
|
514
|
+
}
|
|
433
515
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Command, Orchestrator, WorkspaceRecord } from "./types";
|
|
2
|
+
import { getOrchestrator, listOrchestrators } from "./db";
|
|
3
|
+
import { isPathWithinBase } from "./utils";
|
|
4
|
+
|
|
5
|
+
interface WorkspaceHost {
|
|
6
|
+
id: string;
|
|
7
|
+
status?: string;
|
|
8
|
+
apiUrl?: string;
|
|
9
|
+
baseDir?: string;
|
|
10
|
+
agentId?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface WorkspaceHostOptions {
|
|
14
|
+
onlineOnly?: boolean;
|
|
15
|
+
requireApiUrl?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hostUsable<T extends WorkspaceHost>(orch: T | undefined, opts: WorkspaceHostOptions): orch is T {
|
|
19
|
+
if (!orch) return false;
|
|
20
|
+
if (opts.onlineOnly !== false && orch.status !== "online") return false;
|
|
21
|
+
if (opts.requireApiUrl && !orch.apiUrl) return false;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function workspaceOwnerOrchestrators<T extends WorkspaceHost = Orchestrator>(
|
|
26
|
+
workspace: WorkspaceRecord,
|
|
27
|
+
orchestrators?: T[],
|
|
28
|
+
opts: WorkspaceHostOptions = {},
|
|
29
|
+
): T[] {
|
|
30
|
+
const hosts = orchestrators ?? (listOrchestrators() as unknown as T[]);
|
|
31
|
+
if (workspace.orchestratorId) {
|
|
32
|
+
const owner = hosts.find((candidate) => candidate.id === workspace.orchestratorId);
|
|
33
|
+
return hostUsable(owner, opts) ? [owner] : [];
|
|
34
|
+
}
|
|
35
|
+
return hosts.filter((candidate) =>
|
|
36
|
+
hostUsable(candidate, opts) && Boolean(candidate.baseDir) && isPathWithinBase(workspace.sourceCwd, candidate.baseDir!),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function workspaceOwnerOrchestrator<T extends WorkspaceHost = Orchestrator>(
|
|
41
|
+
workspace: WorkspaceRecord,
|
|
42
|
+
orchestrators?: T[],
|
|
43
|
+
opts: WorkspaceHostOptions = {},
|
|
44
|
+
): T | undefined {
|
|
45
|
+
return workspaceOwnerOrchestrators(workspace, orchestrators, opts)[0];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function commandTargetsWorkspaceOwner(command: Command, workspace: WorkspaceRecord): boolean {
|
|
49
|
+
const ownerId = workspace.orchestratorId;
|
|
50
|
+
if (!ownerId) return true;
|
|
51
|
+
const owner = getOrchestrator(ownerId);
|
|
52
|
+
return command.target === ownerId
|
|
53
|
+
|| command.target === `orchestrator-${ownerId}`
|
|
54
|
+
|| (owner?.agentId ? command.target === owner.agentId : false);
|
|
55
|
+
}
|
package/src/workspace-merge.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
} from "./types";
|
|
23
23
|
import { AUTO_MERGE_POLICIES } from "./types";
|
|
24
24
|
import { isPathWithinBase } from "./utils";
|
|
25
|
+
import { workspaceOwnerOrchestrators } from "./workspace-host";
|
|
25
26
|
|
|
26
27
|
// Re-export so consumers can import the tuple from the strategy home without a
|
|
27
28
|
// second import hop.
|
|
@@ -164,11 +165,9 @@ export function workspaceMergeOrchestrators(
|
|
|
164
165
|
workspace: WorkspaceRecord,
|
|
165
166
|
orchestrators: Orchestrator[] = listOrchestrators(),
|
|
166
167
|
): Orchestrator[] {
|
|
167
|
-
const candidates = orchestrators
|
|
168
|
-
candidate.status === "online" && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
|
|
169
|
-
);
|
|
168
|
+
const candidates = workspaceOwnerOrchestrators(workspace, orchestrators);
|
|
170
169
|
const ownerKeys = workspaceOwnerHostKeys(workspace);
|
|
171
|
-
if (ownerKeys.size === 0) return candidates;
|
|
170
|
+
if (workspace.orchestratorId || ownerKeys.size === 0) return candidates;
|
|
172
171
|
return [...candidates].sort((a, b) => {
|
|
173
172
|
const aOwner = ownerKeys.has(a.id) || ownerKeys.has(a.hostname) || ownerKeys.has(a.agentId) ? 0 : 1;
|
|
174
173
|
const bOwner = ownerKeys.has(b.id) || ownerKeys.has(b.hostname) || ownerKeys.has(b.agentId) ? 0 : 1;
|
package/src/workspace-orphans.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { isPathWithinBase } from "./utils";
|
|
|
28
28
|
import { TERMINAL_WORKSPACE_STATUSES, worktreeReapable, type WorktreeReapState } from "./workspace-phase";
|
|
29
29
|
import { isOwnerAlive, liveAgentCwds, worktreeInUseByLiveAgent } from "./workspace-merge";
|
|
30
30
|
import { applyWorkspaceAction } from "./workspace-actions";
|
|
31
|
+
import { workspaceOwnerOrchestrator } from "./workspace-host";
|
|
31
32
|
import { UNLANDED_FLAG_COOLDOWN_MS, orphanGraceMs, orphanWorktreeReapEnabled, relayToken } from "./config";
|
|
32
33
|
|
|
33
34
|
// Don't re-flag the same un-landed orphan every sweep — surface it once, then
|
|
@@ -139,6 +140,17 @@ function knownRepoRoots(workspaces: WorkspaceRecord[]): string[] {
|
|
|
139
140
|
return [...new Set(workspaces.map((ws) => ws.repoRoot).filter(Boolean))];
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
function repoProbeHosts(repoRoot: string, workspaces: WorkspaceRecord[], orchestrators: OnlineOrchestrator[]): OnlineOrchestrator[] {
|
|
144
|
+
const ownerIds = new Set(
|
|
145
|
+
workspaces
|
|
146
|
+
.filter((ws) => ws.orchestratorId && resolve(ws.repoRoot) === resolve(repoRoot))
|
|
147
|
+
.map((ws) => ws.orchestratorId!),
|
|
148
|
+
);
|
|
149
|
+
if (ownerIds.size > 0) return orchestrators.filter((candidate) => ownerIds.has(candidate.id));
|
|
150
|
+
const legacy = orchestrators.find((candidate) => isPathWithinBase(repoRoot, candidate.baseDir));
|
|
151
|
+
return legacy ? [legacy] : [];
|
|
152
|
+
}
|
|
153
|
+
|
|
142
154
|
interface CollectOrphansResult {
|
|
143
155
|
orphans: WorkspaceOrphan[];
|
|
144
156
|
/** Live isolated rows whose worktree is missing on disk (DB→disk drift). */
|
|
@@ -196,64 +208,72 @@ export async function collectWorkspaceOrphans(): Promise<CollectOrphansResult> {
|
|
|
196
208
|
// (their worktree lives under a different repo's probe than the one iterating). #278
|
|
197
209
|
const onDiskAll = new Set<string>();
|
|
198
210
|
const probedRepoRoots = new Set<string>();
|
|
211
|
+
const onDiskByOwner = new Set<string>();
|
|
212
|
+
const probedOwnerRepoRoots = new Set<string>();
|
|
199
213
|
|
|
200
214
|
for (const repoRoot of knownRepoRoots(all)) {
|
|
201
|
-
const orch
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
for (const worktree of probe.worktrees) {
|
|
213
|
-
if (!worktree.path || resolve(worktree.path) === resolve(repoRoot)) continue;
|
|
214
|
-
// Only agent-relay-created worktrees (agent/* branches) are reclaimable —
|
|
215
|
-
// never touch a user's own linked worktrees.
|
|
216
|
-
if (!worktree.branch?.startsWith("agent/")) continue;
|
|
217
|
-
const row = rowsByPath.get(resolve(worktree.path));
|
|
218
|
-
if (row && !TERMINAL_WORKSPACE_STATUSES.has(row.status)) {
|
|
219
|
-
if (row.mode === "isolated") {
|
|
220
|
-
deadOwnerWorkspaces.push({
|
|
221
|
-
workspaceId: row.id,
|
|
222
|
-
worktreePath: worktree.path,
|
|
223
|
-
repoRoot: row.repoRoot,
|
|
224
|
-
branch: row.branch ?? worktree.branch,
|
|
225
|
-
baseRef: row.baseRef,
|
|
226
|
-
baseSha: row.baseSha,
|
|
227
|
-
ownerAgentId: row.ownerAgentId,
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
continue; // tracked & live — not an orphan
|
|
215
|
+
for (const orch of repoProbeHosts(repoRoot, all, orchestrators)) {
|
|
216
|
+
if (!orch.apiUrl) continue;
|
|
217
|
+
const probe = await fetchHostProbe(orch.apiUrl, repoRoot);
|
|
218
|
+
if (!probe?.worktrees) continue;
|
|
219
|
+
|
|
220
|
+
probedRepoRoots.add(resolve(repoRoot));
|
|
221
|
+
probedOwnerRepoRoots.add(`${orch.id}:${resolve(repoRoot)}`);
|
|
222
|
+
for (const wt of probe.worktrees) {
|
|
223
|
+
if (!wt.path) continue;
|
|
224
|
+
onDiskAll.add(resolve(wt.path));
|
|
225
|
+
onDiskByOwner.add(`${orch.id}:${resolve(wt.path)}`);
|
|
231
226
|
}
|
|
232
227
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
228
|
+
// disk→DB drift: a worktree on disk with no live row → orphan. A worktree with a
|
|
229
|
+
// live isolated row is NOT an orphan, but if its owner has died it's the #279
|
|
230
|
+
// dead-owner case — surface it for the reaper to evaluate (liveness + grace).
|
|
231
|
+
for (const worktree of probe.worktrees) {
|
|
232
|
+
if (!worktree.path || resolve(worktree.path) === resolve(repoRoot)) continue;
|
|
233
|
+
// Only agent-relay-created worktrees (agent/* branches) are reclaimable —
|
|
234
|
+
// never touch a user's own linked worktrees.
|
|
235
|
+
if (!worktree.branch?.startsWith("agent/")) continue;
|
|
236
|
+
const row = rowsByPath.get(resolve(worktree.path));
|
|
237
|
+
if (row && !TERMINAL_WORKSPACE_STATUSES.has(row.status)) {
|
|
238
|
+
if (row.mode === "isolated") {
|
|
239
|
+
deadOwnerWorkspaces.push({
|
|
240
|
+
workspaceId: row.id,
|
|
241
|
+
worktreePath: worktree.path,
|
|
242
|
+
repoRoot: row.repoRoot,
|
|
243
|
+
branch: row.branch ?? worktree.branch,
|
|
244
|
+
baseRef: row.baseRef,
|
|
245
|
+
baseSha: row.baseSha,
|
|
246
|
+
ownerAgentId: row.ownerAgentId,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
continue; // tracked & live — not an orphan
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const orphan: WorkspaceOrphan = {
|
|
253
|
+
worktreePath: worktree.path,
|
|
254
|
+
repoRoot,
|
|
255
|
+
branch: worktree.branch,
|
|
256
|
+
headSha: worktree.headSha,
|
|
257
|
+
baseRef: probe.branch,
|
|
258
|
+
hadTerminalRow: Boolean(row),
|
|
248
259
|
};
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
260
|
+
const preview = await fetchWorktreeReapState(orch.apiUrl, worktree.path, probe.branch);
|
|
261
|
+
if (preview && !preview.missing && !preview.error) {
|
|
262
|
+
const state: WorktreeReapState = {
|
|
263
|
+
landed: preview.landed,
|
|
264
|
+
ahead: preview.ahead,
|
|
265
|
+
unmergedAhead: preview.unmergedAhead,
|
|
266
|
+
dirtyCount: preview.dirtyCount,
|
|
267
|
+
};
|
|
268
|
+
orphan.landed = preview.landed;
|
|
269
|
+
orphan.ahead = preview.ahead;
|
|
270
|
+
orphan.unmergedAhead = preview.unmergedAhead;
|
|
271
|
+
orphan.dirty = (preview.dirtyCount ?? 0) > 0;
|
|
272
|
+
orphan.safeToReap = worktreeReapable(state);
|
|
273
|
+
}
|
|
274
|
+
// No probe → safeToReap stays undefined (treated as not-safe by callers).
|
|
275
|
+
orphans.push(orphan);
|
|
254
276
|
}
|
|
255
|
-
// No probe → safeToReap stays undefined (treated as not-safe by callers).
|
|
256
|
-
orphans.push(orphan);
|
|
257
277
|
}
|
|
258
278
|
}
|
|
259
279
|
|
|
@@ -268,7 +288,10 @@ export async function collectWorkspaceOrphans(): Promise<CollectOrphansResult> {
|
|
|
268
288
|
if (ws.mode !== "isolated" || !ws.worktreePath) continue;
|
|
269
289
|
if (TERMINAL_WORKSPACE_STATUSES.has(ws.status)) continue;
|
|
270
290
|
if (onDiskAll.has(resolve(ws.worktreePath))) continue;
|
|
271
|
-
const coverable =
|
|
291
|
+
const coverable = ws.orchestratorId
|
|
292
|
+
? probedOwnerRepoRoots.has(`${ws.orchestratorId}:${resolve(ws.repoRoot)}`)
|
|
293
|
+
|| onDiskByOwner.has(`${ws.orchestratorId}:${resolve(ws.repoRoot)}`)
|
|
294
|
+
: probedRepoRoots.has(resolve(ws.repoRoot)) || onDiskAll.has(resolve(ws.repoRoot));
|
|
272
295
|
if (!coverable) continue;
|
|
273
296
|
missingWorktrees.push({
|
|
274
297
|
workspaceId: ws.id,
|
|
@@ -420,7 +443,7 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
|
|
|
420
443
|
continue;
|
|
421
444
|
}
|
|
422
445
|
|
|
423
|
-
const orch =
|
|
446
|
+
const orch = workspaceOwnerOrchestrator(workspace, orchestrators, { onlineOnly: false, requireApiUrl: true });
|
|
424
447
|
const probe = orch?.apiUrl
|
|
425
448
|
? await fetchBranchReapState(orch.apiUrl, workspace.repoRoot, workspace.branch, workspace.baseRef, workspace.baseSha)
|
|
426
449
|
: { kind: "unavailable" } as const;
|
|
@@ -520,7 +543,7 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
|
|
|
520
543
|
if (now - firstSeenDeadAt < graceMs) continue;
|
|
521
544
|
|
|
522
545
|
// Grace elapsed + owner dead. Gate on land-state — "nothing would be lost".
|
|
523
|
-
const orch =
|
|
546
|
+
const orch = workspaceOwnerOrchestrator(workspace, orchestrators, { onlineOnly: false, requireApiUrl: true });
|
|
524
547
|
const preview = orch?.apiUrl
|
|
525
548
|
? await fetchWorktreeReapState(orch.apiUrl, workspace.worktreePath, workspace.baseRef)
|
|
526
549
|
: null;
|
package/src/workspace-probe.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
patchWorkspaceMetadata,
|
|
18
18
|
} from "./db";
|
|
19
19
|
import { isPathWithinBase } from "./utils";
|
|
20
|
+
import { workspaceOwnerOrchestrator } from "./workspace-host";
|
|
20
21
|
import { deriveBranchState } from "./workspace-phase";
|
|
21
22
|
import type { AgentCard, WorkspaceMergePreview, WorkspaceRecord } from "./types";
|
|
22
23
|
import { relayToken } from "./config";
|
|
@@ -95,9 +96,7 @@ export async function probeWorkspaceState(agentId: string): Promise<ProbeResult>
|
|
|
95
96
|
const ws = ownedIsolatedWorkspace(agentId);
|
|
96
97
|
if (!ws || !ws.worktreePath) return { probed: false, skipped: "no live isolated workspace" };
|
|
97
98
|
if (ws.status !== "active") return { probed: false, workspaceId: ws.id, skipped: `status ${ws.status} derives badge without git counts` };
|
|
98
|
-
const orch = listOrchestrators()
|
|
99
|
-
(candidate) => candidate.status === "online" && candidate.apiUrl && workspacePathWithinBase(ws.sourceCwd, candidate.baseDir),
|
|
100
|
-
);
|
|
99
|
+
const orch = workspaceOwnerOrchestrator(ws, listOrchestrators(), { requireApiUrl: true });
|
|
101
100
|
if (!orch?.apiUrl) return { probed: false, workspaceId: ws.id, skipped: "no online orchestrator owns this path" };
|
|
102
101
|
// checkPr:false — a badge probe never needs the gh round-trip (#304).
|
|
103
102
|
const preview = await fetchHostMergePreview(orch.apiUrl, ws, { checkPr: false });
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
import{r as e}from"./chunk-CilyBKbf.js";import{Mt as t,Qn as n,Zn as r,er as i,hr as a,mt as o,nr as s,rr as c,wn as l,xn as u}from"./lucide-react-Dc3ZwCBi.js";import{i as d,r as f,t as p}from"./store-OLe8gFh0.js";import{t as m}from"./task-chip-kxk4eNm1.js";import{J as h}from"./index-TamtghSr.js";var g=e(a(),1),_=`(max-width: 767px)`;function v(e){if(typeof window>`u`)return()=>{};let t=window.matchMedia(_);return t.addEventListener(`change`,e),()=>t.removeEventListener(`change`,e)}function y(){return(0,g.useSyncExternalStore)(v,()=>typeof window<`u`?window.matchMedia(_).matches:!1,()=>!1)}var b=s(),x=196,S=84,C=52,w=16,T=14,E=[`landed`,`in-progress`,`pending`,`blocked`],D={landed:{label:`landed`,dot:`bg-emerald-400`,accent:`border-l-emerald-400`,text:`text-emerald-300`,wire:`#34d399`},"in-progress":{label:`in progress`,dot:`bg-sky-400`,accent:`border-l-sky-400`,text:`text-sky-300`,wire:`#38bdf8`},pending:{label:`pending`,dot:`bg-amber-400`,accent:`border-l-amber-400`,text:`text-amber-300`,wire:`#fbbf24`},blocked:{label:`blocked`,dot:`bg-red-400`,accent:`border-l-red-400`,text:`text-red-300`,wire:`#f87171`}};function O(e){return e?e.label||(e.policyName?`policy:${e.policyName}`:e.agentId||``):``}function k(e){let t=new Map(e.nodes.map(e=>[e.id,e])),n=new Map(e.nodes.map((e,t)=>[e.id,t])),r=new Map,i=new Map(e.nodes.map(e=>[e.id,0]));for(let n of e.edges)!t.has(n.from)||!t.has(n.to)||(r.set(n.from,[...r.get(n.from)??[],n.to]),i.set(n.to,(i.get(n.to)??0)+1));let a=new Map(e.nodes.map(e=>[e.id,0])),o=e.nodes.filter(e=>(i.get(e.id)??0)===0).map(e=>e.id),s=new Set;for(;o.length;){let e=o.shift();s.add(e);let t=a.get(e)??0;for(let n of r.get(e)??[])a.set(n,Math.max(a.get(n)??0,t+1)),i.set(n,(i.get(n)??0)-1),(i.get(n)??0)===0&&o.push(n)}let c=Math.max(0,...Array.from(a.values()))+1;for(let t of e.nodes)s.has(t.id)||a.set(t.id,c++);let l=new Map;for(let t of e.nodes){let e=a.get(t.id)??0;l.set(e,[...l.get(e)??[],t])}let u=[...l.entries()].sort(([e],[t])=>e-t),d=Math.max(1,...u.map(([,e])=>e.length)),f=S+w,p=[];u.forEach(([e,t],r)=>{let i=[...t].sort((e,t)=>(e.lane||``).localeCompare(t.lane||``)||(n.get(e.id)??0)-(n.get(t.id)??0)),a=(d-i.length)*f/2;i.forEach((t,n)=>{p.push({node:t,layer:e,x:T+r*(x+C),y:T+a+n*f})})});let m=u.length;return{nodes:p,width:Math.max(320,T*2+m*x+Math.max(0,m-1)*C),height:Math.max(S+T*2,T*2+d*S+Math.max(0,d-1)*w)}}function A(e,t){let n=e.x+x,r=e.y+S/2,i=t.x,a=t.y+S/2,o=n+Math.max(20,(i-n)/2);return`M ${n} ${r} C ${o} ${r}, ${o} ${a}, ${i} ${a}`}function j({threadId:e,teamId:a,channel:o}){let s=a?d(a,o):e?f(e,o):``,c=p(e=>a?e.plansByTeam[s]:s?e.plansByThread[s]:null),_=p(e=>e.fetchPlan),v=p(e=>e.setPlanArchived),C=p(e=>e.planCollapsed),w=p(e=>e.setPlanCollapsed),T=p(e=>e.issueRefsById),j=p(e=>e.deliverableTasks),M=p(e=>e.openTaskDetailById),N=y(),P=C??N;(0,g.useEffect)(()=>{if(a){_({teamId:a,channel:o});return}e&&_({threadId:e,channel:o})},[o,_,a,e]);let F=(0,g.useMemo)(()=>c?k(c):null,[c]),I=(0,g.useMemo)(()=>new Map(F?.nodes.map(e=>[e.node.id,e])??[]),[F]),L=(0,g.useMemo)(()=>{if(!c)return[];let e=new Map;for(let t of c.nodes)e.set(t.status,(e.get(t.status)??0)+1);return E.filter(t=>e.has(t)).map(t=>({status:t,count:e.get(t)??0}))},[c]);if(!c||!F)return null;if(c.archived)return(0,b.jsx)(`section`,{className:`shrink-0 border-b border-border bg-card/45 px-3 py-2`,children:(0,b.jsxs)(`div`,{className:`flex min-w-0 items-center gap-2`,children:[(0,b.jsx)(`span`,{className:`flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted/60`,children:(0,b.jsx)(r,{className:`h-3 w-3 text-muted-foreground`})}),(0,b.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,b.jsx)(`div`,{className:`truncate text-[13px] font-semibold leading-tight text-muted-foreground`,children:c.title||`Plan`}),(0,b.jsx)(`div`,{className:`truncate text-[11px] leading-tight text-muted-foreground`,children:`Archived plan`})]}),(0,b.jsx)(`span`,{className:`shrink-0 rounded border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground`,children:`archived`}),(0,b.jsx)(`button`,{type:`button`,onClick:()=>void v(c.id,!1),title:`Unarchive plan`,"aria-label":`Unarchive plan`,className:`flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted/60 hover:text-foreground`,children:(0,b.jsx)(n,{className:`h-3.5 w-3.5`})})]})});let R=c.nodes.length,z=L.find(e=>e.status===`landed`)?.count??0,B=`${R} node${R===1?``:`s`}${z>0?` · ${z} landed`:``}`;return(0,b.jsxs)(`section`,{className:`shrink-0 border-b border-border bg-card/45 px-3 py-2`,children:[(0,b.jsxs)(`div`,{className:i(`flex min-w-0 items-center gap-2`,!P&&`mb-2`),children:[(0,b.jsxs)(`button`,{type:`button`,onClick:()=>w(!P),"aria-expanded":!P,"aria-label":P?`Expand plan graph`:`Collapse plan graph`,className:`flex min-w-0 flex-1 items-center gap-2 text-left`,children:[(0,b.jsx)(`span`,{className:`flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted/60`,children:(0,b.jsx)(t,{className:`h-3 w-3 text-muted-foreground`})}),(0,b.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,b.jsx)(`div`,{className:`truncate text-[13px] font-semibold leading-tight text-foreground`,children:c.title||`Plan`}),(0,b.jsx)(`div`,{className:`truncate text-[11px] leading-tight text-muted-foreground`,children:P?B:c.objective||B})]})]}),L.length>0&&(0,b.jsx)(`div`,{className:`hidden shrink-0 items-center gap-2.5 sm:flex`,children:L.map(({status:e,count:t})=>(0,b.jsxs)(`span`,{className:`flex items-center gap-1 text-[10px] text-muted-foreground`,children:[(0,b.jsx)(`span`,{className:i(`h-1.5 w-1.5 rounded-full`,D[e].dot)}),(0,b.jsx)(`span`,{className:i(`font-medium tabular-nums`,D[e].text),children:t}),(0,b.jsx)(`span`,{children:D[e].label})]},e))}),(0,b.jsxs)(`span`,{className:`shrink-0 rounded bg-muted/60 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground`,children:[`v`,c.version]}),(0,b.jsx)(`button`,{type:`button`,onClick:()=>void v(c.id,!0),title:`Archive plan`,"aria-label":`Archive plan`,className:`flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted/60 hover:text-foreground`,children:(0,b.jsx)(r,{className:`h-3.5 w-3.5`})}),(0,b.jsx)(`button`,{type:`button`,onClick:()=>w(!P),"aria-expanded":!P,"aria-label":P?`Expand plan graph`:`Collapse plan graph`,className:`flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted/60 hover:text-foreground`,children:P?(0,b.jsx)(l,{className:`h-3.5 w-3.5`}):(0,b.jsx)(u,{className:`h-3.5 w-3.5`})})]}),P?null:c.nodes.length===0?(0,b.jsx)(`div`,{className:`rounded-md border border-dashed border-border px-3 py-3 text-center text-xs text-muted-foreground`,children:`No nodes yet — the plan graph fills in as the team is composed.`}):(0,b.jsx)(`div`,{className:`max-h-[min(46vh,360px)] overflow-auto`,children:(0,b.jsxs)(`div`,{className:`relative`,style:{width:F.width,height:F.height},children:[(0,b.jsxs)(`svg`,{className:`pointer-events-none absolute inset-0`,width:F.width,height:F.height,"aria-hidden":`true`,children:[(0,b.jsx)(`defs`,{children:E.map(e=>(0,b.jsx)(`marker`,{id:`plan-arrow-${e}`,viewBox:`0 0 8 8`,refX:`6.5`,refY:`4`,markerWidth:`6`,markerHeight:`6`,orient:`auto-start-reverse`,children:(0,b.jsx)(`path`,{d:`M0 0 L8 4 L0 8 z`,fill:D[e].wire})},e))}),c.edges.map(e=>{let t=I.get(e.from),n=I.get(e.to);if(!t||!n)return null;let r=D[t.node.status];return(0,b.jsx)(`path`,{d:A(t,n),stroke:r.wire,strokeOpacity:.7,strokeWidth:2,fill:`none`,strokeLinecap:`round`,markerEnd:`url(#plan-arrow-${t.node.status})`},`${e.from}->${e.to}`)})]}),F.nodes.map(({node:e,x:t,y:n})=>{let r=D[e.status],a=O(e.ownerRef),o=e.refs?.issueId?T[e.refs.issueId]:null,s=e.refs?.taskId?parseInt(e.refs.taskId,10):null,c=s==null?void 0:j.find(e=>e.id===s)?.title;return(0,b.jsxs)(`article`,{className:i(`group absolute flex flex-col justify-between rounded-md border border-l-[3px] bg-background/95 p-2`,`shadow-sm transition-all hover:-translate-y-px hover:border-border hover:shadow-md hover:bg-background`,r.accent),style:{left:t,top:n,width:x,height:S},title:e.scopeBlurb||e.label,children:[(0,b.jsx)(`div`,{className:`truncate text-xs font-semibold leading-tight text-foreground`,children:e.label}),(e.scopeBlurb||e.id)&&(0,b.jsx)(`div`,{className:`truncate text-[10px] leading-tight text-muted-foreground`,children:e.scopeBlurb||e.id}),(o||s!=null)&&(0,b.jsxs)(`div`,{className:`flex min-w-0 flex-wrap gap-1`,children:[o&&(0,b.jsx)(h,{issueRef:o,compact:!0,className:`max-w-full`}),s!=null&&(0,b.jsx)(m,{id:s,title:c,onClick:()=>M(s),className:`max-w-full`})]}),(0,b.jsxs)(`div`,{className:`flex min-w-0 items-center justify-between gap-1.5`,children:[(0,b.jsxs)(`div`,{className:`flex shrink-0 items-center gap-1.5`,children:[(0,b.jsx)(`span`,{className:i(`h-1.5 w-1.5 shrink-0 rounded-full`,r.dot)}),(0,b.jsx)(`span`,{className:i(`text-[10px] font-medium`,r.text),children:r.label})]}),(0,b.jsxs)(`div`,{className:`flex min-w-0 items-center gap-1.5`,children:[a&&(0,b.jsx)(`span`,{className:`truncate text-[10px] text-muted-foreground`,children:a}),e.lane&&(0,b.jsx)(`span`,{className:`shrink-0 rounded-sm bg-muted/70 px-1 py-px text-[9px] font-medium uppercase tracking-wide text-muted-foreground`,children:e.lane})]})]})]},e.id)})]})})]})}function M({agentId:e}){let t=p(e=>e.openTaskDetailById),n=p(e=>e.sessionTasksCollapsed),r=p(e=>e.setSessionTasksCollapsed),[a,s]=(0,g.useState)([]);return(0,g.useEffect)(()=>{c(`GET`,`/task-entities?createdBy=`+encodeURIComponent(e)).then(e=>s(e)).catch(()=>{})},[e]),a.length===0?null:(0,b.jsxs)(`section`,{className:`shrink-0 border-b border-border bg-card/45 px-3 py-2`,children:[(0,b.jsxs)(`button`,{type:`button`,onClick:()=>r(!n),"aria-expanded":!n,"aria-label":n?`Expand session tasks`:`Collapse session tasks`,className:i(`flex w-full min-w-0 items-center gap-2 text-left`,!n&&`mb-1.5`),children:[(0,b.jsx)(`span`,{className:`flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted/60`,children:(0,b.jsx)(o,{className:`h-3 w-3 text-muted-foreground`})}),(0,b.jsxs)(`span`,{className:`flex-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground`,children:[`Tasks from this session (`,a.length,`)`]}),(0,b.jsx)(`span`,{className:`flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted/60 hover:text-foreground`,children:n?(0,b.jsx)(l,{className:`h-3.5 w-3.5`}):(0,b.jsx)(u,{className:`h-3.5 w-3.5`})})]}),!n&&(0,b.jsx)(`div`,{className:`flex flex-wrap gap-1`,children:a.map(e=>(0,b.jsx)(m,{id:e.id,title:e.title,status:e.status,onClick:()=>t(e.id),compact:!0},e.id))})]})}export{M as n,j as t};
|
|
2
|
-
//# sourceMappingURL=PlanGraphPanel-B9TN_KQr.js.map
|