oxtail 0.15.0 → 0.16.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/claims.js +27 -7
- package/dist/detect/birthTimeMatchStrategy.js +8 -2
- package/dist/locks.js +26 -7
- package/dist/mailbox.js +44 -3
- package/dist/received.js +26 -5
- package/dist/registry.js +38 -1
- package/dist/server.js +33 -3
- package/dist/wake-debounce.js +8 -1
- package/package.json +1 -1
package/dist/claims.js
CHANGED
|
@@ -155,9 +155,6 @@ function compareClaimScores(a, b) {
|
|
|
155
155
|
}
|
|
156
156
|
return a.claimed_at - b.claimed_at;
|
|
157
157
|
}
|
|
158
|
-
function scoresTie(a, b) {
|
|
159
|
-
return compareClaimScores(a, b) === 0;
|
|
160
|
-
}
|
|
161
158
|
function claimKey(clientType, cwd, sessionId) {
|
|
162
159
|
return createHash("sha256")
|
|
163
160
|
.update(`${clientType} ${cwd} ${sessionId}`)
|
|
@@ -172,7 +169,14 @@ function claimPath(key) {
|
|
|
172
169
|
// Atomic temp+rename so a concurrent reader never sees a torn write.
|
|
173
170
|
export function writeClaim(input) {
|
|
174
171
|
ensureClaimsDir();
|
|
175
|
-
|
|
172
|
+
// Age-only sweep on this hot path. writeClaim can run concurrently with another
|
|
173
|
+
// agent's writeClaim (dual-scope, or two sessions in one project); the
|
|
174
|
+
// transcript-existence check is racy — a transcript that momentarily fails to
|
|
175
|
+
// stat would unlink a sibling's just-written claim (M6). Age is monotonic and
|
|
176
|
+
// race-free, so reclaim only by age here. recoverClaim already skips records
|
|
177
|
+
// whose transcript is gone, and the full (transcript-aware) sweep remains
|
|
178
|
+
// available via a direct gcStaleClaims() call.
|
|
179
|
+
gcStaleClaims(Date.now(), { ageOnly: true });
|
|
176
180
|
const rec = {
|
|
177
181
|
schema_version: 1,
|
|
178
182
|
client_type: input.client_type,
|
|
@@ -245,14 +249,30 @@ export function recoverClaim(clientType, cwd, ancestors, deps = {}) {
|
|
|
245
249
|
matches.sort((a, b) => compareClaimScores(b.score, a.score));
|
|
246
250
|
const best = matches[0];
|
|
247
251
|
const second = matches[1];
|
|
248
|
-
|
|
252
|
+
// Abstain on cross-session ambiguity. Two DISTINCT sessions that overlap the
|
|
253
|
+
// live chain equally (same overlap_count) AND at the same live-chain depth
|
|
254
|
+
// (same nearest_overlap_current) share liveness only at a common ancestor —
|
|
255
|
+
// the shared terminal/login-shell. The remaining tiebreakers (record-side
|
|
256
|
+
// depth, recency) do NOT correlate with which child actually restarted, so
|
|
257
|
+
// adopting either would risk cross-session misrouting (H1) — the very
|
|
258
|
+
// split-identity class this store exists to prevent. Return null so the caller
|
|
259
|
+
// falls back to the explicit claim_session next_step. (This strictly subsumes
|
|
260
|
+
// the old exact-tie check, which had equal overlap_count and nearest_current
|
|
261
|
+
// by definition.) A same-session second-best routes to the same identity and
|
|
262
|
+
// so can never split-route — defensive only, since the per-session claim key
|
|
263
|
+
// means two records can't share a session_id.
|
|
264
|
+
if (second &&
|
|
265
|
+
best.rec.session_id !== second.rec.session_id &&
|
|
266
|
+
best.score.overlap_count === second.score.overlap_count &&
|
|
267
|
+
best.score.nearest_overlap_current === second.score.nearest_overlap_current) {
|
|
249
268
|
return null;
|
|
269
|
+
}
|
|
250
270
|
return best.rec;
|
|
251
271
|
}
|
|
252
272
|
// Drop records that are clearly dead: transcript gone, or older than the max
|
|
253
273
|
// age. Best-effort; never throws. A dead process pid alone is NOT grounds for
|
|
254
274
|
// removal — that's exactly the restart case recovery exists to serve.
|
|
255
|
-
export function gcStaleClaims(nowMs = Date.now()) {
|
|
275
|
+
export function gcStaleClaims(nowMs = Date.now(), opts = {}) {
|
|
256
276
|
const dir = claimsDir();
|
|
257
277
|
if (!existsSync(dir))
|
|
258
278
|
return;
|
|
@@ -274,7 +294,7 @@ export function gcStaleClaims(nowMs = Date.now()) {
|
|
|
274
294
|
catch {
|
|
275
295
|
continue;
|
|
276
296
|
}
|
|
277
|
-
const transcriptGone = !rec.transcript_path || !existsSync(rec.transcript_path);
|
|
297
|
+
const transcriptGone = !opts.ageOnly && (!rec.transcript_path || !existsSync(rec.transcript_path));
|
|
278
298
|
const tooOld = nowMs - rec.claimed_at * 1000 > CLAIM_MAX_AGE_MS;
|
|
279
299
|
if (transcriptGone || tooOld) {
|
|
280
300
|
try {
|
|
@@ -2,6 +2,12 @@ import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
const FIVE_MIN_MS = 5 * 60 * 1000;
|
|
5
|
+
// started_at is whole-second granularity (Math.floor(Date.now()/1000)*1000)
|
|
6
|
+
// while a transcript's birth_ms is real-millisecond, so a transcript
|
|
7
|
+
// legitimately created in the same second can land slightly BEFORE started_at
|
|
8
|
+
// (delta in [-1000, 0]). Allow one second of grace below zero so the unique
|
|
9
|
+
// candidate isn't dropped on pure rounding (M7).
|
|
10
|
+
const ONE_SECOND_MS = 1000;
|
|
5
11
|
const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/;
|
|
6
12
|
// Returns the unique post-start candidate inside the window, or null if there
|
|
7
13
|
// are zero or multiple. Multiple positive-delta candidates means another
|
|
@@ -10,7 +16,7 @@ const UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/
|
|
|
10
16
|
export function pickByDelta(candidates, startedAtMs, windowMs = FIVE_MIN_MS) {
|
|
11
17
|
const ranked = candidates
|
|
12
18
|
.map((c) => ({ ...c, delta: c.birth_ms - startedAtMs }))
|
|
13
|
-
.filter((c) => c.delta >
|
|
19
|
+
.filter((c) => c.delta > -ONE_SECOND_MS && c.delta <= windowMs);
|
|
14
20
|
if (ranked.length !== 1)
|
|
15
21
|
return null;
|
|
16
22
|
return { session_id: ranked[0].session_id, birth_ms: ranked[0].birth_ms };
|
|
@@ -141,7 +147,7 @@ function abstainReason(type, candidates, startedAtMs) {
|
|
|
141
147
|
}
|
|
142
148
|
const ranked = candidates
|
|
143
149
|
.map((c) => ({ ...c, delta: c.birth_ms - startedAtMs }))
|
|
144
|
-
.filter((c) => c.delta >
|
|
150
|
+
.filter((c) => c.delta > -ONE_SECOND_MS && c.delta <= FIVE_MIN_MS);
|
|
145
151
|
if (ranked.length === 0) {
|
|
146
152
|
return {
|
|
147
153
|
abstain: true,
|
package/dist/locks.js
CHANGED
|
@@ -41,8 +41,13 @@ import { trace } from "./trace.js";
|
|
|
41
41
|
// ALWAYS the single-winner `mkdir(lock)`, so even redundant clears can never
|
|
42
42
|
// produce two owners — the worst they do is race to recreate the lock, which
|
|
43
43
|
// exactly one wins.
|
|
44
|
-
const LOCK_RETRY_LIMIT = 50;
|
|
45
44
|
const LOCK_RETRY_DELAY_MS = 10;
|
|
45
|
+
// Total acquire budget is wall-clock, NOT a fixed retry count: a successful
|
|
46
|
+
// stale-clear retries mkdir immediately (no sleep) so it must not consume the
|
|
47
|
+
// budget without time passing — a count-based budget threw "could not acquire
|
|
48
|
+
// lock" spuriously under contention (H2). 2s is ample for the tiny mailbox/
|
|
49
|
+
// ledger critical sections and well under any caller-level timeout.
|
|
50
|
+
const LOCK_ACQUIRE_TIMEOUT_MS = 2_000;
|
|
46
51
|
function sleepSync(ms) {
|
|
47
52
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
48
53
|
}
|
|
@@ -166,7 +171,8 @@ export function clearStaleLock(lock, staleMs, traceEvent, traceCtx) {
|
|
|
166
171
|
// releaseDirLock. The caller is responsible for creating the parent directory.
|
|
167
172
|
export function acquireDirLock(lock, staleMs, traceEvent, traceCtx) {
|
|
168
173
|
const token = mintToken();
|
|
169
|
-
|
|
174
|
+
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
|
|
175
|
+
for (;;) {
|
|
170
176
|
try {
|
|
171
177
|
mkdirSync(lock, { mode: 0o700 });
|
|
172
178
|
writeOwner(lock, token);
|
|
@@ -176,12 +182,19 @@ export function acquireDirLock(lock, staleMs, traceEvent, traceCtx) {
|
|
|
176
182
|
const err = e;
|
|
177
183
|
if (err.code !== "EEXIST")
|
|
178
184
|
throw err;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
185
|
+
// A successful stale-clear means the lock is gone: loop straight back to
|
|
186
|
+
// mkdir WITHOUT sleeping, to grab it before another contender (this retry
|
|
187
|
+
// must not consume the budget without time passing). Otherwise — a fresh
|
|
188
|
+
// holder or a lost steal — back off before retrying.
|
|
189
|
+
if (!clearStaleLock(lock, staleMs, traceEvent, traceCtx)) {
|
|
190
|
+
sleepSync(LOCK_RETRY_DELAY_MS);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Wall-clock budget so the no-sleep stale-clear path cannot spin forever.
|
|
194
|
+
if (Date.now() >= deadline) {
|
|
195
|
+
throw new Error(`could not acquire lock at ${lock}`);
|
|
182
196
|
}
|
|
183
197
|
}
|
|
184
|
-
throw new Error(`could not acquire lock at ${lock}`);
|
|
185
198
|
}
|
|
186
199
|
// Release the lock — but only if we PROVABLY still own it (owner === our token).
|
|
187
200
|
// A holder that stalled past the stale window and was stolen from sees a
|
|
@@ -192,7 +205,13 @@ export function acquireDirLock(lock, staleMs, traceEvent, traceCtx) {
|
|
|
192
205
|
// stale lock and is reclaimed by clearStaleLock, strictly safer than a stomp.
|
|
193
206
|
export function releaseDirLock(lock, token) {
|
|
194
207
|
if (!token) {
|
|
195
|
-
|
|
208
|
+
// No token to prove ownership. An empty token reaches here only from a
|
|
209
|
+
// lockTokens Map miss (an acquire that threw, or a future same-key nested
|
|
210
|
+
// release), so removing would stomp whatever lock currently exists —
|
|
211
|
+
// possibly a DIFFERENT owner's fresh one. Leave it: a genuinely leaked lock
|
|
212
|
+
// ages into a stale lock and is reclaimed by clearStaleLock, strictly safer
|
|
213
|
+
// than a stomp (H3).
|
|
214
|
+
trace("lock_release_skipped_no_token", { lock });
|
|
196
215
|
return;
|
|
197
216
|
}
|
|
198
217
|
const owner = readOwner(lock);
|
package/dist/mailbox.js
CHANGED
|
@@ -188,6 +188,13 @@ export function requeueMany(target_pid, msgs) {
|
|
|
188
188
|
// two unioned sibling mailboxes. Both copies are drained (so neither lingers) but
|
|
189
189
|
// the message is returned ONCE. message_id is a unique per-message nonce, so this
|
|
190
190
|
// only ever collapses true duplicates, never two distinct messages.
|
|
191
|
+
// Union-drain a session's mailboxes (one per server_pid it has used), deduping
|
|
192
|
+
// by message_id so a migrate crash-window duplicate (same id in two sibling
|
|
193
|
+
// mailboxes) is delivered once. INVARIANT: every unioned pid is drained (and so
|
|
194
|
+
// truncated) before returning — do NOT add a budget/early-exit short-circuit
|
|
195
|
+
// here. The dedup is per-call only, so a duplicate left in an un-drained sibling
|
|
196
|
+
// would re-surface on a later call with no cross-call dedup (M5). Budgeting
|
|
197
|
+
// belongs in the caller, applied to the already-fully-drained result.
|
|
191
198
|
export function drainMany(pids) {
|
|
192
199
|
const out = [];
|
|
193
200
|
const seenPids = new Set();
|
|
@@ -265,8 +272,42 @@ export function migrateMailbox(fromPid, toPid) {
|
|
|
265
272
|
}
|
|
266
273
|
if (!raw || !raw.trim())
|
|
267
274
|
return 0;
|
|
268
|
-
|
|
269
|
-
|
|
275
|
+
// Migrate only VALID records, reserialized canonically. A crash mid-append
|
|
276
|
+
// into the source can leave a torn final line; copying raw bytes would glue
|
|
277
|
+
// a synthesized newline onto that fragment, promoting garbage into a
|
|
278
|
+
// standalone (unparseable) line in the dest AND over-counting it (H4). Parse
|
|
279
|
+
// each line with the same guard drain uses, drop torn/invalid ones, and
|
|
280
|
+
// rebuild a clean block so the count reflects real, deliverable messages.
|
|
281
|
+
const valid = [];
|
|
282
|
+
for (const line of raw.split("\n")) {
|
|
283
|
+
if (!line.trim())
|
|
284
|
+
continue;
|
|
285
|
+
let parsed;
|
|
286
|
+
try {
|
|
287
|
+
parsed = JSON.parse(line);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
trace("mailbox_migrate_skip_invalid", { fromPid, toPid, line });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (parsed &&
|
|
294
|
+
typeof parsed === "object" &&
|
|
295
|
+
parsed.schema_version === 1 &&
|
|
296
|
+
typeof parsed.id === "string" &&
|
|
297
|
+
typeof parsed.body === "string") {
|
|
298
|
+
valid.push(parsed);
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
trace("mailbox_migrate_skip_invalid", { fromPid, toPid, line });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (valid.length === 0) {
|
|
305
|
+
// Only torn/garbage lines — clear the source and report nothing migrated.
|
|
306
|
+
truncateSync(src, 0);
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
// serializeMailboxLine already terminates each line with "\n", so join("").
|
|
310
|
+
const block = valid.map((m) => serializeMailboxLine(m)).join("");
|
|
270
311
|
acquireLock(toPid);
|
|
271
312
|
try {
|
|
272
313
|
appendLines(mailboxPath(toPid), block);
|
|
@@ -276,7 +317,7 @@ export function migrateMailbox(fromPid, toPid) {
|
|
|
276
317
|
}
|
|
277
318
|
// Append succeeded → clear the source (still under the source lock).
|
|
278
319
|
truncateSync(src, 0);
|
|
279
|
-
return
|
|
320
|
+
return valid.length;
|
|
280
321
|
}
|
|
281
322
|
finally {
|
|
282
323
|
releaseLock(fromPid);
|
package/dist/received.js
CHANGED
|
@@ -88,24 +88,43 @@ function readLines(sessionId) {
|
|
|
88
88
|
throw err;
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
// The message_id of a serialized ledger line, or null if unparseable. Used to
|
|
92
|
+
// keep recordReceived idempotent without fully deserializing every envelope.
|
|
93
|
+
function lineMessageId(line) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(line);
|
|
96
|
+
return typeof parsed.id === "string" ? parsed.id : null;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
91
102
|
// Append an inbound envelope to the receiver's ledger and prune to receivedMax()
|
|
92
103
|
// (oldest dropped first). Called by delivery.ts BEFORE the mailbox append.
|
|
104
|
+
// Idempotent by message_id: re-recording an id replaces its prior line.
|
|
93
105
|
export function recordReceived(receiverSessionId, msg) {
|
|
94
106
|
if (!receiverSessionId)
|
|
95
107
|
return;
|
|
96
108
|
acquireLock(receiverSessionId);
|
|
97
109
|
try {
|
|
98
110
|
const lines = readLines(receiverSessionId);
|
|
99
|
-
|
|
111
|
+
// Idempotent by message_id: a re-record (ask_peer abort recovery, chained
|
|
112
|
+
// re-delivery) must not append a duplicate ledger line. Duplicates waste the
|
|
113
|
+
// receivedMax prune budget and can evict still-needed handles early,
|
|
114
|
+
// surfacing as spurious reply_to_message "message-not-found" (M4). Drop any
|
|
115
|
+
// prior line for this id, then append the latest. lookupReceived already
|
|
116
|
+
// returns first-match newest-first, so behavior is unchanged for callers.
|
|
117
|
+
const deduped = msg.id ? lines.filter((l) => lineMessageId(l) !== msg.id) : lines;
|
|
118
|
+
deduped.push(JSON.stringify(msg));
|
|
100
119
|
const max = receivedMax();
|
|
101
|
-
let pruned =
|
|
102
|
-
if (
|
|
103
|
-
pruned =
|
|
120
|
+
let pruned = deduped;
|
|
121
|
+
if (deduped.length > max) {
|
|
122
|
+
pruned = deduped.slice(deduped.length - max);
|
|
104
123
|
// No silent caps: a dropped handle becomes reply_to_message
|
|
105
124
|
// "message-not-found", so surface that the bound bit.
|
|
106
125
|
trace("received_ledger_pruned", {
|
|
107
126
|
session_id: receiverSessionId,
|
|
108
|
-
dropped:
|
|
127
|
+
dropped: deduped.length - max,
|
|
109
128
|
kept: max,
|
|
110
129
|
});
|
|
111
130
|
}
|
|
@@ -136,6 +155,8 @@ export function lookupReceived(receiverSessionId, messageId) {
|
|
|
136
155
|
}
|
|
137
156
|
if (parsed &&
|
|
138
157
|
typeof parsed === "object" &&
|
|
158
|
+
parsed.schema_version === 1 &&
|
|
159
|
+
typeof parsed.body === "string" &&
|
|
139
160
|
parsed.id === messageId) {
|
|
140
161
|
return parsed;
|
|
141
162
|
}
|
package/dist/registry.js
CHANGED
|
@@ -68,9 +68,21 @@ export function isValidTmuxSession(s) {
|
|
|
68
68
|
// target, so we refuse rather than fall back to the self-written cached value.
|
|
69
69
|
// - the resolved pane isn't a well-formed pane id (tmux output anomaly).
|
|
70
70
|
// resolvePane is injected in tests; production uses currentPaneForServerPid.
|
|
71
|
-
export function chooseVerifiedWakePane(peer, resolvePane = currentPaneForServerPid) {
|
|
71
|
+
export function chooseVerifiedWakePane(peer, resolvePane = currentPaneForServerPid, resolveSig = processStartSig) {
|
|
72
72
|
if (!peer.tmux_pane)
|
|
73
73
|
return null;
|
|
74
|
+
// PID-reuse guard: if the entry recorded the server process's start-time
|
|
75
|
+
// signature, confirm the live pid is STILL that process before resolving and
|
|
76
|
+
// waking its pane. Otherwise an OS-recycled pid — now an unrelated process
|
|
77
|
+
// that happens to sit under a different tmux pane — would resolve to, and get
|
|
78
|
+
// our wake keystrokes typed into, a stranger's pane (M3). Only refuse on a
|
|
79
|
+
// positively-different signature; an empty reading (transient ps failure)
|
|
80
|
+
// falls through to pane resolution, which fails closed for a truly dead pid.
|
|
81
|
+
if (peer.proc_sig) {
|
|
82
|
+
const liveSig = resolveSig(peer.server_pid);
|
|
83
|
+
if (liveSig && liveSig !== peer.proc_sig)
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
74
86
|
const live = resolvePane(peer.server_pid);
|
|
75
87
|
if (!live || !isValidTmuxPane(live))
|
|
76
88
|
return null;
|
|
@@ -203,11 +215,36 @@ export function resolveTmuxPane(env = process.env, pid = process.pid) {
|
|
|
203
215
|
export function currentPaneForServerPid(serverPid) {
|
|
204
216
|
return findTmuxPaneByAncestry(serverPid, listTmuxPanePids(), listAllPpids());
|
|
205
217
|
}
|
|
218
|
+
// The OS start-time signature (lstart) of a process, or "" if it can't be read
|
|
219
|
+
// (dead pid, or ps unavailable). Same provenance signal claims.ts uses on
|
|
220
|
+
// ancestor pids: an OS-recycled pid yields a DIFFERENT start time, so comparing
|
|
221
|
+
// a live pid's signature against one captured at register time detects pid reuse
|
|
222
|
+
// — distinguishing "our process is still alive" from "the pid now belongs to an
|
|
223
|
+
// unrelated process."
|
|
224
|
+
export function processStartSig(pid) {
|
|
225
|
+
try {
|
|
226
|
+
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
227
|
+
encoding: "utf8",
|
|
228
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
229
|
+
}).trim();
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// A process's start time never changes, so capture our own once and reuse it.
|
|
236
|
+
let cachedSelfProcSig;
|
|
237
|
+
function selfProcSig() {
|
|
238
|
+
if (cachedSelfProcSig === undefined)
|
|
239
|
+
cachedSelfProcSig = processStartSig(process.pid);
|
|
240
|
+
return cachedSelfProcSig;
|
|
241
|
+
}
|
|
206
242
|
export function buildEntry(client, env = process.env) {
|
|
207
243
|
const tmux_pane = resolveTmuxPane(env);
|
|
208
244
|
return {
|
|
209
245
|
server_pid: process.pid,
|
|
210
246
|
started_at: Math.floor(Date.now() / 1000),
|
|
247
|
+
proc_sig: selfProcSig(),
|
|
211
248
|
client,
|
|
212
249
|
tmux_pane,
|
|
213
250
|
tmux_session: resolveTmuxSessionFromPane(tmux_pane),
|
package/dist/server.js
CHANGED
|
@@ -10,7 +10,7 @@ import { dirname, join, sep } from "node:path";
|
|
|
10
10
|
import { clientFromHandshake, detectClient, enrichWithDiagnosis, transcriptPathFor, } from "./clients.js";
|
|
11
11
|
import { isAbstain } from "./detect/index.js";
|
|
12
12
|
import { trace } from "./trace.js";
|
|
13
|
-
import { buildEntry, chooseVerifiedWakePane, findByTmuxSession, readAll, refreshTmuxBinding, register, sessionPidsForId, unregister, } from "./registry.js";
|
|
13
|
+
import { buildEntry, chooseVerifiedWakePane, findByTmuxSession, processStartSig, readAll, refreshTmuxBinding, register, sessionPidsForId, unregister, } from "./registry.js";
|
|
14
14
|
import * as mailbox from "./mailbox.js";
|
|
15
15
|
import * as received from "./received.js";
|
|
16
16
|
import { deliverExistingToPeer, deliverToPeer } from "./delivery.js";
|
|
@@ -638,6 +638,11 @@ function refineFromHandshake(trigger) {
|
|
|
638
638
|
return diagnosis;
|
|
639
639
|
}
|
|
640
640
|
server.server.oninitialized = () => {
|
|
641
|
+
// Sweep pending-ask records orphaned by a prior session (an ask that timed out,
|
|
642
|
+
// was never answered, and whose owner went away). gcPendingAsk otherwise only
|
|
643
|
+
// runs on a later ask_peer timeout, so this startup sweep keeps the dir from
|
|
644
|
+
// accumulating stale records. Best-effort; never throws.
|
|
645
|
+
gcPendingAsk(defaultPendingAskDir(), Date.now());
|
|
641
646
|
const diagnosis = refineFromHandshake("oninitialized");
|
|
642
647
|
// After type is known via handshake, schedule retries to catch transcript files
|
|
643
648
|
// that don't exist yet at handshake time. No-op if session_id is already set.
|
|
@@ -855,12 +860,16 @@ server.registerTool("get_my_session", {
|
|
|
855
860
|
// strategy mirrors session_id_source so callers can still see whether
|
|
856
861
|
// env / birth-time / self-register resolved this entry.
|
|
857
862
|
const source = entry.client.session_id_source ?? "self-register";
|
|
863
|
+
// Report confidence honestly per source: env and explicit self-register
|
|
864
|
+
// (claim_session) are authoritative ("high"); inferred sources (birth-time,
|
|
865
|
+
// sticky-claim) are "medium" — matching what the detect strategies return.
|
|
866
|
+
const confidence = source === "env" || source === "self-register" ? "high" : "medium";
|
|
858
867
|
diagnosis = {
|
|
859
868
|
per_strategy: {},
|
|
860
869
|
winning: {
|
|
861
870
|
session_id: entry.client.session_id,
|
|
862
871
|
source,
|
|
863
|
-
confidence
|
|
872
|
+
confidence,
|
|
864
873
|
strategy: source,
|
|
865
874
|
},
|
|
866
875
|
next_step: null,
|
|
@@ -973,7 +982,20 @@ function resolveTarget(target, caller) {
|
|
|
973
982
|
const fresh = reReadRegistryEntry(e.server_pid);
|
|
974
983
|
if (!fresh)
|
|
975
984
|
return false;
|
|
976
|
-
|
|
985
|
+
if (fresh.started_at !== e.started_at)
|
|
986
|
+
return false;
|
|
987
|
+
// PID-reuse: started_at is the original registration time and lives on the
|
|
988
|
+
// stale on-disk entry, so a recycled pid (alive, file untouched) passes the
|
|
989
|
+
// check above. If the entry recorded the process start-time signature,
|
|
990
|
+
// confirm the live pid is still that same process; a recycled pid reads a
|
|
991
|
+
// different signature and is rejected (M3). Empty reading → indeterminate,
|
|
992
|
+
// leave it to downstream (the pane wake gate re-verifies before keystrokes).
|
|
993
|
+
if (fresh.proc_sig) {
|
|
994
|
+
const liveSig = processStartSig(e.server_pid);
|
|
995
|
+
if (liveSig && liveSig !== fresh.proc_sig)
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
return true;
|
|
977
999
|
});
|
|
978
1000
|
if (candidates.length === 0)
|
|
979
1001
|
return { ok: false, error: "target-not-found" };
|
|
@@ -1474,6 +1496,14 @@ async function wakePeer(peer) {
|
|
|
1474
1496
|
// No session-name fallback: a self-written tmux_session could target another
|
|
1475
1497
|
// session, and the verified pane already handles pane-id churn. Pass null.
|
|
1476
1498
|
const ok = await askPeerWakeImpl(verifiedPane, null, fire);
|
|
1499
|
+
if (!ok && sid) {
|
|
1500
|
+
// The fire failed (e.g. the pane vanished between verification and the
|
|
1501
|
+
// send-keys), so no keystroke landed. Clear the debounce stamp set pre-fire
|
|
1502
|
+
// above — otherwise a genuine retry within WAKE_DEBOUNCE_MS is suppressed as
|
|
1503
|
+
// "debounced" even though the peer was never actually woken (M1). The
|
|
1504
|
+
// pre-stamp only needs to survive a SUCCESSFUL fire's async paste gap.
|
|
1505
|
+
wakeDebounce.delete(sid);
|
|
1506
|
+
}
|
|
1477
1507
|
return ok ? "fired" : "skipped_no_target";
|
|
1478
1508
|
}
|
|
1479
1509
|
// --- send_message wake:auto gating -------------------------------------------
|
package/dist/wake-debounce.js
CHANGED
|
@@ -30,7 +30,14 @@ export function newWakeDebounceStore() {
|
|
|
30
30
|
// True if a wake fired for this key within the window — i.e. skip this one.
|
|
31
31
|
export function recentlyWoke(store, key, nowMs, windowMs = WAKE_DEBOUNCE_MS) {
|
|
32
32
|
const last = store.get(key);
|
|
33
|
-
|
|
33
|
+
if (last === undefined)
|
|
34
|
+
return false;
|
|
35
|
+
const delta = nowMs - last;
|
|
36
|
+
// A backwards clock step (NTP correction, laptop resume) makes delta negative
|
|
37
|
+
// and < windowMs, which would wrongly suppress every wake to this peer until
|
|
38
|
+
// the clock catches back up. Treat a negative delta as "not recent" (mirrors
|
|
39
|
+
// the ageMs >= 0 guard in isFreshIdle).
|
|
40
|
+
return delta >= 0 && delta < windowMs;
|
|
34
41
|
}
|
|
35
42
|
// Record that a wake fired for this key. Opportunistically evicts stale entries
|
|
36
43
|
// so the map can't grow unbounded across many short-lived peers.
|