oxtail 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -56,9 +56,12 @@ The v0.9/v0.10.1 changes close the public dogfooding gaps found by real peer tra
56
56
  - **`ask_peer` replies must correlate when the peer supports it.** Same-peer chatter is not a reply. Upgraded peers advertise `capabilities.mailbox.reply_to` and must satisfy waits with `from_session_id == target.session_id` plus `reply_to == request_id`; unmatched messages stay in the mailbox. The older `from_session_id`-only path is legacy compatibility and must be surfaced as `correlation: "uncorrelated"`. For no-capability peers, stale same-peer chatter may still satisfy the wait; that is an explicit compatibility limitation, not a correctness guarantee.
57
57
  - **Peer messages are context, not user authority.** Mailbox provenance (`origin: "peer"`, `request_id`, `reply_to`, `source_message_id`) is diagnostic metadata, not a trust boundary. Hook text must keep the trust framing visible — the "context, not user authority" line plus the `from_session_id` / `request_id` / `reply_to` reply fields (full protocol names) are rendered on every delivery — and injected hook bodies must stay under an explicit budget. Single-valued provenance the framing already implies (`origin: "peer"`) stays in the mailbox JSONL but need not be rendered into context.
58
58
  - **A displayed reply handle must be resolvable: record the received-ledger before the mailbox line is visible.** Both delivery paths are destructive — `read_my_messages` and the PreToolUse/Stop hook each truncate the mailbox on handoff — so `reply_to_message` resolves `message_id` against a durable per-session ledger (`~/.oxtail/received/<hash(session_id)>.jsonl`), never the queue. `deliverToPeer` (the single delivery primitive behind `send_message` / `ask_peer` / `reply_to_message`) MUST write the ledger entry **before** appending the mailbox line: append-then-record reopens a window where the hook renders a `message_id` the receiver cannot yet reply to. The ledger is keyed and owned by receiver `session_id`; a lookup reads only the caller's own file. The ledger write is best-effort (a failure degrades to "no handle, reply via `send_message`") but must never reorder ahead of, or block, the actual delivery.
59
+ - **Delivery mutations are crash-consistent; the shared advisory lock is owner-validated.** A crash mid-write must never corrupt a neighbour: mailbox appends heal a torn record boundary so two JSONL lines can't glue into one unparseable record (`appendLines`), and full-file rewrites (received-ledger, selective drain) go through a temp file + atomic `rename` (`atomicWrite`), never an in-place `writeFileSync` that a torn write could leave half-applied. The `mkdir` lock — shared between the Node server and the bash hooks — carries an owner token in a sidecar `<lock>.owner` (beside the dir so it stays empty and a plain `rmdir` still works cross-language): **release removes the lock only if it still owns it** (a stalled holder can't stomp a successor), and **stale removal is single-winner** (`<lock>.steal` marker) **plus compare-and-clear** (remove only if the owner is still the dead token observed). Provable race-freedom is unachievable on a plain shared filesystem (no atomic compare-and-swap); the design closes every realistic race and the only residuals — enumerated in `src/locks.ts` — require a >30s SIGSTOP-class stall inside a microsecond syscall gap, with a bounded consequence (rare double-delivery or a degraded reply-handle, never a wedge or torn file). One protocol, mirrored in `src/locks.ts` and both hooks. As a consequence-mitigation, the union reader `drainMany` dedups by `message_id` (v0.14.1), so the most realistic duplicate — a `migrateMailbox` crash-window leaving the same message in two sibling mailboxes — is delivered exactly once (ids are unique nonces, so dedup only ever collapses true duplicates).
59
60
 
60
61
  ## Recently shipped
61
62
 
63
+ - **Crash-consistency + cross-language lock hardening (v0.14.0).** A `compile-sim` pass plus four Codex adversarial rounds hardened the delivery core against crash/torn-write and lock races. **Crash-consistency:** every mailbox append heals a torn previous write so a crash can't glue two JSONL records into one unparseable line (`appendLines` in `src/mailbox.ts`); the received-ledger rewrite and `drainFirstMatching`'s survivor rewrite are now atomic temp-file + `rename` (`atomicWrite`), so a torn write can't drop unrelated survivors / corrupt old reply handles. **Advisory lock:** the `mkdir` lock gains an owner token in a sidecar `<lock>.owner` (kept beside the dir so it stays empty and a bash hook's plain `rmdir` still works); **release** removes the lock only if it still owns it (closes stall-resume-release stomp), and **stale-clear** is gated behind a single-winner `<lock>.steal` marker + compare-and-clear (closes the double-clear). The protocol lives once in `src/locks.ts` and is mirrored in both bash hooks (`assets/pretooluse.sh`, `assets/stop.sh`; `HOOK_MARKER_VERSION` → 6 forces re-install). **Honest limit:** a provably race-free stale-recoverable lock isn't achievable on a plain shared FS — the residuals all require a >30s SIGSTOP-class stall in a microsecond syscall gap, bounded to a rare double-delivery / degraded reply-handle (never a wedge or torn file), documented in `src/locks.ts`. Also: `deliverExistingToPeer` preserves `message_id` + ledger on the `ask_peer` abort-recovery path (was minting a new id + skipping the ledger). Codex converged after 4 rounds (it broke the first two lock attempts before the owner-token + compare-and-clear design held).
64
+
62
65
  - **Reply by id + received-ledger (v0.13.0).** `reply_to_message(message_id, body)` looks the inbound envelope up in a durable per-session ledger and derives `target` / `reply_to` / `source_message_id` server-side, replacing the manual rewiring that silently degraded a correlated exchange into loose mailbox traffic. New `src/received.ts` (ledger: sha256-keyed file, `mkdir`-lock, bounded retention `OXTAIL_RECEIVED_MAX`=1000 with a `received_ledger_pruned` trace so a drop is never silent) and `src/delivery.ts` (`deliverToPeer` = `buildMessage` → `recordReceived` → `requeue` — the record-before-append ordering above), wired into `send_message` / `ask_peer` / `reply_to_message`. Adversarial race-pair + ledger-failure-still-delivers tests in `src/delivery.test.ts`. Converged with Codex over a 5-round peer-messaging pressure test; Codex's review caught the append-before-record race, fixed before merge.
63
66
 
64
67
  - **Wake hardening (v0.12.0 — issues #5/#6/#7, the v0.7-review backlog).** Three deferred wake items, landed together. **#6 (security):** wake send-keys now only ever target the pane the live process tree says hosts the peer's `server_pid` (`chooseVerifiedWakePane` → `currentPaneForServerPid`), never the peer's self-written `tmux_pane`/`tmux_session`; unverifiable ⇒ refuse (`skipped_no_target`). Registry-sourced tmux ids are shape-validated (`isValidTmuxPane`/`isValidTmuxSession`) and a spoofed `TMUX_PANE` env is ignored. This removed the cached-pane and session-name send-keys fallbacks (legit peers always register a real pane; churn is handled by re-resolution). **#5 (debounce):** all wake paths funnel through `wakePeer`, which coalesces repeat wakes to the same peer within `OXTAIL_WAKE_DEBOUNCE_MS` (default 1s, in-memory per process) ⇒ `skipped_debounced`. **#7 (observability):** a `wake_outcome` trace event per wake; `oxtail diagnose` summarizes wake_status counts by tool from `MCP_TRACE_FILE`; a scheduled `codex-drift.yml` fails if Codex's `PASTE_ENTER_SUPPRESS_WINDOW` drifts past our 500ms gap. New modules: `src/wake-debounce.ts`, `src/diagnose.ts`; `chooseVerifiedWakePane` in `src/registry.ts`.
@@ -60,19 +60,73 @@ done < <(grep -lE "\"session_id\"[[:space:]]*:[[:space:]]*\"$sid\"" "$sessions_d
60
60
 
61
61
  [ "${#mboxes[@]}" -eq 0 ] && exit 0
62
62
 
63
- # 3. Acquire each mailbox's mkdir-based lock (best-effort; 30s staleness window,
64
- # matching src/mailbox.ts:LOCK_STALE_MS). GNU and BSD stat formats differ.
65
- locked=()
66
- for m in "${mboxes[@]}"; do
63
+ # ── Advisory lock: owner-token mkdir lock mirror of src/locks.ts ────────────
64
+ # The lock is a mkdir dir; the owner token lives in the SIDECAR file
65
+ # "<lock>.owner" (beside the dir, not inside, so the dir stays empty and a plain
66
+ # rmdir still removes it). Stale removal is gated behind a single-winner mkdir
67
+ # "<lock>.steal" marker plus compare-and-clear (remove only if the owner is still
68
+ # the dead token we observed), and release removes the lock only if we still own
69
+ # it. Keep in sync with src/locks.ts. GNU and BSD stat formats differ.
70
+ OXL_STALE=30 # seconds; mirror src/mailbox.ts LOCK_STALE_MS — also the
71
+ # marker-staleness window (same SIGSTOP-class threshold)
72
+ oxl_now() { date +%s 2>/dev/null || echo 0; }
73
+ oxl_mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; }
74
+ oxl_token() { # pid.random; tolerate a missing /dev/urandom without degrading to bare pid
75
+ local r
76
+ r=$(od -An -N6 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')
77
+ [ -n "$r" ] || r="${RANDOM}${RANDOM}${RANDOM}"
78
+ echo "$$.$r"
79
+ }
80
+ oxl_owner() { cat "$1.owner" 2>/dev/null || true; }
81
+ oxl_clear_stale() { # $1=lock dir; returns 0 if it did clearing work (retry mkdir)
82
+ local lock="$1" n mt obs smt
83
+ n=$(oxl_now); mt=$(oxl_mtime "$lock")
84
+ [ "$mt" -gt 0 ] || return 1
85
+ [ $((n - mt)) -gt "$OXL_STALE" ] || return 1
86
+ obs=$(oxl_owner "$lock")
87
+ if mkdir "$lock.steal" 2>/dev/null; then
88
+ if [ "x$(oxl_owner "$lock")" = "x$obs" ]; then
89
+ rm -f "$lock.owner" 2>/dev/null
90
+ rmdir "$lock" 2>/dev/null || rm -rf "$lock" 2>/dev/null
91
+ fi
92
+ rmdir "$lock.steal" 2>/dev/null
93
+ return 0
94
+ fi
95
+ smt=$(oxl_mtime "$lock.steal")
96
+ if [ "$smt" -gt 0 ] && [ $((n - smt)) -gt "$OXL_STALE" ]; then rmdir "$lock.steal" 2>/dev/null; fi
97
+ return 1
98
+ }
99
+ oxl_acquire() { # $1=lock dir; prints owner token on success, returns 0/1
100
+ local lock="$1" t i
101
+ t=$(oxl_token)
67
102
  for i in $(seq 1 50); do
68
- if mkdir "$m.lock" 2>/dev/null; then locked+=("$m"); break; fi
69
- now=$(date +%s 2>/dev/null || echo 0)
70
- mtime=$(stat -c %Y "$m.lock" 2>/dev/null || stat -f %m "$m.lock" 2>/dev/null || echo 0)
71
- if [ "$mtime" -gt 0 ] && [ $((now - mtime)) -gt 30 ]; then
72
- rmdir "$m.lock" 2>/dev/null
103
+ if mkdir "$lock" 2>/dev/null; then
104
+ printf '%s' "$t" > "$lock.owner" 2>/dev/null || true
105
+ printf '%s' "$t"
106
+ return 0
73
107
  fi
108
+ oxl_clear_stale "$lock" && continue
74
109
  sleep 0.01
75
110
  done
111
+ return 1
112
+ }
113
+ oxl_release() { # $1=lock dir, $2=our token — remove only if we PROVABLY own it
114
+ local lock="$1" t="$2" o
115
+ o=$(oxl_owner "$lock")
116
+ if [ -z "$t" ] || [ "x$o" = "x$t" ]; then
117
+ rm -f "$lock.owner" 2>/dev/null
118
+ rmdir "$lock" 2>/dev/null || true
119
+ fi
120
+ # owner differs or absent → not provably ours; leave it (it ages into a stale
121
+ # lock and is reclaimed by oxl_clear_stale) rather than stomp a successor.
122
+ }
123
+ # ─────────────────────────────────────────────────────────────────────────────
124
+
125
+ # 3. Acquire each mailbox's owner-token lock (best-effort; 30s staleness window).
126
+ locked=()
127
+ locked_tokens=()
128
+ for m in "${mboxes[@]}"; do
129
+ tok=$(oxl_acquire "$m.lock") && { locked+=("$m"); locked_tokens+=("$tok"); }
76
130
  done
77
131
  [ "${#locked[@]}" -eq 0 ] && exit 0
78
132
 
@@ -183,5 +237,9 @@ if [ -n "$output" ]; then
183
237
  for m in "${locked[@]}"; do : > "$m"; done
184
238
  fi
185
239
 
186
- for m in "${locked[@]}"; do rmdir "$m.lock" 2>/dev/null || true; done
240
+ ri=0
241
+ for m in "${locked[@]}"; do
242
+ oxl_release "$m.lock" "${locked_tokens[$ri]:-}"
243
+ ri=$((ri + 1))
244
+ done
187
245
  exit 0
package/assets/stop.sh CHANGED
@@ -90,16 +90,72 @@ if [ "${#mboxes[@]}" -eq 0 ]; then
90
90
  exit 0
91
91
  fi
92
92
 
93
- # 5. Lock each non-empty mailbox (best-effort; 30s staleness window).
94
- locked=()
95
- for m in "${mboxes[@]}"; do
93
+ # ── Advisory lock: owner-token mkdir lock mirror of src/locks.ts ────────────
94
+ # Lock is a mkdir dir; owner token lives in the SIDECAR "<lock>.owner" (beside,
95
+ # not inside, so the dir stays empty and a plain rmdir still removes it). Stale
96
+ # removal is gated behind a single-winner mkdir "<lock>.steal" marker plus
97
+ # compare-and-clear; release removes the lock only if we still own it. Keep in
98
+ # sync with src/locks.ts (identical block in assets/pretooluse.sh).
99
+ OXL_STALE=30 # seconds; mirror src/mailbox.ts LOCK_STALE_MS — also the
100
+ # marker-staleness window (same SIGSTOP-class threshold)
101
+ oxl_now() { date +%s 2>/dev/null || echo 0; }
102
+ oxl_mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; }
103
+ oxl_token() { # pid.random; tolerate a missing /dev/urandom without degrading to bare pid
104
+ local r
105
+ r=$(od -An -N6 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')
106
+ [ -n "$r" ] || r="${RANDOM}${RANDOM}${RANDOM}"
107
+ echo "$$.$r"
108
+ }
109
+ oxl_owner() { cat "$1.owner" 2>/dev/null || true; }
110
+ oxl_clear_stale() { # $1=lock dir; returns 0 if it did clearing work (retry mkdir)
111
+ local lock="$1" n mt obs smt
112
+ n=$(oxl_now); mt=$(oxl_mtime "$lock")
113
+ [ "$mt" -gt 0 ] || return 1
114
+ [ $((n - mt)) -gt "$OXL_STALE" ] || return 1
115
+ obs=$(oxl_owner "$lock")
116
+ if mkdir "$lock.steal" 2>/dev/null; then
117
+ if [ "x$(oxl_owner "$lock")" = "x$obs" ]; then
118
+ rm -f "$lock.owner" 2>/dev/null
119
+ rmdir "$lock" 2>/dev/null || rm -rf "$lock" 2>/dev/null
120
+ fi
121
+ rmdir "$lock.steal" 2>/dev/null
122
+ return 0
123
+ fi
124
+ smt=$(oxl_mtime "$lock.steal")
125
+ if [ "$smt" -gt 0 ] && [ $((n - smt)) -gt "$OXL_STALE" ]; then rmdir "$lock.steal" 2>/dev/null; fi
126
+ return 1
127
+ }
128
+ oxl_acquire() { # $1=lock dir; prints owner token on success, returns 0/1
129
+ local lock="$1" t i
130
+ t=$(oxl_token)
96
131
  for i in $(seq 1 50); do
97
- if mkdir "$m.lock" 2>/dev/null; then locked+=("$m"); break; fi
98
- now=$(date +%s 2>/dev/null || echo 0)
99
- mt=$(stat -c %Y "$m.lock" 2>/dev/null || stat -f %m "$m.lock" 2>/dev/null || echo 0)
100
- if [ "$mt" -gt 0 ] && [ $((now - mt)) -gt 30 ]; then rmdir "$m.lock" 2>/dev/null; fi
132
+ if mkdir "$lock" 2>/dev/null; then
133
+ printf '%s' "$t" > "$lock.owner" 2>/dev/null || true
134
+ printf '%s' "$t"
135
+ return 0
136
+ fi
137
+ oxl_clear_stale "$lock" && continue
101
138
  sleep 0.01
102
139
  done
140
+ return 1
141
+ }
142
+ oxl_release() { # $1=lock dir, $2=our token — remove only if we PROVABLY own it
143
+ local lock="$1" t="$2" o
144
+ o=$(oxl_owner "$lock")
145
+ if [ -z "$t" ] || [ "x$o" = "x$t" ]; then
146
+ rm -f "$lock.owner" 2>/dev/null
147
+ rmdir "$lock" 2>/dev/null || true
148
+ fi
149
+ # owner differs or absent → not provably ours; leave it (it ages into a stale
150
+ # lock and is reclaimed by oxl_clear_stale) rather than stomp a successor.
151
+ }
152
+ # ─────────────────────────────────────────────────────────────────────────────
153
+
154
+ # 5. Lock each non-empty mailbox (best-effort; 30s staleness window).
155
+ locked=()
156
+ locked_tokens=()
157
+ for m in "${mboxes[@]}"; do
158
+ tok=$(oxl_acquire "$m.lock") && { locked+=("$m"); locked_tokens+=("$tok"); }
103
159
  done
104
160
  # Couldn't lock anything → leave messages for next time. This still allows the
105
161
  # turn to stop, so mark idle; otherwise wake:auto will suppress a wake for a
@@ -215,5 +271,9 @@ else
215
271
  mark_idle
216
272
  fi
217
273
 
218
- for m in "${locked[@]}"; do rmdir "$m.lock" 2>/dev/null || true; done
274
+ ri=0
275
+ for m in "${locked[@]}"; do
276
+ oxl_release "$m.lock" "${locked_tokens[$ri]:-}"
277
+ ri=$((ri + 1))
278
+ done
219
279
  exit 0
package/dist/delivery.js CHANGED
@@ -30,3 +30,24 @@ export function deliverToPeer(receiverSessionId, targetPid, body, fromSessionId,
30
30
  mailbox.requeue(targetPid, msg);
31
31
  return msg;
32
32
  }
33
+ // Re-deliver an ALREADY-BUILT message to a peer, preserving its message_id and
34
+ // (re)recording the receiver's ledger handle BEFORE the mailbox line becomes
35
+ // visible — same record-before-append ordering as deliverToPeer. Used by the
36
+ // ask_peer abort-recovery path: the reply was drained into memory but the client
37
+ // aborted before it was returned, so it must be re-enqueued WITHOUT minting a new
38
+ // id. (mailbox.enqueue would mint a fresh id and skip the ledger, so the
39
+ // redelivered reply's displayed id resolves to message-not-found on
40
+ // reply_to_message.) The ledger write is best-effort — a failure must never drop
41
+ // the redelivery; worst case the handle is missing and the peer falls back to
42
+ // send_message.
43
+ export function deliverExistingToPeer(receiverSessionId, targetPid, msg) {
44
+ if (receiverSessionId) {
45
+ try {
46
+ recordReceived(receiverSessionId, msg);
47
+ }
48
+ catch (e) {
49
+ trace("received_ledger_write_failed", { message_id: msg.id, error: String(e) });
50
+ }
51
+ }
52
+ mailbox.requeue(targetPid, msg);
53
+ }
package/dist/locks.js ADDED
@@ -0,0 +1,207 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdirSync, readFileSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { trace } from "./trace.js";
4
+ // Shared advisory-lock primitive for the mkdir-based locks used by both the
5
+ // mailbox queues (mailbox.ts) and the received-ledger (received.ts), and mirrored
6
+ // in the bash hooks (assets/pretooluse.sh, assets/stop.sh). Centralised here
7
+ // because stale-recovery is subtle and must be reasoned about (and tested) once.
8
+ //
9
+ // HONEST LIMIT: a provably race-free, stale-recoverable advisory lock is not
10
+ // achievable on a plain shared filesystem (no atomic compare-and-swap; every
11
+ // "detect stale → remove → reacquire" has a check-then-act window). This design
12
+ // eliminates the REALISTIC failure modes; the residuals that remain ALL require
13
+ // a process to stall (SIGSTOP / huge swap / multi-second GC) past the 30s stale
14
+ // window while inside a microsecond-wide gap between two syscalls:
15
+ // (a) a clearer that stalls >30s between its owner-compare and its rmdir, while
16
+ // another clearer reclaims the (now >30s-stale) steal marker and reacquires;
17
+ // (b) a holder that stalls >30s between mkdir(lock) and writeOwner(lock), gets
18
+ // stale-cleared as owner-less, then resumes and overwrites a successor's
19
+ // owner sidecar;
20
+ // (c) a holder that stalls >30s mid-critical-section and resumes to do its data
21
+ // write believing it still holds the lock (the data ops do not re-validate
22
+ // ownership before writing).
23
+ // Eliminating these needs kernel-arbitrated locks (flock/fcntl), which are not
24
+ // viable here because the lock is shared with bash hooks on macOS (no flock CLI).
25
+ // The consequence of any of these firing is bounded — a rare double-delivery
26
+ // (benign once readers dedup by message_id) or a rare ledger lost-update (the
27
+ // reply handle degrades to send_message), never a wedge or torn file.
28
+ //
29
+ // Two mechanisms do the work:
30
+ // 1. OWNER TOKEN. Each acquisition writes a unique token into the SIDECAR file
31
+ // `<lock>.owner` (kept beside the lock dir, NOT inside it, so the lock dir
32
+ // stays empty and a bash hook's plain `rmdir <lock>` still works cross-
33
+ // language). Release only removes the lock if the token still matches — so a
34
+ // holder that stalled past the stale window, got its lock stolen, and then
35
+ // resumes can no longer rmdir the SUCCESSOR's fresh lock (stall-resume bug).
36
+ // 2. SINGLE-WINNER + COMPARE-AND-CLEAR. Stale removal is gated behind an atomic
37
+ // `mkdir(<lock>.steal)` marker, and the clearer removes the lock only if its
38
+ // owner is STILL the dead token it observed. While the marker is held and the
39
+ // lock still exists, nobody else can clear (marker) or acquire (mkdir EEXIST),
40
+ // so the owner is stable across the check→rmdir. And the actual acquire is
41
+ // ALWAYS the single-winner `mkdir(lock)`, so even redundant clears can never
42
+ // produce two owners — the worst they do is race to recreate the lock, which
43
+ // exactly one wins.
44
+ const LOCK_RETRY_LIMIT = 50;
45
+ const LOCK_RETRY_DELAY_MS = 10;
46
+ function sleepSync(ms) {
47
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
48
+ }
49
+ // Sidecar beside the lock dir (not inside) so the lock dir stays empty and a
50
+ // bash hook's plain `rmdir <lock>` still removes a Node-held lock. An orphaned
51
+ // sidecar (lock dir removed but sidecar left, e.g. by a bash clearer that doesn't
52
+ // know about it) is harmless — the next acquirer overwrites it.
53
+ function ownerPath(lock) {
54
+ return `${lock}.owner`;
55
+ }
56
+ function mintToken() {
57
+ return `${process.pid}.${randomBytes(8).toString("hex")}`;
58
+ }
59
+ // Read the owner token, or null if the lock has none (foreign/legacy lock, or a
60
+ // lock observed in the tiny window after mkdir but before the owner write).
61
+ function readOwner(lock) {
62
+ try {
63
+ return readFileSync(ownerPath(lock), "utf8");
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ function writeOwner(lock, token) {
70
+ try {
71
+ writeFileSync(ownerPath(lock), token, { mode: 0o600 });
72
+ }
73
+ catch {
74
+ // Best effort: an owner-less lock still excludes (the dir exists); it just
75
+ // loses the stall-resume protection until the next acquisition.
76
+ }
77
+ }
78
+ // Remove the lock dir and its owner file. Tolerates a foreign non-empty lock dir
79
+ // (e.g. one a bash hook or test created without our layout) via a recursive rm.
80
+ function removeLock(lock) {
81
+ try {
82
+ unlinkSync(ownerPath(lock));
83
+ }
84
+ catch {
85
+ // no owner file — fine
86
+ }
87
+ try {
88
+ rmdirSync(lock);
89
+ }
90
+ catch (e) {
91
+ const err = e;
92
+ if (err.code === "ENOENT")
93
+ return;
94
+ // Non-empty (foreign contents) or other — fall back to recursive removal.
95
+ try {
96
+ rmSync(lock, { recursive: true, force: true });
97
+ }
98
+ catch {
99
+ // best effort
100
+ }
101
+ }
102
+ }
103
+ // Compare-and-clear a stale lock under the single-winner steal marker. Returns
104
+ // true iff this call did the clearing work (caller retries mkdir immediately);
105
+ // false if the lock is fresh, vanished, or another clearer holds the marker
106
+ // (caller should sleep and retry).
107
+ export function clearStaleLock(lock, staleMs, traceEvent, traceCtx) {
108
+ let st;
109
+ try {
110
+ st = statSync(lock);
111
+ }
112
+ catch {
113
+ return false; // lock vanished between the failed mkdir and now — just retry
114
+ }
115
+ if (Date.now() - st.mtimeMs <= staleMs)
116
+ return false; // fresh holder — wait
117
+ const observed = readOwner(lock); // the (presumed dead) holder's token, or null
118
+ const steal = `${lock}.steal`;
119
+ try {
120
+ mkdirSync(steal, { mode: 0o700 });
121
+ }
122
+ catch (e) {
123
+ const err = e;
124
+ if (err.code === "EEXIST") {
125
+ // Another clearer holds the marker. If the marker is itself stale by the
126
+ // SAME stale window as the lock (its clearer crashed/SIGSTOP'd mid-steal),
127
+ // force it so recovery cannot wedge forever. Using the lock's stale window
128
+ // (not a shorter one) means a clearer can only be displaced after a full
129
+ // 30s stall — the same SIGSTOP-class threshold as every other residual —
130
+ // rather than after a brief pause. Compare-and-clear below still refuses to
131
+ // remove a lock whose owner changed, backstopping a reclaim race.
132
+ try {
133
+ const sst = statSync(steal);
134
+ if (Date.now() - sst.mtimeMs > staleMs) {
135
+ try {
136
+ rmdirSync(steal);
137
+ }
138
+ catch {
139
+ // raced with another clearer — fine
140
+ }
141
+ }
142
+ }
143
+ catch {
144
+ // marker vanished — fine
145
+ }
146
+ }
147
+ return false; // lost the steal — sleep and retry
148
+ }
149
+ // Sole clearer (modulo a leaked-marker race, which compare-and-clear backstops).
150
+ // Re-read the owner now: if it still equals what we observed, the dead holder's
151
+ // lock is unchanged and safe to remove; if it changed, someone reacquired and
152
+ // we must leave their lock alone.
153
+ if (readOwner(lock) === observed) {
154
+ removeLock(lock);
155
+ trace(traceEvent, traceCtx);
156
+ }
157
+ try {
158
+ rmdirSync(steal);
159
+ }
160
+ catch {
161
+ // best effort — a leaked marker is force-cleared by the next clearer
162
+ }
163
+ return true;
164
+ }
165
+ // Acquire the advisory lock, returning the owner token to hand back to
166
+ // releaseDirLock. The caller is responsible for creating the parent directory.
167
+ export function acquireDirLock(lock, staleMs, traceEvent, traceCtx) {
168
+ const token = mintToken();
169
+ for (let i = 0; i < LOCK_RETRY_LIMIT; i++) {
170
+ try {
171
+ mkdirSync(lock, { mode: 0o700 });
172
+ writeOwner(lock, token);
173
+ return token;
174
+ }
175
+ catch (e) {
176
+ const err = e;
177
+ if (err.code !== "EEXIST")
178
+ throw err;
179
+ if (clearStaleLock(lock, staleMs, traceEvent, traceCtx))
180
+ continue;
181
+ sleepSync(LOCK_RETRY_DELAY_MS);
182
+ }
183
+ }
184
+ throw new Error(`could not acquire lock at ${lock}`);
185
+ }
186
+ // Release the lock — but only if we PROVABLY still own it (owner === our token).
187
+ // A holder that stalled past the stale window and was stolen from sees a
188
+ // different owner and leaves the successor's lock intact. We deliberately do NOT
189
+ // remove on an absent owner: a successor in its mkdir→writeOwner window has no
190
+ // owner yet, and removing then would stomp its fresh lock (Codex round-3). If our
191
+ // OWN owner write was lost, the cost is a leaked lock — which simply ages into a
192
+ // stale lock and is reclaimed by clearStaleLock, strictly safer than a stomp.
193
+ export function releaseDirLock(lock, token) {
194
+ if (!token) {
195
+ removeLock(lock); // no token to verify (defensive/legacy) — best-effort
196
+ return;
197
+ }
198
+ const owner = readOwner(lock);
199
+ if (owner === token) {
200
+ removeLock(lock);
201
+ }
202
+ else {
203
+ // Owner differs or is absent → not provably ours; leave it. A truly
204
+ // abandoned lock becomes stale and is reclaimed by clearStaleLock.
205
+ trace("lock_release_skipped_not_owner", { lock });
206
+ }
207
+ }
package/dist/mailbox.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { appendFileSync, mkdirSync, readFileSync, rmdirSync, statSync, truncateSync, writeFileSync, } from "node:fs";
2
+ import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, truncateSync, writeFileSync, } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { acquireDirLock, releaseDirLock } from "./locks.js";
5
6
  import { trace } from "./trace.js";
6
7
  // Resolved lazily so tests can swap HOME between cases. Each call re-reads
7
8
  // homedir(), which on POSIX defers to $HOME.
@@ -17,58 +18,24 @@ function mailboxesDir() {
17
18
  //
18
19
  // Sync this value with assets/pretooluse.sh (find -mmin +0.5 ≈ 30s).
19
20
  const LOCK_STALE_MS = 30_000;
20
- const LOCK_RETRY_LIMIT = 50;
21
- const LOCK_RETRY_DELAY_MS = 10;
22
21
  function mailboxPath(pid) {
23
22
  return join(mailboxesDir(), `${pid}.jsonl`);
24
23
  }
25
24
  function lockPath(pid) {
26
25
  return `${mailboxPath(pid)}.lock`;
27
26
  }
28
- function sleepSync(ms) {
29
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
30
- }
27
+ // Owner tokens for held locks, so releaseLock can prove ownership (a lock stolen
28
+ // out from under a stalled holder is not removed on its late release). Keyed by
29
+ // pid; never two concurrent acquisitions of the same pid within one process.
30
+ const lockTokens = new Map();
31
31
  export function acquireLock(pid) {
32
32
  mkdirSync(mailboxesDir(), { recursive: true, mode: 0o700 });
33
- const lock = lockPath(pid);
34
- for (let i = 0; i < LOCK_RETRY_LIMIT; i++) {
35
- try {
36
- mkdirSync(lock, { mode: 0o700 });
37
- return;
38
- }
39
- catch (e) {
40
- const err = e;
41
- if (err.code !== "EEXIST")
42
- throw err;
43
- // Check staleness. If older than LOCK_STALE_MS, force-clear and retry.
44
- try {
45
- const st = statSync(lock);
46
- if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
47
- try {
48
- rmdirSync(lock);
49
- trace("mailbox_lock_stale_clear", { pid });
50
- }
51
- catch {
52
- // raced with another clearer; fall through to retry
53
- }
54
- continue;
55
- }
56
- }
57
- catch {
58
- // stat may race; just retry
59
- }
60
- sleepSync(LOCK_RETRY_DELAY_MS);
61
- }
62
- }
63
- throw new Error(`could not acquire mailbox lock for pid ${pid}`);
33
+ lockTokens.set(pid, acquireDirLock(lockPath(pid), LOCK_STALE_MS, "mailbox_lock_stale_clear", { pid }));
64
34
  }
65
35
  export function releaseLock(pid) {
66
- try {
67
- rmdirSync(lockPath(pid));
68
- }
69
- catch {
70
- // ignore ENOENT / not-empty / EPERM
71
- }
36
+ const token = lockTokens.get(pid);
37
+ lockTokens.delete(pid);
38
+ releaseDirLock(lockPath(pid), token ?? "");
72
39
  }
73
40
  // Critical: the serialized JSONL line must always begin
74
41
  // `{"schema_version":1,"id":"...","body":"`. The awk extractor in
@@ -107,6 +74,47 @@ export function serializeMailboxLine(msg) {
107
74
  }
108
75
  return line;
109
76
  }
77
+ // Append JSONL bytes to a mailbox, healing a missing record boundary first.
78
+ // appendFileSync of a buffer is NOT a single atomic syscall, so a crash/torn
79
+ // write can leave a file ending in a partial line with no trailing "\n". A later
80
+ // append would then concatenate onto that partial line, gluing two records into
81
+ // one line that fails JSON.parse in BOTH drain() and the awk hook — silently
82
+ // dropping both messages. If the file is non-empty and its last byte isn't "\n",
83
+ // prepend one so the boundary is restored (the already-torn record is still lost,
84
+ // but it can no longer eat its neighbor). Every append path routes through here.
85
+ function appendLines(path, buf) {
86
+ let heal = false;
87
+ let fd;
88
+ try {
89
+ const st = statSync(path);
90
+ if (st.size > 0) {
91
+ fd = openSync(path, "r");
92
+ const last = Buffer.alloc(1);
93
+ readSync(fd, last, 0, 1, st.size - 1);
94
+ heal = last[0] !== 0x0a; // 0x0a === "\n"
95
+ }
96
+ }
97
+ catch (e) {
98
+ const err = e;
99
+ if (err.code !== "ENOENT")
100
+ throw err;
101
+ }
102
+ finally {
103
+ if (fd !== undefined)
104
+ closeSync(fd);
105
+ }
106
+ appendFileSync(path, heal ? "\n" + buf : buf);
107
+ }
108
+ // Atomically replace a file's contents: write to a unique temp file in the same
109
+ // directory, then renameSync over the target. rename(2) is atomic on POSIX, so a
110
+ // concurrent reader/crasher never observes a torn file — unlike writeFileSync,
111
+ // which issues multiple write() syscalls and can leave a half-written line on
112
+ // crash, dropping unrelated surviving records.
113
+ function atomicWrite(path, data) {
114
+ const tmp = `${path}.tmp.${process.pid}.${randomBytes(6).toString("hex")}`;
115
+ writeFileSync(tmp, data, { mode: 0o600 });
116
+ renameSync(tmp, path);
117
+ }
110
118
  // Mint a message envelope WITHOUT writing it anywhere. Split out from enqueue so
111
119
  // a higher layer (delivery.ts) can record the durable received-ledger entry
112
120
  // BEFORE the mailbox line becomes visible — the ordering that guarantees any
@@ -129,7 +137,7 @@ export function enqueue(target_pid, body, from_session_id, options = {}) {
129
137
  const msg = buildMessage(body, from_session_id, options);
130
138
  acquireLock(target_pid);
131
139
  try {
132
- appendFileSync(mailboxPath(target_pid), serializeMailboxLine(msg));
140
+ appendLines(mailboxPath(target_pid), serializeMailboxLine(msg));
133
141
  }
134
142
  finally {
135
143
  releaseLock(target_pid);
@@ -144,7 +152,7 @@ export function requeue(target_pid, msg) {
144
152
  const line = serializeMailboxLine(msg);
145
153
  acquireLock(target_pid);
146
154
  try {
147
- appendFileSync(mailboxPath(target_pid), line);
155
+ appendLines(mailboxPath(target_pid), line);
148
156
  }
149
157
  finally {
150
158
  releaseLock(target_pid);
@@ -161,7 +169,7 @@ export function requeueMany(target_pid, msgs) {
161
169
  buf += serializeMailboxLine(m);
162
170
  acquireLock(target_pid);
163
171
  try {
164
- appendFileSync(mailboxPath(target_pid), buf);
172
+ appendLines(mailboxPath(target_pid), buf);
165
173
  }
166
174
  finally {
167
175
  releaseLock(target_pid);
@@ -174,17 +182,28 @@ export function requeueMany(target_pid, msgs) {
174
182
  // of silently stranding it. Best-effort per pid: a contended/unreadable mailbox
175
183
  // is skipped (counted) and left for the next poll rather than failing the whole
176
184
  // drain — one stuck lock must not block a session's entire inbox.
185
+ //
186
+ // Deduped by message_id: a migrateMailbox crash-window (append to dest done, but
187
+ // the process died before truncating the source) can leave the SAME message in
188
+ // two unioned sibling mailboxes. Both copies are drained (so neither lingers) but
189
+ // the message is returned ONCE. message_id is a unique per-message nonce, so this
190
+ // only ever collapses true duplicates, never two distinct messages.
177
191
  export function drainMany(pids) {
178
192
  const out = [];
179
- const seen = new Set();
193
+ const seenPids = new Set();
194
+ const seenIds = new Set();
180
195
  let skipped = 0;
181
196
  for (const pid of pids) {
182
- if (seen.has(pid))
197
+ if (seenPids.has(pid))
183
198
  continue;
184
- seen.add(pid);
199
+ seenPids.add(pid);
185
200
  try {
186
- for (const m of drain(pid))
201
+ for (const m of drain(pid)) {
202
+ if (seenIds.has(m.id))
203
+ continue;
204
+ seenIds.add(m.id);
187
205
  out.push(m);
206
+ }
188
207
  }
189
208
  catch {
190
209
  skipped++;
@@ -250,7 +269,7 @@ export function migrateMailbox(fromPid, toPid) {
250
269
  const count = raw.split("\n").filter((l) => l.trim().length > 0).length;
251
270
  acquireLock(toPid);
252
271
  try {
253
- appendFileSync(mailboxPath(toPid), block);
272
+ appendLines(mailboxPath(toPid), block);
254
273
  }
255
274
  finally {
256
275
  releaseLock(toPid);
@@ -382,7 +401,7 @@ function drainFirstMatching(my_pid, matches) {
382
401
  }
383
402
  }
384
403
  else {
385
- writeFileSync(mailboxPath(my_pid), surviving.join("\n") + "\n");
404
+ atomicWrite(mailboxPath(my_pid), surviving.join("\n") + "\n");
386
405
  }
387
406
  return matchedMsg;
388
407
  }
package/dist/received.js CHANGED
@@ -1,7 +1,8 @@
1
- import { createHash } from "node:crypto";
2
- import { mkdirSync, readFileSync, rmdirSync, statSync, writeFileSync, } from "node:fs";
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { acquireDirLock, releaseDirLock } from "./locks.js";
5
6
  import { trace } from "./trace.js";
6
7
  // The received-message ledger: a durable, per-session index of every inbound
7
8
  // envelope, keyed by message_id. It exists because both delivery paths are
@@ -38,12 +39,10 @@ function ledgerPath(sessionId) {
38
39
  function lockPath(sessionId) {
39
40
  return `${ledgerPath(sessionId)}.lock`;
40
41
  }
41
- // Lock idiom mirrors mailbox.ts (mkdir-based, staleness-cleared). The ledger
42
- // read-modify-write is small (bounded by receivedMax() lines) so the lock
42
+ // Lock idiom mirrors mailbox.ts (owner-token mkdir lock — see locks.ts). The
43
+ // ledger read-modify-write is small (bounded by receivedMax() lines) so the lock
43
44
  // window is short.
44
45
  const LOCK_STALE_MS = 30_000;
45
- const LOCK_RETRY_LIMIT = 50;
46
- const LOCK_RETRY_DELAY_MS = 10;
47
46
  // Bounded retention: keep at most this many of the most-recent inbound messages
48
47
  // per session. Read lazily so tests can tune it per-case. Generous by default so
49
48
  // a realistic mailbox burst (read_my_messages budgets 50/drain) can't push a
@@ -53,49 +52,27 @@ export function receivedMax() {
53
52
  const n = Number(process.env.OXTAIL_RECEIVED_MAX);
54
53
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1000;
55
54
  }
56
- function sleepSync(ms) {
57
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
58
- }
55
+ // Owner tokens for held ledger locks (see mailbox.ts for the rationale).
56
+ const lockTokens = new Map();
59
57
  function acquireLock(sessionId) {
60
58
  mkdirSync(receivedDir(), { recursive: true, mode: 0o700 });
61
- const lock = lockPath(sessionId);
62
- for (let i = 0; i < LOCK_RETRY_LIMIT; i++) {
63
- try {
64
- mkdirSync(lock, { mode: 0o700 });
65
- return;
66
- }
67
- catch (e) {
68
- const err = e;
69
- if (err.code !== "EEXIST")
70
- throw err;
71
- try {
72
- const st = statSync(lock);
73
- if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
74
- try {
75
- rmdirSync(lock);
76
- trace("received_lock_stale_clear", { session_id: sessionId });
77
- }
78
- catch {
79
- // raced with another clearer; fall through to retry
80
- }
81
- continue;
82
- }
83
- }
84
- catch {
85
- // stat may race; just retry
86
- }
87
- sleepSync(LOCK_RETRY_DELAY_MS);
88
- }
89
- }
90
- throw new Error(`could not acquire received-ledger lock for ${sessionId}`);
59
+ lockTokens.set(sessionId, acquireDirLock(lockPath(sessionId), LOCK_STALE_MS, "received_lock_stale_clear", {
60
+ session_id: sessionId,
61
+ }));
91
62
  }
92
63
  function releaseLock(sessionId) {
93
- try {
94
- rmdirSync(lockPath(sessionId));
95
- }
96
- catch {
97
- // ignore ENOENT / not-empty / EPERM
98
- }
64
+ const token = lockTokens.get(sessionId);
65
+ lockTokens.delete(sessionId);
66
+ releaseDirLock(lockPath(sessionId), token ?? "");
67
+ }
68
+ // Atomically replace the ledger: write a unique temp file, then renameSync over
69
+ // the target. rename(2) is atomic on POSIX, so a crash/torn write can't leave a
70
+ // half-rewritten ledger that loses older reply handles — unlike a direct
71
+ // writeFileSync, which issues multiple write() syscalls.
72
+ function atomicWrite(path, data) {
73
+ const tmp = `${path}.tmp.${process.pid}.${randomBytes(6).toString("hex")}`;
74
+ writeFileSync(tmp, data, { mode: 0o600 });
75
+ renameSync(tmp, path);
99
76
  }
100
77
  function readLines(sessionId) {
101
78
  try {
@@ -132,9 +109,7 @@ export function recordReceived(receiverSessionId, msg) {
132
109
  kept: max,
133
110
  });
134
111
  }
135
- writeFileSync(ledgerPath(receiverSessionId), pruned.join("\n") + "\n", {
136
- mode: 0o600,
137
- });
112
+ atomicWrite(ledgerPath(receiverSessionId), pruned.join("\n") + "\n");
138
113
  }
139
114
  finally {
140
115
  releaseLock(receiverSessionId);
package/dist/server.js CHANGED
@@ -13,7 +13,7 @@ import { trace } from "./trace.js";
13
13
  import { buildEntry, chooseVerifiedWakePane, findByTmuxSession, 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
- import { deliverToPeer } from "./delivery.js";
16
+ import { deliverExistingToPeer, deliverToPeer } from "./delivery.js";
17
17
  import { recoverClaim, resolveAncestors, writeClaim } from "./claims.js";
18
18
  import { decideReplyAutoWake, defaultAutowakeDir } from "./autowake.js";
19
19
  import { markWoke, newWakeDebounceStore, recentlyWoke } from "./wake-debounce.js";
@@ -1728,11 +1728,11 @@ server.registerTool("ask_peer", {
1728
1728
  // Re-enqueue so it's not lost.
1729
1729
  if (aborted && reply) {
1730
1730
  try {
1731
- mailbox.enqueue(entry.server_pid, reply.body, reply.from_session_id, {
1732
- request_id: reply.request_id,
1733
- reply_to: reply.reply_to,
1734
- source_message_id: reply.source_message_id,
1735
- });
1731
+ // Re-deliver the EXISTING reply: preserve reply.id and (re)write the
1732
+ // requester's received-ledger entry so reply_to_message against the
1733
+ // displayed id still resolves. mailbox.enqueue would mint a NEW id and
1734
+ // skip the ledger, breaking the reply handle on the abort path.
1735
+ deliverExistingToPeer(entry.client.session_id, entry.server_pid, reply);
1736
1736
  trace("ask_peer_abort_reenqueue", { message_id: reply.id });
1737
1737
  }
1738
1738
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxtail",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Coordination layer for parallel AI coding agent sessions, exposed over MCP.",
@@ -23,10 +23,20 @@ export const HOOK_MARKER_KEY = "_oxtailHook";
23
23
  // and drop the redundant single-valued `origin` field. message_id +
24
24
  // from_session_id are still rendered (correlation/debug unaffected); a
25
25
  // stale pre-v5 hook is only larger, never wrong.
26
+ // v6: owner-token advisory lock (mirror of src/locks.ts) in pretooluse + stop.
27
+ // The lock dir gains a sidecar `<lock>.owner` token; stale removal is
28
+ // gated behind a single-winner `<lock>.steal` marker + compare-and-clear,
29
+ // and release only removes a lock we still own. The sidecar layout keeps
30
+ // the lock dir EMPTY so a pre-v6 hook's plain `rmdir` still removes a v6
31
+ // lock — i.e. mixed versions never WEDGE. They are not fully race-safe,
32
+ // though: a pre-v6 hook does an unconditional stale-rmdir / release-rmdir
33
+ // with no owner check, so during an upgrade window (before re-install) the
34
+ // old hook can still lose the stall-resume / double-clear races against a
35
+ // v6 peer. The version bump forces re-install to close that window.
26
36
  // INVARIANT: any change to an assets/*.sh script MUST bump this version, so
27
37
  // existing installs are forced to re-install. scripts/check-hook-version.mjs
28
38
  // enforces this in CI.
29
- export const HOOK_MARKER_VERSION = 5;
39
+ export const HOOK_MARKER_VERSION = 6;
30
40
 
31
41
  const HOOKS_DIR = path.join(os.homedir(), ".oxtail", "hooks");
32
42