@love-moon/conductor-cli 0.11.1 → 0.12.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/CHANGELOG.md +64 -0
- package/bin/conductor-config.js +8 -2
- package/bin/conductor-remote.js +93 -0
- package/bin/conductor-send-file.js +38 -16
- package/bin/conductor.js +6 -5
- package/package.json +5 -5
- package/src/daemon.js +307 -24
- package/src/remote/archive.js +129 -0
- package/src/remote/client.js +198 -0
- package/src/remote/cp.js +796 -0
- package/{bin/conductor-remote-exec.js → src/remote/exec.js} +126 -154
- package/src/remote-file-handlers.js +703 -0
package/src/daemon.js
CHANGED
|
@@ -42,6 +42,11 @@ import {
|
|
|
42
42
|
createRemoteExecHandlers,
|
|
43
43
|
handleRemoteExecRequest,
|
|
44
44
|
} from "./remote-exec-handlers.js";
|
|
45
|
+
import {
|
|
46
|
+
REMOTE_FILE_CAPABILITY,
|
|
47
|
+
createRemoteFileHandlers,
|
|
48
|
+
handleRemoteFileRequest,
|
|
49
|
+
} from "./remote-file-handlers.js";
|
|
45
50
|
import {
|
|
46
51
|
UPDATE_DAEMON_CAPABILITY,
|
|
47
52
|
createDaemonUpdateHandlers,
|
|
@@ -288,6 +293,33 @@ function getRemoteExecEnabled(userConfig) {
|
|
|
288
293
|
return true;
|
|
289
294
|
}
|
|
290
295
|
|
|
296
|
+
// Whether this host will accept `conductor remote cp` file transfers (RFC
|
|
297
|
+
// 0037). Kept separate from `remote_exec` on purpose: the two are not distinct
|
|
298
|
+
// trust levels — anyone with exec can already `base64` a file — but a host may
|
|
299
|
+
// well want scripted commands without letting the account push bytes onto its
|
|
300
|
+
// disk, and a separate flag is the only way to say that.
|
|
301
|
+
//
|
|
302
|
+
// Resolution order:
|
|
303
|
+
// 1. CONDUCTOR_REMOTE_FILE env var ("1"/"true"/"on" enable, "0"/"false"/"off" disable)
|
|
304
|
+
// 2. remote_file boolean in the resolved Conductor config.yaml
|
|
305
|
+
// 3. Default: true
|
|
306
|
+
function getRemoteFileEnabled(userConfig) {
|
|
307
|
+
const rawEnv = process.env.CONDUCTOR_REMOTE_FILE;
|
|
308
|
+
if (typeof rawEnv === "string" && rawEnv.trim()) {
|
|
309
|
+
const normalized = rawEnv.trim().toLowerCase();
|
|
310
|
+
if (normalized === "1" || normalized === "true" || normalized === "on" || normalized === "yes") {
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
if (normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no") {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (userConfig && typeof userConfig === "object" && userConfig.remote_file === false) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
291
323
|
function normalizePlanLimitType(limitType) {
|
|
292
324
|
if (typeof limitType !== "string") {
|
|
293
325
|
return null;
|
|
@@ -672,10 +704,102 @@ export function buildFireSpawnArgs({ selectedBackend, initialContent, launchConf
|
|
|
672
704
|
if (effectivePrefill) {
|
|
673
705
|
args.push("--prefill", effectivePrefill);
|
|
674
706
|
}
|
|
707
|
+
const resumeSessionId =
|
|
708
|
+
normalizeOptionalString(launchConfig?.resumeSessionId) ||
|
|
709
|
+
normalizeOptionalString(launchConfig?.resume_session_id);
|
|
710
|
+
if (resumeSessionId) {
|
|
711
|
+
args.push("--resume", resumeSessionId);
|
|
712
|
+
}
|
|
675
713
|
args.push("--");
|
|
676
714
|
return args;
|
|
677
715
|
}
|
|
678
716
|
|
|
717
|
+
// Backends whose local session stores the ai-sdk resume module can enumerate.
|
|
718
|
+
// Kept in sync with the server side of the `list_backend_sessions` RPC.
|
|
719
|
+
const LISTABLE_SESSION_BACKENDS = ["claude", "codex", "kimi"];
|
|
720
|
+
const DEFAULT_BACKEND_SESSION_LIMIT = 20;
|
|
721
|
+
const MAX_BACKEND_SESSION_LIMIT = 50;
|
|
722
|
+
|
|
723
|
+
// Lazy so a daemon running against an older ai-sdk build only fails when the
|
|
724
|
+
// RPC is actually invoked (surfaced as a per-backend error), not at load time.
|
|
725
|
+
async function listSessionsForBackendFromSdk(backendType, options) {
|
|
726
|
+
let sdk = await import("@love-moon/ai-sdk");
|
|
727
|
+
if (typeof sdk.listSessionsForBackend !== "function") {
|
|
728
|
+
// The root index can lag behind the resume module the function lives in;
|
|
729
|
+
// fall back to that entry point before declaring the sdk too old.
|
|
730
|
+
sdk = await import("@love-moon/ai-sdk/dist/resume/index.js").catch(() => sdk);
|
|
731
|
+
}
|
|
732
|
+
if (typeof sdk.listSessionsForBackend !== "function") {
|
|
733
|
+
throw new Error("Session listing is not supported by the installed ai-sdk");
|
|
734
|
+
}
|
|
735
|
+
return sdk.listSessionsForBackend(backendType, options);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Pure aggregation behind the `list_backend_sessions` RPC: intersect the
|
|
740
|
+
* requested backends with what this daemon supports, list each backend's
|
|
741
|
+
* sessions, and merge them newest-first. A single backend failing lands in
|
|
742
|
+
* `errors` without affecting the others. Exported so tests can drive it with
|
|
743
|
+
* a stubbed `listSessions`.
|
|
744
|
+
*/
|
|
745
|
+
export async function collectBackendSessions({
|
|
746
|
+
requestedBackends = null,
|
|
747
|
+
supportedBackends = [],
|
|
748
|
+
limit,
|
|
749
|
+
listSessions = listSessionsForBackendFromSdk,
|
|
750
|
+
} = {}) {
|
|
751
|
+
const numericLimit = Number(limit);
|
|
752
|
+
const effectiveLimit =
|
|
753
|
+
Number.isFinite(numericLimit) && numericLimit > 0
|
|
754
|
+
? Math.min(Math.floor(numericLimit), MAX_BACKEND_SESSION_LIMIT)
|
|
755
|
+
: DEFAULT_BACKEND_SESSION_LIMIT;
|
|
756
|
+
const listableBackends = LISTABLE_SESSION_BACKENDS.filter((backend) => supportedBackends.includes(backend));
|
|
757
|
+
const errors = [];
|
|
758
|
+
let backends;
|
|
759
|
+
if (Array.isArray(requestedBackends)) {
|
|
760
|
+
backends = [];
|
|
761
|
+
for (const raw of requestedBackends) {
|
|
762
|
+
const backend = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
|
763
|
+
if (!backend || backends.includes(backend)) {
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (!listableBackends.includes(backend)) {
|
|
767
|
+
errors.push({ backend, message: `Backend "${backend}" does not support session listing on this daemon` });
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
backends.push(backend);
|
|
771
|
+
}
|
|
772
|
+
} else {
|
|
773
|
+
backends = listableBackends;
|
|
774
|
+
}
|
|
775
|
+
const sessions = [];
|
|
776
|
+
for (const backend of backends) {
|
|
777
|
+
try {
|
|
778
|
+
const listed = await listSessions(backend, { limit: effectiveLimit });
|
|
779
|
+
for (const entry of Array.isArray(listed) ? listed : []) {
|
|
780
|
+
const sessionId = normalizeOptionalString(entry?.sessionId);
|
|
781
|
+
if (!sessionId) {
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
sessions.push({
|
|
785
|
+
backend,
|
|
786
|
+
session_id: sessionId,
|
|
787
|
+
session_file_path: normalizeOptionalString(entry?.sessionFilePath),
|
|
788
|
+
cwd: normalizeOptionalString(entry?.cwd),
|
|
789
|
+
title: normalizeOptionalString(entry?.title),
|
|
790
|
+
updated_at: Number.isFinite(entry?.updatedAt) ? new Date(entry.updatedAt).toISOString() : null,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
} catch (error) {
|
|
794
|
+
errors.push({ backend, message: error?.message || String(error) });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
// ISO 8601 strings compare lexicographically; entries without a timestamp
|
|
798
|
+
// sort last.
|
|
799
|
+
sessions.sort((a, b) => (b.updated_at || "").localeCompare(a.updated_at || ""));
|
|
800
|
+
return { sessions, errors };
|
|
801
|
+
}
|
|
802
|
+
|
|
679
803
|
function normalizeBooleanFlag(value) {
|
|
680
804
|
if (typeof value === "boolean") {
|
|
681
805
|
return value;
|
|
@@ -1062,6 +1186,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1062
1186
|
// path would cost function without removing reach (RFC 0034 makes the same
|
|
1063
1187
|
// argument). An owner who wants it off can set `remote_exec: false`.
|
|
1064
1188
|
const remoteExecEnabled = getRemoteExecEnabled(userConfig);
|
|
1189
|
+
// Same reasoning for file transfer, with one addition: when the share scoped
|
|
1190
|
+
// the guest to a root, transfers are confined to it (`isPathInsideGuestRoot`,
|
|
1191
|
+
// a lexical check — misuse prevention, not a security boundary).
|
|
1192
|
+
const remoteFileEnabled = getRemoteFileEnabled(userConfig);
|
|
1065
1193
|
|
|
1066
1194
|
// Get allow_cli_list from config
|
|
1067
1195
|
const RAW_ALLOW_CLI_LIST = getRawAllowCliList(userConfig);
|
|
@@ -3663,6 +3791,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3663
3791
|
"restart_daemon",
|
|
3664
3792
|
"refresh_session_inplace",
|
|
3665
3793
|
"task_attachments_v1",
|
|
3794
|
+
"backend_session_list",
|
|
3666
3795
|
CUSTOM_COMMANDS_CAPABILITY,
|
|
3667
3796
|
UPDATE_DAEMON_CAPABILITY,
|
|
3668
3797
|
];
|
|
@@ -3671,6 +3800,11 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3671
3800
|
} else {
|
|
3672
3801
|
log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
|
|
3673
3802
|
}
|
|
3803
|
+
if (remoteFileEnabled) {
|
|
3804
|
+
advertisedCapabilities.push(REMOTE_FILE_CAPABILITY);
|
|
3805
|
+
} else {
|
|
3806
|
+
log("[remote-file] Disabled by config (remote_file: false); capability not advertised");
|
|
3807
|
+
}
|
|
3674
3808
|
if (ptyTaskCapabilityEnabled) {
|
|
3675
3809
|
advertisedCapabilities.push("pty_task", "terminal_snapshot");
|
|
3676
3810
|
}
|
|
@@ -3738,6 +3872,13 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3738
3872
|
const remoteExecHandlers = remoteExecEnabled
|
|
3739
3873
|
? createRemoteExecHandlers({ defaultWorkspace: homeDir })
|
|
3740
3874
|
: null;
|
|
3875
|
+
const remoteFileHandlers = remoteFileEnabled
|
|
3876
|
+
? createRemoteFileHandlers({
|
|
3877
|
+
config: sdkConfig,
|
|
3878
|
+
agentHost: AGENT_NAME,
|
|
3879
|
+
guestRoot: IS_GUEST_DAEMON ? GUEST_ROOT : null,
|
|
3880
|
+
})
|
|
3881
|
+
: null;
|
|
3741
3882
|
|
|
3742
3883
|
const client = createWebSocketClient(sdkConfig, {
|
|
3743
3884
|
extraHeaders,
|
|
@@ -6173,6 +6314,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6173
6314
|
void handleGetProjectAgents(event.payload);
|
|
6174
6315
|
return;
|
|
6175
6316
|
}
|
|
6317
|
+
if (event.type === "list_backend_sessions") {
|
|
6318
|
+
void handleListBackendSessions(event.payload);
|
|
6319
|
+
return;
|
|
6320
|
+
}
|
|
6176
6321
|
if (event.type === "ai_manager_request") {
|
|
6177
6322
|
// RFC 0035: reads are fine and genuinely useful to a guest (it needs to
|
|
6178
6323
|
// know how much quota the borrowed machine has left). `switch_account` is
|
|
@@ -6237,6 +6382,50 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6237
6382
|
logError(`Unhandled remote_exec_request failure: ${error?.message || error}`);
|
|
6238
6383
|
});
|
|
6239
6384
|
}
|
|
6385
|
+
if (event.type === "remote_file_request") {
|
|
6386
|
+
// Same reason as remote exec: a transfer leaves no Task row and no
|
|
6387
|
+
// per-run log, so it would be invisible after a restart. Only the action,
|
|
6388
|
+
// the path and the byte count are recorded — never file contents, and
|
|
6389
|
+
// never the transfer token, because this log is collected by
|
|
6390
|
+
// `collect_logs`.
|
|
6391
|
+
const fileArgs = event?.payload?.args && typeof event.payload.args === "object" ? event.payload.args : {};
|
|
6392
|
+
log(
|
|
6393
|
+
`[remote-file] ${event?.payload?.action || "?"} path=${fileArgs.remotePath || fileArgs.remote_path || ""} ` +
|
|
6394
|
+
`bytes=${fileArgs.sizeBytes ?? fileArgs.size_bytes ?? "?"}`,
|
|
6395
|
+
);
|
|
6396
|
+
// Fail fast rather than letting the caller wait out its full timeout —
|
|
6397
|
+
// it has no other way to learn that this daemon will never answer.
|
|
6398
|
+
const rejectReason = !remoteFileHandlers
|
|
6399
|
+
? "remote file transfer is disabled on this daemon (remote_file: false)"
|
|
6400
|
+
: daemonShuttingDown
|
|
6401
|
+
? "daemon is shutting down"
|
|
6402
|
+
: "";
|
|
6403
|
+
if (rejectReason) {
|
|
6404
|
+
void client
|
|
6405
|
+
.sendJson({
|
|
6406
|
+
type: "remote_file_response",
|
|
6407
|
+
payload: {
|
|
6408
|
+
request_id: event?.payload?.request_id ? String(event.payload.request_id) : "",
|
|
6409
|
+
action: event?.payload?.action ? String(event.payload.action) : "",
|
|
6410
|
+
error: rejectReason,
|
|
6411
|
+
},
|
|
6412
|
+
})
|
|
6413
|
+
.catch(() => {});
|
|
6414
|
+
return;
|
|
6415
|
+
}
|
|
6416
|
+
handleRemoteFileRequest(client, remoteFileHandlers, event.payload)
|
|
6417
|
+
.then((response) => {
|
|
6418
|
+
if (response?.result) {
|
|
6419
|
+
log(
|
|
6420
|
+
`[remote-file] ${event?.payload?.action || "?"} ok path=${response.result.path || ""} ` +
|
|
6421
|
+
`bytes=${response.result.bytesWritten ?? response.result.sizeBytes ?? "?"}`,
|
|
6422
|
+
);
|
|
6423
|
+
}
|
|
6424
|
+
})
|
|
6425
|
+
.catch((error) => {
|
|
6426
|
+
logError(`Unhandled remote_file_request failure: ${error?.message || error}`);
|
|
6427
|
+
});
|
|
6428
|
+
}
|
|
6240
6429
|
if (event.type === "restart_daemon") {
|
|
6241
6430
|
void handleRestartDaemon(event.payload).catch((error) => {
|
|
6242
6431
|
logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
|
|
@@ -6551,6 +6740,42 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6551
6740
|
}
|
|
6552
6741
|
}
|
|
6553
6742
|
|
|
6743
|
+
async function handleListBackendSessions(payload) {
|
|
6744
|
+
const requestId = payload?.request_id ? String(payload.request_id).trim() : "";
|
|
6745
|
+
if (!requestId) {
|
|
6746
|
+
logError(`Invalid list_backend_sessions payload: ${JSON.stringify(payload)}`);
|
|
6747
|
+
return;
|
|
6748
|
+
}
|
|
6749
|
+
|
|
6750
|
+
let result;
|
|
6751
|
+
try {
|
|
6752
|
+
result = await collectBackendSessions({
|
|
6753
|
+
requestedBackends: Array.isArray(payload?.backends) ? payload.backends : null,
|
|
6754
|
+
supportedBackends: SUPPORTED_BACKENDS,
|
|
6755
|
+
limit: payload?.limit,
|
|
6756
|
+
listSessions: deps.listBackendSessions,
|
|
6757
|
+
});
|
|
6758
|
+
} catch (error) {
|
|
6759
|
+
result = {
|
|
6760
|
+
sessions: [],
|
|
6761
|
+
errors: [{ backend: "*", message: `Failed to list backend sessions on daemon ${AGENT_NAME}: ${error?.message || error}` }],
|
|
6762
|
+
};
|
|
6763
|
+
}
|
|
6764
|
+
|
|
6765
|
+
try {
|
|
6766
|
+
await client.sendJson({
|
|
6767
|
+
type: "backend_sessions_listed",
|
|
6768
|
+
payload: {
|
|
6769
|
+
request_id: requestId,
|
|
6770
|
+
sessions: result.sessions,
|
|
6771
|
+
errors: result.errors,
|
|
6772
|
+
},
|
|
6773
|
+
});
|
|
6774
|
+
} catch (error) {
|
|
6775
|
+
logError(`Failed to report backend_sessions_listed for ${requestId}: ${error?.message || error}`);
|
|
6776
|
+
}
|
|
6777
|
+
}
|
|
6778
|
+
|
|
6554
6779
|
function stopActiveTaskProcess(
|
|
6555
6780
|
taskId,
|
|
6556
6781
|
{
|
|
@@ -7125,6 +7350,49 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7125
7350
|
});
|
|
7126
7351
|
}
|
|
7127
7352
|
|
|
7353
|
+
/**
|
|
7354
|
+
* Recover the workspace of a resumable session from the backend's own
|
|
7355
|
+
* session store. Used when neither a launch-config cwd nor a bound project
|
|
7356
|
+
* path is available -- the Default Project resuming a local session -- so the
|
|
7357
|
+
* task runs (and logs) where the conversation actually lives instead of an
|
|
7358
|
+
* unrelated scratch directory.
|
|
7359
|
+
*
|
|
7360
|
+
* Returns "" when the session cannot be located; callers keep their own
|
|
7361
|
+
* fallbacks. `runtimeBackend` short-circuits the alias lookup for callers
|
|
7362
|
+
* that already resolved it.
|
|
7363
|
+
*/
|
|
7364
|
+
async function resolveSessionResumeCwd({ backendType, runtimeBackend = "", sessionId }) {
|
|
7365
|
+
const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
|
|
7366
|
+
const normalizedBackend = normalizeRuntimeBackendName(backendType);
|
|
7367
|
+
// opencode has no resumable local session store to look the cwd up in.
|
|
7368
|
+
if (!normalizedSessionId || !normalizedBackend || normalizedBackend === "opencode") {
|
|
7369
|
+
return "";
|
|
7370
|
+
}
|
|
7371
|
+
try {
|
|
7372
|
+
let resumeBackend = normalizeOptionalString(runtimeBackend);
|
|
7373
|
+
if (!resumeBackend) {
|
|
7374
|
+
const configuredBackend = await resolveConfiguredRuntimeBackend(normalizedBackend, ALLOW_CLI_LIST, {
|
|
7375
|
+
configFilePath: config.CONFIG_FILE,
|
|
7376
|
+
});
|
|
7377
|
+
resumeBackend = configuredBackend?.runtimeBackend ||
|
|
7378
|
+
await normalizeRuntimeBackendAlias(normalizedBackend, { configFilePath: config.CONFIG_FILE });
|
|
7379
|
+
}
|
|
7380
|
+
const resumeContext = await (deps.resolveResumeContext || resolveResumeContext)(
|
|
7381
|
+
resumeBackend,
|
|
7382
|
+
normalizedSessionId,
|
|
7383
|
+
{
|
|
7384
|
+
cwd: process.cwd(),
|
|
7385
|
+
configFilePath: config.CONFIG_FILE,
|
|
7386
|
+
},
|
|
7387
|
+
);
|
|
7388
|
+
return normalizeOptionalString(resumeContext?.cwd) || "";
|
|
7389
|
+
} catch {
|
|
7390
|
+
// Provider lookup failed (unknown id, unreadable store): let the caller
|
|
7391
|
+
// fall back rather than failing the task here.
|
|
7392
|
+
return "";
|
|
7393
|
+
}
|
|
7394
|
+
}
|
|
7395
|
+
|
|
7128
7396
|
async function resolveRestartCwd({
|
|
7129
7397
|
taskId,
|
|
7130
7398
|
projectId,
|
|
@@ -7158,29 +7426,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7158
7426
|
return boundPath;
|
|
7159
7427
|
}
|
|
7160
7428
|
|
|
7161
|
-
const
|
|
7162
|
-
|
|
7163
|
-
|
|
7164
|
-
try {
|
|
7165
|
-
const configuredBackend = await resolveConfiguredRuntimeBackend(normalizedBackend, ALLOW_CLI_LIST, {
|
|
7166
|
-
configFilePath: config.CONFIG_FILE,
|
|
7167
|
-
});
|
|
7168
|
-
const resumeBackend = configuredBackend?.runtimeBackend ||
|
|
7169
|
-
await normalizeRuntimeBackendAlias(normalizedBackend, { configFilePath: config.CONFIG_FILE });
|
|
7170
|
-
const resumeContext = await (deps.resolveResumeContext || resolveResumeContext)(
|
|
7171
|
-
resumeBackend,
|
|
7172
|
-
normalizedSessionId,
|
|
7173
|
-
{
|
|
7174
|
-
cwd: process.cwd(),
|
|
7175
|
-
configFilePath: config.CONFIG_FILE,
|
|
7176
|
-
},
|
|
7177
|
-
);
|
|
7178
|
-
if (typeof resumeContext?.cwd === "string" && resumeContext.cwd.trim()) {
|
|
7179
|
-
return resumeContext.cwd.trim();
|
|
7180
|
-
}
|
|
7181
|
-
} catch {
|
|
7182
|
-
// ignore provider-specific fallback failure here; we'll try the remaining fallbacks
|
|
7183
|
-
}
|
|
7429
|
+
const sessionCwd = await resolveSessionResumeCwd({ backendType, sessionId });
|
|
7430
|
+
if (sessionCwd) {
|
|
7431
|
+
return sessionCwd;
|
|
7184
7432
|
}
|
|
7185
7433
|
|
|
7186
7434
|
const normalizedSessionPath =
|
|
@@ -7341,7 +7589,14 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7341
7589
|
launchConfig,
|
|
7342
7590
|
})) ||
|
|
7343
7591
|
normalizeOptionalString(launchConfig.cwd) ||
|
|
7344
|
-
boundPath
|
|
7592
|
+
boundPath ||
|
|
7593
|
+
(await resolveSessionResumeCwd({
|
|
7594
|
+
backendType: requestedBackend,
|
|
7595
|
+
runtimeBackend: effectiveBackend,
|
|
7596
|
+
sessionId:
|
|
7597
|
+
normalizeOptionalString(launchConfig?.resumeSessionId) ||
|
|
7598
|
+
normalizeOptionalString(launchConfig?.resume_session_id),
|
|
7599
|
+
}));
|
|
7345
7600
|
|
|
7346
7601
|
if (resolvedTaskWorkspace) {
|
|
7347
7602
|
taskDir = resolvedTaskWorkspace;
|
|
@@ -7940,6 +8195,31 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7940
8195
|
return;
|
|
7941
8196
|
}
|
|
7942
8197
|
|
|
8198
|
+
if (!resolvedResumeCwd && isForkMode) {
|
|
8199
|
+
// A fork starts a brand-new session and receives its context as a URL, so
|
|
8200
|
+
// it does not need the source task's files. When nothing in the chain
|
|
8201
|
+
// resolves — the normal case for a cross-daemon fork, where the server
|
|
8202
|
+
// drops the source machine's paths and this daemon holds no local path
|
|
8203
|
+
// for the project — land in a fresh workspace dir exactly like
|
|
8204
|
+
// create_task does, instead of killing the successor on arrival.
|
|
8205
|
+
const now = new Date();
|
|
8206
|
+
// Keyed off the task id, not the daemon pid: the run timestamp only has
|
|
8207
|
+
// second granularity and this path never renames the dir afterwards
|
|
8208
|
+
// (create_task does), so two forks landing in the same second would
|
|
8209
|
+
// otherwise share one workspace AND one conductor.log.
|
|
8210
|
+
const taskSuffix =
|
|
8211
|
+
normalizedTargetTaskId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || String(process.pid);
|
|
8212
|
+
resolvedResumeCwd = path.join(
|
|
8213
|
+
WORKSPACE_ROOT,
|
|
8214
|
+
formatWorkspaceDate(now),
|
|
8215
|
+
`${formatWorkspaceRunTimestamp(now)}_fork_${taskSuffix}`,
|
|
8216
|
+
);
|
|
8217
|
+
log(
|
|
8218
|
+
`No local workspace for project ${normalizedProjectId} on this daemon; ` +
|
|
8219
|
+
`forking task ${normalizedTargetTaskId} into ${resolvedResumeCwd}`,
|
|
8220
|
+
);
|
|
8221
|
+
}
|
|
8222
|
+
|
|
7943
8223
|
if (!resolvedResumeCwd) {
|
|
7944
8224
|
reportRestartFailure({
|
|
7945
8225
|
taskId: normalizedTargetTaskId,
|
|
@@ -8409,6 +8689,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
8409
8689
|
clearInterval(tmuxLivenessTimer);
|
|
8410
8690
|
tmuxLivenessTimer = null;
|
|
8411
8691
|
}
|
|
8692
|
+
// An in-flight transfer holds an open socket and a `.part` file; abort
|
|
8693
|
+
// them so shutdown is not gated on a multi-gigabyte download finishing.
|
|
8694
|
+
remoteFileHandlers?.abortAll();
|
|
8412
8695
|
const activeProcessEntries = [...activeTaskProcesses.entries()];
|
|
8413
8696
|
const activePtyEntries = [...activePtySessions.entries()];
|
|
8414
8697
|
const activeEntries = [...activeProcessEntries, ...activePtyEntries];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory transfer, built on the single-file path plus `tar`.
|
|
3
|
+
*
|
|
4
|
+
* `remote cp -r` packs a directory locally (or remotely), moves the one
|
|
5
|
+
* resulting tarball through the ordinary transfer, then unpacks it on the other
|
|
6
|
+
* side. No new protocol: the wire still only ever carries one file.
|
|
7
|
+
*
|
|
8
|
+
* The remote halves run through `sh -c` with the paths passed as **positional
|
|
9
|
+
* arguments**, never interpolated into the script text. A directory literally
|
|
10
|
+
* named `; rm -rf /` is therefore just an awkward directory name.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
/** Where the target stages its tarball. Overridable for hosts without /tmp. */
|
|
20
|
+
export function remoteTempPath(env = process.env) {
|
|
21
|
+
const root = (env.CONDUCTOR_REMOTE_TMP || "/tmp").replace(/\/+$/, "") || "/tmp";
|
|
22
|
+
return `${root}/.conductor-rcp-${randomUUID()}.tgz`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function localTempPath() {
|
|
26
|
+
return path.join(os.tmpdir(), `.conductor-rcp-${randomUUID()}.tgz`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Unpack `$1` into `$2`, following scp: an existing destination directory
|
|
31
|
+
* receives the source *inside* it; a missing one becomes the copy itself.
|
|
32
|
+
* `$3` is the source's basename, which is the top-level entry in the tarball.
|
|
33
|
+
*/
|
|
34
|
+
export const EXTRACT_SCRIPT = `
|
|
35
|
+
set -e
|
|
36
|
+
tmp=$1; dest=$2; base=$3
|
|
37
|
+
if [ -d "$dest" ]; then
|
|
38
|
+
tar xzf "$tmp" -C "$dest"
|
|
39
|
+
else
|
|
40
|
+
parent=$(dirname "$dest")
|
|
41
|
+
mkdir -p "$parent"
|
|
42
|
+
# Unpack into a private staging dir, never straight into the parent: the
|
|
43
|
+
# tarball's top entry is the SOURCE's name, and an unrelated neighbour of
|
|
44
|
+
# the destination could already have it. Extracting in place would clobber
|
|
45
|
+
# that neighbour, and the rename below would then carry it off.
|
|
46
|
+
# Staging inside the parent keeps the final mv on one filesystem.
|
|
47
|
+
stage=$(mktemp -d "$parent/.conductor-rcp-XXXXXX")
|
|
48
|
+
tar xzf "$tmp" -C "$stage"
|
|
49
|
+
rm -rf "$dest"
|
|
50
|
+
mv "$stage/$base" "$dest"
|
|
51
|
+
rmdir "$stage"
|
|
52
|
+
fi
|
|
53
|
+
`.trim();
|
|
54
|
+
|
|
55
|
+
/** Pack directory `$1` into tarball `$2`, keeping `$1`'s own name inside. */
|
|
56
|
+
export const PACK_SCRIPT = `
|
|
57
|
+
set -e
|
|
58
|
+
src=$1; tmp=$2
|
|
59
|
+
if [ ! -d "$src" ]; then
|
|
60
|
+
echo "not a directory: $src" >&2
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
tar czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")"
|
|
64
|
+
`.trim();
|
|
65
|
+
|
|
66
|
+
/** Remove the staged tarball. Never fails the transfer. */
|
|
67
|
+
export const CLEANUP_SCRIPT = 'rm -f "$1"';
|
|
68
|
+
|
|
69
|
+
/** `sh -c <script> sh <args...>` — the `sh` placeholder becomes $0. */
|
|
70
|
+
export function shellArgs(script, ...args) {
|
|
71
|
+
return ["-c", script, "sh", ...args];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runLocal(command, args, { spawnFn = spawn } = {}) {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const child = spawnFn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
77
|
+
let stderr = "";
|
|
78
|
+
child.stderr?.on("data", (chunk) => {
|
|
79
|
+
// Bounded: a pathological tar could otherwise stream forever.
|
|
80
|
+
if (stderr.length < 8192) stderr += String(chunk);
|
|
81
|
+
});
|
|
82
|
+
child.on("error", (error) =>
|
|
83
|
+
reject(new Error(`failed to run ${command}: ${error.message}`)));
|
|
84
|
+
child.on("close", (code) => {
|
|
85
|
+
if (code === 0) return resolve();
|
|
86
|
+
reject(new Error(`${command} exited ${code}${stderr.trim() ? `: ${stderr.trim()}` : ""}`));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Pack a local directory, preserving its own name as the top-level entry. */
|
|
92
|
+
export async function packLocalDirectory(sourceDir, tarball, deps = {}) {
|
|
93
|
+
await runLocal(
|
|
94
|
+
"tar",
|
|
95
|
+
["czf", tarball, "-C", path.dirname(sourceDir), path.basename(sourceDir)],
|
|
96
|
+
deps,
|
|
97
|
+
);
|
|
98
|
+
return tarball;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Unpack locally with the same scp semantics as `EXTRACT_SCRIPT`. */
|
|
102
|
+
export async function unpackLocalDirectory(tarball, destination, baseName, deps = {}) {
|
|
103
|
+
const fsp = deps.fsp || fs.promises;
|
|
104
|
+
let destStat = null;
|
|
105
|
+
try {
|
|
106
|
+
destStat = await fsp.stat(destination);
|
|
107
|
+
} catch {
|
|
108
|
+
destStat = null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (destStat?.isDirectory()) {
|
|
112
|
+
await runLocal("tar", ["xzf", tarball, "-C", destination], deps);
|
|
113
|
+
return path.join(destination, baseName);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const parent = path.dirname(destination);
|
|
117
|
+
await fsp.mkdir(parent, { recursive: true });
|
|
118
|
+
// Same reasoning as EXTRACT_SCRIPT: stage in a private directory so an
|
|
119
|
+
// existing neighbour that happens to share the source's name survives.
|
|
120
|
+
const stage = await fsp.mkdtemp(path.join(parent, ".conductor-rcp-"));
|
|
121
|
+
try {
|
|
122
|
+
await runLocal("tar", ["xzf", tarball, "-C", stage], deps);
|
|
123
|
+
await fsp.rm(destination, { recursive: true, force: true });
|
|
124
|
+
await fsp.rename(path.join(stage, baseName), destination);
|
|
125
|
+
} finally {
|
|
126
|
+
await fsp.rm(stage, { recursive: true, force: true }).catch(() => {});
|
|
127
|
+
}
|
|
128
|
+
return destination;
|
|
129
|
+
}
|