@love-moon/conductor-cli 0.10.0 → 0.11.1
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 +176 -0
- package/bin/conductor-config.js +127 -12
- package/bin/conductor-daemon-update.js +55 -0
- package/bin/conductor-daemon.js +53 -29
- package/bin/conductor-project.js +3 -1
- package/package.json +5 -5
- package/src/daemon-lock.js +240 -0
- package/src/daemon-update.js +614 -0
- package/src/daemon.js +1162 -152
- package/src/fire-session-registry.js +136 -0
- package/src/guest-daemon.js +275 -0
package/src/daemon.js
CHANGED
|
@@ -24,6 +24,13 @@ import {
|
|
|
24
24
|
resolveConductorConfigPath,
|
|
25
25
|
resolveConductorHome,
|
|
26
26
|
} from "./conductor-paths.js";
|
|
27
|
+
import {
|
|
28
|
+
deleteFireSessionRecord,
|
|
29
|
+
pruneFireSessionRecords,
|
|
30
|
+
readFireSessionRecord,
|
|
31
|
+
resolveFireSessionRegistryDir,
|
|
32
|
+
writeFireSessionRecord,
|
|
33
|
+
} from "./fire-session-registry.js";
|
|
27
34
|
import { createAiManagerHandlers, handleAiManagerRequest } from "./ai-manager-handlers.js";
|
|
28
35
|
import {
|
|
29
36
|
CUSTOM_COMMANDS_CAPABILITY,
|
|
@@ -35,9 +42,16 @@ import {
|
|
|
35
42
|
createRemoteExecHandlers,
|
|
36
43
|
handleRemoteExecRequest,
|
|
37
44
|
} from "./remote-exec-handlers.js";
|
|
45
|
+
import {
|
|
46
|
+
UPDATE_DAEMON_CAPABILITY,
|
|
47
|
+
createDaemonUpdateHandlers,
|
|
48
|
+
handleUpdateDaemonRequest,
|
|
49
|
+
resolveDaemonUpdatePaths,
|
|
50
|
+
} from "./daemon-update.js";
|
|
38
51
|
import { resolveResumeContext } from "./fire/resume.js";
|
|
39
52
|
import {
|
|
40
53
|
filterRuntimeSupportedAllowCliList,
|
|
54
|
+
inferBuiltInRuntimeBackendFromCommand,
|
|
41
55
|
listAdvertisedBackends,
|
|
42
56
|
resolveConfiguredRuntimeBackend,
|
|
43
57
|
isBuiltInRuntimeBackend,
|
|
@@ -46,6 +60,7 @@ import {
|
|
|
46
60
|
normalizeRuntimeBackendAlias,
|
|
47
61
|
normalizeRuntimeBackendName,
|
|
48
62
|
} from "./runtime-backends.js";
|
|
63
|
+
import { resolveClaudeCommandForRoot } from "@love-moon/ai-sdk";
|
|
49
64
|
import {
|
|
50
65
|
PACKAGE_NAME,
|
|
51
66
|
buildUpgradeCommand,
|
|
@@ -67,7 +82,31 @@ import {
|
|
|
67
82
|
maskErrorForLogs,
|
|
68
83
|
redactSecretsForLogs,
|
|
69
84
|
} from "./handoff-log-mask.js";
|
|
85
|
+
import {
|
|
86
|
+
DAEMON_LOCK_FILE_NAME,
|
|
87
|
+
FORCE_KILL_UNKNOWN_OWNER_ENV_VAR,
|
|
88
|
+
buildDaemonInstanceIdentity,
|
|
89
|
+
compareDaemonLockIdentity,
|
|
90
|
+
describeDaemonLockOwner,
|
|
91
|
+
describeForceRestartRefusal,
|
|
92
|
+
parseDaemonLockState,
|
|
93
|
+
serializeDaemonLock,
|
|
94
|
+
} from "./daemon-lock.js";
|
|
70
95
|
import { StringDecoder } from "node:string_decoder";
|
|
96
|
+
import {
|
|
97
|
+
MAX_GUEST_DAEMONS,
|
|
98
|
+
buildGuestConfigYaml,
|
|
99
|
+
buildGuestEnv,
|
|
100
|
+
filterGuestCapabilities,
|
|
101
|
+
isGuestAiManagerActionAllowed,
|
|
102
|
+
isGuestRestartAllowed,
|
|
103
|
+
isPathInsideGuestRoot,
|
|
104
|
+
nextRestartDelayMs,
|
|
105
|
+
reconcileGuests,
|
|
106
|
+
startOrphanWatchdog,
|
|
107
|
+
resolveGuestPaths,
|
|
108
|
+
writeGuestConfig,
|
|
109
|
+
} from "./guest-daemon.js";
|
|
71
110
|
|
|
72
111
|
dotenv.config();
|
|
73
112
|
|
|
@@ -76,6 +115,11 @@ const __dirname = path.dirname(__filename);
|
|
|
76
115
|
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
77
116
|
const moduleRequire = createRequire(import.meta.url);
|
|
78
117
|
const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-fire.js");
|
|
118
|
+
// RFC 0035: the launcher a guest daemon child is spawned with. Same binary as
|
|
119
|
+
// the host daemon, so guests always match the installed version and there is
|
|
120
|
+
// no second update path to keep in sync.
|
|
121
|
+
const DAEMON_LAUNCHER_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-daemon.js");
|
|
122
|
+
const DAEMON_UPDATE_SCRIPT_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-daemon-update.js");
|
|
79
123
|
const CLI_VERSION = (() => {
|
|
80
124
|
try {
|
|
81
125
|
return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
|
|
@@ -493,6 +537,18 @@ export function ensureNodePtySpawnHelperExecutable(deps = {}) {
|
|
|
493
537
|
return { helperPath, updated: true };
|
|
494
538
|
}
|
|
495
539
|
|
|
540
|
+
// A tool-preset PTY task runs the configured allow_cli_list command through a
|
|
541
|
+
// login shell, bypassing ai-sdk entirely. claude refuses to start as root with
|
|
542
|
+
// `--dangerously-skip-permissions`, so rewrite the command the same way the
|
|
543
|
+
// ai-sdk session rewrites its permission mode — one shared root check, so the
|
|
544
|
+
// two paths cannot drift.
|
|
545
|
+
export function resolvePtyToolPresetCommand(cliCommand, env = process.env) {
|
|
546
|
+
if (inferBuiltInRuntimeBackendFromCommand(cliCommand) !== "claude") {
|
|
547
|
+
return cliCommand;
|
|
548
|
+
}
|
|
549
|
+
return resolveClaudeCommandForRoot(cliCommand, env);
|
|
550
|
+
}
|
|
551
|
+
|
|
496
552
|
export function isSafeTaskWorktreeRoot(projectWorkspacePath, worktreeRoot) {
|
|
497
553
|
const normalizedWorkspacePath =
|
|
498
554
|
typeof projectWorkspacePath === "string" ? projectWorkspacePath.trim() : "";
|
|
@@ -675,6 +731,12 @@ function parseTaskWorktreeLaunchConfig(launchConfig) {
|
|
|
675
731
|
projectRepoRoot,
|
|
676
732
|
projectWorkspacePath,
|
|
677
733
|
projectRelativePath,
|
|
734
|
+
// Reuse-only members (reviewers in a multi-agent group) share the owner's
|
|
735
|
+
// worktree but must never run `git worktree add` themselves.
|
|
736
|
+
worktreeReuseOnly: normalizeBooleanFlag(
|
|
737
|
+
normalizedLaunchConfig.worktreeReuseOnly ??
|
|
738
|
+
normalizedLaunchConfig.worktree_reuse_only,
|
|
739
|
+
),
|
|
678
740
|
};
|
|
679
741
|
}
|
|
680
742
|
|
|
@@ -772,6 +834,106 @@ function buildPtyTaskEnv(baseEnv = process.env, launchEnv = {}) {
|
|
|
772
834
|
};
|
|
773
835
|
}
|
|
774
836
|
|
|
837
|
+
// Module-level so the PTY launch wiring is directly testable: the tool-preset
|
|
838
|
+
// branch has to hand the *child's* env to the root check, and that seam is
|
|
839
|
+
// exactly where a bug hid before (the check read the daemon's own process.env,
|
|
840
|
+
// silently ignoring a per-task IS_SANDBOX=1 opt-out).
|
|
841
|
+
export function buildPtyLaunchSpec(launchConfig, fallbackCwd, deps = {}) {
|
|
842
|
+
const allowCliList = deps.allowCliList || {};
|
|
843
|
+
const supportedBackends = Array.isArray(deps.supportedBackends) ? deps.supportedBackends : [];
|
|
844
|
+
const existsSyncFn = deps.existsSync || fs.existsSync;
|
|
845
|
+
const baseEnv = deps.baseEnv || process.env;
|
|
846
|
+
const log = typeof deps.log === "function" ? deps.log : () => {};
|
|
847
|
+
const normalizedLaunchConfig = normalizeLaunchConfig(launchConfig);
|
|
848
|
+
const entrypointType =
|
|
849
|
+
normalizeOptionalString(normalizedLaunchConfig.entrypoint_type) ||
|
|
850
|
+
normalizeOptionalString(normalizedLaunchConfig.entrypointType) ||
|
|
851
|
+
(normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
|
|
852
|
+
normalizeOptionalString(normalizedLaunchConfig.toolPreset)
|
|
853
|
+
? "tool_preset"
|
|
854
|
+
: "shell");
|
|
855
|
+
const preferredShell = resolveDefaultPtyShell({
|
|
856
|
+
explicitShell: normalizedLaunchConfig.shell,
|
|
857
|
+
envShell: process.env.SHELL,
|
|
858
|
+
comspec: process.env.COMSPEC,
|
|
859
|
+
platform: process.platform,
|
|
860
|
+
existsSync: existsSyncFn,
|
|
861
|
+
});
|
|
862
|
+
const cwd =
|
|
863
|
+
normalizeOptionalString(normalizedLaunchConfig.cwd) ||
|
|
864
|
+
fallbackCwd;
|
|
865
|
+
const env = normalizeTerminalEnv(normalizedLaunchConfig.env);
|
|
866
|
+
const cols = normalizePositiveInt(
|
|
867
|
+
normalizedLaunchConfig.cols ?? normalizedLaunchConfig.columns,
|
|
868
|
+
DEFAULT_TERMINAL_COLS,
|
|
869
|
+
);
|
|
870
|
+
const rows = normalizePositiveInt(
|
|
871
|
+
normalizedLaunchConfig.rows,
|
|
872
|
+
DEFAULT_TERMINAL_ROWS,
|
|
873
|
+
);
|
|
874
|
+
|
|
875
|
+
if (entrypointType === "tool_preset") {
|
|
876
|
+
const toolPreset =
|
|
877
|
+
normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
|
|
878
|
+
normalizeOptionalString(normalizedLaunchConfig.toolPreset) ||
|
|
879
|
+
supportedBackends[0] ||
|
|
880
|
+
"codex";
|
|
881
|
+
const cliCommand = allowCliList[toolPreset];
|
|
882
|
+
if (!cliCommand) {
|
|
883
|
+
throw new Error(`Unsupported tool preset: ${toolPreset}`);
|
|
884
|
+
}
|
|
885
|
+
const launchCommand = resolvePtyToolPresetCommand(cliCommand, buildPtyTaskEnv(baseEnv, env));
|
|
886
|
+
if (launchCommand !== cliCommand) {
|
|
887
|
+
log(`[pty] Adjusted ${toolPreset} command for root: ${launchCommand}`);
|
|
888
|
+
}
|
|
889
|
+
return {
|
|
890
|
+
entrypointType,
|
|
891
|
+
toolPreset,
|
|
892
|
+
command: preferredShell,
|
|
893
|
+
args: ["-lc", launchCommand],
|
|
894
|
+
shell: preferredShell,
|
|
895
|
+
cwd,
|
|
896
|
+
env,
|
|
897
|
+
cols,
|
|
898
|
+
rows,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (entrypointType === "custom") {
|
|
903
|
+
const command = normalizeOptionalString(normalizedLaunchConfig.command);
|
|
904
|
+
if (!command) {
|
|
905
|
+
throw new Error("launch_config.command is required for custom entrypoint");
|
|
906
|
+
}
|
|
907
|
+
const args = Array.isArray(normalizedLaunchConfig.args)
|
|
908
|
+
? normalizedLaunchConfig.args.filter((value) => typeof value === "string")
|
|
909
|
+
: [];
|
|
910
|
+
return {
|
|
911
|
+
entrypointType,
|
|
912
|
+
toolPreset: null,
|
|
913
|
+
command,
|
|
914
|
+
args,
|
|
915
|
+
shell: preferredShell,
|
|
916
|
+
cwd,
|
|
917
|
+
env,
|
|
918
|
+
cols,
|
|
919
|
+
rows,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
return {
|
|
924
|
+
entrypointType: "shell",
|
|
925
|
+
toolPreset: null,
|
|
926
|
+
command: preferredShell,
|
|
927
|
+
args: ["-l"],
|
|
928
|
+
shell: preferredShell,
|
|
929
|
+
cwd,
|
|
930
|
+
env,
|
|
931
|
+
cols,
|
|
932
|
+
rows,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
|
|
775
937
|
export function startDaemon(config = {}, deps = {}) {
|
|
776
938
|
const exitFn = deps.exit || process.exit;
|
|
777
939
|
const killFn = deps.kill || process.kill;
|
|
@@ -881,6 +1043,24 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
881
1043
|
// warning and silently fall back to direct spawn rather than failing every
|
|
882
1044
|
// create_task with ENOENT.
|
|
883
1045
|
const FIRE_TMUX_MODE_ENABLED = getFireTmuxModeEnabled(userConfig);
|
|
1046
|
+
// RFC 0035: a guest daemon runs on someone else's machine as a different
|
|
1047
|
+
// account. It keeps the ordinary daemon code path but drops the capabilities
|
|
1048
|
+
// that would mutate state the machine's OWNER depends on.
|
|
1049
|
+
const IS_GUEST_DAEMON =
|
|
1050
|
+
userConfig.conductor_guest === true ||
|
|
1051
|
+
fileConfig?.conductorGuest === true ||
|
|
1052
|
+
Boolean(normalizeOptionalString(process.env.CONDUCTOR_GUEST_SHARE_ID));
|
|
1053
|
+
// Only set when the share carries an explicit `workspaceRoot`, i.e. the owner
|
|
1054
|
+
// deliberately scoped where the guest should work. Absent that, a guest binds
|
|
1055
|
+
// projects wherever any other daemon could -- inventing a confinement the
|
|
1056
|
+
// owner never asked for would make the guest gratuitously less capable, and
|
|
1057
|
+
// it was never a security boundary anyway (the grantee's agent runs a shell).
|
|
1058
|
+
const GUEST_ROOT = normalizeOptionalString(process.env.CONDUCTOR_GUEST_ROOT);
|
|
1059
|
+
// A guest resolves `remote_exec` from its own config exactly like any other
|
|
1060
|
+
// daemon. It is not withheld: the grantee already has a shell here through
|
|
1061
|
+
// AI tasks and `pty_task`'s custom entrypoint, so blocking the scriptable
|
|
1062
|
+
// path would cost function without removing reach (RFC 0034 makes the same
|
|
1063
|
+
// argument). An owner who wants it off can set `remote_exec: false`.
|
|
884
1064
|
const remoteExecEnabled = getRemoteExecEnabled(userConfig);
|
|
885
1065
|
|
|
886
1066
|
// Get allow_cli_list from config
|
|
@@ -911,6 +1091,17 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
911
1091
|
readFileSync: deps.readFileSync || fs.readFileSync,
|
|
912
1092
|
}));
|
|
913
1093
|
const skipPidLockCheck = parseBooleanEnv(process.env.CONDUCTOR_TUI_DEBUG);
|
|
1094
|
+
// Fingerprint of *this* daemon instance, persisted into daemon.pid so that a
|
|
1095
|
+
// later `--force` can tell "restart myself" apart from "kill whoever happens
|
|
1096
|
+
// to hold the lock". WORKSPACE_ROOT defaults to $HOME/ws, so unrelated
|
|
1097
|
+
// instances collide on the same lock file by default.
|
|
1098
|
+
const daemonInstanceIdentity = buildDaemonInstanceIdentity({
|
|
1099
|
+
conductorHome: materializedConductorPathEnv.CONDUCTOR_HOME,
|
|
1100
|
+
configPath: effectiveConfigPath,
|
|
1101
|
+
workspaceRoot: WORKSPACE_ROOT,
|
|
1102
|
+
daemonName: AGENT_NAME,
|
|
1103
|
+
backendUrl: BACKEND_HTTP,
|
|
1104
|
+
});
|
|
914
1105
|
const lockHandoffToken =
|
|
915
1106
|
normalizeOptionalString(config.LOCK_HANDOFF_TOKEN) ||
|
|
916
1107
|
normalizeOptionalString(process.env.CONDUCTOR_LOCK_HANDOFF_TOKEN);
|
|
@@ -1010,6 +1201,16 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1010
1201
|
log("Fire tmux mode enabled: each Fire process will run in a detached tmux session");
|
|
1011
1202
|
}
|
|
1012
1203
|
|
|
1204
|
+
// Shared by every Fire tmux session name. Also the discriminator that lets
|
|
1205
|
+
// startup adoption tell our sessions apart from the user's own tmux work.
|
|
1206
|
+
const FIRE_TMUX_SESSION_NAMESPACE = "conductor-fire-";
|
|
1207
|
+
|
|
1208
|
+
// Where the per-session hand-off records live. See fire-session-registry.js
|
|
1209
|
+
// for why a fresh daemon cannot reconstruct them from the session alone.
|
|
1210
|
+
const FIRE_SESSION_REGISTRY_DIR = resolveFireSessionRegistryDir(
|
|
1211
|
+
materializedConductorPathEnv.CONDUCTOR_HOME,
|
|
1212
|
+
);
|
|
1213
|
+
|
|
1013
1214
|
// Single-quote a value so it can be embedded inside a `bash -c '...'`
|
|
1014
1215
|
// command. Embedded single-quotes are escaped via the standard `'\\''`
|
|
1015
1216
|
// sequence.
|
|
@@ -1034,7 +1235,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1034
1235
|
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
1035
1236
|
.replace(/^_+|_+$/g, "")
|
|
1036
1237
|
.slice(0, 32) || "task";
|
|
1037
|
-
return
|
|
1238
|
+
return `${FIRE_TMUX_SESSION_NAMESPACE}${safe}-`;
|
|
1038
1239
|
}
|
|
1039
1240
|
|
|
1040
1241
|
// Build a unique tmux session name for a Fire spawn.
|
|
@@ -1577,7 +1778,14 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1577
1778
|
// would be misread as "died hard" — reporting every clean finish as
|
|
1578
1779
|
// KILLED. Staying silent restores the old blind spot for this task only,
|
|
1579
1780
|
// which is strictly better than manufacturing a wrong cause of death.
|
|
1580
|
-
|
|
1781
|
+
//
|
|
1782
|
+
// The one exception is a Fire adopted without a hand-off record. There
|
|
1783
|
+
// the silence is not a blind spot we are preserving but one we would be
|
|
1784
|
+
// creating: nothing else watches an adopted session, so staying quiet
|
|
1785
|
+
// parks the task at `running` forever. Speak, and let the backend
|
|
1786
|
+
// pre-check below stop us from downgrading a status fire already
|
|
1787
|
+
// published for itself.
|
|
1788
|
+
if (!record.logPath && !record.adoptedWithoutMetadata) {
|
|
1581
1789
|
logError(
|
|
1582
1790
|
`Cannot classify the end of task ${taskId}: no log path on the active record, so no exit marker exists`,
|
|
1583
1791
|
);
|
|
@@ -1590,7 +1798,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1590
1798
|
// that absence as "no exit marker" would report a green task as KILLED
|
|
1591
1799
|
// with an invented "killed or OOM" cause. An empty-but-present file is
|
|
1592
1800
|
// different: the fire really did die before writing anything.
|
|
1593
|
-
if (readLogFileSize(record.logPath) === null) {
|
|
1801
|
+
if (!record.adoptedWithoutMetadata && readLogFileSize(record.logPath) === null) {
|
|
1594
1802
|
logError(
|
|
1595
1803
|
`Cannot classify the end of task ${taskId}: log file ${record.logPath} is missing, ` +
|
|
1596
1804
|
`so the absence of an exit marker proves nothing`,
|
|
@@ -1598,7 +1806,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1598
1806
|
return;
|
|
1599
1807
|
}
|
|
1600
1808
|
|
|
1601
|
-
const rawTail =
|
|
1809
|
+
const rawTail = record.adoptedWithoutMetadata
|
|
1810
|
+
? ""
|
|
1811
|
+
: readLogTailRaw(record.logPath, { minOffset: record.logStartOffset || 0 });
|
|
1602
1812
|
// Search the file for the marker rather than hoping it is still inside
|
|
1603
1813
|
// the tail window — see `findFireExitCode` for why a shared log makes the
|
|
1604
1814
|
// tail unreliable. The tail is still what feeds the human-readable
|
|
@@ -1622,8 +1832,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1622
1832
|
// `latest_status_summary` with a word that says nothing. Naming *how* we
|
|
1623
1833
|
// found out keeps the event worth its row: it tells the next reader that
|
|
1624
1834
|
// fire never published its own status.
|
|
1625
|
-
const baseSummary =
|
|
1626
|
-
|
|
1835
|
+
const baseSummary = record.adoptedWithoutMetadata
|
|
1836
|
+
? "fire tmux session ended; it was adopted from a previous daemon without a hand-off record, so the exit could not be classified"
|
|
1837
|
+
: exitCode === null
|
|
1627
1838
|
? "fire tmux session disappeared without an exit marker (killed or OOM)"
|
|
1628
1839
|
: exitCode === 0
|
|
1629
1840
|
? "completed; detected by the daemon after the tmux session ended (fire never reported it)"
|
|
@@ -1634,6 +1845,19 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1634
1845
|
// Ask the backend before overwriting anything: fire normally reports its
|
|
1635
1846
|
// own terminal status and we must not downgrade a success.
|
|
1636
1847
|
const backendStatus = await fetchBackendTaskStatus(taskId);
|
|
1848
|
+
// For a record adopted without metadata there is no exit marker to read,
|
|
1849
|
+
// so the verdict below is unconditionally KILLED and this probe is the
|
|
1850
|
+
// ONLY thing standing between a task that finished cleanly and a
|
|
1851
|
+
// fabricated failure. `fetchBackendTaskStatus` returns null both for "not
|
|
1852
|
+
// terminal" and for "could not ask", so a backend blip during a daemon
|
|
1853
|
+
// upgrade would mark a green task killed. Require a real answer.
|
|
1854
|
+
if (!backendStatus && record.adoptedWithoutMetadata) {
|
|
1855
|
+
logError(
|
|
1856
|
+
`Cannot classify the end of adopted task ${taskId}: the backend did not answer, ` +
|
|
1857
|
+
`and without a hand-off record its exit code is unknowable. Staying silent.`,
|
|
1858
|
+
);
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1637
1861
|
if (backendStatus && TERMINAL_BACKEND_TASK_STATUSES.has(backendStatus)) {
|
|
1638
1862
|
log(
|
|
1639
1863
|
`Tmux session ${record.tmuxSession} for task ${taskId} ended; backend already ${backendStatus}, no report needed`,
|
|
@@ -1748,6 +1972,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1748
1972
|
record.stopForceKillTimer = null;
|
|
1749
1973
|
}
|
|
1750
1974
|
activeTaskProcesses.delete(taskId);
|
|
1975
|
+
forgetFireSessionRecord(record);
|
|
1751
1976
|
// Report *after* dropping the local entry so a slow backend round
|
|
1752
1977
|
// trip can't make a concurrent stop_task wait on us.
|
|
1753
1978
|
await reportDeadTmuxSessionStatus(taskId, record);
|
|
@@ -1779,22 +2004,27 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1779
2004
|
}
|
|
1780
2005
|
}
|
|
1781
2006
|
|
|
1782
|
-
// List every tmux session name visible to the current user.
|
|
1783
|
-
//
|
|
1784
|
-
//
|
|
2007
|
+
// List every tmux session name visible to the current user.
|
|
2008
|
+
//
|
|
2009
|
+
// Returns `{ sessions, conclusive }`. The flag is not decoration: "tmux says
|
|
2010
|
+
// there are no sessions" and "we could not ask tmux" are the same empty
|
|
2011
|
+
// array, and callers that kill on the strength of an empty list would turn
|
|
2012
|
+
// one flaky probe into a mass kill of live Fires — the very regression the
|
|
2013
|
+
// adoption path exists to prevent. `probeTmuxSession` draws the same
|
|
2014
|
+
// distinction for the same reason. Subject to TMUX_PROBE_TIMEOUT_MS.
|
|
1785
2015
|
function listAllTmuxSessions() {
|
|
1786
2016
|
return new Promise((resolve) => {
|
|
1787
2017
|
let settled = false;
|
|
1788
2018
|
let stdout = "";
|
|
1789
2019
|
let timer = null;
|
|
1790
|
-
const settle = (lines) => {
|
|
2020
|
+
const settle = (lines, conclusive) => {
|
|
1791
2021
|
if (settled) return;
|
|
1792
2022
|
settled = true;
|
|
1793
2023
|
if (timer) {
|
|
1794
2024
|
clearTimeout(timer);
|
|
1795
2025
|
timer = null;
|
|
1796
2026
|
}
|
|
1797
|
-
resolve(lines);
|
|
2027
|
+
resolve({ sessions: lines, conclusive });
|
|
1798
2028
|
};
|
|
1799
2029
|
try {
|
|
1800
2030
|
const probe = spawnFn("tmux", ["list-sessions", "-F", "#{session_name}"], {
|
|
@@ -1812,14 +2042,16 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1812
2042
|
.split("\n")
|
|
1813
2043
|
.map((s) => s.trim())
|
|
1814
2044
|
.filter(Boolean),
|
|
2045
|
+
true,
|
|
1815
2046
|
);
|
|
1816
2047
|
} else {
|
|
1817
|
-
// Non-zero exit
|
|
1818
|
-
//
|
|
1819
|
-
settle([]);
|
|
2048
|
+
// Non-zero exit means "no server running" / "no sessions". tmux
|
|
2049
|
+
// answered, so this is a real, conclusive empty list.
|
|
2050
|
+
settle([], true);
|
|
1820
2051
|
}
|
|
1821
2052
|
});
|
|
1822
|
-
|
|
2053
|
+
// Could not run tmux at all — no information either way.
|
|
2054
|
+
probe.on("error", () => settle([], false));
|
|
1823
2055
|
timer = setTimeout(() => {
|
|
1824
2056
|
try {
|
|
1825
2057
|
if (typeof probe.kill === "function") {
|
|
@@ -1829,13 +2061,13 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1829
2061
|
// ignore; we already settled
|
|
1830
2062
|
}
|
|
1831
2063
|
logError(`tmux list-sessions probe timed out after ${TMUX_PROBE_TIMEOUT_MS}ms`);
|
|
1832
|
-
settle([]);
|
|
2064
|
+
settle([], false);
|
|
1833
2065
|
}, TMUX_PROBE_TIMEOUT_MS);
|
|
1834
2066
|
if (typeof timer.unref === "function") {
|
|
1835
2067
|
timer.unref();
|
|
1836
2068
|
}
|
|
1837
2069
|
} catch {
|
|
1838
|
-
settle([]);
|
|
2070
|
+
settle([], false);
|
|
1839
2071
|
}
|
|
1840
2072
|
});
|
|
1841
2073
|
}
|
|
@@ -1850,17 +2082,288 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1850
2082
|
async function killTmuxSessionsForDeletedTask(taskId) {
|
|
1851
2083
|
if (!FIRE_TMUX_MODE_ACTIVE || !taskId) return 0;
|
|
1852
2084
|
const prefix = buildFireTmuxSessionPrefix(taskId);
|
|
1853
|
-
const sessions = await listAllTmuxSessions();
|
|
2085
|
+
const { sessions } = await listAllTmuxSessions();
|
|
1854
2086
|
let killed = 0;
|
|
1855
2087
|
for (const name of sessions) {
|
|
1856
2088
|
if (name.startsWith(prefix)) {
|
|
1857
2089
|
log(`Killing orphaned tmux session ${name} for deleted task ${taskId}`);
|
|
1858
2090
|
killFireTmuxSession(name);
|
|
2091
|
+
forgetFireSessionRecord({ tmuxSession: name });
|
|
1859
2092
|
killed += 1;
|
|
1860
2093
|
}
|
|
1861
2094
|
}
|
|
1862
2095
|
return killed;
|
|
1863
2096
|
}
|
|
2097
|
+
|
|
2098
|
+
// --- tmux Fire hand-off across daemon restarts ---------------------------
|
|
2099
|
+
//
|
|
2100
|
+
// Shutdown in tmux mode deliberately leaves Fires running, but a fresh
|
|
2101
|
+
// daemon starts with an empty `activeTaskProcesses` by definition. Both
|
|
2102
|
+
// stale-task sweeps decide "this task is dead" from that map alone, so
|
|
2103
|
+
// without the adoption path below every survivor of the hand-off is
|
|
2104
|
+
// PATCHed to `killed` seconds after the new daemon connects — destroying
|
|
2105
|
+
// the exact work the hand-off exists to preserve. Regression history:
|
|
2106
|
+
// 2026-07-23, 2026-07-31, 2026-08-31.
|
|
2107
|
+
|
|
2108
|
+
// Persist what a successor daemon needs to adopt this session. Best-effort:
|
|
2109
|
+
// failing here costs post-restart fidelity, never the running Fire.
|
|
2110
|
+
function rememberFireSessionRecord(taskId, record) {
|
|
2111
|
+
if (!FIRE_TMUX_MODE_ACTIVE || !record?.tmuxSession) return;
|
|
2112
|
+
try {
|
|
2113
|
+
writeFireSessionRecord(FIRE_SESSION_REGISTRY_DIR, {
|
|
2114
|
+
taskId,
|
|
2115
|
+
projectId: record.projectId,
|
|
2116
|
+
tmuxSession: record.tmuxSession,
|
|
2117
|
+
logPath: record.logPath,
|
|
2118
|
+
logStartOffset: record.logStartOffset,
|
|
2119
|
+
exitMarkerToken: record.exitMarkerToken,
|
|
2120
|
+
spawnedAtMs: record.spawnedAtMs,
|
|
2121
|
+
daemonName: AGENT_NAME,
|
|
2122
|
+
});
|
|
2123
|
+
} catch (error) {
|
|
2124
|
+
logError(
|
|
2125
|
+
`Failed to persist tmux hand-off record for task ${taskId}: ${error?.message || error}`,
|
|
2126
|
+
);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// Drop the hand-off record once the session it describes is provably gone.
|
|
2131
|
+
// `pruneFireSessionRecords` at startup is the backstop for the paths that
|
|
2132
|
+
// never get to run (SIGKILL, power loss).
|
|
2133
|
+
function forgetFireSessionRecord(record) {
|
|
2134
|
+
if (!record?.tmuxSession) return;
|
|
2135
|
+
try {
|
|
2136
|
+
deleteFireSessionRecord(FIRE_SESSION_REGISTRY_DIR, record.tmuxSession);
|
|
2137
|
+
} catch {
|
|
2138
|
+
// best effort; the startup prune will collect it
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
function readFireSessionRecordSafe(sessionName) {
|
|
2143
|
+
try {
|
|
2144
|
+
return readFireSessionRecord(FIRE_SESSION_REGISTRY_DIR, sessionName);
|
|
2145
|
+
} catch {
|
|
2146
|
+
return null;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// A session can outlive the Fire inside it (a wrapper shell that never
|
|
2151
|
+
// exited, a pane held open by `remain-on-exit`). Adopting one of those is
|
|
2152
|
+
// worse than killing it: the reaper only ever speaks when a session
|
|
2153
|
+
// disappears, so the task would sit at `running` with nobody left to report
|
|
2154
|
+
// its death. The hand-off record's exit marker is the only evidence that
|
|
2155
|
+
// distinguishes the two, which is why this can only be checked for sessions
|
|
2156
|
+
// we have a record for. Returns the fire's exit code, or null when it is
|
|
2157
|
+
// still running (or when we cannot tell).
|
|
2158
|
+
function readAdoptedFireExitCode(sidecar) {
|
|
2159
|
+
if (!sidecar?.logPath || !sidecar?.exitMarkerToken) return null;
|
|
2160
|
+
return findFireExitCode(sidecar.logPath, {
|
|
2161
|
+
minOffset: sidecar.logStartOffset,
|
|
2162
|
+
markerToken: sidecar.exitMarkerToken,
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
// `FIRE_TMUX_MODE_ACTIVE` is a one-shot startup snapshot (`tmux -V` via
|
|
2167
|
+
// spawnSync). If that probe failed transiently — fork pressure, a PATH blip
|
|
2168
|
+
// — this process spends its ENTIRE life believing tmux is absent, so both
|
|
2169
|
+
// stale sweeps would skip the tmux lookup entirely and kill every Fire the
|
|
2170
|
+
// previous daemon left in a tmux server that is running perfectly well.
|
|
2171
|
+
// That is the same mass kill by a different door.
|
|
2172
|
+
//
|
|
2173
|
+
// Re-probe before killing. We deliberately do NOT adopt in this state: this
|
|
2174
|
+
// process spawns fires directly and never started the tmux liveness reaper,
|
|
2175
|
+
// so an adopted record would have no watcher. Declining to destroy is
|
|
2176
|
+
// enough — the next restart with a healthy probe adopts them properly.
|
|
2177
|
+
function tmuxFiresMayExistUnseen() {
|
|
2178
|
+
return FIRE_TMUX_MODE_ENABLED && !FIRE_TMUX_MODE_ACTIVE && isTmuxAvailable();
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// Sessions already identified as orphaned shells and killed. `tmux
|
|
2182
|
+
// kill-session` is asynchronous, so a sweep running moments later still
|
|
2183
|
+
// sees the name in `list-sessions` — and by then the hand-off record is
|
|
2184
|
+
// gone, which would make it look like a live Fire with no metadata and put
|
|
2185
|
+
// the task straight back to `running`.
|
|
2186
|
+
const retiredOrphanFireSessions = new Set();
|
|
2187
|
+
|
|
2188
|
+
// Shared by both adoption entry points: kill the shell, drop the record and
|
|
2189
|
+
// report "not alive" so the caller falls through to its normal dead-task
|
|
2190
|
+
// handling.
|
|
2191
|
+
function retireOrphanedFireSession(sessionName, taskId, exitCode) {
|
|
2192
|
+
retiredOrphanFireSessions.add(sessionName);
|
|
2193
|
+
log(
|
|
2194
|
+
`Tmux session ${sessionName} outlived its Fire for task ${taskId} ` +
|
|
2195
|
+
`(exit marker code=${exitCode}); killing the orphaned shell`,
|
|
2196
|
+
);
|
|
2197
|
+
killFireTmuxSession(sessionName);
|
|
2198
|
+
forgetFireSessionRecord({ tmuxSession: sessionName });
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// Build an `activeTaskProcesses` entry for a tmux session this daemon did
|
|
2202
|
+
// not spawn. `child` is deliberately absent: the `tmux new-session` client
|
|
2203
|
+
// belonged to the previous daemon and exited long ago.
|
|
2204
|
+
//
|
|
2205
|
+
// This is the one record shape where a truthy `child` does NOT mean "a fire
|
|
2206
|
+
// is running here" — so any code asking that question must go through
|
|
2207
|
+
// `recordHasLiveFire`, never `record.child`. Getting it wrong is not
|
|
2208
|
+
// cosmetic: the restart/create gates read as "nothing running" and spawn a
|
|
2209
|
+
// second fire into a worktree that already has one.
|
|
2210
|
+
function buildAdoptedTmuxRecord({ sessionName, projectId, sidecar }) {
|
|
2211
|
+
return {
|
|
2212
|
+
child: null,
|
|
2213
|
+
projectId: sidecar?.projectId || String(projectId || ""),
|
|
2214
|
+
logPath: sidecar?.logPath || "",
|
|
2215
|
+
logStartOffset: Number(sidecar?.logStartOffset) || 0,
|
|
2216
|
+
exitMarkerToken: sidecar?.exitMarkerToken || "",
|
|
2217
|
+
// Without a record we have no real spawn time. Anchoring at "now" costs
|
|
2218
|
+
// one reaper grace period of delay and is far safer than a 0, which
|
|
2219
|
+
// would make the record instantly reapable.
|
|
2220
|
+
spawnedAtMs: Number(sidecar?.spawnedAtMs) || Date.now(),
|
|
2221
|
+
stopForceKillTimer: null,
|
|
2222
|
+
managedByFireBridge: true,
|
|
2223
|
+
tmuxSession: sessionName,
|
|
2224
|
+
tmuxMode: true,
|
|
2225
|
+
adopted: true,
|
|
2226
|
+
// No record => no logPath and no marker nonce, so the reaper cannot
|
|
2227
|
+
// classify this fire's exit from evidence. Flagged so it reports an
|
|
2228
|
+
// honest KILLED instead of taking the "cannot classify" silent exit,
|
|
2229
|
+
// which would strand the task at `running` with nobody left to speak
|
|
2230
|
+
// for it.
|
|
2231
|
+
adoptedWithoutMetadata: !sidecar,
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// Adopt the live tmux Fire belonging to `task`, if there is one. Returns
|
|
2236
|
+
// true when the task must NOT be treated as stale. Called from both kill
|
|
2237
|
+
// paths, which is also the only place a session whose hand-off record is
|
|
2238
|
+
// missing can be adopted at all: the task id is not recoverable from a
|
|
2239
|
+
// session name, but the backend just told us which ids to look for.
|
|
2240
|
+
function adoptLiveTmuxFireForTask(task, sessionNames) {
|
|
2241
|
+
const taskId = String(task?.id || "");
|
|
2242
|
+
if (!taskId) return false;
|
|
2243
|
+
if (activeTaskProcesses.has(taskId) || activePtySessions.has(taskId)) return true;
|
|
2244
|
+
if (!FIRE_TMUX_MODE_ACTIVE) return false;
|
|
2245
|
+
const prefix = buildFireTmuxSessionPrefix(taskId);
|
|
2246
|
+
const candidates = (sessionNames || []).filter(
|
|
2247
|
+
(name) => name.startsWith(prefix) && !retiredOrphanFireSessions.has(name),
|
|
2248
|
+
);
|
|
2249
|
+
if (candidates.length === 0) return false;
|
|
2250
|
+
// Session names truncate the task id to 32 chars, so a record could in
|
|
2251
|
+
// principle have been filed for a different task sharing that prefix.
|
|
2252
|
+
// Trust it only when it names the task we are actually rescuing.
|
|
2253
|
+
const recordFor = (name) => {
|
|
2254
|
+
const stored = readFireSessionRecordSafe(name);
|
|
2255
|
+
return stored && stored.taskId === taskId ? stored : null;
|
|
2256
|
+
};
|
|
2257
|
+
// With more than one candidate (an abandoned session left beside a
|
|
2258
|
+
// respawn) prefer the one we have a record for: adopting blind would pick
|
|
2259
|
+
// by tmux's list order and could watch the wrong session's exit marker.
|
|
2260
|
+
const sessionName = candidates.find((name) => recordFor(name)) || candidates[0];
|
|
2261
|
+
const sidecar = recordFor(sessionName);
|
|
2262
|
+
const exitCode = readAdoptedFireExitCode(sidecar);
|
|
2263
|
+
if (exitCode !== null) {
|
|
2264
|
+
retireOrphanedFireSession(sessionName, taskId, exitCode);
|
|
2265
|
+
return false;
|
|
2266
|
+
}
|
|
2267
|
+
activeTaskProcesses.set(
|
|
2268
|
+
taskId,
|
|
2269
|
+
buildAdoptedTmuxRecord({ sessionName, projectId: task?.project_id, sidecar }),
|
|
2270
|
+
);
|
|
2271
|
+
log(
|
|
2272
|
+
`Task ${taskId} still has a live tmux Fire (session=${sessionName}); adopted instead of killed` +
|
|
2273
|
+
(sidecar ? "" : " — no hand-off record, so its exit can only be classified as killed"),
|
|
2274
|
+
);
|
|
2275
|
+
return true;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// Resolves once the startup adoption pass has finished. Both stale sweeps
|
|
2279
|
+
// await it so they can never judge a task before its Fire has had the
|
|
2280
|
+
// chance to be reclaimed.
|
|
2281
|
+
let adoptOrphanedTmuxFiresPromise = null;
|
|
2282
|
+
|
|
2283
|
+
// Startup pass: adopt every Fire session the previous daemon left behind,
|
|
2284
|
+
// then discard hand-off records whose session is gone.
|
|
2285
|
+
async function adoptOrphanedTmuxFires() {
|
|
2286
|
+
if (!FIRE_TMUX_MODE_ACTIVE) return 0;
|
|
2287
|
+
const { sessions, conclusive } = await listAllTmuxSessions();
|
|
2288
|
+
if (!conclusive) {
|
|
2289
|
+
// We could not ask tmux. Adopting nothing is survivable — both stale
|
|
2290
|
+
// sweeps re-probe and adopt whatever they find. Pruning is not: the
|
|
2291
|
+
// prune below deletes every record whose session is absent from this
|
|
2292
|
+
// list, so running it on a blind listing would wipe the
|
|
2293
|
+
// `exitMarkerToken`s of every live Fire on the host, permanently
|
|
2294
|
+
// downgrading them all to `adoptedWithoutMetadata`.
|
|
2295
|
+
logError(
|
|
2296
|
+
"Could not list tmux sessions for Fire adoption; adopting nothing and keeping every hand-off record",
|
|
2297
|
+
);
|
|
2298
|
+
return 0;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
let adopted = 0;
|
|
2302
|
+
for (const sessionName of sessions) {
|
|
2303
|
+
if (!sessionName.startsWith(FIRE_TMUX_SESSION_NAMESPACE)) continue;
|
|
2304
|
+
if (retiredOrphanFireSessions.has(sessionName)) continue;
|
|
2305
|
+
const sidecar = readFireSessionRecordSafe(sessionName);
|
|
2306
|
+
// No record: we cannot learn the task id from the session name (it is
|
|
2307
|
+
// truncated to 32 chars). Leave it — the kill paths adopt it later,
|
|
2308
|
+
// once the backend supplies the id.
|
|
2309
|
+
if (!sidecar) continue;
|
|
2310
|
+
|
|
2311
|
+
if (pendingTaskStarts.has(sidecar.taskId)) {
|
|
2312
|
+
// A `create_task` for this task is mid-flight and will install its own
|
|
2313
|
+
// record when the spawn completes. Adopting now just gets overwritten
|
|
2314
|
+
// moments later, which is how a session ends up orphaned with its
|
|
2315
|
+
// record still on disk.
|
|
2316
|
+
continue;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
const tracked = activeTaskProcesses.get(sidecar.taskId);
|
|
2320
|
+
if (tracked) {
|
|
2321
|
+
// A `create_task`/`restart_task` redelivered into the adoption window
|
|
2322
|
+
// can spawn a second session for the same task before we get here.
|
|
2323
|
+
// Keeping both records is the dangerous part: on the NEXT restart
|
|
2324
|
+
// adoption would pick between them by tmux's arbitrary list order and
|
|
2325
|
+
// could end up watching the abandoned session's exit marker while the
|
|
2326
|
+
// live fire's death goes unreported. Drop the loser's record so the
|
|
2327
|
+
// task has exactly one, and say so loudly — two fires in one worktree
|
|
2328
|
+
// is a real problem, just not one we can safely resolve by killing.
|
|
2329
|
+
if (tracked.tmuxSession !== sessionName) {
|
|
2330
|
+
logError(
|
|
2331
|
+
`Task ${sidecar.taskId} has a second tmux session ${sessionName} besides the tracked ` +
|
|
2332
|
+
`${tracked.tmuxSession || "(none)"}; discarding its hand-off record. Two fires may be ` +
|
|
2333
|
+
`sharing one worktree — inspect and kill the stale session manually.`,
|
|
2334
|
+
);
|
|
2335
|
+
forgetFireSessionRecord({ tmuxSession: sessionName });
|
|
2336
|
+
}
|
|
2337
|
+
continue;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
const exitCode = readAdoptedFireExitCode(sidecar);
|
|
2341
|
+
if (exitCode !== null) {
|
|
2342
|
+
retireOrphanedFireSession(sessionName, sidecar.taskId, exitCode);
|
|
2343
|
+
continue;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
activeTaskProcesses.set(
|
|
2347
|
+
sidecar.taskId,
|
|
2348
|
+
buildAdoptedTmuxRecord({ sessionName, projectId: sidecar.projectId, sidecar }),
|
|
2349
|
+
);
|
|
2350
|
+
adopted += 1;
|
|
2351
|
+
log(
|
|
2352
|
+
`Adopted tmux Fire task ${sidecar.taskId} (session=${sessionName}) left running by a previous daemon`,
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
try {
|
|
2357
|
+
pruneFireSessionRecords(FIRE_SESSION_REGISTRY_DIR, sessions);
|
|
2358
|
+
} catch (error) {
|
|
2359
|
+
logError(`Failed to prune tmux hand-off records: ${error?.message || error}`);
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
if (adopted > 0) {
|
|
2363
|
+
log(`Adopted ${adopted} tmux Fire task(s) from a previous daemon`);
|
|
2364
|
+
}
|
|
2365
|
+
return adopted;
|
|
2366
|
+
}
|
|
1864
2367
|
// -------------------------------------------------------------------------
|
|
1865
2368
|
|
|
1866
2369
|
function buildTaskWorktreeRoot(projectWorkspacePath, worktreeBranch) {
|
|
@@ -2453,6 +2956,79 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2453
2956
|
});
|
|
2454
2957
|
}
|
|
2455
2958
|
|
|
2959
|
+
// In-flight worktree preparations keyed on the on-disk root. Group members
|
|
2960
|
+
// (worker + reviewers) start almost simultaneously and would otherwise all
|
|
2961
|
+
// pass the `.git` existence check below and race on `git worktree add -b`
|
|
2962
|
+
// for the same branch: one wins and the losers fail with
|
|
2963
|
+
// "Failed to prepare git worktree". Serializing per root also benefits the
|
|
2964
|
+
// pre-existing fork/branch sharing path.
|
|
2965
|
+
const taskWorktreePreparations = new Map();
|
|
2966
|
+
|
|
2967
|
+
// Readiness marker, written by the owner only after the worktree is FULLY
|
|
2968
|
+
// prepared. `.git` alone is not a readiness signal: it appears the moment
|
|
2969
|
+
// `git worktree add` returns, while submodules and symlinks are still
|
|
2970
|
+
// pending. An owner that dies in between (e.g. submodule sync hits its
|
|
2971
|
+
// timeout) would otherwise leave `.git` behind and let reuse-only members
|
|
2972
|
+
// walk into a half-built directory.
|
|
2973
|
+
//
|
|
2974
|
+
// It lives NEXT TO the worktree, not inside it: an untracked file inside
|
|
2975
|
+
// would make `git status --porcelain` report the worktree as dirty and block
|
|
2976
|
+
// non-forced cleanup.
|
|
2977
|
+
function taskWorktreeReadyMarkerPath(worktreeRoot) {
|
|
2978
|
+
return `${worktreeRoot}.ready`;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
function removeTaskWorktreeReadyMarker(worktreeRoot) {
|
|
2982
|
+
const markerPath = taskWorktreeReadyMarkerPath(worktreeRoot);
|
|
2983
|
+
if (!existsSyncFn(markerPath)) {
|
|
2984
|
+
return;
|
|
2985
|
+
}
|
|
2986
|
+
try {
|
|
2987
|
+
unlinkSyncFn(markerPath);
|
|
2988
|
+
} catch (error) {
|
|
2989
|
+
if (error?.code !== "ENOENT") {
|
|
2990
|
+
logError(
|
|
2991
|
+
`[worktree] failed to clear ready marker for ${worktreeRoot}: ${error?.message || error}`,
|
|
2992
|
+
);
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
// Both must hold. Requiring `.git` too means a marker left behind by a
|
|
2998
|
+
// worktree that was removed outside our cleanup path cannot fake readiness.
|
|
2999
|
+
function isTaskWorktreeReady(worktreeRoot, gitMarkerPath) {
|
|
3000
|
+
return (
|
|
3001
|
+
existsSyncFn(gitMarkerPath) &&
|
|
3002
|
+
existsSyncFn(taskWorktreeReadyMarkerPath(worktreeRoot))
|
|
3003
|
+
);
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
async function waitForSharedTaskWorktree({ taskId, worktreeRoot, gitMarkerPath }) {
|
|
3007
|
+
const deadline = Date.now() + TASK_WORKTREE_REUSE_WAIT_TIMEOUT_MS;
|
|
3008
|
+
for (;;) {
|
|
3009
|
+
// When the owner is preparing in this same daemon process we await the
|
|
3010
|
+
// whole preparation (worktree add + submodules + symlinks). Polling the
|
|
3011
|
+
// marker additionally covers an owner that already finished, or one
|
|
3012
|
+
// running in a different process.
|
|
3013
|
+
const inflight = taskWorktreePreparations.get(worktreeRoot);
|
|
3014
|
+
if (inflight) {
|
|
3015
|
+
await inflight;
|
|
3016
|
+
}
|
|
3017
|
+
if (isTaskWorktreeReady(worktreeRoot, gitMarkerPath)) {
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
if (Date.now() >= deadline) {
|
|
3021
|
+
throw new Error(
|
|
3022
|
+
`Timed out waiting for shared git worktree ${worktreeRoot} for ${taskId}. ` +
|
|
3023
|
+
`Its owner task may have failed midway through preparation.`,
|
|
3024
|
+
);
|
|
3025
|
+
}
|
|
3026
|
+
await new Promise((resolve) =>
|
|
3027
|
+
setTimeout(resolve, TASK_WORKTREE_REUSE_POLL_INTERVAL_MS),
|
|
3028
|
+
);
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
|
|
2456
3032
|
async function ensureTaskWorktree({ taskId, projectId, launchConfig }) {
|
|
2457
3033
|
const worktreeConfig = parseTaskWorktreeLaunchConfig(launchConfig);
|
|
2458
3034
|
if (!worktreeConfig) {
|
|
@@ -2465,7 +3041,50 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2465
3041
|
);
|
|
2466
3042
|
const finalCwd = resolveTaskWorktreeCwd(worktreeRoot, worktreeConfig.projectRelativePath);
|
|
2467
3043
|
const gitMarkerPath = path.join(worktreeRoot, ".git");
|
|
3044
|
+
|
|
3045
|
+
if (worktreeConfig.worktreeReuseOnly) {
|
|
3046
|
+
await waitForSharedTaskWorktree({ taskId, worktreeRoot, gitMarkerPath });
|
|
3047
|
+
mkdirSyncFn(finalCwd, { recursive: true });
|
|
3048
|
+
return finalCwd;
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
const inflight = taskWorktreePreparations.get(worktreeRoot);
|
|
3052
|
+
if (inflight) {
|
|
3053
|
+
await inflight;
|
|
3054
|
+
}
|
|
3055
|
+
// Prepare when we are the first owner here, or when the preparation we
|
|
3056
|
+
// waited on did not leave a fully-prepared worktree (then we take over).
|
|
3057
|
+
if (!inflight || !isTaskWorktreeReady(worktreeRoot, gitMarkerPath)) {
|
|
3058
|
+
const preparation = prepareTaskWorktree({
|
|
3059
|
+
taskId,
|
|
3060
|
+
worktreeConfig,
|
|
3061
|
+
worktreeRoot,
|
|
3062
|
+
gitMarkerPath,
|
|
3063
|
+
finalCwd,
|
|
3064
|
+
});
|
|
3065
|
+
// Waiters must never inherit our rejection; they re-check `.git` instead.
|
|
3066
|
+
taskWorktreePreparations.set(worktreeRoot, preparation.catch(() => {}));
|
|
3067
|
+
try {
|
|
3068
|
+
await preparation;
|
|
3069
|
+
} finally {
|
|
3070
|
+
taskWorktreePreparations.delete(worktreeRoot);
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
return finalCwd;
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
async function prepareTaskWorktree({
|
|
3077
|
+
taskId,
|
|
3078
|
+
worktreeConfig,
|
|
3079
|
+
worktreeRoot,
|
|
3080
|
+
gitMarkerPath,
|
|
3081
|
+
finalCwd,
|
|
3082
|
+
}) {
|
|
2468
3083
|
if (!existsSyncFn(gitMarkerPath)) {
|
|
3084
|
+
// A worktree removed outside our cleanup path can leave a stale marker
|
|
3085
|
+
// behind. Clear it before recreating, so a reuse-only member cannot read
|
|
3086
|
+
// "ready" while `git worktree add` is still running.
|
|
3087
|
+
removeTaskWorktreeReadyMarker(worktreeRoot);
|
|
2469
3088
|
const { syncBranch } = readProjectWorktreeSettings(worktreeConfig.projectWorkspacePath);
|
|
2470
3089
|
if (syncBranch) {
|
|
2471
3090
|
try {
|
|
@@ -2563,7 +3182,8 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2563
3182
|
projectWorkspacePath: worktreeConfig.projectWorkspacePath,
|
|
2564
3183
|
finalCwd,
|
|
2565
3184
|
});
|
|
2566
|
-
|
|
3185
|
+
// Publish readiness last: every step above has to have succeeded.
|
|
3186
|
+
writeFileSyncFn(taskWorktreeReadyMarkerPath(worktreeRoot), "");
|
|
2567
3187
|
}
|
|
2568
3188
|
|
|
2569
3189
|
const RTC_MODULE_CANDIDATES = resolveRtcModuleCandidates(process.env.CONDUCTOR_PTY_RTC_MODULES);
|
|
@@ -2596,6 +3216,17 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2596
3216
|
process.env.CONDUCTOR_WORKTREE_SUBMODULE_SYNC_TIMEOUT_MS,
|
|
2597
3217
|
120_000,
|
|
2598
3218
|
);
|
|
3219
|
+
// A reuse-only member waits for the owner to create the shared worktree.
|
|
3220
|
+
// The bound has to cover a cold clone's submodule sync, hence the generous
|
|
3221
|
+
// default; it only ever elapses when the owner never starts or hard-fails.
|
|
3222
|
+
const TASK_WORKTREE_REUSE_WAIT_TIMEOUT_MS = parsePositiveInt(
|
|
3223
|
+
process.env.CONDUCTOR_WORKTREE_REUSE_WAIT_TIMEOUT_MS,
|
|
3224
|
+
180_000,
|
|
3225
|
+
);
|
|
3226
|
+
const TASK_WORKTREE_REUSE_POLL_INTERVAL_MS = parsePositiveInt(
|
|
3227
|
+
process.env.CONDUCTOR_WORKTREE_REUSE_POLL_INTERVAL_MS,
|
|
3228
|
+
250,
|
|
3229
|
+
);
|
|
2599
3230
|
const SHUTDOWN_STATUS_REPORT_TIMEOUT_MS = parsePositiveInt(
|
|
2600
3231
|
process.env.CONDUCTOR_SHUTDOWN_STATUS_REPORT_TIMEOUT_MS,
|
|
2601
3232
|
1000,
|
|
@@ -2637,36 +3268,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2637
3268
|
DEFAULT_TERMINAL_RESUME_SNAPSHOT_MAX_BYTES,
|
|
2638
3269
|
);
|
|
2639
3270
|
|
|
2640
|
-
const readLockState = () =>
|
|
2641
|
-
const raw = String(readFileSyncFn(LOCK_FILE, "utf-8") || "").trim();
|
|
2642
|
-
if (!raw) {
|
|
2643
|
-
return null;
|
|
2644
|
-
}
|
|
2645
|
-
|
|
2646
|
-
const pid = Number.parseInt(raw, 10);
|
|
2647
|
-
if (!Number.isNaN(pid) && pid > 0) {
|
|
2648
|
-
return {
|
|
2649
|
-
pid,
|
|
2650
|
-
handoffFromPid: null,
|
|
2651
|
-
handoffToken: null,
|
|
2652
|
-
handoffExpiresAt: null,
|
|
2653
|
-
};
|
|
2654
|
-
}
|
|
2655
|
-
|
|
2656
|
-
try {
|
|
2657
|
-
const parsed = JSON.parse(raw);
|
|
2658
|
-
const parsedPid = normalizePositiveInt(parsed?.pid, null);
|
|
2659
|
-
const parsedHandoffFromPid = normalizePositiveInt(parsed?.handoff_from_pid, null);
|
|
2660
|
-
return {
|
|
2661
|
-
pid: parsedPid ?? parsedHandoffFromPid,
|
|
2662
|
-
handoffFromPid: parsedHandoffFromPid,
|
|
2663
|
-
handoffToken: normalizeOptionalString(parsed?.handoff_token),
|
|
2664
|
-
handoffExpiresAt: normalizePositiveInt(parsed?.handoff_expires_at, null),
|
|
2665
|
-
};
|
|
2666
|
-
} catch {
|
|
2667
|
-
return null;
|
|
2668
|
-
}
|
|
2669
|
-
};
|
|
3271
|
+
const readLockState = () => parseDaemonLockState(readFileSyncFn(LOCK_FILE, "utf-8"));
|
|
2670
3272
|
|
|
2671
3273
|
const hasMatchingLockHandoff = (lockState) => {
|
|
2672
3274
|
if (!lockState || !lockHandoffToken || !lockHandoffFromPid) {
|
|
@@ -2705,7 +3307,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2705
3307
|
return exitAndReturn(1);
|
|
2706
3308
|
}
|
|
2707
3309
|
|
|
2708
|
-
const LOCK_FILE = path.join(WORKSPACE_ROOT,
|
|
3310
|
+
const LOCK_FILE = path.join(WORKSPACE_ROOT, DAEMON_LOCK_FILE_NAME);
|
|
2709
3311
|
try {
|
|
2710
3312
|
if (skipPidLockCheck) {
|
|
2711
3313
|
log("CONDUCTOR_TUI_DEBUG enabled; skipping daemon PID lock enforcement");
|
|
@@ -2722,6 +3324,21 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2722
3324
|
const alive = isProcessAlive(pid);
|
|
2723
3325
|
if (alive) {
|
|
2724
3326
|
if (config.FORCE) {
|
|
3327
|
+
const forceRefusal = describeForceRestartRefusal({
|
|
3328
|
+
lockState,
|
|
3329
|
+
identity: daemonInstanceIdentity,
|
|
3330
|
+
lockFile: LOCK_FILE,
|
|
3331
|
+
env: process.env,
|
|
3332
|
+
});
|
|
3333
|
+
if (forceRefusal) {
|
|
3334
|
+
logError(forceRefusal);
|
|
3335
|
+
return exitAndReturn(1);
|
|
3336
|
+
}
|
|
3337
|
+
if (compareDaemonLockIdentity(lockState, daemonInstanceIdentity) === "unknown") {
|
|
3338
|
+
log(
|
|
3339
|
+
`${FORCE_KILL_UNKNOWN_OWNER_ENV_VAR} is set: stopping PID ${pid} even though the lock file carries no instance identity`,
|
|
3340
|
+
);
|
|
3341
|
+
}
|
|
2725
3342
|
log(`Force enabled: stopping existing daemon PID ${pid}`);
|
|
2726
3343
|
let alreadyExited = false;
|
|
2727
3344
|
try {
|
|
@@ -2763,7 +3380,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2763
3380
|
unlinkSyncFn(LOCK_FILE);
|
|
2764
3381
|
}
|
|
2765
3382
|
} else {
|
|
2766
|
-
logError(
|
|
3383
|
+
logError(
|
|
3384
|
+
`Daemon already running with PID ${pid} — ${describeDaemonLockOwner(lockState)}. Use --force to restart it.`,
|
|
3385
|
+
);
|
|
2767
3386
|
return exitAndReturn(1);
|
|
2768
3387
|
}
|
|
2769
3388
|
} else {
|
|
@@ -2784,7 +3403,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2784
3403
|
unlinkSyncFn(LOCK_FILE);
|
|
2785
3404
|
}
|
|
2786
3405
|
}
|
|
2787
|
-
writeFileSyncFn(
|
|
3406
|
+
writeFileSyncFn(
|
|
3407
|
+
LOCK_FILE,
|
|
3408
|
+
serializeDaemonLock({ pid: process.pid, identity: daemonInstanceIdentity }),
|
|
3409
|
+
);
|
|
2788
3410
|
}
|
|
2789
3411
|
} catch (err) {
|
|
2790
3412
|
logError("Failed to acquire lock:", err);
|
|
@@ -2811,13 +3433,15 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2811
3433
|
if (skipPidLockCheck) {
|
|
2812
3434
|
return;
|
|
2813
3435
|
}
|
|
3436
|
+
// Keeps the instance identity attached while the handoff window is open so
|
|
3437
|
+
// an unrelated instance racing a `--force` during a restart still sees who
|
|
3438
|
+
// owns the lock.
|
|
2814
3439
|
writeFileSyncFn(
|
|
2815
3440
|
LOCK_FILE,
|
|
2816
|
-
|
|
3441
|
+
serializeDaemonLock({
|
|
2817
3442
|
pid: handoffFromPid,
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
handoff_expires_at: handoffExpiresAt,
|
|
3443
|
+
identity: daemonInstanceIdentity,
|
|
3444
|
+
handoff: { handoffFromPid, handoffToken, handoffExpiresAt },
|
|
2821
3445
|
}),
|
|
2822
3446
|
);
|
|
2823
3447
|
};
|
|
@@ -2895,6 +3519,18 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2895
3519
|
removeProcessListener("unhandledRejection", onUnhandledRejection);
|
|
2896
3520
|
};
|
|
2897
3521
|
|
|
3522
|
+
// RFC 0035: never leave guest daemons orphaned. They are children of this
|
|
3523
|
+
// process, so without this they would survive the host daemon and keep
|
|
3524
|
+
// serving a share the owner believes is stopped.
|
|
3525
|
+
const stopGuestsOnExit = () => {
|
|
3526
|
+
try {
|
|
3527
|
+
stopAllGuestDaemons("host daemon exiting");
|
|
3528
|
+
} catch {
|
|
3529
|
+
// best effort during shutdown
|
|
3530
|
+
}
|
|
3531
|
+
};
|
|
3532
|
+
process.on("exit", stopGuestsOnExit);
|
|
3533
|
+
|
|
2898
3534
|
process.on("exit", cleanupLock);
|
|
2899
3535
|
process.on("SIGINT", onSigInt);
|
|
2900
3536
|
process.on("SIGTERM", onSigTerm);
|
|
@@ -3022,11 +3658,13 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3022
3658
|
};
|
|
3023
3659
|
const advertisedCapabilities = [
|
|
3024
3660
|
"project_path_validation",
|
|
3661
|
+
"project_path_create",
|
|
3025
3662
|
"project_agents_registry",
|
|
3026
3663
|
"restart_daemon",
|
|
3027
3664
|
"refresh_session_inplace",
|
|
3028
3665
|
"task_attachments_v1",
|
|
3029
3666
|
CUSTOM_COMMANDS_CAPABILITY,
|
|
3667
|
+
UPDATE_DAEMON_CAPABILITY,
|
|
3030
3668
|
];
|
|
3031
3669
|
if (remoteExecEnabled) {
|
|
3032
3670
|
advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY);
|
|
@@ -3036,11 +3674,67 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3036
3674
|
if (ptyTaskCapabilityEnabled) {
|
|
3037
3675
|
advertisedCapabilities.push("pty_task", "terminal_snapshot");
|
|
3038
3676
|
}
|
|
3039
|
-
|
|
3040
|
-
|
|
3677
|
+
// RFC 0035: strip capabilities a guest must never offer. `ai_manager` stays
|
|
3678
|
+
// advertised on purpose -- the grantee needs to see how much AI quota is left
|
|
3679
|
+
// on the borrowed machine -- but its `switch_account` action is refused at
|
|
3680
|
+
// dispatch, because that one rewrites `~/.codex/auth.json` and would change
|
|
3681
|
+
// the machine owner's own active account.
|
|
3682
|
+
const effectiveCapabilities = IS_GUEST_DAEMON
|
|
3683
|
+
? filterGuestCapabilities(advertisedCapabilities)
|
|
3684
|
+
: advertisedCapabilities;
|
|
3685
|
+
if (IS_GUEST_DAEMON) {
|
|
3686
|
+
log(
|
|
3687
|
+
`[guest] Running as a shared guest daemon; capabilities=${effectiveCapabilities.join(",")}` +
|
|
3688
|
+
(GUEST_ROOT ? `, root=${GUEST_ROOT}` : "")
|
|
3689
|
+
);
|
|
3690
|
+
// If the host daemon is SIGKILLed, nothing else would stop this process.
|
|
3691
|
+
startOrphanWatchdog({
|
|
3692
|
+
onOrphaned: () => {
|
|
3693
|
+
logError("[guest] Host daemon is gone; shutting down to avoid an orphaned share");
|
|
3694
|
+
exitAndReturn(0);
|
|
3695
|
+
},
|
|
3696
|
+
});
|
|
3697
|
+
}
|
|
3698
|
+
if (effectiveCapabilities.length > 0) {
|
|
3699
|
+
extraHeaders["x-conductor-capabilities"] = effectiveCapabilities.join(",");
|
|
3700
|
+
}
|
|
3701
|
+
// Built-in "Update Daemon" is a global `npm install -g` followed by a
|
|
3702
|
+
// restart. Refuse up front — with a reason the UI can show — wherever that
|
|
3703
|
+
// is the wrong way to upgrade this install.
|
|
3704
|
+
function resolveDaemonUpdateRefusal() {
|
|
3705
|
+
if (IS_GUEST_DAEMON) {
|
|
3706
|
+
return "a shared guest daemon cannot install a new CLI version on the host machine";
|
|
3707
|
+
}
|
|
3708
|
+
if (installMethod === "homebrew") {
|
|
3709
|
+
return `conductor was installed with Homebrew; run \`${buildUpgradeCommand({ env: process.env })}\` on ${AGENT_NAME} instead`;
|
|
3710
|
+
}
|
|
3711
|
+
if (!autoUpdateSupportedInstall) {
|
|
3712
|
+
return `conductor runs from ${installedPackageRoot}, which is not a global install; upgrade it the same way it was installed`;
|
|
3713
|
+
}
|
|
3714
|
+
return null;
|
|
3041
3715
|
}
|
|
3042
|
-
|
|
3716
|
+
|
|
3717
|
+
const aiManagerHandlers = (deps.createAiManagerHandlers || createAiManagerHandlers)({ configPath: effectiveConfigPath });
|
|
3043
3718
|
const customCommandHandlers = createCustomCommandHandlers({ configPath: effectiveConfigPath });
|
|
3719
|
+
const daemonUpdatePaths = resolveDaemonUpdatePaths(process.env);
|
|
3720
|
+
const daemonUpdateHandlers = createDaemonUpdateHandlers({
|
|
3721
|
+
statusPath: daemonUpdatePaths.statusPath,
|
|
3722
|
+
logPath: daemonUpdatePaths.logPath,
|
|
3723
|
+
updaterScript: DAEMON_UPDATE_SCRIPT_PATH,
|
|
3724
|
+
refuseReason: resolveDaemonUpdateRefusal(),
|
|
3725
|
+
env: { ...process.env, ...materializedConductorPathEnv },
|
|
3726
|
+
updaterParams: {
|
|
3727
|
+
packageRoot: installedPackageRoot,
|
|
3728
|
+
launcherScript: restartLauncherScript,
|
|
3729
|
+
launcherArgs: restartLauncherArgs,
|
|
3730
|
+
versionCheckScript,
|
|
3731
|
+
versionCheckArgs,
|
|
3732
|
+
daemonPid: process.pid,
|
|
3733
|
+
lockFile: LOCK_FILE,
|
|
3734
|
+
daemonLogPath: resolveDaemonLogPaths().logPath,
|
|
3735
|
+
currentVersion: cliVersion,
|
|
3736
|
+
},
|
|
3737
|
+
});
|
|
3044
3738
|
const remoteExecHandlers = remoteExecEnabled
|
|
3045
3739
|
? createRemoteExecHandlers({ defaultWorkspace: homeDir })
|
|
3046
3740
|
: null;
|
|
@@ -3169,10 +3863,26 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3169
3863
|
log(`Daemon Name: ${AGENT_NAME}`);
|
|
3170
3864
|
log(`Supported Backends: ${SUPPORTED_BACKENDS.join(", ")}`);
|
|
3171
3865
|
|
|
3866
|
+
// Reclaim the Fires the previous daemon left running. Started here rather
|
|
3867
|
+
// than awaited: connecting must not wait on a wedged tmux server. Both
|
|
3868
|
+
// stale sweeps await `adoptOrphanedTmuxFiresPromise` before judging
|
|
3869
|
+
// anything, which is the ordering that actually matters — they decide
|
|
3870
|
+
// liveness from `activeTaskProcesses`, so a survivor not yet adopted when
|
|
3871
|
+
// they run is a survivor that gets killed.
|
|
3872
|
+
adoptOrphanedTmuxFiresPromise = adoptOrphanedTmuxFires().catch((error) => {
|
|
3873
|
+
logError(`adoptOrphanedTmuxFires failed: ${error?.message || error}`);
|
|
3874
|
+
});
|
|
3875
|
+
|
|
3172
3876
|
client.connect().catch((err) => {
|
|
3173
3877
|
logError(`Failed to connect: ${err}`);
|
|
3174
3878
|
});
|
|
3175
3879
|
|
|
3880
|
+
// RFC 0035: keep one child daemon per accepted share. A guest never
|
|
3881
|
+
// supervises anything itself, or a share chain could nest.
|
|
3882
|
+
if (!IS_GUEST_DAEMON) {
|
|
3883
|
+
void reconcileGuestDaemons();
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3176
3886
|
if (!AUTO_UPDATE_ENABLED && autoUpdateSupportedInstall === false) {
|
|
3177
3887
|
if (installMethod === "homebrew") {
|
|
3178
3888
|
log(`[auto-update] Disabled for Homebrew install; use ${buildUpgradeCommand({ env: process.env })}`);
|
|
@@ -3183,6 +3893,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3183
3893
|
|
|
3184
3894
|
const runMaintenanceTick = async () => {
|
|
3185
3895
|
void runDaemonWatchdog();
|
|
3896
|
+
if (!IS_GUEST_DAEMON) {
|
|
3897
|
+
void reconcileGuestDaemons();
|
|
3898
|
+
}
|
|
3186
3899
|
try {
|
|
3187
3900
|
await checkForUpdate();
|
|
3188
3901
|
} catch {
|
|
@@ -3683,6 +4396,165 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3683
4396
|
await restartDaemonProcess("auto-update");
|
|
3684
4397
|
}
|
|
3685
4398
|
|
|
4399
|
+
// ---------------------------------------------------------------------
|
|
4400
|
+
// RFC 0035: guest daemon supervision.
|
|
4401
|
+
//
|
|
4402
|
+
// For each share the backend reports as active, run one child
|
|
4403
|
+
// `conductor daemon` that authenticates as the grantee. The child is an
|
|
4404
|
+
// ordinary daemon in every respect; what makes it a guest is its isolated
|
|
4405
|
+
// CONDUCTOR_HOME/CONDUCTOR_WS and the grantee's scoped token.
|
|
4406
|
+
// ---------------------------------------------------------------------
|
|
4407
|
+
/** shareId -> { child, guestHost, restartTimer, stopping } */
|
|
4408
|
+
const guestDaemons = new Map();
|
|
4409
|
+
// Failure counts must outlive the entry they describe. The restart path
|
|
4410
|
+
// deletes the entry and re-asks the backend (so a share revoked during
|
|
4411
|
+
// backoff stays dead), which means a per-entry counter resets to 0 on every
|
|
4412
|
+
// respawn and the exponential backoff degrades into a permanent 10s loop.
|
|
4413
|
+
const guestFailureCounts = new Map();
|
|
4414
|
+
let guestReconcileInFlight = false;
|
|
4415
|
+
|
|
4416
|
+
function stopGuestDaemon(shareId, reason) {
|
|
4417
|
+
const entry = guestDaemons.get(shareId);
|
|
4418
|
+
if (!entry) return;
|
|
4419
|
+
entry.stopping = true;
|
|
4420
|
+
if (entry.restartTimer) clearTimeout(entry.restartTimer);
|
|
4421
|
+
guestDaemons.delete(shareId);
|
|
4422
|
+
guestFailureCounts.delete(shareId);
|
|
4423
|
+
if (entry.child && entry.child.exitCode === null && !entry.child.killed) {
|
|
4424
|
+
log(`[guest] Stopping guest ${entry.guestHost} (${reason})`);
|
|
4425
|
+
try {
|
|
4426
|
+
entry.child.kill("SIGTERM");
|
|
4427
|
+
} catch {
|
|
4428
|
+
// already gone
|
|
4429
|
+
}
|
|
4430
|
+
}
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
function stopAllGuestDaemons(reason) {
|
|
4434
|
+
for (const shareId of [...guestDaemons.keys()]) {
|
|
4435
|
+
stopGuestDaemon(shareId, reason);
|
|
4436
|
+
}
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4439
|
+
function spawnGuestDaemon(share) {
|
|
4440
|
+
const paths = resolveGuestPaths(share.id, share.workspaceRoot, homeDir);
|
|
4441
|
+
try {
|
|
4442
|
+
writeGuestConfig(
|
|
4443
|
+
paths,
|
|
4444
|
+
buildGuestConfigYaml({
|
|
4445
|
+
agentToken: share.agentToken,
|
|
4446
|
+
backendUrl: BACKEND_HTTP,
|
|
4447
|
+
guestHost: share.guestHost,
|
|
4448
|
+
workspace: paths.workspace,
|
|
4449
|
+
allowCliList: RAW_ALLOW_CLI_LIST,
|
|
4450
|
+
})
|
|
4451
|
+
);
|
|
4452
|
+
} catch (error) {
|
|
4453
|
+
logError(`[guest] Failed to materialize config for ${share.guestHost}: ${error?.message || error}`);
|
|
4454
|
+
return;
|
|
4455
|
+
}
|
|
4456
|
+
|
|
4457
|
+
const env = buildGuestEnv(process.env, paths, {
|
|
4458
|
+
shareId: share.id,
|
|
4459
|
+
explicitRoot: Boolean(share.workspaceRoot),
|
|
4460
|
+
});
|
|
4461
|
+
const child = spawn(
|
|
4462
|
+
process.execPath,
|
|
4463
|
+
[DAEMON_LAUNCHER_PATH, "--config-file", paths.configPath],
|
|
4464
|
+
{ env, stdio: "ignore", detached: false }
|
|
4465
|
+
);
|
|
4466
|
+
|
|
4467
|
+
const entry = {
|
|
4468
|
+
child,
|
|
4469
|
+
guestHost: share.guestHost,
|
|
4470
|
+
restartTimer: null,
|
|
4471
|
+
stopping: false,
|
|
4472
|
+
};
|
|
4473
|
+
guestDaemons.set(share.id, entry);
|
|
4474
|
+
log(`[guest] Started guest ${share.guestHost} for ${share.granteeLabel || "grantee"} (pid=${child.pid})`);
|
|
4475
|
+
|
|
4476
|
+
child.on("exit", (code, signal) => {
|
|
4477
|
+
const current = guestDaemons.get(share.id);
|
|
4478
|
+
// Either we asked it to stop, or a newer entry already replaced it.
|
|
4479
|
+
if (!current || current.child !== child || current.stopping) return;
|
|
4480
|
+
const failures = (guestFailureCounts.get(share.id) || 0) + 1;
|
|
4481
|
+
guestFailureCounts.set(share.id, failures);
|
|
4482
|
+
const delay = nextRestartDelayMs(failures);
|
|
4483
|
+
log(
|
|
4484
|
+
`[guest] Guest ${share.guestHost} exited (code=${code}, signal=${signal}); ` +
|
|
4485
|
+
`restarting in ${Math.round(delay / 1000)}s (failure ${failures})`
|
|
4486
|
+
);
|
|
4487
|
+
current.restartTimer = setTimeout(() => {
|
|
4488
|
+
// Don't resurrect from a stale timer: re-check with the backend
|
|
4489
|
+
// instead, so a share revoked while we were backing off stays dead.
|
|
4490
|
+
guestDaemons.delete(share.id);
|
|
4491
|
+
void reconcileGuestDaemons();
|
|
4492
|
+
}, delay);
|
|
4493
|
+
if (typeof current.restartTimer.unref === "function") current.restartTimer.unref();
|
|
4494
|
+
});
|
|
4495
|
+
|
|
4496
|
+
child.on("error", (error) => {
|
|
4497
|
+
// Node emits `error` without `exit` for e.g. ENOENT. Without this the
|
|
4498
|
+
// entry would sit in the map forever: reconcile counts it as running, so
|
|
4499
|
+
// it is never retried and never started.
|
|
4500
|
+
logError(`[guest] Guest ${share.guestHost} failed to spawn: ${error?.message || error}`);
|
|
4501
|
+
const current = guestDaemons.get(share.id);
|
|
4502
|
+
if (!current || current.child !== child || current.stopping) return;
|
|
4503
|
+
const failures = (guestFailureCounts.get(share.id) || 0) + 1;
|
|
4504
|
+
guestFailureCounts.set(share.id, failures);
|
|
4505
|
+
guestDaemons.delete(share.id);
|
|
4506
|
+
const timer = setTimeout(() => void reconcileGuestDaemons(), nextRestartDelayMs(failures));
|
|
4507
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
4508
|
+
});
|
|
4509
|
+
}
|
|
4510
|
+
|
|
4511
|
+
async function fetchActiveShares() {
|
|
4512
|
+
const url = `${BACKEND_HTTP}/api/daemon-shares/mine?daemonHost=${encodeURIComponent(AGENT_NAME)}`;
|
|
4513
|
+
// Without a deadline a hung backend pins `guestReconcileInFlight` for
|
|
4514
|
+
// undici's 300s default, and nothing else clears it -- the supervisor goes
|
|
4515
|
+
// silently deaf for five minutes.
|
|
4516
|
+
const response = await fetch(url, {
|
|
4517
|
+
headers: { Authorization: `Bearer ${AGENT_TOKEN}` },
|
|
4518
|
+
signal: AbortSignal.timeout(15_000),
|
|
4519
|
+
});
|
|
4520
|
+
if (!response.ok) {
|
|
4521
|
+
// A backend that predates this feature returns 404; that is not an error
|
|
4522
|
+
// worth logging on every maintenance tick.
|
|
4523
|
+
if (response.status === 404) return null;
|
|
4524
|
+
throw new Error(`HTTP ${response.status}`);
|
|
4525
|
+
}
|
|
4526
|
+
const body = await response.json();
|
|
4527
|
+
return Array.isArray(body?.shares) ? body.shares : [];
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
async function reconcileGuestDaemons() {
|
|
4531
|
+
if (guestReconcileInFlight || daemonShuttingDown) return;
|
|
4532
|
+
guestReconcileInFlight = true;
|
|
4533
|
+
try {
|
|
4534
|
+
const shares = await fetchActiveShares();
|
|
4535
|
+
if (shares === null) return;
|
|
4536
|
+
const running = new Set(guestDaemons.keys());
|
|
4537
|
+
const { start, stop, skipped } = reconcileGuests(shares, running, MAX_GUEST_DAEMONS);
|
|
4538
|
+
for (const shareId of stop) {
|
|
4539
|
+
stopGuestDaemon(shareId, "share no longer active");
|
|
4540
|
+
}
|
|
4541
|
+
for (const share of start) {
|
|
4542
|
+
spawnGuestDaemon(share);
|
|
4543
|
+
}
|
|
4544
|
+
if (skipped > 0) {
|
|
4545
|
+
// Never drop guests silently -- a share that looks accepted in the UI
|
|
4546
|
+
// but never runs is the hardest kind of bug to report.
|
|
4547
|
+
log(
|
|
4548
|
+
`[guest] ${skipped} share(s) not started: at the ${MAX_GUEST_DAEMONS}-guest limit for this daemon`
|
|
4549
|
+
);
|
|
4550
|
+
}
|
|
4551
|
+
} catch (error) {
|
|
4552
|
+
logError(`[guest] Failed to reconcile guest daemons: ${error?.message || error}`);
|
|
4553
|
+
} finally {
|
|
4554
|
+
guestReconcileInFlight = false;
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
4557
|
+
|
|
3686
4558
|
async function handleRestartDaemon(payload) {
|
|
3687
4559
|
const requestId = payload?.request_id ? String(payload.request_id) : "";
|
|
3688
4560
|
const targetVersionRaw = payload?.target_version
|
|
@@ -3708,6 +4580,22 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3708
4580
|
return;
|
|
3709
4581
|
}
|
|
3710
4582
|
|
|
4583
|
+
// RFC 0035: a versioned restart runs a *global* `npm install -g`, which
|
|
4584
|
+
// would swap the CLI binary out from under the machine owner's own daemon
|
|
4585
|
+
// and every one of their fire processes. A guest may restart itself, but
|
|
4586
|
+
// it may not change the machine's installed version.
|
|
4587
|
+
if (IS_GUEST_DAEMON && !isGuestRestartAllowed(payload)) {
|
|
4588
|
+
log(
|
|
4589
|
+
`[restart_daemon] Refused (${requestId}): guest daemons cannot install version ${targetVersionRaw}`
|
|
4590
|
+
);
|
|
4591
|
+
sendAgentCommandAck({
|
|
4592
|
+
requestId,
|
|
4593
|
+
eventType: "restart_daemon",
|
|
4594
|
+
accepted: false,
|
|
4595
|
+
}).catch(() => {});
|
|
4596
|
+
return;
|
|
4597
|
+
}
|
|
4598
|
+
|
|
3711
4599
|
autoUpdateInProgress = true;
|
|
3712
4600
|
try {
|
|
3713
4601
|
log(
|
|
@@ -3768,6 +4656,8 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3768
4656
|
|
|
3769
4657
|
async function recoverStaleTasks() {
|
|
3770
4658
|
try {
|
|
4659
|
+
// Never judge a task before startup adoption has had its say.
|
|
4660
|
+
await adoptOrphanedTmuxFiresPromise;
|
|
3771
4661
|
const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
|
|
3772
4662
|
method: "GET",
|
|
3773
4663
|
headers: {
|
|
@@ -3800,8 +4690,48 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3800
4690
|
return;
|
|
3801
4691
|
}
|
|
3802
4692
|
|
|
4693
|
+
// Startup is the main case the tmux hand-off exists for: the previous
|
|
4694
|
+
// daemon deliberately left its Fires running and this fresh process has
|
|
4695
|
+
// an empty `activeTaskProcesses` by definition, so "not in memory"
|
|
4696
|
+
// proves nothing here. `adoptOrphanedTmuxFires` has already claimed
|
|
4697
|
+
// every session with a hand-off record; this second pass catches the
|
|
4698
|
+
// ones without a record, which only become identifiable now that the
|
|
4699
|
+
// backend has told us which task ids to look for.
|
|
4700
|
+
if (tmuxFiresMayExistUnseen()) {
|
|
4701
|
+
logError(
|
|
4702
|
+
`tmux is reachable but this daemon started without tmux mode active; skipping stale ` +
|
|
4703
|
+
`recovery for ${staleTasks.length} task(s) rather than killing Fires it cannot see`,
|
|
4704
|
+
);
|
|
4705
|
+
return;
|
|
4706
|
+
}
|
|
4707
|
+
const tmuxSessions = FIRE_TMUX_MODE_ACTIVE ? await listAllTmuxSessions() : null;
|
|
4708
|
+
if (tmuxSessions && !tmuxSessions.conclusive) {
|
|
4709
|
+
// An unanswerable `tmux list-sessions` is not evidence that nothing is
|
|
4710
|
+
// running — and this is the one sweep with no second chance, since it
|
|
4711
|
+
// runs once per process. Killing on a blind probe is precisely how a
|
|
4712
|
+
// single wedged tmux server reproduces the 2026-08-31 mass kill.
|
|
4713
|
+
logError(
|
|
4714
|
+
`Could not list tmux sessions; skipping stale recovery for ${staleTasks.length} task(s) rather than killing on a blind probe`,
|
|
4715
|
+
);
|
|
4716
|
+
return;
|
|
4717
|
+
}
|
|
4718
|
+
const deadTasks = staleTasks.filter((task) => {
|
|
4719
|
+
// A spawn already in flight has no record yet either; killing it
|
|
4720
|
+
// would shoot down a Fire this daemon is in the middle of starting.
|
|
4721
|
+
if (pendingTaskStarts.has(String(task?.id || ""))) return false;
|
|
4722
|
+
return !adoptLiveTmuxFireForTask(task, tmuxSessions?.sessions || []);
|
|
4723
|
+
});
|
|
4724
|
+
|
|
4725
|
+
if (deadTasks.length === 0) {
|
|
4726
|
+
return;
|
|
4727
|
+
}
|
|
4728
|
+
|
|
4729
|
+
// Count what the backend actually accepted, not what we attempted: a
|
|
4730
|
+
// 409/500 leaves the task running, and reporting it as recovered has
|
|
4731
|
+
// repeatedly sent readers of this log looking in the wrong place.
|
|
4732
|
+
let killedCount = 0;
|
|
3803
4733
|
await Promise.all(
|
|
3804
|
-
|
|
4734
|
+
deadTasks.map(async (task) => {
|
|
3805
4735
|
const taskId = task?.id;
|
|
3806
4736
|
if (!taskId) return;
|
|
3807
4737
|
const patchResp = await fetchFn(`${BACKEND_HTTP}/api/tasks/${taskId}`, {
|
|
@@ -3816,12 +4746,13 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3816
4746
|
if (!patchResp.ok) {
|
|
3817
4747
|
logError(`Failed to mark stale task ${taskId} as killed: HTTP ${patchResp.status}`);
|
|
3818
4748
|
} else {
|
|
4749
|
+
killedCount += 1;
|
|
3819
4750
|
markBackendHttpSuccess();
|
|
3820
4751
|
}
|
|
3821
4752
|
}),
|
|
3822
4753
|
);
|
|
3823
4754
|
|
|
3824
|
-
log(`Recovered ${
|
|
4755
|
+
log(`Recovered ${killedCount}/${deadTasks.length} stale task(s) to killed`);
|
|
3825
4756
|
} catch (error) {
|
|
3826
4757
|
logError(`recoverStaleTasks error: ${error?.message || error}`);
|
|
3827
4758
|
}
|
|
@@ -3833,6 +4764,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3833
4764
|
|
|
3834
4765
|
async function reconcileAssignedTasks() {
|
|
3835
4766
|
try {
|
|
4767
|
+
await adoptOrphanedTmuxFiresPromise;
|
|
3836
4768
|
const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
|
|
3837
4769
|
method: "GET",
|
|
3838
4770
|
headers: {
|
|
@@ -3867,10 +4799,36 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3867
4799
|
});
|
|
3868
4800
|
|
|
3869
4801
|
let killedCount = 0;
|
|
4802
|
+
// Shutdown deliberately leaves tmux-detached Fires running, and a ws
|
|
4803
|
+
// reconnect does not repopulate `activeTaskProcesses`, so this map
|
|
4804
|
+
// under-reports what is still alive on this host. Ask tmux.
|
|
4805
|
+
if (assigned.length && tmuxFiresMayExistUnseen()) {
|
|
4806
|
+
logError(
|
|
4807
|
+
`tmux is reachable but this daemon started without tmux mode active; skipping reconcile ` +
|
|
4808
|
+
`for ${assigned.length} assigned task(s)`,
|
|
4809
|
+
);
|
|
4810
|
+
return;
|
|
4811
|
+
}
|
|
4812
|
+
const tmuxSessions =
|
|
4813
|
+
FIRE_TMUX_MODE_ACTIVE && assigned.length ? await listAllTmuxSessions() : null;
|
|
4814
|
+
if (tmuxSessions && !tmuxSessions.conclusive) {
|
|
4815
|
+
// Same rule as the startup sweep. Here we do get another chance on the
|
|
4816
|
+
// next reconnect, which is all the more reason not to guess.
|
|
4817
|
+
logError(
|
|
4818
|
+
`Could not list tmux sessions; skipping reconcile for ${assigned.length} assigned task(s)`,
|
|
4819
|
+
);
|
|
4820
|
+
return;
|
|
4821
|
+
}
|
|
3870
4822
|
for (const task of assigned) {
|
|
3871
4823
|
const taskId = String(task?.id || "");
|
|
3872
4824
|
if (!taskId) continue;
|
|
3873
|
-
if (localTaskIds.has(taskId)) {
|
|
4825
|
+
if (localTaskIds.has(taskId) || pendingTaskStarts.has(taskId)) {
|
|
4826
|
+
continue;
|
|
4827
|
+
}
|
|
4828
|
+
// Adopts on the spot rather than merely skipping: a survivor that
|
|
4829
|
+
// dodges the kill but gains no watcher would sit at `running`
|
|
4830
|
+
// forever, because nothing else reports a tmux Fire's death.
|
|
4831
|
+
if (adoptLiveTmuxFireForTask(task, tmuxSessions?.sessions || [])) {
|
|
3874
4832
|
continue;
|
|
3875
4833
|
}
|
|
3876
4834
|
const patchResp = await fetchFn(`${BACKEND_HTTP}/api/tasks/${taskId}`, {
|
|
@@ -3906,7 +4864,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3906
4864
|
// half-close). Fires raise their own ws to backend independently — this is
|
|
3907
4865
|
// the daemon's *own* belief about which tasks it is hosting. The backend
|
|
3908
4866
|
// arbitrates between this push and the persisted task state.
|
|
3909
|
-
function pushAgentAliveTasks(reason) {
|
|
4867
|
+
async function pushAgentAliveTasks(reason) {
|
|
4868
|
+
// Adoption may still be running when the ws handshake completes. This is
|
|
4869
|
+
// the only channel that revokes a `killed_reason=daemon_disconnected`
|
|
4870
|
+
// flag, so pushing before the adopted tasks are in the map would leave
|
|
4871
|
+
// them showing as killed in the UI even though this daemon rescued them.
|
|
4872
|
+
await adoptOrphanedTmuxFiresPromise;
|
|
3910
4873
|
const aliveTaskIds = getActiveTaskIds();
|
|
3911
4874
|
if (aliveTaskIds.length === 0) {
|
|
3912
4875
|
return Promise.resolve();
|
|
@@ -4315,89 +5278,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4315
5278
|
}
|
|
4316
5279
|
|
|
4317
5280
|
function resolvePtyLaunchSpec(launchConfig, fallbackCwd) {
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
normalizeOptionalString(normalizedLaunchConfig.entrypointType) ||
|
|
4322
|
-
(normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
|
|
4323
|
-
normalizeOptionalString(normalizedLaunchConfig.toolPreset)
|
|
4324
|
-
? "tool_preset"
|
|
4325
|
-
: "shell");
|
|
4326
|
-
const preferredShell = resolveDefaultPtyShell({
|
|
4327
|
-
explicitShell: normalizedLaunchConfig.shell,
|
|
4328
|
-
envShell: process.env.SHELL,
|
|
4329
|
-
comspec: process.env.COMSPEC,
|
|
4330
|
-
platform: process.platform,
|
|
5281
|
+
return buildPtyLaunchSpec(launchConfig, fallbackCwd, {
|
|
5282
|
+
allowCliList: ALLOW_CLI_LIST,
|
|
5283
|
+
supportedBackends: SUPPORTED_BACKENDS,
|
|
4331
5284
|
existsSync: existsSyncFn,
|
|
5285
|
+
log,
|
|
4332
5286
|
});
|
|
4333
|
-
const cwd =
|
|
4334
|
-
normalizeOptionalString(normalizedLaunchConfig.cwd) ||
|
|
4335
|
-
fallbackCwd;
|
|
4336
|
-
const env = normalizeTerminalEnv(normalizedLaunchConfig.env);
|
|
4337
|
-
const cols = normalizePositiveInt(
|
|
4338
|
-
normalizedLaunchConfig.cols ?? normalizedLaunchConfig.columns,
|
|
4339
|
-
DEFAULT_TERMINAL_COLS,
|
|
4340
|
-
);
|
|
4341
|
-
const rows = normalizePositiveInt(
|
|
4342
|
-
normalizedLaunchConfig.rows,
|
|
4343
|
-
DEFAULT_TERMINAL_ROWS,
|
|
4344
|
-
);
|
|
4345
|
-
|
|
4346
|
-
if (entrypointType === "tool_preset") {
|
|
4347
|
-
const toolPreset =
|
|
4348
|
-
normalizeOptionalString(normalizedLaunchConfig.tool_preset) ||
|
|
4349
|
-
normalizeOptionalString(normalizedLaunchConfig.toolPreset) ||
|
|
4350
|
-
SUPPORTED_BACKENDS[0] ||
|
|
4351
|
-
"codex";
|
|
4352
|
-
const cliCommand = ALLOW_CLI_LIST[toolPreset];
|
|
4353
|
-
if (!cliCommand) {
|
|
4354
|
-
throw new Error(`Unsupported tool preset: ${toolPreset}`);
|
|
4355
|
-
}
|
|
4356
|
-
return {
|
|
4357
|
-
entrypointType,
|
|
4358
|
-
toolPreset,
|
|
4359
|
-
command: preferredShell,
|
|
4360
|
-
args: ["-lc", cliCommand],
|
|
4361
|
-
shell: preferredShell,
|
|
4362
|
-
cwd,
|
|
4363
|
-
env,
|
|
4364
|
-
cols,
|
|
4365
|
-
rows,
|
|
4366
|
-
};
|
|
4367
|
-
}
|
|
4368
|
-
|
|
4369
|
-
if (entrypointType === "custom") {
|
|
4370
|
-
const command = normalizeOptionalString(normalizedLaunchConfig.command);
|
|
4371
|
-
if (!command) {
|
|
4372
|
-
throw new Error("launch_config.command is required for custom entrypoint");
|
|
4373
|
-
}
|
|
4374
|
-
const args = Array.isArray(normalizedLaunchConfig.args)
|
|
4375
|
-
? normalizedLaunchConfig.args.filter((value) => typeof value === "string")
|
|
4376
|
-
: [];
|
|
4377
|
-
return {
|
|
4378
|
-
entrypointType,
|
|
4379
|
-
toolPreset: null,
|
|
4380
|
-
command,
|
|
4381
|
-
args,
|
|
4382
|
-
shell: preferredShell,
|
|
4383
|
-
cwd,
|
|
4384
|
-
env,
|
|
4385
|
-
cols,
|
|
4386
|
-
rows,
|
|
4387
|
-
};
|
|
4388
|
-
}
|
|
4389
|
-
|
|
4390
|
-
return {
|
|
4391
|
-
entrypointType: "shell",
|
|
4392
|
-
toolPreset: null,
|
|
4393
|
-
command: preferredShell,
|
|
4394
|
-
args: ["-l"],
|
|
4395
|
-
shell: preferredShell,
|
|
4396
|
-
cwd,
|
|
4397
|
-
env,
|
|
4398
|
-
cols,
|
|
4399
|
-
rows,
|
|
4400
|
-
};
|
|
4401
5287
|
}
|
|
4402
5288
|
|
|
4403
5289
|
function getTerminalChunkByteLength(data) {
|
|
@@ -4913,6 +5799,10 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4913
5799
|
{ cwd: worktreeConfig.projectRepoRoot },
|
|
4914
5800
|
);
|
|
4915
5801
|
}
|
|
5802
|
+
// The marker lives outside the worktree, so `git worktree remove` does
|
|
5803
|
+
// not take it with the directory. Leaving it behind would let a worktree
|
|
5804
|
+
// later recreated on this path look ready before it is prepared.
|
|
5805
|
+
removeTaskWorktreeReadyMarker(worktreeRoot);
|
|
4916
5806
|
|
|
4917
5807
|
await reportTaskWorktreeCleanupResult({
|
|
4918
5808
|
requestId,
|
|
@@ -5284,6 +6174,26 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5284
6174
|
return;
|
|
5285
6175
|
}
|
|
5286
6176
|
if (event.type === "ai_manager_request") {
|
|
6177
|
+
// RFC 0035: reads are fine and genuinely useful to a guest (it needs to
|
|
6178
|
+
// know how much quota the borrowed machine has left). `switch_account` is
|
|
6179
|
+
// not: `AiManager` resolves the codex auth path from `homedir()`, so a
|
|
6180
|
+
// guest switching accounts renames over `~/.codex/auth.json` and changes
|
|
6181
|
+
// the machine owner's own daemon, running fires, and interactive `codex`.
|
|
6182
|
+
const aiAction = event.payload?.action;
|
|
6183
|
+
if (IS_GUEST_DAEMON && !isGuestAiManagerActionAllowed(aiAction)) {
|
|
6184
|
+
log(`[guest] Refused ai_manager action '${aiAction}' on a shared daemon`);
|
|
6185
|
+
client
|
|
6186
|
+
.send({
|
|
6187
|
+
type: "ai_manager_response",
|
|
6188
|
+
payload: {
|
|
6189
|
+
request_id: event.payload?.request_id,
|
|
6190
|
+
error: "Account switching is disabled on a shared daemon",
|
|
6191
|
+
error_code: "guest_forbidden",
|
|
6192
|
+
},
|
|
6193
|
+
})
|
|
6194
|
+
.catch(() => {});
|
|
6195
|
+
return;
|
|
6196
|
+
}
|
|
5287
6197
|
handleAiManagerRequest(client, aiManagerHandlers, event.payload).catch((error) => {
|
|
5288
6198
|
logError(`Unhandled ai_manager_request failure: ${error?.message || error}`);
|
|
5289
6199
|
});
|
|
@@ -5332,6 +6242,14 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5332
6242
|
logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
|
|
5333
6243
|
});
|
|
5334
6244
|
}
|
|
6245
|
+
if (event.type === "update_daemon_request") {
|
|
6246
|
+
if (event?.payload?.action === "start") {
|
|
6247
|
+
log(`[update_daemon] Update requested (current=${cliVersion}); log: ${daemonUpdatePaths.logPath}`);
|
|
6248
|
+
}
|
|
6249
|
+
handleUpdateDaemonRequest(client, daemonUpdateHandlers, event.payload).catch((error) => {
|
|
6250
|
+
logError(`Unhandled update_daemon_request failure: ${error?.message || error}`);
|
|
6251
|
+
});
|
|
6252
|
+
}
|
|
5335
6253
|
}
|
|
5336
6254
|
|
|
5337
6255
|
function markWatchdogHealthy(signal, at = Date.now()) {
|
|
@@ -5406,6 +6324,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5406
6324
|
async function handleValidateProjectPath(payload) {
|
|
5407
6325
|
const requestId = payload?.request_id ? String(payload.request_id).trim() : "";
|
|
5408
6326
|
const rawWorkspacePath = payload?.workspace_path ? String(payload.workspace_path).trim() : "";
|
|
6327
|
+
const createIfMissing = payload?.create_if_missing === true;
|
|
5409
6328
|
const validatedAt = new Date().toISOString();
|
|
5410
6329
|
|
|
5411
6330
|
if (!requestId || !rawWorkspacePath) {
|
|
@@ -5413,6 +6332,27 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5413
6332
|
return;
|
|
5414
6333
|
}
|
|
5415
6334
|
|
|
6335
|
+
// RFC 0035: keep a guest inside the directory its owner set aside. This is
|
|
6336
|
+
// misuse prevention, not a security boundary -- the guest's AI agent runs a
|
|
6337
|
+
// shell and can reach anything the OS user can. It matters because neither
|
|
6338
|
+
// handler is otherwise bounded: this one will `mkdirSync(recursive)` any
|
|
6339
|
+
// absolute path it is handed.
|
|
6340
|
+
if (IS_GUEST_DAEMON && GUEST_ROOT && !isPathInsideGuestRoot(rawWorkspacePath, GUEST_ROOT)) {
|
|
6341
|
+
await client.sendJson({
|
|
6342
|
+
type: "project_path_validated",
|
|
6343
|
+
payload: {
|
|
6344
|
+
request_id: requestId,
|
|
6345
|
+
daemon_host: AGENT_NAME,
|
|
6346
|
+
workspace_path: rawWorkspacePath,
|
|
6347
|
+
error: `Path is outside the shared workspace root (${GUEST_ROOT})`,
|
|
6348
|
+
error_code: "outside_guest_root",
|
|
6349
|
+
validated_at: new Date().toISOString(),
|
|
6350
|
+
},
|
|
6351
|
+
});
|
|
6352
|
+
return;
|
|
6353
|
+
}
|
|
6354
|
+
|
|
6355
|
+
|
|
5416
6356
|
let result = {
|
|
5417
6357
|
workspacePath: null,
|
|
5418
6358
|
repoRoot: null,
|
|
@@ -5429,7 +6369,22 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5429
6369
|
|
|
5430
6370
|
try {
|
|
5431
6371
|
const resolvedPath = path.resolve(rawWorkspacePath);
|
|
5432
|
-
|
|
6372
|
+
let createError = null;
|
|
6373
|
+
if (createIfMissing && !existsSyncFn(resolvedPath)) {
|
|
6374
|
+
try {
|
|
6375
|
+
mkdirSyncFn(resolvedPath, { recursive: true });
|
|
6376
|
+
log(`Created workspace path on request: ${resolvedPath}`);
|
|
6377
|
+
} catch (error) {
|
|
6378
|
+
createError = error;
|
|
6379
|
+
}
|
|
6380
|
+
}
|
|
6381
|
+
if (createError) {
|
|
6382
|
+
result = {
|
|
6383
|
+
...result,
|
|
6384
|
+
error: `Failed to create workspace path on daemon ${AGENT_NAME}: ${createError?.message || createError}`,
|
|
6385
|
+
errorCode: "workspace_create_failed",
|
|
6386
|
+
};
|
|
6387
|
+
} else if (!existsSyncFn(resolvedPath)) {
|
|
5433
6388
|
result = {
|
|
5434
6389
|
...result,
|
|
5435
6390
|
error: `Workspace path does not exist on daemon ${AGENT_NAME}: ${rawWorkspacePath}`,
|
|
@@ -5522,6 +6477,27 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5522
6477
|
return;
|
|
5523
6478
|
}
|
|
5524
6479
|
|
|
6480
|
+
// RFC 0035: keep a guest inside the directory its owner set aside. This is
|
|
6481
|
+
// misuse prevention, not a security boundary -- the guest's AI agent runs a
|
|
6482
|
+
// shell and can reach anything the OS user can. It matters because neither
|
|
6483
|
+
// handler is otherwise bounded: this one will `mkdirSync(recursive)` any
|
|
6484
|
+
// absolute path it is handed.
|
|
6485
|
+
if (IS_GUEST_DAEMON && GUEST_ROOT && !isPathInsideGuestRoot(rawWorkspacePath, GUEST_ROOT)) {
|
|
6486
|
+
await client.sendJson({
|
|
6487
|
+
type: "project_agents_resolved",
|
|
6488
|
+
payload: {
|
|
6489
|
+
request_id: requestId,
|
|
6490
|
+
daemon_host: AGENT_NAME,
|
|
6491
|
+
workspace_path: rawWorkspacePath,
|
|
6492
|
+
error: `Path is outside the shared workspace root (${GUEST_ROOT})`,
|
|
6493
|
+
error_code: "outside_guest_root",
|
|
6494
|
+
resolved_at: new Date().toISOString(),
|
|
6495
|
+
},
|
|
6496
|
+
});
|
|
6497
|
+
return;
|
|
6498
|
+
}
|
|
6499
|
+
|
|
6500
|
+
|
|
5525
6501
|
let result = {
|
|
5526
6502
|
workspacePath: null,
|
|
5527
6503
|
agents: [],
|
|
@@ -5641,6 +6617,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5641
6617
|
}
|
|
5642
6618
|
|
|
5643
6619
|
activeTaskProcesses.delete(taskId);
|
|
6620
|
+
forgetFireSessionRecord(processRecord);
|
|
5644
6621
|
return true;
|
|
5645
6622
|
}
|
|
5646
6623
|
|
|
@@ -5715,6 +6692,15 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5715
6692
|
return Boolean(record?.forceDaemonTerminalStatusReport) || !Boolean(record?.managedByFireBridge);
|
|
5716
6693
|
}
|
|
5717
6694
|
|
|
6695
|
+
// "Does this record stand for a live fire?" `child` alone is not the answer:
|
|
6696
|
+
// an ADOPTED tmux record has none — the `tmux new-session` client belonged
|
|
6697
|
+
// to the daemon that spawned it — while the fire it points at is very much
|
|
6698
|
+
// alive. Every already-active gate must ask this instead, or it will spawn
|
|
6699
|
+
// a second fire into a worktree that already has one.
|
|
6700
|
+
function recordHasLiveFire(record) {
|
|
6701
|
+
return Boolean(record?.child) || Boolean(record?.tmuxMode && record?.tmuxSession);
|
|
6702
|
+
}
|
|
6703
|
+
|
|
5718
6704
|
// RFC 0029: confirm that a task the backend believes is dead is actually
|
|
5719
6705
|
// still hosting a live fire on this daemon. The check is intentionally
|
|
5720
6706
|
// conservative:
|
|
@@ -5813,6 +6799,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5813
6799
|
entry.stopForceKillTimer = null;
|
|
5814
6800
|
}
|
|
5815
6801
|
activeTaskProcesses.delete(taskId);
|
|
6802
|
+
forgetFireSessionRecord(entry);
|
|
5816
6803
|
}
|
|
5817
6804
|
entry = undefined;
|
|
5818
6805
|
}
|
|
@@ -5870,7 +6857,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5870
6857
|
|
|
5871
6858
|
const processRecord = activeTaskProcesses.get(taskId);
|
|
5872
6859
|
const ptyRecord = activePtySessions.get(taskId);
|
|
5873
|
-
|
|
6860
|
+
// `recordHasLiveFire`, not `child`: an adopted tmux record has no child,
|
|
6861
|
+
// and reading it as "nothing to stop" would report the task KILLED to the
|
|
6862
|
+
// backend while leaving the fire running in its session, still writing to
|
|
6863
|
+
// the worktree. Nothing would ever notice — the reaper only speaks when a
|
|
6864
|
+
// session disappears.
|
|
6865
|
+
if (!recordHasLiveFire(processRecord) && !ptyRecord) {
|
|
5874
6866
|
log(`Stop requested for task ${taskId}, but no active process found`);
|
|
5875
6867
|
sendStopAck(false);
|
|
5876
6868
|
// "Nothing to stop" IS the terminal answer, and the server cannot infer
|
|
@@ -6248,9 +7240,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6248
7240
|
}
|
|
6249
7241
|
|
|
6250
7242
|
const existingTaskRecord = activeTaskProcesses.get(taskId);
|
|
6251
|
-
if (existingTaskRecord
|
|
7243
|
+
if (recordHasLiveFire(existingTaskRecord) || pendingTaskStarts.has(taskId)) {
|
|
6252
7244
|
log(
|
|
6253
|
-
`Duplicate create_task ignored for ${taskId}: task already active (
|
|
7245
|
+
`Duplicate create_task ignored for ${taskId}: task already active (` +
|
|
7246
|
+
`${existingTaskRecord?.tmuxSession
|
|
7247
|
+
? `tmux=${existingTaskRecord.tmuxSession}`
|
|
7248
|
+
: `pid=${existingTaskRecord?.child?.pid ?? "unknown"}`})`,
|
|
6254
7249
|
);
|
|
6255
7250
|
sendAgentCommandAck({
|
|
6256
7251
|
requestId,
|
|
@@ -6454,7 +7449,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6454
7449
|
const outputCapture = createChildOutputCapture({ logPath, logStartOffset });
|
|
6455
7450
|
const spawnedAtMs = Date.now();
|
|
6456
7451
|
|
|
6457
|
-
|
|
7452
|
+
const activeProcessRecord = {
|
|
6458
7453
|
child,
|
|
6459
7454
|
projectId,
|
|
6460
7455
|
logPath,
|
|
@@ -6472,7 +7467,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6472
7467
|
managedByFireBridge: true,
|
|
6473
7468
|
tmuxSession: tmuxSession || null,
|
|
6474
7469
|
tmuxMode: Boolean(tmuxSession),
|
|
6475
|
-
}
|
|
7470
|
+
};
|
|
7471
|
+
activeTaskProcesses.set(taskId, activeProcessRecord);
|
|
7472
|
+
// Mirror the parts of the record that only exist in this process's
|
|
7473
|
+
// memory, so a successor daemon can adopt this Fire instead of
|
|
7474
|
+
// mistaking the hand-off for a dead task.
|
|
7475
|
+
rememberFireSessionRecord(taskId, activeProcessRecord);
|
|
6476
7476
|
|
|
6477
7477
|
client
|
|
6478
7478
|
.sendJson({
|
|
@@ -6578,6 +7578,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6578
7578
|
clearTimeout(active.stopForceKillTimer);
|
|
6579
7579
|
}
|
|
6580
7580
|
activeTaskProcesses.delete(taskId);
|
|
7581
|
+
forgetFireSessionRecord(active);
|
|
6581
7582
|
const suppressExitStatusReport = suppressedExitStatusReports.has(taskId);
|
|
6582
7583
|
suppressedExitStatusReports.delete(taskId);
|
|
6583
7584
|
if (logStream) {
|
|
@@ -6768,12 +7769,17 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6768
7769
|
activeTarget.stopForceKillTimer = null;
|
|
6769
7770
|
}
|
|
6770
7771
|
activeTaskProcesses.delete(normalizedTargetTaskId);
|
|
7772
|
+
forgetFireSessionRecord(activeTarget);
|
|
6771
7773
|
activeTarget = undefined;
|
|
6772
7774
|
}
|
|
6773
7775
|
}
|
|
6774
7776
|
|
|
7777
|
+
// The probe above already cleared the record if the session was
|
|
7778
|
+
// conclusively gone, so a surviving tmuxMode entry means a live session.
|
|
7779
|
+
const targetHasLiveFire = recordHasLiveFire(activeTarget);
|
|
7780
|
+
|
|
6775
7781
|
if (isRefreshSessionInplace) {
|
|
6776
|
-
if (!
|
|
7782
|
+
if (!targetHasLiveFire) {
|
|
6777
7783
|
reportRestartFailure({
|
|
6778
7784
|
taskId: normalizedTargetTaskId,
|
|
6779
7785
|
projectId: normalizedProjectId,
|
|
@@ -6784,7 +7790,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6784
7790
|
});
|
|
6785
7791
|
return;
|
|
6786
7792
|
}
|
|
6787
|
-
} else if (
|
|
7793
|
+
} else if (targetHasLiveFire) {
|
|
6788
7794
|
// tmux-mode failures point at the tmux session, not a meaningless pid.
|
|
6789
7795
|
const description = activeTarget.tmuxMode
|
|
6790
7796
|
? `task already active in tmux session ${activeTarget.tmuxSession || "(unknown)"} — stop the task before restarting`
|
|
@@ -7169,6 +8175,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7169
8175
|
tmuxMode: Boolean(tmuxSession),
|
|
7170
8176
|
};
|
|
7171
8177
|
activeTaskProcesses.set(normalizedTargetTaskId, activeProcessRecord);
|
|
8178
|
+
// See the create_task path: this is the only copy of `exitMarkerToken`
|
|
8179
|
+
// that survives the current process.
|
|
8180
|
+
rememberFireSessionRecord(normalizedTargetTaskId, activeProcessRecord);
|
|
7172
8181
|
|
|
7173
8182
|
// In tmux mode the Fire process writes directly to logPath via shell
|
|
7174
8183
|
// redirection inside the tmux session, so the daemon does not pipe
|
|
@@ -7259,6 +8268,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7259
8268
|
clearTimeout(active.stopForceKillTimer);
|
|
7260
8269
|
}
|
|
7261
8270
|
activeTaskProcesses.delete(normalizedTargetTaskId);
|
|
8271
|
+
forgetFireSessionRecord(active);
|
|
7262
8272
|
const suppressExitStatusReport = suppressedExitStatusReports.has(normalizedTargetTaskId);
|
|
7263
8273
|
suppressedExitStatusReports.delete(normalizedTargetTaskId);
|
|
7264
8274
|
if (logStream) {
|