agent-relay-server 0.106.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 (149) hide show
  1. package/docs/openapi.json +503 -1
  2. package/package.json +6 -5
  3. package/public/assets/{MessageBubble-CPYQQLDI.js → MessageBubble-DYJezSjU.js} +2 -2
  4. package/public/assets/{MessageBubble-CPYQQLDI.js.map → MessageBubble-DYJezSjU.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-B7xZt5DI.js → PlanGraphPanel-MWcVw38W.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-B7xZt5DI.js.map → PlanGraphPanel-MWcVw38W.js.map} +1 -1
  7. package/public/assets/{activity-UKQrQ8_0.js → activity-Db3X3dg8.js} +2 -2
  8. package/public/assets/{activity-UKQrQ8_0.js.map → activity-Db3X3dg8.js.map} +1 -1
  9. package/public/assets/{agent-profiles-Zr_5nNEJ.js → agent-profiles-Di2w5U1o.js} +2 -2
  10. package/public/assets/{agent-profiles-Zr_5nNEJ.js.map → agent-profiles-Di2w5U1o.js.map} +1 -1
  11. package/public/assets/{agents-FdrfmJsj.js → agents-GSJXvmU1.js} +2 -2
  12. package/public/assets/{agents-FdrfmJsj.js.map → agents-GSJXvmU1.js.map} +1 -1
  13. package/public/assets/{analytics-VximqXQ9.js → analytics-B9USQLl5.js} +18 -18
  14. package/public/assets/{analytics-VximqXQ9.js.map → analytics-B9USQLl5.js.map} +1 -1
  15. package/public/assets/{automation-DlEcJvgu.js → automation-XRePs1dT.js} +2 -2
  16. package/public/assets/{automation-DlEcJvgu.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-D1bunuI-.js → chat-D_0uHgB6.js} +2 -2
  22. package/public/assets/{chat-D1bunuI-.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-4v9SkUD1.js → formatted-body-impl-BKWCAjBd.js} +2 -2
  28. package/public/assets/{formatted-body-impl-4v9SkUD1.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-CntgHcTp.js → insights-BzNoYvDe.js} +2 -2
  32. package/public/assets/{insights-CntgHcTp.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-Dw7frQtu.js → maintenance-B6_0-ECm.js} +2 -2
  36. package/public/assets/{maintenance-Dw7frQtu.js.map → maintenance-B6_0-ECm.js.map} +1 -1
  37. package/public/assets/{managed-agents-Bj--bFmZ.js → managed-agents-CpzKXDtV.js} +2 -2
  38. package/public/assets/{managed-agents-Bj--bFmZ.js.map → managed-agents-CpzKXDtV.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-DGLCocU8.js → markdown-preview-impl-DxBnlJ2k.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-DGLCocU8.js.map → markdown-preview-impl-DxBnlJ2k.js.map} +1 -1
  41. package/public/assets/{memory-yj2MIxUA.js → memory-Bnm7ILWL.js} +2 -2
  42. package/public/assets/{memory-yj2MIxUA.js.map → memory-Bnm7ILWL.js.map} +1 -1
  43. package/public/assets/{messages-BT5mL7iz.js → messages-D9LkocTc.js} +2 -2
  44. package/public/assets/{messages-BT5mL7iz.js.map → messages-D9LkocTc.js.map} +1 -1
  45. package/public/assets/{orchestrators-BVDAhrPk.js → orchestrators-C__JfrNr.js} +2 -2
  46. package/public/assets/{orchestrators-BVDAhrPk.js.map → orchestrators-C__JfrNr.js.map} +1 -1
  47. package/public/assets/{overview-DlqJIqYK.js → overview-BQ0b0V7Q.js} +2 -2
  48. package/public/assets/{overview-DlqJIqYK.js.map → overview-BQ0b0V7Q.js.map} +1 -1
  49. package/public/assets/{pairs-DQ4tNCo3.js → pairs-ChyeBs0Z.js} +2 -2
  50. package/public/assets/{pairs-DQ4tNCo3.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-DlsqOP0C.js → prompt-settings-YhAOlYc6.js} +2 -2
  54. package/public/assets/{prompt-settings-DlsqOP0C.js.map → prompt-settings-YhAOlYc6.js.map} +1 -1
  55. package/public/assets/{registry-Cw2fdWrn.js → registry-DZoAfrzx.js} +2 -2
  56. package/public/assets/{registry-Cw2fdWrn.js.map → registry-DZoAfrzx.js.map} +1 -1
  57. package/public/assets/{security-ucr7IT1V.js → security-DRCj00eA.js} +2 -2
  58. package/public/assets/{security-ucr7IT1V.js.map → security-DRCj00eA.js.map} +1 -1
  59. package/public/assets/{select-D8UWCtYZ.js → select-ZOpLw2Fd.js} +2 -2
  60. package/public/assets/{select-D8UWCtYZ.js.map → select-ZOpLw2Fd.js.map} +1 -1
  61. package/public/assets/settings-DGgwr9WH.js +6 -0
  62. package/public/assets/settings-DGgwr9WH.js.map +1 -0
  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-jRUvxUd5.js → tasks-DUTu8ZhA.js} +2 -2
  66. package/public/assets/{tasks-jRUvxUd5.js.map → tasks-DUTu8ZhA.js.map} +1 -1
  67. package/public/assets/{teams-Dn0yggD-.js → teams-BJpaoG2m.js} +2 -2
  68. package/public/assets/{teams-Dn0yggD-.js.map → teams-BJpaoG2m.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-fLly20IP.js → terminal-viewer-impl-CQhh6oXa.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-fLly20IP.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-BWLUHt2e.js → user-avatar-BYGrUx1U.js} +2 -2
  74. package/public/assets/{user-avatar-BWLUHt2e.js.map → user-avatar-BYGrUx1U.js.map} +1 -1
  75. package/public/assets/{work-queue-BAnNY0xE.js → work-queue-BH2-mARc.js} +2 -2
  76. package/public/assets/{work-queue-BAnNY0xE.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 +93 -4
  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/provider-quotas.ts +6 -0
  94. package/src/db/schema.ts +67 -1
  95. package/src/db/stats.ts +3 -2
  96. package/src/db/task-comment-through.ts +112 -0
  97. package/src/db/task-core.ts +14 -1
  98. package/src/db/task-entity.ts +629 -0
  99. package/src/db/task-maintenance.ts +4 -3
  100. package/src/db/task-memory.ts +180 -0
  101. package/src/db/tasks.ts +24 -10
  102. package/src/issue-tracker/adapter.ts +15 -0
  103. package/src/issue-tracker/github.ts +40 -2
  104. package/src/issue-tracker/index.ts +1 -0
  105. package/src/mcp/index.ts +12 -1
  106. package/src/mcp/tools-spawn.ts +4 -0
  107. package/src/mcp-dispatch-tools.ts +164 -0
  108. package/src/mcp-notification-subscription-tools.ts +62 -4
  109. package/src/mcp-plan-tools.ts +30 -3
  110. package/src/mcp-release-tools.ts +33 -0
  111. package/src/mcp-task-memory-tools.ts +132 -0
  112. package/src/mcp-task-tools.ts +232 -0
  113. package/src/mcp-task-weave-tools.ts +66 -0
  114. package/src/notification-router.ts +24 -2
  115. package/src/notification-rules.ts +4 -0
  116. package/src/notification-types.ts +25 -0
  117. package/src/prompt-resolver.ts +66 -3
  118. package/src/routes/agents-spawn.ts +10 -0
  119. package/src/routes/commands.ts +17 -1
  120. package/src/routes/index.ts +16 -2
  121. package/src/routes/plans.ts +10 -2
  122. package/src/routes/provider-quotas.ts +13 -1
  123. package/src/routes/tasks.ts +276 -2
  124. package/src/routing-policy-store.ts +147 -2
  125. package/src/security.ts +2 -0
  126. package/src/self-resume.ts +13 -2
  127. package/src/services/issue-lifecycle.ts +9 -1
  128. package/src/services/plan-graph.ts +107 -9
  129. package/src/services/release-readiness.ts +142 -0
  130. package/src/services/send-message.ts +31 -0
  131. package/src/services/spawn-agent.ts +81 -4
  132. package/src/services/task-context-hydration.ts +361 -0
  133. package/src/services/task-entity.ts +149 -0
  134. package/src/services/task-issue-weave.ts +558 -0
  135. package/src/services/task-lifecycle.ts +465 -0
  136. package/src/services/task-plan-view.ts +178 -0
  137. package/src/services/task-semantic-events.ts +137 -0
  138. package/src/services/transient-agent-reaper.ts +71 -23
  139. package/src/spawn-targets.ts +27 -2
  140. package/src/task-dispatcher.ts +461 -0
  141. package/src/upgrade-install-verify.ts +20 -1
  142. package/public/assets/display-BWi8DQCp.js +0 -3
  143. package/public/assets/display-BWi8DQCp.js.map +0 -1
  144. package/public/assets/index-Vmhw442d.js +0 -23
  145. package/public/assets/index-Vmhw442d.js.map +0 -1
  146. package/public/assets/settings-Dvf_ps1B.js +0 -6
  147. package/public/assets/settings-Dvf_ps1B.js.map +0 -1
  148. package/public/assets/types-DorDDvEb.js +0 -2
  149. package/public/assets/types-DorDDvEb.js.map +0 -1
@@ -0,0 +1,465 @@
1
+ // #831 — Task lifecycle automation ON LAND. The land event already knows the branch, commit,
2
+ // backlink, and (worker-reported) gate ledger; this service turns that authoritative land into
3
+ // the Task drive the coordinator otherwise hand-does (~40% of its tool calls): advance the
4
+ // deliverable axis, attach the deliverable, project the issue-close, notify stakeholders, and
5
+ // reap a one-shot worker.
6
+ //
7
+ // LAND-PIPELINE SAFETY (the #823/#824 strand lane lives next door): this is OBSERVE-ONLY. It is
8
+ // invoked best-effort + non-blocking from `notifyBranchLanded` AFTER the merge already completed,
9
+ // so a throw here can never fail or alter a merge. It never touches merge/branch state.
10
+ //
11
+ // TWO DECOUPLED EVENTS (Edin's refinement on #830):
12
+ // - reap-signal = deliverable landed + gates passed → dispose the bound one-shot worker NOW
13
+ // (frees the live-children slot + quota immediately; the worker's contribution
14
+ // is verifiably complete the moment its branch lands green).
15
+ // - task-done = the Task's own `doneWhen` field → reaching it CLOSES the Task (execution
16
+ // status → done). A Task can sit at `landed-on-main` with its worker already
17
+ // gone, advancing to `deployed` on the deliverable-position axis without it.
18
+ // So: landed+gates frees the AGENT; doneWhen closes the TASK. Both are wired below.
19
+ //
20
+ // FIELD-OWNERSHIP BOUNDARY (#830/#833): GitHub owns intent/spec/acceptance/discussion; the Task
21
+ // owns execution telemetry. Issue CLOSE is the canonical #482/#474 path (which runs just before
22
+ // this in `notifyBranchLanded`) — we do NOT re-close; we PROJECT it via #833's `taskCloseBridge`.
23
+ // The one write-through we make is the single milestone summary (#833's milestone exception).
24
+ import { errMessage, TASK_DELIVERABLE_STAGES } from "agent-relay-sdk";
25
+ import {
26
+ deliverableTaskIdsByBranch,
27
+ deliverableTaskIdsByOwnerAgent,
28
+ deliverableTaskIdsBySpawnRequestId,
29
+ getAgent,
30
+ getTaskDetail,
31
+ markTaskAgentReaped,
32
+ } from "../db";
33
+ import { setTaskEntityStage, setTaskEntityStatus } from "./task-entity";
34
+ import { postTaskMilestoneComment, taskCloseBridge, type TaskCloseBridgeEntry } from "./task-issue-weave";
35
+ import { requestTransientAgentShutdown } from "./transient-agent-reaper";
36
+ import { emitRelayEvent } from "../events";
37
+ import { routeNotification } from "../notification-router";
38
+ import { resolveCoordinatorAgentId } from "../steward-identity";
39
+ import { emitTaskSemanticNotification } from "./task-semantic-events";
40
+ import type { IssueRef, TaskDeliverableStage, TaskGateState, WorkspaceRecord } from "../types";
41
+ import type { IssueTrackerAdapter } from "../issue-tracker";
42
+
43
+ type LifecycleWorkspace = Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "baseRef" | "baseSha" | "ownerAgentId">;
44
+ type LogFn = (message: string) => void;
45
+
46
+ export interface LandedTaskLifecycleInput {
47
+ workspace: LifecycleWorkspace;
48
+ mergedSha?: string;
49
+ subject?: string;
50
+ commitSubjects?: string[];
51
+ /** The land arrived via a PR (subject's trailing `(#N)` is the PR number) — forwarded for parity
52
+ * with the issue-lifecycle hook; the Task drive itself doesn't parse backlinks. */
53
+ prLand?: boolean;
54
+ }
55
+
56
+ export interface TaskLifecycleOptions {
57
+ log?: LogFn;
58
+ /** Injected for tests so the milestone write-through never hits a live tracker. */
59
+ adapterResolver?: (ref: IssueRef) => IssueTrackerAdapter;
60
+ }
61
+
62
+ // One Task's land outcome — the structured payload the coordinator receipt + relay event carry.
63
+ export interface TaskLandOutcome {
64
+ taskId: number;
65
+ error?: string;
66
+ deliverableStage?: TaskDeliverableStage;
67
+ gatesPassed?: boolean;
68
+ /** Why gates were not passed (empty/missing/partial/failed), for the receipt + green-report guard. */
69
+ gateReason?: string;
70
+ doneWhen?: string;
71
+ doneWhenSatisfied?: boolean;
72
+ /** Execution status was advanced to `done` because doneWhen was reached. */
73
+ statusAdvanced?: boolean;
74
+ /** Linked issues the canonical #482 path has closed (projection, not a re-close). */
75
+ issuesClosed?: number;
76
+ issuesLinked?: number;
77
+ milestonePosted?: boolean;
78
+ reaped?: boolean;
79
+ reapedAgentId?: string;
80
+ reapSkipReason?: string;
81
+ }
82
+
83
+ export interface LandedTaskLifecycleResult {
84
+ handled: boolean;
85
+ reason?: string;
86
+ tasks: TaskLandOutcome[];
87
+ }
88
+
89
+ // --- diff summary (injectable for tests; best-effort git, never throws into the land) ----------
90
+ type DiffSummaryResolver = (input: LandedTaskLifecycleInput) => string | undefined;
91
+ let diffSummaryResolverForTests: DiffSummaryResolver | undefined;
92
+ type DriveTaskResolver = typeof driveTaskOnLand;
93
+ let driveTaskOnLandForTests: DriveTaskResolver | undefined;
94
+ export function __setLandedDiffSummaryResolverForTests(resolver: DiffSummaryResolver | undefined): void {
95
+ diffSummaryResolverForTests = resolver;
96
+ }
97
+
98
+ export function __setDriveTaskOnLandForTests(resolver: DriveTaskResolver | undefined): void {
99
+ driveTaskOnLandForTests = resolver;
100
+ }
101
+
102
+ function landedDiffSummary(input: LandedTaskLifecycleInput, log: LogFn): string | undefined {
103
+ if (diffSummaryResolverForTests) return diffSummaryResolverForTests(input);
104
+ const repoRoot = input.workspace.repoRoot?.trim();
105
+ const baseSha = input.workspace.baseSha?.trim();
106
+ const tipSha = input.mergedSha?.trim();
107
+ if (!repoRoot || !baseSha || !tipSha || baseSha === tipSha) return undefined;
108
+ try {
109
+ const proc = Bun.spawnSync(["git", "-C", repoRoot, "diff", "--shortstat", `${baseSha}..${tipSha}`], {
110
+ stdin: "ignore",
111
+ stdout: "pipe",
112
+ stderr: "pipe",
113
+ });
114
+ if (proc.exitCode !== 0) return undefined;
115
+ const out = Buffer.from(proc.stdout).toString("utf8").trim();
116
+ return out || undefined;
117
+ } catch (error) {
118
+ log(`task lifecycle diff summary failed for ${input.workspace.id}: ${errMessage(error)}`);
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ // --- doneWhen → deliverable-stage mapping -------------------------------------------------------
124
+ const STAGE_INDEX = new Map<string, number>(TASK_DELIVERABLE_STAGES.map((stage, i) => [stage, i]));
125
+
126
+ // Which deliverable stage satisfies a Task's `doneWhen`. `doneWhen` is free text (#834): we map
127
+ // the small known vocabulary (`landed` | `deployed` | `deliverable-attached`, with synonyms) and
128
+ // default an UNSET doneWhen to `landed-on-main` — the common case where a Task is done when it
129
+ // lands. An unrecognized value falls back to the landed default rather than blocking forever.
130
+ function doneWhenTargetStage(doneWhen: string | undefined): TaskDeliverableStage {
131
+ const value = doneWhen?.trim().toLowerCase();
132
+ if (!value) return "landed-on-main";
133
+ if (value.includes("deploying")) return "deploying";
134
+ if (value.includes("deploy")) return "deployed";
135
+ return "landed-on-main";
136
+ }
137
+
138
+ function stageSatisfiesDoneWhen(stage: TaskDeliverableStage, doneWhen: string | undefined): boolean {
139
+ const current = STAGE_INDEX.get(stage) ?? 0;
140
+ const target = STAGE_INDEX.get(doneWhenTargetStage(doneWhen)) ?? STAGE_INDEX.get("landed-on-main")!;
141
+ return current >= target;
142
+ }
143
+
144
+ // --- gate verdict ------------------------------------------------------------------------------
145
+ // "Gates passed" is the signal that frees the agent (reap) AND closes the Task (doneWhen). The
146
+ // merge landing green is necessary but NOT sufficient (Codex #831 review): a worker that crashed
147
+ // before reporting gates, or reported only a subset, must NOT read as green on a vacuous `[]`.
148
+ // So a pass REQUIRES the expected gates present-and-passing AND no recorded gate failing. An
149
+ // empty / missing / partial / failed ledger is NOT passed → no reap, no close, no green report.
150
+ export const EXPECTED_GATES = ["typecheck", "test"] as const;
151
+
152
+ interface GateVerdict {
153
+ passed: boolean;
154
+ reason?: string;
155
+ }
156
+
157
+ function evaluateGates(gates: TaskGateState[]): GateVerdict {
158
+ if (gates.length === 0) return { passed: false, reason: "no-gates-reported" };
159
+ const reported = new Map(gates.map((gate) => [gate.gate, gate.status]));
160
+ const missing = EXPECTED_GATES.filter((name) => !reported.has(name));
161
+ if (missing.length > 0) return { passed: false, reason: `missing-gates:${missing.join("+")}` };
162
+ const failed = gates.filter((gate) => gate.status !== "pass").map((gate) => gate.gate);
163
+ if (failed.length > 0) return { passed: false, reason: `failed-gates:${failed.join("+")}` };
164
+ return { passed: true };
165
+ }
166
+
167
+ // Stable land identity — shared shape with the issue-lifecycle landKey so the milestone summary
168
+ // is idempotent per land (a re-fired land posts at most one summary).
169
+ function landKeyFor(input: LandedTaskLifecycleInput): string {
170
+ const base = input.workspace.baseRef ?? "main";
171
+ const sha = input.mergedSha?.trim();
172
+ if (sha) return `land:${base}:${sha}`;
173
+ return `land:${base}:${input.workspace.id}:${input.workspace.branch ?? "unknown"}`.slice(0, 240);
174
+ }
175
+
176
+ // --- land → Task linkage (#838 attribution) ----------------------------------------------------
177
+ // Resolve the bound Task(s) for a landed workspace. The robust key is the spawned WORKER, not the
178
+ // branch (a worker cuts its own branch). Union of: owner_agent_id (assignTaskAgent #838), the
179
+ // `agent.spawn_requested` attribution keyed on the worker's meta.spawnRequestId (fire-and-forget,
180
+ // owner not yet backfilled), and the Task's recorded branch column. Deduped + ordered. Returns []
181
+ // for an ordinary non-task branch → the land is a strict no-op (behavior byte-identical).
182
+ function resolveBoundTaskIds(workspace: LifecycleWorkspace): number[] {
183
+ const ids = new Set<number>();
184
+ const owner = workspace.ownerAgentId?.trim();
185
+ if (owner) {
186
+ for (const id of deliverableTaskIdsByOwnerAgent(owner)) ids.add(id);
187
+ const spawnRequestId = getAgent(owner)?.meta?.spawnRequestId;
188
+ if (typeof spawnRequestId === "string" && spawnRequestId.trim()) {
189
+ for (const id of deliverableTaskIdsBySpawnRequestId(spawnRequestId)) ids.add(id);
190
+ }
191
+ }
192
+ if (workspace.branch) {
193
+ for (const id of deliverableTaskIdsByBranch(workspace.branch)) ids.add(id);
194
+ }
195
+ return [...ids].sort((a, b) => a - b);
196
+ }
197
+
198
+ // --- reap signal (decoupled from task-done) ----------------------------------------------------
199
+ interface ReapResult {
200
+ reaped: boolean;
201
+ agentId?: string;
202
+ reason?: string;
203
+ }
204
+
205
+ // Dispose the LANDING one-shot worker — but only when EVERY Task this land bound to it is
206
+ // satisfied (landed + gates-passed), never as soon as a single task goes green (Codex #831).
207
+ // A shared transient owning multiple deliverable Tasks keeps running until all of them clear, so
208
+ // it can't be killed out from under another bound task with missing/failed gates or open work.
209
+ // Owner-level (run once after all tasks are driven), not per-task. Guards: the agent must still
210
+ // exist (already gone → nothing to reap) and be `transient` (one-shot); a `persistent` worker
211
+ // keeps running (land-and-continue). Idempotent: any bound Task already stamped `reapedAt` short-
212
+ // circuits, so a re-land never re-reaps. Reuses the reaper's canonical shutdown path.
213
+ function maybeReapOwnerWorker(workspace: LifecycleWorkspace, outcomes: TaskLandOutcome[]): ReapResult {
214
+ const driven = outcomes.filter((outcome) => !outcome.error);
215
+ if (driven.length === 0) return { reaped: false, reason: "no-task-driven" };
216
+ const agentId = workspace.ownerAgentId?.trim();
217
+ if (!agentId) return { reaped: false, reason: "no-owner-agent" };
218
+ if (driven.some((outcome) => getTaskDetail(outcome.taskId)?.reapedAt)) return { reaped: false, agentId, reason: "already-reaped" };
219
+ const unsatisfied = driven.filter((outcome) => outcome.gatesPassed !== true);
220
+ if (unsatisfied.length > 0) return { reaped: false, agentId, reason: `tasks-not-all-passed:${unsatisfied.map((o) => o.taskId).join("+")}` };
221
+ const agent = getAgent(agentId);
222
+ if (!agent) return { reaped: false, agentId, reason: "agent-gone" };
223
+ if (agent.lifecycle !== "transient") return { reaped: false, agentId, reason: "persistent-worker" };
224
+ requestTransientAgentShutdown(agent, {
225
+ reason: "deliverable-landed",
226
+ requestedBy: "task-lifecycle",
227
+ workspaceId: workspace.id,
228
+ workspaceStatus: "merged",
229
+ activityTitle: "One-shot worker reaped on land",
230
+ activityBody: `All ${driven.length} bound deliverable(s) landed green; disposing one-shot worker ${agentId} to free its slot + quota`,
231
+ landReason: "deliverable-landed",
232
+ });
233
+ return { reaped: true, agentId };
234
+ }
235
+
236
+ // Stamp the reap marker on every bound Task + annotate the outcomes (the reap is decided ONCE at
237
+ // owner level, then applied across the owner's tasks).
238
+ function finalizeReap(outcomes: TaskLandOutcome[], reap: ReapResult, log: LogFn): void {
239
+ for (const outcome of outcomes) {
240
+ if (outcome.error) continue;
241
+ outcome.reaped = reap.reaped;
242
+ if (reap.agentId) outcome.reapedAgentId = reap.agentId;
243
+ if (reap.reason) outcome.reapSkipReason = reap.reason;
244
+ if (reap.reaped && reap.agentId) {
245
+ try {
246
+ markTaskAgentReaped(outcome.taskId, { agentId: reap.agentId, reason: "deliverable-landed" });
247
+ } catch (error) {
248
+ log(`task reap marker failed for task ${outcome.taskId}: ${errMessage(error)}`);
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ // --- per-Task drive ----------------------------------------------------------------------------
255
+ async function driveTaskOnLand(
256
+ taskId: number,
257
+ input: LandedTaskLifecycleInput,
258
+ diffSummary: string | undefined,
259
+ opts: TaskLifecycleOptions,
260
+ log: LogFn,
261
+ ): Promise<TaskLandOutcome> {
262
+ const before = getTaskDetail(taskId);
263
+ if (!before) return { taskId, error: "task not found" };
264
+ const landKey = landKeyFor(input);
265
+
266
+ // 1 + 2 (deliverable-position axis) — advance the stage to `landed-on-main` and ATTACH the
267
+ // landed commit (idempotent append). The execution `status` axis is handled separately below.
268
+ const commit = input.mergedSha ? { sha: input.mergedSha, ...(input.subject ? { subject: input.subject } : {}) } : undefined;
269
+ let detail = setTaskEntityStage(taskId, {
270
+ deliverableStage: "landed-on-main",
271
+ ...(input.workspace.branch ? { branch: input.workspace.branch } : {}),
272
+ ...(commit ? { commit } : {}),
273
+ actor: "task-lifecycle",
274
+ });
275
+
276
+ // 2 (gate ledger) — READ the worker-reported gates and VERIFY them (see evaluateGates): the
277
+ // source of truth for "gates passed". We never fabricate gate runs; the worker records
278
+ // typecheck/test via recordGate before landing. Empty/missing/partial/failed ⇒ NOT passed.
279
+ const gates = detail.gates;
280
+ const gateVerdict = evaluateGates(gates);
281
+ const gatesPassed = gateVerdict.passed;
282
+
283
+ // 3 (doneWhen axis) — reaching the Task's doneWhen target CLOSES the Task (execution status →
284
+ // done) — but ONLY with verified gates (Codex #831): a failed/incomplete gate keeps the Task
285
+ // open, never `done`, even at/after the doneWhen stage. Otherwise it stays where it was ("landed
286
+ // but not done", e.g. doneWhen=deployed waits on deploy). Idempotent: an already-done Task isn't
287
+ // re-written.
288
+ const doneWhenSatisfied = stageSatisfiesDoneWhen("landed-on-main", before.doneWhen);
289
+ let statusAdvanced = false;
290
+ if (doneWhenSatisfied && gatesPassed && before.status !== "done") {
291
+ detail = setTaskEntityStatus(taskId, {
292
+ status: "done",
293
+ note: `doneWhen reached on land (${input.mergedSha?.slice(0, 12) ?? input.workspace.branch ?? "merged"})`,
294
+ actor: "task-lifecycle",
295
+ });
296
+ statusAdvanced = true;
297
+ }
298
+
299
+ // 3b (close projection) — the linked issue is closed by the canonical #482/#474 path (which ran
300
+ // immediately before this in notifyBranchLanded). We PROJECT that ledger via #833, never re-close.
301
+ let closeBridge: TaskCloseBridgeEntry[] = [];
302
+ try {
303
+ closeBridge = taskCloseBridge(taskId);
304
+ } catch (error) {
305
+ log(`task close-bridge projection failed for task ${taskId}: ${errMessage(error)}`);
306
+ }
307
+ const issuesClosed = closeBridge.filter((entry) => entry.closed).length;
308
+
309
+ // 4 (notify) — the single milestone write-through (#833 exception), idempotent by landKey.
310
+ let milestonePosted = false;
311
+ try {
312
+ const summaryBits = [`Landed to \`${input.workspace.baseRef ?? "main"}\``];
313
+ if (diffSummary) summaryBits.push(diffSummary);
314
+ if (!gatesPassed) summaryBits.push(`gates not verified (${gateVerdict.reason ?? "incomplete"})`);
315
+ summaryBits.push(statusAdvanced ? "Task done (doneWhen reached)" : `Task at \`landed-on-main\` (doneWhen \`${before.doneWhen ?? "landed"}\` not yet reached)`);
316
+ const milestone = await postTaskMilestoneComment(
317
+ {
318
+ taskId,
319
+ milestone: "landed",
320
+ summary: summaryBits.join(" — "),
321
+ idempotencyKey: landKey,
322
+ ...(input.mergedSha ? { commitSha: input.mergedSha } : {}),
323
+ gates,
324
+ author: "task-lifecycle",
325
+ },
326
+ { ...(opts.adapterResolver ? { adapterResolver: opts.adapterResolver } : {}), log },
327
+ );
328
+ milestonePosted = milestone.posted.length > 0 || milestone.deduped;
329
+ } catch (error) {
330
+ log(`task milestone comment failed for task ${taskId} on ${landKey}: ${errMessage(error)}`);
331
+ }
332
+
333
+ // NOTE: the one-shot reap (step 5) is decided ONCE at OWNER level AFTER every bound Task is
334
+ // driven (maybeReapOwnerWorker), never per-task — a shared transient must not be killed the
335
+ // instant one of its tasks goes green while another still has open/red gates (Codex #831).
336
+ return {
337
+ taskId,
338
+ deliverableStage: "landed-on-main",
339
+ gatesPassed,
340
+ ...(gateVerdict.reason ? { gateReason: gateVerdict.reason } : {}),
341
+ doneWhen: before.doneWhen,
342
+ doneWhenSatisfied,
343
+ statusAdvanced,
344
+ issuesClosed,
345
+ issuesLinked: closeBridge.length,
346
+ milestonePosted,
347
+ };
348
+ }
349
+
350
+ // 4b (basic deliverable event) — SEAM for #835's rich semantic-event subscriptions. Emitted AFTER
351
+ // the owner-level reap decision so `reaped` is accurate. A deliverable-LEVEL event ("task
352
+ // deliverable landed", not "commit X merged") through the existing bus; #835 enriches the
353
+ // audience/subscription layer on top of this same event.
354
+ function emitDeliverableLandedEvent(input: LandedTaskLifecycleInput, outcome: TaskLandOutcome): void {
355
+ const landKey = landKeyFor(input);
356
+ if (outcome.error) {
357
+ emitDeliverableErrorReceipt(input, outcome, landKey);
358
+ return;
359
+ }
360
+ emitRelayEvent({
361
+ type: "task.deliverable_landed",
362
+ source: "server",
363
+ subject: String(outcome.taskId),
364
+ data: {
365
+ taskId: outcome.taskId,
366
+ workspaceId: input.workspace.id,
367
+ branch: input.workspace.branch,
368
+ base: input.workspace.baseRef ?? "main",
369
+ sha: input.mergedSha,
370
+ deliverableStage: "landed-on-main",
371
+ gatesPassed: outcome.gatesPassed ?? false,
372
+ statusAdvanced: outcome.statusAdvanced ?? false,
373
+ issuesClosed: outcome.issuesClosed ?? 0,
374
+ reaped: outcome.reaped ?? false,
375
+ landKey,
376
+ // #835 seam: the enriched semantic-subscription fan-out reads off this same event.
377
+ semanticEvent: "deliverable.landed",
378
+ },
379
+ });
380
+ const task = getTaskDetail(outcome.taskId);
381
+ if (!task) return;
382
+ const coordinator = resolveCoordinatorAgentId(input.workspace.ownerAgentId ?? undefined);
383
+ emitTaskSemanticNotification({
384
+ task,
385
+ transition: "landed",
386
+ actor: "task-lifecycle",
387
+ recipients: coordinator ? [coordinator] : [],
388
+ workspaceId: input.workspace.id,
389
+ branch: input.workspace.branch,
390
+ sha: input.mergedSha,
391
+ landKey,
392
+ outcome: outcome as unknown as Record<string, unknown>,
393
+ });
394
+ }
395
+
396
+ function emitDeliverableErrorReceipt(input: LandedTaskLifecycleInput, outcome: TaskLandOutcome, landKey: string): void {
397
+ const coordinator = resolveCoordinatorAgentId(input.workspace.ownerAgentId ?? undefined);
398
+ if (!coordinator) return;
399
+ const task = getTaskDetail(outcome.taskId);
400
+ routeNotification({
401
+ type: "task.landed",
402
+ scope: {
403
+ taskId: String(outcome.taskId),
404
+ transition: "landed",
405
+ landKey,
406
+ ...(input.workspace.ownerAgentId ? { agentId: input.workspace.ownerAgentId, author: input.workspace.ownerAgentId } : {}),
407
+ ...(task?.createdBy ? { parent: task.createdBy } : {}),
408
+ ...(task?.teamId ? { teamId: task.teamId } : {}),
409
+ workspaceId: input.workspace.id,
410
+ },
411
+ recipients: [{ agentId: coordinator, matchedRule: "task-stakeholder" }],
412
+ ttlMs: null,
413
+ message: {
414
+ subject: `Task #${outcome.taskId} land lifecycle failed`,
415
+ body: `Task #${outcome.taskId} landed, but Relay could not complete the task lifecycle automation: ${outcome.error}`,
416
+ payload: {
417
+ kind: "task.lifecycle-error",
418
+ notification: "task-lifecycle-error",
419
+ taskId: outcome.taskId,
420
+ transition: "landed",
421
+ semanticEvent: "task.landed",
422
+ workspaceId: input.workspace.id,
423
+ branch: input.workspace.branch,
424
+ ...(input.mergedSha ? { sha: input.mergedSha } : {}),
425
+ outcome: outcome as unknown as Record<string, unknown>,
426
+ },
427
+ idempotencyKey: `task:${outcome.taskId}:land-error:${landKey}`,
428
+ replyExpected: false,
429
+ },
430
+ });
431
+ }
432
+
433
+ /**
434
+ * Drive the bound Task(s) off a confirmed land. Best-effort + non-blocking: the caller invokes this
435
+ * AFTER the merge completed and isolates its throws, so nothing here can fail a merge. A non-task
436
+ * branch (no bound Task) is a strict no-op. See the module header for the reap-vs-done decoupling.
437
+ */
438
+ export async function handleLandedTaskLifecycle(
439
+ input: LandedTaskLifecycleInput,
440
+ opts: TaskLifecycleOptions = {},
441
+ ): Promise<LandedTaskLifecycleResult> {
442
+ const log = opts.log ?? ((message: string) => console.warn(message));
443
+ // Non-default-base lands never drive a deliverable (mirror the issue-lifecycle guard).
444
+ if ((input.workspace.baseRef ?? "main") !== "main") return { handled: false, reason: "non-default-branch", tasks: [] };
445
+ const taskIds = resolveBoundTaskIds(input.workspace);
446
+ if (taskIds.length === 0) return { handled: false, reason: "no-bound-task", tasks: [] };
447
+
448
+ const diffSummary = landedDiffSummary(input, log);
449
+ const outcomes: TaskLandOutcome[] = [];
450
+ for (const taskId of taskIds) {
451
+ try {
452
+ const drive = driveTaskOnLandForTests ?? driveTaskOnLand;
453
+ outcomes.push(await drive(taskId, input, diffSummary, opts, log));
454
+ } catch (error) {
455
+ log(`task lifecycle drive failed for task ${taskId} on ${input.workspace.id}: ${errMessage(error)}`);
456
+ outcomes.push({ taskId, error: errMessage(error) });
457
+ }
458
+ }
459
+ // Reap (step 5) is decided ONCE at owner level, only when ALL bound tasks are satisfied — then
460
+ // applied/annotated across the outcomes. The deliverable event is emitted after, with the
461
+ // accurate `reaped`.
462
+ finalizeReap(outcomes, maybeReapOwnerWorker(input.workspace, outcomes), log);
463
+ for (const outcome of outcomes) emitDeliverableLandedEvent(input, outcome);
464
+ return { handled: true, tasks: outcomes };
465
+ }
@@ -0,0 +1,178 @@
1
+ // #836 — the plan DAG as a VIEW over Tasks. The keystone #834 made `task_dependencies`
2
+ // the durable, cycle-checked edge store and added `PlanNode.refs.taskId` as the binding
3
+ // seam. This module collapses the plan DAG onto that spine: one primitive (the Task), two
4
+ // views. It holds only PURE projections (db reads → Plan shape) so it stays a leaf module —
5
+ // plan-graph.ts imports from here, never the reverse, which keeps the binding/mutation
6
+ // machinery (bindPlanNodeToTask, edge canonicalization) in plan-graph without an import cycle.
7
+ //
8
+ // Two projections live here:
9
+ // 1. projectPlanOverTasks(plan): overlay an EXISTING plan row's bound nodes with their
10
+ // Task's title/intent/status (single source of truth — no double-bookkeeping) and merge
11
+ // in the task_dependencies edges among bound nodes. This is the coexistence path: legacy
12
+ // unbound plans pass through untouched.
13
+ // 2. buildTaskGraphView({teamId}): synthesize a whole Plan-shaped DAG from a team's
14
+ // deliverable Tasks + their task_dependencies, with NO plan row at all. This is the
15
+ // endgame view — the queue IS the task list, the DAG IS the task→task edges.
16
+ import { getTaskDetail, listDeliverableTasks, listTaskDependencies } from "../db";
17
+ import type { Plan, PlanEdge, PlanNode, PlanStatus, Task, TaskStatus } from "../types";
18
+
19
+ // Synthetic node id for a Task in the pure task-backed view. Stable per Task so the view is
20
+ // idempotent and a dashboard can diff it across refreshes.
21
+ export function taskNodeId(taskId: number): string {
22
+ return `task_${taskId}`;
23
+ }
24
+
25
+ // Parse a stored refs.taskId (always a numeric string once cleanRefs has validated it) back
26
+ // to a positive integer, or undefined for an unbound node.
27
+ export function parseTaskId(value: unknown): number | undefined {
28
+ if (value === undefined || value === null) return undefined;
29
+ const n = typeof value === "number" ? value : Number(value);
30
+ return Number.isSafeInteger(n) && n >= 1 ? n : undefined;
31
+ }
32
+
33
+ // Task execution status (+ deliverable stage) → the 4-state plan status. The deliverable
34
+ // stage wins for terminal success: a Task that has landed/deployed reads as "landed" on the
35
+ // board even while its execution status is still settling.
36
+ export function taskStatusToPlanStatus(task: Pick<Task, "status" | "deliverableStage">): PlanStatus {
37
+ if (task.deliverableStage === "landed-on-main" || task.deliverableStage === "deploying" || task.deliverableStage === "deployed") {
38
+ return "landed";
39
+ }
40
+ switch (task.status) {
41
+ case "done":
42
+ return "landed";
43
+ case "in_progress":
44
+ case "claimed":
45
+ return "in-progress";
46
+ case "blocked":
47
+ case "failed":
48
+ case "canceled":
49
+ case "orphaned":
50
+ return "blocked";
51
+ default:
52
+ return "pending";
53
+ }
54
+ }
55
+
56
+ // Reverse map for the migration path (plan node → freshly-created Task). The plan's coarse
57
+ // status seeds the Task's execution status; the richer deliverable axis is filled in later by
58
+ // the land pipeline, not guessed here.
59
+ export function planStatusToTaskStatus(status: PlanStatus): TaskStatus {
60
+ switch (status) {
61
+ case "in-progress":
62
+ return "in_progress";
63
+ case "landed":
64
+ return "done";
65
+ case "blocked":
66
+ return "blocked";
67
+ default:
68
+ return "open";
69
+ }
70
+ }
71
+
72
+ function dedupePlanEdges(edges: PlanEdge[]): PlanEdge[] {
73
+ const seen = new Set<string>();
74
+ const out: PlanEdge[] = [];
75
+ for (const edge of edges) {
76
+ const key = `${edge.from}\0${edge.to}`;
77
+ if (seen.has(key)) continue;
78
+ seen.add(key);
79
+ out.push(edge);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ // Overlay a stored plan with its Task bindings. Bound nodes take their label/scopeBlurb/status
85
+ // from the Task (the Task is the source of truth); edges among bound nodes are projected from
86
+ // the canonical task_dependencies store and merged with the plan's own (legacy/unbound) edges.
87
+ // An unbound plan — the common legacy case — is returned unchanged.
88
+ export function projectPlanOverTasks(plan: Plan): Plan {
89
+ const taskByNode = new Map<string, number>();
90
+ const nodeByTask = new Map<number, string>();
91
+ for (const node of plan.nodes) {
92
+ const taskId = parseTaskId(node.refs?.taskId);
93
+ if (taskId !== undefined) {
94
+ taskByNode.set(node.id, taskId);
95
+ nodeByTask.set(taskId, node.id);
96
+ }
97
+ }
98
+ if (taskByNode.size === 0) return plan;
99
+
100
+ const nodes = plan.nodes.map((node) => {
101
+ const taskId = taskByNode.get(node.id);
102
+ if (taskId === undefined) return node;
103
+ const task = getTaskDetail(taskId);
104
+ // A deleted/non-deliverable Task leaves the stored node as-is — stale but safe; the
105
+ // binding can be repaired by re-binding rather than crashing the whole plan read.
106
+ if (!task) return node;
107
+ const projected: PlanNode = {
108
+ ...node,
109
+ label: task.title,
110
+ status: taskStatusToPlanStatus(task),
111
+ ...(task.ownerAgentId ? { ownerRef: { ...(node.ownerRef ?? {}), agentId: task.ownerAgentId } } : {}),
112
+ };
113
+ // scopeBlurb mirrors the Task's intent EXACTLY — including clearing it when the Task's
114
+ // intent was removed. Preserving the stored node field would leave a stale blurb visible
115
+ // after intent is cleared (#836 review). The Task is the single source of truth.
116
+ if (task.intent) projected.scopeBlurb = task.intent;
117
+ else delete projected.scopeBlurb;
118
+ return projected;
119
+ });
120
+
121
+ // task_dependencies edge `T depends on D` projects to plan edge {from: D, to: T} — the
122
+ // prerequisite points at the dependent, matching the existing plan-edge direction.
123
+ const projected: PlanEdge[] = [];
124
+ for (const [nodeId, taskId] of taskByNode) {
125
+ for (const dependsOn of listTaskDependencies(taskId)) {
126
+ const fromNode = nodeByTask.get(dependsOn);
127
+ if (fromNode) projected.push({ from: fromNode, to: nodeId });
128
+ }
129
+ }
130
+ return { ...plan, nodes, edges: dedupePlanEdges([...plan.edges, ...projected]) };
131
+ }
132
+
133
+ function taskToPlanNode(task: Task): PlanNode {
134
+ return {
135
+ id: taskNodeId(task.id),
136
+ label: task.title,
137
+ ...(task.intent ? { scopeBlurb: task.intent } : {}),
138
+ ...(task.ownerAgentId ? { ownerRef: { agentId: task.ownerAgentId } } : {}),
139
+ status: taskStatusToPlanStatus(task),
140
+ statusSource: "bound",
141
+ refs: { taskId: String(task.id) },
142
+ };
143
+ }
144
+
145
+ // Synthesize a Plan-shaped DAG entirely from a team's deliverable Tasks — no plan row exists.
146
+ // Every Task is a node; every task_dependency is an edge. This is the canonical forward view:
147
+ // a coordinator expresses "B depends on A" via relay_task_add_dependency and reads the ordering
148
+ // here without ever creating a plan. Returns a virtual Plan (id `taskgraph_<teamId>`); the
149
+ // thread/version fields are placeholders since there is no persisted row.
150
+ export function buildTaskGraphView(input: { teamId: string; status?: string; limit?: number }): Plan {
151
+ const teamId = input.teamId.trim();
152
+ if (!teamId) throw new Error("teamId required for the task-backed plan view");
153
+ const tasks = listDeliverableTasks({ teamId, ...(input.status ? { status: input.status } : {}), ...(input.limit ? { limit: input.limit } : {}) });
154
+ const present = new Set(tasks.map((t) => t.id));
155
+ const nodes = tasks.map(taskToPlanNode);
156
+ const edges: PlanEdge[] = [];
157
+ for (const task of tasks) {
158
+ for (const dependsOn of listTaskDependencies(task.id)) {
159
+ // Only edges whose BOTH endpoints are in the rendered set become view edges; a
160
+ // dependency on a Task outside this team/filter is silently dropped (it's not a node).
161
+ if (present.has(dependsOn)) edges.push({ from: taskNodeId(dependsOn), to: taskNodeId(task.id) });
162
+ }
163
+ }
164
+ const now = tasks.reduce((max, t) => Math.max(max, t.updatedAt), 0);
165
+ return {
166
+ id: `taskgraph_${teamId}`,
167
+ threadId: "0",
168
+ teamId,
169
+ version: 1,
170
+ title: "Task graph",
171
+ objective: `Deliverable tasks for ${teamId}`,
172
+ nodes,
173
+ edges: dedupePlanEdges(edges),
174
+ createdBy: "agent-relay",
175
+ createdAt: now,
176
+ updatedAt: now,
177
+ };
178
+ }