agent-relay-server 0.120.0 → 0.120.2

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 (129) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/MessageBubble-DazQ9Rn-.js +2 -0
  4. package/public/assets/{MessageBubble-BJXjk3GB.js.map → MessageBubble-DazQ9Rn-.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-DpTiGCkz.js +2 -0
  6. package/public/assets/PlanGraphPanel-DpTiGCkz.js.map +1 -0
  7. package/public/assets/{activity-BUt4FiFQ.js → activity-DReCE2mK.js} +2 -2
  8. package/public/assets/{activity-BUt4FiFQ.js.map → activity-DReCE2mK.js.map} +1 -1
  9. package/public/assets/{agent-profiles-C1dvEaGS.js → agent-profiles-D3ba8Xpe.js} +2 -2
  10. package/public/assets/{agent-profiles-C1dvEaGS.js.map → agent-profiles-D3ba8Xpe.js.map} +1 -1
  11. package/public/assets/agents-BcsIxadm.js +2 -0
  12. package/public/assets/{agents-DvJZ0Tcz.js.map → agents-BcsIxadm.js.map} +1 -1
  13. package/public/assets/{analytics-DMLoMwta.js → analytics-0lOlkaib.js} +2 -2
  14. package/public/assets/{analytics-DMLoMwta.js.map → analytics-0lOlkaib.js.map} +1 -1
  15. package/public/assets/{automation-DICpRUGK.js → automation-D66mthBn.js} +2 -2
  16. package/public/assets/{automation-DICpRUGK.js.map → automation-D66mthBn.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-BYE2U_y4.js → branch-state-badge-NH-l2Eh3.js} +2 -2
  18. package/public/assets/{branch-state-badge-BYE2U_y4.js.map → branch-state-badge-NH-l2Eh3.js.map} +1 -1
  19. package/public/assets/{channels-3SlDQL3j.js → channels-BlHLMQVk.js} +2 -2
  20. package/public/assets/{channels-3SlDQL3j.js.map → channels-BlHLMQVk.js.map} +1 -1
  21. package/public/assets/chat-rwffRsPX.js +2 -0
  22. package/public/assets/chat-rwffRsPX.js.map +1 -0
  23. package/public/assets/{connectors-CrEW591Q.js → connectors-CdsiaLD3.js} +2 -2
  24. package/public/assets/{connectors-CrEW591Q.js.map → connectors-CdsiaLD3.js.map} +1 -1
  25. package/public/assets/{formatted-body-ChERiS0y.js → formatted-body-C1AprxyU.js} +3 -3
  26. package/public/assets/{formatted-body-ChERiS0y.js.map → formatted-body-C1AprxyU.js.map} +1 -1
  27. package/public/assets/formatted-body-impl-D_ZJQVNk.js +3 -0
  28. package/public/assets/formatted-body-impl-D_ZJQVNk.js.map +1 -0
  29. package/public/assets/{index-CtBqO8vm.js → index-DfPouNiV.js} +10 -10
  30. package/public/assets/index-DfPouNiV.js.map +1 -0
  31. package/public/assets/{insights-DBjaf3N5.js → insights-BJf1vwrf.js} +2 -2
  32. package/public/assets/{insights-DBjaf3N5.js.map → insights-BJf1vwrf.js.map} +1 -1
  33. package/public/assets/{integrations-B-bdliwU.js → integrations-BZSHasnY.js} +2 -2
  34. package/public/assets/{integrations-B-bdliwU.js.map → integrations-BZSHasnY.js.map} +1 -1
  35. package/public/assets/{maintenance-EWlKbynM.js → maintenance-uEI1lArh.js} +2 -2
  36. package/public/assets/{maintenance-EWlKbynM.js.map → maintenance-uEI1lArh.js.map} +1 -1
  37. package/public/assets/{managed-agents-DzjKwBh4.js → managed-agents-mX3hHpOP.js} +4 -4
  38. package/public/assets/{managed-agents-DzjKwBh4.js.map → managed-agents-mX3hHpOP.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-bwLB6Gc-.js → markdown-preview-impl-ClNwMC48.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-bwLB6Gc-.js.map → markdown-preview-impl-ClNwMC48.js.map} +1 -1
  41. package/public/assets/{memory-DEwJsFNK.js → memory-OfusEzGh.js} +2 -2
  42. package/public/assets/{memory-DEwJsFNK.js.map → memory-OfusEzGh.js.map} +1 -1
  43. package/public/assets/messages-DsQGj0gU.js +2 -0
  44. package/public/assets/{messages-BSMkT9X2.js.map → messages-DsQGj0gU.js.map} +1 -1
  45. package/public/assets/{orchestrators-Bl8k3KxQ.js → orchestrators-BwWyl4mB.js} +2 -2
  46. package/public/assets/{orchestrators-Bl8k3KxQ.js.map → orchestrators-BwWyl4mB.js.map} +1 -1
  47. package/public/assets/overview-CED1SeTY.js +2 -0
  48. package/public/assets/{overview-CdFe7Dmc.js.map → overview-CED1SeTY.js.map} +1 -1
  49. package/public/assets/{pairs-0ewxtvFZ.js → pairs-BNu6ZWg4.js} +2 -2
  50. package/public/assets/{pairs-0ewxtvFZ.js.map → pairs-BNu6ZWg4.js.map} +1 -1
  51. package/public/assets/{projects-DEn85LQb.js → projects-BmopRiUD.js} +2 -2
  52. package/public/assets/{projects-DEn85LQb.js.map → projects-BmopRiUD.js.map} +1 -1
  53. package/public/assets/{prompt-settings-CMQ2tHe0.js → prompt-settings-Ci2n1cFt.js} +2 -2
  54. package/public/assets/{prompt-settings-CMQ2tHe0.js.map → prompt-settings-Ci2n1cFt.js.map} +1 -1
  55. package/public/assets/{registry-DntwLc8a.js → registry-DwXnDIql.js} +2 -2
  56. package/public/assets/{registry-DntwLc8a.js.map → registry-DwXnDIql.js.map} +1 -1
  57. package/public/assets/security-AW5EbdOm.js +3 -0
  58. package/public/assets/security-AW5EbdOm.js.map +1 -0
  59. package/public/assets/{select-B19D5ToH.js → select-CEeWX4hp.js} +2 -2
  60. package/public/assets/{select-B19D5ToH.js.map → select-CEeWX4hp.js.map} +1 -1
  61. package/public/assets/settings-DYUdGz4G.js +6 -0
  62. package/public/assets/{settings-BzYvlKIL.js.map → settings-DYUdGz4G.js.map} +1 -1
  63. package/public/assets/store-BaslPLIP.js +9 -0
  64. package/public/assets/store-BaslPLIP.js.map +1 -0
  65. package/public/assets/{tasks-vZ5VRoO8.js → tasks-DxCsrY4g.js} +2 -2
  66. package/public/assets/{tasks-vZ5VRoO8.js.map → tasks-DxCsrY4g.js.map} +1 -1
  67. package/public/assets/{teams-E3rI5gkA.js → teams-CF7vi-oM.js} +2 -2
  68. package/public/assets/{teams-E3rI5gkA.js.map → teams-CF7vi-oM.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-24nB6STe.js → terminal-viewer-impl-Dy3vqOnW.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-24nB6STe.js.map → terminal-viewer-impl-Dy3vqOnW.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-Bql5E-C3.js → work-queue-BPaGXrCE.js} +2 -2
  73. package/public/assets/{work-queue-Bql5E-C3.js.map → work-queue-BPaGXrCE.js.map} +1 -1
  74. package/public/assets/{workspaces-DjQ6EdrZ.js → workspaces-BLEXgnlj.js} +2 -2
  75. package/public/assets/{workspaces-DjQ6EdrZ.js.map → workspaces-BLEXgnlj.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/runner/src/config.ts +24 -3
  79. package/src/automations/reconcile.ts +69 -4
  80. package/src/branch-landed.ts +18 -7
  81. package/src/bus.ts +23 -16
  82. package/src/db/connection.ts +29 -1
  83. package/src/db/maintenance-worker.ts +25 -0
  84. package/src/db/message-reads.ts +69 -44
  85. package/src/db/migrations.ts +1 -0
  86. package/src/db/provider-quotas.ts +6 -7
  87. package/src/db/schema.ts +2 -1
  88. package/src/maintenance/jobs.ts +3 -3
  89. package/src/mcp/index.ts +5 -5
  90. package/src/mcp/tools-workspace.ts +8 -3
  91. package/src/routes/orchestrator-proxy.ts +7 -6
  92. package/src/routes/tasks.ts +29 -0
  93. package/src/routes/workspaces.ts +9 -2
  94. package/src/runtime-tokens.ts +4 -0
  95. package/src/security.ts +31 -7
  96. package/src/server.ts +40 -18
  97. package/src/services/git-remote.ts +29 -0
  98. package/src/services/issue-lifecycle.ts +6 -6
  99. package/src/services/project.ts +47 -1
  100. package/src/services/send-message.ts +2 -4
  101. package/src/services/spawn-agent.ts +2 -2
  102. package/src/services/task-lifecycle.ts +6 -5
  103. package/src/settings/registry.ts +6 -4
  104. package/src/spawn-targets.ts +2 -2
  105. package/src/sse.ts +37 -18
  106. package/src/steward.ts +10 -6
  107. package/src/terminal-authz.ts +50 -0
  108. package/src/token-db.ts +3 -3
  109. package/src/validation.ts +1 -1
  110. package/src/workspace-actions.ts +46 -4
  111. package/src/workspace-auto-merge.ts +120 -13
  112. package/src/workspace-orphans.ts +1 -1
  113. package/src/workspace-probe.ts +19 -10
  114. package/public/assets/MessageBubble-BJXjk3GB.js +0 -2
  115. package/public/assets/PlanGraphPanel-DI5JOnm1.js +0 -2
  116. package/public/assets/PlanGraphPanel-DI5JOnm1.js.map +0 -1
  117. package/public/assets/agents-DvJZ0Tcz.js +0 -2
  118. package/public/assets/chat-_I9nvxj2.js +0 -2
  119. package/public/assets/chat-_I9nvxj2.js.map +0 -1
  120. package/public/assets/formatted-body-impl-CiaUIrP3.js +0 -3
  121. package/public/assets/formatted-body-impl-CiaUIrP3.js.map +0 -1
  122. package/public/assets/index-CtBqO8vm.js.map +0 -1
  123. package/public/assets/messages-BSMkT9X2.js +0 -2
  124. package/public/assets/overview-CdFe7Dmc.js +0 -2
  125. package/public/assets/security-CYwC-26O.js +0 -3
  126. package/public/assets/security-CYwC-26O.js.map +0 -1
  127. package/public/assets/settings-BzYvlKIL.js +0 -6
  128. package/public/assets/store-BuuJUn70.js +0 -9
  129. package/public/assets/store-BuuJUn70.js.map +0 -1
package/src/sse.ts CHANGED
@@ -11,20 +11,27 @@ interface Connection {
11
11
  agentId: string | null;
12
12
  controller: ReadableStreamDefaultController<Uint8Array>;
13
13
  keepalive: Timer;
14
- // Pending cross-tick backpressure re-check (#971 refinement). Set when a stream
15
- // dips below the queue floor during a synchronous fanout; cleared on the deferred
16
- // tick once we know whether it drained (healthy) or stayed stuck (stale drop).
14
+ queuedBytes: number;
15
+ // Pending backpressure re-check. Set when a stream crosses the byte floor; cleared
16
+ // once it drains or is closed after making no progress during the grace interval.
17
17
  drainCheck?: Timer;
18
+ drainCheckDesiredSize?: number | null;
18
19
  }
19
20
 
20
21
  const connections = new Map<string, Connection>();
21
22
  const encoder = new TextEncoder();
22
23
  const SSE_KEEPALIVE_MS = 15_000;
23
24
  const SSE_RETRY_MS = 5_000;
24
- const SSE_MAX_QUEUED_CHUNKS = 128;
25
+ const SSE_MAX_QUEUED_BYTES = 1024 * 1024;
26
+ const SSE_DRAIN_GRACE_MS = 2_000;
25
27
 
26
28
  subscribeRelayEvents((event) => fanout(event));
27
29
 
30
+ function fanoutAgent(agentId: string, cache: Map<string, AgentCard | null>): AgentCard | null {
31
+ if (!cache.has(agentId)) cache.set(agentId, getAgent(agentId));
32
+ return cache.get(agentId) ?? null;
33
+ }
34
+
28
35
  // #797 — let the db layer derive outbound-channel liveness from live SSE subscriptions.
29
36
  // A channel agent is "subscribed" iff the channels-host daemon holds an open
30
37
  // GET /api/events?for=<channelAgentId> stream (createSSEStream stores `for` as agentId).
@@ -48,7 +55,7 @@ export function createSSEStream(agentId: string | null): Response {
48
55
  sendEncoded(conn, encoder.encode(": keepalive\n\n"));
49
56
  }, SSE_KEEPALIVE_MS);
50
57
 
51
- connections.set(connId, { id: connId, agentId, controller, keepalive });
58
+ connections.set(connId, { id: connId, agentId, controller, keepalive, queuedBytes: 0 });
52
59
 
53
60
  controller.enqueue(encoder.encode(
54
61
  `retry: ${SSE_RETRY_MS}\nevent: connected\ndata: ${JSON.stringify({ connectionId: connId })}\n\n`
@@ -90,7 +97,7 @@ function sseFrame(event: string, data: unknown): Uint8Array {
90
97
  }
91
98
 
92
99
  function isBackpressured(conn: Connection): boolean {
93
- return conn.controller.desiredSize !== null && conn.controller.desiredSize < -SSE_MAX_QUEUED_CHUNKS;
100
+ return conn.queuedBytes >= SSE_MAX_QUEUED_BYTES && conn.controller.desiredSize !== null && conn.controller.desiredSize < 0;
94
101
  }
95
102
 
96
103
  function sendEncoded(conn: Connection, chunk: Uint8Array) {
@@ -101,26 +108,36 @@ function sendEncoded(conn: Connection, chunk: Uint8Array) {
101
108
  removeConnection(conn.id);
102
109
  return;
103
110
  }
104
- // #971 the queue only drains between event-loop ticks, so within a single
105
- // synchronous fanout (a batch loop emitting >SSE_MAX_QUEUED_CHUNKS frames)
106
- // desiredSize falls on liveness, not staleness. Closing inline here would kill
107
- // healthy, actively-read dashboards mid-burst. Instead defer the verdict: mark
108
- // the stream and re-check on a later tick, after the runtime has had a chance to
109
- // flush to the socket. Recovered → keep it; still stuck across ticks → drop it.
111
+ if (conn.controller.desiredSize !== null && conn.controller.desiredSize >= 0) conn.queuedBytes = 0;
112
+ else conn.queuedBytes += chunk.byteLength;
110
113
  if (isBackpressured(conn)) scheduleDrainCheck(conn);
111
114
  }
112
115
 
113
116
  function scheduleDrainCheck(conn: Connection) {
114
117
  if (conn.drainCheck) return; // one deferred verdict per stream is enough
118
+ conn.drainCheckDesiredSize = conn.controller.desiredSize;
115
119
  conn.drainCheck = setTimeout(() => {
116
120
  conn.drainCheck = undefined;
117
- // The connection may already have been removed (cancel/close) between ticks
121
+ // The connection may already have been removed (cancel/close) during the grace
118
122
  // closeConnection is idempotent, but bail early so we never touch a stale ref.
119
123
  if (connections.get(conn.id) !== conn) return;
120
- // Drained across the tick → genuinely healthy-but-slow. Stayed backpressured →
121
- // genuinely stalled/stale; drop it (the original #971 protection).
124
+ const desiredSize = conn.controller.desiredSize;
125
+ if (desiredSize !== null && desiredSize >= 0) {
126
+ conn.queuedBytes = 0;
127
+ conn.drainCheckDesiredSize = undefined;
128
+ return;
129
+ }
130
+ const improved = desiredSize !== null
131
+ && conn.drainCheckDesiredSize !== null
132
+ && conn.drainCheckDesiredSize !== undefined
133
+ && desiredSize > conn.drainCheckDesiredSize;
134
+ conn.drainCheckDesiredSize = undefined;
135
+ if (improved) {
136
+ scheduleDrainCheck(conn);
137
+ return;
138
+ }
122
139
  if (isBackpressured(conn)) closeConnection(conn.id);
123
- }, 0);
140
+ }, SSE_DRAIN_GRACE_MS);
124
141
  }
125
142
 
126
143
  function send(conn: Connection, event: string, data: unknown) {
@@ -147,13 +164,14 @@ function sendNewMessage(msg: Message): void {
147
164
  ? new Set(matchingDeliveryAgents("broadcast", { from: msg.from, channel: msg.channel }).map((agent) => agent.id))
148
165
  : null;
149
166
  const chunk = sseFrame("message.new", msg);
167
+ const agentCache = new Map<string, AgentCard | null>();
150
168
  for (const conn of connections.values()) {
151
169
  if (!conn.agentId) {
152
170
  sendEncoded(conn, chunk);
153
171
  continue;
154
172
  }
155
173
  if (scopedBroadcastAgentIds && !scopedBroadcastAgentIds.has(conn.agentId)) continue;
156
- if (!messageMatchesAgent(msg, conn.agentId, getAgent(conn.agentId))) continue;
174
+ if (!messageMatchesAgent(msg, conn.agentId, fanoutAgent(conn.agentId, agentCache))) continue;
157
175
  sendEncoded(conn, chunk);
158
176
  }
159
177
  }
@@ -263,8 +281,9 @@ export function emitMessageReactionUpdated(msg: Message) {
263
281
 
264
282
  function sendTaskChanged(task: Task, eventType = "task.updated"): void {
265
283
  const chunk = sseFrame(eventType, task);
284
+ const agentCache = new Map<string, AgentCard | null>();
266
285
  for (const conn of connections.values()) {
267
- if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId, getAgent(conn.agentId))) continue;
286
+ if (conn.agentId && !targetMatchesAgent(task.target, conn.agentId, fanoutAgent(conn.agentId, agentCache))) continue;
268
287
  sendEncoded(conn, chunk);
269
288
  }
270
289
  }
package/src/steward.ts CHANGED
@@ -12,10 +12,11 @@ import type { Orchestrator, SpawnPolicy, StewardConfig } from "./types";
12
12
  import { isPathWithinBase } from "./utils";
13
13
 
14
14
  /** Stable, readable, collision-resistant policy name for a repo's steward. */
15
- export function repoStewardPolicyName(repoRoot: string): string {
15
+ export function repoStewardPolicyName(repoRoot: string, orchestratorId?: string): string {
16
16
  const slug = basename(repoRoot).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
17
17
  const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 8);
18
- return `steward-${slug}-${hash}`;
18
+ const owner = orchestratorId?.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
19
+ return owner ? `steward-${slug}-${hash}-${owner}` : `steward-${slug}-${hash}`;
19
20
  }
20
21
 
21
22
  /**
@@ -54,7 +55,7 @@ function desiredStewardPolicy(repoRoot: string, owner: Orchestrator, config: Ste
54
55
  const resolved = resolveInternalSpawnTarget("landing-steward", pin);
55
56
 
56
57
  return {
57
- name: repoStewardPolicyName(repoRoot),
58
+ name: repoStewardPolicyName(repoRoot, owner.id),
58
59
  description: `Autonomous repo steward for ${repoRoot} (issue #167).`,
59
60
  enabled: true,
60
61
  orchestratorId: owner.id,
@@ -118,13 +119,16 @@ function stewardPolicyDiffers(existing: SpawnPolicy, desired: SpawnPolicy): bool
118
119
  * owns the repo. Idempotent — refreshes the policy only when config-derived fields
119
120
  * change, so it can be called on every sweep without churn.
120
121
  */
121
- export function ensureRepoSteward(repoRoot: string): string | null {
122
+ export function ensureRepoSteward(repoRoot: string, owningOrchestrator?: Orchestrator): string | null {
122
123
  const config = getStewardConfig();
123
124
  if (!config.enabled) return null;
124
- const owner = listOrchestrators().find((orch) => isPathWithinBase(repoRoot, orch.baseDir));
125
+ const owner = owningOrchestrator ?? listOrchestrators().find((orch) =>
126
+ orch.status === "online" && isPathWithinBase(repoRoot, orch.baseDir)
127
+ );
128
+ if (owner && (owner.status !== "online" || !isPathWithinBase(repoRoot, owner.baseDir))) return null;
125
129
  if (!owner) return null;
126
130
 
127
- const name = repoStewardPolicyName(repoRoot);
131
+ const name = repoStewardPolicyName(repoRoot, owner.id);
128
132
  const desired = desiredStewardPolicy(repoRoot, owner, config);
129
133
  const existing = getSpawnPolicy(name);
130
134
  if (existing && !stewardPolicyDiffers(existing.value, desired)) return name;
@@ -0,0 +1,50 @@
1
+ import { getOrchestrator } from "./db";
2
+ import { getIntegrationAuth, isFullReachRequest, isRequestAuthorizedFor } from "./security";
3
+
4
+ type TerminalAccessMode = "read" | "write";
5
+
6
+ interface TerminalOwner {
7
+ agentId: string;
8
+ policyName?: string;
9
+ spawnRequestId?: string;
10
+ }
11
+
12
+ function hasPresentedCredential(req: Request): boolean {
13
+ return Boolean(req.headers.get("authorization") || req.headers.get("x-agent-relay-token") || new URL(req.url).searchParams.get("token"));
14
+ }
15
+
16
+ function terminalOwner(orchestratorId: string, session: string): TerminalOwner | null {
17
+ const orch = getOrchestrator(orchestratorId);
18
+ if (!orch) return null;
19
+ for (const agent of orch.managedAgents ?? []) {
20
+ const sessions = [agent.terminalSession, agent.tmuxSession, agent.sessionName].filter((value): value is string => typeof value === "string" && value.length > 0);
21
+ if (!sessions.includes(session)) continue;
22
+ if (!agent.agentId) return null;
23
+ return {
24
+ agentId: agent.agentId,
25
+ ...(agent.policyName ? { policyName: agent.policyName } : {}),
26
+ ...(agent.spawnRequestId ? { spawnRequestId: agent.spawnRequestId } : {}),
27
+ };
28
+ }
29
+ return null;
30
+ }
31
+
32
+ export function isTerminalSessionRequestAuthorized(req: Request, orchestratorId: string, session: string, mode: TerminalAccessMode): boolean {
33
+ if (!hasPresentedCredential(req)) return true;
34
+ if (isFullReachRequest(req)) return true;
35
+ if (getIntegrationAuth(req)) return false;
36
+ const owner = terminalOwner(orchestratorId, session);
37
+ if (!owner) return false;
38
+ const terminalCapability = mode === "read" ? { terminalRead: true } : { terminalWrite: true };
39
+ return isRequestAuthorizedFor(req, {
40
+ scope: mode === "read" ? "terminal:read" : "terminal:write",
41
+ resource: {
42
+ orchestratorId,
43
+ agentId: owner.agentId,
44
+ policyName: owner.policyName,
45
+ spawnRequestId: owner.spawnRequestId,
46
+ terminalOwner: true,
47
+ ...terminalCapability,
48
+ },
49
+ });
50
+ }
package/src/token-db.ts CHANGED
@@ -67,8 +67,8 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
67
67
  name: "Orchestrator Host",
68
68
  description: "Host supervisor access for spawn, command, log, and terminal operations.",
69
69
  role: "orchestrator",
70
- scope: ["agent:read", "agent:write", "command:read", "command:write", "task:read", "task:write", "logs:read", "terminal:attach", "quota:write", "provisioning:read"],
71
- constraints: { logsRead: true, terminalAttach: true },
70
+ scope: ["agent:read", "agent:write", "command:read", "command:write", "task:read", "task:write", "logs:read", "terminal:attach", "terminal:read", "terminal:write", "quota:write", "provisioning:read"],
71
+ constraints: { logsRead: true, terminalAttach: true, terminalRead: true, terminalWrite: true },
72
72
  ttlSeconds: 30 * 24 * 60 * 60,
73
73
  builtIn: true,
74
74
  createdBy: "system",
@@ -100,7 +100,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
100
100
  description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
101
101
  role: "provider",
102
102
  scope: ["agent:read", "agent:write", "message:read", "message:send", "schedule:write", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "issue:read", "issue:write", "notifications:read", "notifications:write", "channel:egress", "insights:write", "quota:write"],
103
- constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
103
+ constraints: { terminalAttach: false, terminalRead: false, terminalWrite: false, logsRead: false, canDelegate: false },
104
104
  ttlSeconds: 30 * 24 * 60 * 60,
105
105
  builtIn: true,
106
106
  createdBy: "system",
package/src/validation.ts CHANGED
@@ -109,7 +109,7 @@ export function cleanTokenConstraints(value: unknown): TokenConstraints | undefi
109
109
  if (teamId) constraints.teamId = teamId;
110
110
  const tenantId = cleanString(value.tenantId, "constraints.tenantId", { max: 120 });
111
111
  if (tenantId) constraints.tenantId = tenantId;
112
- for (const key of ["terminalAttach", "logsRead", "canDelegate"] as const) {
112
+ for (const key of ["terminalAttach", "terminalRead", "terminalWrite", "logsRead", "canDelegate"] as const) {
113
113
  if (value[key] !== undefined) {
114
114
  if (typeof value[key] !== "boolean") throw new ValidationError(`constraints.${key} must be a boolean`);
115
115
  constraints[key] = value[key];
@@ -26,8 +26,9 @@ import { isOwnerAlive, requestWorkspaceMerge } from "./workspace-merge";
26
26
  // tuple as the HTTP route without a second import hop (one source: workspace-merge, #304).
27
27
  export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
28
28
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
29
- import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
29
+ import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES, worktreeReapable } from "./workspace-phase";
30
30
  import { CLEAR_MERGE_NO_PROGRESS, scheduleRepoMerge } from "./workspace-auto-merge";
31
+ import { fetchHostMergePreview } from "./workspace-probe";
31
32
  import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
32
33
 
33
34
  // Single source of truth for the action verb set. The route's `optionalEnum` and
@@ -129,7 +130,28 @@ function recordWorkspaceAudit(a: {
129
130
  }
130
131
  }
131
132
 
132
- export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWorkspaceActionInput): WorkspaceActionResult {
133
+ function metadataNumber(metadata: Record<string, unknown>, key: string): number | undefined {
134
+ const value = metadata[key];
135
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
136
+ }
137
+
138
+ async function cleanupWorkSafety(workspace: WorkspaceRecord): Promise<{ ok: true } | { ok: false; error: string }> {
139
+ const owner = workspaceOwnerOrchestrator(workspace, listOrchestrators(), { requireApiUrl: true });
140
+ if (!owner?.apiUrl) return { ok: false, error: "owning orchestrator unavailable for cleanup safety probe" };
141
+ const preview = await fetchHostMergePreview(owner.apiUrl, workspace, { checkPr: false });
142
+ if (!preview) return { ok: false, error: "cleanup safety probe unavailable" };
143
+ if ("available" in preview && preview.available === false) return { ok: false, error: "cleanup safety probe unavailable" };
144
+ const p = preview as Exclude<typeof preview, { available: false }>;
145
+ if (p.missing) return { ok: true };
146
+ if (p.error) return { ok: false, error: `cleanup safety probe error: ${p.error}` };
147
+ if (worktreeReapable({ landed: p.landed, ahead: p.ahead, unmergedAhead: p.unmergedAhead, dirtyCount: p.dirtyCount })) return { ok: true };
148
+ const dirtyCount = p.dirtyCount ?? 0;
149
+ if (dirtyCount > 0) return { ok: false, error: `${dirtyCount} uncommitted change(s)` };
150
+ const ahead = p.unmergedAhead ?? p.ahead ?? 0;
151
+ return { ok: false, error: `${ahead} unlanded commit(s)` };
152
+ }
153
+
154
+ export async function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWorkspaceActionInput): Promise<WorkspaceActionResult> {
133
155
  const { action } = input;
134
156
  const agentId = input.agentId;
135
157
  const detail = input.detail;
@@ -228,6 +250,17 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
228
250
  const nextStatus = STATUS_BY_ACTION[action];
229
251
  if (!nextStatus) return { ok: false, httpStatus: 400, error: `unsupported action: ${action}` };
230
252
 
253
+ if (READY_TO_LAND_STATUSES.has(nextStatus)) {
254
+ const dirtyCount = metadataNumber(workspace.metadata, "gitDirtyCount") ?? 0;
255
+ if (dirtyCount > 0) {
256
+ return {
257
+ ok: false,
258
+ httpStatus: 409,
259
+ error: `workspace ${workspace.id} cannot be marked ${nextStatus}: ${DIRTY_WORKTREE_LAND_SKIP_REASON} (${dirtyCount} dirty file${dirtyCount === 1 ? "" : "s"}); commit or stash first`,
260
+ };
261
+ }
262
+ }
263
+
231
264
  if (
232
265
  action === "cleanup" &&
233
266
  input.force !== true &&
@@ -245,7 +278,15 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
245
278
  let command: Command | undefined;
246
279
  if (requiresCommand) {
247
280
  // Only `cleanup` reaches here — `merge` returned early via the shared helper.
248
- const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard", { force: input.force === true });
281
+ const safety = await cleanupWorkSafety(workspace);
282
+ if (!safety.ok && !(input.force === true && Boolean(detail?.trim()))) {
283
+ return {
284
+ ok: false,
285
+ httpStatus: 409,
286
+ error: `workspace ${workspace.id} cannot be cleaned up safely: ${safety.error}; pass force:true with a detail reason only if intentionally discarding work`,
287
+ };
288
+ }
289
+ const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard", { force: input.force === true, reason: detail });
249
290
  if (!built.ok) return { ok: false, httpStatus: built.status, error: built.error };
250
291
  command = built.command;
251
292
  }
@@ -287,7 +328,7 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
287
328
  export function buildWorkspaceCleanupCommand(
288
329
  workspace: WorkspaceRecord,
289
330
  requestedBy: string,
290
- opts: { force?: boolean } = {},
331
+ opts: { force?: boolean; reason?: string } = {},
291
332
  ): { ok: true; command: Command } | { ok: false; status: number; error: string } {
292
333
  if (
293
334
  opts.force !== true &&
@@ -319,6 +360,7 @@ export function buildWorkspaceCleanupCommand(
319
360
  requestedBy,
320
361
  requestedAt: Date.now(),
321
362
  deleteBranch: true,
363
+ ...(opts.force === true && opts.reason ? { force: true, reason: opts.reason } : {}),
322
364
  queued: false,
323
365
  },
324
366
  });
@@ -36,11 +36,14 @@ import { isAwaitingReviewerApproval } from "./reviewer-pipeline";
36
36
  import { emitWorkspaceTaskSemanticNotifications } from "./services/task-lifecycle";
37
37
  import { ensureRepoSteward } from "./steward";
38
38
  import { requestWorkspaceMerge, workspaceMergeOrchestrators } from "./workspace-merge";
39
+ import { workspaceOwnerOrchestrator } from "./workspace-host";
39
40
  import { workspaceActiveClaim } from "./workspace-claim";
40
41
  import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES } from "./workspace-phase";
41
42
  import { fetchHostMergePreview } from "./workspace-probe";
42
43
  import type { WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMergePreview, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
43
44
  import { STEWARD_WAKE_COOLDOWN_MS, stewardFallbackTarget, workspaceAutoMergeEnabled } from "./config";
45
+ import { isPathWithinBase } from "./utils";
46
+ import type { Orchestrator } from "./types";
44
47
 
45
48
  const WORKSPACE_LAND_STRATEGIES = ["direct", "pr"] as const;
46
49
  const WORKSPACE_AUTO_MERGE_POLICIES = ["on-green", "on-approval", "manual"] as const;
@@ -52,6 +55,21 @@ type AutoMergeRequestOptions = {
52
55
  reviewer?: string;
53
56
  };
54
57
 
58
+ function stewardOwnerForWorkspace(ws: WorkspaceRecord): { owner: Orchestrator } | { error: string } {
59
+ const orchestrators = listOrchestrators();
60
+ if (ws.orchestratorId) {
61
+ const owner = orchestrators.find((orch) => orch.id === ws.orchestratorId);
62
+ if (!owner) return { error: `cannot spawn steward: owning host ${ws.orchestratorId} is not registered` };
63
+ if (owner.status !== "online") return { error: `cannot spawn steward: owning host ${owner.id} is ${owner.status}` };
64
+ if (!isPathWithinBase(ws.worktreePath, owner.baseDir)) {
65
+ return { error: `cannot spawn steward: worktree is not on owning host ${owner.id}` };
66
+ }
67
+ return { owner };
68
+ }
69
+ const owner = workspaceOwnerOrchestrator(ws, orchestrators, { requireApiUrl: true });
70
+ return owner ? { owner } : { error: "cannot spawn steward: no online owner for workspace host path" };
71
+ }
72
+
55
73
  function workspaceLandStrategy(metadata: Record<string, unknown>): WorkspaceLandingPolicy | undefined {
56
74
  const value = metadata.landStrategy;
57
75
  return typeof value === "string" && (WORKSPACE_LAND_STRATEGIES as readonly string[]).includes(value)
@@ -91,8 +109,8 @@ function autoMergeRequestOptions(ws: WorkspaceRecord): AutoMergeRequestOptions {
91
109
 
92
110
  // Don't re-wake the managed steward for the same workspace more than once per this
93
111
  // window — a persistent conflict/behind row would otherwise re-ping every sweep.
94
- // Wake the managed per-repo steward (issue #167) for a workspace it should handle:
95
- // auto-provision the policy from global steward config, then queue a `policy:` wake
112
+ // Wake the managed per-host/repo steward (issue #167/#977) for a workspace it should handle:
113
+ // auto-provision the policy on the workspace-owning host, then queue a `policy:` wake
96
114
  // message (which also spawns the on-demand agent now via onMessageForPolicy). Honors a
97
115
  // per-workspace cooldown so a persistent conflict/behind row isn't re-pinged every sweep.
98
116
  // Returns the steward policy name on a fresh wake, or null (disabled / no owner / cooled down).
@@ -100,8 +118,16 @@ export function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | n
100
118
  const meta = ws.metadata as Record<string, unknown>;
101
119
  const lastWoke = typeof meta.stewardWokenAt === "number" ? meta.stewardWokenAt : 0;
102
120
  if (lastWoke && Date.now() - lastWoke < STEWARD_WAKE_COOLDOWN_MS) return null;
103
- const policyName = ensureRepoSteward(ws.repoRoot);
104
- if (!policyName) return null;
121
+ const ownerResult = stewardOwnerForWorkspace(ws);
122
+ if ("error" in ownerResult) {
123
+ patchWorkspaceMetadata(ws.id, { stewardWakeError: ownerResult.error, stewardWakeErrorAt: Date.now() });
124
+ return null;
125
+ }
126
+ const policyName = ensureRepoSteward(ws.repoRoot, ownerResult.owner);
127
+ if (!policyName) {
128
+ patchWorkspaceMetadata(ws.id, { stewardWakeError: "cannot spawn steward: steward policy could not be provisioned", stewardWakeErrorAt: Date.now() });
129
+ return null;
130
+ }
105
131
  // #640 — resolve the identity triangle so the steward isn't blind to the people-graph:
106
132
  // the workspace's branch agent (owner) and that agent's coordinator (owner's `spawnedBy`).
107
133
  const { ownerAgentId, coordinatorAgentId } = resolveStewardIdentity(ws);
@@ -132,11 +158,18 @@ export function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | n
132
158
  },
133
159
  });
134
160
  getLifecycleManager().onMessageForPolicy(policyName);
135
- patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName, coordinatorAgentId: coordinatorAgentId ?? undefined });
161
+ patchWorkspaceMetadata(ws.id, {
162
+ stewardWokenAt: Date.now(),
163
+ stewardPolicy: policyName,
164
+ coordinatorAgentId: coordinatorAgentId ?? undefined,
165
+ stewardWakeError: undefined,
166
+ stewardWakeErrorAt: undefined,
167
+ });
136
168
  // #641/#642 — tell the owner which steward took over and proactively loop in its coordinator.
137
169
  notifyStewardHandoff({ workspace: ws, stewardId: policyName, coordinatorAgentId, reason });
138
170
  return policyName;
139
- } catch {
171
+ } catch (e) {
172
+ patchWorkspaceMetadata(ws.id, { stewardWakeError: e instanceof Error ? e.message : "failed to wake steward", stewardWakeErrorAt: Date.now() });
140
173
  return null;
141
174
  }
142
175
  }
@@ -247,6 +280,36 @@ function escalateNoProgressMerge(ws: WorkspaceRecord): void {
247
280
  });
248
281
  }
249
282
 
283
+ function notifyProtectedTipRestore(ws: WorkspaceRecord, preview: WorkspaceMergePreview): void {
284
+ const discardedRef = preview.protectedTipDiscardedRef;
285
+ const discardedHead = preview.protectedTipDiscardedHead;
286
+ if (!discardedRef || !discardedHead) return;
287
+ const { coordinatorAgentId } = resolveStewardIdentity(ws);
288
+ const recipients = [...new Set([coordinatorAgentId, ws.ownerAgentId].filter((r): r is string => Boolean(r)))];
289
+ const body = `Workspace \`${ws.branch ?? ws.id}\` restored protected tip \`${preview.protectedTip?.slice(0, 12) ?? "unknown"}\`; discarded HEAD \`${discardedHead.slice(0, 12)}\` was preserved at \`${discardedRef}\`.`;
290
+ createActivityEvent({
291
+ clientId: `workspace-protected-tip-restore-${ws.id}-${Date.now()}`,
292
+ kind: "state",
293
+ title: "Protected workspace tip restored",
294
+ body,
295
+ meta: ws.branch ?? ws.id,
296
+ icon: "ti-shield",
297
+ view: "orchestrators",
298
+ metadata: { source: "server", maintenanceJobId: "workspace-auto-merge", workspaceId: ws.id, protectedTip: preview.protectedTip, discardedHead, discardedRef },
299
+ });
300
+ if (!recipients.length) return;
301
+ routeNotification({
302
+ type: "workspace.land_gate_warning",
303
+ scope: { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ws.ownerAgentId, parent: coordinatorAgentId ?? undefined },
304
+ recipients,
305
+ message: {
306
+ subject: "Workspace protected tip restored",
307
+ body,
308
+ payload: { kind: "workspace.protected-tip-restore", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, protectedTip: preview.protectedTip, discardedHead, discardedRef },
309
+ },
310
+ });
311
+ }
312
+
250
313
  interface LandSweepResult {
251
314
  scanned: number;
252
315
  merged: string[];
@@ -255,13 +318,14 @@ interface LandSweepResult {
255
318
  heldByPause: string[];
256
319
  heldAwaitingApproval: string[];
257
320
  previewUnavailable: string[];
321
+ dirtyWorktree: string[];
258
322
  backedOff: string[];
259
323
  leftForSteward: string[];
260
324
  wokeStewards: string[];
261
325
  }
262
326
 
263
327
  function emptyLandSweep(): LandSweepResult {
264
- return { scanned: 0, merged: [], heldByLease: [], heldByClaim: [], heldByPause: [], heldAwaitingApproval: [], previewUnavailable: [], backedOff: [], leftForSteward: [], wokeStewards: [] };
328
+ return { scanned: 0, merged: [], heldByLease: [], heldByClaim: [], heldByPause: [], heldAwaitingApproval: [], previewUnavailable: [], dirtyWorktree: [], backedOff: [], leftForSteward: [], wokeStewards: [] };
265
329
  }
266
330
 
267
331
  async function fetchFirstUsableMergePreview(
@@ -270,7 +334,7 @@ async function fetchFirstUsableMergePreview(
270
334
  ): Promise<WorkspaceMergePreview | { available: false } | null> {
271
335
  for (const orch of workspaceMergeOrchestrators(ws, orchestrators)) {
272
336
  if (!orch.apiUrl) continue;
273
- const preview = await fetchHostMergePreview(orch.apiUrl, ws);
337
+ const preview = await fetchHostMergePreview(orch.apiUrl, ws, { protectTip: true });
274
338
  if (!preview || (preview as { available?: false }).available === false) continue;
275
339
  const p = preview as WorkspaceMergePreview;
276
340
  if (p.error || p.missing) continue;
@@ -288,7 +352,7 @@ async function landCandidates(
288
352
  ): Promise<LandSweepResult> {
289
353
  const stewardEnabled = getStewardConfig().enabled;
290
354
  const merged: string[] = [], previewUnavailable: string[] = [];
291
- const heldByLease: string[] = [], heldByClaim: string[] = [], heldByPause: string[] = [], heldAwaitingApproval: string[] = [], backedOff: string[] = [], leftForSteward: string[] = [], wokeStewards: string[] = [];
355
+ const heldByLease: string[] = [], heldByClaim: string[] = [], heldByPause: string[] = [], heldAwaitingApproval: string[] = [], dirtyWorktree: string[] = [], backedOff: string[] = [], leftForSteward: string[] = [], wokeStewards: string[] = [];
292
356
 
293
357
  for (const ws of candidates) {
294
358
  if (isAwaitingReviewerApproval(ws)) { heldAwaitingApproval.push(ws.id); continue; }
@@ -310,8 +374,22 @@ async function landCandidates(
310
374
  continue;
311
375
  }
312
376
  const p = preview as WorkspaceMergePreview;
313
- if (p.reason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
314
- patchWorkspaceMetadata(ws.id, { lastLandSkipReason: p.reason, lastLandSkipAt: Date.now() });
377
+ if (p.protectedTipRestored) notifyProtectedTipRestore(ws, p);
378
+ const dirtyCount = p.dirtyCount ?? 0;
379
+ if (p.reason === DIRTY_WORKTREE_LAND_SKIP_REASON || dirtyCount > 0) {
380
+ const mergeError = p.reason ?? DIRTY_WORKTREE_LAND_SKIP_REASON;
381
+ const heldDirty = patchWorkspaceMetadata(ws.id, {
382
+ ...noteMergeNoProgress(ws.metadata),
383
+ mergeError,
384
+ lastLandSkipReason: DIRTY_WORKTREE_LAND_SKIP_REASON,
385
+ lastLandSkipAt: Date.now(),
386
+ }) ?? ws;
387
+ dirtyWorktree.push(ws.id);
388
+ if (mergeNoProgressCount(heldDirty.metadata) >= MAX_NO_PROGRESS_MERGE_ATTEMPTS) {
389
+ backedOff.push(ws.id);
390
+ escalateNoProgressMerge(heldDirty);
391
+ }
392
+ continue;
315
393
  } else if (ws.metadata.lastLandSkipReason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
316
394
  patchWorkspaceMetadata(ws.id, { lastLandSkipReason: undefined, lastLandSkipAt: undefined });
317
395
  }
@@ -328,11 +406,40 @@ async function landCandidates(
328
406
  // the steward every sweep for a workspace with no work.
329
407
  const canLand = p.noop === true || (p.conflict === false && ahead > 0);
330
408
  if (!canLand) {
409
+ let handoffWorkspace = ws;
410
+ if (p.conflict !== true) {
411
+ const mergeError = p.protectedTipReachable === false
412
+ ? p.protectedTipError ?? "protected worker tip is no longer reachable"
413
+ : p.reason ?? p.error ?? "merge state unknown";
414
+ handoffWorkspace = patchWorkspaceMetadata(ws.id, {
415
+ ...noteMergeNoProgress(ws.metadata),
416
+ mergeError,
417
+ lastMergeStateUnknownAt: Date.now(),
418
+ }) ?? ws;
419
+ if (mergeNoProgressCount(handoffWorkspace.metadata) >= MAX_NO_PROGRESS_MERGE_ATTEMPTS) {
420
+ backedOff.push(ws.id);
421
+ escalateNoProgressMerge(handoffWorkspace);
422
+ continue;
423
+ }
424
+ }
331
425
  leftForSteward.push(ws.id);
426
+ let woke: string | null = null;
332
427
  if (stewardEnabled) {
333
- const woke = wakeRepoSteward(ws, p.conflict === true ? "conflict" : "merge state unknown");
428
+ woke = wakeRepoSteward(handoffWorkspace, p.conflict === true ? "conflict" : "merge state unknown");
334
429
  if (woke) wokeStewards.push(woke);
335
430
  }
431
+ if (stewardEnabled && !woke && p.conflict === true) {
432
+ const failedHandoff = patchWorkspaceMetadata(ws.id, {
433
+ ...noteMergeNoProgress(ws.metadata),
434
+ mergeError: "cannot spawn steward on the workspace owning host",
435
+ lastStewardWakeFailedAt: Date.now(),
436
+ }) ?? ws;
437
+ if (mergeNoProgressCount(failedHandoff.metadata) >= MAX_NO_PROGRESS_MERGE_ATTEMPTS) {
438
+ backedOff.push(ws.id);
439
+ escalateNoProgressMerge(failedHandoff);
440
+ continue;
441
+ }
442
+ }
336
443
  if (p.conflict === true) {
337
444
  emitWorkspaceTaskSemanticNotifications({
338
445
  workspace: ws,
@@ -372,7 +479,7 @@ async function landCandidates(
372
479
  });
373
480
  }
374
481
 
375
- return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, previewUnavailable, backedOff, leftForSteward, wokeStewards };
482
+ return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, previewUnavailable, dirtyWorktree, backedOff, leftForSteward, wokeStewards };
376
483
  }
377
484
 
378
485
  // Coalesced, fire-and-forget per-repo land trigger (see module header). A run already
@@ -556,7 +556,7 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
556
556
  // Route through the guarded action (#279): no `force`, so a flipped-alive owner
557
557
  // is rejected (409) — our safety net. Also does the cleanup_requested transition
558
558
  // + audit. Attributed to a distinct requester so incidents are traceable.
559
- const result = applyWorkspaceAction(workspace, {
559
+ const result = await applyWorkspaceAction(workspace, {
560
560
  action: "cleanup",
561
561
  agentId: "workspace-dead-owner-reaper",
562
562
  auditMetadata: { source: "server", maintenanceJobId: "workspace-orphan-reaper", requestedBy: "workspace-dead-owner-reaper", deadOwner: workspace.ownerAgentId },
@@ -32,12 +32,16 @@ export const workspacePathWithinBase = isPathWithinBase;
32
32
  export async function fetchHostMergePreview(
33
33
  apiUrl: string,
34
34
  workspace: WorkspaceRecord,
35
- opts: { checkPr?: boolean } = {},
35
+ opts: { checkPr?: boolean; protectTip?: boolean } = {},
36
36
  ): Promise<WorkspaceMergePreview | { available: false } | null> {
37
37
  const checkPr = opts.checkPr !== false;
38
38
  const query = new URLSearchParams({ path: workspace.worktreePath, ...(checkPr ? { checkPr: "1" } : {}) });
39
39
  if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
40
40
  if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
41
+ if (opts.protectTip) {
42
+ query.set("workspaceId", workspace.id);
43
+ query.set("protectTip", "1");
44
+ }
41
45
  const headers: Record<string, string> = {};
42
46
  const token = relayToken();
43
47
  if (token) headers[RELAY_TOKEN_HEADER] = token;
@@ -84,6 +88,18 @@ interface ProbeResult {
84
88
  skipped?: string;
85
89
  }
86
90
 
91
+ export async function refreshWorkspaceGitState(ws: WorkspaceRecord): Promise<ProbeResult> {
92
+ if (!ws.worktreePath) return { probed: false, workspaceId: ws.id, skipped: "workspace has no worktree" };
93
+ const orch = workspaceOwnerOrchestrator(ws, listOrchestrators(), { requireApiUrl: true });
94
+ if (!orch?.apiUrl) return { probed: false, workspaceId: ws.id, skipped: "no online orchestrator owns this path" };
95
+ const preview = await fetchHostMergePreview(orch.apiUrl, ws, { checkPr: false });
96
+ if (!preview || (preview as { available?: false }).available === false) return { probed: false, workspaceId: ws.id, skipped: "host preview unavailable" };
97
+ const p = preview as WorkspaceMergePreview;
98
+ if (p.error || p.missing) return { probed: false, workspaceId: ws.id, skipped: "host preview error" };
99
+ const flipped = applyBranchFreshness(ws, p);
100
+ return { probed: true, flipped, workspaceId: ws.id };
101
+ }
102
+
87
103
  /**
88
104
  * Probe THIS agent's branch worktree on turn-end (#237). Single scoped host round-trip
89
105
  * (not the full fan-out scan) so the changes badge flips at the exact moment work
@@ -96,15 +112,8 @@ export async function probeWorkspaceState(agentId: string): Promise<ProbeResult>
96
112
  const ws = ownedIsolatedWorkspace(agentId);
97
113
  if (!ws || !ws.worktreePath) return { probed: false, skipped: "no live isolated workspace" };
98
114
  if (ws.status !== "active") return { probed: false, workspaceId: ws.id, skipped: `status ${ws.status} derives badge without git counts` };
99
- const orch = workspaceOwnerOrchestrator(ws, listOrchestrators(), { requireApiUrl: true });
100
- if (!orch?.apiUrl) return { probed: false, workspaceId: ws.id, skipped: "no online orchestrator owns this path" };
101
- // checkPr:false — a badge probe never needs the gh round-trip (#304).
102
- const preview = await fetchHostMergePreview(orch.apiUrl, ws, { checkPr: false });
103
- if (!preview || (preview as { available?: false }).available === false) return { probed: false, workspaceId: ws.id, skipped: "host preview unavailable" };
104
- const p = preview as WorkspaceMergePreview;
105
- if (p.error || p.missing) return { probed: false, workspaceId: ws.id, skipped: "host preview error" };
106
- const flipped = applyBranchFreshness(ws, p);
107
- return { probed: true, flipped, workspaceId: ws.id };
115
+ // checkPr:false a badge/readiness probe never needs the gh round-trip (#304).
116
+ return refreshWorkspaceGitState(ws);
108
117
  }
109
118
 
110
119
  /**