agent-relay-server 0.110.0 → 0.111.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 (78) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-CmqJMGYF.js → MessageBubble-WYqSyKPM.js} +2 -2
  4. package/public/assets/{MessageBubble-CmqJMGYF.js.map → MessageBubble-WYqSyKPM.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-Dvu17jGe.js +2 -0
  6. package/public/assets/PlanGraphPanel-Dvu17jGe.js.map +1 -0
  7. package/public/assets/{activity-DyqKrJe_.js → activity-wAN_upn7.js} +2 -2
  8. package/public/assets/{activity-DyqKrJe_.js.map → activity-wAN_upn7.js.map} +1 -1
  9. package/public/assets/{agent-profiles-DRQ5vbPo.js → agent-profiles-D8WLdTCs.js} +2 -2
  10. package/public/assets/{agent-profiles-DRQ5vbPo.js.map → agent-profiles-D8WLdTCs.js.map} +1 -1
  11. package/public/assets/{agents-BJ9sm23O.js → agents-C5FajNPw.js} +2 -2
  12. package/public/assets/{agents-BJ9sm23O.js.map → agents-C5FajNPw.js.map} +1 -1
  13. package/public/assets/{automation-CJ2-uiYt.js → automation-ShdXg0pX.js} +2 -2
  14. package/public/assets/{automation-CJ2-uiYt.js.map → automation-ShdXg0pX.js.map} +1 -1
  15. package/public/assets/{chat-D0VtHShz.js → chat-aRCk0pLJ.js} +2 -2
  16. package/public/assets/{chat-D0VtHShz.js.map → chat-aRCk0pLJ.js.map} +1 -1
  17. package/public/assets/{formatted-body-impl-ChCxpVtI.js → formatted-body-impl-DZc7C4-2.js} +2 -2
  18. package/public/assets/{formatted-body-impl-ChCxpVtI.js.map → formatted-body-impl-DZc7C4-2.js.map} +1 -1
  19. package/public/assets/{index-BIRtwnUI.js → index-DXEYz_JV.js} +5 -5
  20. package/public/assets/{index-BIRtwnUI.js.map → index-DXEYz_JV.js.map} +1 -1
  21. package/public/assets/{insights-BGuB8DWZ.js → insights-FzTX2wNh.js} +2 -2
  22. package/public/assets/{insights-BGuB8DWZ.js.map → insights-FzTX2wNh.js.map} +1 -1
  23. package/public/assets/{maintenance-BFyuZuv5.js → maintenance-C8Uw8XiI.js} +2 -2
  24. package/public/assets/{maintenance-BFyuZuv5.js.map → maintenance-C8Uw8XiI.js.map} +1 -1
  25. package/public/assets/{managed-agents-BIXZUkrA.js → managed-agents-DVbJ2hqh.js} +2 -2
  26. package/public/assets/{managed-agents-BIXZUkrA.js.map → managed-agents-DVbJ2hqh.js.map} +1 -1
  27. package/public/assets/{markdown-preview-impl-DTI--VmH.js → markdown-preview-impl-C5D-B-9S.js} +2 -2
  28. package/public/assets/{markdown-preview-impl-DTI--VmH.js.map → markdown-preview-impl-C5D-B-9S.js.map} +1 -1
  29. package/public/assets/{memory-OoM2E30h.js → memory-Bp-D_V80.js} +2 -2
  30. package/public/assets/{memory-OoM2E30h.js.map → memory-Bp-D_V80.js.map} +1 -1
  31. package/public/assets/{messages-BHvC3SNR.js → messages-DE1QSeHI.js} +2 -2
  32. package/public/assets/{messages-BHvC3SNR.js.map → messages-DE1QSeHI.js.map} +1 -1
  33. package/public/assets/{orchestrators-9pzQqf6-.js → orchestrators-B0u2lQfw.js} +2 -2
  34. package/public/assets/{orchestrators-9pzQqf6-.js.map → orchestrators-B0u2lQfw.js.map} +1 -1
  35. package/public/assets/{overview-L8iYOYmR.js → overview-DpaEvvLT.js} +2 -2
  36. package/public/assets/{overview-L8iYOYmR.js.map → overview-DpaEvvLT.js.map} +1 -1
  37. package/public/assets/{pairs-BCejxa6_.js → pairs-DRp_qAu8.js} +2 -2
  38. package/public/assets/{pairs-BCejxa6_.js.map → pairs-DRp_qAu8.js.map} +1 -1
  39. package/public/assets/{prompt-settings-D9y9hhSy.js → prompt-settings-CNoBiymk.js} +2 -2
  40. package/public/assets/{prompt-settings-D9y9hhSy.js.map → prompt-settings-CNoBiymk.js.map} +1 -1
  41. package/public/assets/{registry-BwBg64Vj.js → registry-0A4QFMWC.js} +2 -2
  42. package/public/assets/{registry-BwBg64Vj.js.map → registry-0A4QFMWC.js.map} +1 -1
  43. package/public/assets/{security-upd2zf8l.js → security-C9P6yiQ3.js} +2 -2
  44. package/public/assets/{security-upd2zf8l.js.map → security-C9P6yiQ3.js.map} +1 -1
  45. package/public/assets/{select-Cu55Vi-R.js → select-B1PVUj6D.js} +2 -2
  46. package/public/assets/{select-Cu55Vi-R.js.map → select-B1PVUj6D.js.map} +1 -1
  47. package/public/assets/{settings-Dk5iNqCZ.js → settings-TnHlB9-t.js} +2 -2
  48. package/public/assets/{settings-Dk5iNqCZ.js.map → settings-TnHlB9-t.js.map} +1 -1
  49. package/public/assets/{tasks-BoDuvHnZ.js → tasks-1oK_ggFC.js} +2 -2
  50. package/public/assets/{tasks-BoDuvHnZ.js.map → tasks-1oK_ggFC.js.map} +1 -1
  51. package/public/assets/{teams-C6Pn-gpw.js → teams-BBb0ZYiF.js} +2 -2
  52. package/public/assets/{teams-C6Pn-gpw.js.map → teams-BBb0ZYiF.js.map} +1 -1
  53. package/public/assets/{terminal-viewer-impl-afW96xEF.js → terminal-viewer-impl-DzvaRjkp.js} +2 -2
  54. package/public/assets/{terminal-viewer-impl-afW96xEF.js.map → terminal-viewer-impl-DzvaRjkp.js.map} +1 -1
  55. package/public/assets/{user-avatar-tSPnqEk5.js → user-avatar-X1pfizLv.js} +2 -2
  56. package/public/assets/{user-avatar-tSPnqEk5.js.map → user-avatar-X1pfizLv.js.map} +1 -1
  57. package/public/assets/{work-queue-DP3UdDwC.js → work-queue-DQMbkSD8.js} +2 -2
  58. package/public/assets/{work-queue-DP3UdDwC.js.map → work-queue-DQMbkSD8.js.map} +1 -1
  59. package/public/assets/{workspaces-Dt2xndG4.js → workspaces-BNLP4IlV.js} +2 -2
  60. package/public/assets/{workspaces-Dt2xndG4.js.map → workspaces-BNLP4IlV.js.map} +1 -1
  61. package/public/index.html +1 -1
  62. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  63. package/src/db/agents.ts +3 -0
  64. package/src/db/notification-subscriptions.ts +15 -0
  65. package/src/db/task-entity.ts +2 -0
  66. package/src/mcp-notification-subscription-tools.ts +2 -1
  67. package/src/notification-rules.ts +17 -1
  68. package/src/notification-types.ts +18 -0
  69. package/src/reviewer-pipeline.ts +13 -0
  70. package/src/routes/commands.ts +14 -0
  71. package/src/routes/tasks.ts +1 -0
  72. package/src/services/spawn-agent.ts +2 -0
  73. package/src/services/task-entity.ts +20 -6
  74. package/src/services/task-lifecycle.ts +36 -7
  75. package/src/services/task-semantic-events.ts +241 -13
  76. package/src/workspace-auto-merge.ts +17 -0
  77. package/public/assets/PlanGraphPanel-K6aFJmdd.js +0 -2
  78. package/public/assets/PlanGraphPanel-K6aFJmdd.js.map +0 -1
@@ -592,6 +592,7 @@ export interface ListDeliverableTasksFilter {
592
592
  status?: string;
593
593
  deliverableStage?: string;
594
594
  teamId?: string;
595
+ createdBy?: string;
595
596
  limit?: number;
596
597
  }
597
598
 
@@ -601,6 +602,7 @@ export function listDeliverableTasks(filter: ListDeliverableTasksFilter = {}): T
601
602
  if (filter.status) { conditions.push("status = ?"); params.push(filter.status); }
602
603
  if (filter.deliverableStage) { conditions.push("deliverable_stage = ?"); params.push(filter.deliverableStage); }
603
604
  if (filter.teamId) { conditions.push("team_id = ?"); params.push(filter.teamId); }
605
+ if (filter.createdBy) { conditions.push("created_by = ?"); params.push(filter.createdBy); }
604
606
  const limit = Math.min(Math.max(filter.limit ?? 100, 1), 500);
605
607
  params.push(limit);
606
608
  return (getDb().query(`${TASK_SELECT} WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC LIMIT ?`).all(...params as any[]) as any[]).map(rowToTask);
@@ -21,8 +21,9 @@ import { getTaskEntity } from "./services/task-entity";
21
21
  import { cleanString } from "./validation";
22
22
  import { NOTIFICATION_TYPES, type NotificationScope, type NotificationType } from "./notification-types";
23
23
  import { previewNotificationRouting, whyNotified } from "./notification-router";
24
+ import { TASK_SEMANTIC_NOTIFICATION_TYPES } from "./services/task-semantic-events";
24
25
 
25
- const TASK_NOTIFICATION_TYPES = ["task.created", "task.started", "task.blocked", "task.needs_decision", "task.landed", "task.closed"] as const satisfies readonly NotificationType[];
26
+ const TASK_NOTIFICATION_TYPES = TASK_SEMANTIC_NOTIFICATION_TYPES as readonly NotificationType[];
26
27
 
27
28
  export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
28
29
  {
@@ -272,6 +272,22 @@ registerDefaultAudienceRules("message.new", [
272
272
  audiencePredicates.threadParticipant(),
273
273
  ]);
274
274
 
275
- for (const type of ["task.created", "task.started", "task.blocked", "task.needs_decision", "task.landed", "task.closed"] as const) {
275
+ for (const type of [
276
+ "task.created",
277
+ "task.started",
278
+ "task.blocked",
279
+ "task.needs_decision",
280
+ "task.gate_failed",
281
+ "task.committed_on_branch",
282
+ "task.review_requested",
283
+ "task.land_conflict",
284
+ "task.landed",
285
+ "task.landed_on_main",
286
+ "task.closed",
287
+ "task.working",
288
+ "task.done",
289
+ "task.deploying",
290
+ "task.deployed",
291
+ ] as const) {
276
292
  registerDefaultAudienceRules(type, []);
277
293
  }
@@ -7,8 +7,17 @@ export type NotificationType =
7
7
  | "task.started"
8
8
  | "task.blocked"
9
9
  | "task.needs_decision"
10
+ | "task.gate_failed"
11
+ | "task.committed_on_branch"
12
+ | "task.review_requested"
13
+ | "task.land_conflict"
10
14
  | "task.landed"
15
+ | "task.landed_on_main"
11
16
  | "task.closed"
17
+ | "task.working"
18
+ | "task.done"
19
+ | "task.deploying"
20
+ | "task.deployed"
12
21
  | "agent.ready"
13
22
  | "agent.exited"
14
23
  | "agent.spawn_failed"
@@ -94,8 +103,17 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
94
103
  "task.started": null,
95
104
  "task.blocked": null,
96
105
  "task.needs_decision": null,
106
+ "task.gate_failed": null,
107
+ "task.committed_on_branch": null,
108
+ "task.review_requested": null,
109
+ "task.land_conflict": null,
97
110
  "task.landed": null,
111
+ "task.landed_on_main": null,
98
112
  "task.closed": null,
113
+ "task.working": null,
114
+ "task.done": null,
115
+ "task.deploying": null,
116
+ "task.deployed": null,
99
117
  "agent.ready": null,
100
118
  "agent.exited": null,
101
119
  "agent.spawn_failed": null,
@@ -5,6 +5,7 @@ import { getSpawnPolicy } from "./config-store";
5
5
  import { getLifecycleManager } from "./lifecycle-manager";
6
6
  import { routeNotification } from "./notification-router";
7
7
  import { getWorkspace, listAgents, patchWorkspaceMetadata, updateWorkspaceStatus } from "./db";
8
+ import { emitWorkspaceTaskSemanticNotifications } from "./services/task-lifecycle";
8
9
  import type { Command, WorkspaceMergePreview, WorkspaceRecord } from "./types";
9
10
 
10
11
  type ReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED";
@@ -59,6 +60,18 @@ export function handleReviewerMergeResult(command: Command, result: Record<strin
59
60
  compareRef,
60
61
  });
61
62
  if (!updated) return false;
63
+ emitWorkspaceTaskSemanticNotifications({
64
+ workspace: updated,
65
+ transition: "review-requested",
66
+ actor: "reviewer-pipeline",
67
+ occurrenceKey: `workspace-review:${workspace.id}:${command.id}`,
68
+ outcome: {
69
+ reviewer: target,
70
+ prUrl,
71
+ prNumber,
72
+ compareRef,
73
+ },
74
+ });
62
75
 
63
76
  // Obligation (ttl null by type default): a review request must reach the reviewer even if they're
64
77
  // offline a while — it's not a bystander notice.
@@ -11,6 +11,7 @@ import { getCompactionWatch } from "../compaction-watch";
11
11
  import { isRecord } from "agent-relay-sdk";
12
12
  import { authContextFromRequest } from "../services/auth-context";
13
13
  import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
14
+ import { emitWorkspaceTaskSemanticNotifications } from "../services/task-lifecycle";
14
15
  import { ServiceAuthError } from "../services/errors";
15
16
  import { notifyBaseWorktreeStrand, notifyBranchLandFailed, notifyBranchLanded } from "../branch-landed";
16
17
  import { applyIdleRefreshResult } from "../maintenance/workspaces";
@@ -305,6 +306,19 @@ export const patchCommand: Handler = async (req, params) => {
305
306
  mergeError: command.error ?? "merge failed",
306
307
  ...noteMergeNoProgress(current.metadata),
307
308
  });
309
+ if (nextStatus === "conflict") {
310
+ emitWorkspaceTaskSemanticNotifications({
311
+ workspace: current,
312
+ transition: "land-conflict",
313
+ actor: "workspace-merge",
314
+ occurrenceKey: `workspace-merge-conflict:${current.id}:${command.id}`,
315
+ outcome: {
316
+ reason: command.error ?? "merge failed",
317
+ commandId: command.id,
318
+ status: nextStatus,
319
+ },
320
+ });
321
+ }
308
322
  notifyBranchLandFailed({
309
323
  workspace: current,
310
324
  commandId: command.id,
@@ -76,6 +76,7 @@ export const getTaskEntities: Handler = (req) => {
76
76
  status: url.searchParams.get("status") ?? undefined,
77
77
  deliverableStage: url.searchParams.get("deliverableStage") ?? undefined,
78
78
  teamId: url.searchParams.get("teamId") ?? undefined,
79
+ createdBy: url.searchParams.get("createdBy") ?? undefined,
79
80
  limit: limitRaw ?? 100,
80
81
  }));
81
82
  };
@@ -42,6 +42,7 @@ import { applyDefaultSharedMcp, type SharedMcpSpawnOverride } from "../shared-mc
42
42
  import { autoSeedProjectForCwd } from "./project";
43
43
  import { hydrateTaskContext, projectTaskHydration } from "./task-context-hydration";
44
44
  import { recordTaskEntityDispatchDecision } from "./task-entity";
45
+ import { autoSubscribeTaskCoordinator } from "./task-semantic-events";
45
46
  import { dispatchTask } from "../task-dispatcher";
46
47
  import { hasComponentScope, isComponentAuthorizedFor } from "../security";
47
48
  import { isPathWithinBase } from "../utils";
@@ -586,6 +587,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
586
587
  requestedBy,
587
588
  ...(hydratedTask?.steer ? { steer: hydratedTask.steer } : {}),
588
589
  });
590
+ autoSubscribeTaskCoordinator(requestedBy, input.taskId);
589
591
  } catch (err) {
590
592
  console.warn(`task ${input.taskId} spawn attribution skipped: ${errMessage(err)}`);
591
593
  }
@@ -29,16 +29,17 @@ import {
29
29
  } from "../db";
30
30
  import { emitTaskChanged } from "../sse";
31
31
  import type { Task, TaskDetail, TaskEvent, TaskGateState } from "../types";
32
- import { emitTaskSemanticNotification, taskTransitionForStatus } from "./task-semantic-events";
32
+ import { removeSubscriptionsForTask } from "../db/notification-subscriptions";
33
+ import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
33
34
 
34
35
  export function createTaskEntity(input: CreateDeliverableTaskInput): TaskDetail {
35
36
  const detail = createDeliverableTask(input);
37
+ autoSubscribeTaskCoordinator(input.createdBy, detail.id);
36
38
  emitTaskChanged(detail, "task.created");
37
39
  emitTaskSemanticNotification({
38
40
  task: detail,
39
41
  transition: "created",
40
42
  actor: input.createdBy,
41
- recipients: input.createdBy ? [input.createdBy] : [],
42
43
  occurrenceKey: latestTaskEventOccurrence(detail.id, "task.created"),
43
44
  });
44
45
  return detail;
@@ -68,23 +69,27 @@ export function setTaskEntityStatus(id: number, input: SetTaskStatusInput): Task
68
69
  task: detail,
69
70
  transition,
70
71
  actor: input.actor,
71
- recipients: detail.createdBy ? [detail.createdBy] : [],
72
72
  occurrenceKey: latestTaskEventOccurrence(detail.id, `status:${detail.status}`),
73
73
  });
74
74
  }
75
+ if (isTerminalTaskStatus(detail.status)) removeSubscriptionsForTask(detail.id);
75
76
  return detail;
76
77
  }
77
78
 
79
+ function isTerminalTaskStatus(status: Task["status"]): boolean {
80
+ return status === "done" || status === "failed" || status === "canceled";
81
+ }
82
+
78
83
  export function setTaskEntityStage(id: number, input: SetTaskStageInput): TaskDetail {
79
84
  const before = getTaskDetail(id);
80
85
  const detail = setTaskStage(id, input);
81
86
  emitTaskChanged(detail, "task.stage");
82
- if (input.actor !== "task-lifecycle" && before?.deliverableStage !== "landed-on-main" && detail.deliverableStage === "landed-on-main") {
87
+ const transition = taskTransitionForStage(detail.deliverableStage);
88
+ if (input.actor !== "task-lifecycle" && transition && before?.deliverableStage !== detail.deliverableStage) {
83
89
  emitTaskSemanticNotification({
84
90
  task: detail,
85
- transition: "landed",
91
+ transition,
86
92
  actor: input.actor,
87
- recipients: detail.createdBy ? [detail.createdBy] : [],
88
93
  branch: detail.branch,
89
94
  occurrenceKey: latestTaskEventOccurrence(detail.id, `stage:${detail.deliverableStage}`),
90
95
  });
@@ -111,6 +116,15 @@ export function recordTaskEntityGate(id: number, input: RecordGateInput): { gate
111
116
  const gate = recordGate(id, input);
112
117
  const detail = getTaskDetail(id);
113
118
  if (detail) emitTaskChanged(detail, "task.gate");
119
+ if (detail && gate.status === "fail") {
120
+ emitTaskSemanticNotification({
121
+ task: detail,
122
+ transition: "gate-failed",
123
+ actor: input.actor,
124
+ occurrenceKey: latestTaskEventOccurrence(detail.id, `gate:${gate.gate}`),
125
+ outcome: { gate: gate.gate, status: gate.status, ...(gate.detail ? { detail: gate.detail } : {}) },
126
+ });
127
+ }
114
128
  return { gate, gates: detail?.gates ?? [] };
115
129
  }
116
130
 
@@ -36,7 +36,7 @@ import { requestTransientAgentShutdown } from "./transient-agent-reaper";
36
36
  import { emitRelayEvent } from "../events";
37
37
  import { routeNotification } from "../notification-router";
38
38
  import { resolveCoordinatorAgentId } from "../steward-identity";
39
- import { emitTaskSemanticNotification } from "./task-semantic-events";
39
+ import { emitTaskSemanticNotification, type TaskSemanticTransition } from "./task-semantic-events";
40
40
  import type { IssueRef, TaskDeliverableStage, TaskGateState, WorkspaceRecord } from "../types";
41
41
  import type { IssueTrackerAdapter } from "../issue-tracker";
42
42
 
@@ -179,7 +179,7 @@ function landKeyFor(input: LandedTaskLifecycleInput): string {
179
179
  // `agent.spawn_requested` attribution keyed on the worker's meta.spawnRequestId (fire-and-forget,
180
180
  // owner not yet backfilled), and the Task's recorded branch column. Deduped + ordered. Returns []
181
181
  // for an ordinary non-task branch → the land is a strict no-op (behavior byte-identical).
182
- function resolveBoundTaskIds(workspace: LifecycleWorkspace): number[] {
182
+ export function boundTaskIdsForWorkspace(workspace: LifecycleWorkspace): number[] {
183
183
  const ids = new Set<number>();
184
184
  const owner = workspace.ownerAgentId?.trim();
185
185
  if (owner) {
@@ -195,6 +195,35 @@ function resolveBoundTaskIds(workspace: LifecycleWorkspace): number[] {
195
195
  return [...ids].sort((a, b) => a - b);
196
196
  }
197
197
 
198
+ export function emitWorkspaceTaskSemanticNotifications(input: {
199
+ workspace: LifecycleWorkspace;
200
+ transition: TaskSemanticTransition;
201
+ actor?: string;
202
+ occurrenceKey?: string;
203
+ outcome?: Record<string, unknown>;
204
+ sha?: string;
205
+ }): number[] {
206
+ const emitted: number[] = [];
207
+ const coordinator = resolveCoordinatorAgentId(input.workspace.ownerAgentId ?? undefined);
208
+ for (const taskId of boundTaskIdsForWorkspace(input.workspace)) {
209
+ const task = getTaskDetail(taskId);
210
+ if (!task) continue;
211
+ emitTaskSemanticNotification({
212
+ task,
213
+ transition: input.transition,
214
+ actor: input.actor,
215
+ recipients: coordinator ? [coordinator] : [],
216
+ workspaceId: input.workspace.id,
217
+ branch: input.workspace.branch,
218
+ ...(input.sha ? { sha: input.sha } : {}),
219
+ ...(input.occurrenceKey ? { occurrenceKey: input.occurrenceKey } : {}),
220
+ ...(input.outcome ? { outcome: input.outcome } : {}),
221
+ });
222
+ emitted.push(taskId);
223
+ }
224
+ return emitted;
225
+ }
226
+
198
227
  // --- reap signal (decoupled from task-done) ----------------------------------------------------
199
228
  interface ReapResult {
200
229
  reaped: boolean;
@@ -382,7 +411,7 @@ function emitDeliverableLandedEvent(input: LandedTaskLifecycleInput, outcome: Ta
382
411
  const coordinator = resolveCoordinatorAgentId(input.workspace.ownerAgentId ?? undefined);
383
412
  emitTaskSemanticNotification({
384
413
  task,
385
- transition: "landed",
414
+ transition: "landed-on-main",
386
415
  actor: "task-lifecycle",
387
416
  recipients: coordinator ? [coordinator] : [],
388
417
  workspaceId: input.workspace.id,
@@ -401,7 +430,7 @@ function emitDeliverableErrorReceipt(input: LandedTaskLifecycleInput, outcome: T
401
430
  type: "task.landed",
402
431
  scope: {
403
432
  taskId: String(outcome.taskId),
404
- transition: "landed",
433
+ transition: "landed-on-main",
405
434
  landKey,
406
435
  ...(input.workspace.ownerAgentId ? { agentId: input.workspace.ownerAgentId, author: input.workspace.ownerAgentId } : {}),
407
436
  ...(task?.createdBy ? { parent: task.createdBy } : {}),
@@ -417,8 +446,8 @@ function emitDeliverableErrorReceipt(input: LandedTaskLifecycleInput, outcome: T
417
446
  kind: "task.lifecycle-error",
418
447
  notification: "task-lifecycle-error",
419
448
  taskId: outcome.taskId,
420
- transition: "landed",
421
- semanticEvent: "task.landed",
449
+ transition: "landed-on-main",
450
+ semanticEvent: "task.landed-on-main",
422
451
  workspaceId: input.workspace.id,
423
452
  branch: input.workspace.branch,
424
453
  ...(input.mergedSha ? { sha: input.mergedSha } : {}),
@@ -442,7 +471,7 @@ export async function handleLandedTaskLifecycle(
442
471
  const log = opts.log ?? ((message: string) => console.warn(message));
443
472
  // Non-default-base lands never drive a deliverable (mirror the issue-lifecycle guard).
444
473
  if ((input.workspace.baseRef ?? "main") !== "main") return { handled: false, reason: "non-default-branch", tasks: [] };
445
- const taskIds = resolveBoundTaskIds(input.workspace);
474
+ const taskIds = boundTaskIdsForWorkspace(input.workspace);
446
475
  if (taskIds.length === 0) return { handled: false, reason: "no-bound-task", tasks: [] };
447
476
 
448
477
  const diffSummary = landedDiffSummary(input, log);
@@ -1,30 +1,75 @@
1
1
  import { getAgent } from "../db";
2
+ import { addSubscription } from "../db/notification-subscriptions";
2
3
  import { emitRelayEvent } from "../events";
4
+ import { notifySystemMessage } from "../notify";
3
5
  import type { NotificationScope, NotificationType } from "../notification-types";
4
6
  import { routeNotification } from "../notification-router";
5
7
  import { resolveAudience } from "../notification-rules";
6
- import type { Task, TaskBlocker, TaskStatus } from "../types";
8
+ import type { Task, TaskBlocker, TaskDeliverableStage, TaskDetail, TaskIssueLink, TaskStatus } from "../types";
7
9
 
8
- export type TaskSemanticTransition = "created" | "started" | "blocked" | "needs-decision" | "landed" | "closed";
10
+ export type TaskSemanticTransition =
11
+ | KnownTaskSemanticTransition
12
+ | (string & {});
9
13
 
10
- const TASK_NOTIFICATION_TYPE: Record<TaskSemanticTransition, NotificationType> = {
14
+ type KnownTaskSemanticTransition =
15
+ | "created"
16
+ | "working"
17
+ | "blocked"
18
+ | "needs-decision"
19
+ | "gate-failed"
20
+ | "committed-on-branch"
21
+ | "review-requested"
22
+ | "land-conflict"
23
+ | "landed-on-main"
24
+ | "done"
25
+ | "deploying"
26
+ | "deployed";
27
+
28
+ export type TaskSemanticClassification = "action-required" | "status-pulse" | "suppressed";
29
+
30
+ const TASK_NOTIFICATION_TYPE: Record<KnownTaskSemanticTransition, NotificationType> = {
11
31
  created: "task.created",
12
- started: "task.started",
32
+ working: "task.working",
13
33
  blocked: "task.blocked",
14
34
  "needs-decision": "task.needs_decision",
15
- landed: "task.landed",
16
- closed: "task.closed",
35
+ "gate-failed": "task.gate_failed",
36
+ "committed-on-branch": "task.committed_on_branch",
37
+ "review-requested": "task.review_requested",
38
+ "land-conflict": "task.land_conflict",
39
+ "landed-on-main": "task.landed_on_main",
40
+ done: "task.done",
41
+ deploying: "task.deploying",
42
+ deployed: "task.deployed",
17
43
  };
44
+ const UNKNOWN_TASK_NOTIFICATION_TYPE: NotificationType = "task.working";
45
+ export const TASK_SEMANTIC_NOTIFICATION_TYPES = Object.values(TASK_NOTIFICATION_TYPE);
18
46
 
19
47
  export function taskTransitionForStatus(status: TaskStatus, blockers: TaskBlocker[] | undefined): TaskSemanticTransition | null {
20
- if (status === "claimed" || status === "in_progress") return "started";
48
+ if (status === "claimed" || status === "in_progress") return "working";
21
49
  if (status === "blocked") {
22
50
  return blockers?.some((blocker) => blocker.type === "decision") ? "needs-decision" : "blocked";
23
51
  }
24
- if (status === "done" || status === "failed" || status === "canceled") return "closed";
52
+ if (status === "done") return "done";
53
+ if (status === "failed") return "gate-failed";
54
+ return null;
55
+ }
56
+
57
+ export function taskTransitionForStage(stage: TaskDeliverableStage | undefined): TaskSemanticTransition | null {
58
+ if (stage === "committed-on-branch") return "committed-on-branch";
59
+ if (stage === "landed-on-main") return "landed-on-main";
60
+ if (stage === "deploying") return "deploying";
61
+ if (stage === "deployed") return "deployed";
25
62
  return null;
26
63
  }
27
64
 
65
+ export function classifyTaskSemanticTransition(transition: TaskSemanticTransition): TaskSemanticClassification {
66
+ if (transition === "blocked" || transition === "needs-decision" || transition === "gate-failed" || transition === "review-requested" || transition === "land-conflict") {
67
+ return "action-required";
68
+ }
69
+ if (transition === "created") return "suppressed";
70
+ return "status-pulse";
71
+ }
72
+
28
73
  export interface TaskSemanticNotificationInput {
29
74
  task: Task;
30
75
  transition: TaskSemanticTransition;
@@ -38,15 +83,55 @@ export interface TaskSemanticNotificationInput {
38
83
  outcome?: Record<string, unknown>;
39
84
  }
40
85
 
86
+ const TASK_STATUS_PULSE_BATCH_MS = 1000;
87
+ const TASK_NOTIFICATION_TRANSITIONS = Object.keys(TASK_NOTIFICATION_TYPE) as KnownTaskSemanticTransition[];
88
+
89
+ interface StatusPulseDigestItem {
90
+ taskId: number;
91
+ title: string;
92
+ transition: TaskSemanticTransition;
93
+ classification: "status-pulse";
94
+ issue?: Pick<TaskIssueLink, "provider" | "repo" | "externalKey" | "url" | "title">;
95
+ branch?: string;
96
+ sha?: string;
97
+ outcome?: Record<string, unknown>;
98
+ occurredAt: number;
99
+ }
100
+
101
+ interface StatusPulseBatch {
102
+ timer?: ReturnType<typeof setTimeout>;
103
+ items: StatusPulseDigestItem[];
104
+ }
105
+
106
+ const statusPulseBatches = new Map<string, StatusPulseBatch>();
107
+
108
+ export function autoSubscribeTaskCoordinator(agentId: string | undefined, taskId: number): boolean {
109
+ const cleaned = agentId?.trim();
110
+ if (!cleaned || !getAgent(cleaned)) return false;
111
+ for (const transition of TASK_NOTIFICATION_TRANSITIONS) {
112
+ addSubscription({
113
+ agentId: cleaned,
114
+ type: TASK_NOTIFICATION_TYPE[transition],
115
+ mode: "opt_in",
116
+ scopeMatch: { taskId: String(taskId) },
117
+ });
118
+ }
119
+ return true;
120
+ }
121
+
41
122
  export function emitTaskSemanticNotification(input: TaskSemanticNotificationInput): void {
42
- const type = TASK_NOTIFICATION_TYPE[input.transition];
123
+ const type = TASK_NOTIFICATION_TYPE[input.transition as KnownTaskSemanticTransition] ?? UNKNOWN_TASK_NOTIFICATION_TYPE;
43
124
  const scope = taskNotificationScope(input);
44
125
  const semanticEvent = `task.${input.transition}`;
126
+ const classification = classifyTaskSemanticTransition(input.transition);
127
+ const issue = primaryIssue(input.task);
45
128
  const eventData = {
46
129
  taskId: input.task.id,
47
130
  transition: input.transition,
131
+ classification,
48
132
  semanticEvent,
49
133
  title: input.task.title,
134
+ ...(issue ? { issue } : {}),
50
135
  status: input.task.status,
51
136
  deliverableStage: input.task.deliverableStage,
52
137
  ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
@@ -72,7 +157,24 @@ export function emitTaskSemanticNotification(input: TaskSemanticNotificationInpu
72
157
  .map((agentId) => ({ agentId, matchedRule: "task-stakeholder" }));
73
158
 
74
159
  const recipients = [...subscriptionAudience, ...explicit];
75
- if (recipients.length === 0) return;
160
+ if (recipients.length === 0 || classification === "suppressed") return;
161
+
162
+ if (classification === "status-pulse") {
163
+ for (const recipient of dedupeRecipients(recipients)) {
164
+ enqueueStatusPulse(recipient.agentId, {
165
+ taskId: input.task.id,
166
+ title: input.task.title,
167
+ transition: input.transition,
168
+ classification,
169
+ ...(issue ? { issue } : {}),
170
+ ...(input.branch ? { branch: input.branch } : {}),
171
+ ...(input.sha ? { sha: input.sha } : {}),
172
+ ...(input.outcome ? { outcome: input.outcome } : {}),
173
+ occurredAt: Date.now(),
174
+ });
175
+ }
176
+ return;
177
+ }
76
178
 
77
179
  routeNotification({
78
180
  type,
@@ -85,9 +187,13 @@ export function emitTaskSemanticNotification(input: TaskSemanticNotificationInpu
85
187
  payload: {
86
188
  kind: "task.transition",
87
189
  notification: "task-transition",
190
+ classification,
191
+ actionRequired: true,
88
192
  taskId: input.task.id,
193
+ ...(issue ? { issue } : {}),
89
194
  transition: input.transition,
90
195
  semanticEvent,
196
+ needed: taskNotificationNeeded(input),
91
197
  ...(input.outcome ? { outcome: input.outcome } : {}),
92
198
  ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
93
199
  ...(input.branch ? { branch: input.branch } : {}),
@@ -99,6 +205,82 @@ export function emitTaskSemanticNotification(input: TaskSemanticNotificationInpu
99
205
  });
100
206
  }
101
207
 
208
+ function dedupeRecipients(recipients: Array<{ agentId: string; matchedRule: string }>): Array<{ agentId: string; matchedRule: string }> {
209
+ const seen = new Set<string>();
210
+ const out: Array<{ agentId: string; matchedRule: string }> = [];
211
+ for (const recipient of recipients) {
212
+ if (seen.has(recipient.agentId)) continue;
213
+ seen.add(recipient.agentId);
214
+ out.push(recipient);
215
+ }
216
+ return out;
217
+ }
218
+
219
+ function enqueueStatusPulse(agentId: string, item: StatusPulseDigestItem): void {
220
+ const batch = statusPulseBatches.get(agentId) ?? { items: [] };
221
+ batch.items.push(item);
222
+ if (!batch.timer) {
223
+ batch.timer = setTimeout(() => flushTaskStatusPulses(agentId), TASK_STATUS_PULSE_BATCH_MS);
224
+ }
225
+ statusPulseBatches.set(agentId, batch);
226
+ }
227
+
228
+ export function flushTaskStatusPulses(agentId?: string): void {
229
+ const targets = agentId ? [agentId] : [...statusPulseBatches.keys()];
230
+ for (const target of targets) {
231
+ const batch = statusPulseBatches.get(target);
232
+ if (!batch) continue;
233
+ if (batch.timer) clearTimeout(batch.timer);
234
+ statusPulseBatches.delete(target);
235
+ if (batch.items.length === 0) continue;
236
+ const items = compactStatusPulseItems(batch.items);
237
+ notifySystemMessage(target, {
238
+ subject: `Task status pulse (${items.length})`,
239
+ body: taskStatusPulseBody(items),
240
+ payload: {
241
+ kind: "task.status-pulse",
242
+ notification: "task-status-pulse",
243
+ classification: "status-pulse",
244
+ actionRequired: false,
245
+ taskIds: [...new Set(items.map((item) => item.taskId))],
246
+ events: items.map((item) => ({
247
+ taskId: item.taskId,
248
+ title: item.title,
249
+ transition: item.transition,
250
+ classification: item.classification,
251
+ ...(item.issue ? { issue: item.issue } : {}),
252
+ ...(item.branch ? { branch: item.branch } : {}),
253
+ ...(item.sha ? { sha: item.sha } : {}),
254
+ ...(item.outcome ? { outcome: item.outcome } : {}),
255
+ occurredAt: item.occurredAt,
256
+ })),
257
+ },
258
+ idempotencyKey: `task:status-pulse:${target}:${items.map((item) => `${item.taskId}:${item.transition}:${item.occurredAt}`).join("|")}`,
259
+ replyExpected: false,
260
+ maxAgeSeconds: null,
261
+ });
262
+ }
263
+ }
264
+
265
+ function compactStatusPulseItems(items: StatusPulseDigestItem[]): StatusPulseDigestItem[] {
266
+ const byKey = new Map<string, StatusPulseDigestItem>();
267
+ for (const item of items) byKey.set(`${item.taskId}:${item.transition}`, item);
268
+ return [...byKey.values()].sort((a, b) => a.occurredAt - b.occurredAt);
269
+ }
270
+
271
+ function taskStatusPulseBody(items: StatusPulseDigestItem[]): string {
272
+ const lines = ["Task status pulse:"];
273
+ for (const item of items) {
274
+ const bits = [`#${item.taskId}`, item.transition, item.title];
275
+ const issue = issueLabel(item.issue);
276
+ if (issue) bits.push(issue);
277
+ if (item.branch) bits.push(`branch ${item.branch}`);
278
+ if (item.sha) bits.push(`sha ${item.sha.slice(0, 12)}`);
279
+ lines.push(`- ${bits.join(" - ")}`);
280
+ }
281
+ return lines.join("\n");
282
+ }
283
+
102
284
  function taskNotificationIdempotencyKey(input: TaskSemanticNotificationInput): string | undefined {
103
285
  if (input.landKey) return `task:${input.task.id}:transition:${input.transition}:${input.landKey}`;
104
286
  if (input.occurrenceKey) return `task:${input.task.id}:transition:${input.transition}:occurrence:${input.occurrenceKey}`;
@@ -125,13 +307,59 @@ function taskNotificationSubject(input: TaskSemanticNotificationInput): string {
125
307
  }
126
308
 
127
309
  function taskNotificationBody(input: TaskSemanticNotificationInput): string {
128
- if (input.transition === "landed") {
129
- const bits = [`Task #${input.task.id} landed: ${input.task.title}`];
310
+ const issue = issueLabel(primaryIssue(input.task));
311
+ if (input.transition === "landed-on-main") {
312
+ const bits = [`Task #${input.task.id} landed on main: ${input.task.title}`];
130
313
  if (input.sha) bits.push(`sha ${input.sha.slice(0, 12)}`);
131
314
  const gatesPassed = input.outcome?.gatesPassed;
132
315
  if (typeof gatesPassed === "boolean") bits.push(gatesPassed ? "gates passed" : "gates not verified");
133
316
  if (input.outcome?.statusAdvanced === true) bits.push("closed");
317
+ if (issue) bits.push(issue);
134
318
  return bits.join("; ");
135
319
  }
136
- return `Task #${input.task.id} ${input.transition}: ${input.task.title}`;
320
+ const needed = taskNotificationNeeded(input);
321
+ return `Task #${input.task.id} ${input.transition}: ${input.task.title}${issue ? ` (${issue})` : ""}${needed ? `\nNeeded: ${needed}` : ""}`;
322
+ }
323
+
324
+ function taskNotificationNeeded(input: TaskSemanticNotificationInput): string {
325
+ if (input.transition === "needs-decision") return "coordinator decision";
326
+ if (input.transition === "blocked") return blockedReason(input.task) ?? "unblock the worker";
327
+ if (input.transition === "gate-failed") return gateFailureReason(input.outcome) ?? "inspect failed gate";
328
+ if (input.transition === "review-requested") return "review requested";
329
+ if (input.transition === "land-conflict") return "land conflict needs resolution";
330
+ return "";
331
+ }
332
+
333
+ function blockedReason(task: Task): string | undefined {
334
+ const blockers = task.blockedBy ?? [];
335
+ if (blockers.length === 0) return undefined;
336
+ return blockers
337
+ .map((blocker) => [blocker.type, blocker.ref, blocker.note].filter(Boolean).join(": "))
338
+ .join("; ");
339
+ }
340
+
341
+ function gateFailureReason(outcome: Record<string, unknown> | undefined): string | undefined {
342
+ const gate = typeof outcome?.gate === "string" ? outcome.gate : undefined;
343
+ const detail = typeof outcome?.detail === "string" ? outcome.detail : undefined;
344
+ if (gate && detail) return `${gate} failed: ${detail}`;
345
+ if (gate) return `${gate} failed`;
346
+ return detail;
347
+ }
348
+
349
+ function primaryIssue(task: Task): StatusPulseDigestItem["issue"] | undefined {
350
+ const issues = (task as Partial<TaskDetail>).issues;
351
+ const issue = Array.isArray(issues) ? issues[0] : undefined;
352
+ if (!issue) return undefined;
353
+ return {
354
+ provider: issue.provider,
355
+ repo: issue.repo,
356
+ externalKey: issue.externalKey,
357
+ url: issue.url,
358
+ ...(issue.title ? { title: issue.title } : {}),
359
+ };
360
+ }
361
+
362
+ function issueLabel(issue: StatusPulseDigestItem["issue"] | undefined): string {
363
+ if (!issue) return "";
364
+ return `${issue.repo}#${issue.externalKey}`;
137
365
  }