agent-relay-server 0.85.0 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-DqFyach0.js → MessageBubble-CevL-2jc.js} +2 -2
- package/public/assets/{MessageBubble-DqFyach0.js.map → MessageBubble-CevL-2jc.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CCvKbYWe.js → PlanGraphPanel-DGpJhuVw.js} +2 -2
- package/public/assets/{PlanGraphPanel-CCvKbYWe.js.map → PlanGraphPanel-DGpJhuVw.js.map} +1 -1
- package/public/assets/{activity-C6RvknQQ.js → activity-CY2Cbpet.js} +2 -2
- package/public/assets/{activity-C6RvknQQ.js.map → activity-CY2Cbpet.js.map} +1 -1
- package/public/assets/{agent-profiles-RFCLAgnC.js → agent-profiles-CLJNeVxc.js} +2 -2
- package/public/assets/{agent-profiles-RFCLAgnC.js.map → agent-profiles-CLJNeVxc.js.map} +1 -1
- package/public/assets/{agents-D7HZJ5eG.js → agents-Bj5KzrYJ.js} +2 -2
- package/public/assets/{agents-D7HZJ5eG.js.map → agents-Bj5KzrYJ.js.map} +1 -1
- package/public/assets/{analytics-2H2z2UNS.js → analytics-C1F28oqr.js} +2 -2
- package/public/assets/{analytics-2H2z2UNS.js.map → analytics-C1F28oqr.js.map} +1 -1
- package/public/assets/{automation-BUazA3lr.js → automation-BOPk1oh_.js} +2 -2
- package/public/assets/{automation-BUazA3lr.js.map → automation-BOPk1oh_.js.map} +1 -1
- package/public/assets/{branch-state-badge-DxwNcWr8.js → branch-state-badge-Q4W68jxj.js} +2 -2
- package/public/assets/{branch-state-badge-DxwNcWr8.js.map → branch-state-badge-Q4W68jxj.js.map} +1 -1
- package/public/assets/{channels-Sh_jdB7y.js → channels-BQbNtrtF.js} +2 -2
- package/public/assets/{channels-Sh_jdB7y.js.map → channels-BQbNtrtF.js.map} +1 -1
- package/public/assets/{chat-DBTEaf-H.js → chat-D5pI4ebq.js} +2 -2
- package/public/assets/{chat-DBTEaf-H.js.map → chat-D5pI4ebq.js.map} +1 -1
- package/public/assets/{connectors-C3C4i4Gz.js → connectors-CmARSKoV.js} +2 -2
- package/public/assets/{connectors-C3C4i4Gz.js.map → connectors-CmARSKoV.js.map} +1 -1
- package/public/assets/display-CWMHll1J.js.map +1 -1
- package/public/assets/{formatted-body-impl-KG0iExZ-.js → formatted-body-impl-Dr_z1UvS.js} +2 -2
- package/public/assets/{formatted-body-impl-KG0iExZ-.js.map → formatted-body-impl-Dr_z1UvS.js.map} +1 -1
- package/public/assets/{index-TZskQfQL.js → index-CEXhh5nz.js} +6 -6
- package/public/assets/{index-TZskQfQL.js.map → index-CEXhh5nz.js.map} +1 -1
- package/public/assets/{insights-JL1s7Uul.js → insights-C4dEK49e.js} +2 -2
- package/public/assets/{insights-JL1s7Uul.js.map → insights-C4dEK49e.js.map} +1 -1
- package/public/assets/{integrations-PqWMrPJa.js → integrations-D9Cx-7ma.js} +2 -2
- package/public/assets/{integrations-PqWMrPJa.js.map → integrations-D9Cx-7ma.js.map} +1 -1
- package/public/assets/{maintenance-CTMfhXFM.js → maintenance-DJZUSsja.js} +2 -2
- package/public/assets/{maintenance-CTMfhXFM.js.map → maintenance-DJZUSsja.js.map} +1 -1
- package/public/assets/{managed-agents-C4cb3xQ4.js → managed-agents-5wUMDKVi.js} +2 -2
- package/public/assets/{managed-agents-C4cb3xQ4.js.map → managed-agents-5wUMDKVi.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DQIi0O7r.js → markdown-preview-impl-DOJQR3cO.js} +2 -2
- package/public/assets/{markdown-preview-impl-DQIi0O7r.js.map → markdown-preview-impl-DOJQR3cO.js.map} +1 -1
- package/public/assets/{memory-DJPXFdgw.js → memory-BnhAD09c.js} +2 -2
- package/public/assets/{memory-DJPXFdgw.js.map → memory-BnhAD09c.js.map} +1 -1
- package/public/assets/{messages-RSD2g4Wx.js → messages-Cc9YF8Wl.js} +2 -2
- package/public/assets/{messages-RSD2g4Wx.js.map → messages-Cc9YF8Wl.js.map} +1 -1
- package/public/assets/{orchestrators-BGIkULBF.js → orchestrators-B01iyiBA.js} +2 -2
- package/public/assets/{orchestrators-BGIkULBF.js.map → orchestrators-B01iyiBA.js.map} +1 -1
- package/public/assets/{overview-ByE9V8pe.js → overview-DN8CUTVx.js} +2 -2
- package/public/assets/{overview-ByE9V8pe.js.map → overview-DN8CUTVx.js.map} +1 -1
- package/public/assets/{pairs-C284ntvD.js → pairs-BxuDMLhs.js} +2 -2
- package/public/assets/{pairs-C284ntvD.js.map → pairs-BxuDMLhs.js.map} +1 -1
- package/public/assets/{projects-B2-8Eu0e.js → projects-hU5KGW_T.js} +2 -2
- package/public/assets/{projects-B2-8Eu0e.js.map → projects-hU5KGW_T.js.map} +1 -1
- package/public/assets/{security-vDglScsg.js → security-CO0PBIau.js} +2 -2
- package/public/assets/{security-vDglScsg.js.map → security-CO0PBIau.js.map} +1 -1
- package/public/assets/{select-DaOiWDBR.js → select-DHaFsLvt.js} +2 -2
- package/public/assets/{select-DaOiWDBR.js.map → select-DHaFsLvt.js.map} +1 -1
- package/public/assets/{settings-DiLcbSqx.js → settings-De1DKwd6.js} +2 -2
- package/public/assets/{settings-DiLcbSqx.js.map → settings-De1DKwd6.js.map} +1 -1
- package/public/assets/{store-DYaQAXRv.js → store-DsqUEZOf.js} +2 -2
- package/public/assets/store-DsqUEZOf.js.map +1 -0
- package/public/assets/{tasks-DWJdJra3.js → tasks-Bqd188ql.js} +2 -2
- package/public/assets/{tasks-DWJdJra3.js.map → tasks-Bqd188ql.js.map} +1 -1
- package/public/assets/{teams-FejXkix4.js → teams-Mh6ypDPr.js} +2 -2
- package/public/assets/{teams-FejXkix4.js.map → teams-Mh6ypDPr.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-Bkt1w42o.js → terminal-viewer-impl-B7x_JNZe.js} +2 -2
- package/public/assets/{terminal-viewer-impl-Bkt1w42o.js.map → terminal-viewer-impl-B7x_JNZe.js.map} +1 -1
- package/public/assets/{user-avatar-ClE2dMD8.js → user-avatar-Dy5J3E_b.js} +2 -2
- package/public/assets/{user-avatar-ClE2dMD8.js.map → user-avatar-Dy5J3E_b.js.map} +1 -1
- package/public/assets/{work-queue-DZdv9kOb.js → work-queue-ClGgOUSu.js} +2 -2
- package/public/assets/{work-queue-DZdv9kOb.js.map → work-queue-ClGgOUSu.js.map} +1 -1
- package/public/assets/{workspaces-BhblC-Nd.js → workspaces-C740m1ys.js} +2 -2
- package/public/assets/{workspaces-BhblC-Nd.js.map → workspaces-C740m1ys.js.map} +1 -1
- package/public/index.html +2 -2
- package/runner/src/adapter.ts +4 -3
- package/src/agent-lifecycle-events.ts +6 -1
- package/src/branch-landed.ts +81 -0
- package/src/cli/index.ts +3 -3
- package/src/cli/reply.ts +6 -1
- package/src/config.ts +4 -0
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +17 -0
- package/src/db/provider-quotas.ts +99 -21
- package/src/db/terminal-events.ts +56 -0
- package/src/maintenance/constants.ts +4 -0
- package/src/maintenance/jobs.ts +13 -0
- package/src/maintenance/teams.ts +8 -3
- package/src/maintenance/workspaces.ts +23 -10
- package/src/mcp/index.ts +2 -1
- package/src/mcp/tools-spawn.ts +119 -4
- package/src/notification-types.ts +2 -0
- package/src/provider-catalog-store.ts +3 -0
- package/src/routes/workspaces.ts +5 -0
- package/src/services/transient-agent-reaper.ts +4 -1
- package/src/spawn-targets.ts +8 -2
- package/src/steward-identity.ts +50 -0
- package/src/steward.ts +12 -4
- package/src/workspace-auto-merge.ts +35 -6
- package/src/workspace-pr-completion.ts +26 -0
- package/public/assets/store-DYaQAXRv.js.map +0 -1
package/src/mcp/tools-spawn.ts
CHANGED
|
@@ -4,15 +4,16 @@ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../servic
|
|
|
4
4
|
import { buildSpawnTargets } from "../spawn-targets";
|
|
5
5
|
import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
6
6
|
import { parseProjectInstructions, PROJECT_INSTRUCTION_POLICY_NAMES } from "../project-instructions";
|
|
7
|
-
import { ValidationError } from "../db";
|
|
7
|
+
import { countLiveSpawnedAgents, listAgents, listWorkspaces, ValidationError } from "../db";
|
|
8
|
+
import { listCommands } from "../commands-db";
|
|
8
9
|
import { DEFAULT_SPAWN_PROFILE_NAME } from "../config-store";
|
|
9
10
|
import { optionalEnum } from "../validation";
|
|
10
|
-
import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
11
|
+
import { APPROVAL_MODES, SPAWN_PROVIDERS, stringValue, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
11
12
|
import type { ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
12
|
-
import type { AgentLifecycle, SpawnApprovalMode, SpawnProvider, WorkspaceMode } from "../types";
|
|
13
|
+
import type { AgentCard, AgentLifecycle, Command, SpawnApprovalMode, SpawnProvider, WorkspaceMode, WorkspaceRecord } from "../types";
|
|
13
14
|
import type { McpAuthContext, ToolDefinition } from "./types";
|
|
14
15
|
import { callerAgentId } from "./identity";
|
|
15
|
-
import { enumField, optionalNonNegativeInt, optionalPositiveInt, optionalString, optionalStringArray } from "./validation";
|
|
16
|
+
import { enumField, optionalBoolean, optionalNonNegativeInt, optionalPositiveInt, optionalString, optionalStringArray } from "./validation";
|
|
16
17
|
|
|
17
18
|
export const SPAWN_TOOLS: ToolDefinition[] = [
|
|
18
19
|
{
|
|
@@ -57,6 +58,19 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
|
|
|
57
58
|
additionalProperties: false,
|
|
58
59
|
},
|
|
59
60
|
},
|
|
61
|
+
{
|
|
62
|
+
name: "relay_my_agents",
|
|
63
|
+
description: "List the spawned children owned by YOUR token lineage, with lean lifecycle rows and spawn quota at a glance. This is the canonical spawn-tracking tool: use it after relay_spawn_agent to see your own children and choose a spawnRequestId for relay_shutdown_agent. Self-scoped: no agentId/spawnedBy parameter is accepted.",
|
|
64
|
+
requiredScopes: ["command:spawn"],
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
includeReaped: { type: "boolean", description: "Include recent exited/offline child rows in addition to live/pending children. Default false." },
|
|
69
|
+
limit: { type: "integer", minimum: 1, maximum: 50, description: "Maximum rows to return when includeReaped is true. Default 20, capped at 50." },
|
|
70
|
+
},
|
|
71
|
+
additionalProperties: false,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
60
74
|
{
|
|
61
75
|
name: "relay_shutdown_agent",
|
|
62
76
|
description: "Shut down an agent through Relay's orchestrator. Gated: requires the command:shutdown scope; an agent caller may only target its OWN live spawned children, addressed by agentId or spawnRequestId (policyName/tmuxSession are admin-only broad targets). Admin tokens keep full reach.",
|
|
@@ -112,6 +126,53 @@ export function relaySpawnTargets(auth: McpAuthContext): Record<string, unknown>
|
|
|
112
126
|
});
|
|
113
127
|
}
|
|
114
128
|
|
|
129
|
+
type MyAgentRow = {
|
|
130
|
+
label: string | null;
|
|
131
|
+
spawnRequestId: string | null;
|
|
132
|
+
agentId: string | null;
|
|
133
|
+
status: string;
|
|
134
|
+
provider: string | null;
|
|
135
|
+
model: string | null;
|
|
136
|
+
machine: string | null;
|
|
137
|
+
branch: string | null;
|
|
138
|
+
landState: string | null;
|
|
139
|
+
lastSeen: number;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export function relayMyAgents(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
143
|
+
const callerId = callerAgentId(auth);
|
|
144
|
+
if (!callerId) throw new McpAuthError("relay_my_agents requires a single caller agent identity");
|
|
145
|
+
const includeReaped = optionalBoolean(args.includeReaped, "includeReaped") === true;
|
|
146
|
+
const limit = Math.min(optionalPositiveInt(args.limit, "limit") ?? 20, 50);
|
|
147
|
+
const quotaMax = auth.component?.constraints?.maxSpawnedAgents ?? 0;
|
|
148
|
+
const liveChildren = countLiveSpawnedAgents(callerId);
|
|
149
|
+
const registered = listAgents()
|
|
150
|
+
.filter((agent) => agent.spawnedBy === callerId)
|
|
151
|
+
.filter((agent) => includeReaped || isLiveChild(agent))
|
|
152
|
+
.map(agentChildRow);
|
|
153
|
+
const registeredSpawnRequestIds = new Set(registered.map((row) => row.spawnRequestId).filter((value): value is string => Boolean(value)));
|
|
154
|
+
const pending = activeSpawnCommandsFor(callerId)
|
|
155
|
+
.filter((command) => {
|
|
156
|
+
const spawnRequestId = stringValue(command.params.spawnRequestId) ?? command.correlationId;
|
|
157
|
+
return Boolean(spawnRequestId && !registeredSpawnRequestIds.has(spawnRequestId));
|
|
158
|
+
})
|
|
159
|
+
.map(commandChildRow);
|
|
160
|
+
const agents = [...pending, ...registered]
|
|
161
|
+
.sort((a, b) => b.lastSeen - a.lastSeen)
|
|
162
|
+
.slice(0, includeReaped ? limit : undefined);
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
quota: {
|
|
166
|
+
liveChildren,
|
|
167
|
+
max: quotaMax,
|
|
168
|
+
remaining: Math.max(0, quotaMax - liveChildren),
|
|
169
|
+
},
|
|
170
|
+
agents,
|
|
171
|
+
count: agents.length,
|
|
172
|
+
includeReaped,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
115
176
|
export function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
116
177
|
const agentId = optionalString(args.agentId, "agentId", 240);
|
|
117
178
|
const policyName = optionalString(args.policyName, "policyName", 120);
|
|
@@ -144,3 +205,57 @@ export function relayShutdownAgent(auth: McpAuthContext, args: Record<string, un
|
|
|
144
205
|
throw e;
|
|
145
206
|
}
|
|
146
207
|
}
|
|
208
|
+
|
|
209
|
+
function isLiveChild(agent: AgentCard): boolean {
|
|
210
|
+
return agent.ready && agent.status !== "offline" && agent.status !== "stale";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function agentChildRow(agent: AgentCard): MyAgentRow {
|
|
214
|
+
const workspace = latestWorkspaceFor(agent.id);
|
|
215
|
+
return {
|
|
216
|
+
label: agent.label ?? agent.name ?? null,
|
|
217
|
+
spawnRequestId: stringValue(agent.meta?.spawnRequestId) ?? null,
|
|
218
|
+
agentId: agent.id,
|
|
219
|
+
status: childLifecycleStatus(agent),
|
|
220
|
+
provider: agent.providerCapabilities?.model?.provider ?? stringValue(agent.meta?.provider) ?? null,
|
|
221
|
+
model: agent.providerCapabilities?.model?.id ?? stringValue(agent.meta?.model) ?? stringValue(agent.meta?.providerModel) ?? null,
|
|
222
|
+
machine: agent.machine ?? null,
|
|
223
|
+
branch: workspace?.branch ?? null,
|
|
224
|
+
landState: workspace?.status ?? null,
|
|
225
|
+
lastSeen: agent.lastSeen,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function commandChildRow(command: Command): MyAgentRow {
|
|
230
|
+
return {
|
|
231
|
+
label: stringValue(command.params.label) ?? null,
|
|
232
|
+
spawnRequestId: stringValue(command.params.spawnRequestId) ?? command.correlationId ?? null,
|
|
233
|
+
agentId: null,
|
|
234
|
+
status: "spawning",
|
|
235
|
+
provider: stringValue(command.params.provider) ?? null,
|
|
236
|
+
model: stringValue(command.params.model) ?? stringValue(command.params.providerModel) ?? null,
|
|
237
|
+
machine: stringValue(command.params.orchestratorId) ?? null,
|
|
238
|
+
branch: null,
|
|
239
|
+
landState: null,
|
|
240
|
+
lastSeen: command.updatedAt,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function childLifecycleStatus(agent: AgentCard): string {
|
|
245
|
+
const action = stringValue(agent.meta?.lifecycleAction);
|
|
246
|
+
if (action) return action;
|
|
247
|
+
if (agent.status === "offline" || agent.status === "stale") return "exited";
|
|
248
|
+
if (!agent.ready) return "starting";
|
|
249
|
+
if (agent.status === "online") return "ready";
|
|
250
|
+
return agent.status;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function activeSpawnCommandsFor(callerId: string): Command[] {
|
|
254
|
+
return listCommands({ type: "agent.spawn", limit: 500 })
|
|
255
|
+
.filter((command) => ["pending", "accepted", "running"].includes(command.status))
|
|
256
|
+
.filter((command) => stringValue(command.params.spawnedBy) === callerId);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function latestWorkspaceFor(agentId: string): WorkspaceRecord | undefined {
|
|
260
|
+
return listWorkspaces({ ownerAgentId: agentId })[0];
|
|
261
|
+
}
|
|
@@ -14,6 +14,7 @@ export type NotificationType =
|
|
|
14
14
|
| "team.member_silent"
|
|
15
15
|
| "plan.bound_agent_exited"
|
|
16
16
|
| "workspace.steward_task"
|
|
17
|
+
| "workspace.steward_handoff"
|
|
17
18
|
| "workspace.conflict"
|
|
18
19
|
| "workspace.steward_reassigned"
|
|
19
20
|
| "workspace.stranded_escalation"
|
|
@@ -66,6 +67,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
66
67
|
"team.member_silent": null,
|
|
67
68
|
"plan.bound_agent_exited": null,
|
|
68
69
|
"workspace.steward_task": null,
|
|
70
|
+
"workspace.steward_handoff": null,
|
|
69
71
|
"workspace.conflict": null,
|
|
70
72
|
"workspace.steward_reassigned": null,
|
|
71
73
|
"workspace.stranded_escalation": null,
|
|
@@ -150,6 +150,9 @@ function normalizeOverrideInput(input: ProviderModelOverrideInput): ProviderMode
|
|
|
150
150
|
if (contextThreshold) entry.contextThreshold = contextThreshold;
|
|
151
151
|
else delete entry.contextThreshold;
|
|
152
152
|
delete entry.disabled;
|
|
153
|
+
// #368 — unavailable is a static catalog fact (provider-side suspension), not an operator toggle;
|
|
154
|
+
// strip it from DB overrides so an admin can't accidentally clear a catalog-level suspension.
|
|
155
|
+
delete entry.unavailable;
|
|
153
156
|
return {
|
|
154
157
|
...input,
|
|
155
158
|
alias,
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { resolve } from "node:path";
|
|
|
13
13
|
import { type WorkspaceAutoMergePolicy, type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecoveryBranch, type WorkspaceRecord, type WorkspaceStatus } from "../types";
|
|
14
14
|
import { AUTO_MERGE_POLICIES } from "../workspace-merge";
|
|
15
15
|
import { workspaceActiveClaim } from "../workspace-claim";
|
|
16
|
+
import { resolveCoordinatorAgentId } from "../steward-identity";
|
|
16
17
|
import { relayToken } from "../config";
|
|
17
18
|
|
|
18
19
|
export const getWorkspaces: Handler = (req) => {
|
|
@@ -337,6 +338,9 @@ export const getWorkspaceDiagnostics: Handler = async (_req, params) => {
|
|
|
337
338
|
if (!workspace) return error("workspace not found", 404);
|
|
338
339
|
const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
|
|
339
340
|
const ownerOnline = isOwnerAlive(workspace.ownerAgentId);
|
|
341
|
+
// #640 — surface the branch agent's coordinator (owner's `spawnedBy` lineage) so a steward
|
|
342
|
+
// inspecting the workspace knows who to escalate to, not just `repoRoot`.
|
|
343
|
+
const coordinatorAgentId = resolveCoordinatorAgentId(workspace.ownerAgentId);
|
|
340
344
|
const orch = listOrchestrators().find((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
341
345
|
const orchOnline = Boolean(orch) && orch!.status === "online";
|
|
342
346
|
const fetched = await fetchWorkspaceGitState(workspace);
|
|
@@ -354,6 +358,7 @@ export const getWorkspaceDiagnostics: Handler = async (_req, params) => {
|
|
|
354
358
|
baseRef: workspace.baseRef,
|
|
355
359
|
branchMismatch: workspace.branch && liveBranch ? workspace.branch !== liveBranch : undefined,
|
|
356
360
|
owner: { id: workspace.ownerAgentId, status: owner?.status, online: ownerOnline },
|
|
361
|
+
coordinator: { id: coordinatorAgentId, online: coordinatorAgentId ? isOwnerAlive(coordinatorAgentId) : false },
|
|
357
362
|
orchestrator: { id: orch?.id, online: orchOnline },
|
|
358
363
|
...(claim ? { claim: { by: claim.by, purpose: claim.purpose, expiresAt: claim.expiresAt } } : {}),
|
|
359
364
|
...(gitState ? { gitState } : { gitStateUnavailable: "unavailable" in fetched ? fetched.unavailable : "unknown" }),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createCommand } from "../commands-db";
|
|
2
2
|
import { emitRelayEvent } from "../events";
|
|
3
|
-
import { createActivityEvent, getAgent, listAgents, listWorkspaces } from "../db";
|
|
3
|
+
import { createActivityEvent, getAgent, listAgents, listWorkspaces, recordTerminalEvent } from "../db";
|
|
4
4
|
import { routeNotification } from "../notification-router";
|
|
5
5
|
import { isProviderBlocked } from "../provider-state";
|
|
6
6
|
import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
|
|
@@ -135,6 +135,9 @@ export function reapTransientAgents(): Record<string, unknown> {
|
|
|
135
135
|
});
|
|
136
136
|
emitRelayEvent({ type: "command.requested", source: command.source, subject: command.id, data: { command } });
|
|
137
137
|
emitRelayEvent({ type: "agent.exited", source: "server", subject: agent.id, data: { agentId: agent.id, parent: agent.spawnedBy, reason: "transient-complete", workspaceId: land.workspaceId, workspaceStatus: land.status } });
|
|
138
|
+
// #645 — persist a clean terminal marker so the #634 heartbeat-gap watchdog reads this benign
|
|
139
|
+
// completion as a terminus (not a silent death) once the reaped worker stops heartbeating.
|
|
140
|
+
recordTerminalEvent({ agentId: agent.id, reason: "transient-complete", kind: "agent.exited", createdAt: now });
|
|
138
141
|
createActivityEvent({
|
|
139
142
|
clientId: `transient-agent-reaped-${agent.id}-${now}`,
|
|
140
143
|
kind: "state",
|
package/src/spawn-targets.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function buildSpawnTargets(input: { callerId?: string; quota: number }):
|
|
|
48
48
|
interface SpawnCatalogish {
|
|
49
49
|
provider: SpawnProvider;
|
|
50
50
|
defaultModel?: string;
|
|
51
|
-
models: Array<{ alias: string; providerModel: string; efforts: readonly string[]; defaultEffort?: string; disabled?: boolean }>;
|
|
51
|
+
models: Array<{ alias: string; providerModel: string; efforts: readonly string[]; defaultEffort?: string; disabled?: boolean; unavailable?: string }>;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function providerMatrixFor(o: Orchestrator): Array<Record<string, unknown>> {
|
|
@@ -60,12 +60,17 @@ function providerMatrixFor(o: Orchestrator): Array<Record<string, unknown>> {
|
|
|
60
60
|
// REPORTED catalog does not, so cross-reference the effective catalog by alias regardless of
|
|
61
61
|
// which source supplies the matrix entry — otherwise the reported path keeps advertising a model
|
|
62
62
|
// the server then rejects with "model is disabled".
|
|
63
|
+
// #368 — also drop models marked unavailable in the static catalog (provider-side suspension,
|
|
64
|
+
// e.g. export-control; semantically distinct from the operator-toggle disabled flag).
|
|
63
65
|
const disabledAliasesFor = (provider: SpawnProvider): Set<string> =>
|
|
64
66
|
new Set(global.find((e) => e.provider === provider)?.models.filter((m) => m.disabled).map((m) => m.alias) ?? []);
|
|
67
|
+
const unavailableAliasesFor = (provider: SpawnProvider): Set<string> =>
|
|
68
|
+
new Set(global.find((e) => e.provider === provider)?.models.filter((m) => m.unavailable).map((m) => m.alias) ?? []);
|
|
65
69
|
return o.providers.map((provider) => {
|
|
66
70
|
const cat: SpawnCatalogish | undefined =
|
|
67
71
|
reported.find((s) => s.provider === provider) ?? global.find((e) => e.provider === provider);
|
|
68
72
|
const disabled = disabledAliasesFor(provider);
|
|
73
|
+
const unavailable = unavailableAliasesFor(provider);
|
|
69
74
|
return {
|
|
70
75
|
provider,
|
|
71
76
|
available: true,
|
|
@@ -73,7 +78,7 @@ function providerMatrixFor(o: Orchestrator): Array<Record<string, unknown>> {
|
|
|
73
78
|
? {
|
|
74
79
|
defaultModel: cat.defaultModel,
|
|
75
80
|
models: cat.models
|
|
76
|
-
.filter((m) => !m.disabled && !disabled.has(m.alias))
|
|
81
|
+
.filter((m) => !m.disabled && !disabled.has(m.alias) && !m.unavailable && !unavailable.has(m.alias))
|
|
77
82
|
.map((m) => ({
|
|
78
83
|
id: m.alias,
|
|
79
84
|
providerModel: m.providerModel,
|
|
@@ -122,6 +127,7 @@ export function spawnCapablePrimer(input: { canSpawn: boolean; quota?: number })
|
|
|
122
127
|
"- Default to your OWN host. Only set orchestratorId to spawn onto another host when it runs a provider/model yours doesn't, or the work must run there.",
|
|
123
128
|
`- Prefer a named profile (profile: "<name>") over hand-assembling provider+model+effort+approvalMode. Omitting profile applies ${DEFAULT_SPAWN_PROFILE_NAME}; omitting model/effort uses the provider default. Lean to the cheapest provider/model/effort that fits; escalate only for complex work.`,
|
|
124
129
|
"- Set spawnRequestId for idempotency so a retried spawn doesn't double-create.",
|
|
130
|
+
"- After spawning, use relay_my_agents for your self-scoped child roster + quota and copy a returned spawnRequestId into relay_shutdown_agent when you need to reap.",
|
|
125
131
|
"- Spawn is fire-and-forget: pass waitForRegistrationMs:0 and keep working — you'll be PUSHED agent.ready when the child is genuinely ready (past onboarding), or agent.spawn_failed / agent.exited otherwise. Don't block-poll.",
|
|
126
132
|
"- Spawned children cannot themselves spawn (no grandchildren).",
|
|
127
133
|
].join("\n");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The steward ↔ branch-agent ↔ coordinator identity triangle (#639/#640).
|
|
2
|
+
//
|
|
3
|
+
// When the relay hands a workspace to a steward, the steward used to know only the
|
|
4
|
+
// `repoRoot`. To make a handoff legible end-to-end it needs the people-graph:
|
|
5
|
+
// - the workspace's BRANCH AGENT = `ws.ownerAgentId` (already stored on the row), and
|
|
6
|
+
// - that agent's COORDINATOR = the owner's authoritative `spawnedBy` parent (the agent
|
|
7
|
+
// that spawned the branch worker), resolved to a LIVE instance via the shared
|
|
8
|
+
// agent-ref matchers so a coordinator that restarted under a new session id still
|
|
9
|
+
// resolves to its live twin.
|
|
10
|
+
//
|
|
11
|
+
// One home for this resolution so the wake path, the diagnostics/inspect surface, and the
|
|
12
|
+
// escalation path can never drift on "who is the coordinator for this workspace?".
|
|
13
|
+
|
|
14
|
+
import { getAgent, listAgents } from "./db";
|
|
15
|
+
import { resolveAgentRef } from "./agent-ref";
|
|
16
|
+
import type { WorkspaceRecord } from "./types";
|
|
17
|
+
|
|
18
|
+
export interface StewardIdentityTriangle {
|
|
19
|
+
/** The workspace's branch agent (owner), or undefined when the row has no owner. */
|
|
20
|
+
ownerAgentId?: string;
|
|
21
|
+
/** The branch agent's coordinator (owner's `spawnedBy` lineage) resolved to a live id, or null when none resolves. */
|
|
22
|
+
coordinatorAgentId: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the branch agent's coordinator: the owner's authoritative `spawnedBy` parent,
|
|
27
|
+
* preferring a live instance. Returns the recorded parent id when no live match exists
|
|
28
|
+
* (store-ahead still delivers to a momentarily-offline coordinator), or null when the
|
|
29
|
+
* owner is unknown or wasn't spawned by anyone (e.g. a human-launched agent).
|
|
30
|
+
*/
|
|
31
|
+
export function resolveCoordinatorAgentId(ownerAgentId: string | undefined): string | null {
|
|
32
|
+
if (!ownerAgentId) return null;
|
|
33
|
+
const spawnedBy = getAgent(ownerAgentId)?.spawnedBy?.trim();
|
|
34
|
+
if (!spawnedBy) return null;
|
|
35
|
+
const resolved = resolveAgentRef(spawnedBy, listAgents());
|
|
36
|
+
if (resolved.status === "resolved") return resolved.agent.id;
|
|
37
|
+
if (resolved.status === "ambiguous") {
|
|
38
|
+
// Prefer the exact recorded id among live twins; else the most-recently-seen.
|
|
39
|
+
return (resolved.candidates.find((a) => a.id === spawnedBy) ?? resolved.candidates[0])!.id;
|
|
40
|
+
}
|
|
41
|
+
// No live match — keep the authoritative recorded parent id so escalation/handoff
|
|
42
|
+
// notices still address the coordinator (store-ahead delivers when it reconnects).
|
|
43
|
+
return spawnedBy;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve the full identity triangle for a workspace handoff (#640). */
|
|
47
|
+
export function resolveStewardIdentity(ws: Pick<WorkspaceRecord, "ownerAgentId">): StewardIdentityTriangle {
|
|
48
|
+
const ownerAgentId = ws.ownerAgentId ?? undefined;
|
|
49
|
+
return { ownerAgentId, coordinatorAgentId: resolveCoordinatorAgentId(ownerAgentId) };
|
|
50
|
+
}
|
package/src/steward.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { basename, resolve } from "node:path";
|
|
3
|
+
import { stewardFallbackTarget } from "./config";
|
|
3
4
|
import { getSpawnPolicy, getStewardConfig, setConfig } from "./config-store";
|
|
4
5
|
import { listOrchestrators } from "./db";
|
|
5
6
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
@@ -20,23 +21,30 @@ export function repoStewardPolicyName(repoRoot: string): string {
|
|
|
20
21
|
* its diagnostics, rebase/resolve/check, land green ones, release on escalation.
|
|
21
22
|
*/
|
|
22
23
|
export function buildStewardPrompt(repoRoot: string): string {
|
|
24
|
+
// #642 — escalation routes to the branch agent's COORDINATOR (named per-workspace in the
|
|
25
|
+
// wake/task message via #640), not a static `system` dead-end. The fallback target below is
|
|
26
|
+
// used ONLY when a wake message names no coordinator.
|
|
27
|
+
const fallback = stewardFallbackTarget();
|
|
28
|
+
const fallbackTarget = fallback ? `\`${fallback}\`` : "the human operator who configured this relay";
|
|
23
29
|
return [
|
|
24
30
|
`You are the autonomous repository steward for ${repoRoot}.`,
|
|
25
31
|
"",
|
|
26
32
|
"Multiple agents work this repo in isolated git worktrees. The relay auto-merges any branch that rebases cleanly — including ones whose base moved on; it hands YOU only what it can't land deterministically: real merge conflicts, or an unknown/ambiguous merge state. Your job is to land that work with no human in the loop whenever you safely can.",
|
|
27
33
|
"",
|
|
34
|
+
"Each workspace belongs to a BRANCH AGENT (the owner who did the work) who in turn has a COORDINATOR (the agent that spawned it and is responsible for the merge landing). Your wake/task message names both — the `ownerAgentId` and the `coordinatorAgentId` — and `agent-relay steward inspect <id>` shows them. When you can't land work yourself, the coordinator is who you escalate to.",
|
|
35
|
+
"",
|
|
28
36
|
"Use the `agent-relay` CLI for EVERY relay interaction — it wraps the relay API and is the supported toolkit, and it already attaches your session token with the right scopes. Do NOT hand-curl the HTTP API (`/api/workspaces`, …): the CLI exists so you never have to. If you ever truly must call the API directly, authenticate with the `X-Agent-Relay-Token: <your session token>` header — NOT `Authorization: Bearer $AGENT_RELAY_TOKEN`; the admin env token is not present in your environment, so Bearer will 401.",
|
|
29
37
|
"Handle ONE workspace at a time — the relay's per-repo merge lease serializes you with any other merger.",
|
|
30
38
|
"",
|
|
31
39
|
"1. See the queue: `agent-relay steward queue` (workspaces in conflict / review_requested / merge_planned for this repo). The wake message(s) also name a `workspaceId`.",
|
|
32
40
|
"2. CLAIM it FIRST: `agent-relay workspace claim --id <id> --purpose steward`. This stops the deterministic auto-merge from racing you while you validate. The claim auto-expires, so a crash never blocks the repo.",
|
|
33
|
-
"3. Understand it: `agent-relay steward inspect <id>` — a diagnostics briefing (owner liveness, live git state, ahead/behind, recorded-vs-live branch mismatch, and a recommended action). `agent-relay steward checks <id>` suggests check commands from the changed files.",
|
|
34
|
-
"4. `cd` into the workspace's `worktreePath` and rebase the branch onto its `baseRef` (`git rebase <baseRef>`). Resolve conflicts faithfully — preserve the intent of EVERY side; never silently drop a change. If a conflict needs product judgment you can't make confidently, stop and escalate.",
|
|
41
|
+
"3. Understand it: `agent-relay steward inspect <id>` — a diagnostics briefing (the branch agent owner + its coordinator, owner liveness, live git state, ahead/behind, recorded-vs-live branch mismatch, and a recommended action). `agent-relay steward checks <id>` suggests check commands from the changed files.",
|
|
42
|
+
"4. `cd` into the workspace's `worktreePath` and rebase the branch onto its `baseRef` (`git rebase <baseRef>`). Resolve conflicts faithfully — preserve the intent of EVERY side; never silently drop a change. If a conflict needs product judgment you can't make confidently, stop and escalate to the coordinator.",
|
|
35
43
|
"5. Run the repo's checks/tests (the suggested ones, plus the project's documented commands). Fix trivial breakage you caused while rebasing; do not paper over real failures.",
|
|
36
44
|
"6. If green, land it: `agent-relay workspace land --id <id> --strategy rebase-ff`. The relay rebases onto the latest base, fast-forwards, and pushes to origin under the lease. The land consumes your claim.",
|
|
37
|
-
"7. If you must stop without landing, RELEASE the claim so automation can resume: `agent-relay workspace release --id <id>`, then send a clear, specific summary to the
|
|
45
|
+
"7. If you must stop without landing, RELEASE the claim so automation can resume: `agent-relay workspace release --id <id>`, then send a clear, specific summary to the workspace's COORDINATOR (the `coordinatorAgentId` from the wake message) — they own the work and can act on it. Reply to the wake message or message that agent id directly.",
|
|
38
46
|
"",
|
|
39
|
-
|
|
47
|
+
`Escalate (do NOT merge) when: checks fail and you can't fix them, the conflict resolution is genuinely ambiguous, or anything looks risky. ALWAYS escalate to the branch agent's coordinator (the \`coordinatorAgentId\` in the wake message). Only if the wake message names NO coordinator, escalate to ${fallbackTarget}. Never force-merge a red branch, never \`git push --force\` to a shared base, never discard committed work.`,
|
|
40
48
|
"",
|
|
41
49
|
"Be terminal-efficient and decisive. When the queue is empty, you're done — you'll be woken again when there's more.",
|
|
42
50
|
].join("\n");
|
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
listWorkspaces,
|
|
26
26
|
patchWorkspaceMetadata,
|
|
27
27
|
} from "./db";
|
|
28
|
+
import { notifyBranchLandFailed, notifyStewardHandoff } from "./branch-landed";
|
|
29
|
+
import { resolveStewardIdentity } from "./steward-identity";
|
|
28
30
|
import { emitCommandEvent } from "./command-events";
|
|
29
31
|
import { getLandingConfig, getStewardConfig } from "./config-store";
|
|
30
32
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
@@ -51,20 +53,29 @@ export function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | n
|
|
|
51
53
|
if (lastWoke && Date.now() - lastWoke < STEWARD_WAKE_COOLDOWN_MS) return null;
|
|
52
54
|
const policyName = ensureRepoSteward(ws.repoRoot);
|
|
53
55
|
if (!policyName) return null;
|
|
56
|
+
// #640 — resolve the identity triangle so the steward isn't blind to the people-graph:
|
|
57
|
+
// the workspace's branch agent (owner) and that agent's coordinator (owner's `spawnedBy`).
|
|
58
|
+
const { ownerAgentId, coordinatorAgentId } = resolveStewardIdentity(ws);
|
|
59
|
+
const coordinatorLine = coordinatorAgentId
|
|
60
|
+
? ` Escalate to the branch agent's coordinator \`${coordinatorAgentId}\` (not the system fallback) if you can't land it.`
|
|
61
|
+
: "";
|
|
62
|
+
const ownerLine = ownerAgentId ? ` Branch agent (owner): \`${ownerAgentId}\`.` : "";
|
|
54
63
|
try {
|
|
55
64
|
// Obligation (ttl null by type default): a steward task must survive until claimed/handled.
|
|
56
65
|
routeNotification({
|
|
57
66
|
type: "workspace.steward_task",
|
|
58
|
-
scope: { workspaceId: ws.id, repoRoot: ws.repoRoot },
|
|
67
|
+
scope: { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ownerAgentId, parent: coordinatorAgentId ?? undefined },
|
|
59
68
|
recipients: [`policy:${policyName}`],
|
|
60
69
|
message: {
|
|
61
70
|
subject: `Steward: ${ws.status} workspace needs attention`,
|
|
62
|
-
body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason})
|
|
63
|
-
payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
|
|
71
|
+
body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}).${ownerLine} Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.${coordinatorLine}`,
|
|
72
|
+
payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason, ownerAgentId, coordinatorAgentId },
|
|
64
73
|
},
|
|
65
74
|
});
|
|
66
75
|
getLifecycleManager().onMessageForPolicy(policyName);
|
|
67
|
-
patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName });
|
|
76
|
+
patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName, coordinatorAgentId: coordinatorAgentId ?? undefined });
|
|
77
|
+
// #641/#642 — tell the owner which steward took over and proactively loop in its coordinator.
|
|
78
|
+
notifyStewardHandoff({ workspace: ws, stewardId: policyName, coordinatorAgentId, reason });
|
|
68
79
|
return policyName;
|
|
69
80
|
} catch {
|
|
70
81
|
return null;
|
|
@@ -117,12 +128,30 @@ function escalateNoProgressMerge(ws: WorkspaceRecord): void {
|
|
|
117
128
|
if (getStewardConfig().enabled) {
|
|
118
129
|
wakeRepoSteward(ws, `auto-merge backed off after ${MAX_NO_PROGRESS_MERGE_ATTEMPTS} no-progress attempts (${mergeError})`);
|
|
119
130
|
}
|
|
131
|
+
// Emit the owner-facing `landed-failure` wake — the SAME actionable notice the live
|
|
132
|
+
// merge_planned→failed settle fires (routes/commands.ts → notifyBranchLandFailed). The
|
|
133
|
+
// loop-breaker stops dispatching once a lane hits the no-progress cap, so without this
|
|
134
|
+
// the dispatch-coupled wake never fires again and a backed-off lane (e.g. a dirty base
|
|
135
|
+
// checkout, #638/#644) goes silent until a human re-arms it via manual request-review.
|
|
136
|
+
// The branch worker is parked specifically on `landed-failure`; the `stranded_escalation`
|
|
137
|
+
// below is a separate fallback/steward signal, not the wake the owner awaits. Cooldown is
|
|
138
|
+
// already enforced by the early-return above, so this fires at most once per backoff.
|
|
139
|
+
// (Coordinator routing — owner's `spawnedBy` — is the #642 coordinator-awareness epic.)
|
|
140
|
+
notifyBranchLandFailed({
|
|
141
|
+
workspace: ws,
|
|
142
|
+
reason: `auto-merge backed off after ${MAX_NO_PROGRESS_MERGE_ATTEMPTS} no-progress attempts (${mergeError})`,
|
|
143
|
+
});
|
|
144
|
+
// #642 — escalation must reach the branch agent's coordinator (owner's `spawnedBy`), not
|
|
145
|
+
// just the configured fallback. When steward is enabled, wakeRepoSteward above already
|
|
146
|
+
// notified the coordinator of the handoff; including it here covers the steward-disabled
|
|
147
|
+
// path too so a backed-off lane always reaches the agent responsible for the work.
|
|
148
|
+
const { coordinatorAgentId } = resolveStewardIdentity(ws);
|
|
120
149
|
const fallback = stewardFallbackTarget();
|
|
121
|
-
const recipients = [...new Set([fallback, ws.ownerAgentId].filter((r): r is string => Boolean(r)))];
|
|
150
|
+
const recipients = [...new Set([coordinatorAgentId, fallback, ws.ownerAgentId].filter((r): r is string => Boolean(r)))];
|
|
122
151
|
if (recipients.length) {
|
|
123
152
|
routeNotification({
|
|
124
153
|
type: "workspace.stranded_escalation",
|
|
125
|
-
scope: { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ws.ownerAgentId },
|
|
154
|
+
scope: { workspaceId: ws.id, repoRoot: ws.repoRoot, author: ws.ownerAgentId, parent: coordinatorAgentId ?? undefined },
|
|
126
155
|
recipients,
|
|
127
156
|
message: {
|
|
128
157
|
subject: "Workspace can't auto-land — needs attention",
|
|
@@ -155,6 +155,12 @@ function prRefreshForRelayMerge(workspace: WorkspaceRecord, preview: WorkspaceMe
|
|
|
155
155
|
if (policy === "on-approval" && preview.reviewDecision !== "APPROVED") return { ok: false };
|
|
156
156
|
if (preview.conflict === true) return { ok: false };
|
|
157
157
|
if ((preview.behind ?? 0) <= 0) return { ok: false };
|
|
158
|
+
// #458: self-heal ONLY a PR that went red *purely because its base advanced* — its checks are
|
|
159
|
+
// failing AND the branch is behind base. A green (or still-pending) PR is never refreshed: it's
|
|
160
|
+
// left to the merge path / native auto-merge, so we don't re-trigger CI on a branch that isn't
|
|
161
|
+
// actually red. A red+up-to-date PR (behind=0, handled above) is red on its own merits, not by
|
|
162
|
+
// divergence, so it must not be refreshed into a loop either.
|
|
163
|
+
if (!statusChecksRed(preview.statusCheckRollup)) return { ok: false };
|
|
158
164
|
|
|
159
165
|
const mergeable = text(preview.mergeable)?.toUpperCase();
|
|
160
166
|
if (mergeable && mergeable !== "MERGEABLE") return { ok: false };
|
|
@@ -233,6 +239,26 @@ function statusChecksGreen(rollup: unknown): boolean {
|
|
|
233
239
|
return rollup.every(statusNodeGreen);
|
|
234
240
|
}
|
|
235
241
|
|
|
242
|
+
// "Red" = at least one check has a definitive FAILING conclusion/state. Distinct from
|
|
243
|
+
// `!statusChecksGreen` on purpose: a still-pending or no-checks-yet rollup is NOT red, so the
|
|
244
|
+
// #458 base-divergence refresh never interrupts an in-flight run or a PR that hasn't reported.
|
|
245
|
+
const FAILING_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"]);
|
|
246
|
+
const FAILING_STATES = new Set(["FAILURE", "ERROR"]);
|
|
247
|
+
|
|
248
|
+
function statusChecksRed(rollup: unknown): boolean {
|
|
249
|
+
if (!Array.isArray(rollup) || rollup.length === 0) return false;
|
|
250
|
+
return rollup.some(statusNodeRed);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function statusNodeRed(node: unknown): boolean {
|
|
254
|
+
if (!isRecord(node)) return false;
|
|
255
|
+
const conclusion = text(node.conclusion)?.toUpperCase();
|
|
256
|
+
if (conclusion) return FAILING_CONCLUSIONS.has(conclusion);
|
|
257
|
+
const state = text(node.state)?.toUpperCase();
|
|
258
|
+
if (state) return FAILING_STATES.has(state);
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
236
262
|
function prMergeable(preview: WorkspaceMergePreview): boolean {
|
|
237
263
|
const mergeable = text(preview.mergeable)?.toUpperCase();
|
|
238
264
|
if (mergeable !== "MERGEABLE") return false;
|