agent-relay-orchestrator 0.18.0 → 0.19.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 +2 -2
- package/src/api.ts +3 -3
- package/src/control.ts +43 -56
- package/src/provider-probe.ts +2 -1
- package/src/spawn.ts +26 -53
- package/src/terminal-stream.ts +2 -1
- package/src/workspace-probe.ts +129 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"agent-relay-sdk": "0.2.
|
|
19
|
+
"agent-relay-sdk": "0.2.10"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "latest",
|
package/src/api.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
2
|
-
import { stringValue } from "agent-relay-sdk";
|
|
2
|
+
import { errMessage, stringValue } from "agent-relay-sdk";
|
|
3
3
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import type { ServerWebSocket } from "bun";
|
|
5
5
|
import { proxyArtifactRequest } from "./artifact-proxy";
|
|
@@ -694,7 +694,7 @@ function startTerminalSocket(ws: TerminalSocket): void {
|
|
|
694
694
|
if (!ws.data.synced) void syncAndBackfill(ws);
|
|
695
695
|
}, 700);
|
|
696
696
|
} catch (e) {
|
|
697
|
-
ws.send(JSON.stringify({ type: "error", error:
|
|
697
|
+
ws.send(JSON.stringify({ type: "error", error: errMessage(e) }));
|
|
698
698
|
ws.close();
|
|
699
699
|
}
|
|
700
700
|
}
|
|
@@ -771,7 +771,7 @@ function handleTerminalSocketMessage(ws: TerminalSocket, data: string | Buffer):
|
|
|
771
771
|
if (ws.data.synced) void sendBackfill(ws);
|
|
772
772
|
}
|
|
773
773
|
} catch (e) {
|
|
774
|
-
ws.send(JSON.stringify({ type: "error", error:
|
|
774
|
+
ws.send(JSON.stringify({ type: "error", error: errMessage(e) }));
|
|
775
775
|
}
|
|
776
776
|
}
|
|
777
777
|
|
package/src/control.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { isRecord } from "agent-relay-sdk";
|
|
1
|
+
import { errMessage, isRecord, normalizeWorkspaceMode } from "agent-relay-sdk";
|
|
2
2
|
import type { OrchestratorConfig } from "./config";
|
|
3
3
|
import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
|
|
4
4
|
import { handleSelfUpgrade } from "./self-upgrade";
|
|
5
5
|
import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
|
|
6
|
-
import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace } from "./workspace-probe";
|
|
6
|
+
import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps } from "./workspace-probe";
|
|
7
7
|
|
|
8
8
|
interface ControlHandler {
|
|
9
9
|
handleCommand(command: RelayCommand): Promise<boolean>;
|
|
@@ -121,6 +121,13 @@ export function createControlHandler(
|
|
|
121
121
|
prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
|
|
122
122
|
});
|
|
123
123
|
await relay.updateCommand(command.id, "succeeded", result as unknown as Record<string, unknown>);
|
|
124
|
+
} else if (command.type === "workspace.deps-refresh") {
|
|
125
|
+
const result = refreshWorkspaceDeps(
|
|
126
|
+
typeof command.params.repoRoot === "string" ? command.params.repoRoot : "",
|
|
127
|
+
typeof command.params.worktreePath === "string" ? command.params.worktreePath : "",
|
|
128
|
+
{ checkOnly: command.params.checkOnly === true },
|
|
129
|
+
);
|
|
130
|
+
await relay.updateCommand(command.id, "succeeded", { workspaceId: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined, ...result });
|
|
124
131
|
} else if (command.type === "workspace.prune") {
|
|
125
132
|
const result = pruneWorktrees({
|
|
126
133
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
@@ -137,7 +144,7 @@ export function createControlHandler(
|
|
|
137
144
|
await relay.updateManagedAgents(managedAgents);
|
|
138
145
|
return true;
|
|
139
146
|
} catch (error) {
|
|
140
|
-
await relay.updateCommand(command.id, "failed", undefined,
|
|
147
|
+
await relay.updateCommand(command.id, "failed", undefined, errMessage(error));
|
|
141
148
|
return false;
|
|
142
149
|
}
|
|
143
150
|
}
|
|
@@ -176,59 +183,43 @@ function shutdownTimeoutMs(ctrl: Record<string, any>): number | undefined {
|
|
|
176
183
|
return Number.isSafeInteger(ctrl.timeoutMs) && ctrl.timeoutMs > 0 ? Math.min(ctrl.timeoutMs, 60_000) : undefined;
|
|
177
184
|
}
|
|
178
185
|
|
|
179
|
-
|
|
186
|
+
// Both the control-spawn and restart-source paths build SpawnOptions from a
|
|
187
|
+
// loose record in the same way. The two prior copies differed only in two
|
|
188
|
+
// immaterial spots, unified here to the safer form:
|
|
189
|
+
// - provider: `=== "codex" ? "codex" : "claude"` (SPAWN_PROVIDERS is exactly
|
|
190
|
+
// claude|codex, so this matches control's `|| "claude"` for every valid
|
|
191
|
+
// input and stays defensive against junk).
|
|
192
|
+
// - cwd: `source.cwd || baseDir` — an empty-string cwd now falls back to
|
|
193
|
+
// baseDir on the restart path too (was a latent bug; empty cwd is invalid).
|
|
194
|
+
export function spawnOptionsFromRecord(source: Record<string, any>, config: OrchestratorConfig): SpawnOptions {
|
|
180
195
|
return {
|
|
181
|
-
provider:
|
|
182
|
-
cwd:
|
|
183
|
-
rig: typeof
|
|
184
|
-
model: modelFromControl(
|
|
185
|
-
effort: typeof
|
|
186
|
-
profile: typeof
|
|
187
|
-
workspaceMode:
|
|
188
|
-
workspaceSymlinks: stringArray(
|
|
189
|
-
agentProfile: isRecord(
|
|
190
|
-
label: typeof
|
|
191
|
-
agentId: typeof
|
|
192
|
-
approvalMode: typeof
|
|
193
|
-
prompt: typeof
|
|
194
|
-
systemPromptAppend: typeof
|
|
195
|
-
tags: stringArray(
|
|
196
|
-
capabilities: stringArray(
|
|
197
|
-
providerArgs: stringArray(
|
|
198
|
-
env: stringRecord(
|
|
199
|
-
policyName: typeof
|
|
200
|
-
spawnRequestId: typeof
|
|
201
|
-
automationId: typeof
|
|
202
|
-
automationRunId: typeof
|
|
196
|
+
provider: source.provider === "codex" ? "codex" : "claude",
|
|
197
|
+
cwd: source.cwd || config.baseDir,
|
|
198
|
+
rig: typeof source.rig === "string" ? source.rig : undefined,
|
|
199
|
+
model: modelFromControl(source),
|
|
200
|
+
effort: typeof source.effort === "string" ? source.effort : undefined,
|
|
201
|
+
profile: typeof source.profile === "string" ? source.profile : undefined,
|
|
202
|
+
workspaceMode: normalizeWorkspaceMode(source.workspaceMode),
|
|
203
|
+
workspaceSymlinks: stringArray(source.workspaceSymlinks),
|
|
204
|
+
agentProfile: isRecord(source.agentProfile) ? source.agentProfile : undefined,
|
|
205
|
+
label: typeof source.label === "string" ? source.label : undefined,
|
|
206
|
+
agentId: typeof source.agentId === "string" ? source.agentId : undefined,
|
|
207
|
+
approvalMode: typeof source.approvalMode === "string" ? source.approvalMode : "guarded",
|
|
208
|
+
prompt: typeof source.prompt === "string" ? source.prompt : undefined,
|
|
209
|
+
systemPromptAppend: typeof source.systemPromptAppend === "string" ? source.systemPromptAppend : undefined,
|
|
210
|
+
tags: stringArray(source.tags),
|
|
211
|
+
capabilities: stringArray(source.capabilities),
|
|
212
|
+
providerArgs: stringArray(source.providerArgs),
|
|
213
|
+
env: stringRecord(source.env),
|
|
214
|
+
policyName: typeof source.policyName === "string" ? source.policyName : undefined,
|
|
215
|
+
spawnRequestId: typeof source.spawnRequestId === "string" ? source.spawnRequestId : undefined,
|
|
216
|
+
automationId: typeof source.automationId === "string" ? source.automationId : undefined,
|
|
217
|
+
automationRunId: typeof source.automationRunId === "string" ? source.automationRunId : undefined,
|
|
203
218
|
};
|
|
204
219
|
}
|
|
205
220
|
|
|
206
|
-
export
|
|
207
|
-
|
|
208
|
-
provider: restartSource.provider === "codex" ? "codex" : "claude",
|
|
209
|
-
cwd: typeof restartSource.cwd === "string" ? restartSource.cwd : config.baseDir,
|
|
210
|
-
rig: typeof restartSource.rig === "string" ? restartSource.rig : undefined,
|
|
211
|
-
model: modelFromControl(restartSource),
|
|
212
|
-
effort: typeof restartSource.effort === "string" ? restartSource.effort : undefined,
|
|
213
|
-
profile: typeof restartSource.profile === "string" ? restartSource.profile : undefined,
|
|
214
|
-
workspaceMode: workspaceMode(restartSource.workspaceMode),
|
|
215
|
-
workspaceSymlinks: stringArray(restartSource.workspaceSymlinks),
|
|
216
|
-
agentProfile: isRecord(restartSource.agentProfile) ? restartSource.agentProfile : undefined,
|
|
217
|
-
label: typeof restartSource.label === "string" ? restartSource.label : undefined,
|
|
218
|
-
agentId: typeof restartSource.agentId === "string" ? restartSource.agentId : undefined,
|
|
219
|
-
approvalMode: typeof restartSource.approvalMode === "string" ? restartSource.approvalMode : "guarded",
|
|
220
|
-
prompt: typeof restartSource.prompt === "string" ? restartSource.prompt : undefined,
|
|
221
|
-
systemPromptAppend: typeof restartSource.systemPromptAppend === "string" ? restartSource.systemPromptAppend : undefined,
|
|
222
|
-
tags: stringArray(restartSource.tags),
|
|
223
|
-
capabilities: stringArray(restartSource.capabilities),
|
|
224
|
-
providerArgs: stringArray(restartSource.providerArgs),
|
|
225
|
-
env: stringRecord(restartSource.env),
|
|
226
|
-
policyName: typeof restartSource.policyName === "string" ? restartSource.policyName : undefined,
|
|
227
|
-
spawnRequestId: typeof restartSource.spawnRequestId === "string" ? restartSource.spawnRequestId : undefined,
|
|
228
|
-
automationId: typeof restartSource.automationId === "string" ? restartSource.automationId : undefined,
|
|
229
|
-
automationRunId: typeof restartSource.automationRunId === "string" ? restartSource.automationRunId : undefined,
|
|
230
|
-
};
|
|
231
|
-
}
|
|
221
|
+
export const spawnOptionsFromControl = spawnOptionsFromRecord;
|
|
222
|
+
export const spawnOptionsFromRestartSource = spawnOptionsFromRecord;
|
|
232
223
|
|
|
233
224
|
function modelFromControl(ctrl: Record<string, any>): string | undefined {
|
|
234
225
|
return typeof ctrl.providerModel === "string" ? ctrl.providerModel : typeof ctrl.model === "string" ? ctrl.model : undefined;
|
|
@@ -243,7 +234,3 @@ function stringRecord(value: unknown): Record<string, string> | undefined {
|
|
|
243
234
|
function stringArray(value: unknown): string[] | undefined {
|
|
244
235
|
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : undefined;
|
|
245
236
|
}
|
|
246
|
-
|
|
247
|
-
function workspaceMode(value: unknown): SpawnOptions["workspaceMode"] {
|
|
248
|
-
return value === "isolated" || value === "shared" || value === "inherit" ? value : undefined;
|
|
249
|
-
}
|
package/src/provider-probe.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { accessSync, constants, existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { delimiter, join, resolve } from "node:path";
|
|
4
4
|
import { providerCatalogList, type ProviderCatalogEntry } from "agent-relay-sdk/provider-catalog";
|
|
5
|
+
import { errMessage } from "agent-relay-sdk";
|
|
5
6
|
import type { OrchestratorConfig } from "./config";
|
|
6
7
|
import { VERSION } from "./version";
|
|
7
8
|
|
|
@@ -130,7 +131,7 @@ async function probeCommand(
|
|
|
130
131
|
};
|
|
131
132
|
} catch (error) {
|
|
132
133
|
try { proc?.kill("SIGKILL"); } catch {}
|
|
133
|
-
return { command: displayCommand, path, ok: false, error:
|
|
134
|
+
return { command: displayCommand, path, ok: false, error: errMessage(error) };
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
package/src/spawn.ts
CHANGED
|
@@ -6,6 +6,11 @@ import type { OrchestratorConfig } from "./config";
|
|
|
6
6
|
import type { ManagedAgentReport, ManagedSessionExitDiagnostics } from "./relay";
|
|
7
7
|
import { resolveSpawnWorkspace } from "./workspace-probe";
|
|
8
8
|
import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
|
|
9
|
+
import { errMessage } from "agent-relay-sdk";
|
|
10
|
+
import { isPidAlive, parseProcStateIsZombie } from "agent-relay-sdk/process-utils";
|
|
11
|
+
import { shellEscape } from "agent-relay-sdk/shell-utils";
|
|
12
|
+
import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
|
|
13
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
9
14
|
|
|
10
15
|
export interface SpawnOptions {
|
|
11
16
|
provider: "claude" | "codex";
|
|
@@ -151,8 +156,8 @@ export function isWithinBaseDir(path: string, baseDir: string): boolean {
|
|
|
151
156
|
}
|
|
152
157
|
|
|
153
158
|
export function sessionName(config: OrchestratorConfig, provider: string, label: string, uniqueId?: string): string {
|
|
154
|
-
const clean = label
|
|
155
|
-
const suffix = uniqueId ? `-${uniqueId
|
|
159
|
+
const clean = sanitizeFsName(label, { replacement: "-", lowercase: true });
|
|
160
|
+
const suffix = uniqueId ? `-${sanitizeFsName(uniqueId, { replacement: "-", lowercase: true }).slice(-8)}` : "";
|
|
156
161
|
return `${config.tmuxPrefix}-${provider}-${clean}${suffix}`;
|
|
157
162
|
}
|
|
158
163
|
|
|
@@ -242,7 +247,7 @@ function logFilePath(name: string): string {
|
|
|
242
247
|
}
|
|
243
248
|
|
|
244
249
|
function runnerInfoPath(name: string): string {
|
|
245
|
-
const safe = name
|
|
250
|
+
const safe = sanitizeFsName(name, { replacement: "-", trimEdge: true, fallback: "runner" });
|
|
246
251
|
return join(RUNNER_INFO_DIR, `${safe}.json`);
|
|
247
252
|
}
|
|
248
253
|
|
|
@@ -285,30 +290,9 @@ function removeSessionRecord(name: string): void {
|
|
|
285
290
|
saveState(loadState().filter((r) => r.name !== name));
|
|
286
291
|
}
|
|
287
292
|
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
export function parseProcStateIsZombie(statusText: string): boolean {
|
|
292
|
-
const match = statusText.match(/^State:\s+(\w)/m);
|
|
293
|
-
return match?.[1] === "Z";
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function isZombie(pid: number): boolean {
|
|
297
|
-
try {
|
|
298
|
-
return parseProcStateIsZombie(readFileSync(`/proc/${pid}/status`, "utf8"));
|
|
299
|
-
} catch {
|
|
300
|
-
return false;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
export function isPidAlive(pid: number): boolean {
|
|
305
|
-
try {
|
|
306
|
-
process.kill(pid, 0);
|
|
307
|
-
} catch {
|
|
308
|
-
return false;
|
|
309
|
-
}
|
|
310
|
-
return !isZombie(pid);
|
|
311
|
-
}
|
|
293
|
+
// Zombie-aware liveness primitives are shared with the runner via the SDK.
|
|
294
|
+
// Re-exported so existing `./spawn` consumers (and tests) keep resolving them.
|
|
295
|
+
export { isPidAlive, parseProcStateIsZombie };
|
|
312
296
|
|
|
313
297
|
function sessionSupervisor(record?: Pick<SessionRecord, "supervisor">): SessionSupervisor {
|
|
314
298
|
return record?.supervisor ?? { type: "process" };
|
|
@@ -554,8 +538,8 @@ function validateAttachSpec(spec: TerminalAttachSpec, config: OrchestratorConfig
|
|
|
554
538
|
}
|
|
555
539
|
|
|
556
540
|
function guestSessionName(config: OrchestratorConfig, provider: string, agentId: string): string {
|
|
557
|
-
const cleanProvider = provider
|
|
558
|
-
const cleanAgent = agentId
|
|
541
|
+
const cleanProvider = sanitizeFsName(provider, { replacement: "-", lowercase: true, fallback: "provider" });
|
|
542
|
+
const cleanAgent = sanitizeFsName(agentId, { replacement: "-", lowercase: true, maxLen: 48, fallback: "agent" });
|
|
559
543
|
return `${config.tmuxPrefix}-guest-${cleanProvider}-${cleanAgent}-${crypto.randomUUID().slice(0, 8)}`;
|
|
560
544
|
}
|
|
561
545
|
|
|
@@ -585,7 +569,7 @@ function spawnRunner(name: string, command: string[], cwd: string, env: Record<s
|
|
|
585
569
|
try {
|
|
586
570
|
return spawnSystemdRunner(name, command, cwd, env, logFile);
|
|
587
571
|
} catch (error) {
|
|
588
|
-
console.error(`[orchestrator] systemd runner supervisor unavailable for ${name}: ${
|
|
572
|
+
console.error(`[orchestrator] systemd runner supervisor unavailable for ${name}: ${errMessage(error)}`);
|
|
589
573
|
console.error("[orchestrator] Falling back to process child; this agent will not survive orchestrator service restart.");
|
|
590
574
|
}
|
|
591
575
|
}
|
|
@@ -660,12 +644,12 @@ function spawnSystemdRunner(name: string, command: string[], cwd: string, env: R
|
|
|
660
644
|
}
|
|
661
645
|
|
|
662
646
|
export function systemdUnitName(session: string): string {
|
|
663
|
-
const safe = session
|
|
647
|
+
const safe = sanitizeFsName(session, { replacement: "-", trimEdge: true, fallback: "agent" });
|
|
664
648
|
return `agent-relay-managed-${safe}`.slice(0, 180);
|
|
665
649
|
}
|
|
666
650
|
|
|
667
651
|
function launchScriptPath(session: string): string {
|
|
668
|
-
const safe = session
|
|
652
|
+
const safe = sanitizeFsName(session, { replacement: "-", trimEdge: true, fallback: "agent" });
|
|
669
653
|
return join(SESSION_DIR, `${safe}.sh`);
|
|
670
654
|
}
|
|
671
655
|
|
|
@@ -755,7 +739,7 @@ function logFileDiagnostics(logFile: string): Pick<ManagedSessionExitDiagnostics
|
|
|
755
739
|
};
|
|
756
740
|
} catch (error) {
|
|
757
741
|
return {
|
|
758
|
-
logUnavailable:
|
|
742
|
+
logUnavailable: errMessage(error),
|
|
759
743
|
};
|
|
760
744
|
}
|
|
761
745
|
}
|
|
@@ -954,9 +938,10 @@ export function captureSession(
|
|
|
954
938
|
};
|
|
955
939
|
}
|
|
956
940
|
|
|
957
|
-
//
|
|
941
|
+
// Shared with the runner's logger via the SDK helper, so reader + writer
|
|
942
|
+
// resolve the same session-mirror filename for a given agent id.
|
|
958
943
|
function safeMirrorLogName(value: string): string {
|
|
959
|
-
return value
|
|
944
|
+
return sanitizeFsName(value, { replacement: "_", maxLen: 180 });
|
|
960
945
|
}
|
|
961
946
|
|
|
962
947
|
// Read the clean, ANSI-free session-mirror diagnostics log for a managed agent.
|
|
@@ -1167,18 +1152,8 @@ export function tmuxSocketForSession(name: string): string | undefined {
|
|
|
1167
1152
|
return record ? readRunnerInfo(record)?.tmuxSocket : undefined;
|
|
1168
1153
|
}
|
|
1169
1154
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
function tmuxHasSession(name: string, socketName?: string): boolean {
|
|
1175
|
-
const result = Bun.spawnSync(tmuxCommand(socketName, "has-session", "-t", name), {
|
|
1176
|
-
stdin: "ignore",
|
|
1177
|
-
stdout: "ignore",
|
|
1178
|
-
stderr: "ignore",
|
|
1179
|
-
});
|
|
1180
|
-
return result.exitCode === 0;
|
|
1181
|
-
}
|
|
1155
|
+
// Shared tmux helpers; tmuxCommand re-exported for ./terminal-stream.
|
|
1156
|
+
export { tmuxCommand };
|
|
1182
1157
|
|
|
1183
1158
|
// Lightweight liveness for the live terminal stream's backfill metadata — avoids a full
|
|
1184
1159
|
// capture-pane just to learn whether the pane/agent are still up.
|
|
@@ -1329,12 +1304,10 @@ export async function recoverExistingSessions(
|
|
|
1329
1304
|
}
|
|
1330
1305
|
|
|
1331
1306
|
function managedAgentId(config: OrchestratorConfig, provider: string, label: string): string {
|
|
1332
|
-
const cleanHost = config.hostname
|
|
1333
|
-
const cleanLabel = label
|
|
1307
|
+
const cleanHost = sanitizeFsName(config.hostname, { replacement: "-", lowercase: true });
|
|
1308
|
+
const cleanLabel = sanitizeFsName(label, { replacement: "-", lowercase: true });
|
|
1334
1309
|
return `${cleanHost}-${provider}-${cleanLabel}-${crypto.randomUUID().slice(0, 8)}`;
|
|
1335
1310
|
}
|
|
1336
1311
|
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
1340
|
-
}
|
|
1312
|
+
// Shared shell-quoting; re-exported so `./spawn` consumers + tests resolve it.
|
|
1313
|
+
export { shellEscape };
|
package/src/terminal-stream.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { captureConsistent, sessionLiveness, tmuxCommand, tmuxSocketForSession, type TerminalSnapshot } from "./spawn";
|
|
29
29
|
import type { OrchestratorConfig } from "./config";
|
|
30
|
+
import { errMessage } from "agent-relay-sdk";
|
|
30
31
|
|
|
31
32
|
const FLUSH_MS = Math.max(0, Number(process.env.AGENT_RELAY_TERMINAL_FLUSH_MS) || 6);
|
|
32
33
|
const FLUSH_MAX_BYTES = Math.max(4096, Number(process.env.AGENT_RELAY_TERMINAL_FLUSH_MAX_BYTES) || 65536);
|
|
@@ -230,7 +231,7 @@ class SessionStream {
|
|
|
230
231
|
stderr: "ignore",
|
|
231
232
|
});
|
|
232
233
|
} catch (e) {
|
|
233
|
-
this.fail(
|
|
234
|
+
this.fail(errMessage(e));
|
|
234
235
|
return;
|
|
235
236
|
}
|
|
236
237
|
void this.readLoop();
|
package/src/workspace-probe.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readdirSync, statSync, symlinkSync, type Dirent } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, type Dirent } from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
-
import type { WorkspaceDepsProvision, WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceMergePreview, WorkspaceMergeResult, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree, WorkspaceStatus, WorkspaceSymlinkProvision } from "agent-relay-sdk";
|
|
5
|
+
import type { WorkspaceDepsProvision, WorkspaceDepsRefreshDir, WorkspaceDepsRefreshResult, WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceMergePreview, WorkspaceMergeResult, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree, WorkspaceStatus, WorkspaceSymlinkProvision } from "agent-relay-sdk";
|
|
6
|
+
import { errMessage } from "agent-relay-sdk";
|
|
7
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
6
8
|
|
|
7
9
|
const MAX_DIFF_PATCH_BYTES = 200_000;
|
|
8
10
|
|
|
@@ -83,7 +85,7 @@ export async function probeWorkspace(requestedPath: string): Promise<WorkspacePr
|
|
|
83
85
|
const stat = statSync(path);
|
|
84
86
|
if (!stat.isDirectory()) return { path, isGitRepo: false, error: `Not a directory: ${path}` };
|
|
85
87
|
} catch (error) {
|
|
86
|
-
return { path, isGitRepo: false, error:
|
|
88
|
+
return { path, isGitRepo: false, error: errMessage(error) };
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
const root = git(["rev-parse", "--show-toplevel"], path);
|
|
@@ -278,7 +280,7 @@ export function provisionWorkspaceSymlinks(
|
|
|
278
280
|
try {
|
|
279
281
|
rels = resolveSymlinkPattern(repoRoot, pattern);
|
|
280
282
|
} catch (error) {
|
|
281
|
-
errors.push(`${pattern}: ${
|
|
283
|
+
errors.push(`${pattern}: ${errMessage(error)}`);
|
|
282
284
|
continue;
|
|
283
285
|
}
|
|
284
286
|
for (const rel of rels) {
|
|
@@ -292,7 +294,7 @@ export function provisionWorkspaceSymlinks(
|
|
|
292
294
|
symlinkSync(source, target, lstatSync(source).isDirectory() ? "dir" : "file");
|
|
293
295
|
linked.push(rel);
|
|
294
296
|
} catch (error) {
|
|
295
|
-
errors.push(`${rel}: ${
|
|
297
|
+
errors.push(`${rel}: ${errMessage(error)}`);
|
|
296
298
|
}
|
|
297
299
|
}
|
|
298
300
|
}
|
|
@@ -300,6 +302,17 @@ export function provisionWorkspaceSymlinks(
|
|
|
300
302
|
return errors.length ? { linked, errors } : { linked };
|
|
301
303
|
}
|
|
302
304
|
|
|
305
|
+
/** Run the detected package manager's install in a single dir. Shared by fresh
|
|
306
|
+
* provisioning and the deps refresh (issue #51) so the install invocation lives
|
|
307
|
+
* in one place. */
|
|
308
|
+
function installDir(dir: string): { ok: boolean; packageManager?: string; error?: string } {
|
|
309
|
+
const pm = detectPackageManager(dir);
|
|
310
|
+
if (!pm) return { ok: false, error: "no recognized lockfile" };
|
|
311
|
+
const proc = Bun.spawnSync(pm.install, { cwd: dir, stdin: "ignore", stdout: "ignore", stderr: "pipe", env: process.env });
|
|
312
|
+
if (proc.exitCode === 0) return { ok: true, packageManager: pm.pm };
|
|
313
|
+
return { ok: false, packageManager: pm.pm, error: proc.stderr.toString().trim().slice(0, 300) };
|
|
314
|
+
}
|
|
315
|
+
|
|
303
316
|
/** Run a package install in each dir that owns a lockfile. Root install covers
|
|
304
317
|
* the package manager's workspace members; standalone sub-projects with their
|
|
305
318
|
* own lockfile (e.g. dashboard) get a separate install. */
|
|
@@ -309,21 +322,117 @@ function installNodeModules(worktreePath: string): { installed: string[]; packag
|
|
|
309
322
|
let packageManager: string | undefined;
|
|
310
323
|
let error: string | undefined;
|
|
311
324
|
for (const rel of dirs) {
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
if (
|
|
315
|
-
packageManager = pm.pm;
|
|
316
|
-
const proc = Bun.spawnSync(pm.install, { cwd: dir, stdin: "ignore", stdout: "ignore", stderr: "pipe", env: process.env });
|
|
317
|
-
if (proc.exitCode === 0) {
|
|
325
|
+
const result = installDir(join(worktreePath, rel));
|
|
326
|
+
if (result.packageManager) packageManager = result.packageManager;
|
|
327
|
+
if (result.ok) {
|
|
318
328
|
installed.push(rel || ".");
|
|
319
329
|
} else {
|
|
320
|
-
const detail = `${rel || "."}: ${
|
|
330
|
+
const detail = `${rel || "."}: ${result.error ?? "install failed"}`;
|
|
321
331
|
error = error ? `${error}; ${detail}` : detail;
|
|
322
332
|
}
|
|
323
333
|
}
|
|
324
334
|
return { installed, packageManager, error };
|
|
325
335
|
}
|
|
326
336
|
|
|
337
|
+
const DEP_FIELDS = ["dependencies", "devDependencies"] as const;
|
|
338
|
+
|
|
339
|
+
/** Top-level deps a package.json declares that MUST be present for typecheck/build
|
|
340
|
+
* (dependencies + devDependencies). peer/optional are excluded — their absence is
|
|
341
|
+
* legitimate and would cause spurious refreshes. */
|
|
342
|
+
function declaredDeps(pkgPath: string): string[] {
|
|
343
|
+
try {
|
|
344
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
|
345
|
+
const names = new Set<string>();
|
|
346
|
+
for (const field of DEP_FIELDS) {
|
|
347
|
+
const deps = pkg[field];
|
|
348
|
+
if (deps && typeof deps === "object") for (const name of Object.keys(deps as Record<string, unknown>)) names.add(name);
|
|
349
|
+
}
|
|
350
|
+
return [...names];
|
|
351
|
+
} catch {
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Declared deps absent from `dir`/node_modules (resolves through a symlink to the
|
|
357
|
+
* shared source). `join` handles scoped names (`@scope/pkg`). This is the staleness
|
|
358
|
+
* signal: a dep added to the base after the worktree's node_modules was provisioned. */
|
|
359
|
+
function missingDeps(dir: string): string[] {
|
|
360
|
+
const nm = join(dir, "node_modules");
|
|
361
|
+
return declaredDeps(join(dir, "package.json")).filter((name) => !existsSync(join(nm, name)));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function isSymlink(p: string): boolean {
|
|
365
|
+
try {
|
|
366
|
+
return lstatSync(p).isSymbolicLink();
|
|
367
|
+
} catch {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Re-provision an isolated worktree's deps when the shared (symlinked) node_modules
|
|
374
|
+
* has gone stale relative to the worktree's package.json — i.e. a dep was added to
|
|
375
|
+
* the base AFTER the worktree was created (issue #51). For each stale project dir,
|
|
376
|
+
* replace the shared symlink with a REAL, isolated install so we never `bun install`
|
|
377
|
+
* through the symlink into the source checkout (forbidden + races sibling worktrees).
|
|
378
|
+
* `checkOnly` reports staleness without installing. Never throws.
|
|
379
|
+
*/
|
|
380
|
+
export function refreshWorkspaceDeps(repoRoot: string, worktreePath: string, opts: { checkOnly?: boolean } = {}): WorkspaceDepsRefreshResult {
|
|
381
|
+
const requested = (process.env.AGENT_RELAY_WORKSPACE_DEPS || "symlink").toLowerCase();
|
|
382
|
+
if (requested === "none") return { refreshed: false, dirs: [], error: "deps provisioning disabled (AGENT_RELAY_WORKSPACE_DEPS=none)" };
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
const dirs = scanProjectDirs(worktreePath, NODE_MODULES_SCAN_DEPTH, (dir) => existsSync(join(dir, "package.json")) && detectPackageManager(dir) !== undefined);
|
|
386
|
+
const results: WorkspaceDepsRefreshDir[] = [];
|
|
387
|
+
for (const rel of dirs) {
|
|
388
|
+
const dir = join(worktreePath, rel);
|
|
389
|
+
const missing = missingDeps(dir);
|
|
390
|
+
const label = rel || ".";
|
|
391
|
+
if (missing.length === 0) {
|
|
392
|
+
results.push({ dir: label, status: "ok" });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (opts.checkOnly) {
|
|
396
|
+
results.push({ dir: label, status: "stale", missing: missing.slice(0, 20), wasSymlink: isSymlink(join(dir, "node_modules")) });
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const nm = join(dir, "node_modules");
|
|
400
|
+
const wasSymlink = isSymlink(nm);
|
|
401
|
+
// Drop the shared symlink first so the install writes a real dir here, not
|
|
402
|
+
// through the link into the source checkout. rmSync on a symlink unlinks only
|
|
403
|
+
// the link — the source node_modules is untouched.
|
|
404
|
+
if (wasSymlink) {
|
|
405
|
+
try { rmSync(nm); } catch { /* fall through; install may still succeed */ }
|
|
406
|
+
}
|
|
407
|
+
const result = installDir(dir);
|
|
408
|
+
if (result.ok) {
|
|
409
|
+
const stillMissing = missingDeps(dir);
|
|
410
|
+
results.push({
|
|
411
|
+
dir: label,
|
|
412
|
+
status: stillMissing.length ? "failed" : "installed",
|
|
413
|
+
missing: missing.slice(0, 20),
|
|
414
|
+
wasSymlink,
|
|
415
|
+
...(result.packageManager ? { packageManager: result.packageManager } : {}),
|
|
416
|
+
...(stillMissing.length ? { error: `still missing after install: ${stillMissing.slice(0, 10).join(", ")}` } : {}),
|
|
417
|
+
});
|
|
418
|
+
} else {
|
|
419
|
+
// Install failed — restore the prior symlink so the worktree isn't left with
|
|
420
|
+
// NO deps at all (worse than stale). Best-effort.
|
|
421
|
+
if (wasSymlink) {
|
|
422
|
+
try { symlinkSync(join(repoRoot, rel, "node_modules"), nm, "dir"); } catch { /* leave as-is */ }
|
|
423
|
+
}
|
|
424
|
+
results.push({ dir: label, status: "failed", missing: missing.slice(0, 20), wasSymlink, ...(result.packageManager ? { packageManager: result.packageManager } : {}), ...(result.error ? { error: result.error } : {}) });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const refreshed = results.some((r) => r.status === "installed");
|
|
428
|
+
const stale = results.some((r) => r.status === "stale" || r.status === "failed");
|
|
429
|
+
const failed = results.find((r) => r.status === "failed");
|
|
430
|
+
return { refreshed, ...(stale ? { stale } : {}), dirs: results, ...(failed ? { error: `${failed.dir}: ${failed.error ?? "install failed"}` } : {}) };
|
|
431
|
+
} catch (error) {
|
|
432
|
+
return { refreshed: false, dirs: [], error: errMessage(error) };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
327
436
|
/**
|
|
328
437
|
* Provision node_modules into a freshly created isolated worktree (#159).
|
|
329
438
|
* Default: symlink the source checkout's node_modules (instant, matches what
|
|
@@ -351,7 +460,7 @@ export function provisionWorkspaceDeps(repoRoot: string, worktreePath: string):
|
|
|
351
460
|
...(result.error ? { error: result.error } : {}),
|
|
352
461
|
};
|
|
353
462
|
} catch (error) {
|
|
354
|
-
return { mode: "none", error:
|
|
463
|
+
return { mode: "none", error: errMessage(error) };
|
|
355
464
|
}
|
|
356
465
|
}
|
|
357
466
|
|
|
@@ -819,7 +928,12 @@ function mergeRebaseFf(
|
|
|
819
928
|
if (switched.ok) {
|
|
820
929
|
// The old branch now equals base (just fast-forwarded) — drop the litter.
|
|
821
930
|
const oldDeleted = git(["branch", "-D", branch], repoRoot).ok;
|
|
822
|
-
|
|
931
|
+
// The worktree just moved onto the advanced base, which may declare deps the
|
|
932
|
+
// shared (symlinked) node_modules lacks. Re-provision so the recycled session
|
|
933
|
+
// continues from a buildable state (issue #51). No-op when nothing is stale.
|
|
934
|
+
const depsRefresh = refreshWorkspaceDeps(repoRoot, worktreePath);
|
|
935
|
+
const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
|
|
936
|
+
return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), error: undefined });
|
|
823
937
|
}
|
|
824
938
|
// Recycle failed — keep the existing branch. Still landed, still active.
|
|
825
939
|
return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branchDeleted: false, pushed, error: undefined });
|
|
@@ -866,10 +980,5 @@ function repoSlug(repoRoot: string): string {
|
|
|
866
980
|
}
|
|
867
981
|
|
|
868
982
|
function safeSegment(value: string, max: number): string {
|
|
869
|
-
return value
|
|
870
|
-
.trim()
|
|
871
|
-
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
|
872
|
-
.replace(/^-+|-+$/g, "")
|
|
873
|
-
.slice(0, max)
|
|
874
|
-
|| "workspace";
|
|
983
|
+
return sanitizeFsName(value, { replacement: "-", trimWhitespace: true, trimEdge: true, maxLen: max, fallback: "workspace" });
|
|
875
984
|
}
|