agent-relay-orchestrator 0.91.4 → 0.92.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-orchestrator",
3
- "version": "0.91.4",
3
+ "version": "0.92.0",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "test": "bun test"
18
18
  },
19
19
  "dependencies": {
20
- "agent-relay-sdk": "0.2.71"
20
+ "agent-relay-sdk": "0.2.72"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/bun": "latest",
package/src/control.ts CHANGED
@@ -4,7 +4,7 @@ import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
4
4
  import { handleSelfUpgrade } from "./self-upgrade";
5
5
  import { readLocalProviderConfigs } from "./provider-config-migration";
6
6
  import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
7
- import { cleanupWorkspace, discardRecoveryBranch, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
7
+ import { cleanupWorkspace, discardRecoveryBranch, idleRefreshWorktree, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
8
8
  import { armWorkspacePrAutoMerge, mergeWorkspacePr, refreshWorkspacePrBranch } from "./workspace-pr";
9
9
  import type { WorkspaceMergeResult } from "agent-relay-sdk";
10
10
 
@@ -195,6 +195,16 @@ export function createControlHandler(
195
195
  force: command.params.force === true,
196
196
  });
197
197
  await relay.updateCommand(command.id, "succeeded", result as unknown as Record<string, unknown>);
198
+ } else if (command.type === "workspace.idle-refresh") {
199
+ const result = idleRefreshWorktree({
200
+ id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
201
+ repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
202
+ worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
203
+ branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
204
+ baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
205
+ baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
206
+ });
207
+ await relay.updateCommand(command.id, result.error ? "failed" : "succeeded", result as unknown as Record<string, unknown>, result.error);
198
208
  } else if (command.type === "workspace.prune") {
199
209
  const result = pruneWorktrees({
200
210
  repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
@@ -289,6 +299,7 @@ function spawnOptionsFromRecord(source: Record<string, any>, config: Orchestrato
289
299
  automationId: typeof source.automationId === "string" ? source.automationId : undefined,
290
300
  automationRunId: typeof source.automationRunId === "string" ? source.automationRunId : undefined,
291
301
  requestedVia: typeof source.requestedVia === "string" ? source.requestedVia : undefined,
302
+ resumeWorkspace: parseResumeWorkspace(source.resumeWorkspace),
292
303
  };
293
304
  }
294
305
 
@@ -308,3 +319,18 @@ function stringRecord(value: unknown): Record<string, string> | undefined {
308
319
  function stringArray(value: unknown): string[] | undefined {
309
320
  return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : undefined;
310
321
  }
322
+
323
+ function parseResumeWorkspace(value: unknown): import("./workspace-probe/types").ResumeWorkspaceTarget | undefined {
324
+ if (!isRecord(value)) return undefined;
325
+ const branch = typeof value.branch === "string" ? value.branch : undefined;
326
+ const mode = value.mode === "attach" || value.mode === "branch-from" ? value.mode : undefined;
327
+ if (!branch || !mode) return undefined;
328
+ return {
329
+ branch,
330
+ mode,
331
+ worktreePath: typeof value.worktreePath === "string" ? value.worktreePath : undefined,
332
+ workspaceId: typeof value.workspaceId === "string" ? value.workspaceId : undefined,
333
+ baseRef: typeof value.baseRef === "string" ? value.baseRef : undefined,
334
+ baseSha: typeof value.baseSha === "string" ? value.baseSha : undefined,
335
+ };
336
+ }
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import { loadConfig, initConfigFile } from "./config";
4
4
  import { createRelayClient } from "./relay";
5
5
  import type { ManagedSessionExitDiagnostics } from "./relay";
6
6
  import { createControlHandler } from "./control";
7
- import { diagnoseSessionExit, hydrateTerminalGuests, isSessionAlive, reapTerminalGuests, refreshManagedAgentReport } from "./spawn";
7
+ import { diagnoseSessionExit, hydrateTerminalGuests, managedSessionLiveness, reapTerminalGuests, refreshManagedAgentReport } from "./spawn";
8
8
  import { startApiServer } from "./api";
9
9
  import { recoverManagedAgents } from "./recovery";
10
10
  import { ProviderProbeCache } from "./provider-probe";
@@ -166,8 +166,12 @@ async function healthCheck(): Promise<void> {
166
166
  changed = true;
167
167
  }
168
168
  const sessionName = refreshed.sessionName ?? refreshed.tmuxSession;
169
- const alive = isSessionAlive(sessionName);
170
- if (!alive) {
169
+ const liveness = managedSessionLiveness(sessionName);
170
+ if (liveness === "unknown") {
171
+ console.error(`[orchestrator] Session liveness unknown: ${sessionName}; preserving and retrying next health check`);
172
+ continue;
173
+ }
174
+ if (liveness === "dead") {
171
175
  const diagnostics = diagnoseSessionExit({
172
176
  agentId: refreshed.agentId,
173
177
  policyName: refreshed.policyName,
@@ -5,7 +5,7 @@ import type { ManagedAgentReport } from "../relay";
5
5
  import { isPidAlive, parseProcStateIsZombie } from "agent-relay-sdk/process-utils";
6
6
  import { tmuxHasSession } from "agent-relay-sdk/tmux-utils";
7
7
  import { LOG_DIR, RUNNER_INFO_DIR, SESSION_DIR, STATE_FILE } from "./constants";
8
- import { systemdMainPid } from "./systemd";
8
+ import { systemdMainPid, systemdUnitLiveness, type SystemdUnitLiveness } from "./systemd";
9
9
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
10
10
  import type { RunnerInfo, SessionRecord, SessionSupervisor } from "./types";
11
11
 
@@ -66,12 +66,15 @@ export function sessionSupervisor(record?: Pick<SessionRecord, "supervisor">): S
66
66
  }
67
67
 
68
68
  export function isSessionRecordAlive(record: SessionRecord): boolean {
69
+ return sessionRecordLiveness(record) === "alive";
70
+ }
71
+
72
+ export function sessionRecordLiveness(record: SessionRecord): SystemdUnitLiveness {
69
73
  const supervisor = sessionSupervisor(record);
70
74
  if (supervisor.type === "systemd" && supervisor.unit) {
71
- const pid = systemdMainPid(supervisor.unit);
72
- return pid > 0 && isPidAlive(pid);
75
+ return systemdUnitLiveness(supervisor.unit);
73
76
  }
74
- return isPidAlive(record.pid);
77
+ return isPidAlive(record.pid) ? "alive" : "dead";
75
78
  }
76
79
 
77
80
  export function currentSessionPid(record: SessionRecord): number {
@@ -4,7 +4,7 @@ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
4
4
  import { shellEscape } from "agent-relay-sdk/shell-utils";
5
5
  import { tmuxHasSession } from "agent-relay-sdk/tmux-utils";
6
6
  import { cleanupSessionRecord } from "./supervisor";
7
- import { currentSessionPid, findSessionRecord, isSessionRecordAlive, loadState, readRunnerInfo, saveState, sessionReportFields, sessionSupervisor } from "./runtime";
7
+ import { currentSessionPid, findSessionRecord, isSessionRecordAlive, loadState, readRunnerInfo, saveState, sessionRecordLiveness, sessionReportFields, sessionSupervisor } from "./runtime";
8
8
  import type { SessionInfo, SessionRecord } from "./types";
9
9
 
10
10
  export function listSessions(prefix: string): SessionInfo[] {
@@ -31,6 +31,11 @@ export function isSessionAlive(name: string): boolean {
31
31
  return record ? isSessionRecordAlive(record) : false;
32
32
  }
33
33
 
34
+ export function managedSessionLiveness(name: string): "alive" | "dead" | "unknown" {
35
+ const record = loadState().find((r) => r.name === name);
36
+ return record ? sessionRecordLiveness(record) : "dead";
37
+ }
38
+
34
39
  export function refreshManagedAgentReport(agent: ManagedAgentReport): ManagedAgentReport {
35
40
  const record = findSessionRecord({
36
41
  tmuxSession: agent.sessionName ?? agent.tmuxSession,
@@ -56,7 +61,10 @@ export async function recoverExistingSessions(
56
61
  const alive: SessionRecord[] = [];
57
62
 
58
63
  for (const record of records) {
59
- if (!isSessionRecordAlive(record)) {
64
+ const liveness = sessionRecordLiveness(record);
65
+ if (liveness === "unknown") {
66
+ console.error(`[orchestrator] Session liveness unknown: ${record.name} (pid ${record.pid}) — preserving`);
67
+ } else if (liveness === "dead") {
60
68
  console.error(`[orchestrator] Stale session: ${record.name} (pid ${record.pid} dead) — removing`);
61
69
  cleanupSessionRecord(record);
62
70
  continue;
@@ -11,7 +11,7 @@ import { SESSION_DIR } from "./constants";
11
11
  import { disableSystemdSupervisor, forceSystemdSupervisor } from "../config";
12
12
  import { logLines } from "./log-utils";
13
13
  import { currentSessionPid, ensureSessionDir, findSessionRecord, isSessionRecordAlive, loadState, logFilePath, readRunnerInfo, removeSessionRecord, sessionSupervisor } from "./runtime";
14
- import { systemdMainPid, systemdUnitName } from "./systemd";
14
+ import { systemdMainPid, systemdUnitDiagnostics, systemdUnitName } from "./systemd";
15
15
  import type { SessionRecord, SessionSupervisor, SpawnedRunner } from "./types";
16
16
 
17
17
  export function spawnRunner(name: string, command: string[], cwd: string, env: Record<string, string>, logFile: string): SpawnedRunner {
@@ -123,44 +123,6 @@ function waitForSystemdMainPid(unit: string, timeoutMs: number): number {
123
123
  return 0;
124
124
  }
125
125
 
126
- function systemdUnitDiagnostics(unit: string): NonNullable<ManagedSessionExitDiagnostics["systemd"]> {
127
- const result = Bun.spawnSync([
128
- "systemctl", "--user", "show", `${unit}.service`,
129
- "-p", "ActiveState",
130
- "-p", "SubState",
131
- "-p", "Result",
132
- "-p", "ExecMainCode",
133
- "-p", "ExecMainStatus",
134
- "-p", "MainPID",
135
- ], {
136
- stdin: "ignore",
137
- stdout: "pipe",
138
- stderr: "pipe",
139
- });
140
- if (result.exitCode !== 0) {
141
- return {
142
- unit,
143
- unavailable: result.stderr.toString().trim() || `systemctl show exited with ${result.exitCode}`,
144
- };
145
- }
146
- const props = new Map<string, string>();
147
- for (const line of result.stdout.toString().split("\n")) {
148
- const index = line.indexOf("=");
149
- if (index <= 0) continue;
150
- props.set(line.slice(0, index), line.slice(index + 1));
151
- }
152
- const mainPid = Number(props.get("MainPID"));
153
- return {
154
- unit,
155
- activeState: props.get("ActiveState") || undefined,
156
- subState: props.get("SubState") || undefined,
157
- result: props.get("Result") || undefined,
158
- execMainCode: props.get("ExecMainCode") || undefined,
159
- execMainStatus: props.get("ExecMainStatus") || undefined,
160
- mainPid: Number.isFinite(mainPid) && mainPid > 0 ? mainPid : undefined,
161
- };
162
- }
163
-
164
126
  function logFileDiagnostics(logFile: string): Pick<ManagedSessionExitDiagnostics, "logBytes" | "logEmpty" | "logTail"> & { logUnavailable?: string } {
165
127
  try {
166
128
  const stat = statSync(logFile);
@@ -1,17 +1,84 @@
1
1
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
2
+ import { isPidAlive } from "agent-relay-sdk/process-utils";
3
+
4
+ export type SystemdUnitLiveness = "alive" | "dead" | "unknown";
5
+
6
+ export interface SystemdUnitDiagnostics {
7
+ unit: string;
8
+ activeState?: string;
9
+ subState?: string;
10
+ result?: string;
11
+ execMainCode?: string;
12
+ execMainStatus?: string;
13
+ mainPid?: number;
14
+ unavailable?: string;
15
+ }
2
16
 
3
17
  export function systemdUnitName(session: string): string {
4
18
  const safe = sanitizeFsName(session, { replacement: "-", trimEdge: true, fallback: "agent" });
5
19
  return `agent-relay-managed-${safe}`.slice(0, 180);
6
20
  }
7
21
 
8
- export function systemdMainPid(unit: string): number {
9
- const result = Bun.spawnSync(["systemctl", "--user", "show", `${unit}.service`, "-p", "MainPID", "--value"], {
22
+ export function systemdUnitDiagnostics(unit: string): SystemdUnitDiagnostics {
23
+ const result = Bun.spawnSync([
24
+ "systemctl", "--user", "show", `${unit}.service`,
25
+ "-p", "ActiveState",
26
+ "-p", "SubState",
27
+ "-p", "Result",
28
+ "-p", "ExecMainCode",
29
+ "-p", "ExecMainStatus",
30
+ "-p", "MainPID",
31
+ ], {
10
32
  stdin: "ignore",
11
33
  stdout: "pipe",
12
- stderr: "ignore",
34
+ stderr: "pipe",
13
35
  });
14
- if (result.exitCode !== 0) return 0;
15
- const pid = Number(result.stdout.toString().trim());
16
- return Number.isFinite(pid) ? pid : 0;
36
+ if (result.exitCode !== 0) {
37
+ return {
38
+ unit,
39
+ unavailable: result.stderr.toString().trim() || `systemctl show exited with ${result.exitCode}`,
40
+ };
41
+ }
42
+ const props = new Map<string, string>();
43
+ for (const line of result.stdout.toString().split("\n")) {
44
+ const index = line.indexOf("=");
45
+ if (index <= 0) continue;
46
+ props.set(line.slice(0, index), line.slice(index + 1));
47
+ }
48
+ const mainPid = Number(props.get("MainPID"));
49
+ return {
50
+ unit,
51
+ activeState: props.get("ActiveState") || undefined,
52
+ subState: props.get("SubState") || undefined,
53
+ result: props.get("Result") || undefined,
54
+ execMainCode: props.get("ExecMainCode") || undefined,
55
+ execMainStatus: props.get("ExecMainStatus") || undefined,
56
+ mainPid: Number.isFinite(mainPid) && mainPid > 0 ? mainPid : undefined,
57
+ };
58
+ }
59
+
60
+ export function systemdUnitLivenessFromDiagnostics(
61
+ diagnostics: SystemdUnitDiagnostics,
62
+ isAlive: (pid: number) => boolean,
63
+ ): SystemdUnitLiveness {
64
+ if (diagnostics.mainPid && isAlive(diagnostics.mainPid)) return "alive";
65
+ if (diagnostics.unavailable) return "unknown";
66
+
67
+ const activeState = diagnostics.activeState?.toLowerCase();
68
+ const subState = diagnostics.subState?.toLowerCase();
69
+ if (activeState === "inactive" || activeState === "failed" || subState === "dead" || subState === "failed") {
70
+ return "dead";
71
+ }
72
+ if (activeState === "active" || activeState === "activating" || activeState === "reloading" || activeState === "deactivating") {
73
+ return "unknown";
74
+ }
75
+ return "unknown";
76
+ }
77
+
78
+ export function systemdUnitLiveness(unit: string): SystemdUnitLiveness {
79
+ return systemdUnitLivenessFromDiagnostics(systemdUnitDiagnostics(unit), isPidAlive);
80
+ }
81
+
82
+ export function systemdMainPid(unit: string): number {
83
+ return systemdUnitDiagnostics(unit).mainPid ?? 0;
17
84
  }
@@ -1,5 +1,6 @@
1
1
  import type { OrchestratorConfig } from "../config";
2
2
  import type { AgentLifecycle, WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
3
+ import type { ResumeWorkspaceTarget } from "../workspace-probe/types";
3
4
 
4
5
  export interface SpawnOptions {
5
6
  provider: "claude" | "codex";
@@ -29,6 +30,8 @@ export interface SpawnOptions {
29
30
  /** How the spawn was requested (`mcp` = an agent via the MCP surface, else dashboard/CLI). Drives
30
31
  * the origin tag so an MCP-spawned worker isn't mislabeled `dashboard-spawned` (#330). */
31
32
  requestedVia?: string;
33
+ /** #635 — attach to or branch off an existing worktree instead of creating a fresh one. */
34
+ resumeWorkspace?: ResumeWorkspaceTarget;
32
35
  }
33
36
 
34
37
  export interface SessionInfo {
@@ -0,0 +1,110 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { git } from "../git";
4
+ import { refreshWorkspaceDeps } from "./deps";
5
+ import { syncBaseFromOrigin } from "./git-state";
6
+ import { nextBranchName } from "./names";
7
+ import { shortBranch } from "./parse";
8
+ import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
9
+
10
+ export interface IdleRefreshResult {
11
+ workspaceId?: string;
12
+ /** True when the worktree was successfully refreshed to origin/main. */
13
+ refreshed: boolean;
14
+ /** New branch name after refresh (`nextBranchName` --N). Present when `refreshed`. */
15
+ newBranch?: string;
16
+ /** SHA of origin/main the worktree now sits on. Present when `refreshed`. */
17
+ baseSha?: string;
18
+ /** Human-readable skip reason when `refreshed` is false and there is no error. */
19
+ reason?: string;
20
+ /** Git/system error that prevented the refresh. */
21
+ error?: string;
22
+ depsRefresh?: WorkspaceDepsRefreshResult;
23
+ }
24
+
25
+ /**
26
+ * Proactively refresh an idle branch-agent worktree to the current upstream tip
27
+ * (origin/main or equivalent), making it current before the agent's next turn.
28
+ *
29
+ * Safety predicates — ALL must hold before the worktree is touched:
30
+ * 1. Worktree is clean (zero uncommitted/untracked changes, re-verified live).
31
+ * 2. No commits ahead of base (nothing to lose on a branch reset).
32
+ * 3. A remote upstream is configured for the base branch (we know who to follow).
33
+ * 4. HEAD is an ancestor of the upstream tip (FF-only; divergence = skip).
34
+ * 5. HEAD is not already AT the upstream tip (skip if already current).
35
+ *
36
+ * On success: `checkout -B <fresh> <upstream>`, old branch deleted, deps refreshed.
37
+ * On any predicate failure: returns `{ refreshed: false, reason }` — never mutates.
38
+ */
39
+ export function idleRefreshWorktree(input: {
40
+ id?: string;
41
+ worktreePath?: string;
42
+ repoRoot?: string;
43
+ branch?: string;
44
+ baseRef?: string;
45
+ baseSha?: string;
46
+ }): IdleRefreshResult {
47
+ if (!input.worktreePath) return { refreshed: false, error: "worktreePath required" };
48
+ const worktreePath = resolve(input.worktreePath);
49
+ if (!existsSync(worktreePath)) return { workspaceId: input.id, refreshed: false, reason: "worktree missing" };
50
+ const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath;
51
+
52
+ // Predicate 1: re-check live dirty count (stored metadata may be stale).
53
+ const status = git(["status", "--porcelain"], worktreePath);
54
+ if (!status.ok) return { workspaceId: input.id, refreshed: false, error: status.stderr || "git status failed" };
55
+ const dirty = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
56
+ if (dirty > 0) return { workspaceId: input.id, refreshed: false, reason: "worktree has uncommitted changes" };
57
+
58
+ const base = input.baseRef;
59
+ if (!base) return { workspaceId: input.id, refreshed: false, reason: "no base ref" };
60
+
61
+ // Predicate 2: no commits ahead of base.
62
+ const countResult = git(["rev-list", "--count", `${base}..HEAD`], worktreePath);
63
+ const ahead = countResult.ok ? Number(countResult.stdout.trim()) : NaN;
64
+ if (!Number.isFinite(ahead)) return { workspaceId: input.id, refreshed: false, reason: "could not determine ahead count" };
65
+ if (ahead > 0) return { workspaceId: input.id, refreshed: false, reason: "workspace has commits ahead of base" };
66
+
67
+ // Predicate 3: fetch origin and resolve an upstream ref.
68
+ const startRef = syncBaseFromOrigin(worktreePath, base);
69
+ if (!startRef || startRef === base) {
70
+ return { workspaceId: input.id, refreshed: false, reason: "no upstream configured for base branch" };
71
+ }
72
+
73
+ // Predicate 5: skip if already current (HEAD SHA == upstream tip).
74
+ const headSha = git(["rev-parse", "HEAD"], worktreePath).stdout.trim();
75
+ const upstreamSha = git(["rev-parse", startRef], worktreePath).stdout.trim();
76
+ if (headSha && headSha === upstreamSha) {
77
+ return { workspaceId: input.id, refreshed: false, reason: "already current with origin" };
78
+ }
79
+
80
+ // Predicate 4: FF-only guard — HEAD must be an ancestor of the upstream tip.
81
+ // If not, the base has diverged; leave it for the conflict scan.
82
+ if (!git(["merge-base", "--is-ancestor", "HEAD", startRef], worktreePath).ok) {
83
+ return { workspaceId: input.id, refreshed: false, reason: "diverged from origin — leaving for conflict scan" };
84
+ }
85
+
86
+ // All predicates passed — safe to advance.
87
+ const liveBranch = shortBranch(git(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath).stdout || undefined);
88
+ const branch = liveBranch ?? input.branch;
89
+ if (!branch) return { workspaceId: input.id, refreshed: false, reason: "could not determine current branch" };
90
+
91
+ const fresh = nextBranchName(repoRoot, branch);
92
+ if (!git(["checkout", "-B", fresh, startRef], worktreePath).ok) {
93
+ return { workspaceId: input.id, refreshed: false, error: "git checkout failed" };
94
+ }
95
+
96
+ // Old branch is now orphaned (no commits of its own) — safe to delete.
97
+ git(["branch", "-D", branch], repoRoot);
98
+
99
+ const baseSha = git(["rev-parse", "HEAD"], worktreePath).stdout.trim() || undefined;
100
+ const depsRefresh = refreshWorkspaceDeps(repoRoot, worktreePath);
101
+ const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
102
+
103
+ return {
104
+ workspaceId: input.id,
105
+ refreshed: true,
106
+ newBranch: fresh,
107
+ ...(baseSha ? { baseSha } : {}),
108
+ ...(reportDeps ? { depsRefresh } : {}),
109
+ };
110
+ }
@@ -1,5 +1,6 @@
1
1
  export * from "./cleanup";
2
2
  export * from "./deps";
3
+ export * from "./idle-refresh";
3
4
  export * from "./git-state";
4
5
  export * from "./merge";
5
6
  export * from "./names";
@@ -373,7 +373,11 @@ function resolveNoopMerge(
373
373
  const base = preview.baseRef;
374
374
  if (base && branch) {
375
375
  const fresh = nextBranchName(repoRoot, branch);
376
- const start = startRef ?? base;
376
+ // #478 cut from the FETCHED upstream tip so even a plain noop recycle (no
377
+ // startRef) advances to origin/main, not the stale local base. The PR-land
378
+ // recycle (#423) already passes startRef (the verified upstream sha); this
379
+ // makes the rebase-ff noop path equally multi-host-correct.
380
+ const start = startRef ?? syncBaseFromOrigin(worktreePath, base) ?? base;
377
381
  if (git(["checkout", "-B", fresh, start], worktreePath).ok) {
378
382
  // Old branch's tree is already in base (that's what noop means) — safe to drop.
379
383
  const oldDeleted = git(["branch", "-D", branch], repoRoot).ok;
@@ -1,4 +1,4 @@
1
- import { mkdirSync, statSync } from "node:fs";
1
+ import { existsSync, mkdirSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import type { WorkspaceMode, WorkspaceProbe } from "agent-relay-sdk";
@@ -61,6 +61,63 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
61
61
  };
62
62
  }
63
63
 
64
+ // #635 — attach to an existing worktree instead of creating a fresh one.
65
+ if (input.resumeWorkspace?.mode === "attach") {
66
+ const resume = input.resumeWorkspace;
67
+ const wtp = resume.worktreePath;
68
+ if (!wtp) throw new Error("resumeWorkspace attach: worktreePath is required");
69
+ if (!existsSync(wtp)) throw new Error(`resumeWorkspace attach: worktree does not exist: ${wtp}`);
70
+ return {
71
+ cwd: wtp,
72
+ workspace: {
73
+ ...(resume.workspaceId ? { id: resume.workspaceId } : { id: workspaceId(input) }),
74
+ mode: "isolated",
75
+ requestedMode,
76
+ repoRoot: probe.repoRoot,
77
+ sourceCwd,
78
+ worktreePath: wtp,
79
+ branch: resume.branch,
80
+ ...(resume.baseRef ? { baseRef: resume.baseRef } : {}),
81
+ ...(resume.baseSha ? { baseSha: resume.baseSha } : {}),
82
+ status: "active",
83
+ probe,
84
+ },
85
+ };
86
+ }
87
+
88
+ // #635 — create a fresh worktree off an existing branch's HEAD instead of main's HEAD.
89
+ if (input.resumeWorkspace?.mode === "branch-from") {
90
+ const resume = input.resumeWorkspace;
91
+ const repoRoot = probe.repoRoot;
92
+ const baseSha = requireGit(["rev-parse", resume.branch], repoRoot);
93
+ const id = workspaceId(input);
94
+ const branch = await availableBranch(repoRoot, branchName(input, id));
95
+ const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : workspacesRoot(homedir());
96
+ const worktreePath = join(workspaceRoot, repoSlug(repoRoot), id);
97
+ mkdirSync(join(worktreePath, ".."), { recursive: true });
98
+ requireGit(["worktree", "add", "-b", branch, worktreePath, baseSha], repoRoot);
99
+ const deps = provisionWorkspaceDeps(repoRoot, worktreePath);
100
+ const symlinks = provisionWorkspaceSymlinks(repoRoot, worktreePath, input.workspaceSymlinks ?? []);
101
+ return {
102
+ cwd: worktreePath,
103
+ workspace: {
104
+ id,
105
+ mode: "isolated",
106
+ requestedMode,
107
+ repoRoot,
108
+ sourceCwd,
109
+ worktreePath,
110
+ branch,
111
+ baseRef: resume.branch,
112
+ baseSha,
113
+ status: "active",
114
+ deps,
115
+ ...(symlinks.linked.length || symlinks.errors ? { symlinks } : {}),
116
+ probe,
117
+ },
118
+ };
119
+ }
120
+
64
121
  const id = workspaceId(input);
65
122
  const repoRoot = probe.repoRoot;
66
123
  const baseRef = terminalBaseRef(repoRoot, probe.branch);
@@ -1,6 +1,21 @@
1
1
  import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
2
2
  import type { WorkspaceMergeResult } from "agent-relay-sdk";
3
3
 
4
+ /** Attach to or branch off an existing managed worktree instead of creating a fresh one (#635). */
5
+ export interface ResumeWorkspaceTarget {
6
+ branch: string;
7
+ /** `attach` = reuse the existing worktree as-is (crash recovery). `branch-from` = new worktree off the existing branch HEAD. */
8
+ mode: "attach" | "branch-from";
9
+ /** Resolved worktree path from the relay DB. For `attach`: must exist on disk. For `branch-from`: unused (HEAD resolved from branch). */
10
+ worktreePath?: string;
11
+ /** Existing workspace DB id. For `attach`: passed back so the relay reuses the same record (avoids duplicates). */
12
+ workspaceId?: string;
13
+ /** Preserved baseRef from the original workspace. For `attach` only. */
14
+ baseRef?: string;
15
+ /** Preserved baseSha from the original workspace. For `attach` only. */
16
+ baseSha?: string;
17
+ }
18
+
4
19
  export interface WorkspaceResolutionInput {
5
20
  cwd: string;
6
21
  label?: string;
@@ -11,6 +26,8 @@ export interface WorkspaceResolutionInput {
11
26
  workspaceSymlinks?: string[];
12
27
  automationId?: string;
13
28
  automationRunId?: string;
29
+ /** #635 — attach to or branch off an existing worktree instead of creating a fresh one. */
30
+ resumeWorkspace?: ResumeWorkspaceTarget;
14
31
  }
15
32
 
16
33
  export interface WorkspaceResolution {