agent-relay-server 0.120.4 → 0.120.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-Cqi3XsZ7.js → MessageBubble-So86mU9-.js} +2 -2
  4. package/public/assets/{MessageBubble-Cqi3XsZ7.js.map → MessageBubble-So86mU9-.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-D6Um05oW.js → PlanGraphPanel-DBZYAsww.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-D6Um05oW.js.map → PlanGraphPanel-DBZYAsww.js.map} +1 -1
  7. package/public/assets/{activity-B0LsQHEz.js → activity-CZNwmXg1.js} +2 -2
  8. package/public/assets/{activity-B0LsQHEz.js.map → activity-CZNwmXg1.js.map} +1 -1
  9. package/public/assets/{agent-profiles-O--pe5Ic.js → agent-profiles-BhadoGwi.js} +2 -2
  10. package/public/assets/{agent-profiles-O--pe5Ic.js.map → agent-profiles-BhadoGwi.js.map} +1 -1
  11. package/public/assets/agents-DWn1xx9x.js +2 -0
  12. package/public/assets/{agents-Ii6XlVwR.js.map → agents-DWn1xx9x.js.map} +1 -1
  13. package/public/assets/{analytics-BCzdYWRT.js → analytics-DifGnk7q.js} +2 -2
  14. package/public/assets/{analytics-BCzdYWRT.js.map → analytics-DifGnk7q.js.map} +1 -1
  15. package/public/assets/{automation-Crr3Xzui.js → automation-BExrtKkb.js} +2 -2
  16. package/public/assets/{automation-Crr3Xzui.js.map → automation-BExrtKkb.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-Bhw2rloy.js → branch-state-badge-CyC5Qrhc.js} +2 -2
  18. package/public/assets/{branch-state-badge-Bhw2rloy.js.map → branch-state-badge-CyC5Qrhc.js.map} +1 -1
  19. package/public/assets/{channels-CKs9rvsn.js → channels-BLkdt2XR.js} +2 -2
  20. package/public/assets/{channels-CKs9rvsn.js.map → channels-BLkdt2XR.js.map} +1 -1
  21. package/public/assets/chat-C7OhKtr6.js +2 -0
  22. package/public/assets/{chat-BCwEUj5R.js.map → chat-C7OhKtr6.js.map} +1 -1
  23. package/public/assets/{connectors-D_kQR_vw.js → connectors-C0n12Je8.js} +2 -2
  24. package/public/assets/{connectors-D_kQR_vw.js.map → connectors-C0n12Je8.js.map} +1 -1
  25. package/public/assets/{formatted-body-q_qzt1L-.js → formatted-body-BCQT2qSb.js} +3 -3
  26. package/public/assets/{formatted-body-q_qzt1L-.js.map → formatted-body-BCQT2qSb.js.map} +1 -1
  27. package/public/assets/{formatted-body-impl-CSRGuMcM.js → formatted-body-impl-Bk7wnESl.js} +2 -2
  28. package/public/assets/{formatted-body-impl-CSRGuMcM.js.map → formatted-body-impl-Bk7wnESl.js.map} +1 -1
  29. package/public/assets/index-CRraCeo_.js +25 -0
  30. package/public/assets/index-CRraCeo_.js.map +1 -0
  31. package/public/assets/{insights-DUhXqZTZ.js → insights-DEgES6jz.js} +2 -2
  32. package/public/assets/{insights-DUhXqZTZ.js.map → insights-DEgES6jz.js.map} +1 -1
  33. package/public/assets/{integrations-lbg5CjtE.js → integrations-v14ehskT.js} +2 -2
  34. package/public/assets/{integrations-lbg5CjtE.js.map → integrations-v14ehskT.js.map} +1 -1
  35. package/public/assets/{maintenance-CQgneSVf.js → maintenance-BgVB6_gO.js} +2 -2
  36. package/public/assets/{maintenance-CQgneSVf.js.map → maintenance-BgVB6_gO.js.map} +1 -1
  37. package/public/assets/{managed-agents-DsnYf6fm.js → managed-agents-8_NKSkhI.js} +3 -3
  38. package/public/assets/{managed-agents-DsnYf6fm.js.map → managed-agents-8_NKSkhI.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-C1ZtGJp6.js → markdown-preview-impl-yxQkrd2M.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-C1ZtGJp6.js.map → markdown-preview-impl-yxQkrd2M.js.map} +1 -1
  41. package/public/assets/{memory-SSLUTOI2.js → memory-CJD8eekg.js} +2 -2
  42. package/public/assets/{memory-SSLUTOI2.js.map → memory-CJD8eekg.js.map} +1 -1
  43. package/public/assets/{messages-BY26OP4K.js → messages-DLHMZbHZ.js} +2 -2
  44. package/public/assets/{messages-BY26OP4K.js.map → messages-DLHMZbHZ.js.map} +1 -1
  45. package/public/assets/{orchestrators-OY7yPzVI.js → orchestrators-DEOep7FX.js} +2 -2
  46. package/public/assets/{orchestrators-OY7yPzVI.js.map → orchestrators-DEOep7FX.js.map} +1 -1
  47. package/public/assets/{overview-Dj0u1tPG.js → overview-BgpfO6mt.js} +2 -2
  48. package/public/assets/{overview-Dj0u1tPG.js.map → overview-BgpfO6mt.js.map} +1 -1
  49. package/public/assets/{pairs-CrUR50ou.js → pairs-8NVKHh96.js} +2 -2
  50. package/public/assets/{pairs-CrUR50ou.js.map → pairs-8NVKHh96.js.map} +1 -1
  51. package/public/assets/{projects-B_AtxHtH.js → projects-CZJW35C7.js} +2 -2
  52. package/public/assets/{projects-B_AtxHtH.js.map → projects-CZJW35C7.js.map} +1 -1
  53. package/public/assets/{prompt-settings-CPtYSban.js → prompt-settings-fqyLwUa-.js} +2 -2
  54. package/public/assets/{prompt-settings-CPtYSban.js.map → prompt-settings-fqyLwUa-.js.map} +1 -1
  55. package/public/assets/{registry-BgUALWn5.js → registry-BKbLL4mO.js} +2 -2
  56. package/public/assets/{registry-BgUALWn5.js.map → registry-BKbLL4mO.js.map} +1 -1
  57. package/public/assets/{security-5lHfGk7g.js → security-BlTFTbq0.js} +2 -2
  58. package/public/assets/{security-5lHfGk7g.js.map → security-BlTFTbq0.js.map} +1 -1
  59. package/public/assets/{select-Cgx4QSLj.js → select-Zd_qh0uU.js} +2 -2
  60. package/public/assets/{select-Cgx4QSLj.js.map → select-Zd_qh0uU.js.map} +1 -1
  61. package/public/assets/{settings-DFGgxyun.js → settings-nHuSVRkL.js} +2 -2
  62. package/public/assets/{settings-DFGgxyun.js.map → settings-nHuSVRkL.js.map} +1 -1
  63. package/public/assets/store-nXGxgz2Q.js +9 -0
  64. package/public/assets/store-nXGxgz2Q.js.map +1 -0
  65. package/public/assets/{tasks-BvH5rluu.js → tasks-C3JhQ8vB.js} +2 -2
  66. package/public/assets/{tasks-BvH5rluu.js.map → tasks-C3JhQ8vB.js.map} +1 -1
  67. package/public/assets/{teams-VZOIOdoE.js → teams-C9E9Yt39.js} +2 -2
  68. package/public/assets/{teams-VZOIOdoE.js.map → teams-C9E9Yt39.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-DYY3Wmoe.js → terminal-viewer-impl-FP0JlsvS.js} +22 -22
  70. package/public/assets/{terminal-viewer-impl-DYY3Wmoe.js.map → terminal-viewer-impl-FP0JlsvS.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-B6Xk7QNe.js → work-queue-Ema7Hy3s.js} +2 -2
  73. package/public/assets/{work-queue-B6Xk7QNe.js.map → work-queue-Ema7Hy3s.js.map} +1 -1
  74. package/public/assets/{workspaces-CPIzAHAe.js → workspaces-DI317fMk.js} +2 -2
  75. package/public/assets/{workspaces-CPIzAHAe.js.map → workspaces-DI317fMk.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/src/auto-spawn-dispatch.ts +2 -2
  79. package/src/commands-db.ts +14 -0
  80. package/src/db/delivery.ts +85 -3
  81. package/src/db/index.ts +2 -0
  82. package/src/db/migrations.ts +2 -1
  83. package/src/db/provisioning-callmux.ts +180 -0
  84. package/src/db/provisioning-registry.ts +45 -374
  85. package/src/db/provisioning-resolve.ts +245 -0
  86. package/src/db/stats.ts +10 -8
  87. package/src/db/task-entity.ts +7 -8
  88. package/src/lifecycle-manager.ts +11 -12
  89. package/src/maintenance/jobs.ts +3 -1
  90. package/src/maintenance/workspaces.ts +28 -47
  91. package/src/managed-policy-escalation.ts +47 -0
  92. package/src/mcp-task-tools.ts +4 -5
  93. package/src/routes/commands.ts +8 -245
  94. package/src/routes/tasks.ts +3 -5
  95. package/src/routes/workspaces.ts +58 -22
  96. package/src/services/issue-lifecycle.ts +23 -8
  97. package/src/services/task-entity.ts +54 -2
  98. package/src/task-dispatcher.ts +12 -3
  99. package/src/workspace-auto-merge.ts +94 -34
  100. package/src/workspace-command-settle.ts +227 -0
  101. package/src/workspace-escalation.ts +45 -0
  102. package/src/workspace-merge-progress.ts +16 -0
  103. package/src/workspace-orphans.ts +96 -4
  104. package/public/assets/agents-Ii6XlVwR.js +0 -2
  105. package/public/assets/chat-BCwEUj5R.js +0 -2
  106. package/public/assets/index-Ceiwd02X.js +0 -25
  107. package/public/assets/index-Ceiwd02X.js.map +0 -1
  108. package/public/assets/store-BM4zM89B.js +0 -9
  109. package/public/assets/store-BM4zM89B.js.map +0 -1
@@ -0,0 +1,245 @@
1
+ // Profile provisioning resolution lives outside provisioning-registry.ts so the
2
+ // registry CRUD/vendoring module stays under the file-size ratchet.
3
+
4
+ import { existsSync, readdirSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import { getManifest, type ProviderManifest } from "agent-relay-providers";
7
+ import { isRecord } from "agent-relay-sdk";
8
+ import type {
9
+ AgentProfile,
10
+ AgentProfileAssetRef,
11
+ AgentProfileProvider,
12
+ PluginAssetDefinition,
13
+ ProvisioningAssetKind,
14
+ ProvisioningMcpServer,
15
+ ProvisioningSkip,
16
+ ResolvedAssetDefinition,
17
+ ResolvedProvisioning,
18
+ SkillAssetDefinition,
19
+ SpawnProvider,
20
+ } from "agent-relay-sdk";
21
+ import { getDb } from "./connection.ts";
22
+ import { parseJson } from "../utils";
23
+
24
+ type ProviderBundledSkillSetDescriptor = NonNullable<NonNullable<ProviderManifest["provisioning"]>["bundledSkills"]>;
25
+
26
+ interface ProvisioningCapabilityResolutionRow {
27
+ id: string;
28
+ slug: string;
29
+ kind: ProvisioningAssetKind;
30
+ status: string;
31
+ }
32
+
33
+ interface ProvisioningAssetResolutionRow {
34
+ kind: ProvisioningAssetKind;
35
+ name: string;
36
+ provider: AgentProfileProvider;
37
+ definition: string;
38
+ enabled: number;
39
+ approval_status?: string | null;
40
+ validation_status?: string | null;
41
+ validation_errors?: string | null;
42
+ }
43
+
44
+ interface RegistryResolution {
45
+ definition: ResolvedAssetDefinition | null;
46
+ skip?: ProvisioningSkip;
47
+ }
48
+
49
+ export function bundledSkillRefs(descriptor: ProviderBundledSkillSetDescriptor): string[] {
50
+ const skillsDir = resolve(import.meta.dir, "../../", descriptor.dir);
51
+ const entryFile = descriptor.entryFile ?? "SKILL.md";
52
+ if (!existsSync(skillsDir)) return [];
53
+ return readdirSync(skillsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(join(skillsDir, entry.name, entryFile)))
54
+ .map((entry) => descriptor.refPrefix + entry.name).sort();
55
+ }
56
+
57
+ export function stringRecord(value: Record<string, unknown>): Record<string, string> {
58
+ const out: Record<string, string> = {};
59
+ for (const [k, v] of Object.entries(value)) {
60
+ if (typeof v === "string") out[k] = v;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function rowDefinition(row: ProvisioningAssetResolutionRow): ResolvedAssetDefinition {
66
+ return parseJson<ResolvedAssetDefinition>(row.definition, { kind: row.kind, name: row.name } as ResolvedAssetDefinition);
67
+ }
68
+
69
+ function skip(kind: ProvisioningAssetKind, ref: string, provider: SpawnProvider, reason: ProvisioningSkip["reason"], detail: string): ProvisioningSkip {
70
+ return { kind, ref, provider, reason, detail };
71
+ }
72
+
73
+ function variantSkipReason(row: ProvisioningAssetResolutionRow): Pick<ProvisioningSkip, "reason" | "detail"> | null {
74
+ if (row.enabled !== 1) return { reason: "disabled", detail: "Registry variant is disabled." };
75
+ if (row.approval_status !== "approved") return { reason: "unapproved", detail: `Registry variant approval_status is ${row.approval_status ?? "unset"}.` };
76
+ if (row.validation_status !== "valid") {
77
+ const errors = parseJson<unknown[]>(row.validation_errors ?? "[]", []);
78
+ const detail = errors.length ? `Registry variant validation_status is ${row.validation_status}; ${JSON.stringify(errors)}` : `Registry variant validation_status is ${row.validation_status ?? "unset"}.`;
79
+ return { reason: "invalid", detail };
80
+ }
81
+ return null;
82
+ }
83
+
84
+ // Resolve a registry ref for a target provider: an approved/valid/enabled exact
85
+ // provider match wins, else provider-neutral "any". MCP is intentionally
86
+ // provider-neutral in v1, so only "any" MCP variants are candidates.
87
+ function resolveRegistryDefinition(kind: ProvisioningAssetKind, ref: string, provider: SpawnProvider): RegistryResolution {
88
+ const capability = getDb()
89
+ .query("SELECT id, slug, kind, status FROM provisioning_capabilities WHERE kind = ? AND slug = ?")
90
+ .get(kind, ref) as ProvisioningCapabilityResolutionRow | undefined;
91
+ if (!capability) {
92
+ return { definition: null, skip: skip(kind, ref, provider, "missing", "No provisioning capability exists for this ref.") };
93
+ }
94
+
95
+ const rows = getDb()
96
+ .query("SELECT * FROM provisioning_assets WHERE capability_id = ? ORDER BY CASE provider WHEN ? THEN 0 WHEN 'any' THEN 1 ELSE 2 END, provider")
97
+ .all(capability.id, provider) as ProvisioningAssetResolutionRow[];
98
+ const providerCandidates = rows.filter((row) => kind === "mcp" ? row.provider === "any" : row.provider === provider || row.provider === "any");
99
+ if (!providerCandidates.length) {
100
+ const label = getManifest(provider)?.label ?? provider;
101
+ return { definition: null, skip: skip(kind, ref, provider, "provider_mismatch", `No ${label} variant exists for capability ${ref}.`) };
102
+ }
103
+
104
+ for (const row of providerCandidates) {
105
+ if (!variantSkipReason(row)) return { definition: rowDefinition(row) };
106
+ }
107
+
108
+ const blocked = variantSkipReason(providerCandidates[0]!);
109
+ return { definition: null, skip: skip(kind, ref, provider, blocked?.reason ?? "missing", blocked?.detail ?? "No resolvable registry variant exists.") };
110
+ }
111
+
112
+ // A ref applies to a launch iff it's enabled and its provider scope includes the
113
+ // target ("any"/undefined → all providers).
114
+ function refAppliesToProvider(ref: AgentProfileAssetRef, provider: SpawnProvider): boolean {
115
+ return ref.enabled && (!ref.provider || ref.provider === "any" || ref.provider === provider);
116
+ }
117
+
118
+ function hasAssetRef(refs: AgentProfileAssetRef[], source: AgentProfileAssetRef["source"], ref: string): boolean {
119
+ return refs.some((item) => item.source === source && item.ref === ref);
120
+ }
121
+
122
+ function relayBundledProvisioningRefs(profile: AgentProfile, provider: SpawnProvider): Pick<AgentProfile, "skills" | "plugins"> {
123
+ const skills = [...(profile.skills ?? [])];
124
+ const plugins = [...(profile.plugins ?? [])];
125
+ const provisioning = getManifest(provider)?.provisioning;
126
+ if (profile.relay.skills !== false && provisioning?.bundledSkills) {
127
+ for (const ref of bundledSkillRefs(provisioning.bundledSkills)) {
128
+ if (!hasAssetRef(skills, "relay", ref)) skills.push({ source: "relay", ref, enabled: true, provider });
129
+ }
130
+ }
131
+ if (profile.relay.plugins !== false && provisioning?.bundledPlugin && !hasAssetRef(plugins, "relay", provisioning.bundledPlugin.ref)) {
132
+ plugins.push({ source: "relay", ref: provisioning.bundledPlugin.ref, enabled: true, provider });
133
+ }
134
+ return { skills, plugins };
135
+ }
136
+
137
+ function withRelayBundledProvisioningRefs(profile: AgentProfile, provider: SpawnProvider): AgentProfile {
138
+ const { skills, plugins } = relayBundledProvisioningRefs(profile, provider);
139
+ return { ...profile, skills, plugins };
140
+ }
141
+
142
+ function inlineFilesFromMeta(meta: Record<string, unknown> | undefined): { path: string; content: string }[] | undefined {
143
+ const raw = meta?.files;
144
+ if (!Array.isArray(raw)) return undefined;
145
+ const files = raw
146
+ .filter(isRecord)
147
+ .filter((f) => typeof f.path === "string" && typeof f.content === "string")
148
+ .map((f) => ({ path: f.path as string, content: f.content as string }));
149
+ return files.length ? files : undefined;
150
+ }
151
+
152
+ // Resolve a skill/plugin asset ref into a concrete definition. relay → registry;
153
+ // inline → built from ref.meta (files or dir); repo → a cwd-relative dir token the
154
+ // runner resolves against the launch cwd; provider → skipped (provider built-in).
155
+ function resolveDirAssetRef(
156
+ kind: "skill" | "plugin",
157
+ ref: AgentProfileAssetRef,
158
+ provider: SpawnProvider,
159
+ ): { definition: SkillAssetDefinition | PluginAssetDefinition | null; skip?: ProvisioningSkip } {
160
+ if (ref.source === "relay") {
161
+ const resolved = resolveRegistryDefinition(kind, ref.ref, provider);
162
+ const def = resolved.definition;
163
+ return { definition: def && def.kind === kind ? def : null, skip: resolved.skip };
164
+ }
165
+ if (ref.source === "inline") {
166
+ const files = inlineFilesFromMeta(ref.meta);
167
+ const dir = typeof ref.meta?.dir === "string" ? ref.meta.dir : undefined;
168
+ if (!files && !dir) return { definition: null, skip: skip(kind, ref.ref, provider, "invalid_inline", `Inline ${kind} ref requires files or dir metadata.`) };
169
+ return { definition: { kind, name: ref.ref, ...(files ? { files } : {}), ...(dir ? { dir } : {}) } };
170
+ }
171
+ if (ref.source === "repo") {
172
+ return { definition: { kind, name: ref.ref, dir: ref.ref } };
173
+ }
174
+ return { definition: null };
175
+ }
176
+
177
+ // A profile.mcp.servers entry is either an inline server spec or a registry
178
+ // reference (`{ source: "relay", ref }`). Returns the concrete server spec.
179
+ function resolveMcpEntry(name: string, value: unknown, provider: SpawnProvider): { server: ProvisioningMcpServer | null; skip?: ProvisioningSkip } {
180
+ if (!isRecord(value)) return { server: null };
181
+ if (value.source === "relay" && typeof value.ref === "string") {
182
+ const resolved = resolveRegistryDefinition("mcp", value.ref, provider);
183
+ const def = resolved.definition;
184
+ return { server: def && def.kind === "mcp" ? def.server : null, skip: resolved.skip };
185
+ }
186
+ if (value.enabled === false) return { server: null };
187
+ // Inline spec: keep the recognized server fields only.
188
+ const server: ProvisioningMcpServer = {};
189
+ if (typeof value.type === "string") server.type = value.type as ProvisioningMcpServer["type"];
190
+ if (typeof value.command === "string") server.command = value.command;
191
+ if (Array.isArray(value.args)) server.args = value.args.filter((a): a is string => typeof a === "string");
192
+ if (isRecord(value.env)) server.env = stringRecord(value.env);
193
+ if (isRecord(value.configEnv)) server.configEnv = stringRecord(value.configEnv);
194
+ if (typeof value.url === "string") server.url = value.url;
195
+ if (isRecord(value.headers)) server.headers = stringRecord(value.headers);
196
+ return { server: server.command || server.url ? server : null, skip: server.command || server.url ? undefined : skip("mcp", name, provider, "invalid_inline", "Inline MCP server requires command or url.") };
197
+ }
198
+
199
+ // Resolve a profile's declared skill/plugin/MCP refs against the registry into the
200
+ // concrete bundle the runner materializes. Relay-side (needs the DB); the result is
201
+ // attached to the in-flight profile (profile.provisioning) before it's serialized
202
+ // into the spawn env. Pure read — never mutates the registry.
203
+ export function resolveProfileProvisioning(profile: AgentProfile, provider: SpawnProvider): ResolvedProvisioning {
204
+ const skills: SkillAssetDefinition[] = [];
205
+ const skipped: ProvisioningSkip[] = [];
206
+ for (const ref of profile.skills ?? []) {
207
+ if (!refAppliesToProvider(ref, provider)) {
208
+ if (ref.enabled && ref.source === "relay") skipped.push(skip("skill", ref.ref, provider, "provider_mismatch", `Profile ref is scoped to provider ${ref.provider}.`));
209
+ continue;
210
+ }
211
+ const resolved = resolveDirAssetRef("skill", ref, provider);
212
+ if (resolved.definition) skills.push(resolved.definition as SkillAssetDefinition);
213
+ else if (resolved.skip) skipped.push(resolved.skip);
214
+ }
215
+
216
+ const plugins: PluginAssetDefinition[] = [];
217
+ for (const ref of profile.plugins ?? []) {
218
+ if (!refAppliesToProvider(ref, provider)) {
219
+ if (ref.enabled && ref.source === "relay") skipped.push(skip("plugin", ref.ref, provider, "provider_mismatch", `Profile ref is scoped to provider ${ref.provider}.`));
220
+ continue;
221
+ }
222
+ const resolved = resolveDirAssetRef("plugin", ref, provider);
223
+ if (resolved.definition) plugins.push(resolved.definition as PluginAssetDefinition);
224
+ else if (resolved.skip) skipped.push(resolved.skip);
225
+ }
226
+
227
+ const mcpServers: Record<string, ProvisioningMcpServer> = {};
228
+ for (const [name, value] of Object.entries(profile.mcp?.servers ?? {})) {
229
+ const resolved = resolveMcpEntry(name, value, provider);
230
+ if (resolved.server) mcpServers[name] = resolved.server;
231
+ else if (resolved.skip) skipped.push(resolved.skip);
232
+ }
233
+
234
+ return { skills, plugins, mcpServers, ...(skipped.length ? { skipped } : {}) };
235
+ }
236
+
237
+ // Attach the resolved bundle to a spawn-bound profile. Returns a shallow copy so the
238
+ // cached config-store value is never mutated. undefined passes through (an unresolved
239
+ // profile name stays undefined so callers' not-found guards still fire); the runner
240
+ // treats an absent and an empty bundle identically.
241
+ export function withResolvedProvisioning(profile: AgentProfile | undefined, provider: SpawnProvider): AgentProfile | undefined {
242
+ if (!profile) return undefined;
243
+ const composed = withRelayBundledProvisioningRefs(profile, provider);
244
+ return { ...composed, provisioning: resolveProfileProvisioning(composed, provider) };
245
+ }
package/src/db/stats.ts CHANGED
@@ -16,7 +16,7 @@ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_ME
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { listAgents } from "./agents.ts";
18
18
  import { listChannels } from "./channels.ts";
19
- import { countExpiredUndeliveredMessages } from "./delivery.ts";
19
+ import { summarizeExpiredUndeliveredMessages } from "./delivery.ts";
20
20
  import { messageTaskClosed } from "./claim-sql.ts";
21
21
  import { claimTaskWhere } from "./task-core.ts";
22
22
  import { getDb } from "./connection.ts";
@@ -489,14 +489,17 @@ export function getHealth(now: number = Date.now()): HealthReport {
489
489
  : undefined,
490
490
  });
491
491
 
492
- const expiredUndelivered = countExpiredUndeliveredMessages(now);
492
+ const expiredUndelivered = summarizeExpiredUndeliveredMessages(now);
493
493
  checks.push({
494
494
  name: "expired-undelivered-messages",
495
- status: expiredUndelivered > 0 ? "warn" : "ok",
496
- count: expiredUndelivered,
497
- detail: expiredUndelivered > 0
498
- ? `${expiredUndelivered} queued message(s) expired undelivered at max_age in the last 24h (silent data loss)`
499
- : undefined,
495
+ status: expiredUndelivered.unexplainedCount > 0 ? "warn" : "ok",
496
+ count: expiredUndelivered.unexplainedCount,
497
+ detail: expiredUndelivered.unexplainedCount > 0
498
+ ? `${expiredUndelivered.unexplainedCount} queued message(s) expired undelivered at max_age in the last 24h (silent data loss)${expiredUndelivered.explainedManagedPolicyCount > 0 ? `; ${expiredUndelivered.explainedManagedPolicyCount} managed-policy expiry(s) explained by policy health` : ""}`
499
+ : expiredUndelivered.explainedManagedPolicyCount > 0
500
+ ? `${expiredUndelivered.explainedManagedPolicyCount} queued managed-policy message(s) expired while the policy target was unavailable; tracked by spawn-policy health, not silent data loss`
501
+ : undefined,
502
+ subjects: expiredUndelivered.subjects.length > 0 ? expiredUndelivered.subjects : undefined,
500
503
  });
501
504
 
502
505
  const rootTokenRuntimeAgents = listAgents().filter((agent) => {
@@ -539,4 +542,3 @@ export function getHealth(now: number = Date.now()): HealthReport {
539
542
  : "ok";
540
543
  return { status, version: VERSION, generatedAt: now, checks };
541
544
  }
542
-
@@ -152,7 +152,7 @@ export interface CreateDeliverableTaskInput {
152
152
  intent: string;
153
153
  complexity?: TaskComplexity;
154
154
  context?: string;
155
- doneWhen?: string;
155
+ doneWhen?: string; branch?: string;
156
156
  teamId?: string;
157
157
  projectId?: string;
158
158
  repo?: string;
@@ -171,25 +171,24 @@ export function createDeliverableTask(input: CreateDeliverableTaskInput): TaskDe
171
171
  const dispatch = cleanDispatch(input.dispatch);
172
172
  const complexity = cleanTaskComplexity(input.complexity ?? dispatch?.complexity, "complexity") ?? DEFAULT_TASK_COMPLEXITY;
173
173
  const context = optionalText(input.context, "context", 1_000_000);
174
+ const branch = optionalText(input.branch, "branch", 500);
174
175
  const taskProject = cleanTaskProject(input);
175
176
  // Legacy NOT-NULL columns have no DB defaults (#834 finding 6) — set them explicitly.
176
177
  const result = getDb().query(`
177
178
  INSERT INTO tasks (
178
179
  source, title, body, severity, status, target, occurrence_count, metadata,
179
180
  created_at, updated_at, last_seen_at,
180
- kind, intent, complexity, context, latest_steer, deliverable_stage, done_when, team_id, project_id, repo, created_by, dispatch
181
- ) VALUES (?, ?, ?, 'info', 'open', ?, 1, '{}', ?, ?, ?, '${DELIVERABLE_TASK_KIND}', ?, ?, ?, ?, 'none', ?, ?, ?, ?, ?, ?)
181
+ kind, intent, complexity, context, latest_steer, deliverable_stage, done_when, branch, team_id, project_id, repo, created_by, dispatch
182
+ ) VALUES (?, ?, ?, 'info', 'open', ?, 1, '{}', ?, ?, ?, '${DELIVERABLE_TASK_KIND}', ?, ?, ?, ?, 'none', ?, ?, ?, ?, ?, ?, ?)
182
183
  `).run(
183
184
  optionalText(input.source, "source", 200) ?? "coordinator",
184
185
  title,
185
186
  intent,
186
187
  optionalText(input.target, "target", 200) ?? createdBy,
187
- now, now, now,
188
- intent,
189
- complexity,
190
- context ?? null,
188
+ now, now, now, intent, complexity, context ?? null,
191
189
  optionalText(input.latestSteer, "latestSteer", 1_000_000) ?? null,
192
190
  optionalText(input.doneWhen, "doneWhen", 1_000_000) ?? null,
191
+ branch ?? null,
193
192
  optionalText(input.teamId, "teamId", 200) ?? null,
194
193
  taskProject.projectId ?? null,
195
194
  taskProject.repo ?? null,
@@ -203,7 +202,7 @@ export function createDeliverableTask(input: CreateDeliverableTaskInput): TaskDe
203
202
  severity: "info",
204
203
  title: `Task created: ${title}`,
205
204
  body: intent,
206
- metadata: { createdBy, complexity, ...(context ? { context } : {}), ...(taskProject.projectId ? { projectId: taskProject.projectId } : {}), ...(taskProject.repo ? { repo: taskProject.repo } : {}), ...(dispatch ? { dispatch } : {}) },
205
+ metadata: { createdBy, complexity, ...(context ? { context } : {}), ...(branch ? { branch } : {}), ...(taskProject.projectId ? { projectId: taskProject.projectId } : {}), ...(taskProject.repo ? { repo: taskProject.repo } : {}), ...(dispatch ? { dispatch } : {}) },
207
206
  }, now);
208
207
  return getTaskDetail(id)!;
209
208
  }
@@ -2,14 +2,10 @@ import { createCommand } from "./commands-db";
2
2
  import { workspaceOwnerOrchestrator } from "./workspace-host";
3
3
  import { agentProviderState } from "./provider-state";
4
4
  import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces } from "./db";
5
- import {
6
- getManagedAgentState,
7
- listSpawnPolicies,
8
- updateManagedAgentState,
9
- upsertManagedAgentState,
10
- } from "./config-store";
5
+ import { getManagedAgentState, listSpawnPolicies, updateManagedAgentState, upsertManagedAgentState } from "./config-store";
11
6
  import { emitRelayEvent } from "./events";
12
7
  import { alwaysReloadTags, reconcileLifecycleContextPressure, requestAgentCompact } from "./lifecycle-context-pressure";
8
+ import { escalateOfflineOrchestrator } from "./managed-policy-escalation";
13
9
  import { markManagedAgentRunning, emitManagedState } from "./services/managed-running";
14
10
  import { authContextFromSystem } from "./services/auth-context";
15
11
  import { emitCommandEvent } from "./command-events";
@@ -189,15 +185,18 @@ export class LifecycleManager {
189
185
  spawnAgent(policy: SpawnPolicy, reason = "reconcile"): Command | null {
190
186
  const orch = getOrchestrator(policy.orchestratorId);
191
187
  if (!orch || orch.status !== "online") {
192
- const state = upsertManagedAgentState({
188
+ const state = getManagedAgentState(policy.name) ?? {
193
189
  policyName: policy.name,
194
- status: "backoff",
190
+ status: "stopped" as const,
195
191
  orchestratorId: policy.orchestratorId,
196
192
  provider: policy.provider,
197
- backoffUntil: this.now() + this.backoffDelay(policy, getManagedAgentState(policy.name)) * 1000,
198
- lastError: "orchestrator offline",
199
- });
200
- this.emitState(state);
193
+ restartCount: 0,
194
+ consecutiveFailures: 0,
195
+ updatedAt: this.now(),
196
+ };
197
+ this.markBackoff(policy, state, "orchestrator offline");
198
+ const next = getManagedAgentState(policy.name);
199
+ if (next) escalateOfflineOrchestrator(policy, next, orch);
201
200
  return null;
202
201
  }
203
202
  const spawnRequestId = generateSpawnRequestId();
@@ -27,10 +27,11 @@ import { isAgentUnavailable } from "../db/agent-predicates";
27
27
  import { flushQueuedDirectMessages } from "../services/managed-running";
28
28
  import { notifyAgentOffline } from "../agent-lifecycle-events";
29
29
  import { diagnoseAgentDeath } from "../agent-death-diagnosis";
30
- import { autoMergeCleanFastForwards, runAutoMergeWatchdog } from "../workspace-auto-merge";
30
+ import { autoMergeCleanFastForwards, runAutoMergeWatchdog, scheduleRepoMerge } from "../workspace-auto-merge";
31
31
  import { reapOrphanedWorktrees } from "../workspace-orphans";
32
32
  import { getLifecycleManager } from "../lifecycle-manager";
33
33
  import { applyCommandToRecipe } from "../recipe-runner";
34
+ import { settleWorkspaceCommand } from "../workspace-command-settle";
34
35
  import {
35
36
  emitAgentRemoved,
36
37
  emitAgentStatus,
@@ -237,6 +238,7 @@ export const definitions: MaintenanceJobDefinition[] = [
237
238
  handler() {
238
239
  const commands = expireCommands();
239
240
  for (const command of commands) {
241
+ settleWorkspaceCommand(command, { onMergeSettled: scheduleRepoMerge });
240
242
  applyCommandToRecipe(command);
241
243
  emitCommand(command);
242
244
  }
@@ -21,6 +21,7 @@ import { preparePrCompletionScan, reconcileLandedPr } from "../workspace-pr-comp
21
21
  import { applyBranchFreshness, fetchHostMergePreview, workspacePathWithinBase } from "../workspace-probe";
22
22
  import { wakeRepoSteward } from "../workspace-auto-merge";
23
23
  import { workspaceOwnerOrchestrator } from "../workspace-host";
24
+ import { routeWorkspaceCustodyEscalation } from "../workspace-escalation";
24
25
  import {
25
26
  CONFLICT_SCAN_STATUSES,
26
27
  DAY_MS,
@@ -175,74 +176,53 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
175
176
  const escalationTargets: string[] = [];
176
177
  const reconciledLandedIds: string[] = [];
177
178
  const escalationMs = stewardEscalationMs();
178
- const fallbackTarget = stewardFallbackTarget();
179
179
  for (const ws of all) {
180
180
  if (!STRANDABLE_STATUSES.has(ws.status) || ws.mode !== "isolated" || !ws.worktreePath) continue;
181
181
  reelectRepoSteward(ws.repoRoot);
182
182
  const fresh = getWorkspace(ws.id);
183
183
  if (!fresh || !STRANDABLE_STATUSES.has(fresh.status)) continue;
184
184
  const meta = fresh.metadata as Record<string, unknown>;
185
- const steward = fresh.stewardAgentId;
186
- const stewardOnline = Boolean(steward && getAgent(steward) && getAgent(steward)!.status !== "offline");
187
185
  const strandedAt = typeof meta.strandedAt === "number" ? meta.strandedAt : undefined;
188
186
 
189
- if (!stewardOnline) {
190
- const orch = workspaceOwnerOrchestrator(fresh, listOrchestrators().filter((o) => o.status === "online" && o.apiUrl), { requireApiUrl: true });
191
- const preview = orch?.apiUrl ? await fetchHostMergePreview(orch.apiUrl, fresh, { checkPr: true }) : null;
192
- if (preview && !("available" in preview) && !preview.error && !preview.missing) {
193
- const landed = previewProvesLanded(preview);
194
- if (landed) {
195
- reconcileLandedWorkspace(fresh, preview);
196
- patchWorkspaceMetadata(fresh.id, { strandedAt: undefined, escalatedAt: undefined, strandedNotifiedSteward: undefined });
197
- reconciledLandedIds.push(fresh.id);
198
- continue;
199
- }
187
+ const orch = workspaceOwnerOrchestrator(fresh, listOrchestrators().filter((o) => o.status === "online" && o.apiUrl), { requireApiUrl: true });
188
+ const preview = orch?.apiUrl ? await fetchHostMergePreview(orch.apiUrl, fresh, { checkPr: true }) : null;
189
+ if (preview && !("available" in preview) && !preview.error && !preview.missing) {
190
+ const landed = previewProvesLanded(preview);
191
+ if (landed) {
192
+ reconcileLandedWorkspace(fresh, preview);
193
+ patchWorkspaceMetadata(fresh.id, { strandedAt: undefined, escalatedAt: undefined, strandedNotifiedSteward: undefined });
194
+ reconciledLandedIds.push(fresh.id);
195
+ continue;
200
196
  }
201
197
  }
202
198
 
203
- if (stewardOnline) {
204
- if (strandedAt !== undefined && meta.strandedNotifiedSteward !== steward) {
205
- const sent = notifyTarget(
206
- "workspace.steward_reassigned",
207
- { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
208
- steward!,
209
- "Workspace stewardship reassigned",
210
- `You are now steward for ${fresh.repoRoot}. Workspace \`${fresh.branch ?? fresh.id}\` is ${fresh.status} and was stranded without an online steward - please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"}.`,
211
- { kind: "workspace.steward-reassigned", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status },
212
- );
213
- if (sent) reassignedIds.push(fresh.id);
214
- }
215
- patchWorkspaceMetadata(fresh.id, { strandedAt: undefined, escalatedAt: undefined, strandedNotifiedSteward: steward });
216
- continue;
217
- }
218
-
219
199
  if (strandedAt === undefined) { patchWorkspaceMetadata(fresh.id, { strandedAt: now }); continue; }
220
- if (now - strandedAt < escalationMs || meta.escalatedAt) continue;
221
- // #642 escalate to the branch agent's coordinator (owner's `spawnedBy`), falling back to
222
- // the configured `stewardFallbackTarget` only when no coordinator resolves. The bare
223
- // fallback was an unset `system` dead-end in the live incident.
224
- const { coordinatorAgentId } = resolveStewardIdentity(fresh);
225
- const escalationTarget = coordinatorAgentId ?? fallbackTarget;
226
- const sent = notifyTarget(
227
- "workspace.stranded_escalation",
228
- { workspaceId: fresh.id, repoRoot: fresh.repoRoot, author: fresh.ownerAgentId, parent: coordinatorAgentId ?? undefined },
229
- escalationTarget,
230
- "Stranded workspace needs an owner",
231
- `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} with no online steward (all repo agents offline) for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h.${coordinatorAgentId ? ` You are the branch agent \`${fresh.ownerAgentId ?? "?"}\`'s coordinator.` : ""} Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
232
- { kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt, coordinatorAgentId },
233
- );
234
- if (sent) escalationTargets.push(sent);
200
+ const escalatedAt = typeof meta.escalatedAt === "number" ? meta.escalatedAt : 0;
201
+ if (now - strandedAt < escalationMs) continue;
202
+ if (escalatedAt && now - escalatedAt < escalationMs) continue;
203
+
204
+ if (getStewardConfig().enabled) {
205
+ wakeRepoSteward(fresh, `workspace ${fresh.status} stranded for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h`);
206
+ }
207
+ const targets = routeWorkspaceCustodyEscalation({
208
+ workspace: fresh,
209
+ subject: "Stranded workspace needs an owner",
210
+ body: `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} and has been stranded for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
211
+ payload: { kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt },
212
+ idempotencyKey: `workspace-stranded:${fresh.id}:${strandedAt}:${Math.floor(now / escalationMs)}`,
213
+ });
214
+ escalationTargets.push(...targets);
235
215
  patchWorkspaceMetadata(fresh.id, { escalatedAt: now });
236
216
  escalatedIds.push(fresh.id);
237
217
  createActivityEvent({
238
218
  clientId: `workspace-gc-escalate-${fresh.id}-${now}`,
239
219
  kind: "state",
240
220
  title: "Workspace escalated",
241
- body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${escalationTarget ? ` to ${escalationTarget}${coordinatorAgentId ? " (coordinator)" : ""}` : " (no coordinator or fallback configured)"}`,
221
+ body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${targets.length ? ` to ${targets.join(", ")}` : " (no owner, coordinator, or fallback configured)"}`,
242
222
  meta: fresh.branch ?? fresh.id,
243
223
  icon: "ti-alert-octagon",
244
224
  view: "orchestrators",
245
- metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, escalationTarget: escalationTarget || null, coordinatorAgentId: coordinatorAgentId ?? null },
225
+ metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, escalationTargets: targets },
246
226
  });
247
227
  }
248
228
 
@@ -250,6 +230,7 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
250
230
  const autoAbandonDeferred: string[] = [];
251
231
  const notifiedStewards: string[] = [];
252
232
  const reviewOrchestrators = listOrchestrators();
233
+ const fallbackTarget = stewardFallbackTarget();
253
234
  for (const ws of all) {
254
235
  if (ws.status === "review_requested" && ws.updatedAt < reviewCutoff) {
255
236
  const fresh = getWorkspace(ws.id);
@@ -0,0 +1,47 @@
1
+ import { getAgent, getDb } from "./db";
2
+ import { getSpawnPolicy } from "./config-store";
3
+ import { stewardFallbackTarget } from "./config";
4
+ import { routeNotification } from "./notification-router";
5
+ import type { ManagedAgentState, Orchestrator, SpawnPolicy } from "./types";
6
+
7
+ const ORCHESTRATOR_OFFLINE_ESCALATION_FAILURES = 3;
8
+
9
+ export function escalateOfflineOrchestrator(policy: SpawnPolicy, state: ManagedAgentState, orch: Orchestrator | null | undefined): void {
10
+ if (state.consecutiveFailures < ORCHESTRATOR_OFFLINE_ESCALATION_FAILURES) return;
11
+ const updatedBy = getSpawnPolicy(policy.name)?.updatedBy?.trim();
12
+ const previousParent = state.agentId ? getAgent(state.agentId)?.spawnedBy?.trim() : undefined;
13
+ const fallback = stewardFallbackTarget();
14
+ const recipients = [...new Set([updatedBy, previousParent, fallback].filter((item): item is string => Boolean(item && item !== "system")))];
15
+ if (!recipients.length) return;
16
+ const host = orch?.hostname ? `${orch.hostname} (${policy.orchestratorId})` : policy.orchestratorId;
17
+ const queuedMessages = queuedPolicyMessageCount(policy.name);
18
+ const nextRetry = state.backoffUntil ? new Date(state.backoffUntil).toISOString() : "not scheduled";
19
+ routeNotification({
20
+ type: "agent.spawn_failed",
21
+ scope: { target: `policy:${policy.name}` },
22
+ recipients,
23
+ message: {
24
+ subject: "Managed policy pinned host offline",
25
+ body: [
26
+ `Managed policy \`${policy.name}\` cannot spawn because pinned orchestrator \`${host}\` is offline.`,
27
+ `Queued messages: ${queuedMessages}. Consecutive failures: ${state.consecutiveFailures}. Next retry: ${nextRetry}.`,
28
+ ].join("\n"),
29
+ payload: {
30
+ kind: "managed-policy.orchestrator-offline",
31
+ policyName: policy.name,
32
+ orchestratorId: policy.orchestratorId,
33
+ orchestratorHostname: orch?.hostname,
34
+ queuedMessages,
35
+ consecutiveFailures: state.consecutiveFailures,
36
+ backoffUntil: state.backoffUntil,
37
+ },
38
+ idempotencyKey: `managed-policy-orchestrator-offline:${policy.name}:${policy.orchestratorId}`,
39
+ replyExpected: false,
40
+ },
41
+ });
42
+ }
43
+
44
+ function queuedPolicyMessageCount(policyName: string): number {
45
+ const row = getDb().query("SELECT COUNT(*) AS count FROM messages WHERE to_target = ? AND delivery_status = 'queued'").get(`policy:${policyName}`) as { count?: number } | undefined;
46
+ return row?.count ?? 0;
47
+ }
@@ -88,6 +88,7 @@ export const TASK_TOOLS = [
88
88
  teamId: { type: "string" },
89
89
  projectId: { type: "string" },
90
90
  repo: { type: "string", description: "owner/repo for the task's project/repository" },
91
+ branch: { type: "string", description: "optional branch ref to seed on the task" },
91
92
  dispatch: DISPATCH_SCHEMA,
92
93
  issueRefs: { type: "array", items: { type: "string" }, description: "#n, owner/repo#n, GitHub URL, or issue_<id>" },
93
94
  defaultRepo: { type: "string", description: "owner/repo for bare #n issue refs" },
@@ -140,14 +141,12 @@ export function relayTaskTool(auth: McpAuthLike, name: string, args: Record<stri
140
141
  teamId: cleanString(args.teamId, "teamId", { max: 200 }),
141
142
  projectId: cleanString(args.projectId, "projectId", { max: 120 }),
142
143
  repo: cleanString(args.repo, "repo", { max: 500 }),
144
+ branch: cleanString(args.branch, "branch", { max: 500 }),
143
145
  dispatch: args.dispatch as TaskDispatch | undefined,
146
+ issueRefs: Array.isArray(args.issueRefs) ? args.issueRefs.filter((ref): ref is string => typeof ref === "string") : undefined,
147
+ defaultRepo: cleanString(args.defaultRepo, "defaultRepo", { max: 500 }),
144
148
  createdBy: actor,
145
149
  });
146
- if (Array.isArray(args.issueRefs)) {
147
- for (const ref of args.issueRefs) {
148
- if (typeof ref === "string" && ref.trim()) addTaskEntityRef(created.id, { issue: ref, defaultRepo: cleanString(args.defaultRepo, "defaultRepo", { max: 500 }), actor });
149
- }
150
- }
151
150
  if (Array.isArray(args.dependsOn)) {
152
151
  for (const dep of args.dependsOn) addTaskEntityDependency(created.id, taskIdArg(dep), actor);
153
152
  }