agent-relay-server 0.119.7 → 0.119.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-Azda2GIf.js → MessageBubble-U5sX3SNZ.js} +2 -2
  4. package/public/assets/{MessageBubble-Azda2GIf.js.map → MessageBubble-U5sX3SNZ.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-Bi2gvXeE.js +2 -0
  6. package/public/assets/PlanGraphPanel-Bi2gvXeE.js.map +1 -0
  7. package/public/assets/{activity-B0hz4XPy.js → activity-Cwkz51pG.js} +2 -2
  8. package/public/assets/{activity-B0hz4XPy.js.map → activity-Cwkz51pG.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BF0oSOTq.js → agent-profiles-BgddVqRp.js} +2 -2
  10. package/public/assets/{agent-profiles-BF0oSOTq.js.map → agent-profiles-BgddVqRp.js.map} +1 -1
  11. package/public/assets/{agents-CW4--j3Q.js → agents-CiGYBw0h.js} +2 -2
  12. package/public/assets/{agents-CW4--j3Q.js.map → agents-CiGYBw0h.js.map} +1 -1
  13. package/public/assets/{automation-S6_bQ2t-.js → automation-BBJB4RaE.js} +2 -2
  14. package/public/assets/{automation-S6_bQ2t-.js.map → automation-BBJB4RaE.js.map} +1 -1
  15. package/public/assets/{chat-hoFhhvgy.js → chat-DS578gZy.js} +2 -2
  16. package/public/assets/{chat-hoFhhvgy.js.map → chat-DS578gZy.js.map} +1 -1
  17. package/public/assets/{formatted-body-BHm145gP.js → formatted-body-DYUVqWym.js} +3 -3
  18. package/public/assets/{formatted-body-BHm145gP.js.map → formatted-body-DYUVqWym.js.map} +1 -1
  19. package/public/assets/{formatted-body-impl-CvE31j9F.js → formatted-body-impl-DdcyxVO0.js} +2 -2
  20. package/public/assets/{formatted-body-impl-CvE31j9F.js.map → formatted-body-impl-DdcyxVO0.js.map} +1 -1
  21. package/public/assets/{index-TamtghSr.js → index-CqcAqQKW.js} +6 -6
  22. package/public/assets/{index-TamtghSr.js.map → index-CqcAqQKW.js.map} +1 -1
  23. package/public/assets/{insights-Ci9ftVfl.js → insights-BunHbsTA.js} +2 -2
  24. package/public/assets/{insights-Ci9ftVfl.js.map → insights-BunHbsTA.js.map} +1 -1
  25. package/public/assets/{maintenance-bVhNbr33.js → maintenance-cICBVcpJ.js} +2 -2
  26. package/public/assets/{maintenance-bVhNbr33.js.map → maintenance-cICBVcpJ.js.map} +1 -1
  27. package/public/assets/{managed-agents-D5HO4dhI.js → managed-agents-BK9_Gi-M.js} +2 -2
  28. package/public/assets/{managed-agents-D5HO4dhI.js.map → managed-agents-BK9_Gi-M.js.map} +1 -1
  29. package/public/assets/{markdown-preview-impl-DO-p83dz.js → markdown-preview-impl-EZfcTHG9.js} +2 -2
  30. package/public/assets/{markdown-preview-impl-DO-p83dz.js.map → markdown-preview-impl-EZfcTHG9.js.map} +1 -1
  31. package/public/assets/{memory-D72o8umQ.js → memory-Vi-8HvK3.js} +2 -2
  32. package/public/assets/{memory-D72o8umQ.js.map → memory-Vi-8HvK3.js.map} +1 -1
  33. package/public/assets/{messages-5L_Bc_9w.js → messages-DScIiBMI.js} +2 -2
  34. package/public/assets/{messages-5L_Bc_9w.js.map → messages-DScIiBMI.js.map} +1 -1
  35. package/public/assets/{orchestrators-CnfgkQpl.js → orchestrators-DLogldXx.js} +2 -2
  36. package/public/assets/{orchestrators-CnfgkQpl.js.map → orchestrators-DLogldXx.js.map} +1 -1
  37. package/public/assets/{overview-Bu39NIyR.js → overview-B_RQAQR8.js} +2 -2
  38. package/public/assets/{overview-Bu39NIyR.js.map → overview-B_RQAQR8.js.map} +1 -1
  39. package/public/assets/{pairs-WuamP5W5.js → pairs-CSiPQ7E2.js} +2 -2
  40. package/public/assets/{pairs-WuamP5W5.js.map → pairs-CSiPQ7E2.js.map} +1 -1
  41. package/public/assets/{prompt-settings-BCrQreU6.js → prompt-settings-DsDi_Xpd.js} +2 -2
  42. package/public/assets/{prompt-settings-BCrQreU6.js.map → prompt-settings-DsDi_Xpd.js.map} +1 -1
  43. package/public/assets/{registry-kHKn0xwg.js → registry-DHFUWIdb.js} +2 -2
  44. package/public/assets/{registry-kHKn0xwg.js.map → registry-DHFUWIdb.js.map} +1 -1
  45. package/public/assets/{security-Dk5t3r_K.js → security-D2nHkJH6.js} +2 -2
  46. package/public/assets/{security-Dk5t3r_K.js.map → security-D2nHkJH6.js.map} +1 -1
  47. package/public/assets/{select-BdsZQfU2.js → select-BjNcommA.js} +2 -2
  48. package/public/assets/{select-BdsZQfU2.js.map → select-BjNcommA.js.map} +1 -1
  49. package/public/assets/{settings-EpjzQHnZ.js → settings-DiUEq54n.js} +2 -2
  50. package/public/assets/{settings-EpjzQHnZ.js.map → settings-DiUEq54n.js.map} +1 -1
  51. package/public/assets/{tasks-CedMQHbg.js → tasks-Dx8GVQiR.js} +2 -2
  52. package/public/assets/{tasks-CedMQHbg.js.map → tasks-Dx8GVQiR.js.map} +1 -1
  53. package/public/assets/{teams-BlHBo5Qt.js → teams-DQtf7bha.js} +2 -2
  54. package/public/assets/{teams-BlHBo5Qt.js.map → teams-DQtf7bha.js.map} +1 -1
  55. package/public/assets/{terminal-viewer-impl-gGqYpsjq.js → terminal-viewer-impl-D5rhXV4u.js} +2 -2
  56. package/public/assets/{terminal-viewer-impl-gGqYpsjq.js.map → terminal-viewer-impl-D5rhXV4u.js.map} +1 -1
  57. package/public/assets/types-uVOXgvMT.js.map +1 -1
  58. package/public/assets/{work-queue-D7opMqtU.js → work-queue-BfWNDp7U.js} +2 -2
  59. package/public/assets/{work-queue-D7opMqtU.js.map → work-queue-BfWNDp7U.js.map} +1 -1
  60. package/public/index.html +2 -2
  61. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  62. package/src/db/index.ts +1 -0
  63. package/src/db/migrations.ts +7 -0
  64. package/src/db/orchestrators.ts +1 -1
  65. package/src/db/provider-quotas.ts +2 -34
  66. package/src/db/schema.ts +2 -0
  67. package/src/db/work-evidence.ts +38 -0
  68. package/src/db/workspaces.ts +7 -3
  69. package/src/lifecycle-manager.ts +2 -4
  70. package/src/maintenance/jobs.ts +10 -1
  71. package/src/maintenance/workspaces.ts +71 -13
  72. package/src/routes/commands.ts +20 -4
  73. package/src/routes/workspaces.ts +4 -7
  74. package/src/services/transient-agent-reaper.ts +54 -9
  75. package/src/workspace-actions.ts +3 -4
  76. package/src/workspace-auto-merge.ts +95 -13
  77. package/src/workspace-host.ts +55 -0
  78. package/src/workspace-merge.ts +3 -4
  79. package/src/workspace-orphans.ts +78 -55
  80. package/src/workspace-probe.ts +2 -3
  81. package/public/assets/PlanGraphPanel-B9TN_KQr.js +0 -2
  82. package/public/assets/PlanGraphPanel-B9TN_KQr.js.map +0 -1
@@ -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 { isPathWithinBase } from "../utils";
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().find(
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: completedProviderTurns(agent) > 0, reason: "no-isolated-workspace" };
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
- return { complete: true, producedWork: workspaceProducedWork(latest), workspaceId: latest?.id, status: latest?.status, reason: "terminal-workspace" };
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: false, workspaceId: live.id, status: live.status, reason: "empty-branch" };
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 branch work is land-complete; shutdown requested",
274
+ activityBody: "Agent idle grace elapsed and transient work is complete; shutdown requested",
230
275
  landReason: land.reason,
231
276
  now,
232
277
  });
@@ -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 { isPathWithinBase } from "./utils";
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 = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
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 owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
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
- const repoMergeRunning = new Map<string, { rerun: boolean }>();
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
- await autoMergeForRepo(repoRoot);
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 { repoMergeRunning.clear(); }
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 = listWorkspaces({ repoRoot }).filter(
412
- (ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && READY_TO_LAND_STATUSES.has(ws.status),
413
- );
414
- return landCandidates(candidates, orchestrators);
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 = listWorkspaces().filter(
428
- (ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && READY_TO_LAND_STATUSES.has(ws.status),
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
- return { ...(await landCandidates(candidates, orchestrators)) };
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
+ }
@@ -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.filter((candidate) =>
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;
@@ -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 = orchestrators.find((candidate) => candidate.apiUrl && isPathWithinBase(repoRoot, candidate.baseDir));
202
- if (!orch?.apiUrl) continue;
203
- const probe = await fetchHostProbe(orch.apiUrl, repoRoot);
204
- if (!probe?.worktrees) continue;
205
-
206
- probedRepoRoots.add(resolve(repoRoot));
207
- for (const wt of probe.worktrees) if (wt.path) onDiskAll.add(resolve(wt.path));
208
-
209
- // disk→DB drift: a worktree on disk with no live row → orphan. A worktree with a
210
- // live isolated row is NOT an orphan, but if its owner has died it's the #279
211
- // dead-owner case — surface it for the reaper to evaluate (liveness + grace).
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
- const orphan: WorkspaceOrphan = {
234
- worktreePath: worktree.path,
235
- repoRoot,
236
- branch: worktree.branch,
237
- headSha: worktree.headSha,
238
- baseRef: probe.branch,
239
- hadTerminalRow: Boolean(row),
240
- };
241
- const preview = await fetchWorktreeReapState(orch.apiUrl, worktree.path, probe.branch);
242
- if (preview && !preview.missing && !preview.error) {
243
- const state: WorktreeReapState = {
244
- landed: preview.landed,
245
- ahead: preview.ahead,
246
- unmergedAhead: preview.unmergedAhead,
247
- dirtyCount: preview.dirtyCount,
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
- orphan.landed = preview.landed;
250
- orphan.ahead = preview.ahead;
251
- orphan.unmergedAhead = preview.unmergedAhead;
252
- orphan.dirty = (preview.dirtyCount ?? 0) > 0;
253
- orphan.safeToReap = worktreeReapable(state);
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 = probedRepoRoots.has(resolve(ws.repoRoot)) || onDiskAll.has(resolve(ws.repoRoot));
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 = orchestrators.find((candidate) => candidate.apiUrl && isPathWithinBase(workspace.repoRoot, candidate.baseDir));
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 = orchestrators.find((candidate) => candidate.apiUrl && isPathWithinBase(workspace.repoRoot, candidate.baseDir));
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;
@@ -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().find(
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