@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.
- package/dist/adapters/mcp.d.ts +14 -1
- package/dist/adapters/mcp.js +34 -2
- package/dist/agent-helpers.js +22 -4
- package/dist/client.js +34 -18
- package/dist/constants.d.ts +12 -0
- package/dist/constants.js +12 -0
- package/dist/discovery.js +1 -1
- package/dist/hooks.js +75 -4
- package/package.json +1 -1
package/dist/adapters/mcp.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/adapters/mcp.js
CHANGED
|
@@ -11,7 +11,21 @@
|
|
|
11
11
|
* are provided, every call resolves with `null`, same as HTTP/IPC
|
|
12
12
|
* failure paths.
|
|
13
13
|
*/
|
|
14
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/agent-helpers.js
CHANGED
|
@@ -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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 ??
|
|
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
|
|
197
|
-
// header. Live probe 2026-08-24:
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": {
|