agent-relay-server 0.110.0 → 0.112.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 (83) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-CmqJMGYF.js → MessageBubble-DtLlfBAK.js} +2 -2
  4. package/public/assets/{MessageBubble-CmqJMGYF.js.map → MessageBubble-DtLlfBAK.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-pcKJ4q_4.js +2 -0
  6. package/public/assets/PlanGraphPanel-pcKJ4q_4.js.map +1 -0
  7. package/public/assets/{activity-DyqKrJe_.js → activity-DRAeL0Jq.js} +2 -2
  8. package/public/assets/{activity-DyqKrJe_.js.map → activity-DRAeL0Jq.js.map} +1 -1
  9. package/public/assets/{agent-profiles-DRQ5vbPo.js → agent-profiles-cnWPANzx.js} +2 -2
  10. package/public/assets/{agent-profiles-DRQ5vbPo.js.map → agent-profiles-cnWPANzx.js.map} +1 -1
  11. package/public/assets/{agents-BJ9sm23O.js → agents-DgUjmk_D.js} +2 -2
  12. package/public/assets/{agents-BJ9sm23O.js.map → agents-DgUjmk_D.js.map} +1 -1
  13. package/public/assets/{automation-CJ2-uiYt.js → automation-CuTlgY1E.js} +2 -2
  14. package/public/assets/{automation-CJ2-uiYt.js.map → automation-CuTlgY1E.js.map} +1 -1
  15. package/public/assets/{chat-D0VtHShz.js → chat-C7XCmaZz.js} +2 -2
  16. package/public/assets/{chat-D0VtHShz.js.map → chat-C7XCmaZz.js.map} +1 -1
  17. package/public/assets/{formatted-body-impl-ChCxpVtI.js → formatted-body-impl-rLDPgdmJ.js} +2 -2
  18. package/public/assets/{formatted-body-impl-ChCxpVtI.js.map → formatted-body-impl-rLDPgdmJ.js.map} +1 -1
  19. package/public/assets/{index-BIRtwnUI.js → index-DGyeTLPg.js} +5 -5
  20. package/public/assets/index-DGyeTLPg.js.map +1 -0
  21. package/public/assets/{insights-BGuB8DWZ.js → insights-C0dDL4El.js} +2 -2
  22. package/public/assets/{insights-BGuB8DWZ.js.map → insights-C0dDL4El.js.map} +1 -1
  23. package/public/assets/{maintenance-BFyuZuv5.js → maintenance-DkOkcEkb.js} +2 -2
  24. package/public/assets/{maintenance-BFyuZuv5.js.map → maintenance-DkOkcEkb.js.map} +1 -1
  25. package/public/assets/{managed-agents-BIXZUkrA.js → managed-agents-C7Czinaz.js} +2 -2
  26. package/public/assets/{managed-agents-BIXZUkrA.js.map → managed-agents-C7Czinaz.js.map} +1 -1
  27. package/public/assets/{markdown-preview-impl-DTI--VmH.js → markdown-preview-impl-BZ7sigEc.js} +2 -2
  28. package/public/assets/{markdown-preview-impl-DTI--VmH.js.map → markdown-preview-impl-BZ7sigEc.js.map} +1 -1
  29. package/public/assets/{memory-OoM2E30h.js → memory-Bys-M7GK.js} +2 -2
  30. package/public/assets/{memory-OoM2E30h.js.map → memory-Bys-M7GK.js.map} +1 -1
  31. package/public/assets/{messages-BHvC3SNR.js → messages-COSJ6trN.js} +2 -2
  32. package/public/assets/{messages-BHvC3SNR.js.map → messages-COSJ6trN.js.map} +1 -1
  33. package/public/assets/{orchestrators-9pzQqf6-.js → orchestrators-BjLl1Z0L.js} +2 -2
  34. package/public/assets/{orchestrators-9pzQqf6-.js.map → orchestrators-BjLl1Z0L.js.map} +1 -1
  35. package/public/assets/{overview-L8iYOYmR.js → overview-BmU7twic.js} +2 -2
  36. package/public/assets/{overview-L8iYOYmR.js.map → overview-BmU7twic.js.map} +1 -1
  37. package/public/assets/{pairs-BCejxa6_.js → pairs-xclXjw-c.js} +2 -2
  38. package/public/assets/{pairs-BCejxa6_.js.map → pairs-xclXjw-c.js.map} +1 -1
  39. package/public/assets/{prompt-settings-D9y9hhSy.js → prompt-settings-CtQd0nz0.js} +2 -2
  40. package/public/assets/{prompt-settings-D9y9hhSy.js.map → prompt-settings-CtQd0nz0.js.map} +1 -1
  41. package/public/assets/{registry-BwBg64Vj.js → registry-CDe6vjKI.js} +2 -2
  42. package/public/assets/{registry-BwBg64Vj.js.map → registry-CDe6vjKI.js.map} +1 -1
  43. package/public/assets/{security-upd2zf8l.js → security-C9mBNQMZ.js} +2 -2
  44. package/public/assets/{security-upd2zf8l.js.map → security-C9mBNQMZ.js.map} +1 -1
  45. package/public/assets/{select-Cu55Vi-R.js → select-BJDKQk4q.js} +2 -2
  46. package/public/assets/{select-Cu55Vi-R.js.map → select-BJDKQk4q.js.map} +1 -1
  47. package/public/assets/{settings-Dk5iNqCZ.js → settings-BmZjWJkI.js} +2 -2
  48. package/public/assets/{settings-Dk5iNqCZ.js.map → settings-BmZjWJkI.js.map} +1 -1
  49. package/public/assets/{tasks-BoDuvHnZ.js → tasks-DNjZKSM2.js} +2 -2
  50. package/public/assets/tasks-DNjZKSM2.js.map +1 -0
  51. package/public/assets/{teams-C6Pn-gpw.js → teams-KNzmdolE.js} +2 -2
  52. package/public/assets/{teams-C6Pn-gpw.js.map → teams-KNzmdolE.js.map} +1 -1
  53. package/public/assets/{terminal-viewer-impl-afW96xEF.js → terminal-viewer-impl-DnFJDl9N.js} +2 -2
  54. package/public/assets/{terminal-viewer-impl-afW96xEF.js.map → terminal-viewer-impl-DnFJDl9N.js.map} +1 -1
  55. package/public/assets/{user-avatar-tSPnqEk5.js → user-avatar-C8sbjQke.js} +2 -2
  56. package/public/assets/{user-avatar-tSPnqEk5.js.map → user-avatar-C8sbjQke.js.map} +1 -1
  57. package/public/assets/{work-queue-DP3UdDwC.js → work-queue-DINvPVPn.js} +2 -2
  58. package/public/assets/{work-queue-DP3UdDwC.js.map → work-queue-DINvPVPn.js.map} +1 -1
  59. package/public/assets/{workspaces-Dt2xndG4.js → workspaces-CSNdUg15.js} +2 -2
  60. package/public/assets/{workspaces-Dt2xndG4.js.map → workspaces-CSNdUg15.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/mcp-task-tools.ts +2 -1
  68. package/src/notification-rules.ts +17 -1
  69. package/src/notification-types.ts +18 -0
  70. package/src/reviewer-pipeline.ts +13 -0
  71. package/src/routes/commands.ts +14 -0
  72. package/src/routes/tasks.ts +1 -0
  73. package/src/services/spawn-agent.ts +2 -0
  74. package/src/services/spawn-registration-watchdog.ts +5 -5
  75. package/src/services/task-done-when.ts +21 -0
  76. package/src/services/task-entity.ts +30 -7
  77. package/src/services/task-lifecycle.ts +45 -46
  78. package/src/services/task-semantic-events.ts +241 -13
  79. package/src/workspace-auto-merge.ts +17 -0
  80. package/public/assets/PlanGraphPanel-K6aFJmdd.js +0 -2
  81. package/public/assets/PlanGraphPanel-K6aFJmdd.js.map +0 -1
  82. package/public/assets/index-BIRtwnUI.js.map +0 -1
  83. package/public/assets/tasks-BoDuvHnZ.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
  {
@@ -121,7 +121,8 @@ function commitArg(value: unknown): TaskCommitRef | undefined {
121
121
  }
122
122
 
123
123
  export function relayTaskTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
124
- const actor = authContextFromMcp(auth).actor.id;
124
+ const ctx = authContextFromMcp(auth);
125
+ const actor = ctx.callerAgentId ?? ctx.actor.id;
125
126
  if (!isRecord(args)) throw new ValidationError("arguments must be an object");
126
127
 
127
128
  if (name === "relay_task_create") {
@@ -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
  }
@@ -9,7 +9,7 @@ interface SpawnCommandRow {
9
9
  id: string;
10
10
  params: string;
11
11
  correlation_id: string | null;
12
- created_at: number;
12
+ updated_at: number;
13
13
  }
14
14
 
15
15
  interface SpawnRegistrationWatchdogResult extends Record<string, unknown> {
@@ -26,12 +26,12 @@ export function reapUnregisteredSpawnCommands(
26
26
  const cutoff = now - deadlineMs;
27
27
  const notifyCutoff = now - notifyMaxAgeMs;
28
28
  const rows = getDb().query(`
29
- SELECT id, params, correlation_id, created_at
29
+ SELECT id, params, correlation_id, updated_at
30
30
  FROM commands
31
31
  WHERE type = 'agent.spawn'
32
32
  AND status = 'succeeded'
33
- AND created_at <= ?
34
- ORDER BY created_at ASC
33
+ AND updated_at <= ?
34
+ ORDER BY updated_at ASC
35
35
  `).all(cutoff) as SpawnCommandRow[];
36
36
 
37
37
  const failedSpawnCommandIds: string[] = [];
@@ -48,7 +48,7 @@ export function reapUnregisteredSpawnCommands(
48
48
  const failed = updateCommand(row.id, { status: "failed", error: reason });
49
49
  if (!failed || failed.status !== "failed") continue;
50
50
  failedSpawnCommandIds.push(failed.id);
51
- if (row.created_at >= notifyCutoff) {
51
+ if (row.updated_at >= notifyCutoff) {
52
52
  notifiedSpawnCommandIds.push(failed.id);
53
53
  notifyAgentSpawnFailed({
54
54
  parent,
@@ -0,0 +1,21 @@
1
+ import { TASK_DELIVERABLE_STAGES } from "agent-relay-sdk";
2
+ import type { TaskDeliverableStage } from "../types";
3
+
4
+ const STAGE_INDEX = new Map<string, number>(TASK_DELIVERABLE_STAGES.map((stage, i) => [stage, i]));
5
+
6
+ // `doneWhen` is free text, but these lifecycle tokens have durable semantics.
7
+ export function doneWhenTargetStage(doneWhen: string | undefined): TaskDeliverableStage {
8
+ const value = doneWhen?.trim().toLowerCase();
9
+ if (!value) return "landed-on-main";
10
+ if (value === "deliverable-attached" || value.includes("deliverable attached")) return "committed-on-branch";
11
+ if (value === "landed" || value === "landed-on-main" || value.includes("landed")) return "landed-on-main";
12
+ if (value.includes("deploying")) return "deploying";
13
+ if (value.includes("deploy")) return "deployed";
14
+ return "landed-on-main";
15
+ }
16
+
17
+ export function stageSatisfiesDoneWhen(stage: TaskDeliverableStage, doneWhen: string | undefined): boolean {
18
+ const current = STAGE_INDEX.get(stage) ?? 0;
19
+ const target = STAGE_INDEX.get(doneWhenTargetStage(doneWhen)) ?? STAGE_INDEX.get("landed-on-main")!;
20
+ return current >= target;
21
+ }
@@ -29,16 +29,18 @@ 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 { stageSatisfiesDoneWhen } from "./task-done-when";
34
+ import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
33
35
 
34
36
  export function createTaskEntity(input: CreateDeliverableTaskInput): TaskDetail {
35
37
  const detail = createDeliverableTask(input);
38
+ autoSubscribeTaskCoordinator(input.createdBy, detail.id);
36
39
  emitTaskChanged(detail, "task.created");
37
40
  emitTaskSemanticNotification({
38
41
  task: detail,
39
42
  transition: "created",
40
43
  actor: input.createdBy,
41
- recipients: input.createdBy ? [input.createdBy] : [],
42
44
  occurrenceKey: latestTaskEventOccurrence(detail.id, "task.created"),
43
45
  });
44
46
  return detail;
@@ -68,27 +70,39 @@ export function setTaskEntityStatus(id: number, input: SetTaskStatusInput): Task
68
70
  task: detail,
69
71
  transition,
70
72
  actor: input.actor,
71
- recipients: detail.createdBy ? [detail.createdBy] : [],
72
73
  occurrenceKey: latestTaskEventOccurrence(detail.id, `status:${detail.status}`),
73
74
  });
74
75
  }
76
+ if (isTerminalTaskStatus(detail.status)) removeSubscriptionsForTask(detail.id);
75
77
  return detail;
76
78
  }
77
79
 
80
+ function isTerminalTaskStatus(status: Task["status"]): boolean {
81
+ return status === "done" || status === "failed" || status === "canceled";
82
+ }
83
+
78
84
  export function setTaskEntityStage(id: number, input: SetTaskStageInput): TaskDetail {
79
85
  const before = getTaskDetail(id);
80
- const detail = setTaskStage(id, input);
86
+ let detail = setTaskStage(id, input);
81
87
  emitTaskChanged(detail, "task.stage");
82
- if (input.actor !== "task-lifecycle" && before?.deliverableStage !== "landed-on-main" && detail.deliverableStage === "landed-on-main") {
88
+ const transition = taskTransitionForStage(detail.deliverableStage);
89
+ if (input.actor !== "task-lifecycle" && transition && before?.deliverableStage !== detail.deliverableStage) {
83
90
  emitTaskSemanticNotification({
84
91
  task: detail,
85
- transition: "landed",
92
+ transition,
86
93
  actor: input.actor,
87
- recipients: detail.createdBy ? [detail.createdBy] : [],
88
94
  branch: detail.branch,
89
95
  occurrenceKey: latestTaskEventOccurrence(detail.id, `stage:${detail.deliverableStage}`),
90
96
  });
91
97
  }
98
+ const deliverableStage = detail.deliverableStage ?? "none";
99
+ if (detail.status !== "done" && stageSatisfiesDoneWhen(deliverableStage, detail.doneWhen)) {
100
+ detail = setTaskEntityStatus(id, {
101
+ status: "done",
102
+ note: `doneWhen reached at stage ${deliverableStage}`,
103
+ actor: input.actor,
104
+ });
105
+ }
92
106
  return detail;
93
107
  }
94
108
 
@@ -111,6 +125,15 @@ export function recordTaskEntityGate(id: number, input: RecordGateInput): { gate
111
125
  const gate = recordGate(id, input);
112
126
  const detail = getTaskDetail(id);
113
127
  if (detail) emitTaskChanged(detail, "task.gate");
128
+ if (detail && gate.status === "fail") {
129
+ emitTaskSemanticNotification({
130
+ task: detail,
131
+ transition: "gate-failed",
132
+ actor: input.actor,
133
+ occurrenceKey: latestTaskEventOccurrence(detail.id, `gate:${gate.gate}`),
134
+ outcome: { gate: gate.gate, status: gate.status, ...(gate.detail ? { detail: gate.detail } : {}) },
135
+ });
136
+ }
114
137
  return { gate, gates: detail?.gates ?? [] };
115
138
  }
116
139
 
@@ -21,7 +21,7 @@
21
21
  // owns execution telemetry. Issue CLOSE is the canonical #482/#474 path (which runs just before
22
22
  // this in `notifyBranchLanded`) — we do NOT re-close; we PROJECT it via #833's `taskCloseBridge`.
23
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";
24
+ import { errMessage } from "agent-relay-sdk";
25
25
  import {
26
26
  deliverableTaskIdsByBranch,
27
27
  deliverableTaskIdsByOwnerAgent,
@@ -30,13 +30,14 @@ import {
30
30
  getTaskDetail,
31
31
  markTaskAgentReaped,
32
32
  } from "../db";
33
- import { setTaskEntityStage, setTaskEntityStatus } from "./task-entity";
33
+ import { setTaskEntityStage } from "./task-entity";
34
34
  import { postTaskMilestoneComment, taskCloseBridge, type TaskCloseBridgeEntry } from "./task-issue-weave";
35
35
  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 { stageSatisfiesDoneWhen } from "./task-done-when";
40
+ import { emitTaskSemanticNotification, type TaskSemanticTransition } from "./task-semantic-events";
40
41
  import type { IssueRef, TaskDeliverableStage, TaskGateState, WorkspaceRecord } from "../types";
41
42
  import type { IssueTrackerAdapter } from "../issue-tracker";
42
43
 
@@ -120,33 +121,12 @@ function landedDiffSummary(input: LandedTaskLifecycleInput, log: LogFn): string
120
121
  }
121
122
  }
122
123
 
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
124
  // --- gate verdict ------------------------------------------------------------------------------
145
- // "Gates passed" is the signal that frees the agent (reap) AND closes the Task (doneWhen). The
125
+ // "Gates passed" is the signal that frees the agent (reap). The
146
126
  // merge landing green is necessary but NOT sufficient (Codex #831 review): a worker that crashed
147
127
  // before reporting gates, or reported only a subset, must NOT read as green on a vacuous `[]`.
148
128
  // 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.
129
+ // empty / missing / partial / failed ledger is NOT passed → no reap, no green report.
150
130
  export const EXPECTED_GATES = ["typecheck", "test"] as const;
151
131
 
152
132
  interface GateVerdict {
@@ -179,7 +159,7 @@ function landKeyFor(input: LandedTaskLifecycleInput): string {
179
159
  // `agent.spawn_requested` attribution keyed on the worker's meta.spawnRequestId (fire-and-forget,
180
160
  // owner not yet backfilled), and the Task's recorded branch column. Deduped + ordered. Returns []
181
161
  // for an ordinary non-task branch → the land is a strict no-op (behavior byte-identical).
182
- function resolveBoundTaskIds(workspace: LifecycleWorkspace): number[] {
162
+ export function boundTaskIdsForWorkspace(workspace: LifecycleWorkspace): number[] {
183
163
  const ids = new Set<number>();
184
164
  const owner = workspace.ownerAgentId?.trim();
185
165
  if (owner) {
@@ -195,6 +175,35 @@ function resolveBoundTaskIds(workspace: LifecycleWorkspace): number[] {
195
175
  return [...ids].sort((a, b) => a - b);
196
176
  }
197
177
 
178
+ export function emitWorkspaceTaskSemanticNotifications(input: {
179
+ workspace: LifecycleWorkspace;
180
+ transition: TaskSemanticTransition;
181
+ actor?: string;
182
+ occurrenceKey?: string;
183
+ outcome?: Record<string, unknown>;
184
+ sha?: string;
185
+ }): number[] {
186
+ const emitted: number[] = [];
187
+ const coordinator = resolveCoordinatorAgentId(input.workspace.ownerAgentId ?? undefined);
188
+ for (const taskId of boundTaskIdsForWorkspace(input.workspace)) {
189
+ const task = getTaskDetail(taskId);
190
+ if (!task) continue;
191
+ emitTaskSemanticNotification({
192
+ task,
193
+ transition: input.transition,
194
+ actor: input.actor,
195
+ recipients: coordinator ? [coordinator] : [],
196
+ workspaceId: input.workspace.id,
197
+ branch: input.workspace.branch,
198
+ ...(input.sha ? { sha: input.sha } : {}),
199
+ ...(input.occurrenceKey ? { occurrenceKey: input.occurrenceKey } : {}),
200
+ ...(input.outcome ? { outcome: input.outcome } : {}),
201
+ });
202
+ emitted.push(taskId);
203
+ }
204
+ return emitted;
205
+ }
206
+
198
207
  // --- reap signal (decoupled from task-done) ----------------------------------------------------
199
208
  interface ReapResult {
200
209
  reaped: boolean;
@@ -281,20 +290,10 @@ async function driveTaskOnLand(
281
290
  const gatesPassed = gateVerdict.passed;
282
291
 
283
292
  // 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.
293
+ // done). Gates are intentionally separate: they guard reaping and green reporting, not the
294
+ // execution status completion predicate. Otherwise empty gates can strand a landed Task forever.
288
295
  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
- }
296
+ const statusAdvanced = before.status !== "done" && detail.status === "done";
298
297
 
299
298
  // 3b (close projection) — the linked issue is closed by the canonical #482/#474 path (which ran
300
299
  // immediately before this in notifyBranchLanded). We PROJECT that ledger via #833, never re-close.
@@ -312,7 +311,7 @@ async function driveTaskOnLand(
312
311
  const summaryBits = [`Landed to \`${input.workspace.baseRef ?? "main"}\``];
313
312
  if (diffSummary) summaryBits.push(diffSummary);
314
313
  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)`);
314
+ summaryBits.push(doneWhenSatisfied ? "Task done (doneWhen reached)" : `Task at \`landed-on-main\` (doneWhen \`${before.doneWhen ?? "landed"}\` not yet reached)`);
316
315
  const milestone = await postTaskMilestoneComment(
317
316
  {
318
317
  taskId,
@@ -382,7 +381,7 @@ function emitDeliverableLandedEvent(input: LandedTaskLifecycleInput, outcome: Ta
382
381
  const coordinator = resolveCoordinatorAgentId(input.workspace.ownerAgentId ?? undefined);
383
382
  emitTaskSemanticNotification({
384
383
  task,
385
- transition: "landed",
384
+ transition: "landed-on-main",
386
385
  actor: "task-lifecycle",
387
386
  recipients: coordinator ? [coordinator] : [],
388
387
  workspaceId: input.workspace.id,
@@ -401,7 +400,7 @@ function emitDeliverableErrorReceipt(input: LandedTaskLifecycleInput, outcome: T
401
400
  type: "task.landed",
402
401
  scope: {
403
402
  taskId: String(outcome.taskId),
404
- transition: "landed",
403
+ transition: "landed-on-main",
405
404
  landKey,
406
405
  ...(input.workspace.ownerAgentId ? { agentId: input.workspace.ownerAgentId, author: input.workspace.ownerAgentId } : {}),
407
406
  ...(task?.createdBy ? { parent: task.createdBy } : {}),
@@ -417,8 +416,8 @@ function emitDeliverableErrorReceipt(input: LandedTaskLifecycleInput, outcome: T
417
416
  kind: "task.lifecycle-error",
418
417
  notification: "task-lifecycle-error",
419
418
  taskId: outcome.taskId,
420
- transition: "landed",
421
- semanticEvent: "task.landed",
419
+ transition: "landed-on-main",
420
+ semanticEvent: "task.landed-on-main",
422
421
  workspaceId: input.workspace.id,
423
422
  branch: input.workspace.branch,
424
423
  ...(input.mergedSha ? { sha: input.mergedSha } : {}),
@@ -442,7 +441,7 @@ export async function handleLandedTaskLifecycle(
442
441
  const log = opts.log ?? ((message: string) => console.warn(message));
443
442
  // Non-default-base lands never drive a deliverable (mirror the issue-lifecycle guard).
444
443
  if ((input.workspace.baseRef ?? "main") !== "main") return { handled: false, reason: "non-default-branch", tasks: [] };
445
- const taskIds = resolveBoundTaskIds(input.workspace);
444
+ const taskIds = boundTaskIdsForWorkspace(input.workspace);
446
445
  if (taskIds.length === 0) return { handled: false, reason: "no-bound-task", tasks: [] };
447
446
 
448
447
  const diffSummary = landedDiffSummary(input, log);