@sentropic/h2a-runtime 0.91.1 → 0.93.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/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +16 -3
- package/dist/config.js.map +1 -1
- package/dist/conv-guard.d.ts +6 -1
- package/dist/conv-guard.d.ts.map +1 -1
- package/dist/conv-guard.js +33 -8
- package/dist/conv-guard.js.map +1 -1
- package/dist/enroll.d.ts.map +1 -1
- package/dist/enroll.js +12 -2
- package/dist/enroll.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +819 -155
- package/dist/index.js.map +1 -1
- package/dist/launch-layout.d.ts +74 -0
- package/dist/launch-layout.d.ts.map +1 -0
- package/dist/launch-layout.js +324 -0
- package/dist/launch-layout.js.map +1 -0
- package/dist/llm-gateway-runtime/index.d.ts +6 -0
- package/dist/llm-gateway-runtime/index.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/index.js +134 -60
- package/dist/llm-gateway-runtime/index.js.map +1 -1
- package/dist/llm-gateway-runtime/session-ledger.d.ts +38 -55
- package/dist/llm-gateway-runtime/session-ledger.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/session-ledger.js +102 -250
- package/dist/llm-gateway-runtime/session-ledger.js.map +1 -1
- package/dist/llm-gateway-runtime/sticky.d.ts +25 -52
- package/dist/llm-gateway-runtime/sticky.d.ts.map +1 -1
- package/dist/llm-gateway-runtime/sticky.js +36 -605
- package/dist/llm-gateway-runtime/sticky.js.map +1 -1
- package/dist/llm-mesh.d.ts +19 -72
- package/dist/llm-mesh.d.ts.map +1 -1
- package/dist/llm-mesh.js +42 -399
- package/dist/llm-mesh.js.map +1 -1
- package/dist/llm-routing-config.d.ts +28 -0
- package/dist/llm-routing-config.d.ts.map +1 -0
- package/dist/llm-routing-config.js +100 -0
- package/dist/llm-routing-config.js.map +1 -0
- package/dist/native-host.d.ts +146 -0
- package/dist/native-host.d.ts.map +1 -0
- package/dist/native-host.js +456 -0
- package/dist/native-host.js.map +1 -0
- package/dist/native-terminal/host.d.ts +317 -1
- package/dist/native-terminal/host.d.ts.map +1 -1
- package/dist/native-terminal/host.js +696 -3
- package/dist/native-terminal/host.js.map +1 -1
- package/dist/native-terminal/op.d.ts +5 -0
- package/dist/native-terminal/op.d.ts.map +1 -0
- package/dist/native-terminal/op.js +408 -0
- package/dist/native-terminal/op.js.map +1 -0
- package/dist/native-terminal/process.d.ts +2 -0
- package/dist/native-terminal/process.d.ts.map +1 -1
- package/dist/native-terminal/process.js +16 -2
- package/dist/native-terminal/process.js.map +1 -1
- package/dist/native-terminal/server.d.ts.map +1 -1
- package/dist/native-terminal/server.js +6 -2
- package/dist/native-terminal/server.js.map +1 -1
- package/dist/native-terminal/socket-path.d.ts +8 -0
- package/dist/native-terminal/socket-path.d.ts.map +1 -1
- package/dist/native-terminal/socket-path.js +31 -1
- package/dist/native-terminal/socket-path.js.map +1 -1
- package/dist/native-terminal/supervisor.d.ts +13 -0
- package/dist/native-terminal/supervisor.d.ts.map +1 -1
- package/dist/native-terminal/supervisor.js +45 -0
- package/dist/native-terminal/supervisor.js.map +1 -1
- package/dist/pty.d.ts +23 -0
- package/dist/pty.d.ts.map +1 -1
- package/dist/pty.js +35 -0
- package/dist/pty.js.map +1 -1
- package/dist/readiness.d.ts +7 -0
- package/dist/readiness.d.ts.map +1 -1
- package/dist/readiness.js +12 -9
- package/dist/readiness.js.map +1 -1
- package/dist/registry.d.ts +324 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +659 -20
- package/dist/registry.js.map +1 -1
- package/dist/restore.d.ts +91 -32
- package/dist/restore.d.ts.map +1 -1
- package/dist/restore.js +226 -186
- package/dist/restore.js.map +1 -1
- package/dist/status-projection.d.ts.map +1 -1
- package/dist/tmux.d.ts +9 -6
- package/dist/tmux.d.ts.map +1 -1
- package/dist/tmux.js +34 -8
- package/dist/tmux.js.map +1 -1
- package/package.json +3 -3
- package/dist/llm-gateway-runtime/accounts.d.ts +0 -62
- package/dist/llm-gateway-runtime/accounts.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/accounts.js +0 -323
- package/dist/llm-gateway-runtime/accounts.js.map +0 -1
- package/dist/llm-gateway-runtime/model-catalog.d.ts +0 -44
- package/dist/llm-gateway-runtime/model-catalog.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/model-catalog.js +0 -191
- package/dist/llm-gateway-runtime/model-catalog.js.map +0 -1
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts +0 -3
- package/dist/llm-gateway-runtime/proxy-anthropic.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/proxy-anthropic.js +0 -318
- package/dist/llm-gateway-runtime/proxy-anthropic.js.map +0 -1
- package/dist/llm-gateway-runtime/proxy-cloud-code.d.ts +0 -11
- package/dist/llm-gateway-runtime/proxy-cloud-code.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/proxy-cloud-code.js +0 -215
- package/dist/llm-gateway-runtime/proxy-cloud-code.js.map +0 -1
- package/dist/llm-gateway-runtime/proxy-openai.d.ts +0 -118
- package/dist/llm-gateway-runtime/proxy-openai.d.ts.map +0 -1
- package/dist/llm-gateway-runtime/proxy-openai.js +0 -1305
- package/dist/llm-gateway-runtime/proxy-openai.js.map +0 -1
package/dist/registry.js
CHANGED
|
@@ -18,7 +18,12 @@ import { uptime } from "node:os";
|
|
|
18
18
|
import { dirname, join } from "node:path";
|
|
19
19
|
import { getLayoutConfig, resolveConfigPath } from "./config.js";
|
|
20
20
|
import { acquireFileLock, releaseFileLock } from "./file-lock.js";
|
|
21
|
-
import { managedSessionCandidates, parseManagedSessionName, listLocalSessions, } from "./tmux.js";
|
|
21
|
+
import { localSessionName, managedSessionCandidates, parseManagedSessionName, listLocalSessions, tmuxAvailable, } from "./tmux.js";
|
|
22
|
+
import { nativeSessionLiveness } from "./native-host.js";
|
|
23
|
+
/** Managed local interactive session hosted by tmux OR the native PTY host. */
|
|
24
|
+
export function isManagedLocalKind(kind) {
|
|
25
|
+
return kind === "local-tmux" || kind === "local-native";
|
|
26
|
+
}
|
|
22
27
|
/** System boot time in ms epoch (now minus uptime). */
|
|
23
28
|
function defaultBootTimeMs() {
|
|
24
29
|
return Date.now() - uptime() * 1000;
|
|
@@ -38,19 +43,204 @@ const CONVERSATION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[
|
|
|
38
43
|
export function looksLikeConversationUuid(value) {
|
|
39
44
|
return value !== undefined && CONVERSATION_UUID_RE.test(value);
|
|
40
45
|
}
|
|
46
|
+
function isRestorePinnedCandidate(entry) {
|
|
47
|
+
return (entry.kind === "local-tmux" &&
|
|
48
|
+
entry.source === "run" &&
|
|
49
|
+
entry.sessionClass === "human" &&
|
|
50
|
+
!entry.endedAt &&
|
|
51
|
+
looksLikeConversationUuid(entry.convId));
|
|
52
|
+
}
|
|
53
|
+
function shouldPreserveByRestorePin(entry) {
|
|
54
|
+
if (entry.restorePinned === false)
|
|
55
|
+
return false;
|
|
56
|
+
if (isRestorePinnedCandidate(entry))
|
|
57
|
+
return true;
|
|
58
|
+
// Invariant: restore pin never preserves positively non-human rows (no implicit pin by absence).
|
|
59
|
+
// As of 2026-08-08 this affects 0 lines — the 29 role=job rows with absent sessionClass are all kind=remote, already outside the perimeter by the kind/source gates; role==='job' is added by SYMMETRY with sessionClass==='background' so a positively non-human row is never pinned even when classless.
|
|
60
|
+
if (entry.endedAt || entry.sessionClass === "background" || entry.role === "job")
|
|
61
|
+
return false;
|
|
62
|
+
return (entry.kind === "local-tmux" &&
|
|
63
|
+
entry.source === "run" &&
|
|
64
|
+
// Invariant: "unknown protects" inside the local-tmux/run restore-pin perimeter.
|
|
65
|
+
// Restore-pin covers durable human local-tmux/run rows, while remote sessions
|
|
66
|
+
// are deliberately out of the restore-pin perimeter.
|
|
67
|
+
// As of 2026-08-08, the architect measured 29 sessionClass-absent lines; all were kind=remote, so this guard currently has no observed effect.
|
|
68
|
+
(entry.sessionClass === "human" || entry.sessionClass === undefined));
|
|
69
|
+
}
|
|
70
|
+
// Returns the 3-state RegistryReadResult above
|
|
71
|
+
// ({state:"ok",entries,unreadable} | {state:"unknown",reason}), NOT a plain
|
|
72
|
+
// array — a bare `.map()`/`.find()`/`.some()`/`.every()` on the return value
|
|
73
|
+
// is a bug. tsc does NOT type-check `.js` files, so a signature change here
|
|
74
|
+
// is invisible to JS callers (e.g. packages/h2a/test/*.js) even though every
|
|
75
|
+
// `.ts` caller gets a compile error. Anyone widening/changing this return
|
|
76
|
+
// type MUST grep repo-wide for BOTH extensions before trusting the compiler
|
|
77
|
+
// to be exhaustive:
|
|
78
|
+
// git grep -nE 'loadRegistry\b' -- '**/*.js' '**/*.ts' | grep -v loadRegistryWithDiagnostics
|
|
41
79
|
export function loadRegistry(path = resolveRegistryPath()) {
|
|
80
|
+
const raw = rawRegistryRead(path);
|
|
81
|
+
if (raw.state === "unknown")
|
|
82
|
+
return raw;
|
|
83
|
+
// B2 — WHOLE-FILE trace, same logic as the per-identity `unreadable` check
|
|
84
|
+
// applied to the WHOLE file: this file was REBUILT from a registry the
|
|
85
|
+
// write path could not read (rawRegistryEntriesForWrite moved the
|
|
86
|
+
// unreadable bytes aside to a sibling registry.corrupt-*.json and rebuilt
|
|
87
|
+
// from `[]`). Rows that lived in the corrupt bytes are NOT provably absent
|
|
88
|
+
// from THIS read — a destructive caller must never treat "missing here"
|
|
89
|
+
// as "no local session existed" while this trace is present. Every reader
|
|
90
|
+
// (not just this one) must see "unknown", so this is checked on the READ
|
|
91
|
+
// path (loadRegistry), never on rawRegistryRead itself — the write path
|
|
92
|
+
// (rawRegistryEntriesForWrite) deliberately ignores this field so a
|
|
93
|
+
// rebuilt-but-now-valid file keeps accepting new enrolments (REBUILDING is
|
|
94
|
+
// allowed); only the "was something silently lost?" READ verdict is
|
|
95
|
+
// poisoned, and only until the next successful write clears the trace.
|
|
96
|
+
if (raw.rebuiltFromCorruptAt !== undefined) {
|
|
97
|
+
return {
|
|
98
|
+
state: "unknown",
|
|
99
|
+
reason: `registry was rebuilt from an unreadable file at ${raw.rebuiltFromCorruptAt} ` +
|
|
100
|
+
`(see registry.corrupt-*.json next to the registry) — rows from before the ` +
|
|
101
|
+
`rebuild are not provably absent`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const entries = [];
|
|
105
|
+
const unreadable = [];
|
|
106
|
+
for (const row of raw.rows) {
|
|
107
|
+
if (isRegistryEntry(row)) {
|
|
108
|
+
entries.push(row);
|
|
109
|
+
}
|
|
110
|
+
else if (row && typeof row === "object") {
|
|
111
|
+
// Counter-mutant guard (PART-A producer): a row that fails
|
|
112
|
+
// `isRegistryEntry` is TRANSPORTED, never dropped — dropping it here
|
|
113
|
+
// is exactly the per-row lie this type exists to remove.
|
|
114
|
+
unreadable.push(row);
|
|
115
|
+
}
|
|
116
|
+
// A non-object row (string/number/null/etc.) cannot address any target
|
|
117
|
+
// by identity (no id/label/tmuxSession to match against) — it is
|
|
118
|
+
// discarded rather than carried as a formless "unreadable" row.
|
|
119
|
+
}
|
|
120
|
+
return { state: "ok", entries, unreadable };
|
|
121
|
+
}
|
|
122
|
+
/** Raw `entries` array of the registry FILE, with the read state preserved. */
|
|
123
|
+
function rawRegistryRead(path) {
|
|
124
|
+
let text;
|
|
42
125
|
try {
|
|
43
|
-
|
|
126
|
+
text = readFileSync(path, "utf8");
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
// An ABSENT file is provable emptiness (rows cannot exist without a
|
|
130
|
+
// file); every other read failure (EACCES, EIO, …) is UNKNOWN.
|
|
131
|
+
if (error.code === "ENOENT") {
|
|
132
|
+
return { state: "ok", rows: [] };
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
state: "unknown",
|
|
136
|
+
reason: `registry file is unreadable (${error.code ?? "read error"})`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const parsed = JSON.parse(text);
|
|
44
141
|
const entries = parsed?.entries;
|
|
45
|
-
if (!Array.isArray(entries))
|
|
46
|
-
return
|
|
47
|
-
|
|
142
|
+
if (!Array.isArray(entries)) {
|
|
143
|
+
return { state: "unknown", reason: "registry has no entries array" };
|
|
144
|
+
}
|
|
145
|
+
const rebuiltFromCorruptAt = parsed
|
|
146
|
+
?.rebuiltFromCorruptAt;
|
|
147
|
+
return {
|
|
148
|
+
state: "ok",
|
|
149
|
+
rows: entries,
|
|
150
|
+
...(typeof rebuiltFromCorruptAt === "string" ? { rebuiltFromCorruptAt } : {}),
|
|
151
|
+
};
|
|
48
152
|
}
|
|
49
153
|
catch {
|
|
50
|
-
|
|
51
|
-
return [];
|
|
154
|
+
return { state: "unknown", reason: "registry is corrupt (not valid JSON)" };
|
|
52
155
|
}
|
|
53
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Move the unreadable bytes at `path` aside VERBATIM to a sibling
|
|
159
|
+
* `registry.corrupt-<ts>-<pid>.json` (best-effort: a truly unreadable file,
|
|
160
|
+
* e.g. EACCES, has nothing left to move — the rebuild still proceeds and is
|
|
161
|
+
* still flagged by the caller). B2 — "REBUILDING is allowed. DESTROYING is
|
|
162
|
+
* not.": called by `withRegistryLock` ONLY immediately before a write that
|
|
163
|
+
* is actually about to REBUILD the file from `[]`, never on a mere read or a
|
|
164
|
+
* `save:false` no-op (e.g. `prune` finding nothing to change on a corrupt
|
|
165
|
+
* file) — otherwise a corrupt registry nothing ever mutates would spam a
|
|
166
|
+
* fresh sibling file on every poll (`remote ls` calls `prune` every time).
|
|
167
|
+
* Returns the rebuild timestamp to stamp on the save (the WHOLE-FILE trace
|
|
168
|
+
* `loadRegistry` turns into "unknown" for every destructive reader).
|
|
169
|
+
*/
|
|
170
|
+
function moveAsideUnreadableRegistry(path) {
|
|
171
|
+
const at = new Date().toISOString();
|
|
172
|
+
try {
|
|
173
|
+
const original = readFileSync(path, "utf8");
|
|
174
|
+
const corruptPath = join(dirname(path), `registry.corrupt-${at.replace(/[:.]/g, "-")}-${process.pid}.json`);
|
|
175
|
+
writeFileSync(corruptPath, original, "utf8");
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Nothing readable to move aside (EACCES/EIO/ENOENT/…) — the rebuild
|
|
179
|
+
// still proceeds and is still flagged so destructive readers stay
|
|
180
|
+
// fail-closed.
|
|
181
|
+
}
|
|
182
|
+
return at;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Pure identity match: does raw row `raw` (already known to have FAILED
|
|
186
|
+
* `isRegistryEntry`) plausibly address `target` under the SAME identity rules
|
|
187
|
+
* used to match a VALID row (id / label / exact tmuxSession / managed-name
|
|
188
|
+
* candidates)? Shared by `unreadableRowsForTarget` (A2's per-identity poison
|
|
189
|
+
* check inside `resolveManagedHost`) and the legacy path-based helper below.
|
|
190
|
+
* Defensive: an unreadable row is read WITHOUT presuming a well-formed shape.
|
|
191
|
+
*/
|
|
192
|
+
function rawRowMatchesTarget(target, raw) {
|
|
193
|
+
const requested = parseManagedSessionName(target);
|
|
194
|
+
const candidates = requested ? [target] : managedSessionCandidates(target);
|
|
195
|
+
const id = typeof raw.id === "string" ? raw.id : undefined;
|
|
196
|
+
const label = typeof raw.label === "string" ? raw.label : undefined;
|
|
197
|
+
const tmuxSession = typeof raw.tmuxSession === "string" ? raw.tmuxSession : undefined;
|
|
198
|
+
if (requested) {
|
|
199
|
+
return (tmuxSession === target ||
|
|
200
|
+
(tmuxSession === undefined && id === requested.slug));
|
|
201
|
+
}
|
|
202
|
+
return (id === target ||
|
|
203
|
+
label === target ||
|
|
204
|
+
(tmuxSession !== undefined && candidates.includes(tmuxSession)));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Unreadable rows (from an ALREADY-READ `loadRegistry()` snapshot) that
|
|
208
|
+
* plausibly address `target` under the resolver's identity rules. Pure — no
|
|
209
|
+
* fs — so every caller consults the SAME snapshot instead of re-reading the
|
|
210
|
+
* file (this is what `resolveManagedHost` calls for its per-identity poison
|
|
211
|
+
* check, A2). THE RULE: absence must be provable BY IDENTITY — an unknown by
|
|
212
|
+
* identity for X, never a global unknown that disables every OTHER identity.
|
|
213
|
+
*/
|
|
214
|
+
export function unreadableRowsForTarget(target, unreadable) {
|
|
215
|
+
return unreadable.filter((raw) => rawRowMatchesTarget(target, raw));
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Path-based convenience wrapper: performs its OWN registry read (a second
|
|
219
|
+
* read when the caller already has one — prefer `unreadableRowsForTarget`
|
|
220
|
+
* with an existing `loadRegistry()` snapshot when one is available, e.g.
|
|
221
|
+
* inside `resolveManagedHost`). Raw rows in the registry FILE that FAIL
|
|
222
|
+
* validation (e.g. `kind` absent or unreadable) but plausibly address
|
|
223
|
+
* `target`. `loadRegistry()` cannot return them as `entries` — but a
|
|
224
|
+
* DESTRUCTIVE caller must know the identity exists in an unreadable state:
|
|
225
|
+
* such a row makes local host state UNKNOWN (fail closed, sol-2), never "no
|
|
226
|
+
* local session" — which would let the act fall through to a REMOTE homonym.
|
|
227
|
+
*
|
|
228
|
+
* B3 — this is itself a RE-READ on a destructive path (a TOCTOU window: the
|
|
229
|
+
* caller's own earlier read may have succeeded, then the registry became
|
|
230
|
+
* unreadable strictly BETWEEN the two reads — another writer, a partial
|
|
231
|
+
* write, disk pressure). A re-read inherits the SAME 3-state contract as the
|
|
232
|
+
* first: a whole-file "unknown" here used to flatten to `[]` — "no
|
|
233
|
+
* unreadable row for this identity" — which is indistinguishable from "the
|
|
234
|
+
* row is fine" and let the caller fall through past this guard. Returns
|
|
235
|
+
* `"unknown"` instead so the caller refuses exactly like any other unknown
|
|
236
|
+
* registry read, never silently re-simplified into an empty collection.
|
|
237
|
+
*/
|
|
238
|
+
export function unreadableRegistryRowsForTarget(target, path = resolveRegistryPath()) {
|
|
239
|
+
const read = loadRegistry(path);
|
|
240
|
+
if (read.state === "unknown")
|
|
241
|
+
return "unknown";
|
|
242
|
+
return unreadableRowsForTarget(target, read.unreadable);
|
|
243
|
+
}
|
|
54
244
|
/**
|
|
55
245
|
* Read the registry without collapsing a missing/corrupt file into an empty
|
|
56
246
|
* collection. Operational commands may rebuild their registry; the status
|
|
@@ -77,13 +267,162 @@ export function loadRegistryWithDiagnostics(path = resolveRegistryPath()) {
|
|
|
77
267
|
};
|
|
78
268
|
}
|
|
79
269
|
}
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Native-terminal PTY pgid persistence.
|
|
272
|
+
//
|
|
273
|
+
// Durable session identity (incl. pgid) lives in the single registry store BY
|
|
274
|
+
// DESIGN — h2a keeps ONE durable local store (the owner's SQLite direction);
|
|
275
|
+
// a separate native-terminal store would be exactly the heterogeneity being
|
|
276
|
+
// eliminated. This is NOT a layer leak from native-terminal/host.ts into the
|
|
277
|
+
// registry; do not remove this coupling. The pgid is stored on a real
|
|
278
|
+
// `RegistryEntry` row (same file, same lock, same atomic write as every other
|
|
279
|
+
// registry mutation) keyed by a namespaced id so it can never collide with a
|
|
280
|
+
// real claude/codex/agy session row. `tool`/`source`/`cwd` on that row are
|
|
281
|
+
// syntactically-required placeholders (RegistryTool/RegistrySource are closed
|
|
282
|
+
// enums built around CLI sessions, not arbitrary PTY commands) — the only
|
|
283
|
+
// consumer of this row is `readNativeTerminalPgid` below, which reads `.pgid`
|
|
284
|
+
// and ignores the rest. `kind: "local"` keeps the row OUT of `remote ls`
|
|
285
|
+
// (`localLsRows` only surfaces `kind: "local-tmux"`) and out of delegate's
|
|
286
|
+
// concurrency accounting (`role` is left unset, so `tryClaimSlot`/`listJobs`
|
|
287
|
+
// never see it).
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
const NATIVE_TERMINAL_PGID_ENTRY_ID_PREFIX = "native-terminal-pty:";
|
|
290
|
+
function nativeTerminalPgidEntryId(sessionId) {
|
|
291
|
+
return `${NATIVE_TERMINAL_PGID_ENTRY_ID_PREFIX}${sessionId}`;
|
|
292
|
+
}
|
|
293
|
+
export function persistNativeTerminalPgid(sessionId, pgid, path = resolveRegistryPath(), owner, leaderStartTime, groupToken) {
|
|
294
|
+
if (!Number.isSafeInteger(pgid) || pgid <= 0) {
|
|
295
|
+
throw new RangeError("pgid must be a positive safe integer");
|
|
296
|
+
}
|
|
297
|
+
const id = nativeTerminalPgidEntryId(sessionId);
|
|
298
|
+
withRegistryLock(path, (entries) => {
|
|
299
|
+
const now = new Date().toISOString();
|
|
300
|
+
const idx = entries.findIndex((e) => e.id === id);
|
|
301
|
+
const entry = {
|
|
302
|
+
id,
|
|
303
|
+
tool: "claude",
|
|
304
|
+
kind: "local",
|
|
305
|
+
cwd: "",
|
|
306
|
+
source: "scan",
|
|
307
|
+
enrolledAt: idx >= 0 ? entries[idx].enrolledAt : now,
|
|
308
|
+
lastSeenAt: now,
|
|
309
|
+
pgid,
|
|
310
|
+
...(leaderStartTime !== undefined ? { pgidLeaderStartTime: leaderStartTime } : {}),
|
|
311
|
+
...(groupToken !== undefined ? { pgidGroupToken: groupToken } : {}),
|
|
312
|
+
...(owner !== undefined ? { ownerHostPid: owner.pid } : {}),
|
|
313
|
+
...(owner?.startTime !== undefined ? { ownerHostStartTime: owner.startTime } : {}),
|
|
314
|
+
};
|
|
315
|
+
const next = idx >= 0
|
|
316
|
+
? entries.map((e, i) => (i === idx ? entry : e))
|
|
317
|
+
: [...entries, entry];
|
|
318
|
+
return { entries: next, result: undefined };
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Resolve a native-terminal PTY session's durable pgid, for a reap that may
|
|
323
|
+
* come from a host that never knew the session (e.g. a fresh host started
|
|
324
|
+
* after the owning host was killed).
|
|
325
|
+
*
|
|
326
|
+
* Reads via `loadRegistryWithDiagnostics`, NEVER via the plain `loadRegistry`
|
|
327
|
+
* array reader: `loadRegistry` silently flattens a corrupt/unreadable
|
|
328
|
+
* registry to `[]`, which would conflate two very different situations —
|
|
329
|
+
* "this session genuinely has no recorded pgid" vs. "the registry could not
|
|
330
|
+
* be read at all". A reaper must tell those apart: an unreadable registry
|
|
331
|
+
* must NEVER be treated as "nothing to reap" (that recreates the exact
|
|
332
|
+
* invisible-orphan bug this mechanism exists to close), so it is reported as
|
|
333
|
+
* `unresolved` with a diagnostic reason, loudly, by the caller (see
|
|
334
|
+
* `NativeTerminalHost#reapOrphan`), which must never guess a pgid or kill
|
|
335
|
+
* silently on either branch below.
|
|
336
|
+
*
|
|
337
|
+
* (If/when a future 3-state `loadRegistry` lands — `state: "ok" | "unknown"`
|
|
338
|
+
* — the migration is a refinement of the condition below, not a new branch:
|
|
339
|
+
* map `state: "unknown"` to the same `unresolved` result.)
|
|
340
|
+
*/
|
|
341
|
+
export function readNativeTerminalPgid(sessionId, path = resolveRegistryPath()) {
|
|
342
|
+
const diagnostics = loadRegistryWithDiagnostics(path);
|
|
343
|
+
if (!diagnostics.known) {
|
|
344
|
+
return {
|
|
345
|
+
status: "unresolved",
|
|
346
|
+
reason: `registry unreadable: ${diagnostics.reason ?? "unknown reason"}`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const id = nativeTerminalPgidEntryId(sessionId);
|
|
350
|
+
const entry = diagnostics.entries.find((e) => e.id === id);
|
|
351
|
+
if (!entry || typeof entry.pgid !== "number") {
|
|
352
|
+
return {
|
|
353
|
+
status: "unresolved",
|
|
354
|
+
reason: `no pgid recorded for terminal session ${sessionId}`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
status: "resolved",
|
|
359
|
+
pgid: entry.pgid,
|
|
360
|
+
...(typeof entry.pgidLeaderStartTime === "number"
|
|
361
|
+
? { leaderStartTime: entry.pgidLeaderStartTime }
|
|
362
|
+
: {}),
|
|
363
|
+
...(typeof entry.pgidGroupToken === "string"
|
|
364
|
+
? { groupToken: entry.pgidGroupToken }
|
|
365
|
+
: {}),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Enumerate every durably-persisted native-terminal PTY session, for a
|
|
370
|
+
* reconcile pass that must decide, PER ENTRY, whether its owning host is
|
|
371
|
+
* proven dead (see `reconcileDeadHostOrphans` in native-terminal/host.ts, the
|
|
372
|
+
* sole consumer). Reads via `loadRegistryWithDiagnostics` for the same reason
|
|
373
|
+
* `readNativeTerminalPgid` does: an unreadable registry must never collapse
|
|
374
|
+
* to "zero entries" — that would silently make a reconcile pass reap
|
|
375
|
+
* (or skip) NOTHING while believing it saw the whole truth.
|
|
376
|
+
*/
|
|
377
|
+
export function listNativeTerminalPgidEntries(path = resolveRegistryPath()) {
|
|
378
|
+
const diagnostics = loadRegistryWithDiagnostics(path);
|
|
379
|
+
if (!diagnostics.known) {
|
|
380
|
+
return { known: false, reason: diagnostics.reason ?? "unknown reason" };
|
|
381
|
+
}
|
|
382
|
+
const entries = [];
|
|
383
|
+
for (const e of diagnostics.entries) {
|
|
384
|
+
if (!e.id.startsWith(NATIVE_TERMINAL_PGID_ENTRY_ID_PREFIX))
|
|
385
|
+
continue;
|
|
386
|
+
if (typeof e.pgid !== "number")
|
|
387
|
+
continue;
|
|
388
|
+
const sessionId = e.id.slice(NATIVE_TERMINAL_PGID_ENTRY_ID_PREFIX.length);
|
|
389
|
+
const entry = {
|
|
390
|
+
sessionId,
|
|
391
|
+
pgid: e.pgid,
|
|
392
|
+
};
|
|
393
|
+
if (typeof e.ownerHostPid === "number") {
|
|
394
|
+
const owner = { pid: e.ownerHostPid };
|
|
395
|
+
if (typeof e.ownerHostStartTime === "number")
|
|
396
|
+
owner.startTime = e.ownerHostStartTime;
|
|
397
|
+
entry.owner = owner;
|
|
398
|
+
}
|
|
399
|
+
entries.push(entry);
|
|
400
|
+
}
|
|
401
|
+
return { known: true, entries };
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Remove a single native-terminal-pty row once its orphan group has been
|
|
405
|
+
* reaped (or it is otherwise confirmed handled). Returns false when the id
|
|
406
|
+
* was not present (already pruned, or never existed) — a no-op, not an
|
|
407
|
+
* error, since a reconcile pass may race a concurrent prune of the same row.
|
|
408
|
+
*/
|
|
409
|
+
export function pruneNativeTerminalPgidEntry(sessionId, path = resolveRegistryPath()) {
|
|
410
|
+
const id = nativeTerminalPgidEntryId(sessionId);
|
|
411
|
+
return withRegistryLock(path, (entries) => {
|
|
412
|
+
const idx = entries.findIndex((e) => e.id === id);
|
|
413
|
+
if (idx < 0)
|
|
414
|
+
return { entries, result: false, save: false };
|
|
415
|
+
const next = entries.slice(0, idx).concat(entries.slice(idx + 1));
|
|
416
|
+
return { entries: next, result: true };
|
|
417
|
+
});
|
|
418
|
+
}
|
|
80
419
|
function isRegistryEntry(raw) {
|
|
81
420
|
if (!raw || typeof raw !== "object")
|
|
82
421
|
return false;
|
|
83
422
|
const e = raw;
|
|
84
423
|
return (typeof e.id === "string" &&
|
|
85
424
|
(e.tool === "claude" || e.tool === "codex" || e.tool === "agy") &&
|
|
86
|
-
(e.kind === "local-tmux" || e.kind === "local" || e.kind === "remote") &&
|
|
425
|
+
(e.kind === "local-tmux" || e.kind === "local-native" || e.kind === "local" || e.kind === "remote") &&
|
|
87
426
|
typeof e.cwd === "string" &&
|
|
88
427
|
typeof e.enrolledAt === "string" &&
|
|
89
428
|
typeof e.lastSeenAt === "string" &&
|
|
@@ -102,14 +441,46 @@ function isRegistryEntry(raw) {
|
|
|
102
441
|
e.delegationOrigin === "mcp:h2a_run" ||
|
|
103
442
|
e.delegationOrigin === "cli:h2a-delegate") &&
|
|
104
443
|
(e.pid === undefined || (typeof e.pid === "number" && Number.isInteger(e.pid) && e.pid > 0)) &&
|
|
444
|
+
(e.pgid === undefined || (typeof e.pgid === "number" && Number.isInteger(e.pgid) && e.pgid > 0)) &&
|
|
445
|
+
(e.pgidLeaderStartTime === undefined ||
|
|
446
|
+
(typeof e.pgidLeaderStartTime === "number" &&
|
|
447
|
+
Number.isInteger(e.pgidLeaderStartTime) &&
|
|
448
|
+
e.pgidLeaderStartTime >= 0)) &&
|
|
449
|
+
(e.pgidGroupToken === undefined || typeof e.pgidGroupToken === "string") &&
|
|
450
|
+
(e.ownerHostPid === undefined ||
|
|
451
|
+
(typeof e.ownerHostPid === "number" && Number.isInteger(e.ownerHostPid) && e.ownerHostPid > 0)) &&
|
|
452
|
+
(e.ownerHostStartTime === undefined ||
|
|
453
|
+
(typeof e.ownerHostStartTime === "number" &&
|
|
454
|
+
Number.isInteger(e.ownerHostStartTime) &&
|
|
455
|
+
e.ownerHostStartTime >= 0)) &&
|
|
105
456
|
(e.delegatorInstance === undefined || typeof e.delegatorInstance === "string") &&
|
|
106
|
-
(e.delegatorTmuxSession === undefined || typeof e.delegatorTmuxSession === "string")
|
|
457
|
+
(e.delegatorTmuxSession === undefined || typeof e.delegatorTmuxSession === "string") &&
|
|
458
|
+
(e.restorePinned === undefined || typeof e.restorePinned === "boolean"));
|
|
107
459
|
}
|
|
108
|
-
/**
|
|
109
|
-
|
|
460
|
+
/**
|
|
461
|
+
* Atomic write: tmp file in the same dir, then rename. `preserved` carries
|
|
462
|
+
* raw rows that FAIL validation (e.g. `kind` unreadable): they are written
|
|
463
|
+
* back verbatim so a load-mutate-save cycle never silently ERASES an
|
|
464
|
+
* unreadable row from the file (sol-2 — an erased row turns "host state
|
|
465
|
+
* unknown" into "no local session", re-routing destructive acts).
|
|
466
|
+
*
|
|
467
|
+
* `rebuiltFromCorruptAt` (B2) stamps the WHOLE-FILE trace when this save is
|
|
468
|
+
* the rebuild that followed a whole-file-unknown read: `loadRegistry` turns
|
|
469
|
+
* its presence into an "unknown" verdict for every subsequent READ, until a
|
|
470
|
+
* later `saveRegistry` call (one that did NOT rebuild from corrupt) omits it
|
|
471
|
+
* again and the trace clears.
|
|
472
|
+
*/
|
|
473
|
+
function saveRegistry(entries, path, preserved = [], rebuiltFromCorruptAt) {
|
|
110
474
|
mkdirSync(dirname(path), { recursive: true });
|
|
111
475
|
const tmp = `${path}.tmp.${process.pid}`;
|
|
112
|
-
|
|
476
|
+
const body = {
|
|
477
|
+
version: 1,
|
|
478
|
+
entries: [...entries, ...preserved],
|
|
479
|
+
};
|
|
480
|
+
if (rebuiltFromCorruptAt !== undefined) {
|
|
481
|
+
body.rebuiltFromCorruptAt = rebuiltFromCorruptAt;
|
|
482
|
+
}
|
|
483
|
+
writeFileSync(tmp, JSON.stringify(body, null, 2), "utf8");
|
|
113
484
|
renameSync(tmp, path);
|
|
114
485
|
}
|
|
115
486
|
// ---------------------------------------------------------------------------
|
|
@@ -143,9 +514,24 @@ function saveRegistry(entries, path) {
|
|
|
143
514
|
export function withRegistryLock(path, fn) {
|
|
144
515
|
const fd = acquireFileLock(path);
|
|
145
516
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
517
|
+
// WRITE-path raw read: enrolment deliberately REBUILDS a missing/corrupt
|
|
518
|
+
// registry (a hiccup must not brick every future enrolment), so an
|
|
519
|
+
// unknown state flattens to `[]` for `fn`'s input — but see below: the
|
|
520
|
+
// corrupt BYTES are moved aside (never overwritten in place) before any
|
|
521
|
+
// write that actually rebuilds from that `[]`.
|
|
522
|
+
const raw = rawRegistryRead(path);
|
|
523
|
+
const rows = raw.state === "ok" ? raw.rows : [];
|
|
524
|
+
const { entries, result, save } = fn(rows.filter(isRegistryEntry));
|
|
525
|
+
if (save !== false) {
|
|
526
|
+
// B2 — "REBUILDING is allowed. DESTROYING is not.": only reached when
|
|
527
|
+
// a write is ACTUALLY about to rebuild the file, never on a read-only
|
|
528
|
+
// `save:false` no-op (prune finding nothing to change on a corrupt
|
|
529
|
+
// file must not spam a fresh registry.corrupt-*.json on every poll).
|
|
530
|
+
const rebuiltFromCorruptAt = raw.state === "unknown" ? moveAsideUnreadableRegistry(path) : undefined;
|
|
531
|
+
// Unreadable rows are preserved verbatim: a mutation of the VALID
|
|
532
|
+
// entries must never erase what it could not read (sol-2).
|
|
533
|
+
saveRegistry(entries, path, rows.filter((row) => !isRegistryEntry(row)), rebuiltFromCorruptAt);
|
|
534
|
+
}
|
|
149
535
|
return result;
|
|
150
536
|
}
|
|
151
537
|
finally {
|
|
@@ -292,6 +678,15 @@ function applyEnroll(entries, input) {
|
|
|
292
678
|
const gatewayMode = input.gatewayMode ?? prev?.gatewayMode;
|
|
293
679
|
if (gatewayMode !== undefined)
|
|
294
680
|
entry.gatewayMode = gatewayMode;
|
|
681
|
+
if (input.restorePinned !== undefined) {
|
|
682
|
+
entry.restorePinned = input.restorePinned;
|
|
683
|
+
}
|
|
684
|
+
else if (prev?.restorePinned !== undefined) {
|
|
685
|
+
entry.restorePinned = prev.restorePinned;
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
entry.restorePinned = shouldPreserveByRestorePin(entry);
|
|
689
|
+
}
|
|
295
690
|
if (idx >= 0)
|
|
296
691
|
entries[idx] = entry;
|
|
297
692
|
else
|
|
@@ -381,7 +776,14 @@ export function advanceJob(id, to, path = resolveRegistryPath()) {
|
|
|
381
776
|
/** Live job entries (role "job"), liveness reconciled like any other entry. */
|
|
382
777
|
export function listJobs(opts = {}) {
|
|
383
778
|
const path = opts.path ?? resolveRegistryPath();
|
|
384
|
-
|
|
779
|
+
const read = loadRegistry(path);
|
|
780
|
+
// Unknown read → EMPTY VIEW, deliberately: this list feeds supervision
|
|
781
|
+
// views and job launch planning (acts that only ever START work); no
|
|
782
|
+
// kill/stop keys off it. Callers that must PROVE local absence go through
|
|
783
|
+
// resolveManagedHost, which transports the unknown read as a refusal.
|
|
784
|
+
return read.state === "ok"
|
|
785
|
+
? read.entries.filter((e) => e.role === "job")
|
|
786
|
+
: [];
|
|
385
787
|
}
|
|
386
788
|
/** Refresh lastSeenAt. Returns false when the id is unknown. */
|
|
387
789
|
export function touchEntry(id, path = resolveRegistryPath()) {
|
|
@@ -416,6 +818,16 @@ function defaultTmuxHasSession(name) {
|
|
|
416
818
|
return false;
|
|
417
819
|
}
|
|
418
820
|
}
|
|
821
|
+
function defaultNativeSessionLiveness(name) {
|
|
822
|
+
try {
|
|
823
|
+
return nativeSessionLiveness(name);
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
// The producer already maps its own failures to "unknown"; this belt
|
|
827
|
+
// keeps an unexpected throw from ever reading as death.
|
|
828
|
+
return "unknown";
|
|
829
|
+
}
|
|
830
|
+
}
|
|
419
831
|
function defaultPidAlive(pid) {
|
|
420
832
|
try {
|
|
421
833
|
process.kill(pid, 0);
|
|
@@ -468,6 +880,16 @@ function processIsTool(pid, tool, read) {
|
|
|
468
880
|
export function isLive(e, opts = {}) {
|
|
469
881
|
if (e.endedAt)
|
|
470
882
|
return false;
|
|
883
|
+
if (e.kind === "local-native") {
|
|
884
|
+
const liveness = opts.nativeSessionLiveness ?? defaultNativeSessionLiveness;
|
|
885
|
+
// "unknown" deliberately counts as possibly-live here (`!== false`):
|
|
886
|
+
// isLive feeds views AND prune — erasing the host pin of an unprovable
|
|
887
|
+
// session would re-route later destructive acts (F2). Only a POSITIVE
|
|
888
|
+
// "false" reads as death.
|
|
889
|
+
return (e.tmuxSession
|
|
890
|
+
? [e.tmuxSession]
|
|
891
|
+
: managedSessionCandidates(e.id)).some((name) => liveness(name) !== false);
|
|
892
|
+
}
|
|
471
893
|
if (e.kind === "local-tmux") {
|
|
472
894
|
const has = opts.tmuxHasSession ?? defaultTmuxHasSession;
|
|
473
895
|
return (e.tmuxSession
|
|
@@ -498,13 +920,39 @@ export function isLive(e, opts = {}) {
|
|
|
498
920
|
/** Entries considered live right now (see isLive for the per-kind rules). */
|
|
499
921
|
export function listLive(opts = {}) {
|
|
500
922
|
const path = opts.path ?? resolveRegistryPath();
|
|
501
|
-
|
|
923
|
+
const read = loadRegistry(path);
|
|
924
|
+
// Unknown read → EMPTY VIEW: `ls`-style projections degrade to showing no
|
|
925
|
+
// registry rows (the status bar has its own UNKNOWN rendering via
|
|
926
|
+
// loadRegistryWithDiagnostics). Guards that must prove the ABSENCE of a
|
|
927
|
+
// writer must not consume this list — convOwners reads the registry itself
|
|
928
|
+
// and refuses on an unknown read.
|
|
929
|
+
return read.state === "ok" ? read.entries.filter((e) => isLive(e, opts)) : [];
|
|
502
930
|
}
|
|
503
931
|
/**
|
|
504
932
|
* Drop DEAD entries whose last activity (endedAt, else lastSeenAt) is older
|
|
505
933
|
* than maxAgeHours. Live entries always stay; recently-dead ones stay too so
|
|
506
934
|
* `restore` can still resume them after a reboot via the scan fallback.
|
|
507
935
|
* Returns the number of removed entries.
|
|
936
|
+
*
|
|
937
|
+
* RECONCILIATION DECISION (2026-08, #199 rebase — restore-pin x FILE-level
|
|
938
|
+
* unknown; architect to re-measure): `shouldPreserveByRestorePin` never
|
|
939
|
+
* observes `loadRegistry()`'s 3-state read directly — it runs INSIDE
|
|
940
|
+
* `withRegistryLock`, over the already-validated `entries` array only. Two
|
|
941
|
+
* unprovable cases both resolve CONSERVATIVE-PRESERVE by construction,
|
|
942
|
+
* neither newly added here:
|
|
943
|
+
* - whole-FILE unknown (corrupt/unreadable registry.json):
|
|
944
|
+
* `withRegistryLock`'s own raw read flattens to `[]` for its write-path
|
|
945
|
+
* contract, so `kept.length === entries.length` (0 === 0) and this prune
|
|
946
|
+
* is a NO-OP (`save:false`, see below) — nothing is read, so nothing a
|
|
947
|
+
* restore pin could have kept is ever written over or lost.
|
|
948
|
+
* - a single restore-pinned row that fails `isRegistryEntry` (per-row
|
|
949
|
+
* unreadable, PART A): it never reaches `entries` here at all (excluded
|
|
950
|
+
* before this callback runs), but `withRegistryLock`/`saveRegistry`
|
|
951
|
+
* re-append every such raw row VERBATIM on every write (sol-2) —
|
|
952
|
+
* `shouldPreserveByRestorePin` cannot prune what it never sees, and the
|
|
953
|
+
* row survives regardless of what this function decides.
|
|
954
|
+
* Neither case is exercised through an explicit `state:"unknown"` branch
|
|
955
|
+
* here (there isn't one to write) — REPORTED, not silently assumed settled.
|
|
508
956
|
*/
|
|
509
957
|
export function prune(maxAgeHours, opts = {}) {
|
|
510
958
|
const path = opts.path ?? resolveRegistryPath();
|
|
@@ -513,6 +961,8 @@ export function prune(maxAgeHours, opts = {}) {
|
|
|
513
961
|
const kept = entries.filter((e) => {
|
|
514
962
|
if (isLive(e, opts))
|
|
515
963
|
return true;
|
|
964
|
+
if (shouldPreserveByRestorePin(e))
|
|
965
|
+
return true;
|
|
516
966
|
const last = Date.parse(e.endedAt ?? e.lastSeenAt);
|
|
517
967
|
return Number.isFinite(last) && last >= cutoff;
|
|
518
968
|
});
|
|
@@ -544,11 +994,17 @@ export function enrollFromRun(args) {
|
|
|
544
994
|
const tool = coerceRegistryTool(args.profile);
|
|
545
995
|
if (!tool)
|
|
546
996
|
return; // shell/opencode/… sessions stay tmux-only
|
|
997
|
+
// Runtime twin of the required-parameter type: an untyped caller that omits
|
|
998
|
+
// the host gets NO row rather than a row on a guessed host. Never default.
|
|
999
|
+
if (args.hostKind !== "local-tmux" && args.hostKind !== "local-native") {
|
|
1000
|
+
process.stderr.write(`[h2a] registry enrolment refused for ${args.slug}: caller did not name the terminal host\n`);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
547
1003
|
try {
|
|
548
1004
|
enroll({
|
|
549
1005
|
id: args.slug,
|
|
550
1006
|
tool,
|
|
551
|
-
kind:
|
|
1007
|
+
kind: args.hostKind,
|
|
552
1008
|
cwd: args.cwd,
|
|
553
1009
|
source: "run",
|
|
554
1010
|
label: args.slug,
|
|
@@ -603,7 +1059,7 @@ export function localLsRows(sessions, live) {
|
|
|
603
1059
|
// kind:"local" entries are Claude Code conversation sessions (UUID ids,
|
|
604
1060
|
// no tmuxSession) — they are internal CC state, not user-facing sessions.
|
|
605
1061
|
for (const e of live) {
|
|
606
|
-
if (e.kind
|
|
1062
|
+
if (!isManagedLocalKind(e.kind) || matched.has(e.id))
|
|
607
1063
|
continue;
|
|
608
1064
|
// A historical record without an exact tmux name cannot authoritatively
|
|
609
1065
|
// claim either member of a live h2a-/remote- collision. It already remains
|
|
@@ -649,7 +1105,7 @@ export function listLocalForLs(opts = {}) {
|
|
|
649
1105
|
export function resolveLocalTmuxSessionForName(target, entries) {
|
|
650
1106
|
const requested = parseManagedSessionName(target);
|
|
651
1107
|
const matches = entries.filter((e) => {
|
|
652
|
-
if (e.role !== undefined || e.kind
|
|
1108
|
+
if (e.role !== undefined || !isManagedLocalKind(e.kind) || e.endedAt) {
|
|
653
1109
|
return false;
|
|
654
1110
|
}
|
|
655
1111
|
// A full managed name is an exact selector, never a slug/label alias.
|
|
@@ -692,4 +1148,187 @@ export function localTmuxSessionForName(target, entries) {
|
|
|
692
1148
|
const resolution = resolveLocalTmuxSessionForName(target, entries);
|
|
693
1149
|
return resolution.kind === "found" ? resolution.name : undefined;
|
|
694
1150
|
}
|
|
1151
|
+
/**
|
|
1152
|
+
* DEDICATED resolver for `kind:"local-native"` registry entries — the native
|
|
1153
|
+
* twin of the local-tmux target filter in index.ts. That filter stays
|
|
1154
|
+
* tmux-scoped on purpose: its second caller iterates LIVE tmux sessions by
|
|
1155
|
+
* name inside the destructive relaunch planner, where a broadened match would
|
|
1156
|
+
* be a category error. The two resolvers coexist so neither lies about its
|
|
1157
|
+
* name.
|
|
1158
|
+
*
|
|
1159
|
+
* A native entry's PERSISTED identity, as measured on every native write path
|
|
1160
|
+
* (`enroll` in startJob and `enrollFromRun` on run/resume/relaunch): `id` and
|
|
1161
|
+
* `label` carry the slug, and `tmuxSession` — despite its tmux-era name —
|
|
1162
|
+
* carries the native host session name `localSessionName(slug)`, the
|
|
1163
|
+
* h2a-<slug> naming contract shared across hosts (native-host.ts). Historical
|
|
1164
|
+
* rows without a persisted session name fall back to the managed-name
|
|
1165
|
+
* candidates of their id, exactly like `isLive`.
|
|
1166
|
+
*
|
|
1167
|
+
* NO liveness input exists here by construction: which host a recorded
|
|
1168
|
+
* session belongs to is decided by its persisted kind alone — a dead native
|
|
1169
|
+
* session stays native. Whether an ACT on it can proceed is the caller's
|
|
1170
|
+
* separate liveness gate.
|
|
1171
|
+
* (0 local-native entries in the fleet registry as of 2026-08-08; this
|
|
1172
|
+
* resolver prepares the re-use paths for the sessions #178 will produce.)
|
|
1173
|
+
*/
|
|
1174
|
+
export function registryEntriesForNativeTarget(target,
|
|
1175
|
+
// No default read: the caller owns the registry READ STATE (an unknown
|
|
1176
|
+
// read must refuse upstream, never flatten into "no rows" here).
|
|
1177
|
+
entries) {
|
|
1178
|
+
return registryEntriesForManagedKind("local-native", target, entries);
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* The SAME exact-identity filter scoped to persisted `kind:"local-tmux"` rows.
|
|
1182
|
+
* It exists so host resolution can consult BOTH persisted kinds symmetrically
|
|
1183
|
+
* (`resolveManagedHost`): recognizing only one kind before falling back to a
|
|
1184
|
+
* probe was the measured F2 defect — a persisted tmux row plus a homonymous
|
|
1185
|
+
* live native process re-routed destructive acts to the wrong host.
|
|
1186
|
+
* (The broader index.ts local-tmux target filter stays separate on purpose:
|
|
1187
|
+
* it also matches LIVE tmux state for the relaunch planner.)
|
|
1188
|
+
*/
|
|
1189
|
+
export function registryEntriesForLocalTmuxTarget(target,
|
|
1190
|
+
// No default read: the caller owns the registry READ STATE (an unknown
|
|
1191
|
+
// read must refuse upstream, never flatten into "no rows" here).
|
|
1192
|
+
entries) {
|
|
1193
|
+
return registryEntriesForManagedKind("local-tmux", target, entries);
|
|
1194
|
+
}
|
|
1195
|
+
function registryEntriesForManagedKind(kind, target, entries) {
|
|
1196
|
+
const requested = parseManagedSessionName(target);
|
|
1197
|
+
const candidates = requested ? [target] : managedSessionCandidates(target);
|
|
1198
|
+
return entries.filter((e) => {
|
|
1199
|
+
if (e.kind !== kind)
|
|
1200
|
+
return false;
|
|
1201
|
+
// A positively-ENDED session no longer pins a host: only current rows
|
|
1202
|
+
// take part in host resolution (mirrors isLive's endedAt short-circuit).
|
|
1203
|
+
if (e.endedAt !== undefined)
|
|
1204
|
+
return false;
|
|
1205
|
+
// A full managed name is an exact selector, never a slug/label alias
|
|
1206
|
+
// (same rule as the tmux filter and resolveLocalTmuxSessionForName).
|
|
1207
|
+
if (requested) {
|
|
1208
|
+
return (e.tmuxSession === target ||
|
|
1209
|
+
(e.tmuxSession === undefined && e.id === requested.slug));
|
|
1210
|
+
}
|
|
1211
|
+
return (e.id === target ||
|
|
1212
|
+
e.label === target ||
|
|
1213
|
+
(e.tmuxSession !== undefined && candidates.includes(e.tmuxSession)));
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
/** Native probe: an op failure is UNKNOWN, never dead (producer is 3-state). */
|
|
1217
|
+
export function probeNativeSession(name) {
|
|
1218
|
+
try {
|
|
1219
|
+
const alive = nativeSessionLiveness(name);
|
|
1220
|
+
if (alive === true)
|
|
1221
|
+
return "live";
|
|
1222
|
+
if (alive === false)
|
|
1223
|
+
return "dead";
|
|
1224
|
+
return "unknown";
|
|
1225
|
+
}
|
|
1226
|
+
catch {
|
|
1227
|
+
return "unknown";
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Tmux probe. No tmux binary/server means no tmux session CAN exist (a tmux
|
|
1232
|
+
* session cannot outlive its server), so that is provable death — only a
|
|
1233
|
+
* spawn-level failure of an installed tmux is UNKNOWN.
|
|
1234
|
+
*/
|
|
1235
|
+
export function probeTmuxSession(name) {
|
|
1236
|
+
if (!tmuxAvailable())
|
|
1237
|
+
return "dead";
|
|
1238
|
+
try {
|
|
1239
|
+
// "=" prefix forces an exact session-name match (no prefix matching).
|
|
1240
|
+
const r = spawnSync("tmux", ["has-session", "-t", `=${name}`], {
|
|
1241
|
+
stdio: "ignore",
|
|
1242
|
+
});
|
|
1243
|
+
if (r.error)
|
|
1244
|
+
return "unknown";
|
|
1245
|
+
return r.status === 0 ? "live" : "dead";
|
|
1246
|
+
}
|
|
1247
|
+
catch {
|
|
1248
|
+
return "unknown";
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
function isRegistryReadResult(value) {
|
|
1252
|
+
return !Array.isArray(value);
|
|
1253
|
+
}
|
|
1254
|
+
export function resolveManagedHost(target, entries = loadRegistry(), probes = {}) {
|
|
1255
|
+
// The registry READ STATE is part of the resolution (F2): an unreadable
|
|
1256
|
+
// registry means the persisted host cannot be proven — transported as
|
|
1257
|
+
// "unknown" so every destructive caller refuses instead of treating the
|
|
1258
|
+
// unprovable rows as absent and probing/guessing a host.
|
|
1259
|
+
const read = isRegistryReadResult(entries)
|
|
1260
|
+
? entries
|
|
1261
|
+
// A plain-array caller carries NO unreadable-row information (it never
|
|
1262
|
+
// had a snapshot to draw one from) — `unreadable:[]` here is a neutral
|
|
1263
|
+
// "none known", not a claim that none exist. Callers that hold a real
|
|
1264
|
+
// `loadRegistry()` snapshot MUST pass it whole (not `.entries`) for the
|
|
1265
|
+
// per-identity poison check below to see the unreadable rows at all.
|
|
1266
|
+
: { state: "ok", entries: [...entries], unreadable: [] };
|
|
1267
|
+
if (read.state === "unknown") {
|
|
1268
|
+
return {
|
|
1269
|
+
state: "unknown",
|
|
1270
|
+
reason: `registry read failed: ${read.reason}`,
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
// A2 (PART A — resolver per-identity unreadable check, sol-2): for target
|
|
1274
|
+
// identity X, ask "is there an unreadable row that COULD be X?" — a
|
|
1275
|
+
// same-name/same-form TWIN — never "are there any unreadable rows at all?".
|
|
1276
|
+
// A hit poisons the resolution for X ONLY; every OTHER identity in this
|
|
1277
|
+
// same registry stays fully resolvable. This runs BEFORE the recorded/
|
|
1278
|
+
// discovered/missing branches below (A3 — the kill must not precede the
|
|
1279
|
+
// check): a valid row for X plus an unreadable twin of X must never reach
|
|
1280
|
+
// "recorded" and hand a caller a name to kill.
|
|
1281
|
+
const unreadableForTarget = unreadableRowsForTarget(target, read.unreadable);
|
|
1282
|
+
if (unreadableForTarget.length > 0) {
|
|
1283
|
+
const ids = unreadableForTarget
|
|
1284
|
+
.map((row) => (typeof row.id === "string" ? row.id : "<no id>"))
|
|
1285
|
+
.join(", ");
|
|
1286
|
+
return {
|
|
1287
|
+
state: "unknown",
|
|
1288
|
+
reason: `unreadable registry row(s) for this identity (row id: ${ids})`,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
const rows = read.entries;
|
|
1292
|
+
const nativeRows = registryEntriesForNativeTarget(target, rows);
|
|
1293
|
+
const tmuxRows = registryEntriesForLocalTmuxTarget(target, rows);
|
|
1294
|
+
const exactName = (rows) => rows[0]?.tmuxSession ??
|
|
1295
|
+
(parseManagedSessionName(target)
|
|
1296
|
+
? target
|
|
1297
|
+
: localSessionName(rows[0]?.id ?? target));
|
|
1298
|
+
if (nativeRows.length > 0 && tmuxRows.length > 0) {
|
|
1299
|
+
return { state: "ambiguous", candidates: ["local-native", "local-tmux"] };
|
|
1300
|
+
}
|
|
1301
|
+
if (nativeRows.length > 0) {
|
|
1302
|
+
return { state: "recorded", kind: "local-native", name: exactName(nativeRows) };
|
|
1303
|
+
}
|
|
1304
|
+
if (tmuxRows.length > 0) {
|
|
1305
|
+
return { state: "recorded", kind: "local-tmux", name: exactName(tmuxRows) };
|
|
1306
|
+
}
|
|
1307
|
+
// Discovery: probes may only run when NO persisted row exists.
|
|
1308
|
+
const probedName = parseManagedSessionName(target)
|
|
1309
|
+
? target
|
|
1310
|
+
: localSessionName(target);
|
|
1311
|
+
const native = (probes.native ?? probeNativeSession)(probedName);
|
|
1312
|
+
const tmux = (probes.tmux ?? probeTmuxSession)(probedName);
|
|
1313
|
+
if (native === "unknown" || tmux === "unknown") {
|
|
1314
|
+
const failed = [
|
|
1315
|
+
...(native === "unknown" ? ["native"] : []),
|
|
1316
|
+
...(tmux === "unknown" ? ["tmux"] : []),
|
|
1317
|
+
].join("+");
|
|
1318
|
+
return {
|
|
1319
|
+
state: "unknown",
|
|
1320
|
+
reason: `${failed} host probe failed for ${probedName}`,
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
if (native === "live" && tmux === "live") {
|
|
1324
|
+
return { state: "ambiguous", candidates: ["local-native", "local-tmux"] };
|
|
1325
|
+
}
|
|
1326
|
+
if (native === "live") {
|
|
1327
|
+
return { state: "discovered", kind: "local-native", name: probedName };
|
|
1328
|
+
}
|
|
1329
|
+
if (tmux === "live") {
|
|
1330
|
+
return { state: "discovered", kind: "local-tmux", name: probedName };
|
|
1331
|
+
}
|
|
1332
|
+
return { state: "missing" };
|
|
1333
|
+
}
|
|
695
1334
|
//# sourceMappingURL=registry.js.map
|