agent-relay-server 0.107.0 → 0.108.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/docs/openapi.json +475 -1
  2. package/package.json +4 -4
  3. package/public/assets/{MessageBubble-PEdvVh_m.js → MessageBubble-DYJezSjU.js} +2 -2
  4. package/public/assets/{MessageBubble-PEdvVh_m.js.map → MessageBubble-DYJezSjU.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-DSts7Rtq.js → PlanGraphPanel-MWcVw38W.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DSts7Rtq.js.map → PlanGraphPanel-MWcVw38W.js.map} +1 -1
  7. package/public/assets/{activity-BcYf8YNO.js → activity-Db3X3dg8.js} +2 -2
  8. package/public/assets/{activity-BcYf8YNO.js.map → activity-Db3X3dg8.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CuTQtmV6.js → agent-profiles-Di2w5U1o.js} +2 -2
  10. package/public/assets/{agent-profiles-CuTQtmV6.js.map → agent-profiles-Di2w5U1o.js.map} +1 -1
  11. package/public/assets/{agents-CgRFJyks.js → agents-GSJXvmU1.js} +2 -2
  12. package/public/assets/{agents-CgRFJyks.js.map → agents-GSJXvmU1.js.map} +1 -1
  13. package/public/assets/{analytics-C3OTFIXo.js → analytics-B9USQLl5.js} +2 -2
  14. package/public/assets/{analytics-C3OTFIXo.js.map → analytics-B9USQLl5.js.map} +1 -1
  15. package/public/assets/{automation-CH9YGYoE.js → automation-XRePs1dT.js} +2 -2
  16. package/public/assets/{automation-CH9YGYoE.js.map → automation-XRePs1dT.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CcLZ_u86.js → branch-state-badge-C_VNyER3.js} +2 -2
  18. package/public/assets/{branch-state-badge-CcLZ_u86.js.map → branch-state-badge-C_VNyER3.js.map} +1 -1
  19. package/public/assets/{channels-CcSnLzCG.js → channels-DACvNoC2.js} +2 -2
  20. package/public/assets/{channels-CcSnLzCG.js.map → channels-DACvNoC2.js.map} +1 -1
  21. package/public/assets/{chat-BLcm2Aht.js → chat-D_0uHgB6.js} +2 -2
  22. package/public/assets/{chat-BLcm2Aht.js.map → chat-D_0uHgB6.js.map} +1 -1
  23. package/public/assets/{connectors-DbPOlQhL.js → connectors-C0B38LUK.js} +2 -2
  24. package/public/assets/{connectors-DbPOlQhL.js.map → connectors-C0B38LUK.js.map} +1 -1
  25. package/public/assets/display-1J__Ti1K.js +3 -0
  26. package/public/assets/display-1J__Ti1K.js.map +1 -0
  27. package/public/assets/{formatted-body-impl-BJTthVHg.js → formatted-body-impl-BKWCAjBd.js} +2 -2
  28. package/public/assets/{formatted-body-impl-BJTthVHg.js.map → formatted-body-impl-BKWCAjBd.js.map} +1 -1
  29. package/public/assets/index-DlBSuzd2.js +23 -0
  30. package/public/assets/index-DlBSuzd2.js.map +1 -0
  31. package/public/assets/{insights-DklrIhb6.js → insights-BzNoYvDe.js} +2 -2
  32. package/public/assets/{insights-DklrIhb6.js.map → insights-BzNoYvDe.js.map} +1 -1
  33. package/public/assets/{integrations-hkSW8CzM.js → integrations-DLzJpt9G.js} +2 -2
  34. package/public/assets/{integrations-hkSW8CzM.js.map → integrations-DLzJpt9G.js.map} +1 -1
  35. package/public/assets/{maintenance-FAAMGpuZ.js → maintenance-B6_0-ECm.js} +2 -2
  36. package/public/assets/{maintenance-FAAMGpuZ.js.map → maintenance-B6_0-ECm.js.map} +1 -1
  37. package/public/assets/{managed-agents-CVca-s3t.js → managed-agents-CpzKXDtV.js} +2 -2
  38. package/public/assets/{managed-agents-CVca-s3t.js.map → managed-agents-CpzKXDtV.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-BPW4OQo9.js → markdown-preview-impl-DxBnlJ2k.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-BPW4OQo9.js.map → markdown-preview-impl-DxBnlJ2k.js.map} +1 -1
  41. package/public/assets/{memory-BzuDTHC5.js → memory-Bnm7ILWL.js} +2 -2
  42. package/public/assets/{memory-BzuDTHC5.js.map → memory-Bnm7ILWL.js.map} +1 -1
  43. package/public/assets/{messages-_W24PNvT.js → messages-D9LkocTc.js} +2 -2
  44. package/public/assets/{messages-_W24PNvT.js.map → messages-D9LkocTc.js.map} +1 -1
  45. package/public/assets/{orchestrators-Du6I_n8i.js → orchestrators-C__JfrNr.js} +2 -2
  46. package/public/assets/{orchestrators-Du6I_n8i.js.map → orchestrators-C__JfrNr.js.map} +1 -1
  47. package/public/assets/{overview-Dyvm1qpT.js → overview-BQ0b0V7Q.js} +2 -2
  48. package/public/assets/{overview-Dyvm1qpT.js.map → overview-BQ0b0V7Q.js.map} +1 -1
  49. package/public/assets/{pairs-B8x1RCNd.js → pairs-ChyeBs0Z.js} +2 -2
  50. package/public/assets/{pairs-B8x1RCNd.js.map → pairs-ChyeBs0Z.js.map} +1 -1
  51. package/public/assets/{projects-vt63bwHN.js → projects-v5Kp1Oaq.js} +2 -2
  52. package/public/assets/{projects-vt63bwHN.js.map → projects-v5Kp1Oaq.js.map} +1 -1
  53. package/public/assets/{prompt-settings-xVXmvxB5.js → prompt-settings-YhAOlYc6.js} +2 -2
  54. package/public/assets/{prompt-settings-xVXmvxB5.js.map → prompt-settings-YhAOlYc6.js.map} +1 -1
  55. package/public/assets/{registry-ypiICauZ.js → registry-DZoAfrzx.js} +2 -2
  56. package/public/assets/{registry-ypiICauZ.js.map → registry-DZoAfrzx.js.map} +1 -1
  57. package/public/assets/{security-BfOQ6T5r.js → security-DRCj00eA.js} +2 -2
  58. package/public/assets/{security-BfOQ6T5r.js.map → security-DRCj00eA.js.map} +1 -1
  59. package/public/assets/{select-Dx8Ro-A3.js → select-ZOpLw2Fd.js} +2 -2
  60. package/public/assets/{select-Dx8Ro-A3.js.map → select-ZOpLw2Fd.js.map} +1 -1
  61. package/public/assets/{settings-87clTx4N.js → settings-DGgwr9WH.js} +2 -2
  62. package/public/assets/{settings-87clTx4N.js.map → settings-DGgwr9WH.js.map} +1 -1
  63. package/public/assets/{store-DUfrGvOE.js → store-Bnag1kY-.js} +2 -2
  64. package/public/assets/{store-DUfrGvOE.js.map → store-Bnag1kY-.js.map} +1 -1
  65. package/public/assets/{tasks-1Kvp53wh.js → tasks-DUTu8ZhA.js} +2 -2
  66. package/public/assets/{tasks-1Kvp53wh.js.map → tasks-DUTu8ZhA.js.map} +1 -1
  67. package/public/assets/{teams-DJWp4AKi.js → teams-BJpaoG2m.js} +2 -2
  68. package/public/assets/{teams-DJWp4AKi.js.map → teams-BJpaoG2m.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-DgMuRAe_.js → terminal-viewer-impl-CQhh6oXa.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-DgMuRAe_.js.map → terminal-viewer-impl-CQhh6oXa.js.map} +1 -1
  71. package/public/assets/types-BiJFuTiL.js +2 -0
  72. package/public/assets/types-BiJFuTiL.js.map +1 -0
  73. package/public/assets/{user-avatar-CfAUS3PZ.js → user-avatar-BYGrUx1U.js} +2 -2
  74. package/public/assets/{user-avatar-CfAUS3PZ.js.map → user-avatar-BYGrUx1U.js.map} +1 -1
  75. package/public/assets/{work-queue-D-Otxd9u.js → work-queue-BH2-mARc.js} +2 -2
  76. package/public/assets/{work-queue-D-Otxd9u.js.map → work-queue-BH2-mARc.js.map} +1 -1
  77. package/public/assets/{workspaces-CIsQqQby.js → workspaces-DldYyXZR.js} +2 -2
  78. package/public/assets/{workspaces-CIsQqQby.js.map → workspaces-DldYyXZR.js.map} +1 -1
  79. package/public/index.html +4 -4
  80. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  81. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +6 -0
  82. package/src/branch-landed.ts +17 -3
  83. package/src/config-store.ts +12 -1
  84. package/src/db/agents.ts +3 -2
  85. package/src/db/continuations.ts +13 -3
  86. package/src/db/index.ts +3 -0
  87. package/src/db/issue-lifecycle.ts +12 -0
  88. package/src/db/mappers.ts +17 -0
  89. package/src/db/messages.ts +9 -4
  90. package/src/db/migrations.ts +91 -1
  91. package/src/db/notification-subscriptions.ts +1 -1
  92. package/src/db/prompt-templates.ts +19 -0
  93. package/src/db/schema.ts +67 -1
  94. package/src/db/stats.ts +3 -2
  95. package/src/db/task-comment-through.ts +112 -0
  96. package/src/db/task-core.ts +14 -1
  97. package/src/db/task-entity.ts +629 -0
  98. package/src/db/task-maintenance.ts +4 -3
  99. package/src/db/task-memory.ts +180 -0
  100. package/src/db/tasks.ts +24 -10
  101. package/src/issue-tracker/adapter.ts +15 -0
  102. package/src/issue-tracker/github.ts +40 -2
  103. package/src/issue-tracker/index.ts +1 -0
  104. package/src/mcp/index.ts +12 -1
  105. package/src/mcp/tools-spawn.ts +4 -0
  106. package/src/mcp-dispatch-tools.ts +164 -0
  107. package/src/mcp-notification-subscription-tools.ts +62 -4
  108. package/src/mcp-plan-tools.ts +30 -3
  109. package/src/mcp-release-tools.ts +33 -0
  110. package/src/mcp-task-memory-tools.ts +132 -0
  111. package/src/mcp-task-tools.ts +232 -0
  112. package/src/mcp-task-weave-tools.ts +66 -0
  113. package/src/notification-router.ts +24 -2
  114. package/src/notification-rules.ts +4 -0
  115. package/src/notification-types.ts +23 -0
  116. package/src/prompt-resolver.ts +66 -3
  117. package/src/routes/agents-spawn.ts +10 -0
  118. package/src/routes/index.ts +14 -1
  119. package/src/routes/plans.ts +10 -2
  120. package/src/routes/tasks.ts +276 -2
  121. package/src/routing-policy-store.ts +147 -2
  122. package/src/self-resume.ts +13 -2
  123. package/src/services/issue-lifecycle.ts +9 -1
  124. package/src/services/plan-graph.ts +107 -9
  125. package/src/services/release-readiness.ts +142 -0
  126. package/src/services/send-message.ts +31 -0
  127. package/src/services/spawn-agent.ts +81 -4
  128. package/src/services/task-context-hydration.ts +361 -0
  129. package/src/services/task-entity.ts +149 -0
  130. package/src/services/task-issue-weave.ts +558 -0
  131. package/src/services/task-lifecycle.ts +465 -0
  132. package/src/services/task-plan-view.ts +178 -0
  133. package/src/services/task-semantic-events.ts +137 -0
  134. package/src/services/transient-agent-reaper.ts +55 -22
  135. package/src/spawn-targets.ts +27 -2
  136. package/src/task-dispatcher.ts +461 -0
  137. package/src/upgrade-install-verify.ts +20 -1
  138. package/public/assets/display-BWi8DQCp.js +0 -3
  139. package/public/assets/display-BWi8DQCp.js.map +0 -1
  140. package/public/assets/index-3ydeNO0c.js +0 -23
  141. package/public/assets/index-3ydeNO0c.js.map +0 -1
  142. package/public/assets/types-DorDDvEb.js +0 -2
  143. package/public/assets/types-DorDDvEb.js.map +0 -1
@@ -0,0 +1,558 @@
1
+ // #833 — the Task ↔ GitHub issue weave-through. This is the WRITE-THROUGH lens that
2
+ // the design refinement (weave-in, not duplicate) calls for: a comment on a Task does
3
+ // not land in a relay table that can drift — it writes a structured comment THROUGH to
4
+ // the linked GitHub issue, the backing store for human discussion. GitHub stays the
5
+ // system of record for intent/spec/acceptance/discussion; the Task is the lens + the
6
+ // write-through API.
7
+ //
8
+ // FIELD-OWNERSHIP BOUNDARY (the critical hazard #830/#833 guards against):
9
+ // - GitHub owns: intent / spec / acceptance / human discussion → write THROUGH here.
10
+ // - Task owns: execution telemetry (status, stage, gate ledger, branch, commits,
11
+ // attribution) → stays Task-only, in src/db/task-entity.ts.
12
+ //
13
+ // GUARD (required): execution telemetry must NEVER write through to GitHub — a comment
14
+ // per gate-pass / per-position-change is noise + a rate-limit suicide. The guard is
15
+ // structural: the telemetry writers (setTaskStatus/setTaskStage/recordGate/…) live in
16
+ // the db layer and DO NOT import this module, so they have no path to the adapter. The
17
+ // ONE chokepoint that touches the issue tracker is `postThrough` below, reachable only
18
+ // via `commentOnTaskThrough` (human/agent discussion) and
19
+ // `postTaskMilestoneComment` (the single milestone-summary exception). `assertWriteThroughEligible`
20
+ // makes the classification a hard, tested invariant.
21
+ //
22
+ // MILESTONE EXCEPTION: a single structured summary MAY post at a meaningful boundary
23
+ // (e.g. on land). The mechanism is `postTaskMilestoneComment`; the land TRIGGER that
24
+ // calls it is #831's job — left here as a seam, not wired into the land pipeline.
25
+ //
26
+ // CLOSE-BRIDGE: #482/#474 already close the linked issue exactly once on land and record
27
+ // it durably in `issue_lifecycle_actions`. The Task does not duplicate that ledger — it
28
+ // PROJECTS it (`taskCloseBridge`), closing the "I couldn't tell if #482 already closed it"
29
+ // gap by surfacing the canonical record instead of copying it.
30
+ import { errMessage } from "agent-relay-sdk";
31
+ import { getTaskDetail } from "../db/task-entity";
32
+ import { getIssueRef } from "../db/issue-refs";
33
+ import { listSuccessfulCloseActionsForIssueRef } from "../db/issue-lifecycle";
34
+ import { insertTaskEvent, getTaskEvent } from "../db/task-core";
35
+ import { listTaskEvents } from "../db/tasks";
36
+ import {
37
+ failCommentThrough,
38
+ finalizeCommentThrough,
39
+ reserveCommentThrough,
40
+ type TaskCommentThroughKind,
41
+ type TaskCommentThroughRecord,
42
+ } from "../db/task-comment-through";
43
+ import { resolveIssueTrackerAdapter, type IssueTrackerAdapter } from "../issue-tracker";
44
+ import { emitTaskChanged } from "../sse";
45
+ import { ValidationError } from "../db/connection";
46
+ import type { IssueRef, TaskDetail, TaskEvent, TaskGateState, TaskIssueLink } from "../types";
47
+
48
+ type AdapterResolver = (ref: IssueRef) => IssueTrackerAdapter;
49
+ type LogFn = (message: string) => void;
50
+
51
+ interface WeaveOptions {
52
+ adapterResolver?: AdapterResolver;
53
+ log?: LogFn;
54
+ }
55
+
56
+ let adapterResolverForTests: AdapterResolver | undefined;
57
+
58
+ export function __setTaskWeaveAdapterResolverForTests(resolver: AdapterResolver | undefined): void {
59
+ adapterResolverForTests = resolver;
60
+ }
61
+
62
+ function adapterFor(ref: IssueRef, opts: WeaveOptions): IssueTrackerAdapter {
63
+ return (opts.adapterResolver ?? adapterResolverForTests ?? resolveIssueTrackerAdapter)(ref);
64
+ }
65
+
66
+ // --- Structured comment format -------------------------------------------------------
67
+ // A relay-written issue comment carries an HTML-comment marker holding a JSON payload so
68
+ // it is machine-parseable (attributable back to its Task + author + kind) while still
69
+ // rendering as a normal human comment on GitHub. The two kinds are the ONLY things that
70
+ // ever write through — discussion (a comment on the Task) and milestone (the boundary
71
+ // summary). Telemetry has no kind here on purpose.
72
+ export const TASK_COMMENT_MARKER = "agent-relay:task-comment";
73
+ export const TASK_COMMENT_FORMAT_VERSION = 1;
74
+ export type TaskCommentKind = "discussion" | "milestone";
75
+
76
+ // The event types this module appends to the Task ledger as a RECEIPT of a write-through.
77
+ // Everything else (status:*, stage:*, gate:*, dependency.*, ref.*, agent.*) is telemetry
78
+ // and must never reach the chokepoint.
79
+ export const WRITE_THROUGH_EVENT_TYPES = ["comment.posted", "milestone.posted"] as const;
80
+ const TELEMETRY_EVENT_PREFIXES = ["status:", "stage:", "gate:", "dependency.", "ref.", "agent.", "task."];
81
+
82
+ // Hard guard for the field-ownership boundary: only discussion/milestone write-throughs
83
+ // are eligible. Anything telemetry-shaped throws — so a future caller cannot accidentally
84
+ // route a gate/position event through the issue tracker.
85
+ export function isWriteThroughEligibleKind(kind: string): kind is TaskCommentKind {
86
+ return kind === "discussion" || kind === "milestone";
87
+ }
88
+
89
+ export function isTelemetryEventType(type: string): boolean {
90
+ return TELEMETRY_EVENT_PREFIXES.some((prefix) => type.startsWith(prefix));
91
+ }
92
+
93
+ function assertWriteThroughEligible(kind: string): asserts kind is TaskCommentKind {
94
+ if (!isWriteThroughEligibleKind(kind)) {
95
+ throw new ValidationError(
96
+ `task-issue weave: '${kind}' is not write-through eligible — execution telemetry stays Task-only and must never post to GitHub`,
97
+ );
98
+ }
99
+ }
100
+
101
+ interface TaskCommentPayload {
102
+ v: number;
103
+ kind: TaskCommentKind;
104
+ taskId: number;
105
+ author?: string;
106
+ milestone?: string;
107
+ // The idempotency/reservation key (clientId for discussion, `${milestone}:${key}` for
108
+ // milestone). Embedded so a post-before-finalize crash can be RECONCILED: on reclaiming a
109
+ // stale reservation, we read the issue's comments and match this `key` to learn whether the
110
+ // comment already landed — instead of blindly re-posting it.
111
+ key?: string;
112
+ }
113
+
114
+ const HEADINGS: Record<TaskCommentKind, (p: TaskCommentPayload) => string> = {
115
+ discussion: (p) => `💬 **Comment on Task #${p.taskId}**${p.author ? ` — _${p.author}_` : ""}`,
116
+ milestone: (p) => `🏁 **Task #${p.taskId} — ${p.milestone ?? "milestone"}**`,
117
+ };
118
+
119
+ // The payload is base64url-encoded inside the marker, NOT raw JSON: a user/route-supplied
120
+ // author or milestone containing `}` or `-->` would otherwise break the greedy `{...}` scan
121
+ // or terminate the HTML comment early. base64url's alphabet ([A-Za-z0-9_-]) cannot contain
122
+ // either, so the marker is always delimiter-safe regardless of payload content.
123
+ const MARKER_RE = new RegExp(`^<!--\\s*${TASK_COMMENT_MARKER}\\s+b64=([A-Za-z0-9_-]+)\\s*-->`);
124
+
125
+ export function formatTaskComment(payload: TaskCommentPayload, body: string): string {
126
+ const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
127
+ const marker = `<!-- ${TASK_COMMENT_MARKER} b64=${encoded} -->`;
128
+ return `${marker}\n${HEADINGS[payload.kind](payload)}\n\n${body.trim()}`;
129
+ }
130
+
131
+ // Round-trip parser (used by the dashboard lens + tests): pull the relay marker back out
132
+ // of a rendered comment so a relay-written comment is unambiguously identifiable. Parsed
133
+ // only from the leading marker block (anchored `^`), never a greedy scan of the body, and
134
+ // the decoded payload's v/taskId/kind are type-validated before it is trusted.
135
+ export function parseTaskComment(comment: string): TaskCommentPayload | null {
136
+ const match = comment.match(MARKER_RE);
137
+ if (!match) return null;
138
+ try {
139
+ const parsed = JSON.parse(Buffer.from(match[1]!, "base64url").toString("utf8")) as unknown;
140
+ if (typeof parsed !== "object" || parsed === null) return null;
141
+ const p = parsed as Record<string, unknown>;
142
+ if (typeof p.v !== "number" || typeof p.taskId !== "number" || typeof p.kind !== "string") return null;
143
+ if (!isWriteThroughEligibleKind(p.kind)) return null;
144
+ return {
145
+ v: p.v,
146
+ kind: p.kind,
147
+ taskId: p.taskId,
148
+ ...(typeof p.author === "string" ? { author: p.author } : {}),
149
+ ...(typeof p.milestone === "string" ? { milestone: p.milestone } : {}),
150
+ ...(typeof p.key === "string" ? { key: p.key } : {}),
151
+ };
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ // --- The single chokepoint that touches the issue tracker ----------------------------
158
+ interface WriteThroughResult {
159
+ posted: TaskIssueLink[];
160
+ failed: Array<{ link: TaskIssueLink; error: string }>;
161
+ }
162
+
163
+ // Writes `body` through to every GitHub-backed issue linked to the Task. This is the ONLY
164
+ // function in the codebase that posts a Task-originated comment to an issue tracker. It is
165
+ // kind-gated (assertWriteThroughEligible) so telemetry can never be smuggled through.
166
+ // Non-GitHub providers are skipped (the design's backing store for discussion is GitHub);
167
+ // a tracker failure on one issue does not abort the others.
168
+ // Posts `body` through to the GitHub-backed links in `links` (a tracker failure on one issue
169
+ // does not abort the others). Callers pass the exact link set: ALL github links on a fresh
170
+ // post, or just the reconcile-confirmed-missing subset on a reclaim (so a link that already
171
+ // has the comment is never double-posted).
172
+ async function postThrough(
173
+ kind: TaskCommentKind,
174
+ payload: TaskCommentPayload,
175
+ body: string,
176
+ links: TaskIssueLink[],
177
+ opts: WeaveOptions,
178
+ ): Promise<WriteThroughResult> {
179
+ assertWriteThroughEligible(kind);
180
+ const formatted = formatTaskComment(payload, body);
181
+ const result: WriteThroughResult = { posted: [], failed: [] };
182
+ for (const link of links) {
183
+ if (link.provider !== "github") continue;
184
+ const ref = getIssueRef(link.issueRefId);
185
+ if (!ref) continue;
186
+ try {
187
+ await adapterFor(ref, opts).comment(ref, formatted);
188
+ result.posted.push(link);
189
+ } catch (e) {
190
+ const message = errMessage(e);
191
+ result.failed.push({ link, error: message });
192
+ if (opts.log) opts.log(`task-issue weave: comment-through failed for ${link.repo}#${link.externalKey}: ${message}`);
193
+ else console.warn(`task-issue weave: comment-through failed for ${link.repo}#${link.externalKey}: ${message}`);
194
+ }
195
+ }
196
+ return result;
197
+ }
198
+
199
+ interface ReconcileResult {
200
+ // Links that ALREADY carry a relay marker for this (taskId, kind, key) — the post landed.
201
+ matched: TaskIssueLink[];
202
+ // Links read successfully with NO marker — the post is genuinely missing and must be sent.
203
+ missing: TaskIssueLink[];
204
+ // Links whose comments could not be read — state UNKNOWN; we must neither finalize nor post.
205
+ unreadable: TaskIssueLink[];
206
+ }
207
+
208
+ // Per-link reconcile of a reclaimed-stale reservation (#833): for each linked GitHub issue,
209
+ // classify it as already-has-the-marker (matched), readable-but-missing (must post), or
210
+ // unreadable (unknown). The caller finalizes ONLY when every link is in a known state — a
211
+ // single unreadable link blocks finalization so a Task with multiple issues can never clear
212
+ // its retry slot while one issue's state is still unknown.
213
+ async function reconcileExistingPost(
214
+ taskId: number,
215
+ kind: TaskCommentKind,
216
+ slotKey: string,
217
+ links: TaskIssueLink[],
218
+ opts: WeaveOptions,
219
+ ): Promise<ReconcileResult> {
220
+ const matched: TaskIssueLink[] = [];
221
+ const missing: TaskIssueLink[] = [];
222
+ const unreadable: TaskIssueLink[] = [];
223
+ for (const link of links) {
224
+ if (link.provider !== "github") continue;
225
+ const ref = getIssueRef(link.issueRefId);
226
+ if (!ref) continue;
227
+ const adapter = adapterFor(ref, opts);
228
+ if (!adapter.listComments) { unreadable.push(link); continue; }
229
+ try {
230
+ const comments = await adapter.listComments(ref);
231
+ const found = comments.some((c) => {
232
+ const p = parseTaskComment(c);
233
+ return p !== null && p.taskId === taskId && p.kind === kind && p.key === slotKey;
234
+ });
235
+ (found ? matched : missing).push(link);
236
+ } catch (e) {
237
+ unreadable.push(link);
238
+ if (opts.log) opts.log(`task-issue weave: reconcile read failed for ${link.repo}#${link.externalKey}: ${errMessage(e)}`);
239
+ else console.warn(`task-issue weave: reconcile read failed for ${link.repo}#${link.externalKey}: ${errMessage(e)}`);
240
+ }
241
+ }
242
+ return { matched, missing, unreadable };
243
+ }
244
+
245
+ // Shared reclaim handler (discussion + milestone are structurally identical here). Returns the
246
+ // finished result, or throws CommentThroughError to leave the reservation durable. Only called
247
+ // when a stale reservation was reclaimed AND there are GitHub links to reconcile.
248
+ async function reconcileReclaim(params: {
249
+ taskId: number;
250
+ kind: TaskCommentKind;
251
+ slotKey: string;
252
+ payload: TaskCommentPayload;
253
+ body: string;
254
+ githubLinks: TaskIssueLink[];
255
+ reservation: TaskCommentThroughRecord;
256
+ recordReceipt: (posted: TaskIssueLink[], failed: Array<{ link: TaskIssueLink; error: string }>) => TaskEvent;
257
+ opts: WeaveOptions;
258
+ }): Promise<WeaveWriteResult> {
259
+ const { taskId, kind, slotKey, payload, body, githubLinks, reservation, recordReceipt, opts } = params;
260
+ const recon = await reconcileExistingPost(taskId, kind, slotKey, githubLinks, opts);
261
+
262
+ // ANY unreadable link → state unknown → do not finalize, do not blind-post. Leave the
263
+ // reservation `reserved` (durable) and surface; a later retry reconciles again.
264
+ if (recon.unreadable.length > 0) {
265
+ throw new CommentThroughError(
266
+ `comment-through reconcile could not read ${recon.unreadable.map((l) => `${l.repo}#${l.externalKey}`).join(", ")}; not finalizing/re-posting to avoid a duplicate`,
267
+ [],
268
+ );
269
+ }
270
+
271
+ // Every link readable. Post only the genuinely-missing ones (matched links already have it).
272
+ let posted = recon.matched;
273
+ if (recon.missing.length > 0) {
274
+ const result = await postThrough(kind, payload, body, recon.missing, opts);
275
+ if (result.failed.length > 0) {
276
+ // A missing link still couldn't be posted → not every link is satisfied. Keep the slot
277
+ // reclaimable and surface, rather than finalize with a hole.
278
+ failCommentThrough(reservation.id, Date.now());
279
+ throw new CommentThroughError(
280
+ `comment-through reconcile failed to post missing issue(s): ${result.failed.map((f) => `${f.link.repo}#${f.link.externalKey}: ${f.error}`).join("; ")}`,
281
+ result.failed,
282
+ );
283
+ }
284
+ posted = [...recon.matched, ...result.posted];
285
+ }
286
+
287
+ // Every link now satisfied (already-present + freshly-posted) → finalize exactly once.
288
+ const event = recordReceipt(posted, []);
289
+ finalizeCommentThrough(reservation.id, event.id, Date.now());
290
+ emitChanged(taskId);
291
+ return { taskId, posted, failed: [], event, deduped: recon.missing.length === 0 };
292
+ }
293
+
294
+ interface WeaveWriteResult {
295
+ taskId: number;
296
+ posted: TaskIssueLink[];
297
+ failed: Array<{ link: TaskIssueLink; error: string }>;
298
+ event: TaskEvent | null;
299
+ deduped: boolean;
300
+ }
301
+
302
+ function emitChanged(taskId: number): void {
303
+ const detail = getTaskDetail(taskId);
304
+ if (detail) emitTaskChanged(detail, "task.updated");
305
+ }
306
+
307
+ // --- Comment-on-Task → write-through -------------------------------------------------
308
+ export interface CommentOnTaskInput {
309
+ taskId: number;
310
+ body: string;
311
+ author?: string;
312
+ /** Optional idempotency key: a re-delivered comment with the same key posts once. */
313
+ clientId?: string;
314
+ }
315
+
316
+ export interface CommentOnTaskResult {
317
+ taskId: number;
318
+ posted: TaskIssueLink[];
319
+ failed: Array<{ link: TaskIssueLink; error: string }>;
320
+ event: TaskEvent | null;
321
+ deduped: boolean;
322
+ }
323
+
324
+ // Thrown when every GitHub write-through failed. NOT a ValidationError — the request was
325
+ // well-formed; GitHub was unreachable — so it must NOT leave a posted receipt (finding 1):
326
+ // a clientId retry re-hits GitHub because the reservation was flipped to 'failed'.
327
+ export class CommentThroughError extends Error {
328
+ constructor(message: string, readonly failures: Array<{ link: TaskIssueLink; error: string }>) {
329
+ super(message);
330
+ this.name = "CommentThroughError";
331
+ }
332
+ }
333
+
334
+ export async function commentOnTaskThrough(input: CommentOnTaskInput, opts: WeaveOptions = {}): Promise<CommentOnTaskResult> {
335
+ const taskId = input.taskId;
336
+ const detail = getTaskDetail(taskId);
337
+ if (!detail) throw new ValidationError(`task ${taskId} not found`);
338
+ const body = typeof input.body === "string" ? input.body.trim() : "";
339
+ if (!body) throw new ValidationError("comment body required");
340
+
341
+ // Discussion has no relay backing store — it lives in GitHub. With no GitHub-backed
342
+ // issue linked, there is nowhere for it to live, so the write-through is rejected rather
343
+ // than silently dropped into a table that would drift.
344
+ const githubLinks = detail.issues.filter((link) => link.provider === "github");
345
+ if (githubLinks.length === 0) {
346
+ throw new ValidationError("task has no linked GitHub issue to write the comment through to");
347
+ }
348
+
349
+ const clientId = input.clientId?.trim();
350
+ const author = input.author?.trim() || "agent-relay";
351
+ const payload: TaskCommentPayload = { v: TASK_COMMENT_FORMAT_VERSION, kind: "discussion", taskId, author, ...(clientId ? { key: clientId } : {}) };
352
+
353
+ // The success-scoped receipt writer (shared by the normal-post and reconcile-found paths):
354
+ // an AUDIT of what actually landed on GitHub (posted only), not a second store of the
355
+ // discussion. GitHub stays canonical; the Task renders by reading it back.
356
+ const recordReceipt = (posted: TaskIssueLink[], failed: Array<{ link: TaskIssueLink; error: string }>): TaskEvent =>
357
+ insertTaskEvent(taskId, {
358
+ source: author,
359
+ type: "comment.posted",
360
+ severity: "info",
361
+ title: `Comment written through to ${posted.map((l) => `${l.repo}#${l.externalKey}`).join(", ")}`,
362
+ body,
363
+ metadata: {
364
+ kind: "discussion",
365
+ author,
366
+ ...(clientId ? { clientId } : {}),
367
+ posted: posted.map((l) => ({ issueRefId: l.issueRefId, repo: l.repo, externalKey: l.externalKey, url: l.url })),
368
+ ...(failed.length > 0 ? { failed: failed.map((f) => ({ issueRefId: f.link.issueRefId, error: f.error })) } : {}),
369
+ },
370
+ }, Date.now());
371
+
372
+ // Idempotency = reserve→post→finalize (NOT scan→post→insert): the (taskId,kind,clientId)
373
+ // slot is claimed TRANSACTIONALLY before the awaited GitHub append, so concurrent same-key
374
+ // requests can't both post, and a crash leaves a durable reservation rather than re-posting.
375
+ let reservation: TaskCommentThroughRecord | undefined;
376
+ if (clientId) {
377
+ const reserved = reserveCommentThrough(taskId, "discussion", clientId, Date.now());
378
+ if (reserved.outcome === "duplicate") {
379
+ return { taskId, posted: [], failed: [], event: reserved.record.eventId ? getTaskEvent(reserved.record.eventId) : null, deduped: true };
380
+ }
381
+ if (reserved.outcome === "in_flight") {
382
+ return { taskId, posted: [], failed: [], event: null, deduped: true };
383
+ }
384
+ reservation = reserved.record;
385
+ // Stale-`reserved` reclaim is ambiguous (post-before-finalize crash). Reconcile each linked
386
+ // issue against GitHub before re-posting: finalize only when EVERY link's state is known
387
+ // (already-posted or freshly-posted); a single unreadable link leaves the slot durable.
388
+ if (reserved.reconcileNeeded) {
389
+ return await reconcileReclaim({ taskId, kind: "discussion", slotKey: clientId, payload, body, githubLinks, reservation, recordReceipt, opts });
390
+ }
391
+ }
392
+
393
+ const result = await postThrough("discussion", payload, body, githubLinks, opts);
394
+
395
+ // Finding 1: zero successful GitHub posts → NO receipt. Release the reservation to 'failed'
396
+ // (reclaimable) and surface the failure so a retry actually re-hits GitHub. The Task never
397
+ // shows a comment as posted when GitHub never received it.
398
+ if (result.posted.length === 0) {
399
+ if (reservation) failCommentThrough(reservation.id, Date.now());
400
+ throw new CommentThroughError(
401
+ `comment write-through failed for all linked issue(s): ${result.failed.map((f) => `${f.link.repo}#${f.link.externalKey}: ${f.error}`).join("; ")}`,
402
+ result.failed,
403
+ );
404
+ }
405
+
406
+ const event = recordReceipt(result.posted, result.failed);
407
+ if (reservation) finalizeCommentThrough(reservation.id, event.id, Date.now());
408
+ emitChanged(taskId);
409
+ return { taskId, posted: result.posted, failed: result.failed, event, deduped: false };
410
+ }
411
+
412
+ // --- Milestone summary (the single-comment exception) — SEAM for #831 ----------------
413
+ export interface MilestoneCommentInput {
414
+ taskId: number;
415
+ milestone: string;
416
+ summary: string;
417
+ /** One comment per boundary: e.g. the land key, so #831 re-firing posts once. */
418
+ idempotencyKey?: string;
419
+ commitSha?: string;
420
+ gates?: TaskGateState[];
421
+ author?: string;
422
+ }
423
+
424
+ export interface MilestoneCommentResult {
425
+ taskId: number;
426
+ posted: TaskIssueLink[];
427
+ failed: Array<{ link: TaskIssueLink; error: string }>;
428
+ event: TaskEvent | null;
429
+ deduped: boolean;
430
+ }
431
+
432
+ function gatesLine(gates: TaskGateState[] | undefined): string {
433
+ if (!gates || gates.length === 0) return "";
434
+ return `\ngates: ${gates.map((g) => `${g.gate} ${g.status === "pass" ? "✓" : "✗"}`).join(" · ")}`;
435
+ }
436
+
437
+ // The MECHANISM for the milestone exception. #831's land trigger calls this once at a
438
+ // boundary (land/deploy). Idempotent by (milestone, idempotencyKey) so a retried land
439
+ // surfaces exactly one structured summary. NOT wired into the land pipeline here.
440
+ export async function postTaskMilestoneComment(input: MilestoneCommentInput, opts: WeaveOptions = {}): Promise<MilestoneCommentResult> {
441
+ const taskId = input.taskId;
442
+ const detail = getTaskDetail(taskId);
443
+ if (!detail) throw new ValidationError(`task ${taskId} not found`);
444
+ const milestone = input.milestone?.trim();
445
+ if (!milestone) throw new ValidationError("milestone required");
446
+ const summary = input.summary?.trim();
447
+ if (!summary) throw new ValidationError("milestone summary required");
448
+
449
+ // The milestone slot is the (milestone, idempotencyKey) pair so distinct boundaries don't
450
+ // collide. The marker embeds that same slotKey so a reclaim can reconcile against GitHub.
451
+ const idempotencyKey = input.idempotencyKey?.trim();
452
+ const slotKey = idempotencyKey ? `${milestone}:${idempotencyKey}` : undefined;
453
+ const author = input.author?.trim() || "agent-relay";
454
+ const body = `${summary}${input.commitSha ? `\ncommit: \`${input.commitSha.slice(0, 12)}\`` : ""}${gatesLine(input.gates)}`;
455
+ const payload: TaskCommentPayload = { v: TASK_COMMENT_FORMAT_VERSION, kind: "milestone", taskId, author, milestone, ...(slotKey ? { key: slotKey } : {}) };
456
+
457
+ const recordReceipt = (posted: TaskIssueLink[], failed: Array<{ link: TaskIssueLink; error: string }>): TaskEvent =>
458
+ insertTaskEvent(taskId, {
459
+ source: author,
460
+ type: "milestone.posted",
461
+ severity: "info",
462
+ title: `Milestone ${milestone}`,
463
+ body: summary,
464
+ metadata: {
465
+ kind: "milestone",
466
+ milestone,
467
+ author,
468
+ ...(idempotencyKey ? { idempotencyKey } : {}),
469
+ ...(input.commitSha ? { commitSha: input.commitSha } : {}),
470
+ posted: posted.map((l) => ({ issueRefId: l.issueRefId, repo: l.repo, externalKey: l.externalKey, url: l.url })),
471
+ ...(failed.length > 0 ? { failed: failed.map((f) => ({ issueRefId: f.link.issueRefId, error: f.error })) } : {}),
472
+ },
473
+ }, Date.now());
474
+
475
+ // Same reserve→post→finalize + reconcile-on-reclaim discipline as discussion, so a re-fired
476
+ // land posts at most one summary even across concurrency and post-before-finalize crashes.
477
+ const githubLinks = detail.issues.filter((link) => link.provider === "github");
478
+ let reservation: TaskCommentThroughRecord | undefined;
479
+ if (slotKey) {
480
+ const reserved = reserveCommentThrough(taskId, "milestone", slotKey, Date.now());
481
+ if (reserved.outcome === "duplicate") {
482
+ return { taskId, posted: [], failed: [], event: reserved.record.eventId ? getTaskEvent(reserved.record.eventId) : null, deduped: true };
483
+ }
484
+ if (reserved.outcome === "in_flight") {
485
+ return { taskId, posted: [], failed: [], event: null, deduped: true };
486
+ }
487
+ reservation = reserved.record;
488
+ if (reserved.reconcileNeeded && githubLinks.length > 0 && slotKey) {
489
+ return await reconcileReclaim({ taskId, kind: "milestone", slotKey, payload, body, githubLinks, reservation, recordReceipt, opts });
490
+ }
491
+ }
492
+
493
+ // No GitHub-backed issue → nothing to post through, but still leave a local timeline marker.
494
+ // When there ARE linked issues and EVERY post fails, treat it like a failed write-through
495
+ // (finding 1): release the reservation and surface the failure so #831 can re-fire.
496
+ const result = githubLinks.length > 0
497
+ ? await postThrough("milestone", payload, body, githubLinks, opts)
498
+ : { posted: [] as TaskIssueLink[], failed: [] as Array<{ link: TaskIssueLink; error: string }> };
499
+ if (githubLinks.length > 0 && result.posted.length === 0) {
500
+ if (reservation) failCommentThrough(reservation.id, Date.now());
501
+ throw new CommentThroughError(
502
+ `milestone write-through failed for all linked issue(s): ${result.failed.map((f) => `${f.link.repo}#${f.link.externalKey}: ${f.error}`).join("; ")}`,
503
+ result.failed,
504
+ );
505
+ }
506
+
507
+ const event = recordReceipt(result.posted, result.failed);
508
+ if (reservation) finalizeCommentThrough(reservation.id, event.id, Date.now());
509
+ emitChanged(taskId);
510
+ return { taskId, posted: result.posted, failed: result.failed, event, deduped: false };
511
+ }
512
+
513
+ // --- Reads ---------------------------------------------------------------------------
514
+ export function listWriteThroughEvents(taskId: number): TaskEvent[] {
515
+ return listTaskEvents(taskId).filter((e) => (WRITE_THROUGH_EVENT_TYPES as readonly string[]).includes(e.type));
516
+ }
517
+
518
+ // --- Close-bridge projection (#482/#474 receipt, not a copy) --------------------------
519
+ export interface TaskCloseBridgeEntry {
520
+ issueRefId: string;
521
+ provider: string;
522
+ repo: string;
523
+ externalKey: string;
524
+ url: string;
525
+ issueStatus: string;
526
+ /** A successful #482/#474 close action exists for this issue (the canonical record). */
527
+ closed: boolean;
528
+ closedAt?: number;
529
+ commitSha?: string;
530
+ landKey?: string;
531
+ source?: string;
532
+ }
533
+
534
+ // Projects the canonical issue-lifecycle ledger onto the Task's linked issues. This is the
535
+ // answer to "did #482 already close it?" — read, never duplicated. The land WIRING that
536
+ // advances the Task on land is #831; this projection is always accurate regardless, because
537
+ // #474 writes the ledger row the moment it closes the issue.
538
+ export function taskCloseBridge(taskId: number): TaskCloseBridgeEntry[] {
539
+ const detail = getTaskDetail(taskId);
540
+ if (!detail) throw new ValidationError(`task ${taskId} not found`);
541
+ return detail.issues.map((link) => {
542
+ const actions = listSuccessfulCloseActionsForIssueRef(link.issueRefId);
543
+ const latest = actions[0];
544
+ return {
545
+ issueRefId: link.issueRefId,
546
+ provider: link.provider,
547
+ repo: link.repo,
548
+ externalKey: link.externalKey,
549
+ url: link.url,
550
+ issueStatus: link.status,
551
+ closed: Boolean(latest),
552
+ ...(latest?.updatedAt ? { closedAt: latest.updatedAt } : {}),
553
+ ...(latest?.commitSha ? { commitSha: latest.commitSha } : {}),
554
+ ...(latest?.landKey ? { landKey: latest.landKey } : {}),
555
+ ...(latest?.source ? { source: latest.source } : {}),
556
+ };
557
+ });
558
+ }