agent-relay-server 0.61.2 → 0.62.1

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 (181) hide show
  1. package/docs/openapi.json +479 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DVvlvi4B.js → MessageBubble-DRrxfjyT.js} +2 -2
  4. package/public/assets/{MessageBubble-DVvlvi4B.js.map → MessageBubble-DRrxfjyT.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-Dp2eYazz.js +2 -0
  6. package/public/assets/PlanGraphPanel-Dp2eYazz.js.map +1 -0
  7. package/public/assets/{activity-IYl3MEXC.js → activity--t0U2yzq.js} +2 -2
  8. package/public/assets/{activity-IYl3MEXC.js.map → activity--t0U2yzq.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BfGTTbeY.js → agent-profiles-BMtJYzxn.js} +2 -2
  10. package/public/assets/{agent-profiles-BfGTTbeY.js.map → agent-profiles-BMtJYzxn.js.map} +1 -1
  11. package/public/assets/{agents-BquvREQr.js → agents-Ck9AePlc.js} +2 -2
  12. package/public/assets/{agents-BquvREQr.js.map → agents-Ck9AePlc.js.map} +1 -1
  13. package/public/assets/{analytics-De0cEZGx.js → analytics-CzfK387L.js} +3 -3
  14. package/public/assets/{analytics-De0cEZGx.js.map → analytics-CzfK387L.js.map} +1 -1
  15. package/public/assets/automation-Cgw8vtE8.js +2 -0
  16. package/public/assets/automation-Cgw8vtE8.js.map +1 -0
  17. package/public/assets/{badge-DhLyCPag.js → badge-CZhIdlTJ.js} +2 -2
  18. package/public/assets/{badge-DhLyCPag.js.map → badge-CZhIdlTJ.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-DSyWKluD.js → branch-state-badge-DnEEPtOP.js} +2 -2
  20. package/public/assets/{branch-state-badge-DSyWKluD.js.map → branch-state-badge-DnEEPtOP.js.map} +1 -1
  21. package/public/assets/{button-K20wAZG1.js → button-2taQeNHW.js} +2 -2
  22. package/public/assets/{button-K20wAZG1.js.map → button-2taQeNHW.js.map} +1 -1
  23. package/public/assets/card-DzIsAI41.js +2 -0
  24. package/public/assets/{card-CcZ9hLI7.js.map → card-DzIsAI41.js.map} +1 -1
  25. package/public/assets/channels-Dc3L2MQs.js +2 -0
  26. package/public/assets/{channels-BG0Nib7y.js.map → channels-Dc3L2MQs.js.map} +1 -1
  27. package/public/assets/chat-Fc6iJ7sa.js +2 -0
  28. package/public/assets/{chat-Dx6qWNxb.js.map → chat-Fc6iJ7sa.js.map} +1 -1
  29. package/public/assets/{connectors-DWh7MwV_.js → connectors-Dc2ttGWs.js} +2 -2
  30. package/public/assets/{connectors-DWh7MwV_.js.map → connectors-Dc2ttGWs.js.map} +1 -1
  31. package/public/assets/copy-button-HEkwi6xX.js +2 -0
  32. package/public/assets/{copy-button-eF3cKp4Q.js.map → copy-button-HEkwi6xX.js.map} +1 -1
  33. package/public/assets/display-DzNvOu1j.js.map +1 -1
  34. package/public/assets/dist-1rXN5FN9.js +2 -0
  35. package/public/assets/{dist-YpHfnnIA.js.map → dist-1rXN5FN9.js.map} +1 -1
  36. package/public/assets/{dist-1dhfdhm9.js → dist-AbEQuFoK.js} +2 -2
  37. package/public/assets/{dist-1dhfdhm9.js.map → dist-AbEQuFoK.js.map} +1 -1
  38. package/public/assets/{dist-COQuunEv.js → dist-B7p1zkvf.js} +2 -2
  39. package/public/assets/{dist-COQuunEv.js.map → dist-B7p1zkvf.js.map} +1 -1
  40. package/public/assets/{dist-Ci5Y37nj.js → dist-CG10hM1-.js} +2 -2
  41. package/public/assets/{dist-Ci5Y37nj.js.map → dist-CG10hM1-.js.map} +1 -1
  42. package/public/assets/dist-Cfm5_zPB.js +2 -0
  43. package/public/assets/{dist-qd198ib9.js.map → dist-Cfm5_zPB.js.map} +1 -1
  44. package/public/assets/{dist-BrQsqBbc.js → dist-Cgw85UcE.js} +2 -2
  45. package/public/assets/{dist-BrQsqBbc.js.map → dist-Cgw85UcE.js.map} +1 -1
  46. package/public/assets/{dist-CoROafNg.js → dist-D3_TJKTC.js} +2 -2
  47. package/public/assets/{dist-CoROafNg.js.map → dist-D3_TJKTC.js.map} +1 -1
  48. package/public/assets/{dist-I-MFUv51.js → dist-DQkLq0hs.js} +2 -2
  49. package/public/assets/{dist-I-MFUv51.js.map → dist-DQkLq0hs.js.map} +1 -1
  50. package/public/assets/{dist-UQGKwqZH.js → dist-D_sl7u_f.js} +2 -2
  51. package/public/assets/{dist-UQGKwqZH.js.map → dist-D_sl7u_f.js.map} +1 -1
  52. package/public/assets/{dist-yGOq6sB3.js → dist-Ilb3r1im.js} +2 -2
  53. package/public/assets/{dist-yGOq6sB3.js.map → dist-Ilb3r1im.js.map} +1 -1
  54. package/public/assets/dist-Pf_3Gf38.js +2 -0
  55. package/public/assets/{dist-BLBF7qqr.js.map → dist-Pf_3Gf38.js.map} +1 -1
  56. package/public/assets/{es2015-CRxz9lOj.js → es2015-GSepNaIS.js} +2 -2
  57. package/public/assets/{es2015-CRxz9lOj.js.map → es2015-GSepNaIS.js.map} +1 -1
  58. package/public/assets/{formatted-body-impl-DMm6jeqi.js → formatted-body-impl-Bg-gx7rG.js} +2 -2
  59. package/public/assets/{formatted-body-impl-DMm6jeqi.js.map → formatted-body-impl-Bg-gx7rG.js.map} +1 -1
  60. package/public/assets/index-BeDncjN4.js +22 -0
  61. package/public/assets/index-BeDncjN4.js.map +1 -0
  62. package/public/assets/index-DiR-fqy_.css +2 -0
  63. package/public/assets/{input-BHYNpXAG.js → input-BsNqlONJ.js} +2 -2
  64. package/public/assets/{input-BHYNpXAG.js.map → input-BsNqlONJ.js.map} +1 -1
  65. package/public/assets/{insights-C7wKt1_l.js → insights-c08aW-7n.js} +2 -2
  66. package/public/assets/{insights-C7wKt1_l.js.map → insights-c08aW-7n.js.map} +1 -1
  67. package/public/assets/{integrations-CG9qyHUf.js → integrations-BwpgcqnF.js} +2 -2
  68. package/public/assets/{integrations-CG9qyHUf.js.map → integrations-BwpgcqnF.js.map} +1 -1
  69. package/public/assets/{lib-Df4aph5u.js → lib-bXPu6xCL.js} +2 -2
  70. package/public/assets/{lib-Df4aph5u.js.map → lib-bXPu6xCL.js.map} +1 -1
  71. package/public/assets/lucide-react-DWYjU-AF.js +9 -0
  72. package/public/assets/{lucide-react-L2UxFDic.js.map → lucide-react-DWYjU-AF.js.map} +1 -1
  73. package/public/assets/{maintenance-CjpvnV_U.js → maintenance-CTKP0kWB.js} +2 -2
  74. package/public/assets/{maintenance-CjpvnV_U.js.map → maintenance-CTKP0kWB.js.map} +1 -1
  75. package/public/assets/{managed-agents-CdfuBKJA.js → managed-agents-BofCxo8d.js} +2 -2
  76. package/public/assets/{managed-agents-CdfuBKJA.js.map → managed-agents-BofCxo8d.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-Cf4xCmWt.js → markdown-preview-impl-CQ-6BIb8.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-Cf4xCmWt.js.map → markdown-preview-impl-CQ-6BIb8.js.map} +1 -1
  79. package/public/assets/memory-DvfXjMoi.js +2 -0
  80. package/public/assets/{memory-xKWduhyr.js.map → memory-DvfXjMoi.js.map} +1 -1
  81. package/public/assets/{messages-BpZYOUtJ.js → messages-C9VsPzZ_.js} +2 -2
  82. package/public/assets/{messages-BpZYOUtJ.js.map → messages-C9VsPzZ_.js.map} +1 -1
  83. package/public/assets/orchestrators-MffVueeE.js +2 -0
  84. package/public/assets/{orchestrators-BrJvGIOc.js.map → orchestrators-MffVueeE.js.map} +1 -1
  85. package/public/assets/{overview-BUORwePi.js → overview-BSVajefM.js} +2 -2
  86. package/public/assets/{overview-BUORwePi.js.map → overview-BSVajefM.js.map} +1 -1
  87. package/public/assets/pairs-B5711-Cs.js +2 -0
  88. package/public/assets/{pairs-DR0GizDN.js.map → pairs-B5711-Cs.js.map} +1 -1
  89. package/public/assets/{react-dom-CrP_AtcL.js → react-dom-CPghDLpB.js} +2 -2
  90. package/public/assets/{react-dom-CrP_AtcL.js.map → react-dom-CPghDLpB.js.map} +1 -1
  91. package/public/assets/{security-DLwSqTc_.js → security-DkUwK9Vj.js} +2 -2
  92. package/public/assets/{security-DLwSqTc_.js.map → security-DkUwK9Vj.js.map} +1 -1
  93. package/public/assets/{select-pEru111r.js → select-DrM2RRiT.js} +2 -2
  94. package/public/assets/{select-pEru111r.js.map → select-DrM2RRiT.js.map} +1 -1
  95. package/public/assets/{settings-B-ueGvwV.js → settings-DdKCb-vy.js} +3 -3
  96. package/public/assets/{settings-B-ueGvwV.js.map → settings-DdKCb-vy.js.map} +1 -1
  97. package/public/assets/store-iTiheuTD.js +9 -0
  98. package/public/assets/store-iTiheuTD.js.map +1 -0
  99. package/public/assets/tasks-e1y1PFfO.js +2 -0
  100. package/public/assets/{tasks-C2rRQm6E.js.map → tasks-e1y1PFfO.js.map} +1 -1
  101. package/public/assets/teams-BUcxoGC_.js +2 -0
  102. package/public/assets/teams-BUcxoGC_.js.map +1 -0
  103. package/public/assets/{terminal-viewer-impl-BM_3qoeN.js → terminal-viewer-impl-CmZjQOB2.js} +3 -3
  104. package/public/assets/{terminal-viewer-impl-BM_3qoeN.js.map → terminal-viewer-impl-CmZjQOB2.js.map} +1 -1
  105. package/public/assets/{use-keyboard-viewport-DrRH_QzG.js → use-keyboard-viewport-NKw1O5gC.js} +2 -2
  106. package/public/assets/{use-keyboard-viewport-DrRH_QzG.js.map → use-keyboard-viewport-NKw1O5gC.js.map} +1 -1
  107. package/public/assets/{work-queue-CkIYDAZD.js → work-queue-CXBQx43n.js} +2 -2
  108. package/public/assets/{work-queue-CkIYDAZD.js.map → work-queue-CXBQx43n.js.map} +1 -1
  109. package/public/assets/{workspaces-CYm5bLg9.js → workspaces-D2EfZh9a.js} +3 -3
  110. package/public/assets/{workspaces-CYm5bLg9.js.map → workspaces-D2EfZh9a.js.map} +1 -1
  111. package/public/index.html +21 -21
  112. package/src/automations.ts +40 -7
  113. package/src/branch-landed.ts +5 -0
  114. package/src/bus.ts +6 -1
  115. package/src/cli/workspace.ts +9 -17
  116. package/src/db/activity.ts +191 -3
  117. package/src/db/agents.ts +14 -5
  118. package/src/db/integrations.ts +1 -0
  119. package/src/db/issue-associations.ts +128 -0
  120. package/src/db/issue-lifecycle.ts +187 -0
  121. package/src/db/issue-refs.ts +254 -0
  122. package/src/db/mappers.ts +2 -0
  123. package/src/db/migrations.ts +53 -0
  124. package/src/db/schema.ts +3 -2
  125. package/src/issue-tracker/adapter.ts +24 -0
  126. package/src/issue-tracker/github.ts +136 -0
  127. package/src/issue-tracker/index.ts +13 -0
  128. package/src/issue-tracker/registry.ts +26 -0
  129. package/src/maintenance.ts +22 -169
  130. package/src/mcp-issue-activity-tools.ts +114 -0
  131. package/src/mcp-issue-association-tools.ts +130 -0
  132. package/src/mcp-issue-lifecycle-tools.ts +55 -0
  133. package/src/mcp-issue-ref-tools.ts +174 -0
  134. package/src/mcp-team-tools.ts +1 -1
  135. package/src/mcp.ts +3 -3
  136. package/src/provider-config-store.ts +30 -8
  137. package/src/routes/activity.ts +6 -1
  138. package/src/routes/commands.ts +29 -0
  139. package/src/routes/index.ts +19 -0
  140. package/src/routes/issue-activity.ts +78 -0
  141. package/src/routes/issue-associations.ts +76 -0
  142. package/src/routes/issue-lifecycle.ts +34 -0
  143. package/src/routes/issue-refs.ts +137 -0
  144. package/src/routes/orchestrator-bootstrap.ts +2 -1
  145. package/src/routes/orchestrator.ts +42 -1
  146. package/src/routes/tokens.ts +1 -1
  147. package/src/runtime-tokens.ts +15 -11
  148. package/src/security.ts +51 -3
  149. package/src/services/issue-activity.ts +30 -0
  150. package/src/services/issue-associations.ts +72 -0
  151. package/src/services/issue-lifecycle.ts +308 -0
  152. package/src/services/issue-refs.ts +32 -0
  153. package/src/services/plan-graph.ts +40 -2
  154. package/src/services/team.ts +4 -0
  155. package/src/token-db.ts +27 -7
  156. package/src/workspace-actions.ts +8 -1
  157. package/src/workspace-auto-merge.ts +228 -0
  158. package/src/workspace-probe.ts +125 -0
  159. package/public/assets/PlanGraphPanel-Dby7wq9c.js +0 -2
  160. package/public/assets/PlanGraphPanel-Dby7wq9c.js.map +0 -1
  161. package/public/assets/automation-Ddbermbi.js +0 -2
  162. package/public/assets/automation-Ddbermbi.js.map +0 -1
  163. package/public/assets/card-CcZ9hLI7.js +0 -2
  164. package/public/assets/channels-BG0Nib7y.js +0 -2
  165. package/public/assets/chat-Dx6qWNxb.js +0 -2
  166. package/public/assets/copy-button-eF3cKp4Q.js +0 -2
  167. package/public/assets/dist-BLBF7qqr.js +0 -2
  168. package/public/assets/dist-YpHfnnIA.js +0 -2
  169. package/public/assets/dist-qd198ib9.js +0 -2
  170. package/public/assets/index-B3wOeAPO.js +0 -21
  171. package/public/assets/index-B3wOeAPO.js.map +0 -1
  172. package/public/assets/index-Dn_GDXbi.css +0 -2
  173. package/public/assets/lucide-react-L2UxFDic.js +0 -9
  174. package/public/assets/memory-xKWduhyr.js +0 -2
  175. package/public/assets/orchestrators-BrJvGIOc.js +0 -2
  176. package/public/assets/pairs-DR0GizDN.js +0 -2
  177. package/public/assets/store-B8xP6z6T.js +0 -9
  178. package/public/assets/store-B8xP6z6T.js.map +0 -1
  179. package/public/assets/tasks-C2rRQm6E.js +0 -2
  180. package/public/assets/teams-Bo0sdBTp.js +0 -2
  181. package/public/assets/teams-Bo0sdBTp.js.map +0 -1
@@ -0,0 +1,308 @@
1
+ import { errMessage } from "agent-relay-sdk";
2
+ import { getAgent, listPlansWithBoundOwner } from "../db";
3
+ import { listIssuesForEntity, planNodeIssueEntityId } from "../db/issue-associations";
4
+ import {
5
+ getSuccessfulIssueLifecycleAction,
6
+ listSuccessfulCloseActionsForCommit,
7
+ upsertIssueLifecycleAction,
8
+ } from "../db/issue-lifecycle";
9
+ import { getIssueRef, resolveIssueRefByExternalKey, resolveIssueRefFromString, updateIssueRef } from "../db/issue-refs";
10
+ import { associateIssue } from "../db/issue-associations";
11
+ import { resolveIssueTrackerAdapter } from "../issue-tracker";
12
+ import type { IssueTrackerAdapter } from "../issue-tracker";
13
+ import { recordAndEmitIssueActivity } from "./issue-activity";
14
+ import type { IssueAssociationEntityType, IssueProvider, IssueRef, WorkspaceRecord } from "../types";
15
+
16
+ type LifecycleWorkspace = Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "baseRef" | "ownerAgentId">;
17
+ type AdapterResolver = (ref: IssueRef) => IssueTrackerAdapter;
18
+ type LogFn = (message: string) => void;
19
+
20
+ export interface LandedIssueLifecycleInput {
21
+ workspace: LifecycleWorkspace;
22
+ mergedSha?: string;
23
+ subject?: string;
24
+ source?: string;
25
+ }
26
+
27
+ export interface ReopenIssueLifecycleInput {
28
+ issueRefId?: string;
29
+ ref?: string;
30
+ defaultRepo?: string;
31
+ provider?: IssueProvider;
32
+ repo?: string;
33
+ externalKey?: string;
34
+ url?: string;
35
+ title?: string;
36
+ comment?: string;
37
+ agentId?: string;
38
+ entityType?: IssueAssociationEntityType;
39
+ entityId?: string;
40
+ }
41
+
42
+ export interface IssueLifecycleOptions {
43
+ adapterResolver?: AdapterResolver;
44
+ log?: LogFn;
45
+ }
46
+
47
+ export type CloseIssueLifecycleResult =
48
+ | { issueRef: IssueRef; action: "closed"; source: "association" | "commit"; landKey: string }
49
+ | { issueRef: IssueRef; action: "skipped"; reason: "already-closed" | "already-processed"; landKey: string }
50
+ | { issueRef: IssueRef; action: "failed"; error: string; landKey: string };
51
+
52
+ export type ReopenIssueLifecycleResult =
53
+ | { issueRef: IssueRef; action: "reopened"; landKey: string }
54
+ | { issueRef: IssueRef; action: "skipped"; reason: "already-open"; landKey: string }
55
+ | { issueRef: IssueRef; action: "failed"; error: string; landKey: string };
56
+
57
+ let adapterResolverForTests: AdapterResolver | undefined;
58
+
59
+ export function __setIssueLifecycleAdapterResolverForTests(resolver: AdapterResolver | undefined): void {
60
+ adapterResolverForTests = resolver;
61
+ }
62
+
63
+ function adapterFor(ref: IssueRef, opts: IssueLifecycleOptions): IssueTrackerAdapter {
64
+ return (opts.adapterResolver ?? adapterResolverForTests ?? resolveIssueTrackerAdapter)(ref);
65
+ }
66
+
67
+ function logFailure(opts: IssueLifecycleOptions, message: string): void {
68
+ if (opts.log) opts.log(message);
69
+ else console.warn(message);
70
+ }
71
+
72
+ function defaultIssueRepo(): string | undefined {
73
+ return process.env.AGENT_RELAY_DEFAULT_ISSUE_REPO || process.env.GITHUB_REPOSITORY || undefined;
74
+ }
75
+
76
+ function cleanText(value: string | undefined): string | undefined {
77
+ const cleaned = value?.trim();
78
+ return cleaned || undefined;
79
+ }
80
+
81
+ function landKey(input: LandedIssueLifecycleInput): string {
82
+ const base = input.workspace.baseRef ?? "main";
83
+ const sha = cleanText(input.mergedSha);
84
+ if (sha) return `land:${base}:${sha}`;
85
+ return `land:${base}:${input.workspace.id}:${input.workspace.branch ?? "unknown"}:${input.subject ?? ""}`.slice(0, 240);
86
+ }
87
+
88
+ function manualReopenKey(issueRef: IssueRef): string {
89
+ return `manual-reopen:${issueRef.id}:${Date.now()}`;
90
+ }
91
+
92
+ function issueRefMap(refs: IssueRef[]): IssueRef[] {
93
+ const out = new Map<string, IssueRef>();
94
+ for (const ref of refs) out.set(ref.id, ref);
95
+ return [...out.values()];
96
+ }
97
+
98
+ function associatedIssueRefs(workspace: LifecycleWorkspace): IssueRef[] {
99
+ const refs: IssueRef[] = [];
100
+ const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
101
+ if (owner?.teamId) {
102
+ refs.push(...listIssuesForEntity("team", owner.teamId).map((item) => item.issueRef));
103
+ }
104
+ if (workspace.ownerAgentId) {
105
+ for (const plan of listPlansWithBoundOwner(workspace.ownerAgentId)) {
106
+ for (const node of plan.nodes) {
107
+ if (node.ownerRef?.agentId !== workspace.ownerAgentId) continue;
108
+ refs.push(...listIssuesForEntity("plan-node", planNodeIssueEntityId(plan.id, node.id)).map((item) => item.issueRef));
109
+ }
110
+ }
111
+ }
112
+ return issueRefMap(refs);
113
+ }
114
+
115
+ const CLOSING_KEYWORD_RE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+([^\n\r]+)/gi;
116
+ 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]+)/;
117
+ const REF_SEPARATOR_RE = /^(?:\s*,\s*|\s+(?:and|&)\s+)/i;
118
+
119
+ export function parseClosingIssueRefs(text: string | undefined, fallbackDefaultRepo = defaultIssueRepo()): IssueRef[] {
120
+ if (!text) return [];
121
+ const refs: IssueRef[] = [];
122
+ for (const match of text.matchAll(CLOSING_KEYWORD_RE)) {
123
+ let rest = match[1] ?? "";
124
+ while (rest.trim()) {
125
+ rest = rest.trimStart();
126
+ const refMatch = rest.match(ISSUE_REF_AT_START_RE);
127
+ if (!refMatch) break;
128
+ try {
129
+ refs.push(resolveIssueRefFromString({ ref: refMatch[1]!, defaultRepo: fallbackDefaultRepo }));
130
+ } catch {
131
+ // Invalid or bare-without-default refs are ignored; the land itself must never fail here.
132
+ }
133
+ rest = rest.slice(refMatch[0].length);
134
+ const separator = rest.match(REF_SEPARATOR_RE);
135
+ if (!separator) break;
136
+ rest = rest.slice(separator[0].length);
137
+ }
138
+ }
139
+ return issueRefMap(refs);
140
+ }
141
+
142
+ export function resolveIssueRefsForLand(input: LandedIssueLifecycleInput): { source: "association" | "commit"; refs: IssueRef[] } {
143
+ const associated = associatedIssueRefs(input.workspace);
144
+ if (associated.length > 0) return { source: "association", refs: associated };
145
+ return { source: "commit", refs: parseClosingIssueRefs(input.subject) };
146
+ }
147
+
148
+ function closeAuditComment(input: LandedIssueLifecycleInput): string {
149
+ const commit = input.mergedSha ? input.mergedSha.slice(0, 12) : input.workspace.branch ?? input.workspace.id;
150
+ return `Closed by \`${commit}\` on merge to \`main\` - reopen if release surfaces more work.`;
151
+ }
152
+
153
+ async function closeIssueRefForLand(
154
+ issueRef: IssueRef,
155
+ source: "association" | "commit",
156
+ input: LandedIssueLifecycleInput,
157
+ opts: IssueLifecycleOptions,
158
+ ): Promise<CloseIssueLifecycleResult> {
159
+ const key = landKey(input);
160
+ if (getSuccessfulIssueLifecycleAction(issueRef.id, "close", key)) return { issueRef, action: "skipped", reason: "already-processed", landKey: key };
161
+ const latest = getIssueRef(issueRef.id) ?? issueRef;
162
+ if (latest.status === "closed") return { issueRef: latest, action: "skipped", reason: "already-closed", landKey: key };
163
+
164
+ const comment = closeAuditComment(input);
165
+ try {
166
+ await adapterFor(latest, opts).close(latest, { comment, labels: ["fixed"] });
167
+ const closed = updateIssueRef(latest.id, { status: "closed", statusSyncedAt: Date.now() }) ?? latest;
168
+ upsertIssueLifecycleAction({
169
+ issueRefId: latest.id,
170
+ action: "close",
171
+ landKey: key,
172
+ workspaceId: input.workspace.id,
173
+ branch: input.workspace.branch,
174
+ baseRef: input.workspace.baseRef ?? "main",
175
+ commitSha: input.mergedSha,
176
+ commitSubject: input.subject,
177
+ agentId: input.workspace.ownerAgentId,
178
+ source,
179
+ comment,
180
+ status: "succeeded",
181
+ });
182
+ if (input.workspace.ownerAgentId) {
183
+ recordAndEmitIssueActivity({
184
+ agentId: input.workspace.ownerAgentId,
185
+ action: "done",
186
+ issueRefId: latest.id,
187
+ clientId: `issue-close:${latest.id}:${key}`,
188
+ title: "Issue closed on land",
189
+ body: comment,
190
+ metadata: { workspaceId: input.workspace.id, branch: input.workspace.branch, commitSha: input.mergedSha, source },
191
+ });
192
+ }
193
+ return { issueRef: closed, action: "closed", source, landKey: key };
194
+ } catch (error) {
195
+ const message = errMessage(error);
196
+ upsertIssueLifecycleAction({
197
+ issueRefId: latest.id,
198
+ action: "close",
199
+ landKey: key,
200
+ workspaceId: input.workspace.id,
201
+ branch: input.workspace.branch,
202
+ baseRef: input.workspace.baseRef ?? "main",
203
+ commitSha: input.mergedSha,
204
+ commitSubject: input.subject,
205
+ agentId: input.workspace.ownerAgentId,
206
+ source,
207
+ comment,
208
+ status: "failed",
209
+ error: message,
210
+ });
211
+ logFailure(opts, `issue lifecycle close failed for ${latest.id} on ${key}: ${message}`);
212
+ return { issueRef: latest, action: "failed", error: message, landKey: key };
213
+ }
214
+ }
215
+
216
+ function revertedCommitLookup(subject: string | undefined): { commitSha?: string; commitSubject?: string } {
217
+ const text = cleanText(subject);
218
+ if (!text) return {};
219
+ const sha = text.match(/\bThis reverts commit ([0-9a-f]{7,64})\b/i)?.[1];
220
+ if (sha) return { commitSha: sha };
221
+ const revertedSubject = text.match(/^Revert "(.+)"$/)?.[1];
222
+ return revertedSubject ? { commitSubject: revertedSubject } : {};
223
+ }
224
+
225
+ async function reopenIssuesForRevert(input: LandedIssueLifecycleInput, opts: IssueLifecycleOptions): Promise<ReopenIssueLifecycleResult[]> {
226
+ const lookup = revertedCommitLookup(input.subject);
227
+ const closedActions = listSuccessfulCloseActionsForCommit(lookup);
228
+ const results: ReopenIssueLifecycleResult[] = [];
229
+ for (const action of closedActions) {
230
+ results.push(await reopenIssueLifecycle({
231
+ issueRefId: action.issueRefId,
232
+ comment: `Reopened by revert \`${input.mergedSha?.slice(0, 12) ?? input.workspace.branch ?? input.workspace.id}\` on merge to \`main\`.`,
233
+ agentId: input.workspace.ownerAgentId,
234
+ }, opts));
235
+ }
236
+ return results;
237
+ }
238
+
239
+ export async function handleLandedIssueLifecycle(input: LandedIssueLifecycleInput, opts: IssueLifecycleOptions = {}): Promise<{
240
+ source: "association" | "commit";
241
+ closed: CloseIssueLifecycleResult[];
242
+ reopened: ReopenIssueLifecycleResult[];
243
+ skipped: string[];
244
+ }> {
245
+ if ((input.workspace.baseRef ?? "main") !== "main") return { source: "commit", closed: [], reopened: [], skipped: ["non-default-branch"] };
246
+ const resolved = resolveIssueRefsForLand(input);
247
+ const closed: CloseIssueLifecycleResult[] = [];
248
+ for (const ref of resolved.refs) {
249
+ closed.push(await closeIssueRefForLand(ref, resolved.source, input, opts));
250
+ }
251
+ const reopened = await reopenIssuesForRevert(input, opts);
252
+ return { source: resolved.source, closed, reopened, skipped: [] };
253
+ }
254
+
255
+ function resolveReopenRef(input: ReopenIssueLifecycleInput): IssueRef {
256
+ if (input.issueRefId) {
257
+ const issueRef = getIssueRef(input.issueRefId);
258
+ if (!issueRef) throw new Error(`issue ref ${input.issueRefId} not found`);
259
+ return issueRef;
260
+ }
261
+ if (input.ref) return resolveIssueRefFromString({ ref: input.ref, defaultRepo: input.defaultRepo ?? input.repo, provider: input.provider, title: input.title });
262
+ if (input.provider && input.repo && input.externalKey) {
263
+ return resolveIssueRefByExternalKey({
264
+ provider: input.provider,
265
+ repo: input.repo,
266
+ externalKey: input.externalKey,
267
+ url: input.url,
268
+ title: input.title,
269
+ });
270
+ }
271
+ throw new Error("issueRefId, ref, or provider/repo/externalKey required");
272
+ }
273
+
274
+ export async function reopenIssueLifecycle(input: ReopenIssueLifecycleInput, opts: IssueLifecycleOptions = {}): Promise<ReopenIssueLifecycleResult> {
275
+ const issueRef = resolveReopenRef(input);
276
+ const key = manualReopenKey(issueRef);
277
+ if (issueRef.status === "open") return { issueRef, action: "skipped", reason: "already-open", landKey: key };
278
+ const comment = input.comment ?? "Reopened by Agent Relay issue lifecycle.";
279
+ try {
280
+ await adapterFor(issueRef, opts).reopen(issueRef, { comment });
281
+ const opened = updateIssueRef(issueRef.id, { status: "open", statusSyncedAt: Date.now() }) ?? issueRef;
282
+ upsertIssueLifecycleAction({
283
+ issueRefId: issueRef.id,
284
+ action: "reopen",
285
+ landKey: key,
286
+ agentId: input.agentId,
287
+ comment,
288
+ status: "succeeded",
289
+ source: "explicit",
290
+ });
291
+ if (input.entityType && input.entityId) associateIssue(issueRef.id, input.entityType, input.entityId);
292
+ return { issueRef: opened, action: "reopened", landKey: key };
293
+ } catch (error) {
294
+ const message = errMessage(error);
295
+ upsertIssueLifecycleAction({
296
+ issueRefId: issueRef.id,
297
+ action: "reopen",
298
+ landKey: key,
299
+ agentId: input.agentId,
300
+ comment,
301
+ status: "failed",
302
+ error: message,
303
+ source: "explicit",
304
+ });
305
+ logFailure(opts, `issue lifecycle reopen failed for ${issueRef.id}: ${message}`);
306
+ return { issueRef, action: "failed", error: message, landKey: key };
307
+ }
308
+ }
@@ -0,0 +1,32 @@
1
+ import {
2
+ deleteIssueRef,
3
+ getIssueRef,
4
+ listIssueRefs,
5
+ resolveIssueRefByExternalKey,
6
+ resolveIssueRefFromString,
7
+ updateIssueRef,
8
+ upsertIssueRef,
9
+ type ListIssueRefsOptions,
10
+ type ResolveIssueRefByExternalKeyInput,
11
+ type ResolveIssueRefFromStringInput,
12
+ type UpdateIssueRefInput,
13
+ type UpsertIssueRefInput,
14
+ } from "../db/issue-refs";
15
+
16
+ export { getIssueRef, listIssueRefs, type ListIssueRefsOptions };
17
+
18
+ export function upsertIssueRefMapping(input: UpsertIssueRefInput) {
19
+ return upsertIssueRef(input);
20
+ }
21
+
22
+ export function resolveIssueRefMapping(input: ResolveIssueRefByExternalKeyInput | ResolveIssueRefFromStringInput) {
23
+ return "ref" in input ? resolveIssueRefFromString(input) : resolveIssueRefByExternalKey(input);
24
+ }
25
+
26
+ export function patchIssueRefMapping(id: string, input: UpdateIssueRefInput) {
27
+ return updateIssueRef(id, input);
28
+ }
29
+
30
+ export function deleteIssueRefMapping(id: string) {
31
+ return deleteIssueRef(id);
32
+ }
@@ -1,5 +1,7 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
2
  import { createPlanRow, deletePlan, getAgent, getEntry, getPlan, getPlanByTeam, getPlanByThread, listPlansWithBoundOwner, planThreadExists, replacePlan, ValidationError } from "../db";
3
+ import { associateIssue, planNodeIssueEntityId, removeAssociation } from "../db/issue-associations";
4
+ import { resolveIssueRefFromString } from "../db/issue-refs";
3
5
  import { emitRelayEvent } from "../events";
4
6
  import { cleanString, cleanStringArray, optionalEnum } from "../validation";
5
7
  import type { AuthContext } from "./auth-context";
@@ -72,7 +74,37 @@ function cleanOwnerRef(value: unknown): PlanOwnerRef | undefined {
72
74
 
73
75
  function cleanRefs(value: unknown): PlanNode["refs"] | undefined {
74
76
  if (value === undefined || value === null) return undefined;
75
- throw new ValidationError("refs are populated by live binding in slice c");
77
+ const ref = cleanRecordRef(value, "refs");
78
+ if (!ref) return undefined;
79
+ const out: PlanNode["refs"] = {};
80
+ const issueId = cleanString(ref.issueId, "refs.issueId", { max: 1000 });
81
+ if (issueId) {
82
+ out.issueId = resolveIssueRefFromString({ ref: issueId, defaultRepo: defaultIssueRepo() }).id;
83
+ }
84
+ const unsupported = ["agentId", "taskId", "landingRef"].filter((key) => cleanString(ref[key], `refs.${key}`, { max: 240 }));
85
+ if (unsupported.length > 0) throw new ValidationError("agentId/taskId/landingRef refs are populated by live binding in slice c");
86
+ return Object.keys(out).length ? out : undefined;
87
+ }
88
+
89
+ function defaultIssueRepo(): string | undefined {
90
+ return process.env.AGENT_RELAY_DEFAULT_ISSUE_REPO || process.env.GITHUB_REPOSITORY || undefined;
91
+ }
92
+
93
+ function syncPlanNodeIssueAssociation(planId: string, node: PlanNode): void {
94
+ if (!node.refs?.issueId) return;
95
+ associateIssue(node.refs.issueId, "plan-node", planNodeIssueEntityId(planId, node.id));
96
+ }
97
+
98
+ function removePlanNodeIssueAssociation(planId: string, node: PlanNode): void {
99
+ if (!node.refs?.issueId) return;
100
+ removeAssociation(node.refs.issueId, "plan-node", planNodeIssueEntityId(planId, node.id));
101
+ }
102
+
103
+ function syncChangedPlanNodeIssueAssociation(planId: string, before: PlanNode | undefined, after: PlanNode): void {
104
+ if (before?.refs?.issueId && before.refs.issueId !== after.refs?.issueId) {
105
+ removeAssociation(before.refs.issueId, "plan-node", planNodeIssueEntityId(planId, before.id));
106
+ }
107
+ if (after.refs?.issueId) syncPlanNodeIssueAssociation(planId, after);
76
108
  }
77
109
 
78
110
  function cleanBlackboardRefs(value: unknown, planTeamId?: string): string[] | undefined {
@@ -229,6 +261,7 @@ export function createPlan(input: PlanCreateInput, ctx: AuthContext): Plan {
229
261
  edges,
230
262
  createdBy: actor,
231
263
  });
264
+ for (const node of plan.nodes) syncPlanNodeIssueAssociation(plan.id, node);
232
265
  emitPlan("plan.created", plan, actor);
233
266
  return plan;
234
267
  }
@@ -238,6 +271,7 @@ export function addPlanNode(planId: string, nodeInput: PlanNodeInput, ctx: AuthC
238
271
  const node = cleanNode(nodeInput, plan.teamId);
239
272
  if (plan.nodes.some((item) => item.id === node.id)) throw new ValidationError(`node ${node.id} already exists`);
240
273
  const updated = replacePlan({ ...plan, nodes: [...plan.nodes, node] }, actorId(ctx));
274
+ syncPlanNodeIssueAssociation(updated.id, node);
241
275
  emitPlan("plan.changed", updated, actorId(ctx));
242
276
  return updated;
243
277
  }
@@ -260,6 +294,7 @@ export function updatePlanNode(planId: string, nodeId: string, patch: PlanNodePa
260
294
  };
261
295
  const next = applyBoundStatus(base);
262
296
  const updated = replacePlan({ ...plan, nodes: plan.nodes.map((node) => node.id === nodeId ? next : node) }, actorId(ctx));
297
+ syncChangedPlanNodeIssueAssociation(updated.id, existing, next);
263
298
  emitPlan("plan.changed", updated, actorId(ctx));
264
299
  return updated;
265
300
  }
@@ -280,12 +315,14 @@ export function addPlanEdge(planId: string, edgeInput: unknown, ctx: AuthContext
280
315
 
281
316
  export function removePlanNode(planId: string, nodeId: string, ctx: AuthContext): Plan {
282
317
  const plan = existingPlan(planId);
283
- if (!plan.nodes.some((node) => node.id === nodeId)) throw new ValidationError(`node ${nodeId} not found`);
318
+ const removed = plan.nodes.find((node) => node.id === nodeId);
319
+ if (!removed) throw new ValidationError(`node ${nodeId} not found`);
284
320
  const updated = replacePlan({
285
321
  ...plan,
286
322
  nodes: plan.nodes.filter((node) => node.id !== nodeId),
287
323
  edges: plan.edges.filter((edge) => edge.from !== nodeId && edge.to !== nodeId),
288
324
  }, actorId(ctx));
325
+ removePlanNodeIssueAssociation(plan.id, removed);
289
326
  emitPlan("plan.changed", updated, actorId(ctx));
290
327
  return updated;
291
328
  }
@@ -304,6 +341,7 @@ export function deletePlanGraph(planId: string, ctx: AuthContext): { id: string;
304
341
  const plan = existingPlan(planId);
305
342
  const actor = actorId(ctx);
306
343
  deletePlan(plan.id, actor);
344
+ for (const node of plan.nodes) removePlanNodeIssueAssociation(plan.id, node);
307
345
  emitPlan("plan.deleted", plan, actor);
308
346
  return { id: plan.id, deleted: true };
309
347
  }
@@ -1,5 +1,6 @@
1
1
  import { dissolveTeam, ensureTeam, getAgent, getPlanByTeam, getTeam, getTeamRoster, setTeamBrief } from "../db";
2
2
  import { emitRelayEvent } from "../events";
3
+ import { listIssueRefsForEntity } from "./issue-associations";
3
4
  import type { AuthContext } from "./auth-context";
4
5
  import type { Team } from "../types";
5
6
 
@@ -12,6 +13,7 @@ export interface TeamPayload {
12
13
  brief: string | null;
13
14
  definitionOfDone: string | null;
14
15
  plan: ReturnType<typeof getPlanByTeam>;
16
+ issues: ReturnType<typeof listIssueRefsForEntity>;
15
17
  }
16
18
 
17
19
  export interface TeamBriefInput {
@@ -41,6 +43,7 @@ function payloadForAgent(agentId: string, channel?: string): TeamPayload {
41
43
  brief: team.brief ?? null,
42
44
  definitionOfDone: team.definitionOfDone ?? null,
43
45
  plan: getPlanByTeam(team.id, channel) ?? null,
46
+ issues: listIssueRefsForEntity("team", team.id),
44
47
  };
45
48
  }
46
49
 
@@ -85,5 +88,6 @@ export function dissolveCallerTeam(ctx: AuthContext, input: TeamDissolveServiceI
85
88
  brief: team.brief ?? null,
86
89
  definitionOfDone: team.definitionOfDone ?? null,
87
90
  plan: getPlanByTeam(team.id) ?? null,
91
+ issues: listIssueRefsForEntity("team", team.id),
88
92
  };
89
93
  }
package/src/token-db.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { getDb } from "./db";
3
- import { signComponentToken } from "./security";
3
+ import { signComponentToken, type TokenRevokeReason } from "./security";
4
4
  import type { ComponentToken, CreateTokenProfileInput, TokenConstraints, TokenProfile, TokenRecord, UpdateTokenProfileInput } from "./types";
5
5
 
6
6
  interface TokenRow {
@@ -13,6 +13,7 @@ interface TokenRow {
13
13
  issued_at: number;
14
14
  expires_at: number | null;
15
15
  revoked_at: number | null;
16
+ revoked_reason: string | null;
16
17
  created_by: string | null;
17
18
  }
18
19
 
@@ -77,7 +78,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
77
78
  name: "Provider Agent",
78
79
  description: "Coding-agent runtime access for messages, commands, tasks, and scoped memory reads.",
79
80
  role: "provider",
80
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "teams:read", "teams:write", "blackboard:read", "blackboard:write", "project:read", "project:write", "notifications:read", "notifications:write", "insights:write"],
81
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "plans:read", "plans:write", "teams:read", "teams:write", "blackboard:read", "blackboard:write", "project:read", "project:write", "issue:read", "issue:write", "notifications:read", "notifications:write", "insights:write"],
81
82
  ttlSeconds: 24 * 60 * 60,
82
83
  builtIn: true,
83
84
  createdBy: "system",
@@ -87,7 +88,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
87
88
  name: "Provider Child Agent",
88
89
  description: "Delegated child-agent runtime access, constrained to its parent and spawn request.",
89
90
  role: "provider",
90
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "notifications:read", "notifications:write", "insights:write"],
91
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "issue:read", "issue:write", "notifications:read", "notifications:write", "insights:write"],
91
92
  constraints: { canDelegate: false },
92
93
  ttlSeconds: 2 * 60 * 60,
93
94
  builtIn: true,
@@ -98,7 +99,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
98
99
  name: "Provider Interactive Agent",
99
100
  description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
100
101
  role: "provider",
101
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "notifications:read", "notifications:write", "insights:write"],
102
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use", "teams:read", "blackboard:read", "blackboard:write", "project:read", "project:write", "issue:read", "issue:write", "notifications:read", "notifications:write", "insights:write"],
102
103
  constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
103
104
  ttlSeconds: 30 * 24 * 60 * 60,
104
105
  builtIn: true,
@@ -180,8 +181,26 @@ export function getToken(jti: string): TokenRecord | null {
180
181
  return row ? rowToToken(row) : null;
181
182
  }
182
183
 
183
- export function revokeToken(jti: string): boolean {
184
- return getDb().query("UPDATE tokens SET revoked_at = ? WHERE jti = ? AND revoked_at IS NULL").run(Math.floor(Date.now() / 1000), jti).changes > 0;
184
+ // Revoke a token, optionally tagging WHY (#484). The reason gates self-heal:
185
+ // `verifyComponentTokenAllowExpired` lets the orchestrator re-mint a token revoked
186
+ // as "stale" (the restart-strand case) but never one revoked as a hard kill
187
+ // (admin/rotation/agent-removed/pruned). A NULL reason (legacy/unspecified) is a
188
+ // hard kill. Only flips an un-revoked token — already-revoked rows are left as-is
189
+ // (so the 404-on-double-revoke contract in routes/tokens.ts holds).
190
+ export function revokeToken(jti: string, reason?: TokenRevokeReason): boolean {
191
+ return getDb()
192
+ .query("UPDATE tokens SET revoked_at = ?, revoked_reason = ? WHERE jti = ? AND revoked_at IS NULL")
193
+ .run(Math.floor(Date.now() / 1000), reason ?? null, jti).changes > 0;
194
+ }
195
+
196
+ // Downgrade a "stale"-revoked token to a hard "rotation" revocation so it can no
197
+ // longer be re-minted (#484). Called after a successful orchestrator re-mint: the
198
+ // fresh token now carries the identity, and the consumed stale token must not be
199
+ // re-mintable a second time (bounds replay of a captured stale token).
200
+ export function hardenStaleRevocation(jti: string): void {
201
+ getDb()
202
+ .query("UPDATE tokens SET revoked_reason = 'rotation' WHERE jti = ? AND revoked_reason = 'stale'")
203
+ .run(jti);
185
204
  }
186
205
 
187
206
  export function renewToken(input: {
@@ -201,7 +220,7 @@ export function renewToken(input: {
201
220
  profileId: previous.profileId,
202
221
  createdBy: input.createdBy ?? `renew:${previous.jti}`,
203
222
  });
204
- revokeToken(previous.jti);
223
+ revokeToken(previous.jti, "rotation");
205
224
  return { ...renewed, previous };
206
225
  }
207
226
 
@@ -342,6 +361,7 @@ function rowToToken(row: TokenRow): TokenRecord {
342
361
  issuedAt: row.issued_at,
343
362
  expiresAt: row.expires_at ?? undefined,
344
363
  revokedAt: row.revoked_at ?? undefined,
364
+ revokedReason: row.revoked_reason ?? undefined,
345
365
  createdBy: row.created_by ?? undefined,
346
366
  };
347
367
  }
@@ -24,7 +24,8 @@ import { isOwnerAlive, requestWorkspaceMerge } from "./workspace-merge";
24
24
  // tuple as the HTTP route without a second import hop (one source: workspace-merge, #304).
25
25
  export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
26
26
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
27
- import { TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
27
+ import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
28
+ import { scheduleRepoMerge } from "./workspace-auto-merge";
28
29
  import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
29
30
 
30
31
  // Single source of truth for the action verb set. The route's `optionalEnum` and
@@ -239,6 +240,12 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
239
240
  });
240
241
  if (!updated) return { ok: false, httpStatus: 404, error: "workspace not found" };
241
242
 
243
+ // #237: a ready/request-review hand-off kicks the per-repo auto-merge inline right
244
+ // then, instead of waiting up to the backstop interval — this is what makes "mark
245
+ // ready → land" take seconds, not minutes (the #235 root-cause fix). Fire-and-forget
246
+ // + lease-serialized; a non-landable row is a harmless no-op (autoMergeForRepo re-checks).
247
+ if (READY_TO_LAND_STATUSES.has(nextStatus)) scheduleRepoMerge(updated.repoRoot);
248
+
242
249
  let command: Command | undefined;
243
250
  if (requiresCommand) {
244
251
  // Only `cleanup` reaches here — `merge` returned early via the shared helper.