@awebai/oats 0.40.2 → 0.41.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/bin/oats.mjs +39 -12
- package/docs/capabilities.md +9 -1
- package/docs/desktop-cli-api.md +58 -16
- package/docs/desktop.md +5 -4
- package/docs/execution-targets.md +312 -9
- package/docs/implementation.md +37 -6
- package/docs/release-notes/v0.41.0.md +238 -0
- package/docs/release-notes/v0.41.1.md +7 -0
- package/docs/servers.md +4 -1
- package/docs/souls-and-instances.md +32 -6
- package/lib/core.mjs +552 -165
- package/lib/instance-git.mjs +113 -4
- package/lib/instance-lifecycle.mjs +3 -2
- package/lib/packages.mjs +1 -1
- package/lib/resolve.mjs +1 -1
- package/lib/servers.mjs +16 -8
- package/package.json +1 -1
package/lib/core.mjs
CHANGED
|
@@ -68,6 +68,7 @@ export { fingerprintTree };
|
|
|
68
68
|
import { canonicalJson, lineAt, parseStrictJson } from "./canonical-json.mjs";
|
|
69
69
|
import { readPortableBytes } from "./bounded-read.mjs";
|
|
70
70
|
import { copyTreeSafe } from "./tree-copy.mjs";
|
|
71
|
+
import { assertSameWorktreeHead, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
|
|
71
72
|
/** The package and capability id grammar (namespaced, lowercase): an id names a
|
|
72
73
|
* directory (a home's module copy), so no path spelling fits it. */
|
|
73
74
|
const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
@@ -206,6 +207,11 @@ function shIn(cwd, cmdline, timeout = 45000) {
|
|
|
206
207
|
}
|
|
207
208
|
function shInTry(cwd, cmdline, timeout) { try { return shIn(cwd, cmdline, timeout); } catch { return undefined; } }
|
|
208
209
|
export function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
|
|
210
|
+
/** One word of a command line that a person or an agent pastes into a shell: as it is when it holds
|
|
211
|
+
* only letters, digits and `_./:-`, single-quoted otherwise (an empty string too). The safe set is
|
|
212
|
+
* deliberately small: no `~` (an unquoted leading one expands), no `=` (zsh expands a leading
|
|
213
|
+
* `=word`). */
|
|
214
|
+
export function shellWord(s) { const word = String(s); return /^[A-Za-z0-9_.\/:-]+$/.test(word) ? word : shq(word); }
|
|
209
215
|
export function slug(s) {
|
|
210
216
|
const r = String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
211
217
|
return r || "agent";
|
|
@@ -1898,20 +1904,340 @@ export function explicitInstanceName(name) {
|
|
|
1898
1904
|
}
|
|
1899
1905
|
|
|
1900
1906
|
function tmuxAlive(session) { return !!shTry(`tmux has-session -t ${shq(session)} 2>/dev/null && echo yes`); }
|
|
1901
|
-
|
|
1902
|
-
|
|
1907
|
+
/** The window names of `session` on the user's DEFAULT tmux server: only for a home that records no
|
|
1908
|
+
* socket (listInstances). OATS creates nothing there: new sessions live on the OATS server below. */
|
|
1909
|
+
export function tmuxWindows(session = DEFAULT_TMUX_SESSION) {
|
|
1910
|
+
if (!tmuxAlive(session)) return [];
|
|
1911
|
+
return (shTry(`tmux list-windows -t ${shq(session)} -F '#{window_name}'`) || "").split("\n").filter(Boolean);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
// ---------- the OATS tmux server ----------
|
|
1915
|
+
/** The tmux server OATS creates its sessions on, selected by name (`tmux -L oats`): one per host user,
|
|
1916
|
+
* in tmux's own per-user socket directory, with the user's tmux configuration loaded (no `-f`). A
|
|
1917
|
+
* tool that restyles the default server does not reach it. Not configurable. The name selects the
|
|
1918
|
+
* server only while a session is ensured (ensureOatsTmuxSession); every later step, and every record,
|
|
1919
|
+
* uses the absolute socket that step returned, through tmuxOn. docs/execution-targets.md owns the rule. */
|
|
1920
|
+
const OATS_TMUX_SERVER = "oats";
|
|
1921
|
+
/** What OATS sets, always at WINDOW scope and by window id, on a window it created: nothing is ever
|
|
1922
|
+
* set server-global or on a session, on any server. Sizing goes on every window OATS creates (the
|
|
1923
|
+
* session's `hq` window and each agent window): viewers attach at their own size and depend on both
|
|
1924
|
+
* options, and a viewer links the window itself, so the window's options travel with it. A sizing
|
|
1925
|
+
* command that tmux refuses is ignored, as it always was (tmux 3.0 has no `window-size latest`).
|
|
1926
|
+
* Colours go on the agent window only: it shows the viewer's colours whatever the server's global
|
|
1927
|
+
* styles are, and a refused colour command fails the launch. */
|
|
1928
|
+
const OATS_WINDOW_SIZING = [["window-size", "latest"], ["aggressive-resize", "on"]];
|
|
1929
|
+
const OATS_WINDOW_COLOURS = [["window-style", "default"], ["window-active-style", "default"]];
|
|
1930
|
+
function tmuxOatsServer(args, io, options = {}) {
|
|
1931
|
+
return (io?.exec || execFileSync)("tmux", ["-u", "-L", OATS_TMUX_SERVER, ...args], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], ...options });
|
|
1932
|
+
}
|
|
1933
|
+
const tmuxFailure = (e, fallback) => String(e?.stderr ?? e?.message ?? "").trim() || fallback;
|
|
1934
|
+
/** An error OATS raised itself (a root guard under an injected exec), never one a tolerant read may absorb. */
|
|
1935
|
+
const oatsCoded = (e) => typeof e?.code === "string" && e.code.startsWith("E_");
|
|
1936
|
+
/** A failed call that ran to its own exit: it has a status, no signal ended it, and Node reports no
|
|
1937
|
+
* failure of its own. A timeout and an output overflow carry a code and keep what the call had
|
|
1938
|
+
* printed until then, so that text is not the call's answer. Anything else is not a completed call. */
|
|
1939
|
+
const exitedByItself = (e) => Number.isInteger(e?.status) && e.signal == null && e.code == null;
|
|
1940
|
+
/** What the OATS server holds, as `list-sessions` answers: whether it runs, its socket (any session
|
|
1941
|
+
* names it), and whether a session named exactly `session` is there. No server and no such session
|
|
1942
|
+
* are the only answers that mean absent, and "no server" is read only from a call that exited by
|
|
1943
|
+
* itself: a line a cut-off call left behind is not tmux's answer. Any other failure is not absence. */
|
|
1944
|
+
function oatsTmuxSessionSocket(session, io) {
|
|
1945
|
+
let listed;
|
|
1946
|
+
try { listed = tmuxOatsServer(["list-sessions", "-F", "#{session_name}\t#{socket_path}"], io); }
|
|
1947
|
+
catch (e) {
|
|
1948
|
+
if (oatsCoded(e)) throw e;
|
|
1949
|
+
// A tmux that could not be run at all is the one failure a person can act on: say so.
|
|
1950
|
+
if (e?.code === "ENOENT") throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `could not read the OATS tmux server for session ${session}: tmux was not found through this process's PATH. Run this command with a PATH that holds tmux`);
|
|
1951
|
+
if (!exitedByItself(e)) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `could not read the OATS tmux server for session ${session}: tmux list-sessions did not exit by itself (${typeof e?.code === "string" ? e.code : e?.signal || "no exit status"})`);
|
|
1952
|
+
if (tmuxServerLost(e)) return { server: null, present: false };
|
|
1953
|
+
throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `could not read the OATS tmux server for session ${session}: ${tmuxFailure(e, "tmux list-sessions failed")}`);
|
|
1954
|
+
}
|
|
1955
|
+
let server = null, present = false;
|
|
1956
|
+
for (const line of listed.split("\n")) {
|
|
1957
|
+
const tab = line.indexOf("\t");
|
|
1958
|
+
if (tab <= 0) continue;
|
|
1959
|
+
server ??= line.slice(tab + 1).trim() || null;
|
|
1960
|
+
if (line.slice(0, tab) === session) { server = line.slice(tab + 1).trim() || null; present = true; break; }
|
|
1961
|
+
}
|
|
1962
|
+
return { server, present };
|
|
1963
|
+
}
|
|
1964
|
+
/** The names that mark a process as an OATS instance: the launch identity, and what older kernels
|
|
1965
|
+
* called it. One of them in the environment, even empty, is evidence of an instance. */
|
|
1966
|
+
const INSTANCE_IDENTITY_ENV = ["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"];
|
|
1967
|
+
/** Every name the kernel itself generates into a launch or a hook environment, and the three that
|
|
1968
|
+
* tie a process to its own terminal. None of them is part of an environment OATS starts a tmux
|
|
1969
|
+
* server, a session or a window with. Not the whole OATS_ prefix: operators export configuration
|
|
1970
|
+
* under it (OATS_HOME_DIR, OATS_TMUX_SESSION). The inventory: RESERVED_LAUNCH_ENV; what
|
|
1971
|
+
* runLifecycleHooks adds for a hook (capability, layer, level, meta, the spawn and launch facts in
|
|
1972
|
+
* its extraEnv, the team and workspace names of teamEnv); what operator dispatch, retire and a
|
|
1973
|
+
* trigger add. Launch references (OATS_LAUNCH_REF_<NAME>, and the NAME each stands for) are
|
|
1974
|
+
* removed by prefix in withoutKernelEnvironment. No harness's name is here. */
|
|
1975
|
+
const KERNEL_ENV_NAMES = new Set([...RESERVED_LAUNCH_ENV, "COLORFGBG", "TMUX", "TMUX_PANE",
|
|
1976
|
+
"OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_OPERATION",
|
|
1977
|
+
"OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK", "OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME",
|
|
1978
|
+
"OATS_LAUNCH_PREVIEW", "OATS_RETIRE_INTENT", "OATS_TRIGGER_EVENT_FILE",
|
|
1979
|
+
"OATS_TEAM_NAME", "OATS_TEAM_SCOPE", "OATS_TEAM_ID", "OATS_TEAM_LABEL", "OATS_TEAM_LABELS", "OATS_TEAMS", "OATS_TEAMS_SOURCE",
|
|
1980
|
+
"OATS_DEFAULT_TEAM", "OATS_DEFAULT_TEAM_ID", "OATS_DEFAULT_TEAM_FROM", "OATS_WORKSPACE_NAME", "OATS_WORKSPACE_KEY"]);
|
|
1981
|
+
function withoutKernelEnvironment(source) {
|
|
1982
|
+
const env = { ...source };
|
|
1983
|
+
for (const name of Object.keys(env)) {
|
|
1984
|
+
if (name.startsWith(LAUNCH_REF_PREFIX)) { delete env[name]; delete env[name.slice(LAUNCH_REF_PREFIX.length)]; }
|
|
1985
|
+
}
|
|
1986
|
+
for (const name of KERNEL_ENV_NAMES) delete env[name];
|
|
1987
|
+
return env;
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* What `tmux show-environment -g -s` printed, as bytes, read strictly and never executed: the whole
|
|
1991
|
+
* text or nothing. tmux (cmd-show-environment.c) prints one entry per variable, each ending in a
|
|
1992
|
+
* line feed: `NAME="VALUE"; export NAME;` with `"`, `$`, a backtick and `\` in VALUE each preceded
|
|
1993
|
+
* by a backslash, or `unset NAME;` for a removed one. VALUE is read to its closing unescaped quote
|
|
1994
|
+
* (a backslash escapes the character after it), so a line feed inside a value stays inside it.
|
|
1995
|
+
*
|
|
1996
|
+
* Not every value can be known the same way on every tmux. tmux 3.4 and 3.5 write each printed
|
|
1997
|
+
* line through vis(3) (server-client.c server_client_print): a control character or a byte that is
|
|
1998
|
+
* not UTF-8 arrives as `\a`, `\b`, `\f`, `\r`, `\v` or three octal digits, and tmux 3.4 puts one
|
|
1999
|
+
* more backslash before a `$` that a letter, `_` or `{` follows (utf8.c utf8_strvis). Neither
|
|
2000
|
+
* changes where a value ends. So nothing is decoded: a variable is left out whole when its VALUE
|
|
2001
|
+
* holds a line feed, a `$` (escaped or bare, on every version), or a vis-encoded sequence. A
|
|
2002
|
+
* backtick, a quote and a backslash are written the same by every version read, and are carried.
|
|
2003
|
+
*
|
|
2004
|
+
* Kept: an entry whose NAME is a plain identifier and whose VALUE is none of the above; an `unset`
|
|
2005
|
+
* entry carries nothing. An entry is left out only once it is framed whole (its name, its value to
|
|
2006
|
+
* the closing quote, the exact `; export NAME;` tail); its name is added to `omitted` when the
|
|
2007
|
+
* caller passes one. null for bytes that are not valid UTF-8 or that this grammar does not consume
|
|
2008
|
+
* to the end: a caller refuses, it never uses a partial result.
|
|
2009
|
+
*/
|
|
2010
|
+
export function parseTmuxShellEnvironment(bytes, omitted) {
|
|
2011
|
+
let text;
|
|
2012
|
+
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return null; }
|
|
2013
|
+
const env = {};
|
|
2014
|
+
let i = 0;
|
|
2015
|
+
const take = (word) => { if (!text.startsWith(word, i)) return false; i += word.length; return true; };
|
|
2016
|
+
while (i < text.length) {
|
|
2017
|
+
if (take("unset ")) {
|
|
2018
|
+
const end = text.indexOf(";", i);
|
|
2019
|
+
if (end <= i || /[\s"=]/.test(text.slice(i, end))) return null;
|
|
2020
|
+
i = end + 1;
|
|
2021
|
+
} else {
|
|
2022
|
+
const assign = text.indexOf("=\"", i);
|
|
2023
|
+
if (assign <= i) return null;
|
|
2024
|
+
const name = text.slice(i, assign);
|
|
2025
|
+
i = assign + 2;
|
|
2026
|
+
let value = "", closed = false, carried = true;
|
|
2027
|
+
while (i < text.length) {
|
|
2028
|
+
const c = text[i++];
|
|
2029
|
+
if (c === "\"") { closed = true; break; }
|
|
2030
|
+
if (c === "$") { carried = false; continue; }
|
|
2031
|
+
if (c === "`") return null; // tmux never writes a bare backtick
|
|
2032
|
+
if (c !== "\\") { value += c; continue; }
|
|
2033
|
+
const next = text[i];
|
|
2034
|
+
if (next === "$") { carried = false; i += 1; }
|
|
2035
|
+
else if (next !== undefined && "\"`\\".includes(next)) { value += next; i += 1; }
|
|
2036
|
+
else if (/^(?:[abfrv]|[0-7]{3})/.test(text.slice(i, i + 3))) { carried = false; i += /[0-7]/.test(next) ? 3 : 1; }
|
|
2037
|
+
else return null;
|
|
2038
|
+
}
|
|
2039
|
+
if (!closed || !take(`; export ${name};`)) return null;
|
|
2040
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
2041
|
+
if (carried && !value.includes("\n")) env[name] = value; else omitted?.push(name);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
if (!take("\n")) return null;
|
|
2045
|
+
}
|
|
2046
|
+
return env;
|
|
2047
|
+
}
|
|
2048
|
+
/** Whether this process runs inside an OATS instance, as the CLI resolves an in-home command:
|
|
2049
|
+
* `{ home }` for the home OATS_INSTANCE_HOME names when it is one, else the instance home enclosing
|
|
2050
|
+
* the working directory (a harness may strip the session environment). `{ evidence }` when neither
|
|
2051
|
+
* gives a home and yet the environment carries an instance identity name: an instance's environment
|
|
2052
|
+
* without an identified home is not "outside an instance". undefined otherwise. */
|
|
2053
|
+
function callerInstance() {
|
|
2054
|
+
const named = process.env.OATS_INSTANCE_HOME;
|
|
2055
|
+
if (named && isAbsolute(named)) {
|
|
2056
|
+
try { if (isPlainObject(JSON.parse(readFileSync(join(named, "instance.json"), "utf8")))) return { home: named }; }
|
|
2057
|
+
catch { /* names no home */ }
|
|
2058
|
+
}
|
|
2059
|
+
const enclosing = enclosingInstanceHome(logicalCwd());
|
|
2060
|
+
if (enclosing) return { home: enclosing };
|
|
2061
|
+
const evidence = INSTANCE_IDENTITY_ENV.find((name) => process.env[name] !== undefined);
|
|
2062
|
+
return evidence ? { evidence } : undefined;
|
|
2063
|
+
}
|
|
2064
|
+
/** `tmux` as this process's own PATH finds it, always absolute: the creator chooses the tmux that
|
|
2065
|
+
* runs, whatever environment the server it starts is given. A bare name is never run: an exec
|
|
2066
|
+
* looks one up in the PATH of the environment it is passed, which here is the selected one.
|
|
2067
|
+
* Entries in PATH order; a relative entry, and an empty one (an empty PATH is one empty entry),
|
|
2068
|
+
* are this process's working directory, as an exec of `tmux` reads them. With no PATH, or none
|
|
2069
|
+
* that holds a tmux, the creation of `session` is refused, for every creator: no default search
|
|
2070
|
+
* path is assumed. Of the two, only "no PATH" is met in a run: the lookup before this runs the
|
|
2071
|
+
* bare name with this process's own environment, so a PATH that holds no tmux is refused there
|
|
2072
|
+
* first (oatsTmuxSessionSocket), session or no session. The no-match refusal here is the guard
|
|
2073
|
+
* that the creation never runs a bare name, whatever the lookup did. */
|
|
2074
|
+
function creatorTmux(session) {
|
|
2075
|
+
const refuse = (why) => oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot create tmux session ${session} on the OATS tmux server: ${why}, and the tmux that creates a session is the one this process's own PATH finds; the session was not created. Run this command with a PATH that holds tmux`);
|
|
2076
|
+
if (process.env.PATH === undefined) throw refuse("PATH is not set in this process");
|
|
2077
|
+
for (const dir of process.env.PATH.split(":")) {
|
|
2078
|
+
const candidate = resolve(dir || ".", "tmux");
|
|
2079
|
+
try { accessSync(candidate, fsConstants.X_OK); if (statSync(candidate).isFile()) return candidate; } catch { /* not here */ }
|
|
2080
|
+
}
|
|
2081
|
+
throw refuse("no tmux was found on this process's PATH");
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* The environment a `new-session` on the OATS server runs with: the only tmux call that can start
|
|
2085
|
+
* a server, so every one of them gets this, whether or not a server was seen a moment earlier. A
|
|
2086
|
+
* tmux server keeps the environment of the client that started it as its global environment, which
|
|
2087
|
+
* every pane created on it later inherits, and a new session takes the client's variables that
|
|
2088
|
+
* `update-environment` names.
|
|
2089
|
+
*
|
|
2090
|
+
* A process inside an OATS instance never passes its own environment (its harness's variables, its
|
|
2091
|
+
* credentials, its identity). It passes the global environment of the tmux server its home
|
|
2092
|
+
* records: an existing baseline, chosen because a window that instance opened on that server got
|
|
2093
|
+
* exactly it; not proof that it holds nothing old. The home's receipt is checked against
|
|
2094
|
+
* instance.json first, as every session verb checks its endpoint, and the text is read with the
|
|
2095
|
+
* strict reader above. When any of that fails, or no home is identified, the session is not
|
|
2096
|
+
* created: no fallback to the caller's environment, to another server or to a built-in list. Any
|
|
2097
|
+
* other creator (an operator's shell, a schedule runner, the Desktop) passes its own environment.
|
|
2098
|
+
* In every case the kernel's names are removed from the final set.
|
|
2099
|
+
*
|
|
2100
|
+
* Environment and endpoint are chosen separately: which server is reached depends only on the
|
|
2101
|
+
* creator (its TMUX_TMPDIR, kept or absent as the creator has it; its tmux); what the server
|
|
2102
|
+
* process gets (HOME and so its configuration file, PATH, everything else) comes from the selected
|
|
2103
|
+
* environment, with no ambient value filling in. Values travel only as the environment of that
|
|
2104
|
+
* tmux process: never in an argument, a message, an event or a file. docs/execution-targets.md
|
|
2105
|
+
* owns the rule.
|
|
2106
|
+
*/
|
|
2107
|
+
const TMUX_SERVER_START_ENV = ["HOME", "XDG_CONFIG_HOME", "PATH", "SHELL"];
|
|
2108
|
+
function oatsSessionEnvironment(session, io) {
|
|
2109
|
+
const caller = callerInstance();
|
|
2110
|
+
if (!caller) return withoutKernelEnvironment(process.env);
|
|
2111
|
+
const refuse = (why) => oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot create tmux session ${session} on the OATS tmux server from inside an OATS instance: ${why}. A tmux server and its sessions keep the environment of the process that creates them, and an instance's own environment must not become theirs; the session was not created. Create it from your own shell, outside every instance home (tmux -L ${OATS_TMUX_SERVER} new-session -d -s ${shellWord(session)} -n hq), or run this command there. tmux resolves -L ${OATS_TMUX_SERVER} with TMUX_TMPDIR: use the same TMUX_TMPDIR as this process, if it has one`);
|
|
2112
|
+
if (!caller.home) throw refuse(`this process carries an instance's identity (${caller.evidence}) and neither OATS_INSTANCE_HOME nor the working directory names its home`);
|
|
2113
|
+
let target;
|
|
2114
|
+
try { target = instanceSessionTarget(caller.home).target; }
|
|
2115
|
+
catch (e) { if (!oatsCoded(e)) throw e; throw refuse(`the session receipt of ${basename(caller.home)} could not be used (${e.code})`); }
|
|
2116
|
+
if (!target?.socket) throw refuse(`the home of ${basename(caller.home)} records no tmux server`);
|
|
2117
|
+
// Bytes, not text; and nothing of a failed read (tmux's output, its error) goes into the refusal.
|
|
2118
|
+
let recorded = null;
|
|
2119
|
+
const omitted = [];
|
|
1903
2120
|
try {
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
2121
|
+
const printed = (io?.exec || execFileSync)("tmux", ["-u", "-S", target.socket, "show-environment", "-g", "-s"], { timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
2122
|
+
recorded = parseTmuxShellEnvironment(Buffer.isBuffer(printed) ? printed : Buffer.from(String(printed)), omitted);
|
|
2123
|
+
} catch (e) { if (oatsCoded(e)) throw e; }
|
|
2124
|
+
if (recorded === null) throw refuse(`the environment of the tmux server that ${basename(caller.home)} is recorded on (${target.socket}) could not be read`);
|
|
2125
|
+
// A variable the reader left out is never filled in from this process, and a server is never
|
|
2126
|
+
// started when the one left out decides which configuration it loads or which programs it runs.
|
|
2127
|
+
// (A recorded server that simply has none of them is copied as it is.)
|
|
2128
|
+
const undecided = TMUX_SERVER_START_ENV.find((name) => omitted.includes(name));
|
|
2129
|
+
if (undecided) throw refuse(`the tmux server that ${basename(caller.home)} is recorded on has a ${undecided} that cannot be carried over (its value holds a $, a line break or a character tmux prints encoded), and a tmux server's configuration and programs are chosen by it`);
|
|
2130
|
+
const env = withoutKernelEnvironment(recorded);
|
|
2131
|
+
if (process.env.TMUX_TMPDIR === undefined) delete env.TMUX_TMPDIR; else env.TMUX_TMPDIR = process.env.TMUX_TMPDIR;
|
|
2132
|
+
return env;
|
|
2133
|
+
}
|
|
2134
|
+
/** The environment a `new-window` client runs with, and the `respawn-pane` client of a restart in
|
|
2135
|
+
* place. tmux gives a pane the PATH of the client that creates its window or respawns it, and
|
|
2136
|
+
* nothing else of that client (spawn.c). A process inside an instance
|
|
2137
|
+
* passes PATH, without any home's kernel shim directory (the launch command puts the new home's
|
|
2138
|
+
* own first), and the three names tmux's client reads its locale from: it does not start without
|
|
2139
|
+
* a UTF-8 locale, and on a host that has neither en_US.UTF-8 nor C.UTF-8 only these name one.
|
|
2140
|
+
* Nothing else of the instance travels by any route. When it has no PATH to give (none is set, or
|
|
2141
|
+
* only shim directories were in it) no PATH is passed: an empty one would be one empty entry, the
|
|
2142
|
+
* working directory, for the lookup of `tmux` and for the pane. A PATH that is set and empty is
|
|
2143
|
+
* passed as it is. Any other creator passes its environment without the kernel's names. */
|
|
2144
|
+
const TMUX_CLIENT_LOCALE_ENV = ["LANG", "LC_ALL", "LC_CTYPE"];
|
|
2145
|
+
function oatsWindowEnvironment() {
|
|
2146
|
+
if (!callerInstance()) return withoutKernelEnvironment(process.env);
|
|
2147
|
+
const env = {};
|
|
2148
|
+
const dirs = process.env.PATH === undefined ? [] : process.env.PATH.split(":").filter((dir) => !/(?:^|\/)instances\/[^/]+\/\.oats\/bin\/?$/.test(dir));
|
|
2149
|
+
if (dirs.length) env.PATH = dirs.join(":");
|
|
2150
|
+
for (const name of TMUX_CLIENT_LOCALE_ENV) if (process.env[name] !== undefined) env[name] = process.env[name];
|
|
2151
|
+
return env;
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* An optimisation for a spawn or a start, to call BEFORE it creates, stops or writes anything: when
|
|
2155
|
+
* this process is inside an instance and `session` is not on the OATS server, the environment its
|
|
2156
|
+
* creation needs is read now, so that a caller that cannot create it is refused with nothing left
|
|
2157
|
+
* behind; the answer goes to ensureOatsTmuxSession. undefined when there is nothing to read (the
|
|
2158
|
+
* session exists, or the caller is no instance: no tmux is run for it). Not the guarantee:
|
|
2159
|
+
* ensureOatsTmuxSession decides by what it finds when it creates.
|
|
2160
|
+
*/
|
|
2161
|
+
function planOatsTmuxSession(session, io) {
|
|
2162
|
+
if (!callerInstance() || oatsTmuxSessionSocket(session, io).present) return undefined;
|
|
2163
|
+
return { env: oatsSessionEnvironment(session, io) };
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* Make sure `session` exists on the OATS server and return that server's absolute socket: the one
|
|
2167
|
+
* endpoint the caller then creates its window on, records and compensates on. `hq` is the directory
|
|
2168
|
+
* of the session's first window; `io.exec` replaces execFileSync (session start guards its roots there).
|
|
2169
|
+
* This `new-session`, the one that creates an agents' session, runs with oatsSessionEnvironment
|
|
2170
|
+
* (`plan`, when planOatsTmuxSession read it earlier), by the creator's own tmux, on the socket the
|
|
2171
|
+
* lookup named when the server runs. (The viewer's temporary session, lib/session-viewer.mjs, is
|
|
2172
|
+
* created with the attaching process's environment: awebai/oats#623.)
|
|
2173
|
+
* Two creators racing both succeed on one socket: the loser's `duplicate session` is answered by a
|
|
2174
|
+
* second lookup. tmux starts a server once: the first successful creator determines its initial
|
|
2175
|
+
* environment, and nothing here changes a running server's. E_RUNTIME_ENDPOINT_UNKNOWN when the
|
|
2176
|
+
* server cannot be read or names no socket, when this caller may not create the session, or when
|
|
2177
|
+
* its own PATH names no tmux to create it with (creatorTmux).
|
|
2178
|
+
*/
|
|
2179
|
+
export function ensureOatsTmuxSession(session, hq, io, plan) {
|
|
2180
|
+
let { server: socket, present } = oatsTmuxSessionSocket(session, io);
|
|
2181
|
+
if (!present) {
|
|
2182
|
+
// The executable is chosen here, where the creation is about to run, and nowhere earlier: a
|
|
2183
|
+
// session that exists needs none, so nothing is refused for it.
|
|
2184
|
+
const tmux = creatorTmux(session);
|
|
2185
|
+
const env = plan?.env ?? oatsSessionEnvironment(session, io);
|
|
2186
|
+
const address = socket ? ["-S", socket] : ["-L", OATS_TMUX_SERVER];
|
|
2187
|
+
let created;
|
|
2188
|
+
try { created = (io?.exec || execFileSync)(tmux, ["-u", ...address, "new-session", "-d", "-s", session, "-n", "hq", "-c", hq, "-P", "-F", "#{socket_path}\t#{window_id}"], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], env }).trim(); }
|
|
2189
|
+
catch (e) {
|
|
2190
|
+
if (oatsCoded(e)) throw e;
|
|
2191
|
+
const again = oatsTmuxSessionSocket(session, io);
|
|
2192
|
+
if (!again.present) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `could not create tmux session ${session} on the OATS tmux server: ${tmuxFailure(e, "tmux new-session failed")}`);
|
|
2193
|
+
socket = again.server;
|
|
2194
|
+
}
|
|
2195
|
+
if (created !== undefined) {
|
|
2196
|
+
const [path, hqWindow] = created.split("\t");
|
|
2197
|
+
if (!path) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `tmux returned no endpoint for session ${session}`);
|
|
2198
|
+
socket = path;
|
|
2199
|
+
// The session's first window, which this call created: sized like every window OATS creates.
|
|
2200
|
+
// A dead server shows in the caller's next step on this socket.
|
|
2201
|
+
sizeWindow(socket, hqWindow, io);
|
|
2202
|
+
}
|
|
1907
2203
|
}
|
|
1908
2204
|
if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `tmux returned no endpoint for session ${session}`);
|
|
1909
2205
|
return resolve(socket);
|
|
1910
2206
|
}
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
2207
|
+
/** The window names of `session` on the OATS server, creating neither a server nor a session: none is
|
|
2208
|
+
* an empty list. An early read only (an explicit name's refusal); the collision check that counts
|
|
2209
|
+
* lists the socket ensureOatsTmuxSession returned. */
|
|
2210
|
+
function oatsTmuxWindows(session) {
|
|
2211
|
+
try { return tmuxOatsServer(["list-windows", "-t", `=${session}`, "-F", "#{window_name}"]).split("\n").filter(Boolean); }
|
|
2212
|
+
catch { return []; }
|
|
2213
|
+
}
|
|
2214
|
+
/** The harness's window command: no COLORFGBG in the pane, whatever the server's global environment
|
|
2215
|
+
* holds. tmux runs the command with the server's `default-shell -c` (the user's shell: sh, bash and
|
|
2216
|
+
* zsh all take `unset NAME;`), and the fallback shell it execs inherits the result. */
|
|
2217
|
+
const paneCommand = (command) => `unset COLORFGBG; ${command}; exec "\${SHELL:-/bin/zsh}"`;
|
|
2218
|
+
/** The sizing options on one window, each best effort: this command's failure is ignored, nothing is
|
|
2219
|
+
* retried elsewhere and nothing global is written. */
|
|
2220
|
+
function sizeWindow(socket, windowId, io) {
|
|
2221
|
+
if (!/^@\d+$/.test(windowId || "")) return;
|
|
2222
|
+
for (const [option, value] of OATS_WINDOW_SIZING) {
|
|
2223
|
+
try { tmuxOn(socket, ["set-option", "-w", "-t", windowId, option, value], io); } catch (e) { if (oatsCoded(e)) throw e; }
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
/** The agent window OATS just created, by the id new-window returned: showing the viewer's colours and
|
|
2227
|
+
* sized for its viewers. Never a global option, another window or another server. The colour
|
|
2228
|
+
* commands come first and are strict, so a lost server, a permission or a transport failure surfaces
|
|
2229
|
+
* there and the tolerant sizing after them cannot mask it. The palette (`pane-colours`) is left
|
|
2230
|
+
* alone: a local reset did not neutralise inherited entries. */
|
|
2231
|
+
function prepareAgentWindow(socket, windowId, io) {
|
|
2232
|
+
if (!/^@\d+$/.test(windowId || "")) throw new Error("tmux new-window returned no window id");
|
|
2233
|
+
for (const [option, value] of OATS_WINDOW_COLOURS) tmuxOn(socket, ["set-option", "-w", "-t", windowId, option, value], io);
|
|
2234
|
+
// cursor-colour exists from tmux 3.3: -q makes an unknown option exit 0. -q also hides a missing
|
|
2235
|
+
// target, so only this command has it, and only after the two above succeeded on the same window.
|
|
2236
|
+
tmuxOn(socket, ["set-option", "-q", "-w", "-t", windowId, "cursor-colour", "default"], io);
|
|
2237
|
+
sizeWindow(socket, windowId, io);
|
|
2238
|
+
}
|
|
2239
|
+
/** One line for a start that opened its window on another server than the home recorded. */
|
|
2240
|
+
const sessionMovedWarning = (instance, from, to) => `${instance} was recorded on the tmux server ${JSON.stringify(from)}; its window had to be created again, and new windows open on the OATS tmux server: it now runs on ${JSON.stringify(to)}, and that socket is recorded`;
|
|
1915
2241
|
|
|
1916
2242
|
/**
|
|
1917
2243
|
* Spawn an instance of `agent` (as returned by findAgent/listAgents).
|
|
@@ -2297,13 +2623,12 @@ export function launchReportFor({ layers, launchConfigs = {}, contextDir }) {
|
|
|
2297
2623
|
* name: the command says NAME="$OATS_LAUNCH_REF_NAME", so no source
|
|
2298
2624
|
* variable is ever named in the command and no assignment in the same
|
|
2299
2625
|
* prefix can shadow it (zsh evaluates a prefix's assignments in order).
|
|
2300
|
-
*
|
|
2626
|
+
* Each goes to the pane as a tmux `-e NAME=value` argument. */
|
|
2301
2627
|
export function launchEnvRefs(recipe, env = process.env) {
|
|
2302
2628
|
const out = [];
|
|
2303
2629
|
for (const [name, v] of Object.entries(recipe.env || {})) if (v && typeof v === "object" && v.fromEnv && env[v.fromEnv] !== undefined) out.push({ name: `${LAUNCH_REF_PREFIX}${name}`, value: env[v.fromEnv], target: name, source: v.fromEnv });
|
|
2304
2630
|
return out;
|
|
2305
2631
|
}
|
|
2306
|
-
export function launchEnvTmuxFlags(recipe, env) { return launchEnvRefs(recipe, env).map((r) => ` -e ${shq(`${r.name}=${r.value}`)}`).join(""); }
|
|
2307
2632
|
|
|
2308
2633
|
/** The canonical path of this kernel's CLI: what the home's shim points at, and OATS_CLI_BIN. */
|
|
2309
2634
|
export function kernelBin() { return realpathSync(join(PKG_ROOT, "bin", "oats.mjs")); }
|
|
@@ -2769,7 +3094,8 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2769
3094
|
|
|
2770
3095
|
// An explicit name may not be a soul name (soul and instance references stay
|
|
2771
3096
|
// unambiguous) and is taken when any soul of this deployment holds it, or — launched
|
|
2772
|
-
// or not — a live
|
|
3097
|
+
// or not — a live window of the session on the OATS tmux server carries it (an early read that
|
|
3098
|
+
// creates no server; the launch below checks again on the socket it resolved):
|
|
2773
3099
|
// a typed refusal, never a silent `-2` (the operator typed it). Checked after
|
|
2774
3100
|
// key recovery, so a retried keyed spawn still reaches its own receipt; the
|
|
2775
3101
|
// placement below re-checks after its reservation (a concurrent spawn of
|
|
@@ -2778,8 +3104,12 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2778
3104
|
if (deploymentSoulNames(root, o.prepared).has(instance)) throw oatsError("E_INSTANCE_NAME_INVALID", `instance name "${instance}" is a soul name in this deployment; an instance may not share a soul's name`);
|
|
2779
3105
|
const holder = deploymentInstanceHomes(root).get(instance)?.[0];
|
|
2780
3106
|
if (holder) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken in this deployment (${holder}); instance names are unique across every soul — pick another --name`), { instance, home: holder });
|
|
2781
|
-
if (
|
|
3107
|
+
if (oatsTmuxWindows(session).includes(instance)) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken: a live tmux window of that name exists in session ${session} — pick another --name`), { instance, session });
|
|
2782
3108
|
}
|
|
3109
|
+
// A caller inside an instance that will have to create the tmux session reads the environment
|
|
3110
|
+
// for it here, before any scaffold, work tree, identity or hook, so that one that cannot is
|
|
3111
|
+
// refused with nothing left behind. A caller outside every instance runs no tmux for it.
|
|
3112
|
+
const tmuxSessionPlan = launch && o.preview !== true ? planOatsTmuxSession(session) : undefined;
|
|
2783
3113
|
|
|
2784
3114
|
// Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
|
|
2785
3115
|
// anchor the new instance to an EXISTING instance (o.relativeTo). o.parent
|
|
@@ -3524,9 +3854,11 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3524
3854
|
const outstandingGit = new Set();
|
|
3525
3855
|
// A failed new-window command may still have created its window. Verify
|
|
3526
3856
|
// quiescence before removing credentials or work that harness may be using.
|
|
3857
|
+
// On the socket recorded for this spawn: windowMayExist is only set once it is.
|
|
3527
3858
|
if (windowMayExist) {
|
|
3528
|
-
|
|
3529
|
-
|
|
3859
|
+
const socket = spawnTmux.socket;
|
|
3860
|
+
try { tmuxOn(socket, ["kill-window", "-t", `=${session}:=${instance}`]); } catch { /* verify the effect below */ }
|
|
3861
|
+
const winProbe = probe(["tmux", "-u", "-S", socket, "list-windows", "-t", `=${session}`, "-F", "#{window_name}"]);
|
|
3530
3862
|
const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
|
|
3531
3863
|
if (unresolved) {
|
|
3532
3864
|
incomplete.push(!winProbe.ok
|
|
@@ -3803,14 +4135,14 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3803
4135
|
if (work === "directory") assertDirectoryRoots(home, homeReal);
|
|
3804
4136
|
const executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
|
|
3805
4137
|
if (launch) {
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
}
|
|
3812
|
-
if (
|
|
3813
|
-
meta.tmux.socket =
|
|
4138
|
+
// The session on the OATS server, then everything on the one socket that answered: the collision
|
|
4139
|
+
// check, the records, the window and (in compensateSpawn) its removal.
|
|
4140
|
+
const socket = ensureOatsTmuxSession(session, existsSync(root) ? root : workspaceOf(root), undefined, tmuxSessionPlan);
|
|
4141
|
+
let present;
|
|
4142
|
+
try { present = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"]).split("\n").filter(Boolean); }
|
|
4143
|
+
catch (e) { throw new Error(`could not list the windows of tmux session ${session} on ${socket}: ${tmuxFailure(e, "tmux list-windows failed")}`); }
|
|
4144
|
+
if (present.includes(instance)) throw new Error(`tmux window "${instance}" already exists in session ${session}`);
|
|
4145
|
+
meta.tmux.socket = socket;
|
|
3814
4146
|
meta.launched = true;
|
|
3815
4147
|
// Commit the final child metadata and its independent byte authority before
|
|
3816
4148
|
// the managed harness can write. No child-home transition follows launch.
|
|
@@ -3818,10 +4150,15 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3818
4150
|
writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
|
|
3819
4151
|
// Wrap the command so the window drops into an interactive shell when the
|
|
3820
4152
|
// agent exits (e.g. Ctrl-C) instead of tmux killing the window.
|
|
3821
|
-
const windowCmd =
|
|
4153
|
+
const windowCmd = paneCommand(executionCommand);
|
|
4154
|
+
const paneEnvFlags = launchEnvRefs(recipe, process.env).flatMap((r) => ["-e", `${r.name}=${r.value}`]);
|
|
4155
|
+
const launchFailed = (step, refused, e) => oatsError("E_SPAWN_LAUNCH_FAILED", `tmux ${step} failed for ${instance} (${e.code === "ENOENT" ? "tmux unavailable" : refused}); the command line and tmux's output are withheld from this message because they can carry reference values; run tmux -S ${shellWord(socket)} list-windows -t ${shellWord(session)} to inspect`);
|
|
3822
4156
|
windowMayExist = true;
|
|
3823
|
-
|
|
3824
|
-
|
|
4157
|
+
let windowId;
|
|
4158
|
+
try { windowId = tmuxOn(socket, ["new-window", "-P", "-F", "#{window_id}", "-t", `=${session}:`, "-n", instance, "-c", home, ...paneEnvFlags, windowCmd], undefined, oatsWindowEnvironment()).trim(); }
|
|
4159
|
+
catch (e) { throw launchFailed("new-window", "the window command was refused", e); }
|
|
4160
|
+
try { prepareAgentWindow(socket, windowId); }
|
|
4161
|
+
catch (e) { throw launchFailed("set-option", "the new window's options were refused", e); }
|
|
3825
4162
|
} else {
|
|
3826
4163
|
meta.launched = false;
|
|
3827
4164
|
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
@@ -3864,7 +4201,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3864
4201
|
meta.spawnCompleted = true;
|
|
3865
4202
|
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n"); // a kernel-owned field: the baseline fingerprint ignores it
|
|
3866
4203
|
}
|
|
3867
|
-
return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined });
|
|
4204
|
+
return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: launch ? `tmux -S ${shellWord(meta.tmux.socket)} attach -t ${shellWord(session)}` : `oats session attach --home ${shellWord(home)}`, warnings: spawnWarnings.length ? spawnWarnings : undefined });
|
|
3868
4205
|
} catch (error) {
|
|
3869
4206
|
try {
|
|
3870
4207
|
const compensation = compensateSpawn();
|
|
@@ -4393,15 +4730,6 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
|
|
|
4393
4730
|
return `sha256:${hash.digest("hex")}`;
|
|
4394
4731
|
}
|
|
4395
4732
|
|
|
4396
|
-
/** The ref a worktree actually has checked out: `{branch, oid}` with
|
|
4397
|
-
* `branch === null` when HEAD is detached. Recovery derives truth from the
|
|
4398
|
-
* object, never from spawn-time metadata. */
|
|
4399
|
-
function worktreeRef(work) {
|
|
4400
|
-
const oid = execFileSync("git", ["-C", work, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
4401
|
-
let branch = null;
|
|
4402
|
-
try { branch = execFileSync("git", ["-C", work, "symbolic-ref", "--quiet", "--short", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim() || null; } catch { branch = null; }
|
|
4403
|
-
return { branch, oid };
|
|
4404
|
-
}
|
|
4405
4733
|
/** A worktree directory whose git admin entry is gone: no `.git`, an unreadable one, or a gitfile naming an
|
|
4406
4734
|
* admin directory that no longer exists. Read from the gitfile, not asked of git, which would walk up into
|
|
4407
4735
|
* whatever repository encloses the home. A `.git` directory is a repository of its own, not this case. */
|
|
@@ -4540,25 +4868,16 @@ function nestedGitRoots(root) {
|
|
|
4540
4868
|
return out;
|
|
4541
4869
|
}
|
|
4542
4870
|
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${detail || String(e.message ?? "").trim() || "git ref probe failed"}`);
|
|
4554
|
-
}
|
|
4555
|
-
try {
|
|
4556
|
-
const refs = execFileSync("git", ["-C", repo, "for-each-ref", "--format=%(refname)"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER })
|
|
4557
|
-
.split("\n").filter((ref) => ref && ref !== target);
|
|
4558
|
-
return execFileSync("git", ["-C", repo, "rev-list", target, ...(refs.length ? ["--not", ...refs] : [])], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim().split("\n").filter(Boolean);
|
|
4559
|
-
} catch (e) {
|
|
4560
|
-
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${String(e.stderr ?? e.message ?? "").trim() || "git ref probe failed"}`);
|
|
4561
|
-
}
|
|
4871
|
+
/** Whether the recovery can copy the branch recorded at spawn: only when Git
|
|
4872
|
+
* shows it (exit 0). Asked by a quarantine retry for a worktree that is gone:
|
|
4873
|
+
* a failed spawn's rollback may have deleted the branch. A branch Git cannot
|
|
4874
|
+
* show (absent, or a damaged ref) is not copied; no retire deletes it, so it
|
|
4875
|
+
* stays in the repository, and the quarantine's own check of the branch says
|
|
4876
|
+
* whether it could be shown gone. */
|
|
4877
|
+
function recordedBranchExists(repo, branch) {
|
|
4878
|
+
if (!repo || !branch) return true;
|
|
4879
|
+
const r = spawnSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
|
|
4880
|
+
return !r.error && r.status === 0;
|
|
4562
4881
|
}
|
|
4563
4882
|
|
|
4564
4883
|
/** Herdr (removed in 0.31.0) is recognised in a home only to refuse: its instance.json records a
|
|
@@ -4631,16 +4950,28 @@ function tmuxServerLost(e) { return /no server running on |(?:error connecting t
|
|
|
4631
4950
|
* The live processes (other than this one and its children) whose working
|
|
4632
4951
|
* directory is inside `home`: `lsof -a -d cwd` over the host, one call.
|
|
4633
4952
|
* → { ok: true, processes: [{pid, command}] } | { ok: false, error } (the scan
|
|
4634
|
-
* could not run
|
|
4953
|
+
* could not run, did not complete or listed no process: a caller must treat
|
|
4954
|
+
* that as unknown, never as none).
|
|
4635
4955
|
*/
|
|
4636
4956
|
export function processesInHome(home, io) {
|
|
4637
4957
|
let out;
|
|
4638
4958
|
try { out = (io?.exec || execFileSync)("lsof", ["-a", "-d", "cwd", "-F", "pRcn", "-w"], { encoding: "utf8", timeout: 20000, maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }); }
|
|
4639
4959
|
catch (e) {
|
|
4640
|
-
// lsof exits 1 with a full listing when some process could not be read
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4960
|
+
// lsof exits 1 with a full listing when some process could not be read. That is the one failure
|
|
4961
|
+
// whose output is a scan: lsof ended by itself with status 1 (no signal, and no error code, which
|
|
4962
|
+
// Node sets when the capture itself failed: a timeout, too much output). A scan that was cut off
|
|
4963
|
+
// is a failed scan whatever it printed first. The error says what happened, never a line of the
|
|
4964
|
+
// listing.
|
|
4965
|
+
const completed = e?.status === 1 && e.signal == null && e.code === undefined;
|
|
4966
|
+
if (completed && typeof e.stdout === "string") out = e.stdout;
|
|
4967
|
+
else return { ok: false, error: e?.code === "ENOENT" ? "lsof is not on PATH"
|
|
4968
|
+
: e?.code === "ETIMEDOUT" ? "lsof timed out"
|
|
4969
|
+
: e?.code === "ENOBUFS" ? "lsof printed more output than the scan reads"
|
|
4970
|
+
: e?.signal ? `lsof was ended by ${e.signal}`
|
|
4971
|
+
: String(e?.stderr || e?.message || "lsof failed").trim().split("\n")[0] };
|
|
4972
|
+
}
|
|
4973
|
+
// A listing holds at least one process (lsof and its caller are always in it), whichever exit it came with.
|
|
4974
|
+
if (!/^p\d+$/m.test(String(out))) return { ok: false, error: "lsof listed no process" };
|
|
4644
4975
|
const realHome = realPathOrNearest(home);
|
|
4645
4976
|
const processes = [];
|
|
4646
4977
|
let cur = {};
|
|
@@ -4832,8 +5163,8 @@ export function withLaunchModel(command, model) {
|
|
|
4832
5163
|
return renderLaunchCommand(tokens);
|
|
4833
5164
|
}
|
|
4834
5165
|
|
|
4835
|
-
function tmuxOn(socket, args, io) {
|
|
4836
|
-
return (io?.exec || execFileSync)("tmux", ["-u", "-S", socket, ...args], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
5166
|
+
function tmuxOn(socket, args, io, env) {
|
|
5167
|
+
return (io?.exec || execFileSync)("tmux", ["-u", "-S", socket, ...args], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], ...(env ? { env } : {}) });
|
|
4837
5168
|
}
|
|
4838
5169
|
|
|
4839
5170
|
function writeJsonAtomic(path, value, mode) {
|
|
@@ -5138,6 +5469,9 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5138
5469
|
// A self-retirement leaves this marker while its detached teardown runs:
|
|
5139
5470
|
// the home is about to disappear, so nothing is relaunched into it.
|
|
5140
5471
|
if (existsSync(retirePendingMarkerPath(realHome))) throw oatsError("E_INSTANCE_RETIRING", `${basename(realHome)} is being retired (${retirePendingMarkerPath(realHome)} is present); nothing was started`);
|
|
5472
|
+
// A start that leaves the server the home recorded says so: one line per move (see the docs'
|
|
5473
|
+
// "Existing instances"), in the answer and as a launch-warning event.
|
|
5474
|
+
const movedWarnings = [];
|
|
5141
5475
|
// 1. Reconcile a pending receipt before the equality gate: it may be the
|
|
5142
5476
|
// only record of a session an earlier start allocated.
|
|
5143
5477
|
if (existsSync(pendingPath)) {
|
|
@@ -5177,6 +5511,14 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5177
5511
|
// in whichever log the interrupted start did not record it.
|
|
5178
5512
|
recordStartBoundary(realHome, { startId: pending.id, startedAt: pending.startedAt, harness: pending.harness ?? meta.harness ?? null, backend: "tmux", launchConfig: pending.launch?.launchConfig ?? meta.launch?.launchConfig ?? null, phase: "recovered" });
|
|
5179
5513
|
const done = record(meta, { ...pending, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
|
|
5514
|
+
// The adopted target is on another server than the home recorded (the start that allocated it
|
|
5515
|
+
// could not record it): this adoption records it, and says so once, here.
|
|
5516
|
+
const recordedSocket = typeof meta.tmux?.socket === "string" && meta.tmux.socket ? resolve(meta.tmux.socket) : null;
|
|
5517
|
+
if (recordedSocket && recordedSocket !== resolve(pending.target.socket)) {
|
|
5518
|
+
const message = sessionMovedWarning(meta.instance, recordedSocket, resolve(pending.target.socket));
|
|
5519
|
+
appendEvent(realHome, { kind: "launch-warning", data: { message } });
|
|
5520
|
+
movedWarnings.push(message);
|
|
5521
|
+
}
|
|
5180
5522
|
if (st.present && st.state !== "shell") {
|
|
5181
5523
|
if (o.restart) { rmSync(pendingPath, { force: true }); }
|
|
5182
5524
|
else {
|
|
@@ -5185,7 +5527,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5185
5527
|
if (o.model != null && String(o.model).trim() && resolveModelPreference(String(o.model), done.harness || meta.harness) !== done.model) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running with its previously requested model; its target was recovered, but the new model was not applied`);
|
|
5186
5528
|
if (o.launchConfig !== undefined || o.harness !== undefined || o.yolo !== undefined) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running (its pending start was recovered); the requested launch configuration, harness or yolo was not applied; stop it, or use session restart`);
|
|
5187
5529
|
if (meta.startId === pending.id) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running; nothing was started`);
|
|
5188
|
-
return done;
|
|
5530
|
+
return { ...done, warnings: [...done.warnings, ...movedWarnings] };
|
|
5189
5531
|
}
|
|
5190
5532
|
}
|
|
5191
5533
|
}
|
|
@@ -5246,15 +5588,26 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5246
5588
|
checkRoots(); // preparation has run; no backend has been observed
|
|
5247
5589
|
let target = receipt.target;
|
|
5248
5590
|
let state = { present: false, state: "not-launched" };
|
|
5249
|
-
let serverGone = false;
|
|
5250
5591
|
if (target) {
|
|
5251
5592
|
try { state = inspectSessionTarget(target, o.io); }
|
|
5252
5593
|
catch (e) {
|
|
5253
|
-
if (lostTmuxServer(e))
|
|
5594
|
+
if (lostTmuxServer(e)) state = { present: false, state: "stopped" };
|
|
5254
5595
|
else throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`);
|
|
5255
5596
|
}
|
|
5256
5597
|
}
|
|
5257
5598
|
if (state.present && state.state !== "shell" && !o.restart) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
|
|
5599
|
+
// A caller inside an instance that will have to create the tmux session reads the environment
|
|
5600
|
+
// for it here (anyone else runs no tmux here): after the start's preflights and its planning,
|
|
5601
|
+
// so each of their refusals still answers first, and before the real run of preview-aware
|
|
5602
|
+
// launch hooks, a stop and any write of the home's launch state (record, receipt, pending
|
|
5603
|
+
// start). A launch hook that does not declare launchPreview has already run and its warnings
|
|
5604
|
+
// are already events (above), as before any other late refusal of a start (E_SESSION_RUNNING,
|
|
5605
|
+
// E_LAUNCH_ENV_MISSING); nothing undoes what it did, and the hook contract (above
|
|
5606
|
+
// prepareLaunchHooks) allows such a hook idempotent provider registration, which the next
|
|
5607
|
+
// start repeats. Only when this start will have to create a window: a retained pane is reused
|
|
5608
|
+
// where it is. A restart whose window goes away during its stop is decided at the creation.
|
|
5609
|
+
const tmuxSessionPlan = callerInstance() && !(state.paneId && (state.present || state.state === "stopped"))
|
|
5610
|
+
? planOatsTmuxSession(target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION, o.io) : undefined;
|
|
5258
5611
|
// Every preflight has passed: the preview-aware launch hooks run for real
|
|
5259
5612
|
// (they may register the home with their provider), before a restart's
|
|
5260
5613
|
// stop, and must contribute exactly what they contributed as a preview,
|
|
@@ -5293,7 +5646,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5293
5646
|
writeJsonAtomic(join(realHome, ".oats-restart.json"), { instance: meta.instance, at: new Date().toISOString(), stop: stopReceipt, next: { harness: launchPlan?.harness || harness, launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, model: model ?? null } }, 0o600);
|
|
5294
5647
|
appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
|
|
5295
5648
|
if (!stopReceipt.exited) throw oatsError("E_SESSION_STOP_FAILED", `${meta.instance} was asked to stop (${stopReceipt.signal} to ${stopReceipt.requested.map((r) => `${r.comm} pid ${r.pid}`).join(", ")} at ${stopReceipt.sentAt}) and was still running after ${stopReceipt.waitedMs} ms (${stopReceipt.state}); nothing was escalated and nothing was started; stop it yourself, or retry with a longer --stop-grace. Receipt: ${join(realHome, ".oats-restart.json")}`);
|
|
5296
|
-
try { state = inspectSessionTarget(target, o.io); } catch (e) { if (lostTmuxServer(e))
|
|
5649
|
+
try { state = inspectSessionTarget(target, o.io); } catch (e) { if (lostTmuxServer(e)) state = { present: false, state: "stopped" }; else throw oatsError("E_SESSION_UNKNOWN", `after the stop, cannot establish the state of ${meta.instance}: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
|
|
5297
5650
|
if (state.present && state.state !== "shell") throw oatsError("E_SESSION_UNKNOWN", `${meta.instance} read as stopped and then as ${state.state} again; nothing was started`);
|
|
5298
5651
|
}
|
|
5299
5652
|
// The kernel that launches the harness is the one the agent's plain `oats` runs.
|
|
@@ -5308,52 +5661,39 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5308
5661
|
const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
|
|
5309
5662
|
const window = target?.window || meta.tmux?.window || meta.instance;
|
|
5310
5663
|
let socket = target?.socket || meta.tmux?.socket;
|
|
5311
|
-
const windowCmd =
|
|
5664
|
+
const windowCmd = paneCommand(completedCommand);
|
|
5665
|
+
let moved = null;
|
|
5312
5666
|
// A fallback shell (no harness descendant) or a retained dead pane is
|
|
5313
|
-
// the agent's own pane: the command runs there,
|
|
5667
|
+
// the agent's own pane: the command runs there, on the server the home
|
|
5668
|
+
// recorded, no other window touched and no option set.
|
|
5314
5669
|
const inPlace = state.paneId && (state.present || state.state === "stopped");
|
|
5315
5670
|
if (inPlace) {
|
|
5316
5671
|
checkRoots();
|
|
5317
5672
|
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
5318
|
-
try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
|
|
5673
|
+
try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io, oatsWindowEnvironment()); }
|
|
5319
5674
|
catch (e) { throw launchFailure(e); }
|
|
5320
5675
|
reused = "pane";
|
|
5321
5676
|
} else {
|
|
5322
5677
|
const instancesRoot = dirname(realHome);
|
|
5323
5678
|
const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
|
|
5324
5679
|
checkRoots();
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
if (!alive) {
|
|
5331
|
-
defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
|
|
5332
|
-
for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
|
|
5333
|
-
try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
|
|
5334
|
-
}
|
|
5335
|
-
}
|
|
5336
|
-
socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
|
|
5337
|
-
if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
|
|
5338
|
-
} else if (serverGone) {
|
|
5339
|
-
// The recorded server is gone (a reboot): the same socket path again.
|
|
5340
|
-
mkdirSync(dirname(socket), { recursive: true });
|
|
5341
|
-
tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
|
|
5342
|
-
tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
|
|
5343
|
-
tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
|
|
5344
|
-
}
|
|
5345
|
-
let names = [];
|
|
5680
|
+
// A replacement window (never launched, recorded window gone, recorded server gone) is created
|
|
5681
|
+
// on the OATS server, wherever the home was recorded: one socket from here to the record.
|
|
5682
|
+
const recordedSocket = socket ? resolve(socket) : null;
|
|
5683
|
+
socket = ensureOatsTmuxSession(session, hq, o.io, tmuxSessionPlan);
|
|
5684
|
+
let names;
|
|
5346
5685
|
try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
|
|
5347
5686
|
catch (e) {
|
|
5348
|
-
if (
|
|
5349
|
-
|
|
5687
|
+
if (oatsCoded(e)) throw e;
|
|
5688
|
+
throw oatsError("E_SESSION_UNKNOWN", `cannot list tmux windows on ${socket}: ${tmuxFailure(e, "tmux list-windows failed")}`);
|
|
5350
5689
|
}
|
|
5351
5690
|
if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
|
|
5352
|
-
target = { backend: "tmux", session, window, socket
|
|
5691
|
+
target = { backend: "tmux", session, window, socket };
|
|
5353
5692
|
checkRoots();
|
|
5354
5693
|
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
5355
|
-
try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
|
|
5694
|
+
try { prepareAgentWindow(socket, tmuxOn(socket, ["new-window", "-P", "-F", "#{window_id}", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io, oatsWindowEnvironment()).trim(), o.io); }
|
|
5356
5695
|
catch (e) { throw launchFailure(e); }
|
|
5696
|
+
if (recordedSocket && recordedSocket !== socket) moved = sessionMovedWarning(meta.instance, recordedSocket, socket);
|
|
5357
5697
|
}
|
|
5358
5698
|
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
5359
5699
|
// The session exists: its boundary (lib/instance-events.mjs) is recorded
|
|
@@ -5363,11 +5703,15 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5363
5703
|
// Keep launch evidence until the command exits or the target disappears.
|
|
5364
5704
|
// A transient child (for example the native-start recorder) is not proof that startup
|
|
5365
5705
|
// has finished. A later start reconciles the receipt without a watcher.
|
|
5366
|
-
|
|
5706
|
+
let done;
|
|
5707
|
+
try { done = record(meta, { id, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false); }
|
|
5367
5708
|
catch (e) {
|
|
5368
5709
|
if (e.code && String(e.code).startsWith("E_")) throw e;
|
|
5369
5710
|
throw oatsError("E_SESSION_START_INCOMPLETE", `${meta.instance} was started (tmux ${target.session}:${target.window} on ${target.socket}) but its metadata could not be recorded: ${e.message}; the actual target is kept in ${pendingPath} and the next start adopts it instead of allocating another`);
|
|
5370
5711
|
}
|
|
5712
|
+
// Said once the new socket is recorded: a start that could not record leaves it to the adoption.
|
|
5713
|
+
if (moved) { appendEvent(realHome, { kind: "launch-warning", data: { message: moved } }); movedWarnings.push(moved); }
|
|
5714
|
+
return { ...done, warnings: [...warnings, ...movedWarnings] };
|
|
5371
5715
|
} finally {
|
|
5372
5716
|
// A hook may have replaced the home itself. Never follow that replacement
|
|
5373
5717
|
// to remove a target's lock; keep the original retry state with its home.
|
|
@@ -5378,7 +5722,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5378
5722
|
}
|
|
5379
5723
|
}
|
|
5380
5724
|
|
|
5381
|
-
function inspectRetirementWork(home, work, isWorktree, {
|
|
5725
|
+
function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktreeRemoval, directory = false, orphanedWork = false } = {}) {
|
|
5382
5726
|
if (directory) assertDirectoryRoots(home);
|
|
5383
5727
|
const classes = [];
|
|
5384
5728
|
let baseline;
|
|
@@ -5405,8 +5749,13 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
|
|
|
5405
5749
|
// Never stamp hook-created or authored execution bytes as disposable.
|
|
5406
5750
|
if (readdirSync(work).length) classes.push("directory work bytes");
|
|
5407
5751
|
}
|
|
5408
|
-
|
|
5409
|
-
|
|
5752
|
+
// For a quarantine retry whose worktree is gone, whether the recorded branch
|
|
5753
|
+
// is still there to copy. Every other retire takes it to be there.
|
|
5754
|
+
const branchExists = !directory && recordedBranch && !existsSync(work) ? recordedBranchExists(recordedBranch.repo, recordedBranch.branch) : true;
|
|
5755
|
+
// A retire that will remove the worktree asks whether a ref that outlives it
|
|
5756
|
+
// reaches its commit. `head` is kept for the check before the removal.
|
|
5757
|
+
const unreached = worktreeRemoval?.removes && worktreeRemoval.repo && !directory && isWorktree && existsSync(work) ? worktreeCommitUnreached(worktreeRemoval.repo, work) : undefined;
|
|
5758
|
+
if (unreached?.unreached) classes.push("worktree commits no ref reaches");
|
|
5410
5759
|
if (isWorktree && existsSync(work)) {
|
|
5411
5760
|
const status = worktreeStatus(work);
|
|
5412
5761
|
const rows = status.split("\0").filter(Boolean);
|
|
@@ -5415,11 +5764,16 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
|
|
|
5415
5764
|
if (!baseline || baseline.generatedWorkFingerprint !== generatedWorkFingerprint(work, status, disposableRoots)) classes.push("untracked or ignored worktree bytes");
|
|
5416
5765
|
if (nestedGitRoots(work).length) classes.push("nested repository state");
|
|
5417
5766
|
}
|
|
5767
|
+
// When the worktree will be removed, its HEAD and whether a ref reaches it are part of the state: a hook
|
|
5768
|
+
// that commits on a detached HEAD, or deletes the ref that reached it, changes neither the home nor the
|
|
5769
|
+
// status, and the commit must still be preserved again after it.
|
|
5418
5770
|
const stateFingerprint = createHash("sha256")
|
|
5419
5771
|
.update(fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }))
|
|
5420
5772
|
.update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
|
|
5773
|
+
.update("\0").update(unreached ? `${unreached.head.commit}\0${unreached.unreached ? "unreached" : "reached"}\0` : "")
|
|
5774
|
+
.update(unreached?.head.ref ?? "")
|
|
5421
5775
|
.digest("hex");
|
|
5422
|
-
return { classes: [...new Set(classes)], home, work, directory, orphanedWork, directoryFingerprint, stateFingerprint, branchExists:
|
|
5776
|
+
return { classes: [...new Set(classes)], home, work, directory, orphanedWork, directoryFingerprint, stateFingerprint, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
|
|
5423
5777
|
}
|
|
5424
5778
|
|
|
5425
5779
|
function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
|
|
@@ -5619,6 +5973,7 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
5619
5973
|
}
|
|
5620
5974
|
const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
|
|
5621
5975
|
const recovery = join(recoveryRoot, basename(staging).slice(1));
|
|
5976
|
+
let headUnreadable;
|
|
5622
5977
|
try {
|
|
5623
5978
|
// A home-only change (notes, harness files, credentials) needs a home
|
|
5624
5979
|
// snapshot, not another copy of an otherwise disposable clean worktree.
|
|
@@ -5643,14 +5998,18 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
5643
5998
|
// The branch is derived from the worktree while it exists: an instance
|
|
5644
5999
|
// that legitimately switched branches during its task must still be
|
|
5645
6000
|
// recoverable, and the recorded spawn-time branch is only the fallback
|
|
5646
|
-
// when the worktree is gone.
|
|
5647
|
-
|
|
5648
|
-
|
|
6001
|
+
// when the worktree is gone. A HEAD that is detached, or on a ref OATS
|
|
6002
|
+
// carries no name for, recovers detached at its exact commit. A HEAD
|
|
6003
|
+
// that cannot be read is an inspection failure, not a failed copy.
|
|
6004
|
+
let ref;
|
|
6005
|
+
try { ref = existsSync(observation.work) ? worktreeHead(observation.work) : { branch: meta.branch, commit: null }; }
|
|
6006
|
+
catch (e) { headUnreadable = e; throw e; }
|
|
6007
|
+
if (ref.branch !== meta.branch) branchDrift = { recordedBranch: meta.branch, worktreeBranch: ref.branch, detachedAt: ref.branch === null ? ref.commit : null };
|
|
5649
6008
|
if (ref.branch !== null) execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", ref.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
5650
6009
|
else {
|
|
5651
6010
|
execFileSync("git", ["clone", "--no-local", "--quiet", "--no-checkout", meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
5652
|
-
execFileSync("git", ["-C", recoveredRepo, "fetch", "--quiet", observation.work, ref.
|
|
5653
|
-
execFileSync("git", ["-C", recoveredRepo, "checkout", "--quiet", "--detach", ref.
|
|
6011
|
+
execFileSync("git", ["-C", recoveredRepo, "fetch", "--quiet", observation.work, ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
|
|
6012
|
+
execFileSync("git", ["-C", recoveredRepo, "checkout", "--quiet", "--detach", ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
|
|
5654
6013
|
}
|
|
5655
6014
|
const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
|
|
5656
6015
|
detachRecoveryClone(sourceGitContext, recoveredRepo);
|
|
@@ -5672,10 +6031,15 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
5672
6031
|
if (fingerprintTree(observation.work, { excludeRoot: new Set([".git"]), excludeGitMetadata: true }) !== fingerprintTree(recoveredRepo, { excludeRoot: new Set([".git"]), excludeGitMetadata: true })) throw new Error("worktree recovery verification disagreed with the source");
|
|
5673
6032
|
assertStatusAgrees(observation.work, recoveredRepo, "recovered Git index/status disagreed with the source", ".");
|
|
5674
6033
|
}
|
|
6034
|
+
// The proof of the copy: its HEAD is the commit the worktree has checked
|
|
6035
|
+
// out. A branch of the repository can be at another commit with the
|
|
6036
|
+
// same files, so the branch's tip there proves nothing about the copy.
|
|
6037
|
+
// With the worktree gone there is no such commit to read, and the copy
|
|
6038
|
+
// is the recorded branch as the repository has it.
|
|
5675
6039
|
const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
5676
|
-
const sourceHead = ref.
|
|
5677
|
-
|
|
5678
|
-
if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
|
|
6040
|
+
const sourceHead = ref.commit
|
|
6041
|
+
?? execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${ref.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
6042
|
+
if (recoveredHead !== sourceHead) throw new Error(ref.commit ? "recovery clone is not at the commit the worktree has checked out" : "recovery clone does not retain the instance branch tip");
|
|
5679
6043
|
}
|
|
5680
6044
|
if (observation.directory && observation.directoryFingerprint) {
|
|
5681
6045
|
const recoveredWork = join(staging, "work");
|
|
@@ -5695,6 +6059,7 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
5695
6059
|
return { path: recovery, classes: observation.classes, bytes, ...(outputs ? { outputs } : {}), ...(repoCopy ? { repoCopy } : {}) };
|
|
5696
6060
|
} catch (e) {
|
|
5697
6061
|
rmSync(staging, { recursive: true, force: true });
|
|
6062
|
+
if (e === headUnreadable) throw e;
|
|
5698
6063
|
const details = e.statusDisagreement ? { home: observation.home, statusDisagreement: e.statusDisagreement } : undefined;
|
|
5699
6064
|
throw Object.assign(oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`, details), details ? { details } : {});
|
|
5700
6065
|
}
|
|
@@ -5744,7 +6109,7 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
|
|
|
5744
6109
|
const intent = {
|
|
5745
6110
|
instance: name, agent: found.agent.name, root: resolve(root),
|
|
5746
6111
|
requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
|
|
5747
|
-
options: { home: found.home,
|
|
6112
|
+
options: { home: found.home, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
|
|
5748
6113
|
};
|
|
5749
6114
|
const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
|
|
5750
6115
|
for (const k of CORE_LAUNCH_ENV) delete env[k];
|
|
@@ -5786,6 +6151,16 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
|
|
|
5786
6151
|
* (and, after hooks ran, the usual quarantine) in place, so `oats status`
|
|
5787
6152
|
* shows the debt and `oats retire <name>` retries and clears it. Accepts the
|
|
5788
6153
|
* intent object (the child gets it in its env) or a marker path. */
|
|
6154
|
+
/** The item a quarantine retry reports while the branch its failed spawn created is still there. It
|
|
6155
|
+
* names no branch: the name is the receipt's `retention.recordedBranch`, or the retained home's
|
|
6156
|
+
* instance.json `branch`. */
|
|
6157
|
+
export const FAILED_SPAWN_BRANCH_LEFT = "the branch the failed spawn created is left: OATS does not delete it. Inspect it and delete it with Git if it is not wanted, then retry";
|
|
6158
|
+
const OBSOLETE_DELETE_BRANCH_SENTENCE = "this self-retire was requested with --delete-branch by an older OATS; retirement no longer deletes branches, so the branch and the worktree were left";
|
|
6159
|
+
/** The one internal option that says the retirement completes such an intent.
|
|
6160
|
+
* A symbol, so no caller can set it from parsed arguments or JSON. */
|
|
6161
|
+
const OBSOLETE_DELETE_BRANCH = Symbol("an older self-retire intent with --delete-branch");
|
|
6162
|
+
export const RETIRE_DELETE_BRANCH_REFUSED = "oats retire no longer deletes branches: --delete-branch is not accepted. Retire without it; the branch is left in the repository. Inspect it there and delete it with Git if it is no longer wanted.";
|
|
6163
|
+
|
|
5789
6164
|
export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
|
|
5790
6165
|
let intent = intentOrMarkerPath;
|
|
5791
6166
|
if (typeof intentOrMarkerPath === "string") {
|
|
@@ -5802,18 +6177,24 @@ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
|
|
|
5802
6177
|
}, null, 2) + "\n");
|
|
5803
6178
|
} catch (e) { console.error(`deferred retirement: cannot write ${intent.resultPath}: ${e.message}`); }
|
|
5804
6179
|
};
|
|
6180
|
+
// An intent an older OATS recorded for `--self --delete-branch`. The
|
|
6181
|
+
// retirement it owes is completed; the obsolete option authorizes nothing:
|
|
6182
|
+
// the branch and the worktree are left, and the retire says so.
|
|
6183
|
+
const obsoleteDeleteBranch = intent.options?.deleteBranch === true;
|
|
5805
6184
|
const delayMs = Math.max(0, Number(opts.delaySec ?? intent.delaySec ?? 8) * 1000);
|
|
5806
6185
|
if (delayMs) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
|
|
5807
6186
|
let result;
|
|
5808
6187
|
try {
|
|
5809
6188
|
result = retireInstance(intent.root, intent.instance, {
|
|
5810
|
-
home: intent.options?.home,
|
|
6189
|
+
home: intent.options?.home, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
|
|
6190
|
+
...(obsoleteDeleteBranch ? { [OBSOLETE_DELETE_BRANCH]: true } : {}),
|
|
5811
6191
|
});
|
|
5812
6192
|
} catch (e) {
|
|
5813
|
-
record({ ok: false, error: { code: e.code, message: e.message }, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
|
|
6193
|
+
record({ ok: false, error: { code: e.code, message: e.message }, ...(obsoleteDeleteBranch ? { warnings: [OBSOLETE_DELETE_BRANCH_SENTENCE] } : {}), retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
|
|
5814
6194
|
console.error(`deferred retirement of ${intent.instance} failed: ${e.message}`);
|
|
5815
6195
|
return false;
|
|
5816
6196
|
}
|
|
6197
|
+
if (obsoleteDeleteBranch) console.log(OBSOLETE_DELETE_BRANCH_SENTENCE);
|
|
5817
6198
|
if (result.rollbackIncomplete) {
|
|
5818
6199
|
record({ ok: false, result, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
|
|
5819
6200
|
console.error(`deferred retirement of ${intent.instance} is INCOMPLETE; the home is retained:\n ${result.rollbackIncomplete.join("\n ")}`);
|
|
@@ -5833,6 +6214,7 @@ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
|
|
|
5833
6214
|
}
|
|
5834
6215
|
|
|
5835
6216
|
export function retireInstance(root, name, o = {}) {
|
|
6217
|
+
if (o.deleteBranch) throw oatsError("E_BAD_ARGS", RETIRE_DELETE_BRANCH_REFUSED);
|
|
5836
6218
|
const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
|
|
5837
6219
|
// self-retire: the caller IS the instance. Without --keep-dir the whole
|
|
5838
6220
|
// retirement is deferred to a detached external completion (below); with
|
|
@@ -5944,8 +6326,10 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5944
6326
|
// First inspection is non-destructive. Only after it succeeds may OATS quiesce
|
|
5945
6327
|
// the managed harness; recovery copying never races a live managed Pi.
|
|
5946
6328
|
const owesWorktree = !!quarantine && (quarantine.cleanup.outstanding?.git || []).includes("worktree");
|
|
5947
|
-
const
|
|
5948
|
-
|
|
6329
|
+
const recordedBranch = quarantine ? { repo: meta.repo, branch: meta.branch } : undefined;
|
|
6330
|
+
// Whether this retire removes the worktree, when it gets to that step.
|
|
6331
|
+
const worktreeRemoval = { removes: !!(o.discardWorktree || owesWorktree), repo: meta.repo };
|
|
6332
|
+
const initialObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
|
|
5949
6333
|
// Harness identity is destructive authority. The mutable child metadata may
|
|
5950
6334
|
// describe it for humans, but only the independent baseline can authorize the
|
|
5951
6335
|
// endpoint that proves quiescence.
|
|
@@ -5988,10 +6372,20 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5988
6372
|
if (windows.includes(runtimeWindow)) throw new Error(`tmux window ${runtimeSession}:${runtimeWindow} is still running`);
|
|
5989
6373
|
} catch (e) {
|
|
5990
6374
|
const detail = String(e.stderr ?? e.message ?? "").trim();
|
|
5991
|
-
|
|
6375
|
+
const unestablished = (why) => oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that ${runtimeSession}:${runtimeWindow} stopped on ${runtimeSocket}: ${why}`);
|
|
6376
|
+
if (!/no server running|failed to connect|can't find session|no sessions/i.test(detail)) {
|
|
6377
|
+
if (!tmuxServerLost(e)) throw unestablished(detail || "tmux inspection failed");
|
|
6378
|
+
// A server that cannot be reached, typically because its socket file is missing (a reboot clears
|
|
6379
|
+
// tmux's socket directory). A server outlives its socket file, so that alone does not say the
|
|
6380
|
+
// window is gone: it is taken as gone only when no process works in the home, as for a home
|
|
6381
|
+
// without its receipt.
|
|
6382
|
+
const scan = processesInHome(found.home);
|
|
6383
|
+
if (!scan.ok) throw unestablished(`${detail}; whether a process still works in this home could not be established (${scan.error})`);
|
|
6384
|
+
if (scan.processes.length) throw unestablished(`${detail}; a process still works in this home (${scan.processes.slice(0, 5).map((p) => `pid ${p.pid} ${p.command}`).join(", ")}${scan.processes.length > 5 ? `, and ${scan.processes.length - 5} more` : ""})`);
|
|
6385
|
+
}
|
|
5992
6386
|
}
|
|
5993
6387
|
}
|
|
5994
|
-
const stableObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, {
|
|
6388
|
+
const stableObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
|
|
5995
6389
|
const workRecoveries = [];
|
|
5996
6390
|
if (stableObservation.classes.length) workRecoveries.push(preserveRetirementWork(stableObservation, meta, name));
|
|
5997
6391
|
let workRecovery = workRecoveries.at(-1);
|
|
@@ -6070,7 +6464,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6070
6464
|
|
|
6071
6465
|
// Hooks are allowed to mutate the inspected tree, so inspect again after
|
|
6072
6466
|
// them and preserve a separately verified post-hook snapshot when needed.
|
|
6073
|
-
const finalObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, {
|
|
6467
|
+
const finalObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
|
|
6074
6468
|
if (finalObservation.classes.length && finalObservation.stateFingerprint !== stableObservation.stateFingerprint) {
|
|
6075
6469
|
workRecoveries.push(preserveRetirementWork(finalObservation, meta, name));
|
|
6076
6470
|
workRecovery = workRecoveries.at(-1);
|
|
@@ -6132,32 +6526,39 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6132
6526
|
// branch and any PR outlive the home. The worktree cannot stay under
|
|
6133
6527
|
// <home>/work once the home is removed, so it is RE-HOMED with
|
|
6134
6528
|
// `git worktree move` to the deployment-level worktrees root and the move is
|
|
6135
|
-
// recorded in the receipt. `discardWorktree` restores removal
|
|
6136
|
-
//
|
|
6137
|
-
// spawn-time recorded name. A failed move keeps the home (fail closed).
|
|
6529
|
+
// recorded in the receipt. `discardWorktree` restores removal. No retire
|
|
6530
|
+
// deletes a branch. A failed move keeps the home (fail closed).
|
|
6138
6531
|
// The worktree step runs only once nothing else is outstanding (awebai/oats#444): a hook that did not finish
|
|
6139
6532
|
// may need the worktree, and its retry needs the home, the worktree and its admin entry exactly as they
|
|
6140
6533
|
// were. --force removes the home regardless, so the step runs first and no admin entry is left dangling.
|
|
6141
6534
|
// A failed spawn's quarantine that owes the worktree removes it; any other retire retains it unless
|
|
6142
|
-
// --discard-worktree
|
|
6535
|
+
// --discard-worktree. An orphaned work directory is never touched.
|
|
6143
6536
|
const outstandingBeforeWorktree = quarantine ? retryFailures : ordinaryIncomplete;
|
|
6144
6537
|
const worktreeStep = isWorktree && !!meta.repo && !orphanedWork;
|
|
6145
6538
|
const worktreeDeferred = worktreeStep && existsSync(workPath) && outstandingBeforeWorktree.length > 0 && !o.force;
|
|
6146
6539
|
const keptForRetry = worktreeDeferred ? `git worktree ${workPath}: kept for the retry; outstanding: ${outstandingBeforeWorktree.join("; ")}` : null;
|
|
6147
6540
|
let retention = null;
|
|
6148
6541
|
if (worktreeStep && !worktreeDeferred) {
|
|
6149
|
-
|
|
6542
|
+
// A HEAD that cannot be read here is recorded as no branch and no commit, and the worktree is retained.
|
|
6543
|
+
const ref = existsSync(workPath) ? (() => { try { return worktreeHead(workPath); } catch { return { branch: null, commit: null }; } })() : { branch: meta.branch ?? null, commit: null };
|
|
6150
6544
|
const verifiedBranch = ref.branch;
|
|
6151
|
-
//
|
|
6152
|
-
|
|
6153
|
-
|
|
6154
|
-
|
|
6545
|
+
// --discard-worktree, or a quarantine that owes the worktree, removes it. Plain retire retains.
|
|
6546
|
+
if (worktreeRemoval.removes) {
|
|
6547
|
+
// HEAD as it is now against HEAD as the final inspection read it: a
|
|
6548
|
+
// worktree whose HEAD moved since is not removed, and nothing else is
|
|
6549
|
+
// done. Whatever moved it may have made a commit only it reaches.
|
|
6550
|
+
if (existsSync(workPath)) {
|
|
6551
|
+
let now;
|
|
6552
|
+
try { now = worktreeHead(workPath); }
|
|
6553
|
+
catch (e) { throw oatsError("E_WORK_INSPECTION_FAILED", `${e.message}. The worktree was not removed. The home and the worktree are kept, and so is any recovery the retire wrote; retry the retire.`); }
|
|
6554
|
+
assertSameWorktreeHead(finalObservation.head, now);
|
|
6555
|
+
}
|
|
6155
6556
|
shTry(`git -C ${shq(meta.repo)} worktree remove --force ${shq(workPath)}`);
|
|
6156
6557
|
shTry(`git -C ${shq(meta.repo)} worktree prune`);
|
|
6157
6558
|
retention = { worktree: "removed", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
|
|
6158
6559
|
} else if (existsSync(workPath)) {
|
|
6159
6560
|
const repoName = basename(realPathOrNearest(meta.repo)).replace(/\.git$/, "") || "repo";
|
|
6160
|
-
const leaf = (verifiedBranch ?? `detached-${(ref.
|
|
6561
|
+
const leaf = (verifiedBranch ?? `detached-${(ref.commit || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
|
|
6161
6562
|
const retainedRoot = join(workspaceOf(root), ".agents", "worktrees", repoName);
|
|
6162
6563
|
mkdirSync(retainedRoot, { recursive: true });
|
|
6163
6564
|
let dest = join(retainedRoot, leaf);
|
|
@@ -6167,32 +6568,18 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6167
6568
|
} catch (e) {
|
|
6168
6569
|
throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: the worktree at ${workPath} could not be re-homed to ${dest} (${String(e.stderr ?? e.message ?? "").trim()}); the home is kept so nothing is lost — resolve and retry, or pass --discard-worktree to remove the worktree instead`);
|
|
6169
6570
|
}
|
|
6170
|
-
retention = { worktree: "retained", movedTo: dest, branch: verifiedBranch, detachedAt: verifiedBranch === null ? ref.
|
|
6571
|
+
retention = { worktree: "retained", movedTo: dest, branch: verifiedBranch, detachedAt: verifiedBranch === null ? ref.commit : null, recordedBranch: meta.branch ?? null };
|
|
6171
6572
|
} else retention = { worktree: "absent", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
|
|
6172
|
-
if (o.deleteBranch && verifiedBranch) {
|
|
6173
|
-
// A plan-driven caller passes the branch it CONFIRMED. Hooks may mutate
|
|
6174
|
-
// the tree during retirement, so the branch is re-verified here, at the
|
|
6175
|
-
// moment of deletion; a mismatch deletes nothing and says so.
|
|
6176
|
-
if (o.expectedBranch !== undefined && o.expectedBranch !== verifiedBranch) {
|
|
6177
|
-
retention.branchDeletionSkipped = { expected: o.expectedBranch, actual: verifiedBranch, reason: "the worktree's branch changed between confirmation and deletion; nothing was deleted" };
|
|
6178
|
-
} else {
|
|
6179
|
-
shTry(`git -C ${shq(meta.repo)} branch -D ${shq(verifiedBranch)}`);
|
|
6180
|
-
retention.branchDeleted = verifiedBranch;
|
|
6181
|
-
}
|
|
6182
|
-
} else if (o.deleteBranch && !verifiedBranch && o.expectedBranch !== undefined) {
|
|
6183
|
-
retention.branchDeletionSkipped = { expected: o.expectedBranch, actual: null, reason: "the worktree was detached or absent at deletion time; nothing was deleted" };
|
|
6184
|
-
}
|
|
6185
6573
|
}
|
|
6186
6574
|
if (retention) {
|
|
6187
6575
|
if (retention.worktree === "retained") appendEvent(found.home, { kind: "worktree-retained", data: { movedTo: retention.movedTo, branch: retention.branch, recordedBranch: retention.recordedBranch } }, { workspaceOnly: true });
|
|
6188
6576
|
else if (retention.worktree === "removed") appendEvent(found.home, { kind: "worktree-removed", data: { branch: retention.branch } }, { workspaceOnly: true });
|
|
6189
|
-
if (retention.branchDeleted) appendEvent(found.home, { kind: "branch-deleted", data: { branch: retention.branchDeleted } }, { workspaceOnly: true });
|
|
6190
6577
|
}
|
|
6191
6578
|
// `hooks`: which retire hooks ran (in order) and how each ended — the same
|
|
6192
6579
|
// receipt `spawned` carries, so the workspace log shows both halves of a
|
|
6193
6580
|
// capability's lifecycle after the home is gone.
|
|
6194
6581
|
const retireHookReceipt = (() => { const res = hookResults || {}; const failedBy = new Map((res.failures || []).map((f) => [f.capability, f])); return (res.order || []).map((id) => ({ capability: id, ok: !failedBy.has(id), meta: Object.hasOwn(res.meta || {}, id) })); })();
|
|
6195
|
-
appendEvent(found.home, { kind: "retired", data: { agent: found.agent.name, keepDir: !!o.keepDir, self, quarantine: !!quarantine, workRecovery: workRecovery?.path ?? null, hooks: retireHookReceipt } }, { workspaceOnly: true });
|
|
6582
|
+
appendEvent(found.home, { kind: "retired", data: { agent: found.agent.name, keepDir: !!o.keepDir, self, quarantine: !!quarantine, workRecovery: workRecovery?.path ?? null, hooks: retireHookReceipt, ...(o[OBSOLETE_DELETE_BRANCH] ? { reason: OBSOLETE_DELETE_BRANCH_SENTENCE } : {}) } }, { workspaceOnly: true });
|
|
6196
6583
|
// Retrying a quarantine only clears it if compensation ACTUALLY completed.
|
|
6197
6584
|
// Otherwise the home — and the credentials in it — must survive again, or the
|
|
6198
6585
|
// retry becomes the deletion the quarantine was preventing.
|
|
@@ -6224,18 +6611,18 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6224
6611
|
const failures = [...retryFailures];
|
|
6225
6612
|
if (keptForRetry) failures.push(keptForRetry);
|
|
6226
6613
|
// The quarantine may exist BECAUSE Git cleanup failed, so a retry has to
|
|
6227
|
-
// redo those steps and verify them — not just rerun hooks.
|
|
6228
|
-
// deleted here: only --delete-branch deletes one (the verified branch, above).
|
|
6614
|
+
// redo those steps and verify them — not just rerun hooks. No retire deletes a branch.
|
|
6229
6615
|
if (meta.work === "worktree" && meta.repo) {
|
|
6230
6616
|
const gitProbe = (argv) => {
|
|
6231
6617
|
try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
|
|
6232
6618
|
catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
|
|
6233
6619
|
};
|
|
6234
6620
|
// A worktree this retry removed (the step above ran) must be verified gone; one it retained or kept for
|
|
6235
|
-
// the next retry stays registered by design.
|
|
6621
|
+
// the next retry stays registered by design. The removal is not attempted a second time here: only the
|
|
6622
|
+
// worktree step removes, after its check of HEAD, so a worktree it failed to remove is still registered
|
|
6623
|
+
// and the next retry goes through that step again.
|
|
6236
6624
|
if (retention?.worktree === "removed") {
|
|
6237
6625
|
const wtCanonical = realPathOrNearest(workPath);
|
|
6238
|
-
gitProbe(["git", "-C", meta.repo, "worktree", "remove", "--force", workPath]);
|
|
6239
6626
|
gitProbe(["git", "-C", meta.repo, "worktree", "prune"]);
|
|
6240
6627
|
const wtProbe = gitProbe(["git", "-C", meta.repo, "worktree", "list", "--porcelain", "-z"]);
|
|
6241
6628
|
if (!wtProbe.ok) failures.push(`git worktree ${wtCanonical}: could not verify removal (${wtProbe.err || "worktree list failed"})`);
|
|
@@ -6244,25 +6631,16 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6244
6631
|
if (registered.includes(wtCanonical)) failures.push(`git worktree ${wtCanonical}: still registered`);
|
|
6245
6632
|
}
|
|
6246
6633
|
}
|
|
6247
|
-
// The branch is a debt only when the failed spawn's rollback still owes its deletion
|
|
6248
|
-
//
|
|
6249
|
-
//
|
|
6250
|
-
//
|
|
6251
|
-
|
|
6252
|
-
const br = gitProbe(["git", "-C", meta.repo, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
6253
|
-
if (br.ok) return "exists";
|
|
6254
|
-
if (br.status !== 1 || br.err) { failures.push(`git branch ${branch}: could not verify whether it still exists (${br.err || `rev-parse exit ${br.status}`})`); return "unknown"; }
|
|
6255
|
-
return "gone";
|
|
6256
|
-
};
|
|
6257
|
-
const deleted = retention?.branchDeleted;
|
|
6258
|
-
if (deleted && verify(deleted) === "exists") failures.push(`git branch ${deleted}: still exists`);
|
|
6634
|
+
// The branch is a debt only when the failed spawn's rollback still owes its deletion. A retry never
|
|
6635
|
+
// deletes it: the debt is cleared only when the branch is shown to be gone. Git's quiet exit 1 is
|
|
6636
|
+
// "gone"; any other answer, or any text on standard error (a damaged ref file warns there), is
|
|
6637
|
+
// "could not verify", and the debt stays. The text is never matched: this read can only err
|
|
6638
|
+
// toward keeping the debt.
|
|
6259
6639
|
const owesBranch = (quarantine.cleanup.outstanding?.git || []).includes("branch");
|
|
6260
|
-
if (owesBranch && meta.branch
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
failures.push(
|
|
6264
|
-
? `git branch ${meta.branch}: kept; the failed spawn created it; delete it with git branch -D ${meta.branch} if unwanted`
|
|
6265
|
-
: `git branch ${meta.branch}: kept; the failed spawn created it; pass --delete-branch to delete it`);
|
|
6640
|
+
if (owesBranch && meta.branch) {
|
|
6641
|
+
const br = spawnSync("git", ["-C", meta.repo, "rev-parse", "--verify", "--quiet", `refs/heads/${meta.branch}`], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
6642
|
+
if (!br.error && br.status === 0) failures.push(FAILED_SPAWN_BRANCH_LEFT);
|
|
6643
|
+
else if (br.error || br.status !== 1 || br.stderr.length) failures.push(`git branch ${meta.branch}: could not verify whether it still exists (${String(br.stderr ?? "").trim() || br.error?.message || `rev-parse exit ${br.status}`})`);
|
|
6266
6644
|
}
|
|
6267
6645
|
}
|
|
6268
6646
|
// Git debt is proven by the verification block above, which only runs for a
|
|
@@ -6295,8 +6673,9 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6295
6673
|
rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
|
|
6296
6674
|
}
|
|
6297
6675
|
|
|
6298
|
-
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted:
|
|
6676
|
+
const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: false, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
|
|
6299
6677
|
const w = [...(hookResults?.warnings || [])];
|
|
6678
|
+
if (o[OBSOLETE_DELETE_BRANCH]) w.push(OBSOLETE_DELETE_BRANCH_SENTENCE);
|
|
6300
6679
|
if (isCapturedHome(meta) && !quarantine) {
|
|
6301
6680
|
// A captured home retires through the workspace path; its captured retire hooks do not
|
|
6302
6681
|
// run (lead decisions on (e), D1), so each capability whose spawn hook ran is named.
|
|
@@ -6313,7 +6692,15 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6313
6692
|
// The window the home RECORDED (a pre-0.31 home lives in pi-agents), never the default for new ones.
|
|
6314
6693
|
const killSession = runtimeAuthority?.tmux?.session || meta?.tmux?.session || session;
|
|
6315
6694
|
const killWindow = runtimeAuthority?.tmux?.window || meta?.tmux?.window || name;
|
|
6316
|
-
|
|
6695
|
+
// On the socket the receipt (else instance.json) records, both the scheduling and the kill; a home
|
|
6696
|
+
// that records none keeps the ambient server. run-shell expands its command as a format: a
|
|
6697
|
+
// literal # in the socket path is written ##.
|
|
6698
|
+
const killSocket = runtimeAuthority?.tmux?.socket || meta?.tmux?.socket;
|
|
6699
|
+
const killTarget = shq(`=${killSession}:=${killWindow}`);
|
|
6700
|
+
if (typeof killSocket === "string" && killSocket) {
|
|
6701
|
+
const kill = `sleep ${o.selfKillDelaySec ?? 8}; tmux -u -S ${shq(killSocket)} kill-window -t ${killTarget} 2>/dev/null || true`;
|
|
6702
|
+
shTry(`tmux -u -S ${shq(killSocket)} run-shell -b ${shq(kill.replace(/#/g, "##"))}`);
|
|
6703
|
+
} else shTry(`tmux run-shell -b 'sleep ${o.selfKillDelaySec ?? 8}; tmux kill-window -t ${killTarget} 2>/dev/null || true'`);
|
|
6317
6704
|
result.selfKillScheduled = true;
|
|
6318
6705
|
}
|
|
6319
6706
|
return result;
|