agent-relay-server 0.24.0 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "CONTRIBUTING.md"
34
34
  ],
35
35
  "dependencies": {
36
- "agent-relay-sdk": "0.2.13"
36
+ "agent-relay-sdk": "0.2.14"
37
37
  },
38
38
  "scripts": {
39
39
  "prepack": "bun run build:dashboard:bundle >&2",
package/public/index.html CHANGED
@@ -10168,6 +10168,8 @@ function parseSseFrame(frame) {
10168
10168
  }
10169
10169
  //#endregion
10170
10170
  //#region src/lib/api.ts
10171
+ var API_TIMEOUT_MS = 2e4;
10172
+ var SSE_STALE_MS = 35e3;
10171
10173
  var authToken = "";
10172
10174
  function setAuthToken(token) {
10173
10175
  authToken = token;
@@ -10211,11 +10213,11 @@ function openTerminalWebSocket(orchestratorId, session) {
10211
10213
  return new WebSocket(url);
10212
10214
  }
10213
10215
  function openRelayEventStream(token, handlers) {
10214
- const abort = new AbortController();
10215
10216
  const eventUrl = new URL("api/events", baseUrl()).toString();
10216
10217
  let closed = false;
10217
10218
  let retryMs = 5e3;
10218
10219
  let reconnectTimer = null;
10220
+ let activeAbort = null;
10219
10221
  const scheduleReconnect = () => {
10220
10222
  if (closed) return;
10221
10223
  reconnectTimer = setTimeout(connect, retryMs);
@@ -10226,6 +10228,11 @@ function openRelayEventStream(token, handlers) {
10226
10228
  if (data.length > 0) handlers.message(event, data.join("\n"));
10227
10229
  };
10228
10230
  const connect = async () => {
10231
+ if (closed) return;
10232
+ const ac = new AbortController();
10233
+ activeAbort = ac;
10234
+ let lastFrameAt = Date.now();
10235
+ let staleTimer = null;
10229
10236
  try {
10230
10237
  const headers = { Accept: "text/event-stream" };
10231
10238
  const effectiveToken = token || getAuthToken();
@@ -10233,10 +10240,14 @@ function openRelayEventStream(token, handlers) {
10233
10240
  const response = await fetch(eventUrl, {
10234
10241
  headers,
10235
10242
  cache: "no-store",
10236
- signal: abort.signal
10243
+ signal: ac.signal
10237
10244
  });
10238
10245
  if (!response.ok || !response.body) throw new Error(`SSE failed: ${response.status}`);
10239
10246
  handlers.connected?.();
10247
+ lastFrameAt = Date.now();
10248
+ staleTimer = setInterval(() => {
10249
+ if (Date.now() - lastFrameAt > SSE_STALE_MS) ac.abort();
10250
+ }, 5e3);
10240
10251
  const reader = response.body.getReader();
10241
10252
  const decoder = new TextDecoder();
10242
10253
  let buffer = "";
@@ -10246,6 +10257,7 @@ function openRelayEventStream(token, handlers) {
10246
10257
  buffer += decoder.decode();
10247
10258
  break;
10248
10259
  }
10260
+ lastFrameAt = Date.now();
10249
10261
  buffer += decoder.decode(value, { stream: true });
10250
10262
  let frameEnd = buffer.indexOf("\n\n");
10251
10263
  while (frameEnd >= 0) {
@@ -10256,6 +10268,7 @@ function openRelayEventStream(token, handlers) {
10256
10268
  }
10257
10269
  }
10258
10270
  } catch {} finally {
10271
+ if (staleTimer) clearInterval(staleTimer);
10259
10272
  if (closed) return;
10260
10273
  handlers.disconnected?.();
10261
10274
  scheduleReconnect();
@@ -10265,13 +10278,14 @@ function openRelayEventStream(token, handlers) {
10265
10278
  return { close() {
10266
10279
  closed = true;
10267
10280
  if (reconnectTimer) clearTimeout(reconnectTimer);
10268
- abort.abort();
10281
+ activeAbort?.abort();
10269
10282
  } };
10270
10283
  }
10271
10284
  async function api(method, path, body) {
10272
10285
  const opts = {
10273
10286
  method,
10274
- headers: {}
10287
+ headers: {},
10288
+ signal: AbortSignal.timeout(API_TIMEOUT_MS)
10275
10289
  };
10276
10290
  const headers = opts.headers;
10277
10291
  if (authToken) headers["X-Agent-Relay-Token"] = authToken;
@@ -12991,10 +13005,13 @@ var useRelayStore = create$1()(persist((set, get) => ({
12991
13005
  connectSSE() {
12992
13006
  get().disconnectSSE();
12993
13007
  set({ _es: openRelayEventStream(get().authToken, {
12994
- connected: () => set(get().connectionError ? {
12995
- connected: true,
12996
- connectionError: false
12997
- } : { connected: true }),
13008
+ connected: () => {
13009
+ set(get().connectionError ? {
13010
+ connected: true,
13011
+ connectionError: false
13012
+ } : { connected: true });
13013
+ get().refreshLiveData();
13014
+ },
12998
13015
  disconnected: () => set({ connected: false }),
12999
13016
  message: (event, data) => {
13000
13017
  if (event === "connected") return;
@@ -0,0 +1,77 @@
1
+ import { emitRelayEvent } from "./events";
2
+ import { getNotificationsConfig } from "./config-store";
3
+ import { notifySystemMessage } from "./notify";
4
+ import type { WorkspaceRecord } from "./types";
5
+
6
+ export interface BranchLandedInput {
7
+ /**
8
+ * The workspace as it was AT land time — `branch` must be the branch that landed,
9
+ * captured before any land-and-continue recycle repoints the row (#206). `ownerAgentId`
10
+ * is the author the "landed" notice is pushed to.
11
+ */
12
+ workspace: Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "baseRef" | "ownerAgentId">;
13
+ /** SHA the base now points at after the land. */
14
+ mergedSha?: string;
15
+ /** Subject line of the landed commit, when the orchestrator reported it. */
16
+ subject?: string;
17
+ /** Fresh branch the worktree was recycled onto (land-and-continue), if any. */
18
+ newBranch?: string;
19
+ }
20
+
21
+ /**
22
+ * #239 — turn an authoritative land completion into a relay-driven push so the author
23
+ * stops polling to learn it merged. Always emits the durable `branch.landed` event (the
24
+ * rest of the bus does the same); only the agent-facing push is gated, since it wakes the
25
+ * recipient. Offline authors get it on next poll via store-ahead (#234).
26
+ *
27
+ * Agents-on-main fan-out (the second #239 recipient class) lands in a follow-up commit.
28
+ */
29
+ export function notifyBranchLanded(input: BranchLandedInput): void {
30
+ const { workspace } = input;
31
+ const base = workspace.baseRef ?? "base";
32
+ const landedBranch = workspace.branch;
33
+ const shortSha = input.mergedSha ? input.mergedSha.slice(0, 12) : undefined;
34
+
35
+ emitRelayEvent({
36
+ type: "branch.landed",
37
+ source: "server",
38
+ subject: workspace.id,
39
+ data: {
40
+ workspaceId: workspace.id,
41
+ repoRoot: workspace.repoRoot,
42
+ branch: landedBranch,
43
+ base,
44
+ sha: input.mergedSha,
45
+ subject: input.subject,
46
+ author: workspace.ownerAgentId,
47
+ newBranch: input.newBranch,
48
+ },
49
+ });
50
+
51
+ const config = getNotificationsConfig();
52
+ if (!config.enabled || !config.branchLanded) return;
53
+
54
+ const author = workspace.ownerAgentId;
55
+ if (!author) return;
56
+
57
+ const branchLabel = landedBranch ? `\`${landedBranch}\`` : "Your branch";
58
+ const shaLabel = shortSha ? ` as \`${shortSha}\`` : "";
59
+ const subjectLabel = input.subject ? ` — "${input.subject}"` : "";
60
+ const continueLabel = input.newBranch
61
+ ? ` You're now on \`${input.newBranch}\` — keep working there.`
62
+ : " Worktree reclaimed.";
63
+
64
+ notifySystemMessage(author, {
65
+ subject: "Your branch landed",
66
+ body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${continueLabel}`,
67
+ payload: {
68
+ kind: "branch.landed",
69
+ workspaceId: workspace.id,
70
+ repoRoot: workspace.repoRoot,
71
+ branch: landedBranch,
72
+ base,
73
+ sha: input.mergedSha,
74
+ newBranch: input.newBranch,
75
+ },
76
+ });
77
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  InsightsConfig,
11
11
  ManagedAgentState,
12
12
  ManagedAgentStatus,
13
+ NotificationsConfig,
13
14
  SpawnApprovalMode,
14
15
  SpawnPolicy,
15
16
  SpawnProvider,
@@ -24,6 +25,8 @@ const STEWARD_NAMESPACE = "steward";
24
25
  const STEWARD_KEY = "default";
25
26
  const INSIGHTS_NAMESPACE = "insights";
26
27
  const INSIGHTS_KEY = "default";
28
+ const NOTIFICATIONS_NAMESPACE = "notifications";
29
+ const NOTIFICATIONS_KEY = "default";
27
30
  const WORKSPACE_NAMESPACE = "workspace";
28
31
  const WORKSPACE_KEY = "default";
29
32
  const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
@@ -460,6 +463,26 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
460
463
  };
461
464
  }
462
465
 
466
+ // Relay-driven lifecycle push notifications (#239 event bus). Default-on; the
467
+ // operator can flip the master switch or individual events off via the generic
468
+ // config route. Push messages wake recipients, so they must be suppressible.
469
+ const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = {
470
+ enabled: true,
471
+ branchLanded: true,
472
+ };
473
+
474
+ function validateNotificationsConfig(value: unknown): NotificationsConfig {
475
+ if (!isRecord(value)) throw new ValidationError("notifications config value must be an object");
476
+ return {
477
+ enabled: value.enabled === undefined
478
+ ? NOTIFICATIONS_CONFIG_DEFAULTS.enabled
479
+ : cleanBoolean(value.enabled, "enabled"),
480
+ branchLanded: value.branchLanded === undefined
481
+ ? NOTIFICATIONS_CONFIG_DEFAULTS.branchLanded
482
+ : cleanBoolean(value.branchLanded, "branchLanded"),
483
+ };
484
+ }
485
+
463
486
  // Global workspace provisioning config for isolated worktrees (#159 follow-up).
464
487
  // Defaults seed the two untracked paths an isolated agent almost always needs:
465
488
  // the agent guide and the rig config, both gitignored so a fresh worktree lacks them.
@@ -487,6 +510,7 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
487
510
  if (namespace === AGENT_PROFILE_NAMESPACE) return validateAgentProfile(key, value);
488
511
  if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
489
512
  if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
513
+ if (namespace === NOTIFICATIONS_NAMESPACE) return validateNotificationsConfig(value);
490
514
  if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
491
515
  if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
492
516
  return value;
@@ -620,6 +644,13 @@ export function getInsightsConfigEntry(): ConfigEntry<InsightsConfig> {
620
644
  };
621
645
  }
622
646
 
647
+ /** Lifecycle-notification config (#239), merged over defaults (always usable). */
648
+ export function getNotificationsConfig(): NotificationsConfig {
649
+ const entry = getConfig<Partial<NotificationsConfig>>(NOTIFICATIONS_NAMESPACE, NOTIFICATIONS_KEY);
650
+ if (!entry) return { ...NOTIFICATIONS_CONFIG_DEFAULTS };
651
+ return validateNotificationsConfig({ ...NOTIFICATIONS_CONFIG_DEFAULTS, ...entry.value });
652
+ }
653
+
623
654
  export function setInsightsConfig(value: unknown, updatedBy?: string): ConfigEntry<InsightsConfig> {
624
655
  return setConfig(INSIGHTS_NAMESPACE, INSIGHTS_KEY, value as InsightsConfig, updatedBy);
625
656
  }
@@ -27,14 +27,13 @@ import {
27
27
  releaseExpiredMergeLeases,
28
28
  releaseOrphanedTasks,
29
29
  runDbMaintenance,
30
- sendMessage,
31
30
  sweepArtifacts,
32
31
  updateWorkspaceStatus,
33
32
  } from "./db";
34
33
  import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
35
34
  import { requestWorkspaceMerge } from "./workspace-merge";
36
35
  import { workspaceActiveClaim } from "./workspace-claim";
37
- import { TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
36
+ import { READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
38
37
  import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
39
38
  import { getStewardConfig } from "./config-store";
40
39
  import { ensureRepoSteward } from "./steward";
@@ -46,11 +45,11 @@ import {
46
45
  emitAgentStatus,
47
46
  emitMessageClaimReleased,
48
47
  emitMessageExpired,
49
- emitNewMessage,
50
48
  emitOrchestratorStatus,
51
49
  emitPoolBindingChanged,
52
50
  emitTaskChanged,
53
51
  } from "./sse";
52
+ import { notifySystemMessage } from "./notify";
54
53
  import { pruneExpiredTokenRecords } from "./token-db";
55
54
  import type { Command, MaintenanceJob, MaintenanceJobRun } from "./types";
56
55
 
@@ -83,7 +82,10 @@ const STEWARD_WAKE_COOLDOWN_MS = Number(process.env.AGENT_RELAY_STEWARD_WAKE_COO
83
82
  const stewardEscalationMs = () => Number(process.env.AGENT_RELAY_WORKSPACE_STEWARD_ESCALATION_MS) || 60 * 60 * 1000;
84
83
  const stewardFallbackTarget = () => (process.env.AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK || "").trim();
85
84
  // Statuses that need an owner — a stranded one of these is what escalation rescues.
86
- const STRANDABLE_STATUSES = new Set<WorkspaceStatus>(["review_requested", "conflict"]);
85
+ // Derived from the shared ready-to-land set (#242) plus `conflict`, so a stranded
86
+ // `ready` worktree (no online steward) escalates to the fallback target instead of
87
+ // rotting silently — same gap that left the original #242 branch parked.
88
+ const STRANDABLE_STATUSES = new Set<WorkspaceStatus>([...READY_TO_LAND_STATUSES, "conflict"]);
87
89
  // Live statuses worth scanning. Terminal (cleaned/merged/abandoned) and
88
90
  // in-flight (cleanup_requested) states are skipped.
89
91
  const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
@@ -394,7 +396,7 @@ const definitions: MaintenanceJobDefinition[] = [
394
396
  {
395
397
  id: "workspace-auto-merge",
396
398
  title: "Workspace auto-merge",
397
- description: "Auto-merge any non-conflicting review_requested worktree into base under the per-repo lease (rebasing when the base moved on); only real or unknown conflicts are left for the steward.",
399
+ description: "Auto-merge any non-conflicting ready/review_requested worktree into base under the per-repo lease (rebasing when the base moved on); only real or unknown conflicts are left for the steward.",
398
400
  intervalMs: WORKSPACE_AUTO_MERGE_INTERVAL_MS,
399
401
  runOnStart: false,
400
402
  timeoutMs: 60 * 1000,
@@ -532,15 +534,11 @@ function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | null {
532
534
  const policyName = ensureRepoSteward(ws.repoRoot);
533
535
  if (!policyName) return null;
534
536
  try {
535
- const msg = sendMessage({
536
- from: "system",
537
- to: `policy:${policyName}`,
538
- kind: "system",
537
+ notifySystemMessage(`policy:${policyName}`, {
539
538
  subject: `Steward: ${ws.status} workspace needs attention`,
540
539
  body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
541
540
  payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
542
541
  });
543
- emitNewMessage(msg);
544
542
  getLifecycleManager().onMessageForPolicy(policyName);
545
543
  patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName });
546
544
  return policyName;
@@ -631,15 +629,11 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
631
629
  if (woke) notifiedStewards.push(woke);
632
630
  } else if (ws.stewardAgentId) {
633
631
  try {
634
- const msg = sendMessage({
635
- from: "system",
636
- to: ws.stewardAgentId,
637
- kind: "system",
632
+ notifySystemMessage(ws.stewardAgentId, {
638
633
  subject: "Workspace merge conflict",
639
634
  body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
640
635
  payload: { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
641
636
  });
642
- emitNewMessage(msg);
643
637
  notifiedStewards.push(ws.stewardAgentId);
644
638
  } catch {
645
639
  // Steward unregistered/stale — the activity event still records it.
@@ -657,9 +651,11 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
657
651
  return { scanned: candidates.length, flagged, cleared, merged, notifiedStewards };
658
652
  }
659
653
 
660
- // Deterministic auto-land (Layer 0, issue #167 / #207). Walk the "ready to land"
661
- // queue (`review_requested` isolated worktrees) and land any whose merge is
662
- // predicted conflict-free, via the shared lease-serialized merge helper — even
654
+ // Deterministic auto-land (Layer 0, issue #167 / #207 / #242). Walk the "ready to
655
+ // land" queue (isolated worktrees in any READY_TO_LAND status — `ready` from
656
+ // `relay_workspace_ready`, or `review_requested` from a failed-merge retry) and
657
+ // land any whose merge is predicted conflict-free, via the shared lease-serialized
658
+ // merge helper — even
663
659
  // when the base moved on (behind>0): mergeRebaseFf rebases onto the current base
664
660
  // before fast-forwarding. Only a predicted conflict or an unknown merge state is
665
661
  // left for the steward; clean parallel work lands with no agent in the loop.
@@ -669,7 +665,7 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
669
665
  if (!orchestrators.length) return { scanned: 0, skipped: "no online orchestrators" };
670
666
 
671
667
  const candidates = listWorkspaces().filter(
672
- (ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && ws.status === "review_requested",
668
+ (ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && READY_TO_LAND_STATUSES.has(ws.status),
673
669
  );
674
670
  const stewardEnabled = getStewardConfig().enabled;
675
671
  const merged: string[] = [];
@@ -738,7 +734,7 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
738
734
  function notifyTarget(target: string, subject: string, body: string, payload: Record<string, unknown>): string | null {
739
735
  if (!target) return null;
740
736
  try {
741
- emitNewMessage(sendMessage({ from: "system", to: target, kind: "system", subject, body, payload }));
737
+ notifySystemMessage(target, { subject, body, payload });
742
738
  return target;
743
739
  } catch {
744
740
  return null;
package/src/notify.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { sendMessage } from "./db";
2
+ import { emitNewMessage } from "./sse";
3
+ import type { Message, MessageKind } from "./types";
4
+
5
+ export interface SystemNotifyOptions {
6
+ subject?: string;
7
+ body: string;
8
+ payload?: Record<string, unknown>;
9
+ /** Defaults to "system" — a bypass-targeting kind that wakes the recipient like a prompt. */
10
+ kind?: MessageKind;
11
+ /** Sender id; defaults to "system". */
12
+ from?: string;
13
+ }
14
+
15
+ /**
16
+ * Post a system DM to one agent and fan it out over the bus. This is the one home for
17
+ * "relay tells an agent something happened" — store-ahead delivers it on next poll if the
18
+ * recipient is offline (#234). Used by the GC sweep (maintenance) and lifecycle events (#239).
19
+ */
20
+ export function notifySystemMessage(to: string, opts: SystemNotifyOptions): Message {
21
+ const msg = sendMessage({
22
+ from: opts.from ?? "system",
23
+ to,
24
+ kind: opts.kind ?? "system",
25
+ subject: opts.subject,
26
+ body: opts.body,
27
+ payload: opts.payload,
28
+ });
29
+ emitNewMessage(msg);
30
+ return msg;
31
+ }
package/src/routes.ts CHANGED
@@ -177,6 +177,7 @@ import {
177
177
  WORKSPACE_ACTIONS,
178
178
  } from "./workspace-actions";
179
179
  import { describeWorkspacePhase, landReceipt, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
180
+ import { notifyBranchLanded } from "./branch-landed";
180
181
  import type { WorkspaceDiagnostics, WorkspaceGitState, WorkspaceRecord } from "./types";
181
182
  import {
182
183
  getComponentAuth,
@@ -4478,6 +4479,9 @@ const patchCommand: Handler = async (req, params) => {
4478
4479
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4479
4480
  const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4480
4481
  if (workspaceId && resultStatus) {
4482
+ // Snapshot the row BEFORE the recycle repoints `branch` (#206) — the landed
4483
+ // branch name + author (#239 branch.landed push) come from this pre-mutation state.
4484
+ const landedWorkspace = getWorkspace(workspaceId);
4481
4485
  updateWorkspaceStatus(workspaceId, resultStatus, {
4482
4486
  mergeResult: command.result,
4483
4487
  mergeCommandId: command.id,
@@ -4491,10 +4495,20 @@ const patchCommand: Handler = async (req, params) => {
4491
4495
  // Land-and-continue (#206): the worktree was recycled onto a fresh branch.
4492
4496
  // Repoint the row so the next merge targets the live branch, not the deleted one.
4493
4497
  const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
4498
+ const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
4494
4499
  if (newBranch) {
4495
- const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
4496
4500
  setWorkspaceBranch(workspaceId, newBranch, mergedSha);
4497
4501
  }
4502
+ // #239 — push the author a "your branch landed" notice (no polling). Only on a
4503
+ // real land; a no-op resolution (#230) merged nothing, so it earns no notice.
4504
+ if (command.result.merged === true && landedWorkspace) {
4505
+ notifyBranchLanded({
4506
+ workspace: landedWorkspace,
4507
+ mergedSha,
4508
+ subject: cleanString(command.result.subject, "result.subject", { max: 200 }),
4509
+ newBranch,
4510
+ });
4511
+ }
4498
4512
  }
4499
4513
  } else if (command.status === "failed" && command.correlationId) {
4500
4514
  // Merge couldn't complete — don't leave it stuck in merge_planned.
@@ -21,6 +21,26 @@ import type { WorkspaceRecord, WorkspaceStatus } from "./types";
21
21
  // initialize primer (don't brief an agent on a dead workspace). Was duplicated.
22
22
  export const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
23
23
 
24
+ // The "handed off, waiting to land" statuses — an agent has finished and the
25
+ // auto-merge-back is responsible for getting the branch onto base. SINGLE HOME:
26
+ // the auto-land consumer (maintenance `autoMergeCleanFastForwards`) and the
27
+ // strand-escalation set MUST both derive from this. They drifted before (#242):
28
+ // `relay_workspace_ready` sets `ready`, but the consumer only scanned
29
+ // `review_requested`, so a clean `ready` worktree was never a merge candidate and
30
+ // parked forever while this phase view kept reporting "healthy, wait." Producer
31
+ // and consumer now read the same set so a `ready` can never silently fall out of
32
+ // the land queue again. (`review_requested` is the same healthy hand-off state —
33
+ // it's also where a failed auto-merge lands for a retry, see routes.ts.)
34
+ export const READY_TO_LAND_STATUSES = new Set<WorkspaceStatus>(["ready", "review_requested"]);
35
+
36
+ // How long a workspace may sit in a ready-to-land status before the directive
37
+ // projection stops saying "healthy, just wait" and surfaces it as needs-attention
38
+ // (#242 watchdog). A clean auto-merge runs ~every 2 min, so a handful of missed
39
+ // sweeps means something is wrong (wrong status filter, no online orchestrator,
40
+ // an unpushed branch, a wedged steward) and the agent/human should be told —
41
+ // instead of the old behavior where it looked healthy for 90 minutes.
42
+ export const LAND_PENDING_STALL_MS = 15 * 60 * 1000;
43
+
24
44
  export type WorkspacePhase =
25
45
  | "working" // active — your turn: commit, then mark ready
26
46
  | "land-pending" // ready | review_requested — handed off; auto-merge will land it
@@ -66,7 +86,17 @@ const READY_ACTION: WorkspaceNextAction = {
66
86
  // Map every WorkspaceStatus to the branch agent's mental model. Statuses that
67
87
  // look scary but are healthy (review_requested, conflict) carry actionNeeded:false
68
88
  // and an explicit "not your job" hint.
69
- export function describeWorkspacePhase(workspace: Pick<WorkspaceRecord, "status" | "branch" | "stewardAgentId">): WorkspacePhaseView {
89
+ //
90
+ // `opts.now` (defaults to wall-clock) drives the #242 stall watchdog: a workspace
91
+ // pending-to-land past LAND_PENDING_STALL_MS flips from the "healthy, wait" view
92
+ // to needs-attention with a real blocker, so the status surface the agent polls
93
+ // can't keep masking a stuck land. The clock is `readyAt` (set once when the agent
94
+ // marks ready, immune to the heartbeat `updated_at` bump) — not `updatedAt`, which
95
+ // keeps ticking on every heartbeat and made the stall look fresh forever.
96
+ export function describeWorkspacePhase(
97
+ workspace: Pick<WorkspaceRecord, "status" | "branch" | "stewardAgentId" | "readyAt">,
98
+ opts: { now?: number; stallMs?: number } = {},
99
+ ): WorkspacePhaseView {
70
100
  switch (workspace.status) {
71
101
  case "active":
72
102
  return {
@@ -78,10 +108,27 @@ export function describeWorkspacePhase(workspace: Pick<WorkspaceRecord, "status"
78
108
  blockers: [],
79
109
  };
80
110
  case "ready":
81
- case "review_requested":
111
+ case "review_requested": {
82
112
  // The #235 crux: these are the SAME healthy "handed off, waiting" state.
83
113
  // `review_requested` reads like an escalation but is the normal post-ready
84
114
  // node; an absent steward is the healthy case, not a stall.
115
+ const now = opts.now ?? Date.now();
116
+ const stallMs = opts.stallMs ?? LAND_PENDING_STALL_MS;
117
+ const pendingMs = typeof workspace.readyAt === "number" ? now - workspace.readyAt : undefined;
118
+ // #242 watchdog: past the bound this is no longer "healthy, wait." Surface it
119
+ // as needs-attention with a real blocker instead of the anti-panic view, so
120
+ // the agent (and the dashboard) stop reporting a wedged land as healthy.
121
+ if (pendingMs !== undefined && pendingMs > stallMs) {
122
+ const mins = Math.round(pendingMs / 60_000);
123
+ return {
124
+ phase: "land-pending",
125
+ headline: `Stalled — handed off ${mins} min ago but still hasn't landed. A clean auto-merge runs every ~2 min, so this is past the healthy window and likely stuck (no online orchestrator, an unpushed branch, or a wedged merge/steward).`,
126
+ hint: "Do NOT merge, push, rebase, or touch the main checkout yourself. Flag this to a human or the repo steward — the auto-merge/steward path isn't progressing and needs attention.",
127
+ actionNeeded: true,
128
+ nextActions: [WAIT_ACTION],
129
+ blockers: [`pending land for ~${mins} min with no progress — auto-merge/steward isn't landing it`],
130
+ };
131
+ }
85
132
  return {
86
133
  phase: "land-pending",
87
134
  headline: "Handed off — waiting for the auto-merge to land your branch. This is the normal, healthy post-ready state (not an escalation).",
@@ -90,6 +137,7 @@ export function describeWorkspacePhase(workspace: Pick<WorkspaceRecord, "status"
90
137
  nextActions: [WAIT_ACTION],
91
138
  blockers: [],
92
139
  };
140
+ }
93
141
  case "merge_planned":
94
142
  return {
95
143
  phase: "landing",
@@ -157,7 +205,7 @@ export function worktreeMcpInstructions(workspace: Pick<WorkspaceRecord, "branch
157
205
  `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
206
  "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
207
  "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).",
208
+ "After `ready` the status is `ready` (a 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
209
  "Call `relay_workspace_status` anytime to see where you are and the exact next step.",
162
210
  ].join("\n");
163
211
  }