agent-relay-server 0.85.0 → 0.87.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-DS4Hbn0k.js} +2 -2
- package/public/assets/{MessageBubble-DqFyach0.js.map → MessageBubble-DS4Hbn0k.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CCvKbYWe.js → PlanGraphPanel-BYlragEi.js} +2 -2
- package/public/assets/{PlanGraphPanel-CCvKbYWe.js.map → PlanGraphPanel-BYlragEi.js.map} +1 -1
- package/public/assets/{activity-C6RvknQQ.js → activity-CcGFS_sY.js} +2 -2
- package/public/assets/{activity-C6RvknQQ.js.map → activity-CcGFS_sY.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-BTh8SQiP.js} +2 -2
- package/public/assets/{agents-D7HZJ5eG.js.map → agents-BTh8SQiP.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-izYGpZyQ.js} +2 -2
- package/public/assets/{automation-BUazA3lr.js.map → automation-izYGpZyQ.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-ThyPRUPq.js} +2 -2
- package/public/assets/{chat-DBTEaf-H.js.map → chat-ThyPRUPq.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-DEYxGoiw.js} +2 -2
- package/public/assets/{formatted-body-impl-KG0iExZ-.js.map → formatted-body-impl-DEYxGoiw.js.map} +1 -1
- package/public/assets/index-Bd1NBLyd.js +23 -0
- package/public/assets/{index-TZskQfQL.js.map → index-Bd1NBLyd.js.map} +1 -1
- package/public/assets/index-BjbvhfFt.css +2 -0
- package/public/assets/{insights-JL1s7Uul.js → insights-DAVKQ_SF.js} +2 -2
- package/public/assets/{insights-JL1s7Uul.js.map → insights-DAVKQ_SF.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-BL4R9076.js} +2 -2
- package/public/assets/{maintenance-CTMfhXFM.js.map → maintenance-BL4R9076.js.map} +1 -1
- package/public/assets/{managed-agents-C4cb3xQ4.js → managed-agents-C7n1gl7E.js} +2 -2
- package/public/assets/{managed-agents-C4cb3xQ4.js.map → managed-agents-C7n1gl7E.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DQIi0O7r.js → markdown-preview-impl-DK0wNWXJ.js} +2 -2
- package/public/assets/{markdown-preview-impl-DQIi0O7r.js.map → markdown-preview-impl-DK0wNWXJ.js.map} +1 -1
- package/public/assets/{memory-DJPXFdgw.js → memory-BJEkDkwl.js} +2 -2
- package/public/assets/{memory-DJPXFdgw.js.map → memory-BJEkDkwl.js.map} +1 -1
- package/public/assets/{messages-RSD2g4Wx.js → messages-DBn9tXx2.js} +2 -2
- package/public/assets/{messages-RSD2g4Wx.js.map → messages-DBn9tXx2.js.map} +1 -1
- package/public/assets/{orchestrators-BGIkULBF.js → orchestrators-Bc1eC16Q.js} +2 -2
- package/public/assets/{orchestrators-BGIkULBF.js.map → orchestrators-Bc1eC16Q.js.map} +1 -1
- package/public/assets/{overview-ByE9V8pe.js → overview-Bw7zrSqR.js} +2 -2
- package/public/assets/{overview-ByE9V8pe.js.map → overview-Bw7zrSqR.js.map} +1 -1
- package/public/assets/{pairs-C284ntvD.js → pairs-BSwWnLK2.js} +2 -2
- package/public/assets/{pairs-C284ntvD.js.map → pairs-BSwWnLK2.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-wTBapq4K.js} +2 -2
- package/public/assets/{security-vDglScsg.js.map → security-wTBapq4K.js.map} +1 -1
- package/public/assets/{select-DaOiWDBR.js → select-D6HFOeLW.js} +2 -2
- package/public/assets/{select-DaOiWDBR.js.map → select-D6HFOeLW.js.map} +1 -1
- package/public/assets/{settings-DiLcbSqx.js → settings-CDzp7vqJ.js} +2 -2
- package/public/assets/{settings-DiLcbSqx.js.map → settings-CDzp7vqJ.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-Bd7Wjy6e.js} +2 -2
- package/public/assets/{tasks-DWJdJra3.js.map → tasks-Bd7Wjy6e.js.map} +1 -1
- package/public/assets/{teams-FejXkix4.js → teams-l3GUr2XV.js} +2 -2
- package/public/assets/{teams-FejXkix4.js.map → teams-l3GUr2XV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-Bkt1w42o.js → terminal-viewer-impl-BQMMWctd.js} +2 -2
- package/public/assets/{terminal-viewer-impl-Bkt1w42o.js.map → terminal-viewer-impl-BQMMWctd.js.map} +1 -1
- package/public/assets/{user-avatar-ClE2dMD8.js → user-avatar-DcA69UJK.js} +2 -2
- package/public/assets/{user-avatar-ClE2dMD8.js.map → user-avatar-DcA69UJK.js.map} +1 -1
- package/public/assets/{work-queue-DZdv9kOb.js → work-queue-BYlFeJ1g.js} +2 -2
- package/public/assets/{work-queue-DZdv9kOb.js.map → work-queue-BYlFeJ1g.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 +3 -3
- package/runner/src/adapter.ts +4 -3
- package/src/agent-busy-health.ts +93 -0
- package/src/agent-card-projection.ts +4 -1
- 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 +9 -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 +88 -0
- package/src/maintenance/teams.ts +116 -5
- 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 +6 -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/team-idle-tracker.ts +19 -0
- package/src/workspace-auto-merge.ts +35 -6
- package/src/workspace-pr-completion.ts +26 -0
- package/public/assets/index-LUIvWcx6.css +0 -2
- package/public/assets/index-TZskQfQL.js +0 -23
- package/public/assets/store-DYaQAXRv.js.map +0 -1
|
@@ -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");
|
package/src/team-idle-tracker.ts
CHANGED
|
@@ -26,3 +26,22 @@ export interface TeamMemberWatch {
|
|
|
26
26
|
|
|
27
27
|
// teamId → (memberId → watch)
|
|
28
28
|
export const teamMemberWatchTracker = new Map<string, Map<string, TeamMemberWatch>>();
|
|
29
|
+
|
|
30
|
+
// #412/#468 — per-team COORDINATOR liveness watch. The member watch above deliberately skips the
|
|
31
|
+
// coordinator (it's the ping recipient, not a watched worker), so a coordinator that dies silently
|
|
32
|
+
// is invisible to it. This tracker remembers the coordinator's last-alive observation so a later
|
|
33
|
+
// silence escalates ONCE to the human/steward fallback instead of dead-ending — a coordinator-gone
|
|
34
|
+
// team must never be silently abandoned.
|
|
35
|
+
export interface TeamCoordinatorWatch {
|
|
36
|
+
/** Identity of the coordinator this watch is for; a re-elected/replaced coordinator resets it. */
|
|
37
|
+
coordinatorId: string;
|
|
38
|
+
/** Most recent time the coordinator was seen alive (non-offline, fresh heartbeat). */
|
|
39
|
+
lastActiveAt: number;
|
|
40
|
+
/** Whether the coordinator's CURRENT gone-spell already escalated (re-armed when it recovers). */
|
|
41
|
+
escalated: boolean;
|
|
42
|
+
/** Coordinator label for a readable escalation. */
|
|
43
|
+
label?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// teamId → coordinator watch
|
|
47
|
+
export const teamCoordinatorWatchTracker = new Map<string, TeamCoordinatorWatch>();
|
|
@@ -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;
|