agent-relay-server 0.120.1 → 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.
- package/docs/openapi.json +1 -1
- package/package.json +3 -3
- package/public/assets/MessageBubble-DazQ9Rn-.js +2 -0
- package/public/assets/{MessageBubble-jpshgh3K.js.map → MessageBubble-DazQ9Rn-.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CjVG-Jsq.js → PlanGraphPanel-DpTiGCkz.js} +2 -2
- package/public/assets/{PlanGraphPanel-CjVG-Jsq.js.map → PlanGraphPanel-DpTiGCkz.js.map} +1 -1
- package/public/assets/{activity-DLTFF8Dz.js → activity-DReCE2mK.js} +2 -2
- package/public/assets/{activity-DLTFF8Dz.js.map → activity-DReCE2mK.js.map} +1 -1
- package/public/assets/{agent-profiles-BcCLIrON.js → agent-profiles-D3ba8Xpe.js} +2 -2
- package/public/assets/{agent-profiles-BcCLIrON.js.map → agent-profiles-D3ba8Xpe.js.map} +1 -1
- package/public/assets/agents-BcsIxadm.js +2 -0
- package/public/assets/{agents-QemvuxKO.js.map → agents-BcsIxadm.js.map} +1 -1
- package/public/assets/{analytics-DMLoMwta.js → analytics-0lOlkaib.js} +2 -2
- package/public/assets/{analytics-DMLoMwta.js.map → analytics-0lOlkaib.js.map} +1 -1
- package/public/assets/{automation-DACSOffp.js → automation-D66mthBn.js} +2 -2
- package/public/assets/{automation-DACSOffp.js.map → automation-D66mthBn.js.map} +1 -1
- package/public/assets/{branch-state-badge-BYE2U_y4.js → branch-state-badge-NH-l2Eh3.js} +2 -2
- package/public/assets/{branch-state-badge-BYE2U_y4.js.map → branch-state-badge-NH-l2Eh3.js.map} +1 -1
- package/public/assets/{channels-3SlDQL3j.js → channels-BlHLMQVk.js} +2 -2
- package/public/assets/{channels-3SlDQL3j.js.map → channels-BlHLMQVk.js.map} +1 -1
- package/public/assets/chat-rwffRsPX.js +2 -0
- package/public/assets/chat-rwffRsPX.js.map +1 -0
- package/public/assets/{connectors-CrEW591Q.js → connectors-CdsiaLD3.js} +2 -2
- package/public/assets/{connectors-CrEW591Q.js.map → connectors-CdsiaLD3.js.map} +1 -1
- package/public/assets/{formatted-body-Dg9R3RD7.js → formatted-body-C1AprxyU.js} +3 -3
- package/public/assets/{formatted-body-Dg9R3RD7.js.map → formatted-body-C1AprxyU.js.map} +1 -1
- package/public/assets/formatted-body-impl-D_ZJQVNk.js +3 -0
- package/public/assets/formatted-body-impl-D_ZJQVNk.js.map +1 -0
- package/public/assets/{index-D_fNJnsI.js → index-DfPouNiV.js} +10 -10
- package/public/assets/index-DfPouNiV.js.map +1 -0
- package/public/assets/{insights-D48n2ojF.js → insights-BJf1vwrf.js} +2 -2
- package/public/assets/{insights-D48n2ojF.js.map → insights-BJf1vwrf.js.map} +1 -1
- package/public/assets/{integrations-B-bdliwU.js → integrations-BZSHasnY.js} +2 -2
- package/public/assets/{integrations-B-bdliwU.js.map → integrations-BZSHasnY.js.map} +1 -1
- package/public/assets/{maintenance-LQ_U3Nqy.js → maintenance-uEI1lArh.js} +2 -2
- package/public/assets/{maintenance-LQ_U3Nqy.js.map → maintenance-uEI1lArh.js.map} +1 -1
- package/public/assets/{managed-agents-Cfh8FvY8.js → managed-agents-mX3hHpOP.js} +4 -4
- package/public/assets/{managed-agents-Cfh8FvY8.js.map → managed-agents-mX3hHpOP.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BSa7zYx1.js → markdown-preview-impl-ClNwMC48.js} +2 -2
- package/public/assets/{markdown-preview-impl-BSa7zYx1.js.map → markdown-preview-impl-ClNwMC48.js.map} +1 -1
- package/public/assets/{memory-Dr1iYKkC.js → memory-OfusEzGh.js} +2 -2
- package/public/assets/{memory-Dr1iYKkC.js.map → memory-OfusEzGh.js.map} +1 -1
- package/public/assets/messages-DsQGj0gU.js +2 -0
- package/public/assets/{messages-DRKulS-2.js.map → messages-DsQGj0gU.js.map} +1 -1
- package/public/assets/{orchestrators-BguyeViG.js → orchestrators-BwWyl4mB.js} +2 -2
- package/public/assets/{orchestrators-BguyeViG.js.map → orchestrators-BwWyl4mB.js.map} +1 -1
- package/public/assets/overview-CED1SeTY.js +2 -0
- package/public/assets/{overview-Dbq2lr7Y.js.map → overview-CED1SeTY.js.map} +1 -1
- package/public/assets/{pairs-DD7i2scF.js → pairs-BNu6ZWg4.js} +2 -2
- package/public/assets/{pairs-DD7i2scF.js.map → pairs-BNu6ZWg4.js.map} +1 -1
- package/public/assets/{projects-DEn85LQb.js → projects-BmopRiUD.js} +2 -2
- package/public/assets/{projects-DEn85LQb.js.map → projects-BmopRiUD.js.map} +1 -1
- package/public/assets/{prompt-settings-DwgrPKY3.js → prompt-settings-Ci2n1cFt.js} +2 -2
- package/public/assets/{prompt-settings-DwgrPKY3.js.map → prompt-settings-Ci2n1cFt.js.map} +1 -1
- package/public/assets/{registry-DCR3xFpe.js → registry-DwXnDIql.js} +2 -2
- package/public/assets/{registry-DCR3xFpe.js.map → registry-DwXnDIql.js.map} +1 -1
- package/public/assets/security-AW5EbdOm.js +3 -0
- package/public/assets/security-AW5EbdOm.js.map +1 -0
- package/public/assets/{select-CpyCRBK3.js → select-CEeWX4hp.js} +2 -2
- package/public/assets/{select-CpyCRBK3.js.map → select-CEeWX4hp.js.map} +1 -1
- package/public/assets/settings-DYUdGz4G.js +6 -0
- package/public/assets/{settings-DYNFGC2-.js.map → settings-DYUdGz4G.js.map} +1 -1
- package/public/assets/store-BaslPLIP.js +9 -0
- package/public/assets/store-BaslPLIP.js.map +1 -0
- package/public/assets/{tasks-rdy9Nsbf.js → tasks-DxCsrY4g.js} +2 -2
- package/public/assets/{tasks-rdy9Nsbf.js.map → tasks-DxCsrY4g.js.map} +1 -1
- package/public/assets/{teams-DHloBagJ.js → teams-CF7vi-oM.js} +2 -2
- package/public/assets/{teams-DHloBagJ.js.map → teams-CF7vi-oM.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DWTuNejR.js → terminal-viewer-impl-Dy3vqOnW.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DWTuNejR.js.map → terminal-viewer-impl-Dy3vqOnW.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-CShT7nTC.js → work-queue-BPaGXrCE.js} +2 -2
- package/public/assets/{work-queue-CShT7nTC.js.map → work-queue-BPaGXrCE.js.map} +1 -1
- package/public/assets/{workspaces-DjQ6EdrZ.js → workspaces-BLEXgnlj.js} +2 -2
- package/public/assets/{workspaces-DjQ6EdrZ.js.map → workspaces-BLEXgnlj.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/automations/reconcile.ts +69 -4
- package/src/branch-landed.ts +18 -7
- package/src/bus.ts +23 -16
- package/src/db/connection.ts +29 -1
- package/src/db/maintenance-worker.ts +25 -0
- package/src/db/message-reads.ts +69 -44
- package/src/db/migrations.ts +1 -0
- package/src/db/provider-quotas.ts +6 -7
- package/src/db/schema.ts +2 -1
- package/src/maintenance/jobs.ts +3 -3
- package/src/mcp/index.ts +5 -5
- package/src/mcp/tools-workspace.ts +8 -3
- package/src/routes/orchestrator-proxy.ts +7 -6
- package/src/routes/tasks.ts +29 -0
- package/src/routes/workspaces.ts +9 -2
- package/src/runtime-tokens.ts +4 -0
- package/src/security.ts +31 -7
- package/src/server.ts +40 -18
- package/src/services/git-remote.ts +29 -0
- package/src/services/issue-lifecycle.ts +6 -6
- package/src/services/project.ts +47 -1
- package/src/services/send-message.ts +2 -4
- package/src/services/spawn-agent.ts +2 -2
- package/src/services/task-lifecycle.ts +6 -5
- package/src/settings/registry.ts +6 -4
- package/src/spawn-targets.ts +2 -2
- package/src/sse.ts +37 -18
- package/src/steward.ts +10 -6
- package/src/terminal-authz.ts +50 -0
- package/src/token-db.ts +3 -3
- package/src/validation.ts +1 -1
- package/src/workspace-actions.ts +46 -4
- package/src/workspace-auto-merge.ts +120 -13
- package/src/workspace-orphans.ts +1 -1
- package/src/workspace-probe.ts +19 -10
- package/public/assets/MessageBubble-jpshgh3K.js +0 -2
- package/public/assets/agents-QemvuxKO.js +0 -2
- package/public/assets/chat-xKXCafN9.js +0 -2
- package/public/assets/chat-xKXCafN9.js.map +0 -1
- package/public/assets/formatted-body-impl-DlasTD8X.js +0 -3
- package/public/assets/formatted-body-impl-DlasTD8X.js.map +0 -1
- package/public/assets/index-D_fNJnsI.js.map +0 -1
- package/public/assets/messages-DRKulS-2.js +0 -2
- package/public/assets/overview-Dbq2lr7Y.js +0 -2
- package/public/assets/security-BH8kTdA-.js +0 -3
- package/public/assets/security-BH8kTdA-.js.map +0 -1
- package/public/assets/settings-DYNFGC2-.js +0 -6
- package/public/assets/store-BuuJUn70.js +0 -9
- 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
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
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 <
|
|
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
|
-
|
|
105
|
-
|
|
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)
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
},
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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) =>
|
|
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];
|
package/src/workspace-actions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
104
|
-
if (
|
|
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, {
|
|
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.
|
|
314
|
-
|
|
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
|
-
|
|
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
|
package/src/workspace-orphans.ts
CHANGED
|
@@ -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 },
|
package/src/workspace-probe.ts
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
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
|
/**
|