agent-relay-server 0.126.0 → 0.127.1
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 +3 -3
- package/public/assets/{MessageBubble-B6YgulnX.js → MessageBubble-DnXnkt4a.js} +2 -2
- package/public/assets/{MessageBubble-B6YgulnX.js.map → MessageBubble-DnXnkt4a.js.map} +1 -1
- package/public/assets/{PlanGraphPanel--DNxHn1T.js → PlanGraphPanel-D6CPEpQD.js} +2 -2
- package/public/assets/{PlanGraphPanel--DNxHn1T.js.map → PlanGraphPanel-D6CPEpQD.js.map} +1 -1
- package/public/assets/{activity-Fo5aLYD6.js → activity-ZPwHcPLK.js} +2 -2
- package/public/assets/{activity-Fo5aLYD6.js.map → activity-ZPwHcPLK.js.map} +1 -1
- package/public/assets/{agent-profiles-C3p0xclG.js → agent-profiles-CT0E9LV1.js} +2 -2
- package/public/assets/{agent-profiles-C3p0xclG.js.map → agent-profiles-CT0E9LV1.js.map} +1 -1
- package/public/assets/{agents-_GBQyF5b.js → agents-Dt5TncjC.js} +2 -2
- package/public/assets/{agents-_GBQyF5b.js.map → agents-Dt5TncjC.js.map} +1 -1
- package/public/assets/{automation-Cf_Ewi9f.js → automation-yzUJbvT2.js} +2 -2
- package/public/assets/{automation-Cf_Ewi9f.js.map → automation-yzUJbvT2.js.map} +1 -1
- package/public/assets/chat-DhYX9BDG.js +2 -0
- package/public/assets/chat-DhYX9BDG.js.map +1 -0
- package/public/assets/{formatted-body-jjhFXTap.js → formatted-body-D5-1aiyB.js} +3 -3
- package/public/assets/{formatted-body-jjhFXTap.js.map → formatted-body-D5-1aiyB.js.map} +1 -1
- package/public/assets/{formatted-body-impl-gOLBKeyW.js → formatted-body-impl-Do5NbJUA.js} +2 -2
- package/public/assets/{formatted-body-impl-gOLBKeyW.js.map → formatted-body-impl-Do5NbJUA.js.map} +1 -1
- package/public/assets/{index-CgH1BZwq.js → index-BdNafiPz.js} +6 -6
- package/public/assets/{index-CgH1BZwq.js.map → index-BdNafiPz.js.map} +1 -1
- package/public/assets/{insights-CTW85An9.js → insights-i6AEZT_a.js} +2 -2
- package/public/assets/{insights-CTW85An9.js.map → insights-i6AEZT_a.js.map} +1 -1
- package/public/assets/{maintenance-D5PEygZz.js → maintenance-CJ6wikpg.js} +2 -2
- package/public/assets/{maintenance-D5PEygZz.js.map → maintenance-CJ6wikpg.js.map} +1 -1
- package/public/assets/{managed-agents-pkpAzZy8.js → managed-agents-JZn0xNJR.js} +3 -3
- package/public/assets/managed-agents-JZn0xNJR.js.map +1 -0
- package/public/assets/{markdown-preview-impl-DMzFE-Lo.js → markdown-preview-impl-BsvAkpR4.js} +2 -2
- package/public/assets/{markdown-preview-impl-DMzFE-Lo.js.map → markdown-preview-impl-BsvAkpR4.js.map} +1 -1
- package/public/assets/{memory-BnquVYQz.js → memory-BE9IwCdC.js} +2 -2
- package/public/assets/{memory-BnquVYQz.js.map → memory-BE9IwCdC.js.map} +1 -1
- package/public/assets/{messages-DgxLlndC.js → messages-vXgdc3H4.js} +2 -2
- package/public/assets/{messages-DgxLlndC.js.map → messages-vXgdc3H4.js.map} +1 -1
- package/public/assets/{orchestrators-BaWxeAlK.js → orchestrators-CribhLg-.js} +2 -2
- package/public/assets/{orchestrators-BaWxeAlK.js.map → orchestrators-CribhLg-.js.map} +1 -1
- package/public/assets/{overview-Kvs-j2gg.js → overview-q3O9K9VK.js} +2 -2
- package/public/assets/{overview-Kvs-j2gg.js.map → overview-q3O9K9VK.js.map} +1 -1
- package/public/assets/{pairs-eDzbqcER.js → pairs-BNOnd0IE.js} +2 -2
- package/public/assets/{pairs-eDzbqcER.js.map → pairs-BNOnd0IE.js.map} +1 -1
- package/public/assets/{prompt-settings-CjcqBG_r.js → prompt-settings-Bdz_xCgD.js} +2 -2
- package/public/assets/{prompt-settings-CjcqBG_r.js.map → prompt-settings-Bdz_xCgD.js.map} +1 -1
- package/public/assets/{registry-C1DSnjjg.js → registry-DPcyGypi.js} +2 -2
- package/public/assets/{registry-C1DSnjjg.js.map → registry-DPcyGypi.js.map} +1 -1
- package/public/assets/{security-VbEe_jwm.js → security-Z3l3u-Pl.js} +2 -2
- package/public/assets/{security-VbEe_jwm.js.map → security-Z3l3u-Pl.js.map} +1 -1
- package/public/assets/{select-CVfwgfKl.js → select-BX_lW49D.js} +2 -2
- package/public/assets/{select-CVfwgfKl.js.map → select-BX_lW49D.js.map} +1 -1
- package/public/assets/{settings-ujA9hZW1.js → settings-Y381jZt7.js} +2 -2
- package/public/assets/{settings-ujA9hZW1.js.map → settings-Y381jZt7.js.map} +1 -1
- package/public/assets/{tasks-BtbKaa-b.js → tasks-Di6d0_Ch.js} +2 -2
- package/public/assets/{tasks-BtbKaa-b.js.map → tasks-Di6d0_Ch.js.map} +1 -1
- package/public/assets/{teams-CrVDQ99v.js → teams-DJv4l-_l.js} +2 -2
- package/public/assets/{teams-CrVDQ99v.js.map → teams-DJv4l-_l.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-Aays_e7-.js → terminal-viewer-impl-3zBLpRel.js} +2 -2
- package/public/assets/{terminal-viewer-impl-Aays_e7-.js.map → terminal-viewer-impl-3zBLpRel.js.map} +1 -1
- package/public/assets/types-BqeSkWrv.js.map +1 -1
- package/public/assets/{work-queue-DDSGZO4Z.js → work-queue-D73-hP0c.js} +2 -2
- package/public/assets/{work-queue-DDSGZO4Z.js.map → work-queue-D73-hP0c.js.map} +1 -1
- package/public/index.html +2 -2
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +2 -2
- package/runner/src/provisioning.ts +121 -0
- package/runner/src/relay-mcp-proxy.ts +1 -3
- package/src/db/index.ts +1 -0
- package/src/db/task-deliverables.ts +206 -0
- package/src/db/task-entity.ts +4 -8
- package/src/mcp/index.ts +4 -4
- package/src/mcp/tools-workspace.ts +3 -36
- package/src/mcp-task-deliver-tools.ts +143 -0
- package/src/notification-rules.ts +1 -0
- package/src/notification-types.ts +2 -0
- package/src/prompt-resolver.ts +6 -6
- package/src/routes/agent-sessions.ts +4 -1
- package/src/routes/agents.ts +3 -0
- package/src/services/task-delivery.ts +275 -0
- package/src/services/task-entity.ts +32 -1
- package/src/workspace-actions.ts +4 -2
- package/src/workspace-phase.ts +5 -5
- package/public/assets/chat-BdbIWZpQ.js +0 -2
- package/public/assets/chat-BdbIWZpQ.js.map +0 -1
- package/public/assets/managed-agents-pkpAzZy8.js.map +0 -1
|
@@ -15,33 +15,19 @@ import { optionalBoolean, optionalPositiveInt, optionalString } from "./validati
|
|
|
15
15
|
export const WORKSPACE_TOOLS: ToolDefinition[] = [
|
|
16
16
|
{
|
|
17
17
|
name: "relay_workspace_status",
|
|
18
|
-
description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes; do not use wait:true after
|
|
18
|
+
description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes; do not use wait:true after relay_task_deliver unless explicitly debugging. Normal branch workers deliver the Task, stop their turn, and are woken later by landed-success or landed-failure notifications. Defaults to your own workspace; pass workspaceId to target another.",
|
|
19
19
|
requiredScopes: ["agents:read", "agent:read", "agent:write"],
|
|
20
20
|
alwaysLoad: true,
|
|
21
21
|
inputSchema: {
|
|
22
22
|
type: "object",
|
|
23
23
|
properties: {
|
|
24
24
|
workspaceId: { type: "string", description: "Defaults to your own isolated workspace (resolved from your identity)." },
|
|
25
|
-
wait: { type: "boolean", description: "Block until the workspace status changes (the actionable signal), or until the timeout. Do not use this after
|
|
25
|
+
wait: { type: "boolean", description: "Block until the workspace status changes (the actionable signal), or until the timeout. Do not use this after relay_task_deliver in the normal worker flow; stop and wait for landed-success/landed-failure instead." },
|
|
26
26
|
timeoutSeconds: { type: "integer", minimum: 1, maximum: 600, description: "Max seconds to wait when wait:true (default 300, max 600). Returns early on any transition." },
|
|
27
27
|
},
|
|
28
28
|
additionalProperties: false,
|
|
29
29
|
},
|
|
30
30
|
},
|
|
31
|
-
{
|
|
32
|
-
name: "relay_workspace_ready",
|
|
33
|
-
description: "Signal that your branch work is finished and ready to merge back to main. This kicks off the auto-merge-back: a clean merge lands immediately; on conflict a steward agent reconciles and lands it (escalating if it can't). After this tool returns, stop your turn and go idle; Relay wakes you later with landed-success when the merge lands and your branch refreshes, or landed-failure if landing fatally fails. Defaults to your own workspace.",
|
|
34
|
-
requiredScopes: ["agent:write"],
|
|
35
|
-
alwaysLoad: true,
|
|
36
|
-
inputSchema: {
|
|
37
|
-
type: "object",
|
|
38
|
-
properties: {
|
|
39
|
-
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
40
|
-
detail: { type: "string", description: "Optional note recorded on the activity feed." },
|
|
41
|
-
},
|
|
42
|
-
additionalProperties: false,
|
|
43
|
-
},
|
|
44
|
-
},
|
|
45
31
|
{
|
|
46
32
|
name: "relay_workspace_deps",
|
|
47
33
|
description: "Re-provision your worktree's dependencies when the shared symlinked node_modules has gone stale (e.g. typecheck reports a missing module). checkOnly:true just reports staleness without changing anything. Self-service — acts only on your own worktree.",
|
|
@@ -98,25 +84,6 @@ export const WORKSPACE_TOOLS: ToolDefinition[] = [
|
|
|
98
84
|
additionalProperties: false,
|
|
99
85
|
},
|
|
100
86
|
},
|
|
101
|
-
{
|
|
102
|
-
name: "relay_workspace_land",
|
|
103
|
-
description: "Dispatch a base merge for your workspace directly (lease-serialized per repo, same path the auto-merge job uses). Most branch agents should use relay_workspace_ready instead and let the relay land it; this is the explicit/steward path and requires the command:write scope.",
|
|
104
|
-
requiredScopes: ["command:write"],
|
|
105
|
-
inputSchema: {
|
|
106
|
-
type: "object",
|
|
107
|
-
properties: {
|
|
108
|
-
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
109
|
-
strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto). Falls back to the instance landing.strategy config when unset." },
|
|
110
|
-
deleteBranch: { type: "boolean", description: "Delete the branch after a successful merge (default true)." },
|
|
111
|
-
prTitle: { type: "string" },
|
|
112
|
-
prBody: { type: "string" },
|
|
113
|
-
autoMerge: { type: "string", enum: AUTO_MERGE_POLICIES, description: "Auto-merge policy for PR-strategy lands. Defaults to 'on-green' when strategy resolves to 'pr'. Ignored for rebase-ff/auto strategies." },
|
|
114
|
-
reviewer: { type: "string", description: "GitHub login or team slug to request as a reviewer on the opened PR (pr strategy only)." },
|
|
115
|
-
detail: { type: "string" },
|
|
116
|
-
},
|
|
117
|
-
additionalProperties: false,
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
87
|
];
|
|
121
88
|
|
|
122
89
|
function isWorkspaceAdmin(auth: McpAuthContext): boolean {
|
|
@@ -219,7 +186,7 @@ export async function relayWorkspaceMutation(auth: McpAuthContext, action: Works
|
|
|
219
186
|
if (action === "ready") {
|
|
220
187
|
payload.contract = readyContract(result.workspace);
|
|
221
188
|
payload.guidance = describeWorkspacePhase(result.workspace);
|
|
222
|
-
//
|
|
189
|
+
// Legacy workspace-ready route/CLI path is an explicit, platform-owned completion signal: the caller
|
|
223
190
|
// is declaring its work window closed. Close it, forwarding only the held final group.
|
|
224
191
|
markAgentWorkFinal(result.workspace.ownerAgentId ?? callerAgentId(auth), "workspace-ready");
|
|
225
192
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { McpAuthError } from "./mcp-errors";
|
|
2
|
+
import { ValidationError } from "./db";
|
|
3
|
+
import { authContextFromMcp } from "./services/auth-context";
|
|
4
|
+
import { deliverTask } from "./services/task-delivery";
|
|
5
|
+
import { ServiceAuthError } from "./services/errors";
|
|
6
|
+
import { cleanString, optionalEnum } from "./validation";
|
|
7
|
+
import {
|
|
8
|
+
TASK_DELIVERY_DISPOSITIONS,
|
|
9
|
+
TASK_BLOCKER_TYPES,
|
|
10
|
+
TASK_GATE_STATUSES,
|
|
11
|
+
isRecord,
|
|
12
|
+
type TaskDeliveryGateReports,
|
|
13
|
+
type TaskDeliveryResult,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
const TASK_ID_PROP = { oneOf: [{ type: "integer", minimum: 1 }, { type: "string" }], description: "Task id. Optional when the caller is bound to exactly one Task." } as const;
|
|
17
|
+
const COMMIT_SCHEMA = {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: { sha: { type: "string" }, subject: { type: "string" } },
|
|
20
|
+
required: ["sha"],
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
} as const;
|
|
23
|
+
const GATE_RESULT_SCHEMA = {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
status: { type: "string", enum: TASK_GATE_STATUSES },
|
|
27
|
+
detail: { type: "string" },
|
|
28
|
+
count: { type: "integer", minimum: 0 },
|
|
29
|
+
runId: { type: "string" },
|
|
30
|
+
},
|
|
31
|
+
required: ["status"],
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
export const TASK_DELIVER_TOOLS = [
|
|
36
|
+
{
|
|
37
|
+
name: "relay_task_deliver",
|
|
38
|
+
description: "Submit a structured Delivery Result for a durable Task. Writes a typed deliverable event to the Task thread before any side effect. disposition:'complete' requires passing typecheck+test gates and auto-triggers the caller's task-bound isolated workspace handoff to land.",
|
|
39
|
+
requiredScopes: ["task:write"],
|
|
40
|
+
alwaysLoad: true,
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: {
|
|
44
|
+
taskId: TASK_ID_PROP,
|
|
45
|
+
disposition: { type: "string", enum: TASK_DELIVERY_DISPOSITIONS },
|
|
46
|
+
summary: { type: "string" },
|
|
47
|
+
reason: { type: "string", description: "Required for blocked/failed deliveries." },
|
|
48
|
+
blockedBy: {
|
|
49
|
+
type: "array",
|
|
50
|
+
description: "Required for blocked deliveries; records the blocker set on the Task.",
|
|
51
|
+
items: {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: { type: { type: "string", enum: TASK_BLOCKER_TYPES }, ref: { type: "string" }, note: { type: "string" } },
|
|
54
|
+
required: ["type"],
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
commits: { type: "array", items: COMMIT_SCHEMA },
|
|
59
|
+
gates: {
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: {
|
|
62
|
+
typecheck: GATE_RESULT_SCHEMA,
|
|
63
|
+
test: GATE_RESULT_SCHEMA,
|
|
64
|
+
other: {
|
|
65
|
+
type: "array",
|
|
66
|
+
items: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: { name: { type: "string" }, status: { type: "string", enum: TASK_GATE_STATUSES }, detail: { type: "string" }, count: { type: "integer", minimum: 0 }, runId: { type: "string" } },
|
|
69
|
+
required: ["name", "status"],
|
|
70
|
+
additionalProperties: false,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
filesChanged: { type: "integer", minimum: 0 },
|
|
77
|
+
rca: { type: "string" },
|
|
78
|
+
testCoverage: { type: "string" },
|
|
79
|
+
risks: { type: "array", items: { type: "string" } },
|
|
80
|
+
selfAcceptance: {
|
|
81
|
+
type: "array",
|
|
82
|
+
items: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: { criterion: { type: "string" }, status: { type: "string", enum: ["met", "unmet"] }, note: { type: "string" } },
|
|
85
|
+
required: ["criterion", "status"],
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
workspaceId: { type: "string", description: "Defaults to the caller's isolated workspace." },
|
|
90
|
+
idempotencyKey: { type: "string", description: "Dedupes retry delivery and prevents double-land on replay." },
|
|
91
|
+
},
|
|
92
|
+
required: ["disposition", "summary"],
|
|
93
|
+
additionalProperties: false,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown>; alwaysLoad?: boolean }>;
|
|
97
|
+
|
|
98
|
+
type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
|
|
99
|
+
|
|
100
|
+
function optionalPositiveInt(value: unknown, field: string): number | undefined {
|
|
101
|
+
if (value === undefined || value === null) return undefined;
|
|
102
|
+
const n = typeof value === "string" ? Number(value) : value;
|
|
103
|
+
if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) throw new ValidationError(`${field} must be a positive integer`);
|
|
104
|
+
return n;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function cleanDeliveryGates(value: unknown): TaskDeliveryGateReports | undefined {
|
|
108
|
+
if (value === undefined || value === null) return undefined;
|
|
109
|
+
if (!isRecord(value)) throw new ValidationError("gates must be an object");
|
|
110
|
+
return value as TaskDeliveryGateReports;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cleanDeliveryResult(args: Record<string, unknown>): TaskDeliveryResult {
|
|
114
|
+
return {
|
|
115
|
+
disposition: optionalEnum(args.disposition, "disposition", TASK_DELIVERY_DISPOSITIONS)!,
|
|
116
|
+
summary: cleanString(args.summary, "summary", { required: true, max: 1_000_000 })!,
|
|
117
|
+
...(args.reason !== undefined ? { reason: cleanString(args.reason, "reason", { max: 64_000 }) } : {}),
|
|
118
|
+
...(Array.isArray(args.blockedBy) ? { blockedBy: args.blockedBy as TaskDeliveryResult["blockedBy"] } : {}),
|
|
119
|
+
...(Array.isArray(args.commits) ? { commits: args.commits as TaskDeliveryResult["commits"] } : {}),
|
|
120
|
+
...(args.gates !== undefined ? { gates: cleanDeliveryGates(args.gates) } : {}),
|
|
121
|
+
...(typeof args.filesChanged === "number" ? { filesChanged: args.filesChanged } : {}),
|
|
122
|
+
...(args.rca !== undefined ? { rca: cleanString(args.rca, "rca", { max: 64_000 }) } : {}),
|
|
123
|
+
...(args.testCoverage !== undefined ? { testCoverage: cleanString(args.testCoverage, "testCoverage", { max: 64_000 }) } : {}),
|
|
124
|
+
...(Array.isArray(args.risks) ? { risks: args.risks.filter((risk): risk is string => typeof risk === "string") } : {}),
|
|
125
|
+
...(Array.isArray(args.selfAcceptance) ? { selfAcceptance: args.selfAcceptance as TaskDeliveryResult["selfAcceptance"] } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function relayTaskDeliverTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
130
|
+
if (name !== "relay_task_deliver") throw new ValidationError(`unknown tool: ${name}`);
|
|
131
|
+
if (!isRecord(args)) throw new ValidationError("arguments must be an object");
|
|
132
|
+
try {
|
|
133
|
+
return await deliverTask({
|
|
134
|
+
...cleanDeliveryResult(args),
|
|
135
|
+
taskId: optionalPositiveInt(args.taskId, "taskId"),
|
|
136
|
+
workspaceId: cleanString(args.workspaceId, "workspaceId", { max: 240 }),
|
|
137
|
+
idempotencyKey: cleanString(args.idempotencyKey, "idempotencyKey", { max: 240 }),
|
|
138
|
+
}, authContextFromMcp(auth));
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error instanceof ServiceAuthError) throw new McpAuthError(error.message);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -2,6 +2,7 @@ export type NotificationType =
|
|
|
2
2
|
| "message.new"
|
|
3
3
|
| "landed-failure"
|
|
4
4
|
| "branch.landed"
|
|
5
|
+
| "task.deliverable"
|
|
5
6
|
| "task.deliverable_landed"
|
|
6
7
|
| "task.created"
|
|
7
8
|
| "task.started"
|
|
@@ -99,6 +100,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
99
100
|
"message.new": null,
|
|
100
101
|
"landed-failure": null,
|
|
101
102
|
"branch.landed": null,
|
|
103
|
+
"task.deliverable": null,
|
|
102
104
|
// The #831 auto-driven "task deliverable landed" receipt to the coordinator. Durable (null):
|
|
103
105
|
// it carries the auto-actions the coordinator confirms/exception-handles, so it must survive
|
|
104
106
|
// the coordinator being briefly offline rather than expiring as an ephemeral bystander ping.
|
package/src/prompt-resolver.ts
CHANGED
|
@@ -289,7 +289,7 @@ const DEFAULT_PROMPT_TEMPLATES = [
|
|
|
289
289
|
template: lines(
|
|
290
290
|
"You are in an isolated git worktree on branch {{branch}}, based on {{baseRef}} — NOT the main checkout. {{baseRef}} moves under you as other agents land in parallel; that's expected.",
|
|
291
291
|
"Your shell's working directory is already this worktree — run commands directly without prefixing `cd`.",
|
|
292
|
-
"Changes reach the base via: commit your work, then call `
|
|
292
|
+
"Changes reach the base via: commit your work, then call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates. Relay records the typed delivery on the Task, then rebases onto the latest base, lands, and pushes for you.",
|
|
293
293
|
"Do NOT push, rebase, merge, resolve conflicts, or `cd` into the main checkout — Relay (and a steward, spawned only if a clean auto-merge isn't possible) own all of that.",
|
|
294
294
|
"After `ready` the status is `ready` (a normal, healthy hand-off state, not a stall). Stop your turn and go idle; Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails.",
|
|
295
295
|
"Call `relay_workspace_status` anytime to see where you are and the exact next step.",
|
|
@@ -297,7 +297,7 @@ const DEFAULT_PROMPT_TEMPLATES = [
|
|
|
297
297
|
defaultTemplate: lines(
|
|
298
298
|
"You are in an isolated git worktree on branch {{branch}}, based on {{baseRef}} — NOT the main checkout. {{baseRef}} moves under you as other agents land in parallel; that's expected.",
|
|
299
299
|
"Your shell's working directory is already this worktree — run commands directly without prefixing `cd`.",
|
|
300
|
-
"Changes reach the base via: commit your work, then call `
|
|
300
|
+
"Changes reach the base via: commit your work, then call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates. Relay records the typed delivery on the Task, then rebases onto the latest base, lands, and pushes for you.",
|
|
301
301
|
"Do NOT push, rebase, merge, resolve conflicts, or `cd` into the main checkout — Relay (and a steward, spawned only if a clean auto-merge isn't possible) own all of that.",
|
|
302
302
|
"After `ready` the status is `ready` (a normal, healthy hand-off state, not a stall). Stop your turn and go idle; Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails.",
|
|
303
303
|
"Call `relay_workspace_status` anytime to see where you are and the exact next step.",
|
|
@@ -423,8 +423,8 @@ const DEFAULT_PROMPT_TEMPLATES = [
|
|
|
423
423
|
category: "workspace-phase",
|
|
424
424
|
title: "Workspace Active Hint",
|
|
425
425
|
description: "Workspace status hint for active isolated work.",
|
|
426
|
-
template: "Commit your work in this worktree, then
|
|
427
|
-
defaultTemplate: "Commit your work in this worktree, then
|
|
426
|
+
template: "Commit your work in this worktree, then deliver the Task with `relay_task_deliver` using disposition `complete` and passing `typecheck` + `test` gates. Relay records the typed delivery, lands the branch, and pushes for you — do NOT push, rebase, or merge yourself, and never touch the main checkout.",
|
|
427
|
+
defaultTemplate: "Commit your work in this worktree, then deliver the Task with `relay_task_deliver` using disposition `complete` and passing `typecheck` + `test` gates. Relay records the typed delivery, lands the branch, and pushes for you — do NOT push, rebase, or merge yourself, and never touch the main checkout.",
|
|
428
428
|
variables: [],
|
|
429
429
|
},
|
|
430
430
|
{
|
|
@@ -432,8 +432,8 @@ const DEFAULT_PROMPT_TEMPLATES = [
|
|
|
432
432
|
category: "workspace-phase",
|
|
433
433
|
title: "Workspace Ready Headline",
|
|
434
434
|
description: "Workspace status headline for healthy handoff states.",
|
|
435
|
-
template: "
|
|
436
|
-
defaultTemplate: "
|
|
435
|
+
template: "Delivered — stop this turn and go idle while Relay lands your branch. This is the normal, healthy post-delivery state (not an escalation).",
|
|
436
|
+
defaultTemplate: "Delivered — stop this turn and go idle while Relay lands your branch. This is the normal, healthy post-delivery state (not an escalation).",
|
|
437
437
|
variables: [],
|
|
438
438
|
},
|
|
439
439
|
{
|
|
@@ -614,7 +614,10 @@ export const postAgentTerminalSession: Handler = async (req, params) => {
|
|
|
614
614
|
resource: { agentId: agent.id, orchestratorId: orchestrator.id, policyName: metaPolicyName, spawnRequestId: metaSpawnRequestId, terminalAttach: true },
|
|
615
615
|
});
|
|
616
616
|
if (denied) return denied;
|
|
617
|
-
|
|
617
|
+
// Provider-owned attach surfaces are more specific than the runner's tmux pane.
|
|
618
|
+
// Codex runs its real interactive TUI as a guest attached to the app-server thread;
|
|
619
|
+
// the runner pane can be a stale launcher frame and is not the right input target.
|
|
620
|
+
if (agent.providerCapabilities?.terminal?.attach?.create !== true && agent.providerCapabilities?.terminal?.live?.read && metaTmuxSession) {
|
|
618
621
|
return json({
|
|
619
622
|
mode: "live",
|
|
620
623
|
orchestratorId: orchestrator.id,
|
package/src/routes/agents.ts
CHANGED
|
@@ -216,6 +216,9 @@ export const postHeartbeat: Handler = async (req, params) => {
|
|
|
216
216
|
if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
|
|
217
217
|
// #416 — capture unavailability before heartbeat to detect the transition.
|
|
218
218
|
const beforeHeartbeat = getAgent(params.id!);
|
|
219
|
+
if (beforeHeartbeat?.kind === "channel" && !beforeHeartbeat.ready) {
|
|
220
|
+
return error("channel agent not ready; re-register required", 409);
|
|
221
|
+
}
|
|
219
222
|
const wasUnavailable = beforeHeartbeat ? isAgentUnavailable(beforeHeartbeat) : false;
|
|
220
223
|
if (!heartbeat(params.id!, guard)) return error("agent not found", 404);
|
|
221
224
|
if (wasUnavailable) {
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getTaskDetail,
|
|
3
|
+
getWorkspace,
|
|
4
|
+
insertTaskEvent,
|
|
5
|
+
listTaskEvents,
|
|
6
|
+
ownedIsolatedWorkspace,
|
|
7
|
+
recordGate,
|
|
8
|
+
recordTaskDeliverable,
|
|
9
|
+
setTaskStatus,
|
|
10
|
+
ValidationError,
|
|
11
|
+
} from "../db";
|
|
12
|
+
import { emitRelayEvent } from "../events";
|
|
13
|
+
import { routeNotification } from "../notification-router";
|
|
14
|
+
import { emitTaskChanged, emitNewMessages } from "../sse";
|
|
15
|
+
import { agentBoundTaskIds, hasTaskReadAdminScope } from "../task-authz";
|
|
16
|
+
import { applyWorkspaceAction } from "../workspace-actions";
|
|
17
|
+
import { emitTaskSemanticNotification } from "./task-semantic-events";
|
|
18
|
+
import { ServiceAuthError } from "./errors";
|
|
19
|
+
import { resolveCoordinatorAgentId } from "../steward-identity";
|
|
20
|
+
import { markAgentWorkFinal } from "./work-finality";
|
|
21
|
+
import type { AuthContext } from "./auth-context";
|
|
22
|
+
import type { Command, TaskBlocker, TaskDeliverableEventMetadata, TaskDeliveryGateReports, TaskDeliveryResult, TaskGateStatus, TaskDetail, WorkspaceRecord } from "../types";
|
|
23
|
+
|
|
24
|
+
const REQUIRED_COMPLETE_GATES = ["typecheck", "test"] as const;
|
|
25
|
+
|
|
26
|
+
export interface DeliverTaskInput extends TaskDeliveryResult {
|
|
27
|
+
taskId?: number;
|
|
28
|
+
workspaceId?: string;
|
|
29
|
+
idempotencyKey?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface GateWrite {
|
|
33
|
+
gate: string;
|
|
34
|
+
status: TaskGateStatus;
|
|
35
|
+
detail?: string;
|
|
36
|
+
count?: number;
|
|
37
|
+
runId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function callerLabel(ctx: AuthContext): string {
|
|
41
|
+
return ctx.callerAgentId ?? ctx.actor.id;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function taskIdFrom(value: unknown): number | undefined {
|
|
45
|
+
if (value === undefined || value === null) return undefined;
|
|
46
|
+
const n = typeof value === "string" ? Number(value) : value;
|
|
47
|
+
if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) throw new ValidationError("taskId must be a positive integer");
|
|
48
|
+
return n;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveTaskId(ctx: AuthContext, explicit: unknown): number {
|
|
52
|
+
const taskId = taskIdFrom(explicit);
|
|
53
|
+
const caller = ctx.callerAgentId;
|
|
54
|
+
const bound = caller ? agentBoundTaskIds(caller) : new Set<number>();
|
|
55
|
+
const admin = hasTaskReadAdminScope(ctx.scopes);
|
|
56
|
+
if (taskId !== undefined) {
|
|
57
|
+
if (admin || bound.has(taskId)) return taskId;
|
|
58
|
+
throw new ServiceAuthError("taskId is not bound to the caller");
|
|
59
|
+
}
|
|
60
|
+
if (bound.size === 1) return [...bound][0]!;
|
|
61
|
+
if (bound.size === 0) throw new ServiceAuthError("caller is not bound to a Task");
|
|
62
|
+
throw new ValidationError("caller is bound to multiple Tasks; pass taskId explicitly");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveWorkspace(ctx: AuthContext, input: { workspaceId?: string }, taskId: number): WorkspaceRecord | undefined {
|
|
66
|
+
const caller = ctx.callerAgentId;
|
|
67
|
+
const explicit = input.workspaceId?.trim();
|
|
68
|
+
const ws = explicit ? getWorkspace(explicit) : caller ? ownedIsolatedWorkspace(caller) : undefined;
|
|
69
|
+
if (!ws) return undefined;
|
|
70
|
+
if (!hasTaskReadAdminScope(ctx.scopes) && (!caller || ws.ownerAgentId !== caller)) {
|
|
71
|
+
throw new ServiceAuthError(`workspace ${ws.id} is not owned by the caller`);
|
|
72
|
+
}
|
|
73
|
+
const task = getTaskDetail(taskId);
|
|
74
|
+
if (!task) throw new ValidationError("task not found");
|
|
75
|
+
if (task.branch && ws.branch && task.branch !== ws.branch) {
|
|
76
|
+
throw new ValidationError(`task ${taskId} is bound to branch ${task.branch}, not workspace branch ${ws.branch}`);
|
|
77
|
+
}
|
|
78
|
+
return ws;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function gateWrites(gates: TaskDeliveryGateReports | undefined, deliveryKey: string): GateWrite[] {
|
|
82
|
+
const writes: GateWrite[] = [];
|
|
83
|
+
if (gates?.typecheck) writes.push({ gate: "typecheck", ...gatePayload(gates.typecheck, `${deliveryKey}:typecheck`) });
|
|
84
|
+
if (gates?.test) writes.push({ gate: "test", ...gatePayload(gates.test, `${deliveryKey}:test`) });
|
|
85
|
+
for (const gate of gates?.other ?? []) {
|
|
86
|
+
writes.push({ gate: gate.name, ...gatePayload(gate, `${deliveryKey}:other:${gate.name}`) });
|
|
87
|
+
}
|
|
88
|
+
return writes;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function gatePayload(input: { status: TaskGateStatus; detail?: string; count?: number; runId?: string }, fallbackRunId: string): Omit<GateWrite, "gate"> {
|
|
92
|
+
return {
|
|
93
|
+
status: input.status,
|
|
94
|
+
...(input.detail ? { detail: input.detail } : {}),
|
|
95
|
+
...(typeof input.count === "number" ? { count: input.count } : {}),
|
|
96
|
+
runId: input.runId ?? fallbackRunId,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizedDoneWhen(task: TaskDetail): string {
|
|
101
|
+
return (task.doneWhen ?? "").toLowerCase();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function completeDeliveryLands(task: TaskDetail): boolean {
|
|
105
|
+
const dispatchType = task.dispatch?.type;
|
|
106
|
+
if (dispatchType === "review" || dispatchType === "research" || dispatchType === "ops") return false;
|
|
107
|
+
const acceptance = normalizedDoneWhen(task);
|
|
108
|
+
if (/\b(no[-\s]?branch|review(?:ed)?|verdict|report[-\s]?only|none)\b/.test(acceptance)) return false;
|
|
109
|
+
if (/\b(merged?|landed(?:-on-main)?|live[-\s]?verified|deployed|agent[-\s]?exit|reaped)\b/.test(acceptance)) return true;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cleanReason(value: unknown, disposition: "blocked" | "failed"): string {
|
|
114
|
+
if (typeof value !== "string" || !value.trim()) throw new ValidationError(`${disposition} delivery requires reason`);
|
|
115
|
+
const cleaned = value.trim();
|
|
116
|
+
if (cleaned.length > 64_000) throw new ValidationError("reason must be 64000 characters or fewer");
|
|
117
|
+
return cleaned;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function cleanBlockedBy(value: unknown): TaskBlocker[] {
|
|
121
|
+
if (!Array.isArray(value) || value.length === 0) throw new ValidationError("blocked delivery requires blockedBy");
|
|
122
|
+
return value as TaskBlocker[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function completeGateProblem(gates: TaskDeliveryGateReports | undefined): string | undefined {
|
|
126
|
+
if (!gates) return "missing-gate-block";
|
|
127
|
+
const writes = gateWrites(gates, "check");
|
|
128
|
+
const byName = new Map(writes.map((gate) => [gate.gate, gate.status]));
|
|
129
|
+
const missing = REQUIRED_COMPLETE_GATES.filter((gate) => !byName.has(gate));
|
|
130
|
+
if (missing.length > 0) return `missing-gates:${missing.join("+")}`;
|
|
131
|
+
const failed = writes.filter((gate) => gate.status !== "pass").map((gate) => gate.gate);
|
|
132
|
+
if (failed.length > 0) return `failed-gates:${failed.join("+")}`;
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function deliveryEvent(taskId: number, idempotencyKey: string | undefined): { id: number; metadata: TaskDeliverableEventMetadata } | undefined {
|
|
137
|
+
const events = listTaskEvents(taskId).filter((event) => event.type === "deliverable").reverse();
|
|
138
|
+
const event = idempotencyKey
|
|
139
|
+
? events.find((item) => item.metadata.idempotencyKey === idempotencyKey)
|
|
140
|
+
: events[0];
|
|
141
|
+
if (!event) return undefined;
|
|
142
|
+
return { id: event.id, metadata: event.metadata as unknown as TaskDeliverableEventMetadata };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function notifyDelivery(taskId: number, eventId: number, metadata: TaskDeliverableEventMetadata): void {
|
|
146
|
+
const task = getTaskDetail(taskId);
|
|
147
|
+
if (!task) return;
|
|
148
|
+
emitRelayEvent({
|
|
149
|
+
type: "task.deliverable",
|
|
150
|
+
source: metadata.actor,
|
|
151
|
+
subject: String(taskId),
|
|
152
|
+
data: { taskId, eventId, disposition: metadata.disposition, summary: metadata.summary, workspaceId: metadata.workspaceId },
|
|
153
|
+
});
|
|
154
|
+
const coordinator = resolveCoordinatorAgentId(task.ownerAgentId ?? metadata.actor);
|
|
155
|
+
const recipients = [...new Set([coordinator, task.createdBy].filter((item): item is string => Boolean(item && item.trim())))];
|
|
156
|
+
const delivered = routeNotification({
|
|
157
|
+
type: "task.deliverable",
|
|
158
|
+
scope: {
|
|
159
|
+
taskId: String(taskId),
|
|
160
|
+
transition: "deliverable",
|
|
161
|
+
...(task.ownerAgentId ? { agentId: task.ownerAgentId, author: task.ownerAgentId } : {}),
|
|
162
|
+
...(task.createdBy ? { parent: task.createdBy } : {}),
|
|
163
|
+
...(task.teamId ? { teamId: task.teamId } : {}),
|
|
164
|
+
...(metadata.workspaceId ? { workspaceId: metadata.workspaceId } : {}),
|
|
165
|
+
},
|
|
166
|
+
recipients,
|
|
167
|
+
ttlMs: null,
|
|
168
|
+
message: {
|
|
169
|
+
subject: `Task T${taskId} delivery: ${metadata.disposition}`,
|
|
170
|
+
body: `Task T${taskId} delivery (${metadata.disposition}): ${metadata.summary}`,
|
|
171
|
+
payload: {
|
|
172
|
+
kind: "task.deliverable",
|
|
173
|
+
notification: "task-deliverable",
|
|
174
|
+
taskId,
|
|
175
|
+
eventId,
|
|
176
|
+
disposition: metadata.disposition,
|
|
177
|
+
summary: metadata.summary,
|
|
178
|
+
gates: metadata.gates,
|
|
179
|
+
risks: metadata.risks,
|
|
180
|
+
workspaceId: metadata.workspaceId,
|
|
181
|
+
},
|
|
182
|
+
idempotencyKey: metadata.idempotencyKey ? `task:${taskId}:deliverable:${metadata.idempotencyKey}` : `task:${taskId}:deliverable:event:${eventId}`,
|
|
183
|
+
replyExpected: false,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
if (delivered.length > 0) emitNewMessages(delivered);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function emitCommand(command: Command): void {
|
|
190
|
+
emitRelayEvent({
|
|
191
|
+
type: command.status === "pending" ? "command.requested" : `command.${command.status}`,
|
|
192
|
+
source: command.source,
|
|
193
|
+
subject: command.id,
|
|
194
|
+
data: { command },
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function deliverTask(input: DeliverTaskInput, ctx: AuthContext): Promise<Record<string, unknown>> {
|
|
199
|
+
const actor = callerLabel(ctx);
|
|
200
|
+
const taskId = resolveTaskId(ctx, input.taskId);
|
|
201
|
+
const before = getTaskDetail(taskId);
|
|
202
|
+
if (!before) throw new ValidationError("task not found");
|
|
203
|
+
if (before.kind !== "deliverable") throw new ValidationError("task not found");
|
|
204
|
+
const wantsLand = input.disposition === "complete" && completeDeliveryLands(before);
|
|
205
|
+
const isVerdictOnlyComplete = input.disposition === "complete" && !wantsLand;
|
|
206
|
+
const gateProblem = input.disposition === "complete" ? completeGateProblem(input.gates) : undefined;
|
|
207
|
+
if (gateProblem) throw new ValidationError(`complete delivery cannot land without passing gate block (${gateProblem})`);
|
|
208
|
+
const reason = input.disposition === "blocked" || input.disposition === "failed" ? cleanReason(input.reason, input.disposition) : undefined;
|
|
209
|
+
const blockedBy = input.disposition === "blocked" ? cleanBlockedBy(input.blockedBy) : input.blockedBy;
|
|
210
|
+
const workspace = wantsLand || input.workspaceId ? resolveWorkspace(ctx, input, taskId) : undefined;
|
|
211
|
+
if (wantsLand && (!workspace || workspace.mode !== "isolated" || !workspace.worktreePath)) {
|
|
212
|
+
throw new ValidationError("complete delivery requires a live isolated workspace");
|
|
213
|
+
}
|
|
214
|
+
const landMeta: TaskDeliverableEventMetadata["land"] | undefined = wantsLand && workspace
|
|
215
|
+
? { requested: true, triggered: false, reason: "pending", workspaceId: workspace.id, workspaceStatus: workspace.status }
|
|
216
|
+
: undefined;
|
|
217
|
+
const delivery = recordTaskDeliverable(taskId, {
|
|
218
|
+
...input,
|
|
219
|
+
actor,
|
|
220
|
+
...(reason ? { reason } : {}),
|
|
221
|
+
...(blockedBy ? { blockedBy } : {}),
|
|
222
|
+
...(workspace ? { workspaceId: workspace.id } : {}),
|
|
223
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
224
|
+
...(landMeta ? { land: landMeta } : {}),
|
|
225
|
+
});
|
|
226
|
+
const eventMeta = deliveryEvent(taskId, input.idempotencyKey)?.metadata;
|
|
227
|
+
if (delivery.created) {
|
|
228
|
+
const deliveryKey = input.idempotencyKey ?? `event:${delivery.event.id}`;
|
|
229
|
+
for (const gate of gateWrites(input.gates, deliveryKey)) {
|
|
230
|
+
recordGate(taskId, { ...gate, actor });
|
|
231
|
+
}
|
|
232
|
+
if (input.disposition === "blocked") {
|
|
233
|
+
setTaskStatus(taskId, { status: "blocked", blockedBy: blockedBy as TaskBlocker[], note: reason, actor });
|
|
234
|
+
} else if (input.disposition === "failed") {
|
|
235
|
+
setTaskStatus(taskId, { status: "failed", note: reason, actor });
|
|
236
|
+
} else if (isVerdictOnlyComplete) {
|
|
237
|
+
setTaskStatus(taskId, { status: "done", note: "complete delivery recorded without land", actor });
|
|
238
|
+
}
|
|
239
|
+
const detail = getTaskDetail(taskId);
|
|
240
|
+
if (detail) {
|
|
241
|
+
emitTaskChanged(detail, "task.deliverable");
|
|
242
|
+
notifyDelivery(taskId, delivery.event.id, eventMeta ?? (delivery.event.metadata as unknown as TaskDeliverableEventMetadata));
|
|
243
|
+
if (input.disposition === "failed") {
|
|
244
|
+
emitTaskSemanticNotification({ task: detail, transition: "gate-failed", actor, occurrenceKey: `event:${delivery.event.id}`, outcome: { disposition: "failed" } });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let land: Record<string, unknown> | undefined;
|
|
250
|
+
if (wantsLand && delivery.created && workspace) {
|
|
251
|
+
markAgentWorkFinal(actor, "task-deliver");
|
|
252
|
+
const result = await applyWorkspaceAction(workspace, {
|
|
253
|
+
action: "ready",
|
|
254
|
+
agentId: actor,
|
|
255
|
+
detail: `Task T${taskId} delivered complete`,
|
|
256
|
+
auditMetadata: { via: "task_deliver", actor, taskId, deliveryEventId: delivery.event.id },
|
|
257
|
+
});
|
|
258
|
+
if (!result.ok) {
|
|
259
|
+
land = { requested: true, triggered: false, error: result.error, workspaceId: workspace.id };
|
|
260
|
+
} else {
|
|
261
|
+
if (result.command) emitCommand(result.command);
|
|
262
|
+
land = { requested: true, triggered: true, workspace: result.workspace, command: result.command };
|
|
263
|
+
}
|
|
264
|
+
} else if (wantsLand && !delivery.created) {
|
|
265
|
+
land = { requested: true, triggered: false, reason: "duplicate-delivery" };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
taskId,
|
|
270
|
+
event: delivery.event,
|
|
271
|
+
created: delivery.created,
|
|
272
|
+
delivery: eventMeta ?? delivery.event.metadata,
|
|
273
|
+
...(land ? { land } : {}),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
@@ -38,7 +38,7 @@ import { errMessage } from "agent-relay-sdk";
|
|
|
38
38
|
import { findSpawnCommandByRequestId } from "../commands-db";
|
|
39
39
|
import { defaultIssueRepo } from "../config";
|
|
40
40
|
import { emitTaskChanged } from "../sse";
|
|
41
|
-
import type { CommandStatus, Task, TaskDetail, TaskEvent, TaskGateState, TaskListItem, TaskStatus, WorkspaceRecord } from "../types";
|
|
41
|
+
import type { CommandStatus, Task, TaskDeliverableEventMetadata, TaskDetail, TaskEvent, TaskGateState, TaskListItem, TaskStatus, WorkspaceRecord } from "../types";
|
|
42
42
|
import { removeSubscriptionsForTask } from "../db/notification-subscriptions";
|
|
43
43
|
import { emitRelayEvent } from "../events";
|
|
44
44
|
import { routeNotification } from "../notification-router";
|
|
@@ -389,6 +389,30 @@ export function reconcileAutoScaffoldedTaskReaps(opts: ReconcileAutoScaffoldedTa
|
|
|
389
389
|
return { dryRun, scanned: completed.length + canceled.length + skipped.length, completed, canceled, skipped };
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
+
function latestDeliverableEvent(taskId: number): TaskEvent | undefined {
|
|
393
|
+
return [...listTaskEvents(taskId)].reverse().find((event) => event.type === "deliverable");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function restoreDeliveredDisposition(task: TaskDetail, event: TaskEvent): void {
|
|
397
|
+
const metadata = event.metadata as unknown as Partial<TaskDeliverableEventMetadata>;
|
|
398
|
+
if (metadata.disposition === "failed" && task.status !== "failed") {
|
|
399
|
+
setTaskEntityStatus(task.id, {
|
|
400
|
+
status: "failed",
|
|
401
|
+
...(typeof metadata.reason === "string" && metadata.reason.trim() ? { note: metadata.reason } : {}),
|
|
402
|
+
actor: "task-lifecycle",
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (metadata.disposition === "blocked" && task.status !== "blocked" && Array.isArray(metadata.blockedBy) && metadata.blockedBy.length > 0) {
|
|
407
|
+
setTaskEntityStatus(task.id, {
|
|
408
|
+
status: "blocked",
|
|
409
|
+
blockedBy: metadata.blockedBy,
|
|
410
|
+
...(typeof metadata.reason === "string" && metadata.reason.trim() ? { note: metadata.reason } : {}),
|
|
411
|
+
actor: "task-lifecycle",
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
392
416
|
// #889 — finalize bound tasks for a reaped agent whose doneWhen is "agent-exit" / "reaped".
|
|
393
417
|
// Called by the transient reaper after requesting shutdown so ephemeral run-and-exit workers
|
|
394
418
|
// don't leave their auto-scaffolded Task stuck in_progress forever.
|
|
@@ -407,6 +431,13 @@ export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnReque
|
|
|
407
431
|
if (!isAgentExitDoneWhen(task.doneWhen)) continue;
|
|
408
432
|
if (opts.producedWork === false) {
|
|
409
433
|
try {
|
|
434
|
+
const deliverable = latestDeliverableEvent(taskId);
|
|
435
|
+
if (deliverable) {
|
|
436
|
+
if (!task.reapedAt) markTaskAgentReaped(taskId, { agentId, reason: opts.reason ?? "agent-exit" });
|
|
437
|
+
restoreDeliveredDisposition(task, deliverable);
|
|
438
|
+
finalized.push(taskId);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
410
441
|
if (!task.reapedAt) markTaskAgentReaped(taskId, { agentId, reason: opts.reason ?? "agent-exit" });
|
|
411
442
|
setTaskEntityStatus(taskId, { status: "canceled", note: "worker reaped without landing or producing work; task canceled", actor: "task-lifecycle" });
|
|
412
443
|
notifyEmptyTransientReap(agentId, task, opts);
|
package/src/workspace-actions.ts
CHANGED
|
@@ -28,7 +28,7 @@ export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
|
|
|
28
28
|
import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
|
|
29
29
|
import { DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES, worktreeReapable } from "./workspace-phase";
|
|
30
30
|
import { CLEAR_MERGE_NO_PROGRESS, scheduleRepoMerge } from "./workspace-auto-merge";
|
|
31
|
-
import { fetchHostMergePreview } from "./workspace-probe";
|
|
31
|
+
import { fetchHostMergePreview, refreshWorkspaceGitState } from "./workspace-probe";
|
|
32
32
|
import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
33
33
|
|
|
34
34
|
// Single source of truth for the action verb set. The route's `optionalEnum` and
|
|
@@ -251,7 +251,9 @@ export async function applyWorkspaceAction(workspace: WorkspaceRecord, input: Ap
|
|
|
251
251
|
if (!nextStatus) return { ok: false, httpStatus: 400, error: `unsupported action: ${action}` };
|
|
252
252
|
|
|
253
253
|
if (READY_TO_LAND_STATUSES.has(nextStatus)) {
|
|
254
|
-
|
|
254
|
+
await refreshWorkspaceGitState(workspace);
|
|
255
|
+
const freshWorkspace = getWorkspace(workspace.id) ?? workspace;
|
|
256
|
+
const dirtyCount = metadataNumber(freshWorkspace.metadata, "gitDirtyCount") ?? 0;
|
|
255
257
|
if (dirtyCount > 0) {
|
|
256
258
|
return {
|
|
257
259
|
ok: false,
|