@wrongstack/wrongtrace 0.317.0 → 0.317.2

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.
@@ -20,7 +20,20 @@ export interface McpTransport {
20
20
  readonly availableTools: McpToolName[];
21
21
  invoke<T = unknown>(tool: McpToolName, args: Record<string, unknown>): Promise<T | null>;
22
22
  }
23
- export declare function createMcpTransport(tools?: McpToolBag): McpTransport;
23
+ /**
24
+ * Per-call bound — shared with httpJson via constants.ts so every transport
25
+ * surface in this adapter carries the same latency ceiling: a hung MCP
26
+ * bridge can never block the edit path (see hooks.ts failure philosophy:
27
+ * coordination is an optimization, never a hard dependency).
28
+ *
29
+ * Cancellation note: the bound limits CALLER settlement only. When the
30
+ * timeout branch wins, the underlying handler promise is not aborted — the
31
+ * MCP tool contract has no abort channel, so a never-settling handler keeps
32
+ * running (and any resources it captured stay alive) until it settles on its
33
+ * own. Callers are bounded regardless; the outlived promise is garbage once
34
+ * nothing references it.
35
+ */
36
+ export declare function createMcpTransport(tools?: McpToolBag, timeoutMs?: number): McpTransport;
24
37
  export declare const mcp: {
25
38
  health(_health: WrongTraceHealth | null): McpToolName | null;
26
39
  lockResult(result: WrongTraceLockResult | null): WrongTraceLockResult | null;
@@ -11,7 +11,21 @@
11
11
  * are provided, every call resolves with `null`, same as HTTP/IPC
12
12
  * failure paths.
13
13
  */
14
- export function createMcpTransport(tools = {}) {
14
+ import { DEFAULT_TRANSPORT_TIMEOUT_MS } from "../constants.js";
15
+ /**
16
+ * Per-call bound — shared with httpJson via constants.ts so every transport
17
+ * surface in this adapter carries the same latency ceiling: a hung MCP
18
+ * bridge can never block the edit path (see hooks.ts failure philosophy:
19
+ * coordination is an optimization, never a hard dependency).
20
+ *
21
+ * Cancellation note: the bound limits CALLER settlement only. When the
22
+ * timeout branch wins, the underlying handler promise is not aborted — the
23
+ * MCP tool contract has no abort channel, so a never-settling handler keeps
24
+ * running (and any resources it captured stay alive) until it settles on its
25
+ * own. Callers are bounded regardless; the outlived promise is garbage once
26
+ * nothing references it.
27
+ */
28
+ export function createMcpTransport(tools = {}, timeoutMs = DEFAULT_TRANSPORT_TIMEOUT_MS) {
15
29
  const entries = Object.entries(tools).filter(([, v]) => typeof v === "function");
16
30
  return {
17
31
  isWired: entries.length > 0,
@@ -20,12 +34,30 @@ export function createMcpTransport(tools = {}) {
20
34
  const handler = tools[tool];
21
35
  if (!handler)
22
36
  return null;
37
+ let timer;
23
38
  try {
24
- return (await handler(args));
39
+ // Bound the await: a handler that never settles must resolve null
40
+ // after timeoutMs instead of leaving the caller pending forever.
41
+ // Promise.race consumes both settlement paths, so a late rejection
42
+ // of the underlying handler cannot surface as an unhandled rejection.
43
+ return (await Promise.race([
44
+ handler(args),
45
+ new Promise((resolve) => {
46
+ timer = setTimeout(() => resolve(null), timeoutMs);
47
+ }),
48
+ ]));
25
49
  }
26
50
  catch {
27
51
  return null;
28
52
  }
53
+ finally {
54
+ // Never leave the race timer dangling: when the handler wins the
55
+ // race, a ref'ed setTimeout would hold the event loop open for the
56
+ // remaining timeoutMs (and abandoned timers stack at higher call
57
+ // rates). Clear it on every settlement path.
58
+ if (timer !== undefined)
59
+ clearTimeout(timer);
60
+ }
29
61
  },
30
62
  };
31
63
  }
@@ -134,10 +134,28 @@ export function summarizeFriction(friction) {
134
134
  }
135
135
  const topEntry = [...pairTotals.values()].sort((x, y) => y.count - x.count)[0];
136
136
  const topPair = topEntry ? `${topEntry.a} ↔ ${topEntry.b} (${topEntry.count} conflicts)` : null;
137
- const selfThrash = edges.filter((e) => e.is_self_thrash).length;
138
- const crossAgent = total - selfThrash;
139
- const crossAgentRatioPct = total > 0 ? Math.round((crossAgent / total) * 100) : 0;
140
- const selfThrashRatioPct = total > 0 ? Math.round((selfThrash / total) * 100) : 0;
137
+ // Ratio units must match. `total` is a COLLISION count when the report
138
+ // carries `total_collisions`, so self-thrash must be weighted by each
139
+ // edge's conflict_count (falling back to one collision per edge when the
140
+ // count is missing) the old per-EDGE count inflated the cross-agent
141
+ // share (99% instead of the true 50/50) and went NEGATIVE when
142
+ // self-thrash edges outnumbered total_collisions. When the report omits
143
+ // total_collisions, `total` is an edge count and per-edge counting is the
144
+ // correct unit. Percentages are clamped to [0,100] so a self-thrash
145
+ // collision sum bigger than the daemon's windowed total renders 100%,
146
+ // never 1433%.
147
+ const collisionUnits = typeof r.total_collisions === "number";
148
+ const selfThrash = edges.reduce((acc, e) => {
149
+ if (!e.is_self_thrash)
150
+ return acc;
151
+ if (!collisionUnits)
152
+ return acc + 1;
153
+ const raw = e.conflict_count;
154
+ return acc + (typeof raw === "number" ? raw : 1);
155
+ }, 0);
156
+ const crossAgent = Math.max(0, total - selfThrash);
157
+ const crossAgentRatioPct = total > 0 ? Math.min(100, Math.round((crossAgent / total) * 100)) : 0;
158
+ const selfThrashRatioPct = total > 0 ? Math.min(100, Math.round((selfThrash / total) * 100)) : 0;
141
159
  const prose = (topPair ? `Top friction pair: ${topPair}. ` : "") +
142
160
  `Cross-agent ratio: ${crossAgentRatioPct}% of ${total} collisions. ` +
143
161
  `Self-thrash: ${selfThrashRatioPct}%.`;
package/dist/client.js CHANGED
@@ -32,8 +32,8 @@
32
32
  */
33
33
  import { createMcpTransport } from "./adapters/mcp.js";
34
34
  import { createIpcTransport } from "./adapters/ipc.js";
35
+ import { DEFAULT_TRANSPORT_TIMEOUT_MS } from "./constants.js";
35
36
  import { discover } from "./discovery.js";
36
- const DEFAULT_TIMEOUT_MS = 4_000;
37
37
  class HttpError extends Error {
38
38
  status;
39
39
  constructor(status, message) {
@@ -47,7 +47,7 @@ async function httpJson(baseUrl, path, init) {
47
47
  if (typeof fetchImpl !== "function")
48
48
  return null;
49
49
  const controller = new AbortController();
50
- const timer = setTimeout(() => controller.abort(), init?.timeoutMs ?? DEFAULT_TIMEOUT_MS);
50
+ const timer = setTimeout(() => controller.abort(), init?.timeoutMs ?? DEFAULT_TRANSPORT_TIMEOUT_MS);
51
51
  try {
52
52
  const headers = { Accept: "application/json" };
53
53
  if (init?.body !== undefined)
@@ -193,11 +193,14 @@ export async function createWrongTraceClient(opts = {}) {
193
193
  return httpJson(base, `/api/atlas${qs}`);
194
194
  },
195
195
  async lockFile(path, reason, opts) {
196
- // Deliberately HTTP-first — the ONE IPC exception; see the strategy
197
- // header. Live probe 2026-08-24: IPC guardrail/lock ignores conflicts
198
- // (silently takes over even with force:false), so routing lock calls
199
- // through the pipe would break the 409-conflict semantics the
200
- // production guardrail depends on.
196
+ // Deliberately HTTP-first — the ONE exception to the transport
197
+ // preference order; see the strategy header. Live probe 2026-08-24:
198
+ // the daemon's non-HTTP lock surface ignores conflicts (IPC silently
199
+ // takes over another owner's lock even with force:false), and the MCP
200
+ // lock_file tool mirrors the pipe — so routing lock calls through
201
+ // either surface BEFORE HTTP would break the 409-conflict semantics
202
+ // the production guardrail depends on. MCP is consulted only when the
203
+ // HTTP path is unavailable.
201
204
  const body = { path, reason };
202
205
  if (opts?.owner !== undefined)
203
206
  body.owner = opts.owner;
@@ -207,22 +210,35 @@ export async function createWrongTraceClient(opts = {}) {
207
210
  body.ttl_seconds = opts.ttlSeconds;
208
211
  if (opts?.force === true)
209
212
  body.force = true;
213
+ const base = requireBaseUrl();
214
+ if (base) {
215
+ // 409 = lock conflict: the daemon returns {ok:false, owner, owner_run_id,
216
+ // locked_at, expires_at, error, message} — exactly what the caller needs
217
+ // to decide whether to wait or take over, so pass the body through.
218
+ const viaHttp = await httpJson(base, "/api/guardrail/lock", {
219
+ method: "POST",
220
+ body,
221
+ acceptStatus: [409],
222
+ });
223
+ if (viaHttp !== null)
224
+ return viaHttp;
225
+ // httpJson collapses timeout / 5xx / unreadable-409-body into null —
226
+ // the POST's outcome is AMBIGUOUS: it may have been processed and may
227
+ // have conflicted. Consulting the conflict-unsafe MCP lock surface now
228
+ // could silently take over a peer's lock (live-probed daemon behavior,
229
+ // see header). Fail closed instead: report no lock; the caller's
230
+ // fail-open/TTL philosophy covers the coordination gap.
231
+ return null;
232
+ }
233
+ // No HTTP route exists at all (daemon undiscoverable over HTTP) — MCP
234
+ // is the only remaining surface. Best-effort coordination, and any
235
+ // conflict the MCP tool does report (ok:false) reaches the caller.
210
236
  if (mcp.isWired) {
211
237
  const viaMcp = await mcp.invoke("lock_file", { ...body });
212
238
  if (viaMcp)
213
239
  return viaMcp;
214
240
  }
215
- const base = requireBaseUrl();
216
- if (!base)
217
- return notAvailable();
218
- // 409 = lock conflict: the daemon returns {ok:false, owner, owner_run_id,
219
- // locked_at, expires_at, error, message} — exactly what the caller needs
220
- // to decide whether to wait or take over, so pass the body through.
221
- return httpJson(base, "/api/guardrail/lock", {
222
- method: "POST",
223
- body,
224
- acceptStatus: [409],
225
- });
241
+ return null;
226
242
  },
227
243
  async unlockFile(path) {
228
244
  // IPC-first since daemon v0.3.3 (live-verified 2026-08-24): the pipe
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Default per-call transport timeout (ms) shared by every request surface in
3
+ * this adapter. `httpJson` (client.ts) and `createMcpTransport` (mcp.ts) both
4
+ * default their per-call bound to this single value, so a slow daemon can
5
+ * never add latency surprises to the edit path (see the hooks.ts failure
6
+ * philosophy: coordination is an optimization, never a hard dependency).
7
+ *
8
+ * One source of truth instead of mirroring literals that can drift apart —
9
+ * tuning the ceiling once adjusts every transport surface.
10
+ */
11
+ export declare const DEFAULT_TRANSPORT_TIMEOUT_MS = 4000;
12
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Default per-call transport timeout (ms) shared by every request surface in
3
+ * this adapter. `httpJson` (client.ts) and `createMcpTransport` (mcp.ts) both
4
+ * default their per-call bound to this single value, so a slow daemon can
5
+ * never add latency surprises to the edit path (see the hooks.ts failure
6
+ * philosophy: coordination is an optimization, never a hard dependency).
7
+ *
8
+ * One source of truth instead of mirroring literals that can drift apart —
9
+ * tuning the ceiling once adjusts every transport surface.
10
+ */
11
+ export const DEFAULT_TRANSPORT_TIMEOUT_MS = 4_000;
12
+ //# sourceMappingURL=constants.js.map
package/dist/discovery.js CHANGED
@@ -19,7 +19,7 @@ import { join } from "node:path";
19
19
  /** Default IPC paths per platform — only consulted when `/api/health` did not return `socket_path`. */
20
20
  export function defaultSocketPath(home = process.env['HOME'] ?? process.env['USERPROFILE'] ?? "") {
21
21
  if (platform() === "win32")
22
- return "\\\\.\\pipe\\wrongtrace.sock";
22
+ return "\\\\.\\pipe\\wrongtrace";
23
23
  if (home)
24
24
  return join(home, ".wrongtrace", "ipc.sock");
25
25
  return "/tmp/wrongtrace.sock";
package/dist/hooks.js CHANGED
@@ -104,10 +104,43 @@ function emitSafe(emit, event) {
104
104
  * same path increment the count and the daemon lock is only released when
105
105
  * the LAST finisher's postToolUse decrements it to zero — a sibling finishing
106
106
  * early can never unlock a path another in-flight edit still holds.
107
+ *
108
+ * Counts PARTICIPANTS, not just acquirers: a lock-conflict-race preToolUse
109
+ * (claim failed but the edit proceeds) also increments, so the racer's own
110
+ * postToolUse decrements its own entry instead of a sibling's claim. The
111
+ * final release is owner-guarded: when a race touched the path, count-zero
112
+ * does not prove the lock is ours, so the daemon's file-health is consulted
113
+ * before unlocking (a peer's lock is never released).
107
114
  */
108
115
  function newWrongTraceLockCounter() {
109
116
  return new Map();
110
117
  }
118
+ // Race-flag ledger keyed by the counters map identity, so pairs AND the
119
+ // legacy factories (which share the module-level `legacyLocks` map) see the
120
+ // same "did a race touch this path" state for the same map.
121
+ const racedByCounters = new WeakMap();
122
+ function racedSetFor(counters) {
123
+ let set = racedByCounters.get(counters);
124
+ if (!set) {
125
+ set = new Set();
126
+ racedByCounters.set(counters, set);
127
+ }
128
+ return set;
129
+ }
130
+ // Acquiring identity per path, keyed by the counters map identity. The
131
+ // owner-guard at release compares the daemon's lock_owner against the owner
132
+ // the claim was ACTUALLY acquired with — not re-derived from sessionId() —
133
+ // so legacy post hooks (which are built with an empty session identity) can
134
+ // still verify and release locks their paired pre hook acquired.
135
+ const claimedOwnerByCounters = new WeakMap();
136
+ function claimedOwnerSetFor(counters) {
137
+ let map = claimedOwnerByCounters.get(counters);
138
+ if (!map) {
139
+ map = new Map();
140
+ claimedOwnerByCounters.set(counters, map);
141
+ }
142
+ return map;
143
+ }
111
144
  function acquireLock(counters, path) {
112
145
  counters.set(path, (counters.get(path) ?? 0) + 1);
113
146
  }
@@ -158,13 +191,23 @@ export function createWrongTraceHookPair(sessionId, opts = {}, counters = newWro
158
191
  });
159
192
  if (res?.ok === true) {
160
193
  acquireLock(counters, path);
194
+ // Remember the identity this claim was actually acquired with —
195
+ // the release-side owner-guard needs it (legacy post hooks are
196
+ // built with an empty session identity and cannot re-derive it).
197
+ claimedOwnerSetFor(counters).set(path, owner);
161
198
  emitSafe(emit, { kind: "lock-acquired", path, owner });
162
199
  }
163
200
  else {
164
201
  // Peer grabbed it between the pre-flight and the claim (or our
165
202
  // own earlier leak still holds it — the exemption let us through).
166
203
  // Either way the file is being edited by someone: we proceed
167
- // without re-claiming; coordination stays advisory.
204
+ // without re-claiming; coordination stays advisory. The edit is
205
+ // STILL an in-flight participant on this path, so count it —
206
+ // otherwise this racer's postToolUse would decrement a sibling's
207
+ // claim and release the daemon lock mid-edit (the refcount
208
+ // contract: only the LAST finisher releases).
209
+ acquireLock(counters, path);
210
+ racedSetFor(counters).add(path);
168
211
  emitSafe(emit, { kind: "lock-conflict-race", path });
169
212
  }
170
213
  }
@@ -197,11 +240,39 @@ export function createWrongTraceHookPair(sessionId, opts = {}, counters = newWro
197
240
  });
198
241
  if (!shouldUnlock)
199
242
  return; // a sibling still holds this path
200
- emitSafe(emit, { kind: "lock-released", path });
201
243
  try {
202
244
  const wt = await getWrongTrace();
203
- if (wt.isAvailable)
204
- await wt.unlockFile(path);
245
+ if (wt.isAvailable) {
246
+ // If a lock-conflict-race ever touched this path, count-zero does
247
+ // NOT prove the daemon lock is ours — the claim went to a peer (or
248
+ // never landed). Verify ownership against the daemon's file-health
249
+ // before releasing, and never unlock a peer's lock. The expected
250
+ // owner is the identity the claim was ACTUALLY acquired with
251
+ // (recorded on acquire) — legacy post hooks are built with an
252
+ // empty session identity and cannot re-derive it; only when no
253
+ // acquisition was recorded do we fall back to the pair's session
254
+ // id (pure self-leak cleanup).
255
+ let racerInvolved = false;
256
+ if (racedSetFor(counters).has(path)) {
257
+ racerInvolved = true;
258
+ const recordedOwner = claimedOwnerSetFor(counters).get(path);
259
+ const expectedOwner = recordedOwner ?? `wrongstack:${sessionId()}`;
260
+ const health = await wt.getFileHealth(path);
261
+ if (health?.lock_owner !== expectedOwner)
262
+ return; // not ours — TTL reaps
263
+ }
264
+ const released = await wt.unlockFile(path);
265
+ if (released !== null && released.ok !== false) {
266
+ // Emit lock-released only on a CONFIRMED daemon response —
267
+ // mirroring lock-acquired's confirmed-emission rule. A failed
268
+ // unlock (daemon offline mid-flight, HTTP 5xx/4xx, IPC
269
+ // unreachable → null) must not record a release the daemon never
270
+ // performed; the TTL backstop reaps the lock instead.
271
+ emitSafe(emit, { kind: "lock-released", path });
272
+ }
273
+ if (racerInvolved)
274
+ racedSetFor(counters).delete(path);
275
+ }
205
276
  }
206
277
  catch {
207
278
  // TTL backstop will reap it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/wrongtrace",
3
- "version": "0.317.0",
3
+ "version": "0.317.2",
4
4
  "license": "MIT",
5
5
  "description": "Optional client adapter for the external WrongTrace AI Observability daemon. Discovers the daemon over HTTP/IPC/MCP at runtime; returns isAvailable:false when it is not reachable, so callers degrade gracefully. Not coupled to any runtime inside WrongStack — this is the 'sibling' integration protocol.",
6
6
  "repository": {