@awebai/oats 0.40.1 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/oats.mjs +45 -18
- package/docs/capabilities.md +44 -3
- package/docs/desktop-cli-api.md +147 -23
- package/docs/desktop.md +5 -4
- package/docs/execution-targets.md +342 -38
- package/docs/implementation.md +85 -11
- package/docs/oats-local.schema.json +2 -2
- package/docs/official-catalog.md +1 -1
- package/docs/packages.md +5 -5
- package/docs/release-lane.md +8 -4
- package/docs/release-notes/v0.40.2.md +120 -0
- package/docs/release-notes/v0.41.0.md +238 -0
- package/docs/schedules.md +40 -11
- package/docs/servers.md +7 -2
- package/docs/souls-and-instances.md +32 -6
- package/docs/workspaces.md +1 -1
- package/lib/core.mjs +579 -177
- package/lib/dir-lock.mjs +7 -4
- package/lib/instance-events.mjs +130 -46
- 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/schedule-command-child.mjs +39 -9
- package/lib/schedule.mjs +57 -28
- package/lib/servers.mjs +23 -9
- package/lib/session-input.mjs +42 -47
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +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")); }
|
|
@@ -2668,6 +2993,12 @@ export async function spawnInstanceAsync(root, agent, o = {}) {
|
|
|
2668
2993
|
}
|
|
2669
2994
|
return step.value;
|
|
2670
2995
|
}
|
|
2996
|
+
/** The producer sets this only when dispatched effects or their compensation
|
|
2997
|
+
* cannot be confirmed. A diagnostic's wording or home path is not evidence. */
|
|
2998
|
+
function unconfirmedSpawn(error) {
|
|
2999
|
+
error.details = { ...error.details, unconfirmed: true };
|
|
3000
|
+
return error;
|
|
3001
|
+
}
|
|
2671
3002
|
function* spawnBody(root, agent, o = {}) {
|
|
2672
3003
|
if (!o.prepared) throw localMissingForSpawn(agent);
|
|
2673
3004
|
const deliver = (r) => r;
|
|
@@ -2756,14 +3087,15 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2756
3087
|
// Completion custody: the home exists from the first metadata write, but
|
|
2757
3088
|
// the launch/lineage/events that make it a finished spawn may not have
|
|
2758
3089
|
// happened (crash in the interval). Say which, never replay a half-spawn.
|
|
2759
|
-
if (prior.spawnCompleted !== true) throw Object.assign(oatsError("E_SPAWN_INCOMPLETE", `${prior.instance} was created for this key but its spawn did not complete (launch or lineage unfinished); inspect it with oats session inspect --home ${prior.home} — do not spawn again`), { instance: prior.instance, home: prior.home, launched: prior.launched === true ? "unknown" : false });
|
|
3090
|
+
if (prior.spawnCompleted !== true) throw unconfirmedSpawn(Object.assign(oatsError("E_SPAWN_INCOMPLETE", `${prior.instance} was created for this key but its spawn did not complete (launch or lineage unfinished); inspect it with oats session inspect --home ${prior.home} — do not spawn again`), { instance: prior.instance, home: prior.home, launched: prior.launched === true ? "unknown" : false }));
|
|
2760
3091
|
return { ...prior, replayed: true, launch: undefined, command: undefined, wake: prior.wake ?? { requested: null, saved: null, error: null } };
|
|
2761
3092
|
}
|
|
2762
3093
|
}
|
|
2763
3094
|
|
|
2764
3095
|
// An explicit name may not be a soul name (soul and instance references stay
|
|
2765
3096
|
// unambiguous) and is taken when any soul of this deployment holds it, or — launched
|
|
2766
|
-
// 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):
|
|
2767
3099
|
// a typed refusal, never a silent `-2` (the operator typed it). Checked after
|
|
2768
3100
|
// key recovery, so a retried keyed spawn still reaches its own receipt; the
|
|
2769
3101
|
// placement below re-checks after its reservation (a concurrent spawn of
|
|
@@ -2772,8 +3104,12 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
2772
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`);
|
|
2773
3105
|
const holder = deploymentInstanceHomes(root).get(instance)?.[0];
|
|
2774
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 });
|
|
2775
|
-
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 });
|
|
2776
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;
|
|
2777
3113
|
|
|
2778
3114
|
// Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
|
|
2779
3115
|
// anchor the new instance to an EXISTING instance (o.relativeTo). o.parent
|
|
@@ -3218,7 +3554,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3218
3554
|
// name taken (M1). Nothing of a classic home exists at this point either.
|
|
3219
3555
|
const rollbackEmptyOrPreparedHome = (e) => {
|
|
3220
3556
|
try { rmSync(home, { recursive: true, force: true }); }
|
|
3221
|
-
catch (x) { e.message += ` — rollback INCOMPLETE, remove ${home} manually: ${x.message}`; }
|
|
3557
|
+
catch (x) { e.message += ` — rollback INCOMPLETE, remove ${home} manually: ${x.message}`; unconfirmedSpawn(e); }
|
|
3222
3558
|
return e;
|
|
3223
3559
|
};
|
|
3224
3560
|
// Workspace model: copy every resolved capability WHOLE into the new home
|
|
@@ -3386,9 +3722,10 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3386
3722
|
if (incomplete.length) {
|
|
3387
3723
|
// Nothing outside the home exists yet (no worktree, no hooks, no window), so
|
|
3388
3724
|
// removing the scaffold is the whole rollback.
|
|
3389
|
-
let removal = "";
|
|
3390
|
-
try { rmSync(home, { recursive: true, force: true }); } catch (e) { removal = ` — rollback INCOMPLETE, remove ${home} manually: ${e.message}`; }
|
|
3391
|
-
|
|
3725
|
+
let removal = "", removalFailed = false;
|
|
3726
|
+
try { rmSync(home, { recursive: true, force: true }); } catch (e) { removalFailed = true; removal = ` — rollback INCOMPLETE, remove ${home} manually: ${e.message}`; }
|
|
3727
|
+
const error = oatsError("E_COMPOSITION_INCOMPLETE", `the instance composition did not materialize completely:\n${incomplete.map((m) => ` ${m}`).join("\n")}${removal}`);
|
|
3728
|
+
throw removalFailed ? unconfirmedSpawn(error) : error;
|
|
3392
3729
|
}
|
|
3393
3730
|
|
|
3394
3731
|
// Work tree.
|
|
@@ -3433,7 +3770,8 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3433
3770
|
}
|
|
3434
3771
|
try { rmSync(home, { recursive: true, force: true }); } catch (e2) { incomplete.push(`instance home ${home}: ${e2.message}`); }
|
|
3435
3772
|
const note = incomplete.length ? ` — rollback INCOMPLETE — clean up manually: ${incomplete.join("; ")}` : "";
|
|
3436
|
-
|
|
3773
|
+
const error = new Error(`git worktree add/canonicalization failed: ${original}${note}`);
|
|
3774
|
+
throw incomplete.length ? unconfirmedSpawn(error) : error;
|
|
3437
3775
|
}
|
|
3438
3776
|
} else if (work === "directory") {
|
|
3439
3777
|
// An owned execution directory, not a link to the source or a fake Git repo.
|
|
@@ -3516,9 +3854,11 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3516
3854
|
const outstandingGit = new Set();
|
|
3517
3855
|
// A failed new-window command may still have created its window. Verify
|
|
3518
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.
|
|
3519
3858
|
if (windowMayExist) {
|
|
3520
|
-
|
|
3521
|
-
|
|
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}"]);
|
|
3522
3862
|
const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
|
|
3523
3863
|
if (unresolved) {
|
|
3524
3864
|
incomplete.push(!winProbe.ok
|
|
@@ -3529,11 +3869,11 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3529
3869
|
outstandingGit.add("worktree");
|
|
3530
3870
|
if (branch) outstandingGit.add("branch");
|
|
3531
3871
|
}
|
|
3532
|
-
return quarantineInstanceHome({
|
|
3872
|
+
return { unconfirmed: true, note: quarantineInstanceHome({
|
|
3533
3873
|
home, instance, agent, soulDir: homeSoulTarget, soulId: preparedSoulId, incomplete, failed, outstandingHooks, outstandingGit,
|
|
3534
3874
|
repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
|
|
3535
3875
|
launched: true, tmux: spawnTmux, directoryHome: homeReal, recordRetirementBaseline: true,
|
|
3536
|
-
});
|
|
3876
|
+
}) };
|
|
3537
3877
|
}
|
|
3538
3878
|
}
|
|
3539
3879
|
// Once hooks ran, directory execution may already hold authored results.
|
|
@@ -3555,7 +3895,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3555
3895
|
launched: false, directoryPreservation: true, directoryHome: homeReal,
|
|
3556
3896
|
recordRetirementBaseline: true,
|
|
3557
3897
|
});
|
|
3558
|
-
return `${note}${directoryRecoveries.length ? `; prior work recovery: ${directoryRecoveries.join(", ")}` : ""}
|
|
3898
|
+
return { unconfirmed: true, note: `${note}${directoryRecoveries.length ? `; prior work recovery: ${directoryRecoveries.join(", ")}` : ""}` };
|
|
3559
3899
|
};
|
|
3560
3900
|
const preserveDirectory = () => {
|
|
3561
3901
|
if (work !== "directory") return;
|
|
@@ -3639,7 +3979,7 @@ function* spawnBody(root, agent, o = {}) {
|
|
|
3639
3979
|
if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
|
|
3640
3980
|
note = incomplete.length ? ` — rollback INCOMPLETE, clean up manually: ${incomplete.join("; ")}` : " — spawn rolled back";
|
|
3641
3981
|
}
|
|
3642
|
-
return `${note}${directoryRecoveries.length ? `; directory work preserved at ${directoryRecoveries.join(", ")}` : ""}
|
|
3982
|
+
return { unconfirmed: incomplete.length > 0, note: `${note}${directoryRecoveries.length ? `; directory work preserved at ${directoryRecoveries.join(", ")}` : ""}` };
|
|
3643
3983
|
};
|
|
3644
3984
|
|
|
3645
3985
|
try {
|
|
@@ -3795,14 +4135,14 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3795
4135
|
if (work === "directory") assertDirectoryRoots(home, homeReal);
|
|
3796
4136
|
const executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
|
|
3797
4137
|
if (launch) {
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
}
|
|
3804
|
-
if (
|
|
3805
|
-
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;
|
|
3806
4146
|
meta.launched = true;
|
|
3807
4147
|
// Commit the final child metadata and its independent byte authority before
|
|
3808
4148
|
// the managed harness can write. No child-home transition follows launch.
|
|
@@ -3810,10 +4150,15 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3810
4150
|
writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
|
|
3811
4151
|
// Wrap the command so the window drops into an interactive shell when the
|
|
3812
4152
|
// agent exits (e.g. Ctrl-C) instead of tmux killing the window.
|
|
3813
|
-
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`);
|
|
3814
4156
|
windowMayExist = true;
|
|
3815
|
-
|
|
3816
|
-
|
|
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); }
|
|
3817
4162
|
} else {
|
|
3818
4163
|
meta.launched = false;
|
|
3819
4164
|
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
@@ -3856,10 +4201,17 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
|
|
|
3856
4201
|
meta.spawnCompleted = true;
|
|
3857
4202
|
writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n"); // a kernel-owned field: the baseline fingerprint ignores it
|
|
3858
4203
|
}
|
|
3859
|
-
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 });
|
|
3860
4205
|
} catch (error) {
|
|
3861
|
-
|
|
3862
|
-
|
|
4206
|
+
try {
|
|
4207
|
+
const compensation = compensateSpawn();
|
|
4208
|
+
error.message += compensation.note;
|
|
4209
|
+
if (compensation.unconfirmed) unconfirmedSpawn(error);
|
|
4210
|
+
} catch (cleanupError) {
|
|
4211
|
+
// An interrupted compensation pass cannot establish that effects ended.
|
|
4212
|
+
error.message += ` — rollback INCOMPLETE: cleanup could not be completed: ${cleanupError.message}`;
|
|
4213
|
+
unconfirmedSpawn(error);
|
|
4214
|
+
}
|
|
3863
4215
|
throw error;
|
|
3864
4216
|
}
|
|
3865
4217
|
}
|
|
@@ -4378,15 +4730,6 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
|
|
|
4378
4730
|
return `sha256:${hash.digest("hex")}`;
|
|
4379
4731
|
}
|
|
4380
4732
|
|
|
4381
|
-
/** The ref a worktree actually has checked out: `{branch, oid}` with
|
|
4382
|
-
* `branch === null` when HEAD is detached. Recovery derives truth from the
|
|
4383
|
-
* object, never from spawn-time metadata. */
|
|
4384
|
-
function worktreeRef(work) {
|
|
4385
|
-
const oid = execFileSync("git", ["-C", work, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
4386
|
-
let branch = null;
|
|
4387
|
-
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; }
|
|
4388
|
-
return { branch, oid };
|
|
4389
|
-
}
|
|
4390
4733
|
/** A worktree directory whose git admin entry is gone: no `.git`, an unreadable one, or a gitfile naming an
|
|
4391
4734
|
* admin directory that no longer exists. Read from the gitfile, not asked of git, which would walk up into
|
|
4392
4735
|
* whatever repository encloses the home. A `.git` directory is a repository of its own, not this case. */
|
|
@@ -4525,25 +4868,16 @@ function nestedGitRoots(root) {
|
|
|
4525
4868
|
return out;
|
|
4526
4869
|
}
|
|
4527
4870
|
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${detail || String(e.message ?? "").trim() || "git ref probe failed"}`);
|
|
4539
|
-
}
|
|
4540
|
-
try {
|
|
4541
|
-
const refs = execFileSync("git", ["-C", repo, "for-each-ref", "--format=%(refname)"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER })
|
|
4542
|
-
.split("\n").filter((ref) => ref && ref !== target);
|
|
4543
|
-
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);
|
|
4544
|
-
} catch (e) {
|
|
4545
|
-
throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${String(e.stderr ?? e.message ?? "").trim() || "git ref probe failed"}`);
|
|
4546
|
-
}
|
|
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;
|
|
4547
4881
|
}
|
|
4548
4882
|
|
|
4549
4883
|
/** Herdr (removed in 0.31.0) is recognised in a home only to refuse: its instance.json records a
|
|
@@ -4616,16 +4950,28 @@ function tmuxServerLost(e) { return /no server running on |(?:error connecting t
|
|
|
4616
4950
|
* The live processes (other than this one and its children) whose working
|
|
4617
4951
|
* directory is inside `home`: `lsof -a -d cwd` over the host, one call.
|
|
4618
4952
|
* → { ok: true, processes: [{pid, command}] } | { ok: false, error } (the scan
|
|
4619
|
-
* 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).
|
|
4620
4955
|
*/
|
|
4621
4956
|
export function processesInHome(home, io) {
|
|
4622
4957
|
let out;
|
|
4623
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"] }); }
|
|
4624
4959
|
catch (e) {
|
|
4625
|
-
// lsof exits 1 with a full listing when some process could not be read
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
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" };
|
|
4629
4975
|
const realHome = realPathOrNearest(home);
|
|
4630
4976
|
const processes = [];
|
|
4631
4977
|
let cur = {};
|
|
@@ -4817,8 +5163,8 @@ export function withLaunchModel(command, model) {
|
|
|
4817
5163
|
return renderLaunchCommand(tokens);
|
|
4818
5164
|
}
|
|
4819
5165
|
|
|
4820
|
-
function tmuxOn(socket, args, io) {
|
|
4821
|
-
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 } : {}) });
|
|
4822
5168
|
}
|
|
4823
5169
|
|
|
4824
5170
|
function writeJsonAtomic(path, value, mode) {
|
|
@@ -5123,6 +5469,9 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5123
5469
|
// A self-retirement leaves this marker while its detached teardown runs:
|
|
5124
5470
|
// the home is about to disappear, so nothing is relaunched into it.
|
|
5125
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 = [];
|
|
5126
5475
|
// 1. Reconcile a pending receipt before the equality gate: it may be the
|
|
5127
5476
|
// only record of a session an earlier start allocated.
|
|
5128
5477
|
if (existsSync(pendingPath)) {
|
|
@@ -5162,6 +5511,14 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5162
5511
|
// in whichever log the interrupted start did not record it.
|
|
5163
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" });
|
|
5164
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
|
+
}
|
|
5165
5522
|
if (st.present && st.state !== "shell") {
|
|
5166
5523
|
if (o.restart) { rmSync(pendingPath, { force: true }); }
|
|
5167
5524
|
else {
|
|
@@ -5170,7 +5527,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5170
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`);
|
|
5171
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`);
|
|
5172
5529
|
if (meta.startId === pending.id) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running; nothing was started`);
|
|
5173
|
-
return done;
|
|
5530
|
+
return { ...done, warnings: [...done.warnings, ...movedWarnings] };
|
|
5174
5531
|
}
|
|
5175
5532
|
}
|
|
5176
5533
|
}
|
|
@@ -5231,15 +5588,26 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5231
5588
|
checkRoots(); // preparation has run; no backend has been observed
|
|
5232
5589
|
let target = receipt.target;
|
|
5233
5590
|
let state = { present: false, state: "not-launched" };
|
|
5234
|
-
let serverGone = false;
|
|
5235
5591
|
if (target) {
|
|
5236
5592
|
try { state = inspectSessionTarget(target, o.io); }
|
|
5237
5593
|
catch (e) {
|
|
5238
|
-
if (lostTmuxServer(e))
|
|
5594
|
+
if (lostTmuxServer(e)) state = { present: false, state: "stopped" };
|
|
5239
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}`);
|
|
5240
5596
|
}
|
|
5241
5597
|
}
|
|
5242
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;
|
|
5243
5611
|
// Every preflight has passed: the preview-aware launch hooks run for real
|
|
5244
5612
|
// (they may register the home with their provider), before a restart's
|
|
5245
5613
|
// stop, and must contribute exactly what they contributed as a preview,
|
|
@@ -5278,7 +5646,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5278
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);
|
|
5279
5647
|
appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
|
|
5280
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")}`);
|
|
5281
|
-
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}`); }
|
|
5282
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`);
|
|
5283
5651
|
}
|
|
5284
5652
|
// The kernel that launches the harness is the one the agent's plain `oats` runs.
|
|
@@ -5293,52 +5661,39 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5293
5661
|
const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
|
|
5294
5662
|
const window = target?.window || meta.tmux?.window || meta.instance;
|
|
5295
5663
|
let socket = target?.socket || meta.tmux?.socket;
|
|
5296
|
-
const windowCmd =
|
|
5664
|
+
const windowCmd = paneCommand(completedCommand);
|
|
5665
|
+
let moved = null;
|
|
5297
5666
|
// A fallback shell (no harness descendant) or a retained dead pane is
|
|
5298
|
-
// 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.
|
|
5299
5669
|
const inPlace = state.paneId && (state.present || state.state === "stopped");
|
|
5300
5670
|
if (inPlace) {
|
|
5301
5671
|
checkRoots();
|
|
5302
5672
|
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
5303
|
-
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()); }
|
|
5304
5674
|
catch (e) { throw launchFailure(e); }
|
|
5305
5675
|
reused = "pane";
|
|
5306
5676
|
} else {
|
|
5307
5677
|
const instancesRoot = dirname(realHome);
|
|
5308
5678
|
const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
|
|
5309
5679
|
checkRoots();
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
if (!alive) {
|
|
5316
|
-
defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
|
|
5317
|
-
for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
|
|
5318
|
-
try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
|
|
5319
|
-
}
|
|
5320
|
-
}
|
|
5321
|
-
socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
|
|
5322
|
-
if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
|
|
5323
|
-
} else if (serverGone) {
|
|
5324
|
-
// The recorded server is gone (a reboot): the same socket path again.
|
|
5325
|
-
mkdirSync(dirname(socket), { recursive: true });
|
|
5326
|
-
tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
|
|
5327
|
-
tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
|
|
5328
|
-
tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
|
|
5329
|
-
}
|
|
5330
|
-
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;
|
|
5331
5685
|
try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
|
|
5332
5686
|
catch (e) {
|
|
5333
|
-
if (
|
|
5334
|
-
|
|
5687
|
+
if (oatsCoded(e)) throw e;
|
|
5688
|
+
throw oatsError("E_SESSION_UNKNOWN", `cannot list tmux windows on ${socket}: ${tmuxFailure(e, "tmux list-windows failed")}`);
|
|
5335
5689
|
}
|
|
5336
5690
|
if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
|
|
5337
|
-
target = { backend: "tmux", session, window, socket
|
|
5691
|
+
target = { backend: "tmux", session, window, socket };
|
|
5338
5692
|
checkRoots();
|
|
5339
5693
|
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
|
|
5340
|
-
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); }
|
|
5341
5695
|
catch (e) { throw launchFailure(e); }
|
|
5696
|
+
if (recordedSocket && recordedSocket !== socket) moved = sessionMovedWarning(meta.instance, recordedSocket, socket);
|
|
5342
5697
|
}
|
|
5343
5698
|
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
5344
5699
|
// The session exists: its boundary (lib/instance-events.mjs) is recorded
|
|
@@ -5348,11 +5703,15 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5348
5703
|
// Keep launch evidence until the command exits or the target disappears.
|
|
5349
5704
|
// A transient child (for example the native-start recorder) is not proof that startup
|
|
5350
5705
|
// has finished. A later start reconciles the receipt without a watcher.
|
|
5351
|
-
|
|
5706
|
+
let done;
|
|
5707
|
+
try { done = record(meta, { id, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false); }
|
|
5352
5708
|
catch (e) {
|
|
5353
5709
|
if (e.code && String(e.code).startsWith("E_")) throw e;
|
|
5354
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`);
|
|
5355
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] };
|
|
5356
5715
|
} finally {
|
|
5357
5716
|
// A hook may have replaced the home itself. Never follow that replacement
|
|
5358
5717
|
// to remove a target's lock; keep the original retry state with its home.
|
|
@@ -5363,7 +5722,7 @@ export function startInstanceSession(home, o = {}) {
|
|
|
5363
5722
|
}
|
|
5364
5723
|
}
|
|
5365
5724
|
|
|
5366
|
-
function inspectRetirementWork(home, work, isWorktree, {
|
|
5725
|
+
function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktreeRemoval, directory = false, orphanedWork = false } = {}) {
|
|
5367
5726
|
if (directory) assertDirectoryRoots(home);
|
|
5368
5727
|
const classes = [];
|
|
5369
5728
|
let baseline;
|
|
@@ -5390,8 +5749,13 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
|
|
|
5390
5749
|
// Never stamp hook-created or authored execution bytes as disposable.
|
|
5391
5750
|
if (readdirSync(work).length) classes.push("directory work bytes");
|
|
5392
5751
|
}
|
|
5393
|
-
|
|
5394
|
-
|
|
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");
|
|
5395
5759
|
if (isWorktree && existsSync(work)) {
|
|
5396
5760
|
const status = worktreeStatus(work);
|
|
5397
5761
|
const rows = status.split("\0").filter(Boolean);
|
|
@@ -5400,11 +5764,16 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
|
|
|
5400
5764
|
if (!baseline || baseline.generatedWorkFingerprint !== generatedWorkFingerprint(work, status, disposableRoots)) classes.push("untracked or ignored worktree bytes");
|
|
5401
5765
|
if (nestedGitRoots(work).length) classes.push("nested repository state");
|
|
5402
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.
|
|
5403
5770
|
const stateFingerprint = createHash("sha256")
|
|
5404
5771
|
.update(fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }))
|
|
5405
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 ?? "")
|
|
5406
5775
|
.digest("hex");
|
|
5407
|
-
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 };
|
|
5408
5777
|
}
|
|
5409
5778
|
|
|
5410
5779
|
function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
|
|
@@ -5604,6 +5973,7 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
5604
5973
|
}
|
|
5605
5974
|
const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
|
|
5606
5975
|
const recovery = join(recoveryRoot, basename(staging).slice(1));
|
|
5976
|
+
let headUnreadable;
|
|
5607
5977
|
try {
|
|
5608
5978
|
// A home-only change (notes, harness files, credentials) needs a home
|
|
5609
5979
|
// snapshot, not another copy of an otherwise disposable clean worktree.
|
|
@@ -5628,14 +5998,18 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
5628
5998
|
// The branch is derived from the worktree while it exists: an instance
|
|
5629
5999
|
// that legitimately switched branches during its task must still be
|
|
5630
6000
|
// recoverable, and the recorded spawn-time branch is only the fallback
|
|
5631
|
-
// when the worktree is gone.
|
|
5632
|
-
|
|
5633
|
-
|
|
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 };
|
|
5634
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 });
|
|
5635
6009
|
else {
|
|
5636
6010
|
execFileSync("git", ["clone", "--no-local", "--quiet", "--no-checkout", meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
|
|
5637
|
-
execFileSync("git", ["-C", recoveredRepo, "fetch", "--quiet", observation.work, ref.
|
|
5638
|
-
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 });
|
|
5639
6013
|
}
|
|
5640
6014
|
const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
|
|
5641
6015
|
detachRecoveryClone(sourceGitContext, recoveredRepo);
|
|
@@ -5657,10 +6031,15 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
5657
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");
|
|
5658
6032
|
assertStatusAgrees(observation.work, recoveredRepo, "recovered Git index/status disagreed with the source", ".");
|
|
5659
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.
|
|
5660
6039
|
const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
|
|
5661
|
-
const sourceHead = ref.
|
|
5662
|
-
|
|
5663
|
-
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");
|
|
5664
6043
|
}
|
|
5665
6044
|
if (observation.directory && observation.directoryFingerprint) {
|
|
5666
6045
|
const recoveredWork = join(staging, "work");
|
|
@@ -5680,6 +6059,7 @@ function preserveRetirementWork(observation, meta, instance) {
|
|
|
5680
6059
|
return { path: recovery, classes: observation.classes, bytes, ...(outputs ? { outputs } : {}), ...(repoCopy ? { repoCopy } : {}) };
|
|
5681
6060
|
} catch (e) {
|
|
5682
6061
|
rmSync(staging, { recursive: true, force: true });
|
|
6062
|
+
if (e === headUnreadable) throw e;
|
|
5683
6063
|
const details = e.statusDisagreement ? { home: observation.home, statusDisagreement: e.statusDisagreement } : undefined;
|
|
5684
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 } : {});
|
|
5685
6065
|
}
|
|
@@ -5729,7 +6109,7 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
|
|
|
5729
6109
|
const intent = {
|
|
5730
6110
|
instance: name, agent: found.agent.name, root: resolve(root),
|
|
5731
6111
|
requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
|
|
5732
|
-
options: { home: found.home,
|
|
6112
|
+
options: { home: found.home, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
|
|
5733
6113
|
};
|
|
5734
6114
|
const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
|
|
5735
6115
|
for (const k of CORE_LAUNCH_ENV) delete env[k];
|
|
@@ -5771,6 +6151,16 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
|
|
|
5771
6151
|
* (and, after hooks ran, the usual quarantine) in place, so `oats status`
|
|
5772
6152
|
* shows the debt and `oats retire <name>` retries and clears it. Accepts the
|
|
5773
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
|
+
|
|
5774
6164
|
export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
|
|
5775
6165
|
let intent = intentOrMarkerPath;
|
|
5776
6166
|
if (typeof intentOrMarkerPath === "string") {
|
|
@@ -5787,18 +6177,24 @@ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
|
|
|
5787
6177
|
}, null, 2) + "\n");
|
|
5788
6178
|
} catch (e) { console.error(`deferred retirement: cannot write ${intent.resultPath}: ${e.message}`); }
|
|
5789
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;
|
|
5790
6184
|
const delayMs = Math.max(0, Number(opts.delaySec ?? intent.delaySec ?? 8) * 1000);
|
|
5791
6185
|
if (delayMs) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
|
|
5792
6186
|
let result;
|
|
5793
6187
|
try {
|
|
5794
6188
|
result = retireInstance(intent.root, intent.instance, {
|
|
5795
|
-
home: intent.options?.home,
|
|
6189
|
+
home: intent.options?.home, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
|
|
6190
|
+
...(obsoleteDeleteBranch ? { [OBSOLETE_DELETE_BRANCH]: true } : {}),
|
|
5796
6191
|
});
|
|
5797
6192
|
} catch (e) {
|
|
5798
|
-
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}` : ""}` });
|
|
5799
6194
|
console.error(`deferred retirement of ${intent.instance} failed: ${e.message}`);
|
|
5800
6195
|
return false;
|
|
5801
6196
|
}
|
|
6197
|
+
if (obsoleteDeleteBranch) console.log(OBSOLETE_DELETE_BRANCH_SENTENCE);
|
|
5802
6198
|
if (result.rollbackIncomplete) {
|
|
5803
6199
|
record({ ok: false, result, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
|
|
5804
6200
|
console.error(`deferred retirement of ${intent.instance} is INCOMPLETE; the home is retained:\n ${result.rollbackIncomplete.join("\n ")}`);
|
|
@@ -5818,6 +6214,7 @@ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
|
|
|
5818
6214
|
}
|
|
5819
6215
|
|
|
5820
6216
|
export function retireInstance(root, name, o = {}) {
|
|
6217
|
+
if (o.deleteBranch) throw oatsError("E_BAD_ARGS", RETIRE_DELETE_BRANCH_REFUSED);
|
|
5821
6218
|
const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
|
|
5822
6219
|
// self-retire: the caller IS the instance. Without --keep-dir the whole
|
|
5823
6220
|
// retirement is deferred to a detached external completion (below); with
|
|
@@ -5929,8 +6326,10 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5929
6326
|
// First inspection is non-destructive. Only after it succeeds may OATS quiesce
|
|
5930
6327
|
// the managed harness; recovery copying never races a live managed Pi.
|
|
5931
6328
|
const owesWorktree = !!quarantine && (quarantine.cleanup.outstanding?.git || []).includes("worktree");
|
|
5932
|
-
const
|
|
5933
|
-
|
|
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 });
|
|
5934
6333
|
// Harness identity is destructive authority. The mutable child metadata may
|
|
5935
6334
|
// describe it for humans, but only the independent baseline can authorize the
|
|
5936
6335
|
// endpoint that proves quiescence.
|
|
@@ -5973,10 +6372,20 @@ export function retireInstance(root, name, o = {}) {
|
|
|
5973
6372
|
if (windows.includes(runtimeWindow)) throw new Error(`tmux window ${runtimeSession}:${runtimeWindow} is still running`);
|
|
5974
6373
|
} catch (e) {
|
|
5975
6374
|
const detail = String(e.stderr ?? e.message ?? "").trim();
|
|
5976
|
-
|
|
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
|
+
}
|
|
5977
6386
|
}
|
|
5978
6387
|
}
|
|
5979
|
-
const stableObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, {
|
|
6388
|
+
const stableObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
|
|
5980
6389
|
const workRecoveries = [];
|
|
5981
6390
|
if (stableObservation.classes.length) workRecoveries.push(preserveRetirementWork(stableObservation, meta, name));
|
|
5982
6391
|
let workRecovery = workRecoveries.at(-1);
|
|
@@ -6055,7 +6464,7 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6055
6464
|
|
|
6056
6465
|
// Hooks are allowed to mutate the inspected tree, so inspect again after
|
|
6057
6466
|
// them and preserve a separately verified post-hook snapshot when needed.
|
|
6058
|
-
const finalObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, {
|
|
6467
|
+
const finalObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
|
|
6059
6468
|
if (finalObservation.classes.length && finalObservation.stateFingerprint !== stableObservation.stateFingerprint) {
|
|
6060
6469
|
workRecoveries.push(preserveRetirementWork(finalObservation, meta, name));
|
|
6061
6470
|
workRecovery = workRecoveries.at(-1);
|
|
@@ -6117,32 +6526,39 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6117
6526
|
// branch and any PR outlive the home. The worktree cannot stay under
|
|
6118
6527
|
// <home>/work once the home is removed, so it is RE-HOMED with
|
|
6119
6528
|
// `git worktree move` to the deployment-level worktrees root and the move is
|
|
6120
|
-
// recorded in the receipt. `discardWorktree` restores removal
|
|
6121
|
-
//
|
|
6122
|
-
// 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).
|
|
6123
6531
|
// The worktree step runs only once nothing else is outstanding (awebai/oats#444): a hook that did not finish
|
|
6124
6532
|
// may need the worktree, and its retry needs the home, the worktree and its admin entry exactly as they
|
|
6125
6533
|
// were. --force removes the home regardless, so the step runs first and no admin entry is left dangling.
|
|
6126
6534
|
// A failed spawn's quarantine that owes the worktree removes it; any other retire retains it unless
|
|
6127
|
-
// --discard-worktree
|
|
6535
|
+
// --discard-worktree. An orphaned work directory is never touched.
|
|
6128
6536
|
const outstandingBeforeWorktree = quarantine ? retryFailures : ordinaryIncomplete;
|
|
6129
6537
|
const worktreeStep = isWorktree && !!meta.repo && !orphanedWork;
|
|
6130
6538
|
const worktreeDeferred = worktreeStep && existsSync(workPath) && outstandingBeforeWorktree.length > 0 && !o.force;
|
|
6131
6539
|
const keptForRetry = worktreeDeferred ? `git worktree ${workPath}: kept for the retry; outstanding: ${outstandingBeforeWorktree.join("; ")}` : null;
|
|
6132
6540
|
let retention = null;
|
|
6133
6541
|
if (worktreeStep && !worktreeDeferred) {
|
|
6134
|
-
|
|
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 };
|
|
6135
6544
|
const verifiedBranch = ref.branch;
|
|
6136
|
-
//
|
|
6137
|
-
|
|
6138
|
-
|
|
6139
|
-
|
|
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
|
+
}
|
|
6140
6556
|
shTry(`git -C ${shq(meta.repo)} worktree remove --force ${shq(workPath)}`);
|
|
6141
6557
|
shTry(`git -C ${shq(meta.repo)} worktree prune`);
|
|
6142
6558
|
retention = { worktree: "removed", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
|
|
6143
6559
|
} else if (existsSync(workPath)) {
|
|
6144
6560
|
const repoName = basename(realPathOrNearest(meta.repo)).replace(/\.git$/, "") || "repo";
|
|
6145
|
-
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";
|
|
6146
6562
|
const retainedRoot = join(workspaceOf(root), ".agents", "worktrees", repoName);
|
|
6147
6563
|
mkdirSync(retainedRoot, { recursive: true });
|
|
6148
6564
|
let dest = join(retainedRoot, leaf);
|
|
@@ -6152,32 +6568,18 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6152
6568
|
} catch (e) {
|
|
6153
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`);
|
|
6154
6570
|
}
|
|
6155
|
-
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 };
|
|
6156
6572
|
} else retention = { worktree: "absent", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
|
|
6157
|
-
if (o.deleteBranch && verifiedBranch) {
|
|
6158
|
-
// A plan-driven caller passes the branch it CONFIRMED. Hooks may mutate
|
|
6159
|
-
// the tree during retirement, so the branch is re-verified here, at the
|
|
6160
|
-
// moment of deletion; a mismatch deletes nothing and says so.
|
|
6161
|
-
if (o.expectedBranch !== undefined && o.expectedBranch !== verifiedBranch) {
|
|
6162
|
-
retention.branchDeletionSkipped = { expected: o.expectedBranch, actual: verifiedBranch, reason: "the worktree's branch changed between confirmation and deletion; nothing was deleted" };
|
|
6163
|
-
} else {
|
|
6164
|
-
shTry(`git -C ${shq(meta.repo)} branch -D ${shq(verifiedBranch)}`);
|
|
6165
|
-
retention.branchDeleted = verifiedBranch;
|
|
6166
|
-
}
|
|
6167
|
-
} else if (o.deleteBranch && !verifiedBranch && o.expectedBranch !== undefined) {
|
|
6168
|
-
retention.branchDeletionSkipped = { expected: o.expectedBranch, actual: null, reason: "the worktree was detached or absent at deletion time; nothing was deleted" };
|
|
6169
|
-
}
|
|
6170
6573
|
}
|
|
6171
6574
|
if (retention) {
|
|
6172
6575
|
if (retention.worktree === "retained") appendEvent(found.home, { kind: "worktree-retained", data: { movedTo: retention.movedTo, branch: retention.branch, recordedBranch: retention.recordedBranch } }, { workspaceOnly: true });
|
|
6173
6576
|
else if (retention.worktree === "removed") appendEvent(found.home, { kind: "worktree-removed", data: { branch: retention.branch } }, { workspaceOnly: true });
|
|
6174
|
-
if (retention.branchDeleted) appendEvent(found.home, { kind: "branch-deleted", data: { branch: retention.branchDeleted } }, { workspaceOnly: true });
|
|
6175
6577
|
}
|
|
6176
6578
|
// `hooks`: which retire hooks ran (in order) and how each ended — the same
|
|
6177
6579
|
// receipt `spawned` carries, so the workspace log shows both halves of a
|
|
6178
6580
|
// capability's lifecycle after the home is gone.
|
|
6179
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) })); })();
|
|
6180
|
-
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 });
|
|
6181
6583
|
// Retrying a quarantine only clears it if compensation ACTUALLY completed.
|
|
6182
6584
|
// Otherwise the home — and the credentials in it — must survive again, or the
|
|
6183
6585
|
// retry becomes the deletion the quarantine was preventing.
|
|
@@ -6209,18 +6611,18 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6209
6611
|
const failures = [...retryFailures];
|
|
6210
6612
|
if (keptForRetry) failures.push(keptForRetry);
|
|
6211
6613
|
// The quarantine may exist BECAUSE Git cleanup failed, so a retry has to
|
|
6212
|
-
// redo those steps and verify them — not just rerun hooks.
|
|
6213
|
-
// 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.
|
|
6214
6615
|
if (meta.work === "worktree" && meta.repo) {
|
|
6215
6616
|
const gitProbe = (argv) => {
|
|
6216
6617
|
try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
|
|
6217
6618
|
catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
|
|
6218
6619
|
};
|
|
6219
6620
|
// A worktree this retry removed (the step above ran) must be verified gone; one it retained or kept for
|
|
6220
|
-
// 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.
|
|
6221
6624
|
if (retention?.worktree === "removed") {
|
|
6222
6625
|
const wtCanonical = realPathOrNearest(workPath);
|
|
6223
|
-
gitProbe(["git", "-C", meta.repo, "worktree", "remove", "--force", workPath]);
|
|
6224
6626
|
gitProbe(["git", "-C", meta.repo, "worktree", "prune"]);
|
|
6225
6627
|
const wtProbe = gitProbe(["git", "-C", meta.repo, "worktree", "list", "--porcelain", "-z"]);
|
|
6226
6628
|
if (!wtProbe.ok) failures.push(`git worktree ${wtCanonical}: could not verify removal (${wtProbe.err || "worktree list failed"})`);
|
|
@@ -6229,25 +6631,16 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6229
6631
|
if (registered.includes(wtCanonical)) failures.push(`git worktree ${wtCanonical}: still registered`);
|
|
6230
6632
|
}
|
|
6231
6633
|
}
|
|
6232
|
-
// The branch is a debt only when the failed spawn's rollback still owes its deletion
|
|
6233
|
-
//
|
|
6234
|
-
//
|
|
6235
|
-
//
|
|
6236
|
-
|
|
6237
|
-
const br = gitProbe(["git", "-C", meta.repo, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
6238
|
-
if (br.ok) return "exists";
|
|
6239
|
-
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"; }
|
|
6240
|
-
return "gone";
|
|
6241
|
-
};
|
|
6242
|
-
const deleted = retention?.branchDeleted;
|
|
6243
|
-
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.
|
|
6244
6639
|
const owesBranch = (quarantine.cleanup.outstanding?.git || []).includes("branch");
|
|
6245
|
-
if (owesBranch && meta.branch
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
failures.push(
|
|
6249
|
-
? `git branch ${meta.branch}: kept; the failed spawn created it; delete it with git branch -D ${meta.branch} if unwanted`
|
|
6250
|
-
: `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}`})`);
|
|
6251
6644
|
}
|
|
6252
6645
|
}
|
|
6253
6646
|
// Git debt is proven by the verification block above, which only runs for a
|
|
@@ -6280,8 +6673,9 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6280
6673
|
rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
|
|
6281
6674
|
}
|
|
6282
6675
|
|
|
6283
|
-
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: (() => {
|
|
6284
6677
|
const w = [...(hookResults?.warnings || [])];
|
|
6678
|
+
if (o[OBSOLETE_DELETE_BRANCH]) w.push(OBSOLETE_DELETE_BRANCH_SENTENCE);
|
|
6285
6679
|
if (isCapturedHome(meta) && !quarantine) {
|
|
6286
6680
|
// A captured home retires through the workspace path; its captured retire hooks do not
|
|
6287
6681
|
// run (lead decisions on (e), D1), so each capability whose spawn hook ran is named.
|
|
@@ -6298,7 +6692,15 @@ export function retireInstance(root, name, o = {}) {
|
|
|
6298
6692
|
// The window the home RECORDED (a pre-0.31 home lives in pi-agents), never the default for new ones.
|
|
6299
6693
|
const killSession = runtimeAuthority?.tmux?.session || meta?.tmux?.session || session;
|
|
6300
6694
|
const killWindow = runtimeAuthority?.tmux?.window || meta?.tmux?.window || name;
|
|
6301
|
-
|
|
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'`);
|
|
6302
6704
|
result.selfKillScheduled = true;
|
|
6303
6705
|
}
|
|
6304
6706
|
return result;
|