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.
- package/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-CmqJMGYF.js → MessageBubble-DtLlfBAK.js} +2 -2
- package/public/assets/{MessageBubble-CmqJMGYF.js.map → MessageBubble-DtLlfBAK.js.map} +1 -1
- package/public/assets/PlanGraphPanel-pcKJ4q_4.js +2 -0
- package/public/assets/PlanGraphPanel-pcKJ4q_4.js.map +1 -0
- package/public/assets/{activity-DyqKrJe_.js → activity-DRAeL0Jq.js} +2 -2
- package/public/assets/{activity-DyqKrJe_.js.map → activity-DRAeL0Jq.js.map} +1 -1
- package/public/assets/{agent-profiles-DRQ5vbPo.js → agent-profiles-cnWPANzx.js} +2 -2
- package/public/assets/{agent-profiles-DRQ5vbPo.js.map → agent-profiles-cnWPANzx.js.map} +1 -1
- package/public/assets/{agents-BJ9sm23O.js → agents-DgUjmk_D.js} +2 -2
- package/public/assets/{agents-BJ9sm23O.js.map → agents-DgUjmk_D.js.map} +1 -1
- package/public/assets/{automation-CJ2-uiYt.js → automation-CuTlgY1E.js} +2 -2
- package/public/assets/{automation-CJ2-uiYt.js.map → automation-CuTlgY1E.js.map} +1 -1
- package/public/assets/{chat-D0VtHShz.js → chat-C7XCmaZz.js} +2 -2
- package/public/assets/{chat-D0VtHShz.js.map → chat-C7XCmaZz.js.map} +1 -1
- package/public/assets/{formatted-body-impl-ChCxpVtI.js → formatted-body-impl-rLDPgdmJ.js} +2 -2
- package/public/assets/{formatted-body-impl-ChCxpVtI.js.map → formatted-body-impl-rLDPgdmJ.js.map} +1 -1
- package/public/assets/{index-BIRtwnUI.js → index-DGyeTLPg.js} +5 -5
- package/public/assets/index-DGyeTLPg.js.map +1 -0
- package/public/assets/{insights-BGuB8DWZ.js → insights-C0dDL4El.js} +2 -2
- package/public/assets/{insights-BGuB8DWZ.js.map → insights-C0dDL4El.js.map} +1 -1
- package/public/assets/{maintenance-BFyuZuv5.js → maintenance-DkOkcEkb.js} +2 -2
- package/public/assets/{maintenance-BFyuZuv5.js.map → maintenance-DkOkcEkb.js.map} +1 -1
- package/public/assets/{managed-agents-BIXZUkrA.js → managed-agents-C7Czinaz.js} +2 -2
- package/public/assets/{managed-agents-BIXZUkrA.js.map → managed-agents-C7Czinaz.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DTI--VmH.js → markdown-preview-impl-BZ7sigEc.js} +2 -2
- package/public/assets/{markdown-preview-impl-DTI--VmH.js.map → markdown-preview-impl-BZ7sigEc.js.map} +1 -1
- package/public/assets/{memory-OoM2E30h.js → memory-Bys-M7GK.js} +2 -2
- package/public/assets/{memory-OoM2E30h.js.map → memory-Bys-M7GK.js.map} +1 -1
- package/public/assets/{messages-BHvC3SNR.js → messages-COSJ6trN.js} +2 -2
- package/public/assets/{messages-BHvC3SNR.js.map → messages-COSJ6trN.js.map} +1 -1
- package/public/assets/{orchestrators-9pzQqf6-.js → orchestrators-BjLl1Z0L.js} +2 -2
- package/public/assets/{orchestrators-9pzQqf6-.js.map → orchestrators-BjLl1Z0L.js.map} +1 -1
- package/public/assets/{overview-L8iYOYmR.js → overview-BmU7twic.js} +2 -2
- package/public/assets/{overview-L8iYOYmR.js.map → overview-BmU7twic.js.map} +1 -1
- package/public/assets/{pairs-BCejxa6_.js → pairs-xclXjw-c.js} +2 -2
- package/public/assets/{pairs-BCejxa6_.js.map → pairs-xclXjw-c.js.map} +1 -1
- package/public/assets/{prompt-settings-D9y9hhSy.js → prompt-settings-CtQd0nz0.js} +2 -2
- package/public/assets/{prompt-settings-D9y9hhSy.js.map → prompt-settings-CtQd0nz0.js.map} +1 -1
- package/public/assets/{registry-BwBg64Vj.js → registry-CDe6vjKI.js} +2 -2
- package/public/assets/{registry-BwBg64Vj.js.map → registry-CDe6vjKI.js.map} +1 -1
- package/public/assets/{security-upd2zf8l.js → security-C9mBNQMZ.js} +2 -2
- package/public/assets/{security-upd2zf8l.js.map → security-C9mBNQMZ.js.map} +1 -1
- package/public/assets/{select-Cu55Vi-R.js → select-BJDKQk4q.js} +2 -2
- package/public/assets/{select-Cu55Vi-R.js.map → select-BJDKQk4q.js.map} +1 -1
- package/public/assets/{settings-Dk5iNqCZ.js → settings-BmZjWJkI.js} +2 -2
- package/public/assets/{settings-Dk5iNqCZ.js.map → settings-BmZjWJkI.js.map} +1 -1
- package/public/assets/{tasks-BoDuvHnZ.js → tasks-DNjZKSM2.js} +2 -2
- package/public/assets/tasks-DNjZKSM2.js.map +1 -0
- package/public/assets/{teams-C6Pn-gpw.js → teams-KNzmdolE.js} +2 -2
- package/public/assets/{teams-C6Pn-gpw.js.map → teams-KNzmdolE.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-afW96xEF.js → terminal-viewer-impl-DnFJDl9N.js} +2 -2
- package/public/assets/{terminal-viewer-impl-afW96xEF.js.map → terminal-viewer-impl-DnFJDl9N.js.map} +1 -1
- package/public/assets/{user-avatar-tSPnqEk5.js → user-avatar-C8sbjQke.js} +2 -2
- package/public/assets/{user-avatar-tSPnqEk5.js.map → user-avatar-C8sbjQke.js.map} +1 -1
- package/public/assets/{work-queue-DP3UdDwC.js → work-queue-DINvPVPn.js} +2 -2
- package/public/assets/{work-queue-DP3UdDwC.js.map → work-queue-DINvPVPn.js.map} +1 -1
- package/public/assets/{workspaces-Dt2xndG4.js → workspaces-CSNdUg15.js} +2 -2
- package/public/assets/{workspaces-Dt2xndG4.js.map → workspaces-CSNdUg15.js.map} +1 -1
- package/public/index.html +1 -1
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/db/agents.ts +3 -0
- package/src/db/notification-subscriptions.ts +15 -0
- package/src/db/task-entity.ts +2 -0
- package/src/mcp-notification-subscription-tools.ts +2 -1
- package/src/mcp-task-tools.ts +2 -1
- package/src/notification-rules.ts +17 -1
- package/src/notification-types.ts +18 -0
- package/src/reviewer-pipeline.ts +13 -0
- package/src/routes/commands.ts +14 -0
- package/src/routes/tasks.ts +1 -0
- package/src/services/spawn-agent.ts +2 -0
- package/src/services/spawn-registration-watchdog.ts +5 -5
- package/src/services/task-done-when.ts +21 -0
- package/src/services/task-entity.ts +30 -7
- package/src/services/task-lifecycle.ts +45 -46
- package/src/services/task-semantic-events.ts +241 -13
- package/src/workspace-auto-merge.ts +17 -0
- package/public/assets/PlanGraphPanel-K6aFJmdd.js +0 -2
- package/public/assets/PlanGraphPanel-K6aFJmdd.js.map +0 -1
- package/public/assets/index-BIRtwnUI.js.map +0 -1
- package/public/assets/tasks-BoDuvHnZ.js.map +0 -1
package/src/db/task-entity.ts
CHANGED
|
@@ -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 =
|
|
26
|
+
const TASK_NOTIFICATION_TYPES = TASK_SEMANTIC_NOTIFICATION_TYPES as readonly NotificationType[];
|
|
26
27
|
|
|
27
28
|
export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
|
|
28
29
|
{
|
package/src/mcp-task-tools.ts
CHANGED
|
@@ -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
|
|
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 [
|
|
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,
|
package/src/reviewer-pipeline.ts
CHANGED
|
@@ -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.
|
package/src/routes/commands.ts
CHANGED
|
@@ -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,
|
package/src/routes/tasks.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
34
|
-
ORDER BY
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
86
|
+
let detail = setTaskStage(id, input);
|
|
81
87
|
emitTaskChanged(detail, "task.stage");
|
|
82
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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)
|
|
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
|
|
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
|
|
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)
|
|
285
|
-
//
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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);
|