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
@@ -1,76 +1,28 @@
1
1
  // Auto-split from routes.ts (#299). Domain: commands.
2
2
  import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authorizeRoute, emitCommand, error, json, parseBody, parseQueryInt, type AgentControlAction, type Handler } from "./_shared";
3
3
  import { VALID_WORKSPACE_STATUSES, cleanParams, cleanString, optionalEnum } from "../validation";
4
- import { ValidationError, deleteWorkspace, getOrchestrator, getWorkspace, patchWorkspaceMetadata, releaseMergeLease, setOrchestratorUpgradeState, setWorkspaceBranch, updateWorkspaceStatus } from "../db";
4
+ import { ValidationError, deleteWorkspace, getOrchestrator, getWorkspace, patchWorkspaceMetadata, setOrchestratorUpgradeState, updateWorkspaceStatus } from "../db";
5
5
  import { applyCommandToRecipe } from "../recipe-runner";
6
- import { claimMetadataPatch } from "../workspace-claim";
7
6
  import { clearActiveMemories, injectAlwaysReloadMemories } from "../memory-service";
8
7
  import { getCommand, listCommands, updateCommand } from "../commands-db";
9
8
  import { emitOrchestratorStatus } from "../sse";
10
9
  import { getCompactionWatch } from "../compaction-watch";
11
- import { isRecord, sanitizeFsName, type LandGateRunResult } from "agent-relay-sdk";
12
- import { storeTextArtifact } from "../artifact-text";
10
+ import { isRecord } from "agent-relay-sdk";
13
11
  import { authContextFromRequest } from "../services/auth-context";
14
12
  import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
15
13
  import { prepareScheduledCommandResultForStorage, settleScheduledCommandTimer } from "../services/scheduler-command";
16
- import { emitWorkspaceTaskSemanticNotifications } from "../services/task-lifecycle";
17
14
  import { ServiceAuthError } from "../services/errors";
18
- import { notifyBaseWorktreeStrand, notifyBranchLandFailed, notifyBranchLanded, notifyLandGateFailed, notifyLandGateWarnings } from "../branch-landed";
19
15
  import { applyIdleRefreshResult } from "../maintenance/workspaces";
20
16
  import { seedProviderConfigs } from "../provider-config-store";
21
17
  import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
22
- import { handleReviewerMergeResult } from "../reviewer-pipeline";
23
- import { CLEAR_MERGE_NO_PROGRESS, noteMergeNoProgress, scheduleRepoMerge } from "../workspace-auto-merge";
18
+ import { scheduleRepoMerge } from "../workspace-auto-merge";
24
19
  import { enqueueContinuationInjectionAfterSelfResume } from "../self-resume";
25
20
  import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
26
21
  import { commandTargetsWorkspaceOwner } from "../workspace-host";
22
+ import { settleWorkspaceCommand } from "../workspace-command-settle";
27
23
 
28
24
  const VALID_COMMAND_STATUSES = ["pending", "accepted", "running", "succeeded", "failed", "timed_out", "rejected", "canceled"] as const;
29
25
 
30
- // #902 — normalize a land-gate run result that round-tripped through the command bus
31
- // (JSON, loosely typed) back into a LandGateRunResult. Returns undefined when the value
32
- // isn't a gate result, so the caller falls back to the generic land-failed path.
33
- function normalizeGateRunResult(value: unknown): LandGateRunResult | undefined {
34
- if (!isRecord(value)) return undefined;
35
- const name = typeof value.name === "string" ? value.name : undefined;
36
- const command = typeof value.command === "string" ? value.command : "";
37
- if (!name) return undefined;
38
- return {
39
- name,
40
- command,
41
- optional: value.optional === true,
42
- passed: value.passed === true,
43
- exitCode: typeof value.exitCode === "number" ? value.exitCode : null,
44
- timedOut: value.timedOut === true,
45
- durationMs: typeof value.durationMs === "number" ? value.durationMs : 0,
46
- outputTail: typeof value.outputTail === "string" ? value.outputTail : "",
47
- output: typeof value.output === "string" ? value.output : (typeof value.outputTail === "string" ? value.outputTail : ""),
48
- };
49
- }
50
-
51
- function normalizeGateWarnings(value: unknown): LandGateRunResult[] {
52
- if (!Array.isArray(value)) return [];
53
- return value.map(normalizeGateRunResult).filter((g): g is LandGateRunResult => Boolean(g));
54
- }
55
-
56
- // Upload a gate's full output to a relay artifact; returns its id, or undefined on any
57
- // failure (artifact upload is best-effort — the tail still rides in the notification).
58
- async function uploadGateOutputArtifact(workspaceId: string, gate: LandGateRunResult): Promise<string | undefined> {
59
- try {
60
- const safeName = sanitizeFsName(gate.name, { replacement: "-", maxLen: 60, trimEdge: true, fallback: "gate" });
61
- const stored = await storeTextArtifact({
62
- text: gate.output || gate.outputTail || "(no output)",
63
- filename: `land-gate-${safeName}-${workspaceId}.log`,
64
- createdBy: "server",
65
- metadata: { kind: "land-gate-output", workspaceId, gateName: gate.name, gateCommand: gate.command, exitCode: gate.exitCode, timedOut: gate.timedOut },
66
- });
67
- return stored.id;
68
- } catch (error) {
69
- console.warn(`land-gate artifact upload failed for ${workspaceId} gate "${gate.name}": ${String(error)}`);
70
- return undefined;
71
- }
72
- }
73
-
74
26
  function auditCommandOutcome(command: Command): void {
75
27
  if (command.status !== "succeeded" && command.status !== "failed") return;
76
28
  if (command.type !== "agent.restart" && command.type !== "agent.shutdown" && command.type !== "agent.reconnect") return;
@@ -232,204 +184,15 @@ export const patchCommand: Handler = async (req, params) => {
232
184
  if (command.type === "scheduler.command") {
233
185
  await settleScheduledCommandTimer(command);
234
186
  }
235
- if (command.type === "workspace.cleanup" && command.status === "succeeded" && isRecord(command.result)) {
236
- const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
237
- if (workspaceId) {
238
- const workspace = getWorkspace(workspaceId);
239
- if (workspace && commandTargetsWorkspaceOwner(command, workspace)) {
240
- updateWorkspaceStatus(workspaceId, "cleaned", {
241
- cleanupResult: command.result,
242
- cleanupCommandId: command.id,
243
- });
244
- }
245
- }
187
+ const workspaceSettle = settleWorkspaceCommand(command, { onMergeSettled: scheduleRepoMerge });
188
+ if (workspaceSettle.ignoredWrongOwner) {
189
+ auditCommandOutcome(command);
190
+ return json(command);
246
191
  }
247
192
  if (command.type === "workspace.idle-refresh" && command.status === "succeeded" && isRecord(command.result)) {
248
193
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
249
194
  if (workspaceId) applyIdleRefreshResult(workspaceId, command.result);
250
195
  }
251
- if (command.type === "workspace.merge") {
252
- // Merge settled (either way) — free the per-repo merge lease so the next
253
- // base merge can proceed (issue #157).
254
- if (command.status === "succeeded" || command.status === "failed") {
255
- releaseMergeLease({ commandId: command.id });
256
- // #237: the lease just freed — drain the next ready/review_requested row for
257
- // this repo right now instead of waiting for the backstop sweep. This is the
258
- // on-settle link of the sequential per-repo drain chain (no-op if nothing else
259
- // is queued, or if AUTO_MERGE is disabled).
260
- const settledRepoRoot = typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined;
261
- scheduleRepoMerge(settledRepoRoot);
262
- }
263
- if (command.status === "succeeded" && isRecord(command.result)) {
264
- if (handleReviewerMergeResult(command, command.result)) {
265
- emitCommand(command);
266
- auditCommandOutcome(command);
267
- return json(command);
268
- }
269
- const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
270
- const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
271
- if (workspaceId && resultStatus) {
272
- // Snapshot the row BEFORE the recycle repoints `branch` (#206) — the landed
273
- // branch name + author (#239 branch.landed push) come from this pre-mutation state.
274
- const landedWorkspace = getWorkspace(workspaceId);
275
- if (landedWorkspace && !commandTargetsWorkspaceOwner(command, landedWorkspace)) {
276
- auditCommandOutcome(command);
277
- return json(command);
278
- }
279
- const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
280
- const normalizedStatus = command.result.merged === true
281
- && !newBranch
282
- && ["review_requested", "merge_planned", "conflict"].includes(resultStatus)
283
- ? "merged"
284
- : resultStatus;
285
- updateWorkspaceStatus(workspaceId, normalizedStatus, {
286
- mergeResult: command.result,
287
- mergeCommandId: command.id,
288
- mergedAt: Date.now(),
289
- // A merge that settles `succeeded` made genuine progress (land / recycle /
290
- // noop-resolve / PR-opened) — clear any prior no-progress back-off so the lane
291
- // is attempted normally next time (#638).
292
- ...CLEAR_MERGE_NO_PROGRESS,
293
- mergeError: undefined,
294
- ...(normalizedStatus !== resultStatus ? { mergeStatusNormalizedFrom: resultStatus } : {}),
295
- // The land consumes the steward's claim (#208 steward contract / vent
296
- // #62): a successful land must release it, or `steward inspect` keeps
297
- // showing a stale claim on the recycled workspace and blocks the next
298
- // automation. Clearing a null claim (auto-merge path) is a harmless no-op.
299
- ...claimMetadataPatch(true),
300
- });
301
- // Land-and-continue (#206): the worktree was recycled onto a fresh branch.
302
- // Repoint the row so the next merge targets the live branch, not the deleted one.
303
- const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
304
- // base_sha tracks the tip the recycled workspace forks from. On a no-ff land
305
- // (#287) that's the merge commit (result.baseSha), not the preserved landed
306
- // commit (mergedSha); fall back to mergedSha for older orchestrators.
307
- const baseSha = cleanString(command.result.baseSha, "result.baseSha", { max: 64 });
308
- if (newBranch) {
309
- setWorkspaceBranch(workspaceId, newBranch, baseSha ?? mergedSha);
310
- }
311
- // #239 — push the author a "your branch landed" notice (no polling). Fires on a
312
- // real land; a no-op resolution (#230) merged nothing, so it earns no notice.
313
- // #423 — a PR-land RECYCLE is a no-op merge (`merged:false`) but a land DID happen
314
- // on the remote: the relay carries that ground truth in `params.prLanded`, so the
315
- // notice fires from it with the recycled `newBranch` — full rebase-ff parity.
316
- const prLanded = isRecord(command.params.prLanded) ? command.params.prLanded : undefined;
317
- // #824 — the land advanced a DIRTY base by ref plumbing and the shared checkout was
318
- // left in a MIXED state (HEAD moved, some landed working files didn't). The land
319
- // succeeded, but surface the strand LOUDLY so it's reconciled before anything reads/
320
- // builds/publishes from the stale checkout — never silently as before.
321
- const baseWorktreeSync = isRecord(command.result.baseWorktreeSync) ? command.result.baseWorktreeSync : undefined;
322
- if (baseWorktreeSync && baseWorktreeSync.reconciled === false && landedWorkspace) {
323
- notifyBaseWorktreeStrand({
324
- workspace: landedWorkspace,
325
- sync: {
326
- reconciled: false,
327
- unsyncedPaths: Array.isArray(baseWorktreeSync.unsyncedPaths) ? baseWorktreeSync.unsyncedPaths.filter((p): p is string => typeof p === "string") : undefined,
328
- preservedWipPaths: Array.isArray(baseWorktreeSync.preservedWipPaths) ? baseWorktreeSync.preservedWipPaths.filter((p): p is string => typeof p === "string") : undefined,
329
- message: typeof baseWorktreeSync.message === "string" ? baseWorktreeSync.message : undefined,
330
- },
331
- });
332
- }
333
- if ((command.result.merged === true || prLanded) && landedWorkspace) {
334
- notifyBranchLanded({
335
- workspace: landedWorkspace,
336
- mergedSha: prLanded ? cleanString(prLanded.sha, "params.prLanded.sha", { max: 64 }) ?? mergedSha : mergedSha,
337
- subject: prLanded
338
- ? cleanString(prLanded.subject, "params.prLanded.subject", { max: 200 })
339
- : cleanString(command.result.subject, "result.subject", { max: 200 }),
340
- // PR-land subjects carry a trailing `(#PR)` GitHub stamps on the squash — not an
341
- // issue. Tell the lifecycle hook so it won't close the PR number as an issue (#482).
342
- prLand: Boolean(prLanded),
343
- newBranch,
344
- pushed: prLanded ? true : typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
345
- });
346
- }
347
- // #902 — OPTIONAL land gates that failed on a SUCCESSFUL land (merged:true, ref
348
- // advanced). Warn the worker (tail + combined-output artifact) without blocking.
349
- // Best-effort + isolated so a warn-notify throw can't undo the completed land.
350
- const gateWarnings = command.result.merged === true ? normalizeGateWarnings(command.result.gateWarnings) : [];
351
- if (gateWarnings.length && landedWorkspace) {
352
- const warnWorkspace = landedWorkspace;
353
- void (async () => {
354
- const combined = gateWarnings.map((g) => `### ${g.name} (${g.command}) — ${g.timedOut ? "timed out" : g.exitCode === null ? "could not run" : `exited ${g.exitCode}`}\n${g.output || g.outputTail || "(no output)"}`).join("\n\n");
355
- let artifactId: string | undefined;
356
- try {
357
- artifactId = (await storeTextArtifact({ text: combined, filename: `land-gate-warnings-${warnWorkspace.id}.log`, createdBy: "server", metadata: { kind: "land-gate-warnings", workspaceId: warnWorkspace.id, gateNames: gateWarnings.map((g) => g.name) } })).id;
358
- } catch (error) {
359
- console.warn(`land-gate-warnings artifact upload failed for ${warnWorkspace.id}: ${String(error)}`);
360
- }
361
- try {
362
- notifyLandGateWarnings({ workspace: warnWorkspace, warnings: gateWarnings, artifactId });
363
- } catch (error) {
364
- console.warn(`land-gate-warnings notify threw for ${warnWorkspace.id}: ${String(error)}`);
365
- }
366
- })();
367
- }
368
- }
369
- } else if (command.status === "failed" && command.correlationId) {
370
- // Merge couldn't complete — don't leave it stuck in merge_planned. A no-progress
371
- // merge now settles `failed` (orchestrator #638) carrying the result; honor the
372
- // orchestrator's intended target status when it gave a valid one so a real
373
- // `conflict` routes to the steward (a non-landable status the auto-merge job
374
- // skips, which itself stops the loop) rather than bouncing back to the landable
375
- // `review_requested`. Surface the error and count the no-progress attempt so the
376
- // auto-merge job backs off after N instead of busy-looping (#638).
377
- const current = getWorkspace(command.correlationId);
378
- if (current && current.status === "merge_planned") {
379
- if (!commandTargetsWorkspaceOwner(command, current)) {
380
- auditCommandOutcome(command);
381
- return json(command);
382
- }
383
- const failedResultStatus = isRecord(command.result)
384
- ? optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined
385
- : undefined;
386
- const nextStatus: WorkspaceStatus = failedResultStatus && failedResultStatus !== "merge_planned"
387
- ? failedResultStatus
388
- : "review_requested";
389
- updateWorkspaceStatus(command.correlationId, nextStatus, {
390
- mergeCommandId: command.id,
391
- mergeError: command.error ?? "merge failed",
392
- ...noteMergeNoProgress(current.metadata),
393
- });
394
- if (nextStatus === "conflict") {
395
- emitWorkspaceTaskSemanticNotifications({
396
- workspace: current,
397
- transition: "land-conflict",
398
- actor: "workspace-merge",
399
- occurrenceKey: `workspace-merge-conflict:${current.id}:${command.id}`,
400
- outcome: {
401
- reason: command.error ?? "merge failed",
402
- commandId: command.id,
403
- status: nextStatus,
404
- },
405
- });
406
- }
407
- const gateFailure = isRecord(command.result) ? normalizeGateRunResult(command.result.gateFailure) : undefined;
408
- if (gateFailure) {
409
- // #902 — a REQUIRED land gate failed: the orchestrator aborted WITHOUT advancing
410
- // the ref and settled status "review_requested" (not "conflict", so the steward
411
- // path above is never taken). Send the richer worker+coordinator notice (gate name
412
- // + output tail + full-output artifact) INSTEAD of the generic land-failed wake.
413
- // Best-effort + isolated: the land already (didn't) happen; a notify/upload throw
414
- // must not break command settling.
415
- void (async () => {
416
- const artifactId = await uploadGateOutputArtifact(current.id, gateFailure);
417
- try {
418
- notifyLandGateFailed({ workspace: current, gate: gateFailure, artifactId });
419
- } catch (error) {
420
- console.warn(`land-gate-failed notify threw for ${current.id}: ${String(error)}`);
421
- }
422
- })();
423
- } else {
424
- notifyBranchLandFailed({
425
- workspace: current,
426
- commandId: command.id,
427
- reason: command.error ?? "merge failed",
428
- });
429
- }
430
- }
431
- }
432
- }
433
196
  if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
434
197
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
435
198
  const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
@@ -130,14 +130,12 @@ export const postTaskCreate: Handler = async (req) => {
130
130
  teamId: cleanString(body.teamId, "teamId", { max: 200 }),
131
131
  projectId: cleanString(body.projectId, "projectId", { max: 120 }),
132
132
  repo: cleanString(body.repo, "repo", { max: 500 }),
133
+ branch: cleanString(body.branch, "branch", { max: 500 }),
133
134
  dispatch: body.dispatch as TaskDispatch | undefined,
135
+ issueRefs: Array.isArray(body.issueRefs) ? body.issueRefs.filter((ref): ref is string => typeof ref === "string") : undefined,
136
+ defaultRepo: cleanString(body.defaultRepo, "defaultRepo", { max: 500 }),
134
137
  createdBy: actorOf(req, body.createdBy),
135
138
  });
136
- if (Array.isArray(body.issueRefs)) {
137
- for (const ref of body.issueRefs) {
138
- if (typeof ref === "string" && ref.trim()) addTaskEntityRef(created.id, { issue: ref, defaultRepo: cleanString(body.defaultRepo, "defaultRepo", { max: 500 }), actor: actorOf(req, body.createdBy) });
139
- }
140
- }
141
139
  if (Array.isArray(body.dependsOn)) {
142
140
  for (const dep of body.dependsOn) {
143
141
  const depId = parseId(String(dep));
@@ -104,12 +104,27 @@ function latestWorkspaceByBranch(workspaces: WorkspaceRecord[]): Map<string, Wor
104
104
  return byBranch;
105
105
  }
106
106
 
107
- function owningOrchestratorForRepo(repoRoot: string) {
108
- return listOrchestrators().find((candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(repoRoot, candidate.baseDir));
107
+ function owningOrchestratorsForRepo(repoRoot: string, orchestratorId?: string) {
108
+ const candidates = listOrchestrators().filter((candidate) => candidate.status === "online" && candidate.apiUrl);
109
+ if (orchestratorId) {
110
+ return candidates.filter((candidate) => candidate.id === orchestratorId);
111
+ }
112
+ const ownerIds = new Set(
113
+ listWorkspaces()
114
+ .filter((workspace) => workspace.orchestratorId && resolve(workspace.repoRoot) === resolve(repoRoot))
115
+ .map((workspace) => workspace.orchestratorId!),
116
+ );
117
+ const owners = candidates.filter((candidate) => ownerIds.has(candidate.id));
118
+ if (owners.length > 0) return owners;
119
+ const legacy = candidates.find((candidate) => isPathWithinBase(repoRoot, candidate.baseDir));
120
+ return legacy ? [legacy] : [];
121
+ }
122
+
123
+ function owningOrchestratorForRepo(repoRoot: string, orchestratorId?: string) {
124
+ return owningOrchestratorsForRepo(repoRoot, orchestratorId)[0];
109
125
  }
110
126
 
111
- async function fetchRecoveryBranchesForRepo(repoRoot: string, opts: { baseRef?: string; baseSha?: string } = {}): Promise<{ branches: WorkspaceRecoveryBranch[]; unavailable?: string }> {
112
- const orch = owningOrchestratorForRepo(repoRoot);
127
+ async function fetchRecoveryBranchesFromOrchestrator(repoRoot: string, orch: NonNullable<ReturnType<typeof owningOrchestratorForRepo>>, opts: { baseRef?: string; baseSha?: string } = {}): Promise<{ branches: WorkspaceRecoveryBranch[]; unavailable?: string }> {
113
128
  if (!orch?.apiUrl) return { branches: [], unavailable: "owning orchestrator offline" };
114
129
  const query = new URLSearchParams({ repoRoot });
115
130
  if (opts.baseRef) query.set("baseRef", opts.baseRef);
@@ -122,16 +137,36 @@ async function fetchRecoveryBranchesForRepo(repoRoot: string, opts: { baseRef?:
122
137
  if (!res.ok) return { branches: [], unavailable: `host returned ${res.status}` };
123
138
  const payload = await res.json() as unknown;
124
139
  if (!isRecord(payload) || !Array.isArray(payload.branches)) return { branches: [], unavailable: "host returned malformed recovery branch list" };
125
- return { branches: payload.branches.filter(isRecord) as unknown as WorkspaceRecoveryBranch[] };
140
+ return {
141
+ branches: (payload.branches.filter(isRecord) as unknown as WorkspaceRecoveryBranch[]).map((branch) => ({
142
+ ...branch,
143
+ orchestratorId: orch.id,
144
+ orchestratorAgentId: orch.agentId,
145
+ })),
146
+ };
126
147
  } catch (e) {
127
148
  return { branches: [], unavailable: `orchestrator unreachable: ${(e as Error).message}` };
128
149
  }
129
150
  }
130
151
 
152
+ async function fetchRecoveryBranchesForRepo(repoRoot: string, opts: { baseRef?: string; baseSha?: string; orchestratorId?: string } = {}): Promise<{ branches: WorkspaceRecoveryBranch[]; unavailable?: string }> {
153
+ const hosts = owningOrchestratorsForRepo(repoRoot, opts.orchestratorId);
154
+ if (hosts.length === 0) return { branches: [], unavailable: "owning orchestrator offline" };
155
+ const branches: WorkspaceRecoveryBranch[] = [];
156
+ const unavailable: string[] = [];
157
+ for (const orch of hosts) {
158
+ const fetched = await fetchRecoveryBranchesFromOrchestrator(repoRoot, orch, opts);
159
+ branches.push(...fetched.branches);
160
+ if (fetched.unavailable) unavailable.push(`${orch.id}: ${fetched.unavailable}`);
161
+ }
162
+ return { branches, ...(branches.length === 0 && unavailable.length > 0 ? { unavailable: unavailable.join("; ") } : {}) };
163
+ }
164
+
131
165
  export const getWorkspaceRecoveryBranches: Handler = async (req) => {
132
166
  try {
133
167
  const url = new URL(req.url);
134
168
  const repoRoot = cleanString(url.searchParams.get("repoRoot") ?? undefined, "repoRoot", { max: 1000 });
169
+ const orchestratorId = cleanString(url.searchParams.get("orchestratorId") ?? undefined, "orchestratorId", { max: 200 });
135
170
  const baseRef = cleanString(url.searchParams.get("baseRef") ?? undefined, "baseRef", { max: 240 });
136
171
  const baseSha = cleanString(url.searchParams.get("baseSha") ?? undefined, "baseSha", { max: 80 });
137
172
  const includeActive = url.searchParams.get("includeActive") === "1";
@@ -142,7 +177,7 @@ export const getWorkspaceRecoveryBranches: Handler = async (req) => {
142
177
  const unavailable: Record<string, string> = {};
143
178
 
144
179
  for (const repo of repoRoots) {
145
- const fetched = await fetchRecoveryBranchesForRepo(repo, { baseRef, baseSha });
180
+ const fetched = await fetchRecoveryBranchesForRepo(repo, { baseRef, baseSha, orchestratorId });
146
181
  if (fetched.unavailable) {
147
182
  unavailable[repo] = fetched.unavailable;
148
183
  continue;
@@ -186,6 +221,7 @@ export const postWorkspaceRecoveryBranchDiscard: Handler = async (req) => {
186
221
  if (!isRecord(parsed.body)) return error("body required");
187
222
  const repoRoot = cleanString(parsed.body.repoRoot, "repoRoot", { max: 1000 });
188
223
  const branch = cleanString(parsed.body.branch, "branch", { max: 240 });
224
+ const requestedOrchestratorId = cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 200 });
189
225
  const baseRef = cleanString(parsed.body.baseRef, "baseRef", { max: 240 });
190
226
  const baseSha = cleanString(parsed.body.baseSha, "baseSha", { max: 80 });
191
227
  const reason = cleanString(parsed.body.reason, "reason", { max: 1000 });
@@ -193,11 +229,11 @@ export const postWorkspaceRecoveryBranchDiscard: Handler = async (req) => {
193
229
  if (!repoRoot || !branch) return error("repoRoot and branch required", 400);
194
230
  if (!branch.startsWith("agent/")) return error("only agent/* branches can be discarded through recovery cleanup", 400);
195
231
  if (force && !reason) return error("reason required for forced recovery branch discard", 400);
196
- const orch = owningOrchestratorForRepo(repoRoot);
232
+ const fetched = await fetchRecoveryBranchesForRepo(repoRoot, { baseRef, baseSha, orchestratorId: requestedOrchestratorId });
233
+ const target = fetched.branches.find((item) => item.branch === branch);
234
+ const orch = target?.orchestratorId ? owningOrchestratorForRepo(repoRoot, target.orchestratorId) : owningOrchestratorForRepo(repoRoot);
197
235
  if (!orch?.agentId) return error("no online orchestrator owns this repo", 409);
198
- const fetched = await fetchRecoveryBranchesForRepo(repoRoot, { baseRef, baseSha });
199
236
  if (fetched.unavailable) return error(fetched.unavailable, 409);
200
- const target = fetched.branches.find((item) => item.branch === branch);
201
237
  if (!target) return error("recovery branch not found", 404);
202
238
  if (target.safeToDelete !== true && !force) {
203
239
  return error(`branch holds work or cannot be proven safe (${target.preserveReason ?? `${target.unmergedAhead ?? target.ahead ?? "?"} unlanded commit(s)`}); land it first or pass force:true with reason to discard`, 409);
@@ -242,25 +278,25 @@ export const postWorkspaceOrphanReclaim: Handler = async (req) => {
242
278
  // Refuse to reclaim a path that still backs a live workspace row.
243
279
  const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
244
280
  if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
245
- const orch = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
246
- if (!orch) return error("no online orchestrator owns this path", 409);
247
281
  // Land-safety gate (#244): reclaim force-removes the worktree, so refuse when
248
282
  // it holds un-landed work unless the caller explicitly opts into discarding
249
283
  // it. Mirrors the scheduled reaper — never destroy work on uncertainty.
250
284
  let baseRef: string | undefined;
251
285
  let baseSha: string | undefined;
252
- if (!force) {
253
- const { orphans } = await collectWorkspaceOrphans();
254
- const target = orphans.find((o) => resolve(o.worktreePath) === resolve(worktreePath));
255
- if (target && target.safeToReap !== true) {
256
- const why = target.safeToReap === undefined
257
- ? "land-state could not be determined"
258
- : target.dirty ? "uncommitted changes" : `${target.unmergedAhead ?? target.ahead ?? "?"} un-landed commit(s)`;
259
- return error(`worktree holds un-landed work (${why}); recover it first, or pass {"force":true} to discard`, 409);
260
- }
261
- baseRef = target?.baseRef;
262
- baseSha = target?.baseSha;
286
+ const { orphans } = await collectWorkspaceOrphans();
287
+ const target = orphans.find((o) => resolve(o.worktreePath) === resolve(worktreePath));
288
+ if (target && !force && target.safeToReap !== true) {
289
+ const why = target.safeToReap === undefined
290
+ ? "land-state could not be determined"
291
+ : target.dirty ? "uncommitted changes" : `${target.unmergedAhead ?? target.ahead ?? "?"} un-landed commit(s)`;
292
+ return error(`worktree holds un-landed work (${why}); recover it first, or pass {"force":true} to discard`, 409);
263
293
  }
294
+ baseRef = target?.baseRef;
295
+ baseSha = target?.baseSha;
296
+ const orch = target?.orchestratorId
297
+ ? listOrchestrators().find((candidate) => candidate.id === target.orchestratorId && candidate.status === "online")
298
+ : listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
299
+ if (!orch) return error("no online orchestrator owns this path", 409);
264
300
  const command = createCommand({
265
301
  type: "workspace.cleanup",
266
302
  source: "system",
@@ -1,5 +1,5 @@
1
1
  import { errMessage } from "agent-relay-sdk";
2
- import { getAgent, listPlansWithBoundOwner, deliverableTaskIdsByBranch } from "../db";
2
+ import { getAgent, listPlansWithBoundOwner, deliverableTaskIdsByBranch, deliverableTaskIdsByOwnerAgent, deliverableTaskIdsBySpawnRequestId } from "../db";
3
3
  import { listIssuesForEntity, planNodeIssueEntityId } from "../db/issue-associations";
4
4
  import {
5
5
  getSuccessfulIssueLifecycleAction,
@@ -142,17 +142,28 @@ function associatedIssueRefs(workspace: LifecycleWorkspace): IssueRef[] {
142
142
  }
143
143
  }
144
144
  }
145
- // #834 task-aware resolution: a deliverable Task owns its branch, so the landed branch
146
- // resolves to its linked issue(s). This is the resolution PATH; lifecycle automation that
147
- // advances the Task itself on land is #831.
148
- if (workspace.branch) {
149
- for (const taskId of deliverableTaskIdsByBranch(workspace.branch)) {
150
- refs.push(...listIssuesForEntity("task", String(taskId)).map((item) => item.issueRef));
151
- }
145
+ for (const taskId of associatedTaskIds(workspace)) {
146
+ refs.push(...listIssuesForEntity("task", String(taskId)).map((item) => item.issueRef));
152
147
  }
153
148
  return issueRefMap(refs);
154
149
  }
155
150
 
151
+ function associatedTaskIds(workspace: LifecycleWorkspace): number[] {
152
+ const ids = new Set<number>();
153
+ const ownerId = workspace.ownerAgentId?.trim();
154
+ if (ownerId) {
155
+ for (const id of deliverableTaskIdsByOwnerAgent(ownerId)) ids.add(id);
156
+ const spawnRequestId = getAgent(ownerId)?.meta?.spawnRequestId;
157
+ if (typeof spawnRequestId === "string" && spawnRequestId.trim()) {
158
+ for (const id of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) ids.add(id);
159
+ }
160
+ }
161
+ if (workspace.branch) {
162
+ for (const id of deliverableTaskIdsByBranch(workspace.branch)) ids.add(id);
163
+ }
164
+ return [...ids].sort((a, b) => a - b);
165
+ }
166
+
156
167
  const CLOSING_KEYWORD_RE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+([^\n\r]+)/gi;
157
168
  const ISSUE_REF_AT_START_RE = /^(https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/[0-9]+(?:[/?#][^\s,)]*)?|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|#[0-9]+)/;
158
169
  const REF_SEPARATOR_RE = /^(?:\s*,\s*|\s+(?:and|&)\s+)/i;
@@ -400,6 +411,10 @@ export async function handleLandedIssueLifecycle(input: LandedIssueLifecycleInpu
400
411
  }> {
401
412
  if ((input.workspace.baseRef ?? "main") !== "main") return { source: "commit", closed: [], reopened: [], skipped: ["non-default-branch"] };
402
413
  const resolved = await resolveIssueRefsForLand(input);
414
+ const taskIds = associatedTaskIds(input.workspace);
415
+ if (resolved.refs.length === 0 && taskIds.length > 0) {
416
+ logFailure(opts, `issue lifecycle found landed task(s) without linked issue refs: tasks=${taskIds.join(",")} workspace=${input.workspace.id} branch=${input.workspace.branch ?? "unknown"}`);
417
+ }
403
418
  const closed: CloseIssueLifecycleResult[] = [];
404
419
  for (const ref of resolved.refs) {
405
420
  closed.push(await closeIssueRefForLand(ref, resolved.source, input, opts));
@@ -34,7 +34,9 @@ import {
34
34
  type SetTaskStatusInput,
35
35
  type UpdateDeliverableTaskInput,
36
36
  } from "../db";
37
+ import { errMessage } from "agent-relay-sdk";
37
38
  import { findSpawnCommandByRequestId } from "../commands-db";
39
+ import { defaultIssueRepo } from "../config";
38
40
  import { emitTaskChanged } from "../sse";
39
41
  import type { CommandStatus, Task, TaskDetail, TaskEvent, TaskGateState, TaskListItem, TaskStatus, WorkspaceRecord } from "../types";
40
42
  import { removeSubscriptionsForTask } from "../db/notification-subscriptions";
@@ -43,8 +45,58 @@ import { routeNotification } from "../notification-router";
43
45
  import { isAgentExitDoneWhen, stageSatisfiesDoneWhen } from "./task-done-when";
44
46
  import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
45
47
 
46
- export function createTaskEntity(input: CreateDeliverableTaskInput): TaskDetail {
47
- const detail = createDeliverableTask(input);
48
+ type CreateTaskEntityInput = CreateDeliverableTaskInput & {
49
+ issueRefs?: string[];
50
+ defaultRepo?: string;
51
+ };
52
+
53
+ const EXPLICIT_ISSUE_REF_RE = /(https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/[0-9]+(?:[/?#][^\s,)]*)?|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|#[0-9]+)/;
54
+ const ISSUE_SLUG_RE = /(?:^|[\/_\-\s])(?:fix|feat|bug|issue|debug|chore|reverify|reenable)-([1-9][0-9]{0,6})(?=$|[\/_\-\s])/i;
55
+
56
+ function explicitIssueRefIn(text: string | undefined): string | undefined {
57
+ return text?.match(EXPLICIT_ISSUE_REF_RE)?.[1];
58
+ }
59
+
60
+ function slugIssueRefIn(text: string | undefined): string | undefined {
61
+ const match = text?.match(ISSUE_SLUG_RE)?.[1];
62
+ return match ? `#${match}` : undefined;
63
+ }
64
+
65
+ function explicitIssueRefs(input: CreateTaskEntityInput): string[] {
66
+ return (input.issueRefs ?? []).map((ref) => ref.trim()).filter(Boolean);
67
+ }
68
+
69
+ function inferIssueRef(input: CreateTaskEntityInput): string | undefined {
70
+ for (const text of [input.title, input.branch, input.intent, input.context, input.latestSteer]) {
71
+ const explicit = explicitIssueRefIn(text);
72
+ if (explicit) return explicit;
73
+ }
74
+ for (const text of [input.title, input.branch]) {
75
+ const slug = slugIssueRefIn(text);
76
+ if (slug) return slug;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function addInitialIssueRefs(taskId: number, input: CreateTaskEntityInput): TaskDetail {
82
+ const refs = explicitIssueRefs(input);
83
+ const inferred = refs.length > 0 ? undefined : inferIssueRef(input);
84
+ const issueRefs = refs.length > 0 ? refs : inferred ? [inferred] : [];
85
+ const defaultRepo = input.defaultRepo?.trim() || input.repo?.trim() || defaultIssueRepo();
86
+ let detail = getTaskDetail(taskId)!;
87
+ for (const issue of issueRefs) {
88
+ try {
89
+ detail = addTaskRef(taskId, { issue, defaultRepo, actor: input.createdBy });
90
+ } catch (error) {
91
+ console.warn(`task ${taskId} issue ref '${issue}' could not be linked on create: ${errMessage(error)}`);
92
+ }
93
+ }
94
+ return detail;
95
+ }
96
+
97
+ export function createTaskEntity(input: CreateTaskEntityInput): TaskDetail {
98
+ const created = createDeliverableTask(input);
99
+ const detail = addInitialIssueRefs(created.id, input);
48
100
  autoSubscribeTaskCoordinator(input.createdBy, detail.id);
49
101
  emitTaskChanged(detail, "task.created");
50
102
  emitTaskSemanticNotification({
@@ -382,7 +382,8 @@ function rerouteForBand(
382
382
  // no burn data yet. Prefer "prefer" band, then "normal"; never a blocked one.
383
383
  //
384
384
  // When mustResolveAvailable is set (#901 degrade-never-refuse), widens search in tiers when
385
- // no prefer/normal provider is available: caution → avoid-large → least-utilized blockedNow.
385
+ // no prefer/normal provider is available: caution → avoid-large → hard-avoid-but-not-blocked
386
+ // → least-utilized blockedNow.
386
387
  // A `degraded` flag + `degradedReason` signals to the caller to emit a louder warning.
387
388
  function healthiestAlternative(
388
389
  avoid: SpawnProvider,
@@ -415,11 +416,19 @@ function healthiestAlternative(
415
416
  .sort((a, b) => bandRank(a.band) - bandRank(b.band));
416
417
  if (avoidLargeAlt[0]) return { ...avoidLargeAlt[0], degraded: true, degradedReason: "avoid-large band (tier 2 degraded)" };
417
418
 
418
- // Tier 3 degradation: least-utilized blockedNow (last resort emit the loudest warning).
419
+ // Tier 3 degradation: hard-avoid is still better than a provider that is actively blocked.
420
+ // This is the exact shape a release auto-heal can hit: Claude session limit is blockedNow,
421
+ // Codex is over pace (hard-avoid) but still runnable.
422
+ const hardAvoidAlt = all
423
+ .filter((c) => !c.blockedNow && c.band === "hard-avoid")
424
+ .sort((a, b) => bandRank(a.band) - bandRank(b.band));
425
+ if (hardAvoidAlt[0]) return { ...hardAvoidAlt[0], degraded: true, degradedReason: "hard-avoid band (tier 3 degraded)" };
426
+
427
+ // Tier 4 degradation: least-utilized blockedNow (last resort — emit the loudest warning).
419
428
  // Sort by bandRank so the healthiest (lowest penalty) blocked provider is picked first,
420
429
  // not whichever happens to be earliest in catalog order.
421
430
  const blockedAlts = all.filter((c) => c.blockedNow).sort((a, b) => bandRank(a.band) - bandRank(b.band));
422
- if (blockedAlts[0]) return { ...blockedAlts[0], degraded: true, degradedReason: "blockedNow provider (tier 3 last-resort — ALL providers at capacity)" };
431
+ if (blockedAlts[0]) return { ...blockedAlts[0], degraded: true, degradedReason: "blockedNow provider (tier 4 last-resort — ALL providers at capacity)" };
423
432
 
424
433
  return undefined;
425
434
  }