agent-relay-server 0.20.0 → 0.22.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 +4 -1
- package/package.json +2 -2
- package/public/index.html +698 -140
- package/runner/src/adapter.ts +1 -1
- package/scripts/install-bin-shim.cjs +16 -3
- package/src/agent-ref.ts +217 -0
- package/src/automations.ts +4 -1
- package/src/cli.ts +68 -8
- package/src/config-store.ts +35 -1
- package/src/context-router.ts +7 -7
- package/src/db.ts +111 -29
- package/src/maintenance.ts +9 -4
- package/src/managed-policy.ts +4 -1
- package/src/mcp.ts +452 -69
- package/src/routes.ts +94 -170
- package/src/runtime-tokens.ts +26 -1
- package/src/security.ts +3 -1
- package/src/workspace-actions.ts +336 -0
- package/src/workspace-phase.ts +181 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// Single home for workspace lifecycle actions, shared by the HTTP route
|
|
2
|
+
// (`POST /api/workspaces/:id/actions`) and the `relay_workspace_*` MCP tools.
|
|
3
|
+
//
|
|
4
|
+
// Why this module exists: the action→status mapping, claim/merge/deps branching,
|
|
5
|
+
// and the per-action audit descriptors are exactly the kind of rule that used to
|
|
6
|
+
// live in one handler and silently drift when a second surface re-implemented it.
|
|
7
|
+
// Both surfaces authorize FIRST (route via authorizeRoute, MCP via token scopes)
|
|
8
|
+
// and then call `applyWorkspaceAction` — which does NO auth. Like
|
|
9
|
+
// `requestWorkspaceMerge`, the core does not emit the command event; it returns
|
|
10
|
+
// the Command and the caller emits via its own `emitCommand` (kept module-local).
|
|
11
|
+
|
|
12
|
+
import { createCommand } from "./commands-db";
|
|
13
|
+
import { isPathWithinBase } from "./utils";
|
|
14
|
+
import {
|
|
15
|
+
createActivityEvent,
|
|
16
|
+
getWorkspace,
|
|
17
|
+
listOrchestrators,
|
|
18
|
+
patchWorkspaceMetadata,
|
|
19
|
+
updateWorkspaceStatus,
|
|
20
|
+
} from "./db";
|
|
21
|
+
import { emitActivityEvent } from "./sse";
|
|
22
|
+
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
23
|
+
import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
|
|
24
|
+
import type { Command, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
25
|
+
|
|
26
|
+
// Single source of truth for the action verb set. The route's `optionalEnum` and
|
|
27
|
+
// the MCP tool surface both import this so they can never drift out of sync.
|
|
28
|
+
export const WORKSPACE_ACTIONS = [
|
|
29
|
+
"status",
|
|
30
|
+
"ready",
|
|
31
|
+
"conflict-found",
|
|
32
|
+
"request-review",
|
|
33
|
+
"merge-plan",
|
|
34
|
+
"merge",
|
|
35
|
+
"abandon",
|
|
36
|
+
"cleanup",
|
|
37
|
+
"claim",
|
|
38
|
+
"release-claim",
|
|
39
|
+
"deps-refresh",
|
|
40
|
+
] as const;
|
|
41
|
+
export type WorkspaceAction = (typeof WORKSPACE_ACTIONS)[number];
|
|
42
|
+
|
|
43
|
+
export interface ApplyWorkspaceActionInput {
|
|
44
|
+
action: WorkspaceAction;
|
|
45
|
+
agentId?: string;
|
|
46
|
+
detail?: string;
|
|
47
|
+
metadata?: Record<string, unknown>;
|
|
48
|
+
// merge
|
|
49
|
+
strategy?: WorkspaceMergeStrategy;
|
|
50
|
+
deleteBranch?: boolean;
|
|
51
|
+
prTitle?: string;
|
|
52
|
+
prBody?: string;
|
|
53
|
+
// claim / release-claim
|
|
54
|
+
purpose?: string;
|
|
55
|
+
// deps-refresh
|
|
56
|
+
checkOnly?: boolean;
|
|
57
|
+
// Provenance merged into the activity-event metadata (who/how) — the HTTP route
|
|
58
|
+
// passes `authAuditMetadata(req)`, the MCP surface passes `{ via: "mcp", actor }`.
|
|
59
|
+
auditMetadata?: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type WorkspaceActionResult =
|
|
63
|
+
| {
|
|
64
|
+
ok: true;
|
|
65
|
+
httpStatus: number;
|
|
66
|
+
workspace: WorkspaceRecord;
|
|
67
|
+
command?: Command;
|
|
68
|
+
claim?: ReturnType<typeof workspaceActiveClaim>;
|
|
69
|
+
}
|
|
70
|
+
| { ok: false; httpStatus: number; error: string };
|
|
71
|
+
|
|
72
|
+
// Map of the plain status-transition actions to their target status. merge/cleanup
|
|
73
|
+
// (command-emitting) and status/claim/release/deps (special) are handled explicitly.
|
|
74
|
+
const STATUS_BY_ACTION: Partial<Record<WorkspaceAction, WorkspaceStatus>> = {
|
|
75
|
+
ready: "ready",
|
|
76
|
+
"conflict-found": "conflict",
|
|
77
|
+
"request-review": "review_requested",
|
|
78
|
+
"merge-plan": "merge_planned",
|
|
79
|
+
abandon: "abandoned",
|
|
80
|
+
cleanup: "cleanup_requested",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Mirror of routes.ts `auditEvent` (source-tagged, never-throws) so the activity
|
|
84
|
+
// feed gets identical entries whether the action came over HTTP or MCP.
|
|
85
|
+
function recordWorkspaceAudit(a: {
|
|
86
|
+
action: WorkspaceAction;
|
|
87
|
+
workspace: WorkspaceRecord;
|
|
88
|
+
agentId?: string;
|
|
89
|
+
title: string;
|
|
90
|
+
body?: string;
|
|
91
|
+
icon: string;
|
|
92
|
+
auditMetadata?: Record<string, unknown>;
|
|
93
|
+
extra?: Record<string, unknown>;
|
|
94
|
+
}): void {
|
|
95
|
+
try {
|
|
96
|
+
const event = createActivityEvent({
|
|
97
|
+
clientId: `workspace-${a.action}-${a.workspace.id}-${Date.now()}`,
|
|
98
|
+
kind: "state",
|
|
99
|
+
title: a.title,
|
|
100
|
+
body: a.body,
|
|
101
|
+
meta: a.workspace.branch ?? a.workspace.id,
|
|
102
|
+
icon: a.icon,
|
|
103
|
+
view: "orchestrators",
|
|
104
|
+
agentId: a.agentId,
|
|
105
|
+
metadata: {
|
|
106
|
+
source: "server",
|
|
107
|
+
action: a.action,
|
|
108
|
+
workspaceId: a.workspace.id,
|
|
109
|
+
repoRoot: a.workspace.repoRoot,
|
|
110
|
+
worktreePath: a.workspace.worktreePath,
|
|
111
|
+
...(a.extra ?? {}),
|
|
112
|
+
...(a.auditMetadata ?? {}),
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
emitActivityEvent(event);
|
|
116
|
+
} catch {
|
|
117
|
+
// Audit trail writes must never block relay behavior.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWorkspaceActionInput): WorkspaceActionResult {
|
|
122
|
+
const { action } = input;
|
|
123
|
+
const agentId = input.agentId;
|
|
124
|
+
const detail = input.detail;
|
|
125
|
+
const metadata = input.metadata ?? {};
|
|
126
|
+
const requiresCommand = action === "cleanup" || action === "merge";
|
|
127
|
+
|
|
128
|
+
// Shared-mode rows are occupancy markers with no worktree — nothing on disk to
|
|
129
|
+
// merge or clean. Reject host commands against them up front.
|
|
130
|
+
if (requiresCommand && (workspace.mode !== "isolated" || !workspace.worktreePath)) {
|
|
131
|
+
return { ok: false, httpStatus: 422, error: `workspace ${workspace.id} has no worktree to ${action}` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (action === "status") {
|
|
135
|
+
return { ok: true, httpStatus: 200, workspace };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Steward claim/lease (#208): a TTL'd metadata lease that auto-merge yields to,
|
|
139
|
+
// so deterministic landing can't race a steward mid-validation. No status change.
|
|
140
|
+
if (action === "claim" || action === "release-claim") {
|
|
141
|
+
const release = action === "release-claim";
|
|
142
|
+
const updated = patchWorkspaceMetadata(workspace.id, claimMetadataPatch(release, agentId ?? "steward", input.purpose));
|
|
143
|
+
if (!updated) return { ok: false, httpStatus: 404, error: "workspace not found" };
|
|
144
|
+
recordWorkspaceAudit({
|
|
145
|
+
action,
|
|
146
|
+
workspace: updated,
|
|
147
|
+
agentId,
|
|
148
|
+
title: release ? "Workspace claim released" : "Workspace claimed",
|
|
149
|
+
body: detail ?? input.purpose ?? updated.worktreePath,
|
|
150
|
+
icon: "ti-lock",
|
|
151
|
+
auditMetadata: input.auditMetadata,
|
|
152
|
+
});
|
|
153
|
+
return { ok: true, httpStatus: 200, workspace: updated, claim: workspaceActiveClaim(updated) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Base merges go through the shared helper (lease + command + bind), the same
|
|
157
|
+
// path the auto-merge job uses, so both serialize per repo (issue #157).
|
|
158
|
+
if (action === "merge") {
|
|
159
|
+
const result = requestWorkspaceMerge(workspace, {
|
|
160
|
+
requestedBy: agentId ?? "dashboard",
|
|
161
|
+
strategy: input.strategy ?? "auto",
|
|
162
|
+
deleteBranch: input.deleteBranch !== false,
|
|
163
|
+
prTitle: input.prTitle,
|
|
164
|
+
prBody: input.prBody,
|
|
165
|
+
metadata: { ...metadata, ...(detail ? { detail } : {}), ...(agentId ? { updatedByAgentId: agentId } : {}) },
|
|
166
|
+
});
|
|
167
|
+
if (!result.ok) return { ok: false, httpStatus: result.status, error: result.error };
|
|
168
|
+
recordWorkspaceAudit({
|
|
169
|
+
action: "merge",
|
|
170
|
+
workspace: result.workspace,
|
|
171
|
+
agentId,
|
|
172
|
+
title: "Workspace merge",
|
|
173
|
+
body: detail ?? workspace.worktreePath,
|
|
174
|
+
icon: "ti-git-merge",
|
|
175
|
+
auditMetadata: input.auditMetadata,
|
|
176
|
+
extra: { status: result.workspace.status, commandId: result.command.id },
|
|
177
|
+
});
|
|
178
|
+
return { ok: true, httpStatus: 202, workspace: result.workspace, command: result.command };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Deps refresh (#51): self-service for the owning agent — re-provision deps the
|
|
182
|
+
// shared symlinked node_modules has gone stale on (or, checkOnly, just report).
|
|
183
|
+
if (action === "deps-refresh") {
|
|
184
|
+
if (workspace.mode !== "isolated" || !workspace.worktreePath) {
|
|
185
|
+
return { ok: false, httpStatus: 422, error: `workspace ${workspace.id} has no isolated worktree to refresh` };
|
|
186
|
+
}
|
|
187
|
+
const checkOnly = input.checkOnly === true;
|
|
188
|
+
const built = buildWorkspaceDepsRefreshCommand(workspace, agentId ?? "agent", checkOnly);
|
|
189
|
+
if (!built.ok) return { ok: false, httpStatus: built.status, error: built.error };
|
|
190
|
+
recordWorkspaceAudit({
|
|
191
|
+
action,
|
|
192
|
+
workspace,
|
|
193
|
+
agentId,
|
|
194
|
+
title: checkOnly ? "Workspace deps check" : "Workspace deps refresh",
|
|
195
|
+
body: detail ?? workspace.worktreePath,
|
|
196
|
+
icon: "ti-package",
|
|
197
|
+
auditMetadata: input.auditMetadata,
|
|
198
|
+
extra: { checkOnly, commandId: built.command.id },
|
|
199
|
+
});
|
|
200
|
+
return { ok: true, httpStatus: 202, workspace, command: built.command };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const nextStatus = STATUS_BY_ACTION[action];
|
|
204
|
+
if (!nextStatus) return { ok: false, httpStatus: 400, error: `unsupported action: ${action}` };
|
|
205
|
+
|
|
206
|
+
const updated = updateWorkspaceStatus(workspace.id, nextStatus, {
|
|
207
|
+
...metadata,
|
|
208
|
+
...(detail ? { detail } : {}),
|
|
209
|
+
...(agentId ? { updatedByAgentId: agentId } : {}),
|
|
210
|
+
lastWorkspaceAction: action,
|
|
211
|
+
lastWorkspaceActionAt: Date.now(),
|
|
212
|
+
});
|
|
213
|
+
if (!updated) return { ok: false, httpStatus: 404, error: "workspace not found" };
|
|
214
|
+
|
|
215
|
+
let command: Command | undefined;
|
|
216
|
+
if (requiresCommand) {
|
|
217
|
+
// Only `cleanup` reaches here — `merge` returned early via the shared helper.
|
|
218
|
+
const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard");
|
|
219
|
+
if (!built.ok) return { ok: false, httpStatus: built.status, error: built.error };
|
|
220
|
+
command = built.command;
|
|
221
|
+
}
|
|
222
|
+
recordWorkspaceAudit({
|
|
223
|
+
action,
|
|
224
|
+
workspace: updated,
|
|
225
|
+
agentId,
|
|
226
|
+
title: `Workspace ${action}`,
|
|
227
|
+
body: detail ?? workspace.worktreePath,
|
|
228
|
+
icon: action === "cleanup" ? "ti-trash" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
|
|
229
|
+
auditMetadata: input.auditMetadata,
|
|
230
|
+
extra: { status: updated.status, commandId: command?.id },
|
|
231
|
+
});
|
|
232
|
+
return { ok: true, httpStatus: requiresCommand ? 202 : 200, workspace: updated, command };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Build a `workspace.cleanup` command for the worktree's owning orchestrator.
|
|
236
|
+
// Moved here (from routes.ts) so the MCP surface reaches it without importing routes.
|
|
237
|
+
export function buildWorkspaceCleanupCommand(
|
|
238
|
+
workspace: WorkspaceRecord,
|
|
239
|
+
requestedBy: string,
|
|
240
|
+
): { ok: true; command: Command } | { ok: false; status: number; error: string } {
|
|
241
|
+
const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
242
|
+
const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
|
|
243
|
+
if (!owner) return { ok: false, status: 409, error: "no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record" };
|
|
244
|
+
const command = createCommand({
|
|
245
|
+
type: "workspace.cleanup",
|
|
246
|
+
source: "system",
|
|
247
|
+
target: owner.agentId,
|
|
248
|
+
correlationId: workspace.id,
|
|
249
|
+
params: {
|
|
250
|
+
action: "cleanup",
|
|
251
|
+
workspaceId: workspace.id,
|
|
252
|
+
repoRoot: workspace.repoRoot,
|
|
253
|
+
worktreePath: workspace.worktreePath,
|
|
254
|
+
branch: workspace.branch,
|
|
255
|
+
requestedBy,
|
|
256
|
+
requestedAt: Date.now(),
|
|
257
|
+
deleteBranch: true,
|
|
258
|
+
queued: owner.status !== "online",
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
return { ok: true, command };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Build a `workspace.deps-refresh` command for a worktree's owning orchestrator
|
|
265
|
+
// (issue #51). checkOnly just reports staleness. Same owner-resolution as cleanup.
|
|
266
|
+
export function buildWorkspaceDepsRefreshCommand(
|
|
267
|
+
workspace: WorkspaceRecord,
|
|
268
|
+
requestedBy: string,
|
|
269
|
+
checkOnly: boolean,
|
|
270
|
+
): { ok: true; command: Command } | { ok: false; status: number; error: string } {
|
|
271
|
+
const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
|
|
272
|
+
const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
|
|
273
|
+
if (!owner) return { ok: false, status: 409, error: "no online orchestrator owns this workspace path" };
|
|
274
|
+
const command = createCommand({
|
|
275
|
+
type: "workspace.deps-refresh",
|
|
276
|
+
source: "system",
|
|
277
|
+
target: owner.agentId,
|
|
278
|
+
correlationId: workspace.id,
|
|
279
|
+
params: {
|
|
280
|
+
action: "deps-refresh",
|
|
281
|
+
workspaceId: workspace.id,
|
|
282
|
+
repoRoot: workspace.repoRoot,
|
|
283
|
+
worktreePath: workspace.worktreePath,
|
|
284
|
+
checkOnly,
|
|
285
|
+
requestedBy,
|
|
286
|
+
requestedAt: Date.now(),
|
|
287
|
+
queued: owner.status !== "online",
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
return { ok: true, command };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export const DEFAULT_WORKSPACE_WAIT_MS = 300_000;
|
|
294
|
+
export const MAX_WORKSPACE_WAIT_MS = 600_000;
|
|
295
|
+
|
|
296
|
+
export interface WaitForWorkspaceResult {
|
|
297
|
+
workspace: WorkspaceRecord | null;
|
|
298
|
+
/** The status when the wait began. */
|
|
299
|
+
fromStatus?: WorkspaceStatus;
|
|
300
|
+
/** True when the status changed before the deadline (the actionable signal). */
|
|
301
|
+
transitioned: boolean;
|
|
302
|
+
/** True when the deadline was reached without a transition. */
|
|
303
|
+
timedOut: boolean;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Long-poll a workspace until its status changes (the actionable signal after a
|
|
307
|
+
// `ready`: the auto-merge job either lands it → `merged`/recycled-to-`active`, or
|
|
308
|
+
// stalls into `conflict`/`review_requested`), or until the deadline. A plain
|
|
309
|
+
// handler-side poll loop — no workspace event bus exists, and this stays correct
|
|
310
|
+
// and cheap. `sleep` is injectable so tests don't wait real time.
|
|
311
|
+
export async function waitForWorkspaceStatus(
|
|
312
|
+
id: string,
|
|
313
|
+
opts: { timeoutMs?: number; pollMs?: number; sleep?: (ms: number) => Promise<void> } = {},
|
|
314
|
+
): Promise<WaitForWorkspaceResult> {
|
|
315
|
+
const timeoutMs = Math.min(Math.max(opts.timeoutMs ?? DEFAULT_WORKSPACE_WAIT_MS, 0), MAX_WORKSPACE_WAIT_MS);
|
|
316
|
+
const pollMs = opts.pollMs ?? 750;
|
|
317
|
+
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
318
|
+
|
|
319
|
+
const start = getWorkspace(id);
|
|
320
|
+
if (!start) return { workspace: null, transitioned: false, timedOut: false };
|
|
321
|
+
const fromStatus = start.status;
|
|
322
|
+
const deadline = Date.now() + timeoutMs;
|
|
323
|
+
|
|
324
|
+
let current = start;
|
|
325
|
+
while (Date.now() < deadline) {
|
|
326
|
+
const remaining = deadline - Date.now();
|
|
327
|
+
await sleep(Math.min(pollMs, Math.max(0, remaining)));
|
|
328
|
+
const next = getWorkspace(id);
|
|
329
|
+
if (!next) return { workspace: null, fromStatus, transitioned: false, timedOut: false };
|
|
330
|
+
current = next;
|
|
331
|
+
if (current.status !== fromStatus) {
|
|
332
|
+
return { workspace: current, fromStatus, transitioned: true, timedOut: false };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { workspace: current, fromStatus, transitioned: false, timedOut: true };
|
|
336
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Directive status projection (#214 §2 / #235) — the single home for "what does
|
|
2
|
+
// this workspace state mean to the branch agent, and what should it do next."
|
|
3
|
+
//
|
|
4
|
+
// Why its own module (no server deps): a branch agent reads raw `status` enums
|
|
5
|
+
// and can't tell "working as designed" from "stuck" — `review_requested` looks
|
|
6
|
+
// like an escalation, an absent steward looks like nobody's handling it, the
|
|
7
|
+
// recycled `…--2` branch looks alarming. This pure projection is rendered by the
|
|
8
|
+
// CLI (`agent-relay workspace status`, a remote HTTP client) AND returned by the
|
|
9
|
+
// in-process MCP status tool, so the guidance can never drift between surfaces.
|
|
10
|
+
// Keeping it dependency-light (types only) means the CLI can import it without
|
|
11
|
+
// dragging in db/sse/merge — the server graph stays out of the client.
|
|
12
|
+
//
|
|
13
|
+
// The load-bearing decision: `ready` and `review_requested` collapse to the SAME
|
|
14
|
+
// "handed off, healthy, wait" guidance, and `actionNeeded:false` is the explicit
|
|
15
|
+
// anti-panic signal.
|
|
16
|
+
|
|
17
|
+
import type { WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
18
|
+
|
|
19
|
+
// Statuses where the worktree's lifecycle is over — landed or torn down. Single
|
|
20
|
+
// home; imported by maintenance (stale reap), routes (orphan scan), and the MCP
|
|
21
|
+
// initialize primer (don't brief an agent on a dead workspace). Was duplicated.
|
|
22
|
+
export const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
|
|
23
|
+
|
|
24
|
+
export type WorkspacePhase =
|
|
25
|
+
| "working" // active — your turn: commit, then mark ready
|
|
26
|
+
| "land-pending" // ready | review_requested — handed off; auto-merge will land it
|
|
27
|
+
| "landing" // merge_planned — merge dispatched, in progress
|
|
28
|
+
| "steward" // conflict — auto-merge couldn't; a steward is reconciling (not your job)
|
|
29
|
+
| "landed" // merged — on the base; a fresh rebased branch is coming
|
|
30
|
+
| "closed"; // abandoned | cleanup_requested | cleaned — torn down
|
|
31
|
+
|
|
32
|
+
export interface WorkspaceNextAction {
|
|
33
|
+
/** MCP tool to call (when the agent is on the MCP surface). */
|
|
34
|
+
tool?: string;
|
|
35
|
+
/** Equivalent CLI invocation (when the agent is on the shell surface). */
|
|
36
|
+
cli?: string;
|
|
37
|
+
/** When/why to take this step. */
|
|
38
|
+
when: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WorkspacePhaseView {
|
|
42
|
+
phase: WorkspacePhase;
|
|
43
|
+
/** One-line meaning of the current state, in the agent's terms. */
|
|
44
|
+
headline: string;
|
|
45
|
+
/** What to do — or an explicit statement that nothing is required. */
|
|
46
|
+
hint: string;
|
|
47
|
+
/** The crux: false means "do not intervene, wait" — the anti-panic flag. */
|
|
48
|
+
actionNeeded: boolean;
|
|
49
|
+
nextActions: WorkspaceNextAction[];
|
|
50
|
+
/** Real blockers the agent should know about (rare — most "stuck"-looking states aren't). */
|
|
51
|
+
blockers: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const WAIT_ACTION: WorkspaceNextAction = {
|
|
55
|
+
tool: "relay_workspace_status (wait:true)",
|
|
56
|
+
cli: "agent-relay workspace status --wait",
|
|
57
|
+
when: "to block until your branch lands (returns when the status changes)",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const READY_ACTION: WorkspaceNextAction = {
|
|
61
|
+
tool: "relay_workspace_ready",
|
|
62
|
+
cli: "agent-relay workspace ready",
|
|
63
|
+
when: "after you commit your work in this worktree",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Map every WorkspaceStatus to the branch agent's mental model. Statuses that
|
|
67
|
+
// look scary but are healthy (review_requested, conflict) carry actionNeeded:false
|
|
68
|
+
// and an explicit "not your job" hint.
|
|
69
|
+
export function describeWorkspacePhase(workspace: Pick<WorkspaceRecord, "status" | "branch" | "stewardAgentId">): WorkspacePhaseView {
|
|
70
|
+
switch (workspace.status) {
|
|
71
|
+
case "active":
|
|
72
|
+
return {
|
|
73
|
+
phase: "working",
|
|
74
|
+
headline: "Your isolated branch — work in progress.",
|
|
75
|
+
hint: "Commit your work in this worktree, then mark it ready. Relay rebases onto the latest base, lands, and pushes for you — do NOT push, rebase, or merge yourself, and never touch the main checkout.",
|
|
76
|
+
actionNeeded: true,
|
|
77
|
+
nextActions: [READY_ACTION],
|
|
78
|
+
blockers: [],
|
|
79
|
+
};
|
|
80
|
+
case "ready":
|
|
81
|
+
case "review_requested":
|
|
82
|
+
// The #235 crux: these are the SAME healthy "handed off, waiting" state.
|
|
83
|
+
// `review_requested` reads like an escalation but is the normal post-ready
|
|
84
|
+
// node; an absent steward is the healthy case, not a stall.
|
|
85
|
+
return {
|
|
86
|
+
phase: "land-pending",
|
|
87
|
+
headline: "Handed off — waiting for the auto-merge to land your branch. This is the normal, healthy post-ready state (not an escalation).",
|
|
88
|
+
hint: "No action needed. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned only if it can't land deterministically — so no steward visible means it's healthy, not stuck. Wait. Do NOT merge, push, resolve anything, or touch the main checkout.",
|
|
89
|
+
actionNeeded: false,
|
|
90
|
+
nextActions: [WAIT_ACTION],
|
|
91
|
+
blockers: [],
|
|
92
|
+
};
|
|
93
|
+
case "merge_planned":
|
|
94
|
+
return {
|
|
95
|
+
phase: "landing",
|
|
96
|
+
headline: "Merge dispatched — landing your branch on the base now.",
|
|
97
|
+
hint: "No action needed. When it completes you'll be moved onto a fresh rebased branch (the name gains a `--N` suffix — expected) to keep working.",
|
|
98
|
+
actionNeeded: false,
|
|
99
|
+
nextActions: [WAIT_ACTION],
|
|
100
|
+
blockers: [],
|
|
101
|
+
};
|
|
102
|
+
case "conflict":
|
|
103
|
+
return {
|
|
104
|
+
phase: "steward",
|
|
105
|
+
headline: "Auto-merge hit a conflict — a steward agent is reconciling it. Resolving it is NOT your job.",
|
|
106
|
+
hint: "Do not resolve the conflict, rebase, merge, or push yourself. The steward rebases and lands it, escalating to the human only if it truly can't. You can keep working or wait for it to land.",
|
|
107
|
+
actionNeeded: false,
|
|
108
|
+
nextActions: [WAIT_ACTION],
|
|
109
|
+
blockers: ["rebase/merge conflict against the base — a steward is handling it"],
|
|
110
|
+
};
|
|
111
|
+
case "merged":
|
|
112
|
+
return {
|
|
113
|
+
phase: "landed",
|
|
114
|
+
headline: "✅ Landed — your commits are on the base.",
|
|
115
|
+
hint: "A fresh rebased branch is being prepared; you'll continue on it (the branch name gains a `--N` suffix — expected, not an error). Keep working.",
|
|
116
|
+
actionNeeded: false,
|
|
117
|
+
nextActions: [],
|
|
118
|
+
blockers: [],
|
|
119
|
+
};
|
|
120
|
+
case "abandoned":
|
|
121
|
+
case "cleanup_requested":
|
|
122
|
+
case "cleaned":
|
|
123
|
+
return {
|
|
124
|
+
phase: "closed",
|
|
125
|
+
headline: "This workspace is being torn down.",
|
|
126
|
+
hint: "No further branch work happens here. If you still have work to do, the host will spawn you into a fresh workspace.",
|
|
127
|
+
actionNeeded: false,
|
|
128
|
+
nextActions: [],
|
|
129
|
+
blockers: [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Plain-language contract printed/returned right when an agent marks a workspace
|
|
135
|
+
// ready, so the whole "what happens next" is stated up front instead of being
|
|
136
|
+
// decoded from status enums over the following minutes (#235).
|
|
137
|
+
export function readyContract(workspace: Pick<WorkspaceRecord, "status">): string {
|
|
138
|
+
return [
|
|
139
|
+
`Marked ready. Status is now \`${workspace.status}\` — this is the normal, healthy hand-off state, not an escalation.`,
|
|
140
|
+
"Relay auto-merges clean rebases roughly every 2 minutes. A steward agent is spawned (after a short delay) ONLY if it can't land deterministically — so no steward appearing means it's working, not stuck.",
|
|
141
|
+
"Wait with `relay_workspace_status wait:true` (or `agent-relay workspace status --wait`) — it returns the moment your branch lands.",
|
|
142
|
+
"On landing you'll be moved onto a fresh rebased branch (its name gains a `--N` suffix — expected). Keep working there.",
|
|
143
|
+
"Do NOT merge, push, rebase, resolve conflicts, or `cd` into the main checkout — Relay (and the steward) own all of that.",
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Mode-tailored MCP `initialize` instructions primer (#214 §3). The relay's MCP
|
|
148
|
+
// endpoint returns this in the `instructions` field ONLY for callers that own an
|
|
149
|
+
// isolated worktree, so worktree agents get a concise one-time map of the
|
|
150
|
+
// merge-back flow even if they connected without the runner's spawn briefing —
|
|
151
|
+
// and shared-workspace agents never see the noise. Kept short and in the same
|
|
152
|
+
// home as the projection so the wording can't drift from `status`/`ready`.
|
|
153
|
+
export function worktreeMcpInstructions(workspace: Pick<WorkspaceRecord, "branch" | "baseRef">): string {
|
|
154
|
+
const branch = workspace.branch ? `\`${workspace.branch}\`` : "an isolated agent branch";
|
|
155
|
+
const base = workspace.baseRef ? `\`${workspace.baseRef}\`` : "the base branch";
|
|
156
|
+
return [
|
|
157
|
+
`You are in an isolated git worktree on branch ${branch}, based on ${base} — NOT the main checkout. ${base} moves under you as other agents land in parallel; that's expected.`,
|
|
158
|
+
"Changes reach the base via: commit your work, then call `relay_workspace_ready`. Relay rebases onto the latest base, lands, and pushes for you.",
|
|
159
|
+
"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.",
|
|
160
|
+
"After `ready` the status is `review_requested` — that is the NORMAL, healthy hand-off state, not a stall. Call `relay_workspace_status` with `wait:true` to block until your branch lands; you'll then continue on a fresh rebased branch (name gains a `--N` suffix).",
|
|
161
|
+
"Call `relay_workspace_status` anytime to see where you are and the exact next step.",
|
|
162
|
+
].join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Positive land receipt for the wait-result path: when a long-poll observes the
|
|
166
|
+
// status leave a pending/landing state into landed/recycled, tell the agent its
|
|
167
|
+
// work is on the base and which branch to keep working on. Returns null when the
|
|
168
|
+
// transition isn't a land (so callers can omit the field).
|
|
169
|
+
export function landReceipt(
|
|
170
|
+
fromStatus: WorkspaceStatus | undefined,
|
|
171
|
+
workspace: Pick<WorkspaceRecord, "status" | "branch">,
|
|
172
|
+
): string | null {
|
|
173
|
+
const wasPending = fromStatus === "ready" || fromStatus === "review_requested" || fromStatus === "merge_planned" || fromStatus === "conflict";
|
|
174
|
+
if (!wasPending) return null;
|
|
175
|
+
// merged, or recycled straight back to active on a fresh branch.
|
|
176
|
+
if (workspace.status === "merged" || workspace.status === "active") {
|
|
177
|
+
const branch = workspace.branch ? `\`${workspace.branch}\`` : "a fresh rebased branch";
|
|
178
|
+
return `✅ Landed — your commits are on the base. You're now on ${branch}; keep working there.`;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|