@gcunharodrigues/wrxn 0.20.0 → 0.20.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gcunharodrigues/wrxn",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "WRXN Kernel — installable AI operating system. Two profiles (project | workspace), pull-based updates, managed/seeded/state file classes.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"wrxn": "bin/wrxn.cjs"
|
|
@@ -49,6 +49,91 @@ function findInstallRoot(startDir) {
|
|
|
49
49
|
return null;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// ── skip-log (#104) ──────────────────────────────────────────────────────────────
|
|
53
|
+
// The #45 `wx` claim blocks a re-spawn SILENTLY, so a benign dedup skip is invisible in the synth log.
|
|
54
|
+
// When a fire skips because the marker is already present, append ONE row to .wrxn/continuity/.synth.log so
|
|
55
|
+
// the dedup is diagnosable. The row matches memory-synth.cjs's writeSynthLog shape EXACTLY — tab-separated
|
|
56
|
+
// six fields: ISO timestamp, session id, task `handoff`, engine `-`, `attempts=0`, outcome `skip`. The
|
|
57
|
+
// session id is sanitized via the same idiom as memory-synth's sanitizeLogField (strip C0/C1 control chars —
|
|
58
|
+
// which INCLUDES tab \x09 and newline \x0a — then length-cap) so a hostile id can't forge extra rows. `skip`
|
|
59
|
+
// is a NON-failure token (the #51 staleness guard treats only no-engine/error as rot), so a dedup never
|
|
60
|
+
// false-warns. Self-contained — node stdlib only, NO kernel import; sanitizeLogField is duplicated by design
|
|
61
|
+
// (every install-only hook is standalone, exactly as safeId is replicated across the hooks).
|
|
62
|
+
const SYNTH_LOG = '.synth.log';
|
|
63
|
+
const LOG_FIELD_MAX = 64;
|
|
64
|
+
|
|
65
|
+
function sanitizeLogField(v) {
|
|
66
|
+
// eslint-disable-next-line no-control-regex
|
|
67
|
+
return String(v == null ? '' : v).replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, LOG_FIELD_MAX);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── content-versioned claim (#105) ───────────────────────────────────────────────
|
|
71
|
+
// Slice 1 (#104) released the `.spawned-<sid>` marker on SessionStart so a resumed session's later end could
|
|
72
|
+
// re-synth. When that release is MISSED (e.g. a multi-terminal continuation whose new process never released
|
|
73
|
+
// the claim), the existence-only marker freezes the baton with no recovery. Slice 2 makes the dedup
|
|
74
|
+
// CONTENT-AWARE: stamp the marker with the session transcript's byte size (+ a timestamp) at claim time, then
|
|
75
|
+
// on a marker-present end re-arm the synth when the transcript has GROWN materially since the stamp — a
|
|
76
|
+
// self-heal that needs no SessionStart release. Any fault (absent/unreadable transcript path, stat fault,
|
|
77
|
+
// unparseable marker) falls back to slice 1's existence-only claim and is fully fail-open (never throws,
|
|
78
|
+
// never double-fires on a fault). Self-contained — node stdlib only, NO kernel import.
|
|
79
|
+
//
|
|
80
|
+
// GROWTH_THRESHOLD — the byte growth that counts as "did real work since the stamp". A same-end double-fire
|
|
81
|
+
// appends NOTHING to the transcript (~0 bytes); a resumed session that did real work appends KB–MB of JSONL
|
|
82
|
+
// (each transcript turn — uuid/parentUuid/sessionId/timestamp/message — is hundreds of bytes to several KB).
|
|
83
|
+
// 1 KiB sits comfortably above zero / trailing-byte jitter and well below a single real exchange, so it
|
|
84
|
+
// cleanly separates "no new work" (skip) from "genuine continuation" (re-arm). Conservative by design: the
|
|
85
|
+
// re-arm fires only on a POSITIVE growth signal, so any uncertainty defaults to skip (never double-fire).
|
|
86
|
+
const GROWTH_THRESHOLD = 1024;
|
|
87
|
+
|
|
88
|
+
// The transcript byte size from the SessionEnd payload's transcript_path, or null on ANY fault (absent path,
|
|
89
|
+
// ENOENT, EACCES, …). null = "size unknown" → the caller falls back to the existence-only claim (fail-open).
|
|
90
|
+
function transcriptSize(transcriptPath) {
|
|
91
|
+
try {
|
|
92
|
+
if (!transcriptPath) return null;
|
|
93
|
+
return fs.statSync(transcriptPath).size;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The marker payload stamped at claim/re-arm time: the transcript size + an ISO timestamp. A null size
|
|
100
|
+
// (unreadable transcript) is stamped as-is, so a later read yields no baseline → existence-only fallback.
|
|
101
|
+
function stampContent(size) {
|
|
102
|
+
return JSON.stringify({ size: typeof size === 'number' ? size : null, at: new Date().toISOString() });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The size stamped into an existing marker, or null when there is no usable baseline — a #104 zero-byte
|
|
106
|
+
// marker, unparseable JSON, or a null/non-number size. null → the caller falls back to existence-only (skip),
|
|
107
|
+
// so a corrupt/legacy marker can never trigger a re-arm (any uncertainty defaults to skip, never double-fire).
|
|
108
|
+
function readStampedSize(markerPath) {
|
|
109
|
+
try {
|
|
110
|
+
const raw = fs.readFileSync(markerPath, 'utf8');
|
|
111
|
+
if (!raw.trim()) return null;
|
|
112
|
+
const parsed = JSON.parse(raw);
|
|
113
|
+
return typeof parsed.size === 'number' ? parsed.size : null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Append exactly one tab-separated `skip` row for a marker-present dedup. Best-effort + FAIL-OPEN: a logging
|
|
120
|
+
// fault is swallowed so it can NEVER affect the dedup (the diagnosability log must not become a failure mode).
|
|
121
|
+
function appendSkipLog(dir, sessionId) {
|
|
122
|
+
try {
|
|
123
|
+
const line = [
|
|
124
|
+
new Date().toISOString(),
|
|
125
|
+
sanitizeLogField(sessionId) || '-', // untrusted stash value — strip control chars, cap length.
|
|
126
|
+
'handoff',
|
|
127
|
+
'-',
|
|
128
|
+
'attempts=0',
|
|
129
|
+
'skip',
|
|
130
|
+
].join('\t');
|
|
131
|
+
fs.appendFileSync(path.join(dir, SYNTH_LOG), line + '\n');
|
|
132
|
+
} catch {
|
|
133
|
+
/* the skip log is best-effort — a write fault must never affect the dedup */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
52
137
|
/**
|
|
53
138
|
* The testable core. Given the SessionEnd payload, the install root, the env (for the recursion guard),
|
|
54
139
|
* and an injectable `spawn`, it stashes the payload + writes the pending markers and launches the synth
|
|
@@ -68,14 +153,52 @@ function run({ payload, root, env = process.env, spawn: spawnFn = spawn }) {
|
|
|
68
153
|
// Once-per-session claim (#45): the harness fires SessionEnd more than once per session, so an
|
|
69
154
|
// unguarded hook launches N synths that race on one shared `.pending`. CLAIM the session ATOMICALLY
|
|
70
155
|
// with an exclusive create (`wx` throws EEXIST if the marker exists) BEFORE staging markers / spawning:
|
|
71
|
-
// the FIRST fire creates the marker and proceeds;
|
|
72
|
-
//
|
|
73
|
-
// processes (no TOCTOU). The marker is a PERSISTENT per-session file — it must
|
|
74
|
-
// is NOT `.pending`/`.pending-handoff` (which the synth clears on exit). A
|
|
75
|
-
// cannot be deduped → preserve today's spawn-every-time behavior for that path.
|
|
156
|
+
// the FIRST fire creates+stamps the marker and proceeds; a later fire for the same session throws EEXIST,
|
|
157
|
+
// handled below by the content-aware dedup (#105: re-arm if the transcript grew, else skip). Race-safe
|
|
158
|
+
// across the separate hook processes (no TOCTOU). The marker is a PERSISTENT per-session file — it must
|
|
159
|
+
// OUTLIVE the synth, so it is NOT `.pending`/`.pending-handoff` (which the synth clears on exit). A
|
|
160
|
+
// missing/empty session_id cannot be deduped → preserve today's spawn-every-time behavior for that path.
|
|
76
161
|
const sid = payload && payload.session_id;
|
|
77
162
|
if (sid) {
|
|
78
|
-
|
|
163
|
+
const markerPath = path.join(dir, `.spawned-${safeId(sid)}`);
|
|
164
|
+
const currentSize = transcriptSize(payload && payload.transcript_path); // null on any fault → fallback
|
|
165
|
+
try {
|
|
166
|
+
// Atomic exclusive claim (wx is TOCTOU-free across the separate hook processes, #45) — and STAMP it
|
|
167
|
+
// with the transcript byte size + a timestamp (#105), so a later marker-present end can tell a genuine
|
|
168
|
+
// continuation (transcript grew) from a same-end duplicate. Write to the just-claimed fd so the create
|
|
169
|
+
// and the stamp are one operation on the file we exclusively own.
|
|
170
|
+
const fd = fs.openSync(markerPath, 'wx');
|
|
171
|
+
try {
|
|
172
|
+
fs.writeSync(fd, stampContent(currentSize));
|
|
173
|
+
} finally {
|
|
174
|
+
fs.closeSync(fd);
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err && err.code === 'EEXIST') {
|
|
178
|
+
// The session is already claimed — a same-instance double-fire (#45) OR a resume's later end before
|
|
179
|
+
// SessionStart released the marker. Content-aware dedup (#105): re-arm ONLY on a positive growth
|
|
180
|
+
// signal — if the transcript grew past the threshold since the stamp, this is a genuine continuation
|
|
181
|
+
// (a missed release), so RE-STAMP to the new size and fall through to spawn (the baton un-freezes).
|
|
182
|
+
// Otherwise — no material growth, OR an indeterminate baseline/size from a fault — it is a same-end
|
|
183
|
+
// duplicate: log ONE benign `skip` row (#104) and no-op. Any uncertainty defaults to skip, so a
|
|
184
|
+
// stat/read fault can never double-fire the synth.
|
|
185
|
+
const stampedSize = readStampedSize(markerPath);
|
|
186
|
+
if (stampedSize != null && currentSize != null && currentSize - stampedSize > GROWTH_THRESHOLD) {
|
|
187
|
+
try {
|
|
188
|
+
fs.writeFileSync(markerPath, stampContent(currentSize)); // re-stamp the new baseline
|
|
189
|
+
} catch {
|
|
190
|
+
/* re-stamp is best-effort: a write fault must not block the re-arm — still spawn (PRD: the rare
|
|
191
|
+
double-spawn is bounded by the in-flight .pending markers + the synth's once-per-session guard) */
|
|
192
|
+
}
|
|
193
|
+
// fall through (do NOT return) → stash the pending markers + spawn the synth.
|
|
194
|
+
} else {
|
|
195
|
+
appendSkipLog(dir, sid);
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
throw err; // any OTHER claim fault → fail-open via the outer catch (not a dedup → no skip row)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
79
202
|
}
|
|
80
203
|
|
|
81
204
|
// Stash the payload as .pending (the synth reads it for transcript_path) and raise the handoff gate.
|
|
@@ -93,6 +93,38 @@ function stampStartHead(root, sessionId, opts = {}) {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// ── re-arm the SessionEnd synth on resume (#104) ─────────────────────────────────
|
|
97
|
+
// The SessionEnd spawn hook (memory-synth-spawn.cjs) dedups via a PERSISTENT per-session marker
|
|
98
|
+
// .wrxn/continuity/.spawned-<sid>. But SessionEnd fires once per PROCESS instance, not per session id — a
|
|
99
|
+
// resume reopens the SAME id in a NEW process, so the resumed session's later (content-rich) end hits the
|
|
100
|
+
// existing marker, is swallowed fail-open, and never re-synths: the baton FREEZES at the first end. The
|
|
101
|
+
// fix: SessionStart releases (deletes) the marker on EVERY start, so the next SessionEnd for this id is free
|
|
102
|
+
// to synth again. A fresh id (startup / clear) has no marker → no-op. The spawn hook's WITHIN-instance `wx`
|
|
103
|
+
// claim (the same-process #45 guard) is untouched — only this cross-process persistent marker is released
|
|
104
|
+
// between sessions. The marker name is keyed by safeId, byte-identical to the spawn hook's writer, so the
|
|
105
|
+
// release deletes EXACTLY what that hook wrote.
|
|
106
|
+
|
|
107
|
+
const CONTINUITY_DIR_REL = ['.wrxn', 'continuity'];
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Release (delete) the session's once-per-session synth claim marker at
|
|
111
|
+
* <root>/.wrxn/continuity/.spawned-<safeId(sessionId)>, re-arming the SessionEnd synth for a resumed
|
|
112
|
+
* session id (#104). Returns true when a marker was removed; false on any fail-open path (no root/session,
|
|
113
|
+
* absent marker, unwritable / any fault). NEVER throws — orientation must never block on the release.
|
|
114
|
+
* @param {string} root install root
|
|
115
|
+
* @param {string} sessionId the session id (marker key)
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
function releaseSpawnClaim(root, sessionId) {
|
|
119
|
+
try {
|
|
120
|
+
if (!root || !sessionId) return false;
|
|
121
|
+
fs.unlinkSync(path.join(root, ...CONTINUITY_DIR_REL, `.spawned-${safeId(sessionId)}`));
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false; // absent marker (no-op) / unwritable / any delete fault → fail-open, never blocks orientation
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
96
128
|
// Walk up from CLAUDE_PROJECT_DIR (or cwd) to the install root carrying wrxn.install.json.
|
|
97
129
|
function findInstallRoot(startDir) {
|
|
98
130
|
let dir = startDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
@@ -348,6 +380,15 @@ function main() {
|
|
|
348
380
|
/* never block orientation on the baseline stamp */
|
|
349
381
|
}
|
|
350
382
|
|
|
383
|
+
// #104: release the SessionEnd synth's once-per-session claim so a RESUMED session (same id, new process)
|
|
384
|
+
// re-synths its later, content-rich end instead of freezing the baton at the first end. A fresh id
|
|
385
|
+
// (startup / clear) has no marker → no-op. Fail-open: any fault is swallowed and orientation proceeds.
|
|
386
|
+
try {
|
|
387
|
+
if (event && event.session_id) releaseSpawnClaim(root, event.session_id);
|
|
388
|
+
} catch {
|
|
389
|
+
/* never block orientation on the claim release */
|
|
390
|
+
}
|
|
391
|
+
|
|
351
392
|
// Hold for an in-flight SessionEnd synth (auto-memory-03) so a back-to-back /clear resumes on the
|
|
352
393
|
// FRESH baton, bounded by the crash safety-cap. Fail-open: any fault here must not block orientation.
|
|
353
394
|
try {
|
|
@@ -399,4 +440,4 @@ if (require.main === module) {
|
|
|
399
440
|
}
|
|
400
441
|
}
|
|
401
442
|
|
|
402
|
-
module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL, batonStaleness, parseSynthLog, readSynthLogTail, SYNTH_LOG_TAIL_BYTES };
|
|
443
|
+
module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, releaseSpawnClaim, resolveGitHead, BASELINE_DIR_REL, batonStaleness, parseSynthLog, readSynthLogTail, SYNTH_LOG_TAIL_BYTES };
|