agent-relay-server 0.29.0 → 0.30.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.
@@ -15,13 +15,23 @@
15
15
  // anti-panic signal.
16
16
 
17
17
  import { TERMINAL_WORKSPACE_STATUS_VALUES } from "agent-relay-sdk";
18
- import type { WorkspaceRecord, WorkspaceStatus } from "./types";
18
+ import type { BranchState, WorkspaceRecord, WorkspaceStatus } from "./types";
19
+ import { workspaceActiveClaim } from "./workspace-claim";
19
20
 
20
21
  // Statuses where the worktree's lifecycle is over — landed or torn down. Single
21
22
  // home; imported by maintenance (stale reap), routes (orphan scan), and the MCP
22
23
  // initialize primer (don't brief an agent on a dead workspace). Was duplicated.
23
24
  export const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(TERMINAL_WORKSPACE_STATUS_VALUES);
24
25
 
26
+ // The "this is the worktree the agent is actively branch-working in" predicate:
27
+ // isolated mode and not yet landed/torn down. SINGLE HOME — the MCP owner-workspace
28
+ // resolver, the db owner lookup, and the #236 badge enrichment all mean the same
29
+ // thing; they drifted into private copies before. listWorkspaces is ORDER BY
30
+ // updated_at DESC, so the first match per owner is the most recent live worktree.
31
+ export function isLiveIsolatedWorkspace(ws: Pick<WorkspaceRecord, "mode" | "status">): boolean {
32
+ return ws.mode === "isolated" && !TERMINAL_WORKSPACE_STATUSES.has(ws.status);
33
+ }
34
+
25
35
  // The "handed off, waiting to land" statuses — an agent has finished and the
26
36
  // auto-merge-back is responsible for getting the branch onto base. SINGLE HOME:
27
37
  // the auto-land consumer (maintenance `autoMergeCleanFastForwards`) and the
@@ -209,6 +219,64 @@ export function describeWorkspacePhase(
209
219
  }
210
220
  }
211
221
 
222
+ function metaNumber(meta: Record<string, unknown> | undefined, key: string): number | undefined {
223
+ const v = meta?.[key];
224
+ return typeof v === "number" ? v : undefined;
225
+ }
226
+
227
+ // THE branch-state projection for the human (#236) — the sibling of
228
+ // describeWorkspacePhase, which targets the agent. Maps a workspace to one compact
229
+ // state for the agent-card/chat badge, so the recurring "does this agent have
230
+ // unlanded work, and where is it in the merge-back" question is answered at a
231
+ // glance. SINGLE HOME (server-side); the dashboard never recomputes it.
232
+ //
233
+ // idle/changes need the worktree's ahead/dirty counts, which the relay isn't in the
234
+ // git path to know live — the ~2 min conflict scan stashes them in metadata
235
+ // (`gitAhead`/`gitDirtyCount`). Until the first scan they're absent, so an active
236
+ // worktree shows the optimistic `changes` (the high-value "mark ready" affordance);
237
+ // the scan then settles it to `idle` when genuinely empty (#236 v1 option a).
238
+ //
239
+ // Returns undefined for non-branch / torn-down workspaces (no badge).
240
+ export function deriveBranchState(
241
+ workspace: Pick<WorkspaceRecord, "status" | "metadata" | "readyAt"> | null | undefined,
242
+ opts: { now?: number; stallMs?: number } = {},
243
+ ): BranchState | undefined {
244
+ if (!workspace) return undefined;
245
+ const now = opts.now ?? Date.now();
246
+ switch (workspace.status) {
247
+ case "active": {
248
+ const meta = workspace.metadata as Record<string, unknown> | undefined;
249
+ const ahead = metaNumber(meta, "gitAhead");
250
+ const dirty = metaNumber(meta, "gitDirtyCount");
251
+ if (ahead === undefined && dirty === undefined) return "changes";
252
+ return (ahead ?? 0) > 0 || (dirty ?? 0) > 0 ? "changes" : "idle";
253
+ }
254
+ case "ready":
255
+ case "review_requested":
256
+ // Handed off, waiting for the auto-merge. A steward holding the claim means a
257
+ // human-out-of-loop reconciliation is underway → 🟠, otherwise the robot has it → 🔵.
258
+ return workspaceActiveClaim(workspace, now)?.purpose === "steward" ? "steward" : "ready";
259
+ case "merge_planned":
260
+ // Merge dispatched / under reconciliation — robot-or-steward, either way not the human's move.
261
+ return "steward";
262
+ case "conflict": {
263
+ // Held by a steward → reconciling (🟠). Past the stall window with nobody
264
+ // holding it → the steward path isn't progressing → escalate to the human (🔴).
265
+ if (workspaceActiveClaim(workspace, now)?.purpose === "steward") return "steward";
266
+ const stallMs = opts.stallMs ?? LAND_PENDING_STALL_MS;
267
+ const since = typeof workspace.readyAt === "number" ? now - workspace.readyAt : undefined;
268
+ if (since !== undefined && since > stallMs) return "blocked";
269
+ return "steward";
270
+ }
271
+ case "merged":
272
+ case "abandoned":
273
+ case "cleanup_requested":
274
+ case "cleaned":
275
+ // Terminal/torn down: the owner lookup filters these out, so the badge clears.
276
+ return undefined;
277
+ }
278
+ }
279
+
212
280
  // Plain-language contract printed/returned right when an agent marks a workspace
213
281
  // ready, so the whole "what happens next" is stated up front instead of being
214
282
  // decoded from status enums over the following minutes (#235).