@love-moon/conductor-cli 0.11.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 +74 -0
- package/bin/conductor-daemon-update.js +55 -0
- package/package.json +5 -5
- package/src/daemon-update.js +614 -0
- package/src/daemon.js +518 -29
- package/src/fire-session-registry.js +136 -0
- package/src/guest-daemon.js +8 -1
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,6 +42,12 @@ 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,
|
|
@@ -106,6 +119,7 @@ const CLI_PATH = path.resolve(PACKAGE_ROOT, "bin", "conductor-fire.js");
|
|
|
106
119
|
// the host daemon, so guests always match the installed version and there is
|
|
107
120
|
// no second update path to keep in sync.
|
|
108
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");
|
|
109
123
|
const CLI_VERSION = (() => {
|
|
110
124
|
try {
|
|
111
125
|
return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf-8")).version;
|
|
@@ -1187,6 +1201,16 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1187
1201
|
log("Fire tmux mode enabled: each Fire process will run in a detached tmux session");
|
|
1188
1202
|
}
|
|
1189
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
|
+
|
|
1190
1214
|
// Single-quote a value so it can be embedded inside a `bash -c '...'`
|
|
1191
1215
|
// command. Embedded single-quotes are escaped via the standard `'\\''`
|
|
1192
1216
|
// sequence.
|
|
@@ -1211,7 +1235,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1211
1235
|
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
1212
1236
|
.replace(/^_+|_+$/g, "")
|
|
1213
1237
|
.slice(0, 32) || "task";
|
|
1214
|
-
return
|
|
1238
|
+
return `${FIRE_TMUX_SESSION_NAMESPACE}${safe}-`;
|
|
1215
1239
|
}
|
|
1216
1240
|
|
|
1217
1241
|
// Build a unique tmux session name for a Fire spawn.
|
|
@@ -1754,7 +1778,14 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1754
1778
|
// would be misread as "died hard" — reporting every clean finish as
|
|
1755
1779
|
// KILLED. Staying silent restores the old blind spot for this task only,
|
|
1756
1780
|
// which is strictly better than manufacturing a wrong cause of death.
|
|
1757
|
-
|
|
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) {
|
|
1758
1789
|
logError(
|
|
1759
1790
|
`Cannot classify the end of task ${taskId}: no log path on the active record, so no exit marker exists`,
|
|
1760
1791
|
);
|
|
@@ -1767,7 +1798,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1767
1798
|
// that absence as "no exit marker" would report a green task as KILLED
|
|
1768
1799
|
// with an invented "killed or OOM" cause. An empty-but-present file is
|
|
1769
1800
|
// different: the fire really did die before writing anything.
|
|
1770
|
-
if (readLogFileSize(record.logPath) === null) {
|
|
1801
|
+
if (!record.adoptedWithoutMetadata && readLogFileSize(record.logPath) === null) {
|
|
1771
1802
|
logError(
|
|
1772
1803
|
`Cannot classify the end of task ${taskId}: log file ${record.logPath} is missing, ` +
|
|
1773
1804
|
`so the absence of an exit marker proves nothing`,
|
|
@@ -1775,7 +1806,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1775
1806
|
return;
|
|
1776
1807
|
}
|
|
1777
1808
|
|
|
1778
|
-
const rawTail =
|
|
1809
|
+
const rawTail = record.adoptedWithoutMetadata
|
|
1810
|
+
? ""
|
|
1811
|
+
: readLogTailRaw(record.logPath, { minOffset: record.logStartOffset || 0 });
|
|
1779
1812
|
// Search the file for the marker rather than hoping it is still inside
|
|
1780
1813
|
// the tail window — see `findFireExitCode` for why a shared log makes the
|
|
1781
1814
|
// tail unreliable. The tail is still what feeds the human-readable
|
|
@@ -1799,8 +1832,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1799
1832
|
// `latest_status_summary` with a word that says nothing. Naming *how* we
|
|
1800
1833
|
// found out keeps the event worth its row: it tells the next reader that
|
|
1801
1834
|
// fire never published its own status.
|
|
1802
|
-
const baseSummary =
|
|
1803
|
-
|
|
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
|
|
1804
1838
|
? "fire tmux session disappeared without an exit marker (killed or OOM)"
|
|
1805
1839
|
: exitCode === 0
|
|
1806
1840
|
? "completed; detected by the daemon after the tmux session ended (fire never reported it)"
|
|
@@ -1811,6 +1845,19 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1811
1845
|
// Ask the backend before overwriting anything: fire normally reports its
|
|
1812
1846
|
// own terminal status and we must not downgrade a success.
|
|
1813
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
|
+
}
|
|
1814
1861
|
if (backendStatus && TERMINAL_BACKEND_TASK_STATUSES.has(backendStatus)) {
|
|
1815
1862
|
log(
|
|
1816
1863
|
`Tmux session ${record.tmuxSession} for task ${taskId} ended; backend already ${backendStatus}, no report needed`,
|
|
@@ -1925,6 +1972,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1925
1972
|
record.stopForceKillTimer = null;
|
|
1926
1973
|
}
|
|
1927
1974
|
activeTaskProcesses.delete(taskId);
|
|
1975
|
+
forgetFireSessionRecord(record);
|
|
1928
1976
|
// Report *after* dropping the local entry so a slow backend round
|
|
1929
1977
|
// trip can't make a concurrent stop_task wait on us.
|
|
1930
1978
|
await reportDeadTmuxSessionStatus(taskId, record);
|
|
@@ -1956,22 +2004,27 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1956
2004
|
}
|
|
1957
2005
|
}
|
|
1958
2006
|
|
|
1959
|
-
// List every tmux session name visible to the current user.
|
|
1960
|
-
//
|
|
1961
|
-
//
|
|
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.
|
|
1962
2015
|
function listAllTmuxSessions() {
|
|
1963
2016
|
return new Promise((resolve) => {
|
|
1964
2017
|
let settled = false;
|
|
1965
2018
|
let stdout = "";
|
|
1966
2019
|
let timer = null;
|
|
1967
|
-
const settle = (lines) => {
|
|
2020
|
+
const settle = (lines, conclusive) => {
|
|
1968
2021
|
if (settled) return;
|
|
1969
2022
|
settled = true;
|
|
1970
2023
|
if (timer) {
|
|
1971
2024
|
clearTimeout(timer);
|
|
1972
2025
|
timer = null;
|
|
1973
2026
|
}
|
|
1974
|
-
resolve(lines);
|
|
2027
|
+
resolve({ sessions: lines, conclusive });
|
|
1975
2028
|
};
|
|
1976
2029
|
try {
|
|
1977
2030
|
const probe = spawnFn("tmux", ["list-sessions", "-F", "#{session_name}"], {
|
|
@@ -1989,14 +2042,16 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
1989
2042
|
.split("\n")
|
|
1990
2043
|
.map((s) => s.trim())
|
|
1991
2044
|
.filter(Boolean),
|
|
2045
|
+
true,
|
|
1992
2046
|
);
|
|
1993
2047
|
} else {
|
|
1994
|
-
// Non-zero exit
|
|
1995
|
-
//
|
|
1996
|
-
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);
|
|
1997
2051
|
}
|
|
1998
2052
|
});
|
|
1999
|
-
|
|
2053
|
+
// Could not run tmux at all — no information either way.
|
|
2054
|
+
probe.on("error", () => settle([], false));
|
|
2000
2055
|
timer = setTimeout(() => {
|
|
2001
2056
|
try {
|
|
2002
2057
|
if (typeof probe.kill === "function") {
|
|
@@ -2006,13 +2061,13 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2006
2061
|
// ignore; we already settled
|
|
2007
2062
|
}
|
|
2008
2063
|
logError(`tmux list-sessions probe timed out after ${TMUX_PROBE_TIMEOUT_MS}ms`);
|
|
2009
|
-
settle([]);
|
|
2064
|
+
settle([], false);
|
|
2010
2065
|
}, TMUX_PROBE_TIMEOUT_MS);
|
|
2011
2066
|
if (typeof timer.unref === "function") {
|
|
2012
2067
|
timer.unref();
|
|
2013
2068
|
}
|
|
2014
2069
|
} catch {
|
|
2015
|
-
settle([]);
|
|
2070
|
+
settle([], false);
|
|
2016
2071
|
}
|
|
2017
2072
|
});
|
|
2018
2073
|
}
|
|
@@ -2027,17 +2082,288 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
2027
2082
|
async function killTmuxSessionsForDeletedTask(taskId) {
|
|
2028
2083
|
if (!FIRE_TMUX_MODE_ACTIVE || !taskId) return 0;
|
|
2029
2084
|
const prefix = buildFireTmuxSessionPrefix(taskId);
|
|
2030
|
-
const sessions = await listAllTmuxSessions();
|
|
2085
|
+
const { sessions } = await listAllTmuxSessions();
|
|
2031
2086
|
let killed = 0;
|
|
2032
2087
|
for (const name of sessions) {
|
|
2033
2088
|
if (name.startsWith(prefix)) {
|
|
2034
2089
|
log(`Killing orphaned tmux session ${name} for deleted task ${taskId}`);
|
|
2035
2090
|
killFireTmuxSession(name);
|
|
2091
|
+
forgetFireSessionRecord({ tmuxSession: name });
|
|
2036
2092
|
killed += 1;
|
|
2037
2093
|
}
|
|
2038
2094
|
}
|
|
2039
2095
|
return killed;
|
|
2040
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
|
+
}
|
|
2041
2367
|
// -------------------------------------------------------------------------
|
|
2042
2368
|
|
|
2043
2369
|
function buildTaskWorktreeRoot(projectWorkspacePath, worktreeBranch) {
|
|
@@ -3338,6 +3664,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3338
3664
|
"refresh_session_inplace",
|
|
3339
3665
|
"task_attachments_v1",
|
|
3340
3666
|
CUSTOM_COMMANDS_CAPABILITY,
|
|
3667
|
+
UPDATE_DAEMON_CAPABILITY,
|
|
3341
3668
|
];
|
|
3342
3669
|
if (remoteExecEnabled) {
|
|
3343
3670
|
advertisedCapabilities.push(REMOTE_EXEC_CAPABILITY);
|
|
@@ -3371,8 +3698,43 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3371
3698
|
if (effectiveCapabilities.length > 0) {
|
|
3372
3699
|
extraHeaders["x-conductor-capabilities"] = effectiveCapabilities.join(",");
|
|
3373
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;
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3374
3717
|
const aiManagerHandlers = (deps.createAiManagerHandlers || createAiManagerHandlers)({ configPath: effectiveConfigPath });
|
|
3375
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
|
+
});
|
|
3376
3738
|
const remoteExecHandlers = remoteExecEnabled
|
|
3377
3739
|
? createRemoteExecHandlers({ defaultWorkspace: homeDir })
|
|
3378
3740
|
: null;
|
|
@@ -3501,6 +3863,16 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
3501
3863
|
log(`Daemon Name: ${AGENT_NAME}`);
|
|
3502
3864
|
log(`Supported Backends: ${SUPPORTED_BACKENDS.join(", ")}`);
|
|
3503
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
|
+
|
|
3504
3876
|
client.connect().catch((err) => {
|
|
3505
3877
|
logError(`Failed to connect: ${err}`);
|
|
3506
3878
|
});
|
|
@@ -4284,6 +4656,8 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4284
4656
|
|
|
4285
4657
|
async function recoverStaleTasks() {
|
|
4286
4658
|
try {
|
|
4659
|
+
// Never judge a task before startup adoption has had its say.
|
|
4660
|
+
await adoptOrphanedTmuxFiresPromise;
|
|
4287
4661
|
const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
|
|
4288
4662
|
method: "GET",
|
|
4289
4663
|
headers: {
|
|
@@ -4316,8 +4690,48 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4316
4690
|
return;
|
|
4317
4691
|
}
|
|
4318
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;
|
|
4319
4733
|
await Promise.all(
|
|
4320
|
-
|
|
4734
|
+
deadTasks.map(async (task) => {
|
|
4321
4735
|
const taskId = task?.id;
|
|
4322
4736
|
if (!taskId) return;
|
|
4323
4737
|
const patchResp = await fetchFn(`${BACKEND_HTTP}/api/tasks/${taskId}`, {
|
|
@@ -4332,12 +4746,13 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4332
4746
|
if (!patchResp.ok) {
|
|
4333
4747
|
logError(`Failed to mark stale task ${taskId} as killed: HTTP ${patchResp.status}`);
|
|
4334
4748
|
} else {
|
|
4749
|
+
killedCount += 1;
|
|
4335
4750
|
markBackendHttpSuccess();
|
|
4336
4751
|
}
|
|
4337
4752
|
}),
|
|
4338
4753
|
);
|
|
4339
4754
|
|
|
4340
|
-
log(`Recovered ${
|
|
4755
|
+
log(`Recovered ${killedCount}/${deadTasks.length} stale task(s) to killed`);
|
|
4341
4756
|
} catch (error) {
|
|
4342
4757
|
logError(`recoverStaleTasks error: ${error?.message || error}`);
|
|
4343
4758
|
}
|
|
@@ -4349,6 +4764,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4349
4764
|
|
|
4350
4765
|
async function reconcileAssignedTasks() {
|
|
4351
4766
|
try {
|
|
4767
|
+
await adoptOrphanedTmuxFiresPromise;
|
|
4352
4768
|
const response = await fetchFn(`${BACKEND_HTTP}/api/tasks`, {
|
|
4353
4769
|
method: "GET",
|
|
4354
4770
|
headers: {
|
|
@@ -4383,10 +4799,36 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4383
4799
|
});
|
|
4384
4800
|
|
|
4385
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
|
+
}
|
|
4386
4822
|
for (const task of assigned) {
|
|
4387
4823
|
const taskId = String(task?.id || "");
|
|
4388
4824
|
if (!taskId) continue;
|
|
4389
|
-
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 || [])) {
|
|
4390
4832
|
continue;
|
|
4391
4833
|
}
|
|
4392
4834
|
const patchResp = await fetchFn(`${BACKEND_HTTP}/api/tasks/${taskId}`, {
|
|
@@ -4422,7 +4864,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
4422
4864
|
// half-close). Fires raise their own ws to backend independently — this is
|
|
4423
4865
|
// the daemon's *own* belief about which tasks it is hosting. The backend
|
|
4424
4866
|
// arbitrates between this push and the persisted task state.
|
|
4425
|
-
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;
|
|
4426
4873
|
const aliveTaskIds = getActiveTaskIds();
|
|
4427
4874
|
if (aliveTaskIds.length === 0) {
|
|
4428
4875
|
return Promise.resolve();
|
|
@@ -5795,6 +6242,14 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
5795
6242
|
logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
|
|
5796
6243
|
});
|
|
5797
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
|
+
}
|
|
5798
6253
|
}
|
|
5799
6254
|
|
|
5800
6255
|
function markWatchdogHealthy(signal, at = Date.now()) {
|
|
@@ -6162,6 +6617,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6162
6617
|
}
|
|
6163
6618
|
|
|
6164
6619
|
activeTaskProcesses.delete(taskId);
|
|
6620
|
+
forgetFireSessionRecord(processRecord);
|
|
6165
6621
|
return true;
|
|
6166
6622
|
}
|
|
6167
6623
|
|
|
@@ -6236,6 +6692,15 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6236
6692
|
return Boolean(record?.forceDaemonTerminalStatusReport) || !Boolean(record?.managedByFireBridge);
|
|
6237
6693
|
}
|
|
6238
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
|
+
|
|
6239
6704
|
// RFC 0029: confirm that a task the backend believes is dead is actually
|
|
6240
6705
|
// still hosting a live fire on this daemon. The check is intentionally
|
|
6241
6706
|
// conservative:
|
|
@@ -6334,6 +6799,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6334
6799
|
entry.stopForceKillTimer = null;
|
|
6335
6800
|
}
|
|
6336
6801
|
activeTaskProcesses.delete(taskId);
|
|
6802
|
+
forgetFireSessionRecord(entry);
|
|
6337
6803
|
}
|
|
6338
6804
|
entry = undefined;
|
|
6339
6805
|
}
|
|
@@ -6391,7 +6857,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6391
6857
|
|
|
6392
6858
|
const processRecord = activeTaskProcesses.get(taskId);
|
|
6393
6859
|
const ptyRecord = activePtySessions.get(taskId);
|
|
6394
|
-
|
|
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) {
|
|
6395
6866
|
log(`Stop requested for task ${taskId}, but no active process found`);
|
|
6396
6867
|
sendStopAck(false);
|
|
6397
6868
|
// "Nothing to stop" IS the terminal answer, and the server cannot infer
|
|
@@ -6769,9 +7240,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6769
7240
|
}
|
|
6770
7241
|
|
|
6771
7242
|
const existingTaskRecord = activeTaskProcesses.get(taskId);
|
|
6772
|
-
if (existingTaskRecord
|
|
7243
|
+
if (recordHasLiveFire(existingTaskRecord) || pendingTaskStarts.has(taskId)) {
|
|
6773
7244
|
log(
|
|
6774
|
-
`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"}`})`,
|
|
6775
7249
|
);
|
|
6776
7250
|
sendAgentCommandAck({
|
|
6777
7251
|
requestId,
|
|
@@ -6975,7 +7449,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6975
7449
|
const outputCapture = createChildOutputCapture({ logPath, logStartOffset });
|
|
6976
7450
|
const spawnedAtMs = Date.now();
|
|
6977
7451
|
|
|
6978
|
-
|
|
7452
|
+
const activeProcessRecord = {
|
|
6979
7453
|
child,
|
|
6980
7454
|
projectId,
|
|
6981
7455
|
logPath,
|
|
@@ -6993,7 +7467,12 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
6993
7467
|
managedByFireBridge: true,
|
|
6994
7468
|
tmuxSession: tmuxSession || null,
|
|
6995
7469
|
tmuxMode: Boolean(tmuxSession),
|
|
6996
|
-
}
|
|
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);
|
|
6997
7476
|
|
|
6998
7477
|
client
|
|
6999
7478
|
.sendJson({
|
|
@@ -7099,6 +7578,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7099
7578
|
clearTimeout(active.stopForceKillTimer);
|
|
7100
7579
|
}
|
|
7101
7580
|
activeTaskProcesses.delete(taskId);
|
|
7581
|
+
forgetFireSessionRecord(active);
|
|
7102
7582
|
const suppressExitStatusReport = suppressedExitStatusReports.has(taskId);
|
|
7103
7583
|
suppressedExitStatusReports.delete(taskId);
|
|
7104
7584
|
if (logStream) {
|
|
@@ -7289,12 +7769,17 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7289
7769
|
activeTarget.stopForceKillTimer = null;
|
|
7290
7770
|
}
|
|
7291
7771
|
activeTaskProcesses.delete(normalizedTargetTaskId);
|
|
7772
|
+
forgetFireSessionRecord(activeTarget);
|
|
7292
7773
|
activeTarget = undefined;
|
|
7293
7774
|
}
|
|
7294
7775
|
}
|
|
7295
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
|
+
|
|
7296
7781
|
if (isRefreshSessionInplace) {
|
|
7297
|
-
if (!
|
|
7782
|
+
if (!targetHasLiveFire) {
|
|
7298
7783
|
reportRestartFailure({
|
|
7299
7784
|
taskId: normalizedTargetTaskId,
|
|
7300
7785
|
projectId: normalizedProjectId,
|
|
@@ -7305,7 +7790,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7305
7790
|
});
|
|
7306
7791
|
return;
|
|
7307
7792
|
}
|
|
7308
|
-
} else if (
|
|
7793
|
+
} else if (targetHasLiveFire) {
|
|
7309
7794
|
// tmux-mode failures point at the tmux session, not a meaningless pid.
|
|
7310
7795
|
const description = activeTarget.tmuxMode
|
|
7311
7796
|
? `task already active in tmux session ${activeTarget.tmuxSession || "(unknown)"} — stop the task before restarting`
|
|
@@ -7690,6 +8175,9 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7690
8175
|
tmuxMode: Boolean(tmuxSession),
|
|
7691
8176
|
};
|
|
7692
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);
|
|
7693
8181
|
|
|
7694
8182
|
// In tmux mode the Fire process writes directly to logPath via shell
|
|
7695
8183
|
// redirection inside the tmux session, so the daemon does not pipe
|
|
@@ -7780,6 +8268,7 @@ export function startDaemon(config = {}, deps = {}) {
|
|
|
7780
8268
|
clearTimeout(active.stopForceKillTimer);
|
|
7781
8269
|
}
|
|
7782
8270
|
activeTaskProcesses.delete(normalizedTargetTaskId);
|
|
8271
|
+
forgetFireSessionRecord(active);
|
|
7783
8272
|
const suppressExitStatusReport = suppressedExitStatusReports.has(normalizedTargetTaskId);
|
|
7784
8273
|
suppressedExitStatusReports.delete(normalizedTargetTaskId);
|
|
7785
8274
|
if (logStream) {
|