agent-relay-server 0.84.0 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/docs/openapi.json +39 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-BTBQr8uG.js → MessageBubble-CevL-2jc.js} +2 -2
  4. package/public/assets/{MessageBubble-BTBQr8uG.js.map → MessageBubble-CevL-2jc.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CoWJJVyD.js → PlanGraphPanel-DGpJhuVw.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CoWJJVyD.js.map → PlanGraphPanel-DGpJhuVw.js.map} +1 -1
  7. package/public/assets/{activity-XqRFu_9T.js → activity-CY2Cbpet.js} +2 -2
  8. package/public/assets/{activity-XqRFu_9T.js.map → activity-CY2Cbpet.js.map} +1 -1
  9. package/public/assets/{agent-profiles-RFCLAgnC.js → agent-profiles-CLJNeVxc.js} +2 -2
  10. package/public/assets/{agent-profiles-RFCLAgnC.js.map → agent-profiles-CLJNeVxc.js.map} +1 -1
  11. package/public/assets/{agents-BWmmWj4Q.js → agents-Bj5KzrYJ.js} +2 -2
  12. package/public/assets/{agents-BWmmWj4Q.js.map → agents-Bj5KzrYJ.js.map} +1 -1
  13. package/public/assets/{analytics-2H2z2UNS.js → analytics-C1F28oqr.js} +2 -2
  14. package/public/assets/{analytics-2H2z2UNS.js.map → analytics-C1F28oqr.js.map} +1 -1
  15. package/public/assets/{automation-DRtOENs0.js → automation-BOPk1oh_.js} +2 -2
  16. package/public/assets/{automation-DRtOENs0.js.map → automation-BOPk1oh_.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-DxwNcWr8.js → branch-state-badge-Q4W68jxj.js} +2 -2
  18. package/public/assets/{branch-state-badge-DxwNcWr8.js.map → branch-state-badge-Q4W68jxj.js.map} +1 -1
  19. package/public/assets/{channels-Sh_jdB7y.js → channels-BQbNtrtF.js} +2 -2
  20. package/public/assets/{channels-Sh_jdB7y.js.map → channels-BQbNtrtF.js.map} +1 -1
  21. package/public/assets/{chat-gfUxbTus.js → chat-D5pI4ebq.js} +2 -2
  22. package/public/assets/{chat-gfUxbTus.js.map → chat-D5pI4ebq.js.map} +1 -1
  23. package/public/assets/{connectors-C3C4i4Gz.js → connectors-CmARSKoV.js} +2 -2
  24. package/public/assets/{connectors-C3C4i4Gz.js.map → connectors-CmARSKoV.js.map} +1 -1
  25. package/public/assets/display-CWMHll1J.js.map +1 -1
  26. package/public/assets/{formatted-body-impl-DpNHGXXQ.js → formatted-body-impl-Dr_z1UvS.js} +2 -2
  27. package/public/assets/{formatted-body-impl-DpNHGXXQ.js.map → formatted-body-impl-Dr_z1UvS.js.map} +1 -1
  28. package/public/assets/index-CEXhh5nz.js +23 -0
  29. package/public/assets/index-CEXhh5nz.js.map +1 -0
  30. package/public/assets/index-LUIvWcx6.css +2 -0
  31. package/public/assets/{insights-DPaJ5Hme.js → insights-C4dEK49e.js} +2 -2
  32. package/public/assets/{insights-DPaJ5Hme.js.map → insights-C4dEK49e.js.map} +1 -1
  33. package/public/assets/{integrations-PqWMrPJa.js → integrations-D9Cx-7ma.js} +2 -2
  34. package/public/assets/{integrations-PqWMrPJa.js.map → integrations-D9Cx-7ma.js.map} +1 -1
  35. package/public/assets/{maintenance-DsszLb92.js → maintenance-DJZUSsja.js} +2 -2
  36. package/public/assets/{maintenance-DsszLb92.js.map → maintenance-DJZUSsja.js.map} +1 -1
  37. package/public/assets/{managed-agents-DqWPxFE0.js → managed-agents-5wUMDKVi.js} +2 -2
  38. package/public/assets/{managed-agents-DqWPxFE0.js.map → managed-agents-5wUMDKVi.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-CWR4ILHK.js → markdown-preview-impl-DOJQR3cO.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-CWR4ILHK.js.map → markdown-preview-impl-DOJQR3cO.js.map} +1 -1
  41. package/public/assets/{memory-C2s9bJ38.js → memory-BnhAD09c.js} +2 -2
  42. package/public/assets/{memory-C2s9bJ38.js.map → memory-BnhAD09c.js.map} +1 -1
  43. package/public/assets/{messages-DHRJNAvy.js → messages-Cc9YF8Wl.js} +2 -2
  44. package/public/assets/{messages-DHRJNAvy.js.map → messages-Cc9YF8Wl.js.map} +1 -1
  45. package/public/assets/{orchestrators-BGNNllJc.js → orchestrators-B01iyiBA.js} +2 -2
  46. package/public/assets/{orchestrators-BGNNllJc.js.map → orchestrators-B01iyiBA.js.map} +1 -1
  47. package/public/assets/{overview-C1vZfyhW.js → overview-DN8CUTVx.js} +2 -2
  48. package/public/assets/{overview-C1vZfyhW.js.map → overview-DN8CUTVx.js.map} +1 -1
  49. package/public/assets/{pairs-C1uzo8x0.js → pairs-BxuDMLhs.js} +2 -2
  50. package/public/assets/{pairs-C1uzo8x0.js.map → pairs-BxuDMLhs.js.map} +1 -1
  51. package/public/assets/{projects-B2-8Eu0e.js → projects-hU5KGW_T.js} +2 -2
  52. package/public/assets/{projects-B2-8Eu0e.js.map → projects-hU5KGW_T.js.map} +1 -1
  53. package/public/assets/{security-XhAAP6q3.js → security-CO0PBIau.js} +2 -2
  54. package/public/assets/{security-XhAAP6q3.js.map → security-CO0PBIau.js.map} +1 -1
  55. package/public/assets/{select-DEGUFEjx.js → select-DHaFsLvt.js} +2 -2
  56. package/public/assets/{select-DEGUFEjx.js.map → select-DHaFsLvt.js.map} +1 -1
  57. package/public/assets/{settings-DTbVUdlZ.js → settings-De1DKwd6.js} +2 -2
  58. package/public/assets/{settings-DTbVUdlZ.js.map → settings-De1DKwd6.js.map} +1 -1
  59. package/public/assets/{store-DYaQAXRv.js → store-DsqUEZOf.js} +2 -2
  60. package/public/assets/store-DsqUEZOf.js.map +1 -0
  61. package/public/assets/{tasks-zwGo7hon.js → tasks-Bqd188ql.js} +2 -2
  62. package/public/assets/{tasks-zwGo7hon.js.map → tasks-Bqd188ql.js.map} +1 -1
  63. package/public/assets/{teams-Brc5D8ox.js → teams-Mh6ypDPr.js} +2 -2
  64. package/public/assets/{teams-Brc5D8ox.js.map → teams-Mh6ypDPr.js.map} +1 -1
  65. package/public/assets/{terminal-viewer-impl-C6yUAL8G.js → terminal-viewer-impl-B7x_JNZe.js} +2 -2
  66. package/public/assets/{terminal-viewer-impl-C6yUAL8G.js.map → terminal-viewer-impl-B7x_JNZe.js.map} +1 -1
  67. package/public/assets/{user-avatar-Bw1R011j.js → user-avatar-Dy5J3E_b.js} +2 -2
  68. package/public/assets/{user-avatar-Bw1R011j.js.map → user-avatar-Dy5J3E_b.js.map} +1 -1
  69. package/public/assets/{work-queue-ge4R7keo.js → work-queue-ClGgOUSu.js} +2 -2
  70. package/public/assets/{work-queue-ge4R7keo.js.map → work-queue-ClGgOUSu.js.map} +1 -1
  71. package/public/assets/{workspaces-BhblC-Nd.js → workspaces-C740m1ys.js} +2 -2
  72. package/public/assets/{workspaces-BhblC-Nd.js.map → workspaces-C740m1ys.js.map} +1 -1
  73. package/public/index.html +3 -3
  74. package/runner/src/adapter.ts +4 -3
  75. package/src/agent-death-diagnosis.ts +255 -0
  76. package/src/agent-lifecycle-events.ts +47 -19
  77. package/src/branch-landed.ts +81 -0
  78. package/src/bus.ts +18 -4
  79. package/src/cli/index.ts +3 -3
  80. package/src/cli/reply.ts +6 -1
  81. package/src/config.ts +4 -0
  82. package/src/db/agents.ts +35 -2
  83. package/src/db/crash-reports.ts +115 -0
  84. package/src/db/index.ts +2 -0
  85. package/src/db/migrations.ts +22 -0
  86. package/src/db/provider-quotas.ts +99 -21
  87. package/src/db/terminal-events.ts +56 -0
  88. package/src/maintenance/constants.ts +4 -0
  89. package/src/maintenance/jobs.ts +19 -1
  90. package/src/maintenance/teams.ts +73 -1
  91. package/src/maintenance/workspaces.ts +23 -10
  92. package/src/mcp/index.ts +4 -2
  93. package/src/mcp/tools-messaging.ts +38 -0
  94. package/src/mcp/tools-spawn.ts +119 -4
  95. package/src/notification-types.ts +4 -0
  96. package/src/provider-catalog-store.ts +3 -0
  97. package/src/routes/agents.ts +11 -1
  98. package/src/routes/commands.ts +21 -3
  99. package/src/routes/index.ts +2 -1
  100. package/src/routes/orchestrator.ts +14 -1
  101. package/src/routes/workspaces.ts +5 -0
  102. package/src/services/terminal-provider-exit.ts +33 -0
  103. package/src/services/transient-agent-reaper.ts +4 -1
  104. package/src/spawn-targets.ts +25 -8
  105. package/src/steward-identity.ts +50 -0
  106. package/src/steward.ts +12 -4
  107. package/src/team-idle-tracker.ts +15 -0
  108. package/src/workspace-actions.ts +5 -1
  109. package/src/workspace-auto-merge.ts +120 -8
  110. package/src/workspace-merge.ts +10 -0
  111. package/src/workspace-pr-completion.ts +26 -0
  112. package/public/assets/index-D8TVUdlC.js +0 -22
  113. package/public/assets/index-D8TVUdlC.js.map +0 -1
  114. package/public/assets/index-DgZLDL0K.css +0 -2
  115. package/public/assets/store-DYaQAXRv.js.map +0 -1
@@ -0,0 +1,50 @@
1
+ // The steward ↔ branch-agent ↔ coordinator identity triangle (#639/#640).
2
+ //
3
+ // When the relay hands a workspace to a steward, the steward used to know only the
4
+ // `repoRoot`. To make a handoff legible end-to-end it needs the people-graph:
5
+ // - the workspace's BRANCH AGENT = `ws.ownerAgentId` (already stored on the row), and
6
+ // - that agent's COORDINATOR = the owner's authoritative `spawnedBy` parent (the agent
7
+ // that spawned the branch worker), resolved to a LIVE instance via the shared
8
+ // agent-ref matchers so a coordinator that restarted under a new session id still
9
+ // resolves to its live twin.
10
+ //
11
+ // One home for this resolution so the wake path, the diagnostics/inspect surface, and the
12
+ // escalation path can never drift on "who is the coordinator for this workspace?".
13
+
14
+ import { getAgent, listAgents } from "./db";
15
+ import { resolveAgentRef } from "./agent-ref";
16
+ import type { WorkspaceRecord } from "./types";
17
+
18
+ export interface StewardIdentityTriangle {
19
+ /** The workspace's branch agent (owner), or undefined when the row has no owner. */
20
+ ownerAgentId?: string;
21
+ /** The branch agent's coordinator (owner's `spawnedBy` lineage) resolved to a live id, or null when none resolves. */
22
+ coordinatorAgentId: string | null;
23
+ }
24
+
25
+ /**
26
+ * Resolve the branch agent's coordinator: the owner's authoritative `spawnedBy` parent,
27
+ * preferring a live instance. Returns the recorded parent id when no live match exists
28
+ * (store-ahead still delivers to a momentarily-offline coordinator), or null when the
29
+ * owner is unknown or wasn't spawned by anyone (e.g. a human-launched agent).
30
+ */
31
+ export function resolveCoordinatorAgentId(ownerAgentId: string | undefined): string | null {
32
+ if (!ownerAgentId) return null;
33
+ const spawnedBy = getAgent(ownerAgentId)?.spawnedBy?.trim();
34
+ if (!spawnedBy) return null;
35
+ const resolved = resolveAgentRef(spawnedBy, listAgents());
36
+ if (resolved.status === "resolved") return resolved.agent.id;
37
+ if (resolved.status === "ambiguous") {
38
+ // Prefer the exact recorded id among live twins; else the most-recently-seen.
39
+ return (resolved.candidates.find((a) => a.id === spawnedBy) ?? resolved.candidates[0])!.id;
40
+ }
41
+ // No live match — keep the authoritative recorded parent id so escalation/handoff
42
+ // notices still address the coordinator (store-ahead delivers when it reconnects).
43
+ return spawnedBy;
44
+ }
45
+
46
+ /** Resolve the full identity triangle for a workspace handoff (#640). */
47
+ export function resolveStewardIdentity(ws: Pick<WorkspaceRecord, "ownerAgentId">): StewardIdentityTriangle {
48
+ const ownerAgentId = ws.ownerAgentId ?? undefined;
49
+ return { ownerAgentId, coordinatorAgentId: resolveCoordinatorAgentId(ownerAgentId) };
50
+ }
package/src/steward.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { basename, resolve } from "node:path";
3
+ import { stewardFallbackTarget } from "./config";
3
4
  import { getSpawnPolicy, getStewardConfig, setConfig } from "./config-store";
4
5
  import { listOrchestrators } from "./db";
5
6
  import { getLifecycleManager } from "./lifecycle-manager";
@@ -20,23 +21,30 @@ export function repoStewardPolicyName(repoRoot: string): string {
20
21
  * its diagnostics, rebase/resolve/check, land green ones, release on escalation.
21
22
  */
22
23
  export function buildStewardPrompt(repoRoot: string): string {
24
+ // #642 — escalation routes to the branch agent's COORDINATOR (named per-workspace in the
25
+ // wake/task message via #640), not a static `system` dead-end. The fallback target below is
26
+ // used ONLY when a wake message names no coordinator.
27
+ const fallback = stewardFallbackTarget();
28
+ const fallbackTarget = fallback ? `\`${fallback}\`` : "the human operator who configured this relay";
23
29
  return [
24
30
  `You are the autonomous repository steward for ${repoRoot}.`,
25
31
  "",
26
32
  "Multiple agents work this repo in isolated git worktrees. The relay auto-merges any branch that rebases cleanly — including ones whose base moved on; it hands YOU only what it can't land deterministically: real merge conflicts, or an unknown/ambiguous merge state. Your job is to land that work with no human in the loop whenever you safely can.",
27
33
  "",
34
+ "Each workspace belongs to a BRANCH AGENT (the owner who did the work) who in turn has a COORDINATOR (the agent that spawned it and is responsible for the merge landing). Your wake/task message names both — the `ownerAgentId` and the `coordinatorAgentId` — and `agent-relay steward inspect <id>` shows them. When you can't land work yourself, the coordinator is who you escalate to.",
35
+ "",
28
36
  "Use the `agent-relay` CLI for EVERY relay interaction — it wraps the relay API and is the supported toolkit, and it already attaches your session token with the right scopes. Do NOT hand-curl the HTTP API (`/api/workspaces`, …): the CLI exists so you never have to. If you ever truly must call the API directly, authenticate with the `X-Agent-Relay-Token: <your session token>` header — NOT `Authorization: Bearer $AGENT_RELAY_TOKEN`; the admin env token is not present in your environment, so Bearer will 401.",
29
37
  "Handle ONE workspace at a time — the relay's per-repo merge lease serializes you with any other merger.",
30
38
  "",
31
39
  "1. See the queue: `agent-relay steward queue` (workspaces in conflict / review_requested / merge_planned for this repo). The wake message(s) also name a `workspaceId`.",
32
40
  "2. CLAIM it FIRST: `agent-relay workspace claim --id <id> --purpose steward`. This stops the deterministic auto-merge from racing you while you validate. The claim auto-expires, so a crash never blocks the repo.",
33
- "3. Understand it: `agent-relay steward inspect <id>` — a diagnostics briefing (owner liveness, live git state, ahead/behind, recorded-vs-live branch mismatch, and a recommended action). `agent-relay steward checks <id>` suggests check commands from the changed files.",
34
- "4. `cd` into the workspace's `worktreePath` and rebase the branch onto its `baseRef` (`git rebase <baseRef>`). Resolve conflicts faithfully — preserve the intent of EVERY side; never silently drop a change. If a conflict needs product judgment you can't make confidently, stop and escalate.",
41
+ "3. Understand it: `agent-relay steward inspect <id>` — a diagnostics briefing (the branch agent owner + its coordinator, owner liveness, live git state, ahead/behind, recorded-vs-live branch mismatch, and a recommended action). `agent-relay steward checks <id>` suggests check commands from the changed files.",
42
+ "4. `cd` into the workspace's `worktreePath` and rebase the branch onto its `baseRef` (`git rebase <baseRef>`). Resolve conflicts faithfully — preserve the intent of EVERY side; never silently drop a change. If a conflict needs product judgment you can't make confidently, stop and escalate to the coordinator.",
35
43
  "5. Run the repo's checks/tests (the suggested ones, plus the project's documented commands). Fix trivial breakage you caused while rebasing; do not paper over real failures.",
36
44
  "6. If green, land it: `agent-relay workspace land --id <id> --strategy rebase-ff`. The relay rebases onto the latest base, fast-forwards, and pushes to origin under the lease. The land consumes your claim.",
37
- "7. If you must stop without landing, RELEASE the claim so automation can resume: `agent-relay workspace release --id <id>`, then send a clear, specific summary to the fallback/human target.",
45
+ "7. If you must stop without landing, RELEASE the claim so automation can resume: `agent-relay workspace release --id <id>`, then send a clear, specific summary to the workspace's COORDINATOR (the `coordinatorAgentId` from the wake message) — they own the work and can act on it. Reply to the wake message or message that agent id directly.",
38
46
  "",
39
- "Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky. Never force-merge a red branch, never `git push --force` to a shared base, never discard committed work.",
47
+ `Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky. ALWAYS escalate to the branch agent's coordinator (the \`coordinatorAgentId\` in the wake message). Only if the wake message names NO coordinator, escalate to ${fallbackTarget}. Never force-merge a red branch, never \`git push --force\` to a shared base, never discard committed work.`,
40
48
  "",
41
49
  "Be terminal-efficient and decisive. When the queue is empty, you're done — you'll be woken again when there's more.",
42
50
  ].join("\n");
@@ -11,3 +11,18 @@ export const teamIdleTracker = new Map<string, TeamIdleEntry>();
11
11
  export function getTeamIdleEntry(teamId: string): TeamIdleEntry | undefined {
12
12
  return teamIdleTracker.get(teamId);
13
13
  }
14
+
15
+ // #634 — per-member heartbeat-gap watch. Kept in-memory (not derived from the live roster) ON
16
+ // PURPOSE: a member that was ready/busy and then vanished from the roster entirely escapes any
17
+ // roster-only enumeration, so the watch must remember it independently to detect the silence gap.
18
+ export interface TeamMemberWatch {
19
+ /** Most recent time the member was seen alive (non-offline heartbeat). */
20
+ lastActiveAt: number;
21
+ /** Whether the member's CURRENT silence has already pinged the coordinator (re-armed on recovery). */
22
+ pinged: boolean;
23
+ /** Member label for a readable ping. */
24
+ label?: string;
25
+ }
26
+
27
+ // teamId → (memberId → watch)
28
+ export const teamMemberWatchTracker = new Map<string, Map<string, TeamMemberWatch>>();
@@ -25,7 +25,7 @@ import { isOwnerAlive, requestWorkspaceMerge } from "./workspace-merge";
25
25
  export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
26
26
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
27
27
  import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
28
- import { scheduleRepoMerge } from "./workspace-auto-merge";
28
+ import { CLEAR_MERGE_NO_PROGRESS, scheduleRepoMerge } from "./workspace-auto-merge";
29
29
  import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
30
30
 
31
31
  // Single source of truth for the action verb set. The route's `optionalEnum` and
@@ -235,6 +235,10 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
235
235
  ...metadata,
236
236
  ...(detail ? { detail } : {}),
237
237
  ...(agentId ? { updatedByAgentId: agentId } : {}),
238
+ // #638 — a fresh ready/request-review hand-off means the owner did more work, so a
239
+ // prior auto-merge no-progress back-off should clear: the new commits may resolve
240
+ // whatever blocked the land (e.g. a base that's since been synced). Re-arm the lane.
241
+ ...(READY_TO_LAND_STATUSES.has(nextStatus) ? CLEAR_MERGE_NO_PROGRESS : {}),
238
242
  lastWorkspaceAction: action,
239
243
  lastWorkspaceActionAt: Date.now(),
240
244
  });
@@ -25,6 +25,8 @@ import {
25
25
  listWorkspaces,
26
26
  patchWorkspaceMetadata,
27
27
  } from "./db";
28
+ import { notifyBranchLandFailed, notifyStewardHandoff } from "./branch-landed";
29
+ import { resolveStewardIdentity } from "./steward-identity";
28
30
  import { emitCommandEvent } from "./command-events";
29
31
  import { getLandingConfig, getStewardConfig } from "./config-store";
30
32
  import { getLifecycleManager } from "./lifecycle-manager";
@@ -36,7 +38,7 @@ import { workspaceActiveClaim } from "./workspace-claim";
36
38
  import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES } from "./workspace-phase";
37
39
  import { fetchHostMergePreview, workspacePathWithinBase } from "./workspace-probe";
38
40
  import type { WorkspaceMergePreview, WorkspaceRecord } from "./types";
39
- import { STEWARD_WAKE_COOLDOWN_MS, workspaceAutoMergeEnabled } from "./config";
41
+ import { STEWARD_WAKE_COOLDOWN_MS, stewardFallbackTarget, workspaceAutoMergeEnabled } from "./config";
40
42
 
41
43
  // Don't re-wake the managed steward for the same workspace more than once per this
42
44
  // window — a persistent conflict/behind row would otherwise re-ping every sweep.
@@ -51,26 +53,126 @@ export function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | n
51
53
  if (lastWoke && Date.now() - lastWoke < STEWARD_WAKE_COOLDOWN_MS) return null;
52
54
  const policyName = ensureRepoSteward(ws.repoRoot);
53
55
  if (!policyName) return null;
56
+ // #640 — resolve the identity triangle so the steward isn't blind to the people-graph:
57
+ // the workspace's branch agent (owner) and that agent's coordinator (owner's `spawnedBy`).
58
+ const { ownerAgentId, coordinatorAgentId } = resolveStewardIdentity(ws);
59
+ const coordinatorLine = coordinatorAgentId
60
+ ? ` Escalate to the branch agent's coordinator \`${coordinatorAgentId}\` (not the system fallback) if you can't land it.`
61
+ : "";
62
+ const ownerLine = ownerAgentId ? ` Branch agent (owner): \`${ownerAgentId}\`.` : "";
54
63
  try {
55
64
  // Obligation (ttl null by type default): a steward task must survive until claimed/handled.
56
65
  routeNotification({
57
66
  type: "workspace.steward_task",
58
- scope: { workspaceId: ws.id, repoRoot: ws.repoRoot },
67
+ scope: { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ownerAgentId, parent: coordinatorAgentId ?? undefined },
59
68
  recipients: [`policy:${policyName}`],
60
69
  message: {
61
70
  subject: `Steward: ${ws.status} workspace needs attention`,
62
- body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
63
- payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
71
+ body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}).${ownerLine} Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.${coordinatorLine}`,
72
+ payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason, ownerAgentId, coordinatorAgentId },
64
73
  },
65
74
  });
66
75
  getLifecycleManager().onMessageForPolicy(policyName);
67
- patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName });
76
+ patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName, coordinatorAgentId: coordinatorAgentId ?? undefined });
77
+ // #641/#642 — tell the owner which steward took over and proactively loop in its coordinator.
78
+ notifyStewardHandoff({ workspace: ws, stewardId: policyName, coordinatorAgentId, reason });
68
79
  return policyName;
69
80
  } catch {
70
81
  return null;
71
82
  }
72
83
  }
73
84
 
85
+ // #638 — auto-merge loop breaker. A `workspace.merge` the orchestrator settles `failed`
86
+ // (no progress: origin moved ahead of local base, a replay conflict the clean-preview
87
+ // didn't predict, a push race) returns the workspace to a landable status
88
+ // (`review_requested`). The on-settle re-trigger (routes/commands.ts → scheduleRepoMerge)
89
+ // then re-dispatches it within a few seconds — forever. That's a silent merge-command
90
+ // storm that churns the per-repo lease and 409s every manual land (the live incident).
91
+ // Cap consecutive no-progress attempts per workspace; past the cap, stop dispatching and
92
+ // escalate. The count is incremented by the merge-result handler (server owns the
93
+ // transition) and cleared on genuine progress or a fresh ready/request-review hand-off.
94
+ export const MAX_NO_PROGRESS_MERGE_ATTEMPTS = 3;
95
+
96
+ // Don't re-escalate a backed-off lane more than once per this window.
97
+ const NO_PROGRESS_ESCALATION_COOLDOWN_MS = 30 * 60 * 1000;
98
+
99
+ /** The recorded count of consecutive no-progress merge attempts for a workspace (#638). */
100
+ export function mergeNoProgressCount(metadata: Record<string, unknown>): number {
101
+ const n = metadata.autoMergeNoProgressCount;
102
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : 0;
103
+ }
104
+
105
+ /** Metadata patch recording one more no-progress merge attempt (#638). Applied by the
106
+ * merge-result handler when a dispatched merge settles `failed` without landing. */
107
+ export function noteMergeNoProgress(metadata: Record<string, unknown>): Record<string, unknown> {
108
+ return { autoMergeNoProgressCount: mergeNoProgressCount(metadata) + 1, autoMergeNoProgressAt: Date.now() };
109
+ }
110
+
111
+ /** Metadata patch that clears the no-progress back-off — genuine progress (a land/recycle)
112
+ * or a fresh hand-off means the lane is worth attempting again (#638). */
113
+ export const CLEAR_MERGE_NO_PROGRESS: Record<string, unknown> = {
114
+ autoMergeNoProgressCount: 0,
115
+ autoMergeNoProgressAt: undefined,
116
+ autoMergeBackedOffAt: undefined,
117
+ };
118
+
119
+ // Surface a lane auto-merge can't advance (#638): wake the repo steward (if enabled) and
120
+ // route a stranded-escalation notice to the configured fallback / workspace owner, so a
121
+ // no-progress lane raises attention instead of churning silently. Cooldown-guarded so a
122
+ // persistently-stuck row isn't re-escalated every sweep.
123
+ function escalateNoProgressMerge(ws: WorkspaceRecord): void {
124
+ const meta = ws.metadata as Record<string, unknown>;
125
+ const lastEscalated = typeof meta.autoMergeBackedOffAt === "number" ? meta.autoMergeBackedOffAt : 0;
126
+ if (lastEscalated && Date.now() - lastEscalated < NO_PROGRESS_ESCALATION_COOLDOWN_MS) return;
127
+ const mergeError = typeof meta.mergeError === "string" && meta.mergeError ? meta.mergeError : "merge made no progress";
128
+ if (getStewardConfig().enabled) {
129
+ wakeRepoSteward(ws, `auto-merge backed off after ${MAX_NO_PROGRESS_MERGE_ATTEMPTS} no-progress attempts (${mergeError})`);
130
+ }
131
+ // Emit the owner-facing `landed-failure` wake — the SAME actionable notice the live
132
+ // merge_planned→failed settle fires (routes/commands.ts → notifyBranchLandFailed). The
133
+ // loop-breaker stops dispatching once a lane hits the no-progress cap, so without this
134
+ // the dispatch-coupled wake never fires again and a backed-off lane (e.g. a dirty base
135
+ // checkout, #638/#644) goes silent until a human re-arms it via manual request-review.
136
+ // The branch worker is parked specifically on `landed-failure`; the `stranded_escalation`
137
+ // below is a separate fallback/steward signal, not the wake the owner awaits. Cooldown is
138
+ // already enforced by the early-return above, so this fires at most once per backoff.
139
+ // (Coordinator routing — owner's `spawnedBy` — is the #642 coordinator-awareness epic.)
140
+ notifyBranchLandFailed({
141
+ workspace: ws,
142
+ reason: `auto-merge backed off after ${MAX_NO_PROGRESS_MERGE_ATTEMPTS} no-progress attempts (${mergeError})`,
143
+ });
144
+ // #642 — escalation must reach the branch agent's coordinator (owner's `spawnedBy`), not
145
+ // just the configured fallback. When steward is enabled, wakeRepoSteward above already
146
+ // notified the coordinator of the handoff; including it here covers the steward-disabled
147
+ // path too so a backed-off lane always reaches the agent responsible for the work.
148
+ const { coordinatorAgentId } = resolveStewardIdentity(ws);
149
+ const fallback = stewardFallbackTarget();
150
+ const recipients = [...new Set([coordinatorAgentId, fallback, ws.ownerAgentId].filter((r): r is string => Boolean(r)))];
151
+ if (recipients.length) {
152
+ routeNotification({
153
+ type: "workspace.stranded_escalation",
154
+ scope: { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ws.ownerAgentId, parent: coordinatorAgentId ?? undefined },
155
+ recipients,
156
+ message: {
157
+ subject: "Workspace can't auto-land — needs attention",
158
+ body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} failed to land ${MAX_NO_PROGRESS_MERGE_ATTEMPTS}× without progress (${mergeError}). Auto-merge has backed off to stop a merge-command loop. Sync \`${ws.baseRef ?? "base"}\` and land manually, or release the workspace.`,
159
+ payload: { kind: "workspace.auto-merge-stuck", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: ws.baseRef, attempts: MAX_NO_PROGRESS_MERGE_ATTEMPTS, reason: mergeError },
160
+ },
161
+ });
162
+ }
163
+ patchWorkspaceMetadata(ws.id, { autoMergeBackedOffAt: Date.now() });
164
+ createActivityEvent({
165
+ clientId: `workspace-auto-merge-backoff-${ws.id}-${Date.now()}`,
166
+ kind: "state",
167
+ title: "Auto-merge backed off (no progress)",
168
+ body: `${ws.branch ?? ws.id} → ${ws.baseRef ?? "base"} failed to land ${MAX_NO_PROGRESS_MERGE_ATTEMPTS}× without progress — stopping the merge loop (${mergeError})`,
169
+ meta: ws.branch ?? ws.id,
170
+ icon: "ti-alert-octagon",
171
+ view: "orchestrators",
172
+ metadata: { source: "server", maintenanceJobId: "workspace-auto-merge", workspaceId: ws.id, attempts: MAX_NO_PROGRESS_MERGE_ATTEMPTS },
173
+ });
174
+ }
175
+
74
176
  interface LandSweepResult {
75
177
  scanned: number;
76
178
  merged: string[];
@@ -78,12 +180,13 @@ interface LandSweepResult {
78
180
  heldByClaim: string[];
79
181
  heldByPause: string[];
80
182
  heldAwaitingApproval: string[];
183
+ backedOff: string[];
81
184
  leftForSteward: string[];
82
185
  wokeStewards: string[];
83
186
  }
84
187
 
85
188
  function emptyLandSweep(): LandSweepResult {
86
- return { scanned: 0, merged: [], heldByLease: [], heldByClaim: [], heldByPause: [], heldAwaitingApproval: [], leftForSteward: [], wokeStewards: [] };
189
+ return { scanned: 0, merged: [], heldByLease: [], heldByClaim: [], heldByPause: [], heldAwaitingApproval: [], backedOff: [], leftForSteward: [], wokeStewards: [] };
87
190
  }
88
191
 
89
192
  // Deterministic auto-land (Layer 0, #167/#207/#242): land conflict-free ready work
@@ -95,13 +198,22 @@ async function landCandidates(
95
198
  ): Promise<LandSweepResult> {
96
199
  const stewardEnabled = getStewardConfig().enabled;
97
200
  const merged: string[] = [];
98
- const heldByLease: string[] = [], heldByClaim: string[] = [], heldByPause: string[] = [], heldAwaitingApproval: string[] = [], leftForSteward: string[] = [], wokeStewards: string[] = [];
201
+ const heldByLease: string[] = [], heldByClaim: string[] = [], heldByPause: string[] = [], heldAwaitingApproval: string[] = [], backedOff: string[] = [], leftForSteward: string[] = [], wokeStewards: string[] = [];
99
202
 
100
203
  for (const ws of candidates) {
101
204
  if (isAwaitingReviewerApproval(ws)) { heldAwaitingApproval.push(ws.id); continue; }
102
205
  // A claimed workspace is being validated by a steward — don't race it (#208).
103
206
  if (workspaceActiveClaim(ws)) { heldByClaim.push(ws.id); continue; }
104
207
  if (getActiveMergePause(ws.repoRoot, ws.baseRef ?? "main")) { heldByPause.push(ws.id); continue; }
208
+ // #638 loop breaker: a lane whose last N merges settled `failed` without landing
209
+ // can't be advanced by re-dispatching — stop the storm, escalate once, and leave it
210
+ // for a steward/human. Cleared on genuine progress or a fresh hand-off (the agent did
211
+ // more work), so this is a back-off, not a permanent ban.
212
+ if (mergeNoProgressCount(ws.metadata) >= MAX_NO_PROGRESS_MERGE_ATTEMPTS) {
213
+ backedOff.push(ws.id);
214
+ escalateNoProgressMerge(ws);
215
+ continue;
216
+ }
105
217
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
106
218
  if (!orch?.apiUrl) continue;
107
219
  const preview = await fetchHostMergePreview(orch.apiUrl, ws);
@@ -164,7 +276,7 @@ async function landCandidates(
164
276
  });
165
277
  }
166
278
 
167
- return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, leftForSteward, wokeStewards };
279
+ return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, backedOff, leftForSteward, wokeStewards };
168
280
  }
169
281
 
170
282
  // Coalesced, fire-and-forget per-repo land trigger (see module header). A run already
@@ -3,6 +3,7 @@ import { isRecord, stringValue } from "agent-relay-sdk";
3
3
  import { createCommand } from "./commands-db";
4
4
  import {
5
5
  acquireMergeLease,
6
+ getActiveMergePause,
6
7
  getAgent,
7
8
  listAgents,
8
9
  listOrchestrators,
@@ -186,6 +187,15 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
186
187
  if (workspace.mode !== "isolated" || !workspace.worktreePath) {
187
188
  return { ok: false, status: 422, error: `workspace ${workspace.id} has no worktree to merge` };
188
189
  }
190
+ // #638 — an active merge-pause is the operator's tourniquet for a merge loop on this
191
+ // repo. The auto-merge job already honors it (workspace-auto-merge.ts), but gate the
192
+ // shared dispatcher too as defense-in-depth: never let an auto-merge-originated request
193
+ // acquire the lease + storm while paused, regardless of which caller dispatched it. A
194
+ // manual/steward land (any other `requestedBy`) is the intended RECOVERY path during a
195
+ // pause, so it's allowed through.
196
+ if (opts.requestedBy === "auto-merge" && getActiveMergePause(workspace.repoRoot, workspace.baseRef ?? "main")) {
197
+ return { ok: false, status: 409, error: `auto-merge paused for ${workspace.repoRoot}` };
198
+ }
189
199
  const lease = acquireMergeLease(workspace.repoRoot, workspace.id, opts.requestedBy);
190
200
  if (!lease.ok) {
191
201
  return {
@@ -155,6 +155,12 @@ function prRefreshForRelayMerge(workspace: WorkspaceRecord, preview: WorkspaceMe
155
155
  if (policy === "on-approval" && preview.reviewDecision !== "APPROVED") return { ok: false };
156
156
  if (preview.conflict === true) return { ok: false };
157
157
  if ((preview.behind ?? 0) <= 0) return { ok: false };
158
+ // #458: self-heal ONLY a PR that went red *purely because its base advanced* — its checks are
159
+ // failing AND the branch is behind base. A green (or still-pending) PR is never refreshed: it's
160
+ // left to the merge path / native auto-merge, so we don't re-trigger CI on a branch that isn't
161
+ // actually red. A red+up-to-date PR (behind=0, handled above) is red on its own merits, not by
162
+ // divergence, so it must not be refreshed into a loop either.
163
+ if (!statusChecksRed(preview.statusCheckRollup)) return { ok: false };
158
164
 
159
165
  const mergeable = text(preview.mergeable)?.toUpperCase();
160
166
  if (mergeable && mergeable !== "MERGEABLE") return { ok: false };
@@ -233,6 +239,26 @@ function statusChecksGreen(rollup: unknown): boolean {
233
239
  return rollup.every(statusNodeGreen);
234
240
  }
235
241
 
242
+ // "Red" = at least one check has a definitive FAILING conclusion/state. Distinct from
243
+ // `!statusChecksGreen` on purpose: a still-pending or no-checks-yet rollup is NOT red, so the
244
+ // #458 base-divergence refresh never interrupts an in-flight run or a PR that hasn't reported.
245
+ const FAILING_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"]);
246
+ const FAILING_STATES = new Set(["FAILURE", "ERROR"]);
247
+
248
+ function statusChecksRed(rollup: unknown): boolean {
249
+ if (!Array.isArray(rollup) || rollup.length === 0) return false;
250
+ return rollup.some(statusNodeRed);
251
+ }
252
+
253
+ function statusNodeRed(node: unknown): boolean {
254
+ if (!isRecord(node)) return false;
255
+ const conclusion = text(node.conclusion)?.toUpperCase();
256
+ if (conclusion) return FAILING_CONCLUSIONS.has(conclusion);
257
+ const state = text(node.state)?.toUpperCase();
258
+ if (state) return FAILING_STATES.has(state);
259
+ return false;
260
+ }
261
+
236
262
  function prMergeable(preview: WorkspaceMergePreview): boolean {
237
263
  const mergeable = text(preview.mergeable)?.toUpperCase();
238
264
  if (mergeable !== "MERGEABLE") return false;