@love-moon/conductor-cli 0.9.0 → 0.11.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 +129 -0
- package/bin/conductor-config.js +133 -8
- package/bin/conductor-daemon.js +53 -29
- package/bin/conductor-diagnose.js +3 -1
- package/bin/conductor-fire.js +11 -0
- package/bin/conductor-project.js +3 -1
- package/bin/conductor-update.js +13 -0
- package/package.json +5 -5
- package/src/daemon-lock.js +240 -0
- package/src/daemon.js +717 -123
- package/src/guest-daemon.js +268 -0
- package/src/runtime-backends.js +63 -2
- package/src/version-check.js +23 -0
package/src/daemon.js
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { resolveResumeContext } from "./fire/resume.js";
|
|
39
39
|
import {
|
|
40
40
|
filterRuntimeSupportedAllowCliList,
|
|
41
|
+
inferBuiltInRuntimeBackendFromCommand,
|
|
41
42
|
listAdvertisedBackends,
|
|
42
43
|
resolveConfiguredRuntimeBackend,
|
|
43
44
|
isBuiltInRuntimeBackend,
|
|
@@ -46,6 +47,7 @@ import {
|
|
|
46
47
|
normalizeRuntimeBackendAlias,
|
|
47
48
|
normalizeRuntimeBackendName,
|
|
48
49
|
} from "./runtime-backends.js";
|
|
50
|
+
import { resolveClaudeCommandForRoot } from "@love-moon/ai-sdk";
|
|
49
51
|
import {
|
|
50
52
|
PACKAGE_NAME,
|
|
51
53
|
buildUpgradeCommand,
|
|
@@ -67,7 +69,31 @@ import {
|
|
|
67
69
|
maskErrorForLogs,
|
|
68
70
|
redactSecretsForLogs,
|
|
69
71
|
} from "./handoff-log-mask.js";
|
|
72
|
+
import {
|
|
73
|
+
DAEMON_LOCK_FILE_NAME,
|
|
74
|
+
FORCE_KILL_UNKNOWN_OWNER_ENV_VAR,
|
|
75
|
+
buildDaemonInstanceIdentity,
|
|
76
|
+
compareDaemonLockIdentity,
|
|
77
|
+
describeDaemonLockOwner,
|
|
78
|
+
describeForceRestartRefusal,
|
|
79
|
+
parseDaemonLockState,
|
|
80
|
+
serializeDaemonLock,
|
|
81
|
+
} from "./daemon-lock.js";
|
|
70
82
|
import { StringDecoder } from "node:string_decoder";
|
|
83
|
+
import {
|
|
84
|
+
MAX_GUEST_DAEMONS,
|
|
85
|
+
buildGuestConfigYaml,
|
|
86
|
+
buildGuestEnv,
|
|
87
|
+
filterGuestCapabilities,
|
|
88
|
+
isGuestAiManagerActionAllowed,
|
|
89
|
+
isGuestRestartAllowed,
|
|
90
|
+
isPathInsideGuestRoot,
|
|
91
|
+
nextRestartDelayMs,
|
|
92
|
+
reconcileGuests,
|
|
93
|
+
startOrphanWatchdog,
|
|
94
|
+
resolveGuestPaths,
|
|
95
|
+
writeGuestConfig,
|
|
96
|
+
} from "./guest-daemon.js";
|
|
71
97
|
|
|
72
98
|
dotenv.config();
|
|
73
99
|
|
|
@@ -76,6 +102,10 @@ const __dirname = path.dirname(__filename);
|
|
|
76
102
|
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
77
103
|
const moduleRequire = createRequire(import.meta.url);
|
|
78
104
|
const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-fire.js");
|
|
105
|
+
// RFC 0035: the launcher a guest daemon child is spawned with. Same binary as
|
|
106
|
+
// the host daemon, so guests always match the installed version and there is
|
|
107
|
+
// no second update path to keep in sync.
|
|
108
|
+
const DAEMON_LAUNCHER_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-daemon.js");
|
|
79
109
|
const CLI_VERSION = (() => {
|
|
80
110
|
try {
|
|
81
111
|
return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
|
|
@@ -353,6 +383,57 @@ function serializeRuntimeBackendMap(runtimeBackendMap) {
|
|
|
353
383
|
.join(",");
|
|
354
384
|
}
|
|
355
385
|
|
|
386
|
+
// Backends whose install/auth health can be probed via the AI manager. Others
|
|
387
|
+
// (external providers) are left out of the advertised health map so the web
|
|
388
|
+
// runtime preflight fails open for them.
|
|
389
|
+
const RUNTIME_HEALTH_TOOLS = new Set(["codex", "claude", "copilot", "kimi", "dsh"]);
|
|
390
|
+
|
|
391
|
+
function runtimeHealthToolForBackend(backend, runtimeBackendMap) {
|
|
392
|
+
const normalized = String(backend || "").trim().toLowerCase();
|
|
393
|
+
if (RUNTIME_HEALTH_TOOLS.has(normalized)) {
|
|
394
|
+
return normalized;
|
|
395
|
+
}
|
|
396
|
+
const runtime = String(runtimeBackendMap?.[normalized] || "").trim().toLowerCase();
|
|
397
|
+
return RUNTIME_HEALTH_TOOLS.has(runtime) ? runtime : null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function serializeRuntimeHealth(runtimeHealth) {
|
|
401
|
+
if (!runtimeHealth || typeof runtimeHealth !== "object") {
|
|
402
|
+
return "";
|
|
403
|
+
}
|
|
404
|
+
return Object.entries(runtimeHealth)
|
|
405
|
+
.filter(([backend, state]) => backend && state)
|
|
406
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
407
|
+
.map(([backend, state]) => `${backend}=${state}`)
|
|
408
|
+
.join(",");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Best-effort per-backend runtime health for the web runtime preflight. Only a
|
|
412
|
+
// cheap install probe (which/--version) runs, once at startup. We advertise
|
|
413
|
+
// only a POSITIVE `ready` confirmation: a supported backend whose default-name
|
|
414
|
+
// `which` probe fails is very likely resolved via a custom or absolute path
|
|
415
|
+
// (allow_cli_list, or a systemd PATH that differs from the login shell) and is
|
|
416
|
+
// still runnable, so reporting it as "missing" would be an unreliable false
|
|
417
|
+
// negative that could wrongly block task creation. Omitting it lets the web
|
|
418
|
+
// preflight fail open for that backend.
|
|
419
|
+
async function computeAdvertisedRuntimeHealth(manager, supportedBackends, runtimeBackendMap) {
|
|
420
|
+
if (!manager || typeof manager.checkInstallAll !== "function") {
|
|
421
|
+
return {};
|
|
422
|
+
}
|
|
423
|
+
const install = await manager.checkInstallAll();
|
|
424
|
+
const runtimeHealth = {};
|
|
425
|
+
for (const backend of supportedBackends || []) {
|
|
426
|
+
const tool = runtimeHealthToolForBackend(backend, runtimeBackendMap);
|
|
427
|
+
if (!tool) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (install?.[tool]?.installed) {
|
|
431
|
+
runtimeHealth[String(backend).trim().toLowerCase()] = "ready";
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return runtimeHealth;
|
|
435
|
+
}
|
|
436
|
+
|
|
356
437
|
async function defaultCreatePty(command, args, options) {
|
|
357
438
|
if (!nodePtySpawnPromise) {
|
|
358
439
|
const spawnHelperInfo = ensureNodePtySpawnHelperExecutable();
|
|
@@ -442,6 +523,18 @@ export function ensureNodePtySpawnHelperExecutable(deps = {}) {
|
|
|
442
523
|
return { helperPath, updated: true };
|
|
443
524
|
}
|
|
444
525
|
|
|
526
|
+
// A tool-preset PTY task runs the configured allow_cli_list command through a
|
|
527
|
+
// login shell, bypassing ai-sdk entirely. claude refuses to start as root with
|
|
528
|
+
// `--dangerously-skip-permissions`, so rewrite the command the same way the
|
|
529
|
+
// ai-sdk session rewrites its permission mode — one shared root check, so the
|
|
530
|
+
// two paths cannot drift.
|
|
531
|
+
export function resolvePtyToolPresetCommand(cliCommand, env = process.env) {
|
|
532
|
+
if (inferBuiltInRuntimeBackendFromCommand(cliCommand) !== "claude") {
|
|
533
|
+
return cliCommand;
|
|
534
|
+
}
|
|
535
|
+
return resolveClaudeCommandForRoot(cliCommand, env);
|
|
536
|
+
}
|
|
537
|
+
|
|
445
538
|
export function isSafeTaskWorktreeRoot(projectWorkspacePath, worktreeRoot) {
|
|
446
539
|
const normalizedWorkspacePath =
|
|
447
540
|
typeof projectWorkspacePath === "string" ? projectWorkspacePath.trim() : "";
|
|
@@ -624,6 +717,12 @@ function parseTaskWorktreeLaunchConfig(launchConfig) {
|
|
|
624
717
|
projectRepoRoot,
|
|
625
718
|
projectWorkspacePath,
|
|
626
719
|
projectRelativePath,
|
|
720
|
+
// Reuse-only members (reviewers in a multi-agent group) share the owner's
|
|
721
|
+
// worktree but must never run `git worktree add` themselves.
|
|
722
|
+
worktreeReuseOnly: normalizeBooleanFlag(
|
|
723
|
+
normalizedLaunchConfig.worktreeReuseOnly ??
|
|
724
|
+
normalizedLaunchConfig.worktree_reuse_only,
|
|
725
|
+
),
|
|
627
726
|
};
|
|
628
727
|
}
|
|
629
728
|
|
|
@@ -721,6 +820,106 @@ function buildPtyTaskEnv(baseEnv = process.env, launchEnv = {}) {
|
|
|
721
820
|
};
|
|
722
821
|
}
|
|
723
822
|
|
|
823
|
+
// Module-level so the PTY launch wiring is directly testable: the tool-preset
|
|
824
|
+
// branch has to hand the *child's* env to the root check, and that seam is
|
|
825
|
+
// exactly where a bug hid before (the check read the daemon's own process.env,
|
|
826
|
+
// silently ignoring a per-task IS_SANDBOX=1 opt-out).
|
|
827
|
+
export function buildPtyLaunchSpec(launchConfig, fallbackCwd, deps = {}) {
|
|
828
|
+
const allowCliList = deps.allowCliList || {};
|
|
829
|
+
const supportedBackends = Array.isArray(deps.supportedBackends) ? deps.supportedBackends : [];
|
|
830
|
+
const existsSyncFn = deps.existsSync || fs.existsSync;
|
|
831
|
+
const baseEnv = deps.baseEnv || process.env;
|
|
832
|
+
const log = typeof deps.log === "function" ? deps.log : () => {};
|
|
833
|
+
const normalizedLaunchConfig = normalizeLaunchConfig(launchConfig);
|
|
834
|
+
const entrypointType =
|
|
835
|
+
normalizeOptionalString(normalizedLaunchConfig.entrypoint_type) ||
|
|
836
|
+
normalizeOptionalString(normalizedLaunchConfig.entrypointType) ||
|
|
837
|
+
(normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
|
|
838
|
+
normalizeOptionalString(normalizedLaunchConfig.toolPreset)
|
|
839
|
+
? "tool_preset"
|
|
840
|
+
: "shell");
|
|
841
|
+
const preferredShell = resolveDefaultPtyShell({
|
|
842
|
+
explicitShell: normalizedLaunchConfig.shell,
|
|
843
|
+
envShell: process.env.SHELL,
|
|
844
|
+
comspec: process.env.COMSPEC,
|
|
845
|
+
platform: process.platform,
|
|
846
|
+
existsSync: existsSyncFn,
|
|
847
|
+
});
|
|
848
|
+
const cwd =
|
|
849
|
+
normalizeOptionalString(normalizedLaunchConfig.cwd) ||
|
|
850
|
+
fallbackCwd;
|
|
851
|
+
const env = normalizeTerminalEnv(normalizedLaunchConfig.env);
|
|
852
|
+
const cols = normalizePositiveInt(
|
|
853
|
+
normalizedLaunchConfig.cols ?? normalizedLaunchConfig.columns,
|
|
854
|
+
DEFAULT_TERMINAL_COLS,
|
|
855
|
+
);
|
|
856
|
+
const rows = normalizePositiveInt(
|
|
857
|
+
normalizedLaunchConfig.rows,
|
|
858
|
+
DEFAULT_TERMINAL_ROWS,
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
if (entrypointType === "tool_preset") {
|
|
862
|
+
const toolPreset =
|
|
863
|
+
normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
|
|
864
|
+
normalizeOptionalString(normalizedLaunchConfig.toolPreset) ||
|
|
865
|
+
supportedBackends[0] ||
|
|
866
|
+
"codex";
|
|
867
|
+
const cliCommand = allowCliList[toolPreset];
|
|
868
|
+
if (!cliCommand) {
|
|
869
|
+
throw new Error(`Unsupported tool preset: ${toolPreset}`);
|
|
870
|
+
}
|
|
871
|
+
const launchCommand = resolvePtyToolPresetCommand(cliCommand, buildPtyTaskEnv(baseEnv, env));
|
|
872
|
+
if (launchCommand !== cliCommand) {
|
|
873
|
+
log(`[pty] Adjusted ${toolPreset} command for root: ${launchCommand}`);
|
|
874
|
+
}
|
|
875
|
+
return {
|
|
876
|
+
entrypointType,
|
|
877
|
+
toolPreset,
|
|
878
|
+
command: preferredShell,
|
|
879
|
+
args: ["-lc", launchCommand],
|
|
880
|
+
shell: preferredShell,
|
|
881
|
+
cwd,
|
|
882
|
+
env,
|
|
883
|
+
cols,
|
|
884
|
+
rows,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (entrypointType === "custom") {
|
|
889
|
+
const command = normalizeOptionalString(normalizedLaunchConfig.command);
|
|
890
|
+
if (!command) {
|
|
891
|
+
throw new Error("launch_config.command is required for custom entrypoint");
|
|
892
|
+
}
|
|
893
|
+
const args = Array.isArray(normalizedLaunchConfig.args)
|
|
894
|
+
? normalizedLaunchConfig.args.filter((value) => typeof value === "string")
|
|
895
|
+
: [];
|
|
896
|
+
return {
|
|
897
|
+
entrypointType,
|
|
898
|
+
toolPreset: null,
|
|
899
|
+
command,
|
|
900
|
+
args,
|
|
901
|
+
shell: preferredShell,
|
|
902
|
+
cwd,
|
|
903
|
+
env,
|
|
904
|
+
cols,
|
|
905
|
+
rows,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return {
|
|
910
|
+
entrypointType: "shell",
|
|
911
|
+
toolPreset: null,
|
|
912
|
+
command: preferredShell,
|
|
913
|
+
args: ["-l"],
|
|
914
|
+
shell: preferredShell,
|
|
915
|
+
cwd,
|
|
916
|
+
env,
|
|
917
|
+
cols,
|
|
918
|
+
rows,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
|
|
724
923
|
export function startDaemon(config = {}, deps = {}) {
|
|
725
924
|
const exitFn = deps.exit || process.exit;
|
|
726
925
|
const killFn = deps.kill || process.kill;
|
|
@@ -830,6 +1029,24 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
830
1029
|
// warning and silently fall back to direct spawn rather than failing every
|
|
831
1030
|
// create_task with ENOENT.
|
|
832
1031
|
const FIRE_TMUX_MODE_ENABLED = getFireTmuxModeEnabled(userConfig);
|
|
1032
|
+
// RFC 0035: a guest daemon runs on someone else's machine as a different
|
|
1033
|
+
// account. It keeps the ordinary daemon code path but drops the capabilities
|
|
1034
|
+
// that would mutate state the machine's OWNER depends on.
|
|
1035
|
+
const IS_GUEST_DAEMON =
|
|
1036
|
+
userConfig.conductor_guest === true ||
|
|
1037
|
+
fileConfig?.conductorGuest === true ||
|
|
1038
|
+
Boolean(normalizeOptionalString(process.env.CONDUCTOR_GUEST_SHARE_ID));
|
|
1039
|
+
// Only set when the share carries an explicit `workspaceRoot`, i.e. the owner
|
|
1040
|
+
// deliberately scoped where the guest should work. Absent that, a guest binds
|
|
1041
|
+
// projects wherever any other daemon could -- inventing a confinement the
|
|
1042
|
+
// owner never asked for would make the guest gratuitously less capable, and
|
|
1043
|
+
// it was never a security boundary anyway (the grantee's agent runs a shell).
|
|
1044
|
+
const GUEST_ROOT = normalizeOptionalString(process.env.CONDUCTOR_GUEST_ROOT);
|
|
1045
|
+
// A guest resolves `remote_exec` from its own config exactly like any other
|
|
1046
|
+
// daemon. It is not withheld: the grantee already has a shell here through
|
|
1047
|
+
// AI tasks and `pty_task`'s custom entrypoint, so blocking the scriptable
|
|
1048
|
+
// path would cost function without removing reach (RFC 0034 makes the same
|
|
1049
|
+
// argument). An owner who wants it off can set `remote_exec: false`.
|
|
833
1050
|
const remoteExecEnabled = getRemoteExecEnabled(userConfig);
|
|
834
1051
|
|
|
835
1052
|
// Get allow_cli_list from config
|
|
@@ -860,6 +1077,17 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
860
1077
|
readFileSync: deps.readFileSync || fs.readFileSync,
|
|
861
1078
|
}));
|
|
862
1079
|
const skipPidLockCheck = parseBooleanEnv(process.env.CONDUCTOR_TUI_DEBUG);
|
|
1080
|
+
// Fingerprint of *this* daemon instance, persisted into daemon.pid so that a
|
|
1081
|
+
// later `--force` can tell "restart myself" apart from "kill whoever happens
|
|
1082
|
+
// to hold the lock". WORKSPACE_ROOT defaults to $HOME/ws, so unrelated
|
|
1083
|
+
// instances collide on the same lock file by default.
|
|
1084
|
+
const daemonInstanceIdentity = buildDaemonInstanceIdentity({
|
|
1085
|
+
conductorHome: materializedConductorPathEnv.CONDUCTOR_HOME,
|
|
1086
|
+
configPath: effectiveConfigPath,
|
|
1087
|
+
workspaceRoot: WORKSPACE_ROOT,
|
|
1088
|
+
daemonName: AGENT_NAME,
|
|
1089
|
+
backendUrl: BACKEND_HTTP,
|
|
1090
|
+
});
|
|
863
1091
|
const lockHandoffToken =
|
|
864
1092
|
normalizeOptionalString(config.LOCK_HANDOFF_TOKEN) ||
|
|
865
1093
|
normalizeOptionalString(process.env.CONDUCTOR_LOCK_HANDOFF_TOKEN);
|
|
@@ -2402,6 +2630,79 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2402
2630
|
});
|
|
2403
2631
|
}
|
|
2404
2632
|
|
|
2633
|
+
// In-flight worktree preparations keyed on the on-disk root. Group members
|
|
2634
|
+
// (worker + reviewers) start almost simultaneously and would otherwise all
|
|
2635
|
+
// pass the `.git` existence check below and race on `git worktree add -b`
|
|
2636
|
+
// for the same branch: one wins and the losers fail with
|
|
2637
|
+
// "Failed to prepare git worktree". Serializing per root also benefits the
|
|
2638
|
+
// pre-existing fork/branch sharing path.
|
|
2639
|
+
const taskWorktreePreparations = new Map();
|
|
2640
|
+
|
|
2641
|
+
// Readiness marker, written by the owner only after the worktree is FULLY
|
|
2642
|
+
// prepared. `.git` alone is not a readiness signal: it appears the moment
|
|
2643
|
+
// `git worktree add` returns, while submodules and symlinks are still
|
|
2644
|
+
// pending. An owner that dies in between (e.g. submodule sync hits its
|
|
2645
|
+
// timeout) would otherwise leave `.git` behind and let reuse-only members
|
|
2646
|
+
// walk into a half-built directory.
|
|
2647
|
+
//
|
|
2648
|
+
// It lives NEXT TO the worktree, not inside it: an untracked file inside
|
|
2649
|
+
// would make `git status --porcelain` report the worktree as dirty and block
|
|
2650
|
+
// non-forced cleanup.
|
|
2651
|
+
function taskWorktreeReadyMarkerPath(worktreeRoot) {
|
|
2652
|
+
return `${worktreeRoot}.ready`;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
function removeTaskWorktreeReadyMarker(worktreeRoot) {
|
|
2656
|
+
const markerPath = taskWorktreeReadyMarkerPath(worktreeRoot);
|
|
2657
|
+
if (!existsSyncFn(markerPath)) {
|
|
2658
|
+
return;
|
|
2659
|
+
}
|
|
2660
|
+
try {
|
|
2661
|
+
unlinkSyncFn(markerPath);
|
|
2662
|
+
} catch (error) {
|
|
2663
|
+
if (error?.code !== "ENOENT") {
|
|
2664
|
+
logError(
|
|
2665
|
+
`[worktree] failed to clear ready marker for ${worktreeRoot}: ${error?.message || error}`,
|
|
2666
|
+
);
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
// Both must hold. Requiring `.git` too means a marker left behind by a
|
|
2672
|
+
// worktree that was removed outside our cleanup path cannot fake readiness.
|
|
2673
|
+
function isTaskWorktreeReady(worktreeRoot, gitMarkerPath) {
|
|
2674
|
+
return (
|
|
2675
|
+
existsSyncFn(gitMarkerPath) &&
|
|
2676
|
+
existsSyncFn(taskWorktreeReadyMarkerPath(worktreeRoot))
|
|
2677
|
+
);
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
async function waitForSharedTaskWorktree({ taskId, worktreeRoot, gitMarkerPath }) {
|
|
2681
|
+
const deadline = Date.now() + TASK_WORKTREE_REUSE_WAIT_TIMEOUT_MS;
|
|
2682
|
+
for (;;) {
|
|
2683
|
+
// When the owner is preparing in this same daemon process we await the
|
|
2684
|
+
// whole preparation (worktree add + submodules + symlinks). Polling the
|
|
2685
|
+
// marker additionally covers an owner that already finished, or one
|
|
2686
|
+
// running in a different process.
|
|
2687
|
+
const inflight = taskWorktreePreparations.get(worktreeRoot);
|
|
2688
|
+
if (inflight) {
|
|
2689
|
+
await inflight;
|
|
2690
|
+
}
|
|
2691
|
+
if (isTaskWorktreeReady(worktreeRoot, gitMarkerPath)) {
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
if (Date.now() >= deadline) {
|
|
2695
|
+
throw new Error(
|
|
2696
|
+
`Timed out waiting for shared git worktree ${worktreeRoot} for ${taskId}. ` +
|
|
2697
|
+
`Its owner task may have failed midway through preparation.`,
|
|
2698
|
+
);
|
|
2699
|
+
}
|
|
2700
|
+
await new Promise((resolve) =>
|
|
2701
|
+
setTimeout(resolve, TASK_WORKTREE_REUSE_POLL_INTERVAL_MS),
|
|
2702
|
+
);
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2405
2706
|
async function ensureTaskWorktree({ taskId, projectId, launchConfig }) {
|
|
2406
2707
|
const worktreeConfig = parseTaskWorktreeLaunchConfig(launchConfig);
|
|
2407
2708
|
if (!worktreeConfig) {
|
|
@@ -2414,7 +2715,50 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2414
2715
|
);
|
|
2415
2716
|
const finalCwd = resolveTaskWorktreeCwd(worktreeRoot, worktreeConfig.projectRelativePath);
|
|
2416
2717
|
const gitMarkerPath = path.join(worktreeRoot, ".git");
|
|
2718
|
+
|
|
2719
|
+
if (worktreeConfig.worktreeReuseOnly) {
|
|
2720
|
+
await waitForSharedTaskWorktree({ taskId, worktreeRoot, gitMarkerPath });
|
|
2721
|
+
mkdirSyncFn(finalCwd, { recursive: true });
|
|
2722
|
+
return finalCwd;
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
const inflight = taskWorktreePreparations.get(worktreeRoot);
|
|
2726
|
+
if (inflight) {
|
|
2727
|
+
await inflight;
|
|
2728
|
+
}
|
|
2729
|
+
// Prepare when we are the first owner here, or when the preparation we
|
|
2730
|
+
// waited on did not leave a fully-prepared worktree (then we take over).
|
|
2731
|
+
if (!inflight || !isTaskWorktreeReady(worktreeRoot, gitMarkerPath)) {
|
|
2732
|
+
const preparation = prepareTaskWorktree({
|
|
2733
|
+
taskId,
|
|
2734
|
+
worktreeConfig,
|
|
2735
|
+
worktreeRoot,
|
|
2736
|
+
gitMarkerPath,
|
|
2737
|
+
finalCwd,
|
|
2738
|
+
});
|
|
2739
|
+
// Waiters must never inherit our rejection; they re-check `.git` instead.
|
|
2740
|
+
taskWorktreePreparations.set(worktreeRoot, preparation.catch(() => {}));
|
|
2741
|
+
try {
|
|
2742
|
+
await preparation;
|
|
2743
|
+
} finally {
|
|
2744
|
+
taskWorktreePreparations.delete(worktreeRoot);
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
return finalCwd;
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
async function prepareTaskWorktree({
|
|
2751
|
+
taskId,
|
|
2752
|
+
worktreeConfig,
|
|
2753
|
+
worktreeRoot,
|
|
2754
|
+
gitMarkerPath,
|
|
2755
|
+
finalCwd,
|
|
2756
|
+
}) {
|
|
2417
2757
|
if (!existsSyncFn(gitMarkerPath)) {
|
|
2758
|
+
// A worktree removed outside our cleanup path can leave a stale marker
|
|
2759
|
+
// behind. Clear it before recreating, so a reuse-only member cannot read
|
|
2760
|
+
// "ready" while `git worktree add` is still running.
|
|
2761
|
+
removeTaskWorktreeReadyMarker(worktreeRoot);
|
|
2418
2762
|
const { syncBranch } = readProjectWorktreeSettings(worktreeConfig.projectWorkspacePath);
|
|
2419
2763
|
if (syncBranch) {
|
|
2420
2764
|
try {
|
|
@@ -2512,7 +2856,8 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2512
2856
|
projectWorkspacePath: worktreeConfig.projectWorkspacePath,
|
|
2513
2857
|
finalCwd,
|
|
2514
2858
|
});
|
|
2515
|
-
|
|
2859
|
+
// Publish readiness last: every step above has to have succeeded.
|
|
2860
|
+
writeFileSyncFn(taskWorktreeReadyMarkerPath(worktreeRoot), "");
|
|
2516
2861
|
}
|
|
2517
2862
|
|
|
2518
2863
|
const RTC_MODULE_CANDIDATES = resolveRtcModuleCandidates(process.env.CONDUCTOR_PTY_RTC_MODULES);
|
|
@@ -2545,6 +2890,17 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2545
2890
|
process.env.CONDUCTOR_WORKTREE_SUBMODULE_SYNC_TIMEOUT_MS,
|
|
2546
2891
|
120_000,
|
|
2547
2892
|
);
|
|
2893
|
+
// A reuse-only member waits for the owner to create the shared worktree.
|
|
2894
|
+
// The bound has to cover a cold clone's submodule sync, hence the generous
|
|
2895
|
+
// default; it only ever elapses when the owner never starts or hard-fails.
|
|
2896
|
+
const TASK_WORKTREE_REUSE_WAIT_TIMEOUT_MS = parsePositiveInt(
|
|
2897
|
+
process.env.CONDUCTOR_WORKTREE_REUSE_WAIT_TIMEOUT_MS,
|
|
2898
|
+
180_000,
|
|
2899
|
+
);
|
|
2900
|
+
const TASK_WORKTREE_REUSE_POLL_INTERVAL_MS = parsePositiveInt(
|
|
2901
|
+
process.env.CONDUCTOR_WORKTREE_REUSE_POLL_INTERVAL_MS,
|
|
2902
|
+
250,
|
|
2903
|
+
);
|
|
2548
2904
|
const SHUTDOWN_STATUS_REPORT_TIMEOUT_MS = parsePositiveInt(
|
|
2549
2905
|
process.env.CONDUCTOR_SHUTDOWN_STATUS_REPORT_TIMEOUT_MS,
|
|
2550
2906
|
1000,
|
|
@@ -2586,36 +2942,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2586
2942
|
DEFAULT_TERMINAL_RESUME_SNAPSHOT_MAX_BYTES,
|
|
2587
2943
|
);
|
|
2588
2944
|
|
|
2589
|
-
const readLockState = () =>
|
|
2590
|
-
const raw = String(readFileSyncFn(LOCK_FILE, "utf-8") || "").trim();
|
|
2591
|
-
if (!raw) {
|
|
2592
|
-
return null;
|
|
2593
|
-
}
|
|
2594
|
-
|
|
2595
|
-
const pid = Number.parseInt(raw, 10);
|
|
2596
|
-
if (!Number.isNaN(pid) && pid > 0) {
|
|
2597
|
-
return {
|
|
2598
|
-
pid,
|
|
2599
|
-
handoffFromPid: null,
|
|
2600
|
-
handoffToken: null,
|
|
2601
|
-
handoffExpiresAt: null,
|
|
2602
|
-
};
|
|
2603
|
-
}
|
|
2604
|
-
|
|
2605
|
-
try {
|
|
2606
|
-
const parsed = JSON.parse(raw);
|
|
2607
|
-
const parsedPid = normalizePositiveInt(parsed?.pid, null);
|
|
2608
|
-
const parsedHandoffFromPid = normalizePositiveInt(parsed?.handoff_from_pid, null);
|
|
2609
|
-
return {
|
|
2610
|
-
pid: parsedPid ?? parsedHandoffFromPid,
|
|
2611
|
-
handoffFromPid: parsedHandoffFromPid,
|
|
2612
|
-
handoffToken: normalizeOptionalString(parsed?.handoff_token),
|
|
2613
|
-
handoffExpiresAt: normalizePositiveInt(parsed?.handoff_expires_at, null),
|
|
2614
|
-
};
|
|
2615
|
-
} catch {
|
|
2616
|
-
return null;
|
|
2617
|
-
}
|
|
2618
|
-
};
|
|
2945
|
+
const readLockState = () => parseDaemonLockState(readFileSyncFn(LOCK_FILE, "utf-8"));
|
|
2619
2946
|
|
|
2620
2947
|
const hasMatchingLockHandoff = (lockState) => {
|
|
2621
2948
|
if (!lockState || !lockHandoffToken || !lockHandoffFromPid) {
|
|
@@ -2654,7 +2981,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2654
2981
|
return exitAndReturn(1);
|
|
2655
2982
|
}
|
|
2656
2983
|
|
|
2657
|
-
const LOCK_FILE = path.join(WORKSPACE_ROOT,
|
|
2984
|
+
const LOCK_FILE = path.join(WORKSPACE_ROOT, DAEMON_LOCK_FILE_NAME);
|
|
2658
2985
|
try {
|
|
2659
2986
|
if (skipPidLockCheck) {
|
|
2660
2987
|
log("CONDUCTOR_TUI_DEBUG enabled; skipping daemon PID lock enforcement");
|
|
@@ -2671,6 +2998,21 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2671
2998
|
const alive = isProcessAlive(pid);
|
|
2672
2999
|
if (alive) {
|
|
2673
3000
|
if (config.FORCE) {
|
|
3001
|
+
const forceRefusal = describeForceRestartRefusal({
|
|
3002
|
+
lockState,
|
|
3003
|
+
identity: daemonInstanceIdentity,
|
|
3004
|
+
lockFile: LOCK_FILE,
|
|
3005
|
+
env: process.env,
|
|
3006
|
+
});
|
|
3007
|
+
if (forceRefusal) {
|
|
3008
|
+
logError(forceRefusal);
|
|
3009
|
+
return exitAndReturn(1);
|
|
3010
|
+
}
|
|
3011
|
+
if (compareDaemonLockIdentity(lockState, daemonInstanceIdentity) === "unknown") {
|
|
3012
|
+
log(
|
|
3013
|
+
`${FORCE_KILL_UNKNOWN_OWNER_ENV_VAR} is set: stopping PID ${pid} even though the lock file carries no instance identity`,
|
|
3014
|
+
);
|
|
3015
|
+
}
|
|
2674
3016
|
log(`Force enabled: stopping existing daemon PID ${pid}`);
|
|
2675
3017
|
let alreadyExited = false;
|
|
2676
3018
|
try {
|
|
@@ -2712,7 +3054,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2712
3054
|
unlinkSyncFn(LOCK_FILE);
|
|
2713
3055
|
}
|
|
2714
3056
|
} else {
|
|
2715
|
-
logError(
|
|
3057
|
+
logError(
|
|
3058
|
+
`Daemon already running with PID ${pid} — ${describeDaemonLockOwner(lockState)}. Use --force to restart it.`,
|
|
3059
|
+
);
|
|
2716
3060
|
return exitAndReturn(1);
|
|
2717
3061
|
}
|
|
2718
3062
|
} else {
|
|
@@ -2733,7 +3077,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2733
3077
|
unlinkSyncFn(LOCK_FILE);
|
|
2734
3078
|
}
|
|
2735
3079
|
}
|
|
2736
|
-
writeFileSyncFn(
|
|
3080
|
+
writeFileSyncFn(
|
|
3081
|
+
LOCK_FILE,
|
|
3082
|
+
serializeDaemonLock({ pid: process.pid, identity: daemonInstanceIdentity }),
|
|
3083
|
+
);
|
|
2737
3084
|
}
|
|
2738
3085
|
} catch (err) {
|
|
2739
3086
|
logError("Failed to acquire lock:", err);
|
|
@@ -2760,13 +3107,15 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2760
3107
|
if (skipPidLockCheck) {
|
|
2761
3108
|
return;
|
|
2762
3109
|
}
|
|
3110
|
+
// Keeps the instance identity attached while the handoff window is open so
|
|
3111
|
+
// an unrelated instance racing a `--force` during a restart still sees who
|
|
3112
|
+
// owns the lock.
|
|
2763
3113
|
writeFileSyncFn(
|
|
2764
3114
|
LOCK_FILE,
|
|
2765
|
-
|
|
3115
|
+
serializeDaemonLock({
|
|
2766
3116
|
pid: handoffFromPid,
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
handoff_expires_at: handoffExpiresAt,
|
|
3117
|
+
identity: daemonInstanceIdentity,
|
|
3118
|
+
handoff: { handoffFromPid, handoffToken, handoffExpiresAt },
|
|
2770
3119
|
}),
|
|
2771
3120
|
);
|
|
2772
3121
|
};
|
|
@@ -2844,6 +3193,18 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2844
3193
|
removeProcessListener("unhandledRejection", onUnhandledRejection);
|
|
2845
3194
|
};
|
|
2846
3195
|
|
|
3196
|
+
// RFC 0035: never leave guest daemons orphaned. They are children of this
|
|
3197
|
+
// process, so without this they would survive the host daemon and keep
|
|
3198
|
+
// serving a share the owner believes is stopped.
|
|
3199
|
+
const stopGuestsOnExit = () => {
|
|
3200
|
+
try {
|
|
3201
|
+
stopAllGuestDaemons("host daemon exiting");
|
|
3202
|
+
} catch {
|
|
3203
|
+
// best effort during shutdown
|
|
3204
|
+
}
|
|
3205
|
+
};
|
|
3206
|
+
process.on("exit", stopGuestsOnExit);
|
|
3207
|
+
|
|
2847
3208
|
process.on("exit", cleanupLock);
|
|
2848
3209
|
process.on("SIGINT", onSigInt);
|
|
2849
3210
|
process.on("SIGTERM", onSigTerm);
|
|
@@ -2971,6 +3332,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2971
3332
|
};
|
|
2972
3333
|
const advertisedCapabilities = [
|
|
2973
3334
|
"project_path_validation",
|
|
3335
|
+
"project_path_create",
|
|
2974
3336
|
"project_agents_registry",
|
|
2975
3337
|
"restart_daemon",
|
|
2976
3338
|
"refresh_session_inplace",
|
|
@@ -2985,10 +3347,31 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2985
3347
|
if (ptyTaskCapabilityEnabled) {
|
|
2986
3348
|
advertisedCapabilities.push("pty_task", "terminal_snapshot");
|
|
2987
3349
|
}
|
|
2988
|
-
|
|
2989
|
-
|
|
3350
|
+
// RFC 0035: strip capabilities a guest must never offer. `ai_manager` stays
|
|
3351
|
+
// advertised on purpose -- the grantee needs to see how much AI quota is left
|
|
3352
|
+
// on the borrowed machine -- but its `switch_account` action is refused at
|
|
3353
|
+
// dispatch, because that one rewrites `~/.codex/auth.json` and would change
|
|
3354
|
+
// the machine owner's own active account.
|
|
3355
|
+
const effectiveCapabilities = IS_GUEST_DAEMON
|
|
3356
|
+
? filterGuestCapabilities(advertisedCapabilities)
|
|
3357
|
+
: advertisedCapabilities;
|
|
3358
|
+
if (IS_GUEST_DAEMON) {
|
|
3359
|
+
log(
|
|
3360
|
+
`[guest] Running as a shared guest daemon; capabilities=${effectiveCapabilities.join(",")}` +
|
|
3361
|
+
(GUEST_ROOT ? `, root=${GUEST_ROOT}` : "")
|
|
3362
|
+
);
|
|
3363
|
+
// If the host daemon is SIGKILLed, nothing else would stop this process.
|
|
3364
|
+
startOrphanWatchdog({
|
|
3365
|
+
onOrphaned: () => {
|
|
3366
|
+
logError("[guest] Host daemon is gone; shutting down to avoid an orphaned share");
|
|
3367
|
+
exitAndReturn(0);
|
|
3368
|
+
},
|
|
3369
|
+
});
|
|
3370
|
+
}
|
|
3371
|
+
if (effectiveCapabilities.length > 0) {
|
|
3372
|
+
extraHeaders["x-conductor-capabilities"] = effectiveCapabilities.join(",");
|
|
2990
3373
|
}
|
|
2991
|
-
const aiManagerHandlers = createAiManagerHandlers({ configPath: effectiveConfigPath });
|
|
3374
|
+
const aiManagerHandlers = (deps.createAiManagerHandlers || createAiManagerHandlers)({ configPath: effectiveConfigPath });
|
|
2992
3375
|
const customCommandHandlers = createCustomCommandHandlers({ configPath: effectiveConfigPath });
|
|
2993
3376
|
const remoteExecHandlers = remoteExecEnabled
|
|
2994
3377
|
? createRemoteExecHandlers({ defaultWorkspace: homeDir })
|
|
@@ -3085,6 +3468,28 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3085
3468
|
if (typeof client?.setExtraHeaders === "function") {
|
|
3086
3469
|
client.setExtraHeaders(extraHeaders);
|
|
3087
3470
|
}
|
|
3471
|
+
|
|
3472
|
+
// Advertise best-effort runtime health so the backend can reject a task
|
|
3473
|
+
// whose backend is configured but cannot start (CLI missing/not signed in)
|
|
3474
|
+
// before creating any timeline activity. Fully additive and guarded: any
|
|
3475
|
+
// failure just omits the header and the preflight fails open.
|
|
3476
|
+
try {
|
|
3477
|
+
const runtimeHealth = await computeAdvertisedRuntimeHealth(
|
|
3478
|
+
aiManagerHandlers?.manager,
|
|
3479
|
+
SUPPORTED_BACKENDS,
|
|
3480
|
+
SUPPORTED_BACKEND_RUNTIME_MAP,
|
|
3481
|
+
);
|
|
3482
|
+
const serializedRuntimeHealth = serializeRuntimeHealth(runtimeHealth);
|
|
3483
|
+
if (serializedRuntimeHealth) {
|
|
3484
|
+
extraHeaders["x-conductor-runtime-health"] = serializedRuntimeHealth;
|
|
3485
|
+
if (typeof client?.setExtraHeaders === "function") {
|
|
3486
|
+
client.setExtraHeaders(extraHeaders);
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
} catch (error) {
|
|
3490
|
+
logError(`Failed to probe runtime health: ${error?.message || error}`);
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3088
3493
|
if (daemonShuttingDown) {
|
|
3089
3494
|
return;
|
|
3090
3495
|
}
|
|
@@ -3100,6 +3505,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3100
3505
|
logError(`Failed to connect: ${err}`);
|
|
3101
3506
|
});
|
|
3102
3507
|
|
|
3508
|
+
// RFC 0035: keep one child daemon per accepted share. A guest never
|
|
3509
|
+
// supervises anything itself, or a share chain could nest.
|
|
3510
|
+
if (!IS_GUEST_DAEMON) {
|
|
3511
|
+
void reconcileGuestDaemons();
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3103
3514
|
if (!AUTO_UPDATE_ENABLED && autoUpdateSupportedInstall === false) {
|
|
3104
3515
|
if (installMethod === "homebrew") {
|
|
3105
3516
|
log(`[auto-update] Disabled for Homebrew install; use ${buildUpgradeCommand({ env: process.env })}`);
|
|
@@ -3110,6 +3521,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3110
3521
|
|
|
3111
3522
|
const runMaintenanceTick = async () => {
|
|
3112
3523
|
void runDaemonWatchdog();
|
|
3524
|
+
if (!IS_GUEST_DAEMON) {
|
|
3525
|
+
void reconcileGuestDaemons();
|
|
3526
|
+
}
|
|
3113
3527
|
try {
|
|
3114
3528
|
await checkForUpdate();
|
|
3115
3529
|
} catch {
|
|
@@ -3610,6 +4024,165 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3610
4024
|
await restartDaemonProcess("auto-update");
|
|
3611
4025
|
}
|
|
3612
4026
|
|
|
4027
|
+
// ---------------------------------------------------------------------
|
|
4028
|
+
// RFC 0035: guest daemon supervision.
|
|
4029
|
+
//
|
|
4030
|
+
// For each share the backend reports as active, run one child
|
|
4031
|
+
// `conductor daemon` that authenticates as the grantee. The child is an
|
|
4032
|
+
// ordinary daemon in every respect; what makes it a guest is its isolated
|
|
4033
|
+
// CONDUCTOR_HOME/CONDUCTOR_WS and the grantee's scoped token.
|
|
4034
|
+
// ---------------------------------------------------------------------
|
|
4035
|
+
/** shareId -> { child, guestHost, restartTimer, stopping } */
|
|
4036
|
+
const guestDaemons = new Map();
|
|
4037
|
+
// Failure counts must outlive the entry they describe. The restart path
|
|
4038
|
+
// deletes the entry and re-asks the backend (so a share revoked during
|
|
4039
|
+
// backoff stays dead), which means a per-entry counter resets to 0 on every
|
|
4040
|
+
// respawn and the exponential backoff degrades into a permanent 10s loop.
|
|
4041
|
+
const guestFailureCounts = new Map();
|
|
4042
|
+
let guestReconcileInFlight = false;
|
|
4043
|
+
|
|
4044
|
+
function stopGuestDaemon(shareId, reason) {
|
|
4045
|
+
const entry = guestDaemons.get(shareId);
|
|
4046
|
+
if (!entry) return;
|
|
4047
|
+
entry.stopping = true;
|
|
4048
|
+
if (entry.restartTimer) clearTimeout(entry.restartTimer);
|
|
4049
|
+
guestDaemons.delete(shareId);
|
|
4050
|
+
guestFailureCounts.delete(shareId);
|
|
4051
|
+
if (entry.child && entry.child.exitCode === null && !entry.child.killed) {
|
|
4052
|
+
log(`[guest] Stopping guest ${entry.guestHost} (${reason})`);
|
|
4053
|
+
try {
|
|
4054
|
+
entry.child.kill("SIGTERM");
|
|
4055
|
+
} catch {
|
|
4056
|
+
// already gone
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
function stopAllGuestDaemons(reason) {
|
|
4062
|
+
for (const shareId of [...guestDaemons.keys()]) {
|
|
4063
|
+
stopGuestDaemon(shareId, reason);
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
|
|
4067
|
+
function spawnGuestDaemon(share) {
|
|
4068
|
+
const paths = resolveGuestPaths(share.id, share.workspaceRoot, homeDir);
|
|
4069
|
+
try {
|
|
4070
|
+
writeGuestConfig(
|
|
4071
|
+
paths,
|
|
4072
|
+
buildGuestConfigYaml({
|
|
4073
|
+
agentToken: share.agentToken,
|
|
4074
|
+
backendUrl: BACKEND_HTTP,
|
|
4075
|
+
guestHost: share.guestHost,
|
|
4076
|
+
workspace: paths.workspace,
|
|
4077
|
+
allowCliList: RAW_ALLOW_CLI_LIST,
|
|
4078
|
+
})
|
|
4079
|
+
);
|
|
4080
|
+
} catch (error) {
|
|
4081
|
+
logError(`[guest] Failed to materialize config for ${share.guestHost}: ${error?.message || error}`);
|
|
4082
|
+
return;
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4085
|
+
const env = buildGuestEnv(process.env, paths, {
|
|
4086
|
+
shareId: share.id,
|
|
4087
|
+
explicitRoot: Boolean(share.workspaceRoot),
|
|
4088
|
+
});
|
|
4089
|
+
const child = spawn(
|
|
4090
|
+
process.execPath,
|
|
4091
|
+
[DAEMON_LAUNCHER_PATH, "--config-file", paths.configPath],
|
|
4092
|
+
{ env, stdio: "ignore", detached: false }
|
|
4093
|
+
);
|
|
4094
|
+
|
|
4095
|
+
const entry = {
|
|
4096
|
+
child,
|
|
4097
|
+
guestHost: share.guestHost,
|
|
4098
|
+
restartTimer: null,
|
|
4099
|
+
stopping: false,
|
|
4100
|
+
};
|
|
4101
|
+
guestDaemons.set(share.id, entry);
|
|
4102
|
+
log(`[guest] Started guest ${share.guestHost} for ${share.granteeLabel || "grantee"} (pid=${child.pid})`);
|
|
4103
|
+
|
|
4104
|
+
child.on("exit", (code, signal) => {
|
|
4105
|
+
const current = guestDaemons.get(share.id);
|
|
4106
|
+
// Either we asked it to stop, or a newer entry already replaced it.
|
|
4107
|
+
if (!current || current.child !== child || current.stopping) return;
|
|
4108
|
+
const failures = (guestFailureCounts.get(share.id) || 0) + 1;
|
|
4109
|
+
guestFailureCounts.set(share.id, failures);
|
|
4110
|
+
const delay = nextRestartDelayMs(failures);
|
|
4111
|
+
log(
|
|
4112
|
+
`[guest] Guest ${share.guestHost} exited (code=${code}, signal=${signal}); ` +
|
|
4113
|
+
`restarting in ${Math.round(delay / 1000)}s (failure ${failures})`
|
|
4114
|
+
);
|
|
4115
|
+
current.restartTimer = setTimeout(() => {
|
|
4116
|
+
// Don't resurrect from a stale timer: re-check with the backend
|
|
4117
|
+
// instead, so a share revoked while we were backing off stays dead.
|
|
4118
|
+
guestDaemons.delete(share.id);
|
|
4119
|
+
void reconcileGuestDaemons();
|
|
4120
|
+
}, delay);
|
|
4121
|
+
if (typeof current.restartTimer.unref === "function") current.restartTimer.unref();
|
|
4122
|
+
});
|
|
4123
|
+
|
|
4124
|
+
child.on("error", (error) => {
|
|
4125
|
+
// Node emits `error` without `exit` for e.g. ENOENT. Without this the
|
|
4126
|
+
// entry would sit in the map forever: reconcile counts it as running, so
|
|
4127
|
+
// it is never retried and never started.
|
|
4128
|
+
logError(`[guest] Guest ${share.guestHost} failed to spawn: ${error?.message || error}`);
|
|
4129
|
+
const current = guestDaemons.get(share.id);
|
|
4130
|
+
if (!current || current.child !== child || current.stopping) return;
|
|
4131
|
+
const failures = (guestFailureCounts.get(share.id) || 0) + 1;
|
|
4132
|
+
guestFailureCounts.set(share.id, failures);
|
|
4133
|
+
guestDaemons.delete(share.id);
|
|
4134
|
+
const timer = setTimeout(() => void reconcileGuestDaemons(), nextRestartDelayMs(failures));
|
|
4135
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
4136
|
+
});
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
async function fetchActiveShares() {
|
|
4140
|
+
const url = `${BACKEND_HTTP}/api/daemon-shares/mine?daemonHost=${encodeURIComponent(AGENT_NAME)}`;
|
|
4141
|
+
// Without a deadline a hung backend pins `guestReconcileInFlight` for
|
|
4142
|
+
// undici's 300s default, and nothing else clears it -- the supervisor goes
|
|
4143
|
+
// silently deaf for five minutes.
|
|
4144
|
+
const response = await fetch(url, {
|
|
4145
|
+
headers: { Authorization: `Bearer ${AGENT_TOKEN}` },
|
|
4146
|
+
signal: AbortSignal.timeout(15_000),
|
|
4147
|
+
});
|
|
4148
|
+
if (!response.ok) {
|
|
4149
|
+
// A backend that predates this feature returns 404; that is not an error
|
|
4150
|
+
// worth logging on every maintenance tick.
|
|
4151
|
+
if (response.status === 404) return null;
|
|
4152
|
+
throw new Error(`HTTP ${response.status}`);
|
|
4153
|
+
}
|
|
4154
|
+
const body = await response.json();
|
|
4155
|
+
return Array.isArray(body?.shares) ? body.shares : [];
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
async function reconcileGuestDaemons() {
|
|
4159
|
+
if (guestReconcileInFlight || daemonShuttingDown) return;
|
|
4160
|
+
guestReconcileInFlight = true;
|
|
4161
|
+
try {
|
|
4162
|
+
const shares = await fetchActiveShares();
|
|
4163
|
+
if (shares === null) return;
|
|
4164
|
+
const running = new Set(guestDaemons.keys());
|
|
4165
|
+
const { start, stop, skipped } = reconcileGuests(shares, running, MAX_GUEST_DAEMONS);
|
|
4166
|
+
for (const shareId of stop) {
|
|
4167
|
+
stopGuestDaemon(shareId, "share no longer active");
|
|
4168
|
+
}
|
|
4169
|
+
for (const share of start) {
|
|
4170
|
+
spawnGuestDaemon(share);
|
|
4171
|
+
}
|
|
4172
|
+
if (skipped > 0) {
|
|
4173
|
+
// Never drop guests silently -- a share that looks accepted in the UI
|
|
4174
|
+
// but never runs is the hardest kind of bug to report.
|
|
4175
|
+
log(
|
|
4176
|
+
`[guest] ${skipped} share(s) not started: at the ${MAX_GUEST_DAEMONS}-guest limit for this daemon`
|
|
4177
|
+
);
|
|
4178
|
+
}
|
|
4179
|
+
} catch (error) {
|
|
4180
|
+
logError(`[guest] Failed to reconcile guest daemons: ${error?.message || error}`);
|
|
4181
|
+
} finally {
|
|
4182
|
+
guestReconcileInFlight = false;
|
|
4183
|
+
}
|
|
4184
|
+
}
|
|
4185
|
+
|
|
3613
4186
|
async function handleRestartDaemon(payload) {
|
|
3614
4187
|
const requestId = payload?.request_id ? String(payload.request_id) : "";
|
|
3615
4188
|
const targetVersionRaw = payload?.target_version
|
|
@@ -3635,6 +4208,22 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3635
4208
|
return;
|
|
3636
4209
|
}
|
|
3637
4210
|
|
|
4211
|
+
// RFC 0035: a versioned restart runs a *global* `npm install -g`, which
|
|
4212
|
+
// would swap the CLI binary out from under the machine owner's own daemon
|
|
4213
|
+
// and every one of their fire processes. A guest may restart itself, but
|
|
4214
|
+
// it may not change the machine's installed version.
|
|
4215
|
+
if (IS_GUEST_DAEMON && !isGuestRestartAllowed(payload)) {
|
|
4216
|
+
log(
|
|
4217
|
+
`[restart_daemon] Refused (${requestId}): guest daemons cannot install version ${targetVersionRaw}`
|
|
4218
|
+
);
|
|
4219
|
+
sendAgentCommandAck({
|
|
4220
|
+
requestId,
|
|
4221
|
+
eventType: "restart_daemon",
|
|
4222
|
+
accepted: false,
|
|
4223
|
+
}).catch(() => {});
|
|
4224
|
+
return;
|
|
4225
|
+
}
|
|
4226
|
+
|
|
3638
4227
|
autoUpdateInProgress = true;
|
|
3639
4228
|
try {
|
|
3640
4229
|
log(
|
|
@@ -4242,89 +4831,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4242
4831
|
}
|
|
4243
4832
|
|
|
4244
4833
|
function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
normalizeOptionalString(normalizedLaunchConfig.entrypointType) ||
|
|
4249
|
-
(normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
|
|
4250
|
-
normalizeOptionalString(normalizedLaunchConfig.toolPreset)
|
|
4251
|
-
? "tool_preset"
|
|
4252
|
-
: "shell");
|
|
4253
|
-
const preferredShell = resolveDefaultPtyShell({
|
|
4254
|
-
explicitShell: normalizedLaunchConfig.shell,
|
|
4255
|
-
envShell: process.env.SHELL,
|
|
4256
|
-
comspec: process.env.COMSPEC,
|
|
4257
|
-
platform: process.platform,
|
|
4834
|
+
return buildPtyLaunchSpec(launchConfig, fallbackCwd, {
|
|
4835
|
+
allowCliList: ALLOW_CLI_LIST,
|
|
4836
|
+
supportedBackends: SUPPORTED_BACKENDS,
|
|
4258
4837
|
existsSync: existsSyncFn,
|
|
4838
|
+
log,
|
|
4259
4839
|
});
|
|
4260
|
-
const cwd =
|
|
4261
|
-
normalizeOptionalString(normalizedLaunchConfig.cwd) ||
|
|
4262
|
-
fallbackCwd;
|
|
4263
|
-
const env = normalizeTerminalEnv(normalizedLaunchConfig.env);
|
|
4264
|
-
const cols = normalizePositiveInt(
|
|
4265
|
-
normalizedLaunchConfig.cols ?? normalizedLaunchConfig.columns,
|
|
4266
|
-
DEFAULT_TERMINAL_COLS,
|
|
4267
|
-
);
|
|
4268
|
-
const rows = normalizePositiveInt(
|
|
4269
|
-
normalizedLaunchConfig.rows,
|
|
4270
|
-
DEFAULT_TERMINAL_ROWS,
|
|
4271
|
-
);
|
|
4272
|
-
|
|
4273
|
-
if (entrypointType === "tool_preset") {
|
|
4274
|
-
const toolPreset =
|
|
4275
|
-
normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
|
|
4276
|
-
normalizeOptionalString(normalizedLaunchConfig.toolPreset) ||
|
|
4277
|
-
SUPPORTED_BACKENDS[0] ||
|
|
4278
|
-
"codex";
|
|
4279
|
-
const cliCommand = ALLOW_CLI_LIST[toolPreset];
|
|
4280
|
-
if (!cliCommand) {
|
|
4281
|
-
throw new Error(`Unsupported tool preset: ${toolPreset}`);
|
|
4282
|
-
}
|
|
4283
|
-
return {
|
|
4284
|
-
entrypointType,
|
|
4285
|
-
toolPreset,
|
|
4286
|
-
command: preferredShell,
|
|
4287
|
-
args: ["-lc", cliCommand],
|
|
4288
|
-
shell: preferredShell,
|
|
4289
|
-
cwd,
|
|
4290
|
-
env,
|
|
4291
|
-
cols,
|
|
4292
|
-
rows,
|
|
4293
|
-
};
|
|
4294
|
-
}
|
|
4295
|
-
|
|
4296
|
-
if (entrypointType === "custom") {
|
|
4297
|
-
const command = normalizeOptionalString(normalizedLaunchConfig.command);
|
|
4298
|
-
if (!command) {
|
|
4299
|
-
throw new Error("launch_config.command is required for custom entrypoint");
|
|
4300
|
-
}
|
|
4301
|
-
const args = Array.isArray(normalizedLaunchConfig.args)
|
|
4302
|
-
? normalizedLaunchConfig.args.filter((value) => typeof value === "string")
|
|
4303
|
-
: [];
|
|
4304
|
-
return {
|
|
4305
|
-
entrypointType,
|
|
4306
|
-
toolPreset: null,
|
|
4307
|
-
command,
|
|
4308
|
-
args,
|
|
4309
|
-
shell: preferredShell,
|
|
4310
|
-
cwd,
|
|
4311
|
-
env,
|
|
4312
|
-
cols,
|
|
4313
|
-
rows,
|
|
4314
|
-
};
|
|
4315
|
-
}
|
|
4316
|
-
|
|
4317
|
-
return {
|
|
4318
|
-
entrypointType: "shell",
|
|
4319
|
-
toolPreset: null,
|
|
4320
|
-
command: preferredShell,
|
|
4321
|
-
args: ["-l"],
|
|
4322
|
-
shell: preferredShell,
|
|
4323
|
-
cwd,
|
|
4324
|
-
env,
|
|
4325
|
-
cols,
|
|
4326
|
-
rows,
|
|
4327
|
-
};
|
|
4328
4840
|
}
|
|
4329
4841
|
|
|
4330
4842
|
function getTerminalChunkByteLength(data) {
|
|
@@ -4840,6 +5352,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4840
5352
|
{ cwd: worktreeConfig.projectRepoRoot },
|
|
4841
5353
|
);
|
|
4842
5354
|
}
|
|
5355
|
+
// The marker lives outside the worktree, so `git worktree remove` does
|
|
5356
|
+
// not take it with the directory. Leaving it behind would let a worktree
|
|
5357
|
+
// later recreated on this path look ready before it is prepared.
|
|
5358
|
+
removeTaskWorktreeReadyMarker(worktreeRoot);
|
|
4843
5359
|
|
|
4844
5360
|
await reportTaskWorktreeCleanupResult({
|
|
4845
5361
|
requestId,
|
|
@@ -5211,6 +5727,26 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5211
5727
|
return;
|
|
5212
5728
|
}
|
|
5213
5729
|
if (event.type === "ai_manager_request") {
|
|
5730
|
+
// RFC 0035: reads are fine and genuinely useful to a guest (it needs to
|
|
5731
|
+
// know how much quota the borrowed machine has left). `switch_account` is
|
|
5732
|
+
// not: `AiManager` resolves the codex auth path from `homedir()`, so a
|
|
5733
|
+
// guest switching accounts renames over `~/.codex/auth.json` and changes
|
|
5734
|
+
// the machine owner's own daemon, running fires, and interactive `codex`.
|
|
5735
|
+
const aiAction = event.payload?.action;
|
|
5736
|
+
if (IS_GUEST_DAEMON && !isGuestAiManagerActionAllowed(aiAction)) {
|
|
5737
|
+
log(`[guest] Refused ai_manager action '${aiAction}' on a shared daemon`);
|
|
5738
|
+
client
|
|
5739
|
+
.send({
|
|
5740
|
+
type: "ai_manager_response",
|
|
5741
|
+
payload: {
|
|
5742
|
+
request_id: event.payload?.request_id,
|
|
5743
|
+
error: "Account switching is disabled on a shared daemon",
|
|
5744
|
+
error_code: "guest_forbidden",
|
|
5745
|
+
},
|
|
5746
|
+
})
|
|
5747
|
+
.catch(() => {});
|
|
5748
|
+
return;
|
|
5749
|
+
}
|
|
5214
5750
|
handleAiManagerRequest(client, aiManagerHandlers, event.payload).catch((error) => {
|
|
5215
5751
|
logError(`Unhandled ai_manager_request failure: ${error?.message || error}`);
|
|
5216
5752
|
});
|
|
@@ -5333,6 +5869,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5333
5869
|
async function handleValidateProjectPath(payload) {
|
|
5334
5870
|
const requestId = payload?.request_id ? String(payload.request_id).trim() : "";
|
|
5335
5871
|
const rawWorkspacePath = payload?.workspace_path ? String(payload.workspace_path).trim() : "";
|
|
5872
|
+
const createIfMissing = payload?.create_if_missing === true;
|
|
5336
5873
|
const validatedAt = new Date().toISOString();
|
|
5337
5874
|
|
|
5338
5875
|
if (!requestId || !rawWorkspacePath) {
|
|
@@ -5340,6 +5877,27 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5340
5877
|
return;
|
|
5341
5878
|
}
|
|
5342
5879
|
|
|
5880
|
+
// RFC 0035: keep a guest inside the directory its owner set aside. This is
|
|
5881
|
+
// misuse prevention, not a security boundary -- the guest's AI agent runs a
|
|
5882
|
+
// shell and can reach anything the OS user can. It matters because neither
|
|
5883
|
+
// handler is otherwise bounded: this one will `mkdirSync(recursive)` any
|
|
5884
|
+
// absolute path it is handed.
|
|
5885
|
+
if (IS_GUEST_DAEMON && GUEST_ROOT && !isPathInsideGuestRoot(rawWorkspacePath, GUEST_ROOT)) {
|
|
5886
|
+
await client.sendJson({
|
|
5887
|
+
type: "project_path_validated",
|
|
5888
|
+
payload: {
|
|
5889
|
+
request_id: requestId,
|
|
5890
|
+
daemon_host: AGENT_NAME,
|
|
5891
|
+
workspace_path: rawWorkspacePath,
|
|
5892
|
+
error: `Path is outside the shared workspace root (${GUEST_ROOT})`,
|
|
5893
|
+
error_code: "outside_guest_root",
|
|
5894
|
+
validated_at: new Date().toISOString(),
|
|
5895
|
+
},
|
|
5896
|
+
});
|
|
5897
|
+
return;
|
|
5898
|
+
}
|
|
5899
|
+
|
|
5900
|
+
|
|
5343
5901
|
let result = {
|
|
5344
5902
|
workspacePath: null,
|
|
5345
5903
|
repoRoot: null,
|
|
@@ -5356,7 +5914,22 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5356
5914
|
|
|
5357
5915
|
try {
|
|
5358
5916
|
const resolvedPath = path.resolve(rawWorkspacePath);
|
|
5359
|
-
|
|
5917
|
+
let createError = null;
|
|
5918
|
+
if (createIfMissing && !existsSyncFn(resolvedPath)) {
|
|
5919
|
+
try {
|
|
5920
|
+
mkdirSyncFn(resolvedPath, { recursive: true });
|
|
5921
|
+
log(`Created workspace path on request: ${resolvedPath}`);
|
|
5922
|
+
} catch (error) {
|
|
5923
|
+
createError = error;
|
|
5924
|
+
}
|
|
5925
|
+
}
|
|
5926
|
+
if (createError) {
|
|
5927
|
+
result = {
|
|
5928
|
+
...result,
|
|
5929
|
+
error: `Failed to create workspace path on daemon ${AGENT_NAME}: ${createError?.message || createError}`,
|
|
5930
|
+
errorCode: "workspace_create_failed",
|
|
5931
|
+
};
|
|
5932
|
+
} else if (!existsSyncFn(resolvedPath)) {
|
|
5360
5933
|
result = {
|
|
5361
5934
|
...result,
|
|
5362
5935
|
error: `Workspace path does not exist on daemon ${AGENT_NAME}: ${rawWorkspacePath}`,
|
|
@@ -5449,6 +6022,27 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5449
6022
|
return;
|
|
5450
6023
|
}
|
|
5451
6024
|
|
|
6025
|
+
// RFC 0035: keep a guest inside the directory its owner set aside. This is
|
|
6026
|
+
// misuse prevention, not a security boundary -- the guest's AI agent runs a
|
|
6027
|
+
// shell and can reach anything the OS user can. It matters because neither
|
|
6028
|
+
// handler is otherwise bounded: this one will `mkdirSync(recursive)` any
|
|
6029
|
+
// absolute path it is handed.
|
|
6030
|
+
if (IS_GUEST_DAEMON && GUEST_ROOT && !isPathInsideGuestRoot(rawWorkspacePath, GUEST_ROOT)) {
|
|
6031
|
+
await client.sendJson({
|
|
6032
|
+
type: "project_agents_resolved",
|
|
6033
|
+
payload: {
|
|
6034
|
+
request_id: requestId,
|
|
6035
|
+
daemon_host: AGENT_NAME,
|
|
6036
|
+
workspace_path: rawWorkspacePath,
|
|
6037
|
+
error: `Path is outside the shared workspace root (${GUEST_ROOT})`,
|
|
6038
|
+
error_code: "outside_guest_root",
|
|
6039
|
+
resolved_at: new Date().toISOString(),
|
|
6040
|
+
},
|
|
6041
|
+
});
|
|
6042
|
+
return;
|
|
6043
|
+
}
|
|
6044
|
+
|
|
6045
|
+
|
|
5452
6046
|
let result = {
|
|
5453
6047
|
workspacePath: null,
|
|
5454
6048
|
agents: [],
|