@gcunharodrigues/wrxn 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gcunharodrigues/wrxn",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
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"
|
|
@@ -201,6 +201,99 @@ function holdForHandoff({ root, capMs = HOLD_CAP_MS, now = Date.now, sleep } = {
|
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
// ── baton-staleness guard (#51) ─────────────────────────────────────────────────
|
|
205
|
+
// The handoff baton is surfaced as the resume point with no health check, so a baton frozen by a failed
|
|
206
|
+
// SessionEnd synth (no-engine / error) is shown as current — silently (real incident 2026-06-22→23: the
|
|
207
|
+
// baton froze ~20h while .synth.log logged no-engine from later sessions). This guard reads the synth log +
|
|
208
|
+
// the baton mtime and, when the latest synth attempt failed and it is NOT a same-session double-spawn (the
|
|
209
|
+
// session that wrote the baton failing again right after — #45), surfaces a visible warning. The DECISION is
|
|
210
|
+
// pure (no IO, no clock — the holdDecision idiom); the hook does the IO and formats the age where it holds
|
|
211
|
+
// the clock. NO polling/sleeping — one file read + one stat.
|
|
212
|
+
|
|
213
|
+
const SYNTH_LOG_REL = ['.wrxn', 'continuity', '.synth.log'];
|
|
214
|
+
|
|
215
|
+
// A synth row is a FAILURE iff its outcome is `no-engine` or an `error…` string (`wrote`/`trivial` = healthy).
|
|
216
|
+
function isFailure(outcome) {
|
|
217
|
+
const o = String(outcome || '');
|
|
218
|
+
return o === 'no-engine' || /^error/.test(o);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Parse the tab-separated synth log into chronological rows { timestampMs, sessionId, outcome }, dropping
|
|
222
|
+
// any line we cannot resolve (malformed / unparseable timestamp) — "newest-resolvable last". The sessionId
|
|
223
|
+
// (field 1, `-` when absent) is what discriminates a double-spawn from genuine rot. PURE + total, never throws.
|
|
224
|
+
function parseSynthLog(text) {
|
|
225
|
+
const rows = [];
|
|
226
|
+
for (const line of String(text || '').split('\n')) {
|
|
227
|
+
if (!line.trim()) continue;
|
|
228
|
+
const f = line.split('\t');
|
|
229
|
+
if (f.length < 6) continue; // not a full outcome row → skip (fail-open)
|
|
230
|
+
const ts = Date.parse(f[0]);
|
|
231
|
+
if (!Number.isFinite(ts)) continue; // unresolvable timestamp → drop
|
|
232
|
+
rows.push({ timestampMs: ts, sessionId: f[1], outcome: f.slice(5).join('\t').trim() });
|
|
233
|
+
}
|
|
234
|
+
return rows;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Human age for the warning line (computed where the clock is, so batonStaleness stays clock-free). PURE.
|
|
238
|
+
function formatAge(ms) {
|
|
239
|
+
const m = Math.max(0, Math.round(Number(ms) / 60000));
|
|
240
|
+
if (m < 60) return `${m}m`;
|
|
241
|
+
const h = Math.floor(m / 60);
|
|
242
|
+
if (h < 24) return `${h}h`;
|
|
243
|
+
const d = Math.floor(h / 24);
|
|
244
|
+
const rem = h % 24;
|
|
245
|
+
return rem ? `${d}d ${rem}h` : `${d}d`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Sanitize the echoed synth outcome before it is interpolated into the orientation block (sec-F1). An
|
|
249
|
+
// `error: …` row carries a FREE-FORM message, so a crafted/corrupt log row could smuggle control chars or a
|
|
250
|
+
// forged `</wrxn-orientation>` close tag into additionalContext. Strip control chars (incl CR/LF/tab), drop
|
|
251
|
+
// angle brackets so no tag can be forged, and cap the length. PURE + total. (Mirrors memory-synth's
|
|
252
|
+
// sanitizeLogField idiom — each install-only hook is self-contained, node stdlib only.)
|
|
253
|
+
function sanitizeOutcome(s) {
|
|
254
|
+
return String(s == null ? '' : s)
|
|
255
|
+
// eslint-disable-next-line no-control-regex
|
|
256
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
257
|
+
.replace(/[<>]/g, '')
|
|
258
|
+
.slice(0, 120);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Pure baton-staleness decision (#51). Given the baton's last-write time and the synth-log rows
|
|
263
|
+
* (chronological, newest-resolvable last, each carrying its sessionId), return the FAILING outcome to name
|
|
264
|
+
* in the warning, or null when the baton is healthy/fresh. Warn IFF the newest row failed (`no-engine` |
|
|
265
|
+
* `error…`) AND the baton predates that newest row AND it is NOT a same-session double-spawn.
|
|
266
|
+
*
|
|
267
|
+
* Double-spawn vs genuine-rot is discriminated by SESSION ID, NOT by timestamp. The session that wrote the
|
|
268
|
+
* CURRENT baton is the session of the MOST-RECENT `wrote` row (every successful synth logs a `wrote` row in
|
|
269
|
+
* runHandoff's finally, a few ms AFTER it set the baton mtime — so that row's timestamp is ALWAYS ≥ the baton
|
|
270
|
+
* mtime, and a timestamp test can never tell the two apart). The #45 double-spawn logs `wrote` then a spurious
|
|
271
|
+
* `no-engine` ~2s later under the SAME session → suppress. A genuine freeze has later failures from DIFFERENT
|
|
272
|
+
* sessions (real incident 2026-06-22→23: baton written by 6898c0a9, then 7b69b97c / 39e5754b no-engine hours
|
|
273
|
+
* later) → warn. A missing/`-` session id on either side cannot confirm a double-spawn → default to warn (a
|
|
274
|
+
* loud false-positive beats silent rot). PURE + total: no IO, no clock, never throws.
|
|
275
|
+
* @param {{ batonMtimeMs:number, rows:Array<{timestampMs:number, sessionId?:string, outcome:string}> }} [input]
|
|
276
|
+
* @returns {string|null} the failing outcome to name, or null when healthy
|
|
277
|
+
*/
|
|
278
|
+
function batonStaleness({ batonMtimeMs, rows } = {}) {
|
|
279
|
+
if (!Array.isArray(rows) || rows.length === 0) return null; // no rows → nothing to judge
|
|
280
|
+
if (typeof batonMtimeMs !== 'number' || !Number.isFinite(batonMtimeMs)) return null; // no usable mtime
|
|
281
|
+
const newest = rows[rows.length - 1];
|
|
282
|
+
if (!newest || !isFailure(newest.outcome)) return null; // latest run healthy → fresh
|
|
283
|
+
if (Number.isFinite(newest.timestampMs) && newest.timestampMs <= batonMtimeMs) return null; // baton at/after newest → fresh
|
|
284
|
+
// The session that wrote the CURRENT baton = the session of the most-recent `wrote` row.
|
|
285
|
+
let wroteSession;
|
|
286
|
+
for (const r of rows) {
|
|
287
|
+
if (r && r.outcome === 'wrote') wroteSession = r.sessionId;
|
|
288
|
+
}
|
|
289
|
+
const sameSession =
|
|
290
|
+
!!wroteSession && wroteSession !== '-' &&
|
|
291
|
+
!!newest.sessionId && newest.sessionId !== '-' &&
|
|
292
|
+
wroteSession === newest.sessionId;
|
|
293
|
+
if (sameSession) return null; // same-session double-spawn → not stale
|
|
294
|
+
return newest.outcome; // genuine rot (or unconfirmable) → name the failing outcome
|
|
295
|
+
}
|
|
296
|
+
|
|
204
297
|
function main() {
|
|
205
298
|
let consumed = '';
|
|
206
299
|
try {
|
|
@@ -241,6 +334,24 @@ function main() {
|
|
|
241
334
|
const baton = readBaton(root);
|
|
242
335
|
if (baton && baton.trim()) {
|
|
243
336
|
parts.push('', 'Resume — deliberate handoff baton (.wrxn/continuity/latest.md):', baton.trim());
|
|
337
|
+
// #51: warn if the latest SessionEnd synth failed and no successful write has refreshed the baton since
|
|
338
|
+
// — a frozen baton must not be surfaced as current silently. Fail-open: any read/stat/parse fault → no
|
|
339
|
+
// warning, never a throw. Pure file reads + a stat only (no polling/sleeping beyond the hold cap above).
|
|
340
|
+
try {
|
|
341
|
+
const rows = parseSynthLog(readFileOr(path.join(root, ...SYNTH_LOG_REL), ''));
|
|
342
|
+
const batonMtimeMs = fs.statSync(path.join(root, '.wrxn', 'continuity', 'latest.md')).mtimeMs;
|
|
343
|
+
const failing = batonStaleness({ batonMtimeMs, rows });
|
|
344
|
+
if (failing) {
|
|
345
|
+
const age = formatAge(Date.now() - batonMtimeMs);
|
|
346
|
+
const outcome = sanitizeOutcome(failing); // sec-F1: never echo a raw log field into the orientation
|
|
347
|
+
parts.push(
|
|
348
|
+
'',
|
|
349
|
+
`WARNING — stale handoff baton: the latest memory-synth run failed ("${outcome}") and the baton has not been refreshed for ${age}. It may not reflect the last session — see .wrxn/continuity/.synth.log.`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
/* fail-open: a staleness-check fault must never block orientation (#51 AC3) */
|
|
354
|
+
}
|
|
244
355
|
} else {
|
|
245
356
|
parts.push('', 'Resume — no prior handoff.');
|
|
246
357
|
}
|
|
@@ -261,4 +372,4 @@ if (require.main === module) {
|
|
|
261
372
|
}
|
|
262
373
|
}
|
|
263
374
|
|
|
264
|
-
module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL };
|
|
375
|
+
module.exports = { holdDecision, holdForHandoff, HOLD_CAP_MS, stampStartHead, resolveGitHead, BASELINE_DIR_REL, batonStaleness };
|
package/payload/.wrxn/dream.cjs
CHANGED
|
@@ -390,7 +390,11 @@ const NEGATIVE_FILTERS = [
|
|
|
390
390
|
// a transient environment / setup failure — not a durable property of the system. (The bare adjectives
|
|
391
391
|
// `transient`/`intermittent` are intentionally NOT here: they false-positive on DI-lifetime decisions
|
|
392
392
|
// like "services are registered transient" — the concrete error codes below carry the failure intent.)
|
|
393
|
-
|
|
393
|
+
// SAME discipline for rate-limiting: the bare noun "rate limit" / adjective "rate-limited" is NOT matched
|
|
394
|
+
// (it false-positives on rate limiting AS A FEATURE — a token-bucket decision, a "rate-limited tier").
|
|
395
|
+
// Only the ERROR sense matches: a passive/verb "got rate-limited", a transitive "rate-limited us", or a
|
|
396
|
+
// "rate-limit(ed) error" / "rate limit exceeded" cue — the transient war-story, never the design.
|
|
397
|
+
{ reason: 'negative_filter_transient_failure', re: /\b(econnrefused|enoent|eaddrinuse|etimedout|connection refused|connection reset|timed out|time-?out|flak(e|y|ey)|(?:got|get|getting|gets|was|were|been|being|keeps?|kept|repeatedly) rate[- ]?limited|rate[- ]?limited (?:us|me|them|by|again)|rate[- ]?limit(?:ed|ing)? (?:error|errors|exceeded)|http 5\d\d|50[234]|port (already )?in use|address already in use|network (error|issue|glitch)|dns (error|failure))\b/ },
|
|
394
398
|
// a smoke / sanity / happy-path RESULT — proves nothing durable. Gated on a result word so a forward
|
|
395
399
|
// decision ("we adopt smoke tests", "the happy path must stay fast") is NOT a false positive.
|
|
396
400
|
{ reason: 'negative_filter_smoke_test', re: /\bhello[- ]?world\b|\b(smoke[- ]?tests?|sanity[- ]?checks?|happy path)\s+(pass(ed|es|ing)?|ran|run|succeed(ed|s)?|works?|worked|green)\b/ },
|
|
@@ -390,10 +390,13 @@ function defaultSleep(ms) {
|
|
|
390
390
|
* Run a single engine through the invoker; return `{ text, attempts }` (text null when no output). A
|
|
391
391
|
* `gemini` engine with no API key fails WITHOUT calling the invoker (no key → no request, attempts 0). A
|
|
392
392
|
* transient `ok:false` is retried up to ENGINE_ATTEMPTS with a fixed backoff via the injected sleep; the
|
|
393
|
-
* first
|
|
393
|
+
* first text that is non-empty AND passes the optional task `validate` predicate wins. A non-empty text
|
|
394
|
+
* that FAILS `validate` (unusable output — e.g. dream prose the gate can't parse) is a failed attempt →
|
|
395
|
+
* retried, then null, so synthesizeDetailed advances to the fallback. No validator → any non-empty wins.
|
|
396
|
+
* An invoker that throws is caught → counts as a failed attempt, then null.
|
|
394
397
|
* @returns {Promise<{ text: string|null, attempts: number }>}
|
|
395
398
|
*/
|
|
396
|
-
async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep }) {
|
|
399
|
+
async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = defaultSleep, validate }) {
|
|
397
400
|
if (!engine || !engine.engine) return { text: null, attempts: 0 };
|
|
398
401
|
let spec;
|
|
399
402
|
if (engine.engine === 'claude') {
|
|
@@ -415,12 +418,30 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
|
|
|
415
418
|
} catch {
|
|
416
419
|
text = null; // an invoker throw is a transient failure for this attempt (degrade, never throw).
|
|
417
420
|
}
|
|
418
|
-
|
|
421
|
+
// success = non-empty AND task-usable (validate). No validator → permissive: any non-empty text wins.
|
|
422
|
+
if (text && (!validate || validate(text))) return { text, attempts };
|
|
419
423
|
if (i < ENGINE_ATTEMPTS - 1) sleep(ENGINE_BACKOFF_MS); // back off before the next attempt (not after the last). sleep is synchronous (Atomics.wait / injected no-op) — no await.
|
|
420
424
|
}
|
|
421
425
|
return { text: null, attempts };
|
|
422
426
|
}
|
|
423
427
|
|
|
428
|
+
// ── per-task engine-success validators (synth-robustness #50) ───────────────────
|
|
429
|
+
// An engine attempt succeeds only when its text is non-empty AND passes the task's validator, so an engine
|
|
430
|
+
// that returns non-empty-but-UNUSABLE output exhausts its retries and synthesizeDetailed advances to the
|
|
431
|
+
// fallback — instead of the unusable primary "winning" while the downstream gate parses zero proposals and
|
|
432
|
+
// writes nothing. A task with NO registered validator is permissive (any non-empty text wins, exactly as
|
|
433
|
+
// before), so every existing caller is preserved.
|
|
434
|
+
const TASK_VALIDATORS = {
|
|
435
|
+
// dream output is usable only if the gate (parseProposals) reads ≥1 proposal from it, OR it is an explicit
|
|
436
|
+
// abstain — a deliberate "nothing to record" is a valid answer, NOT a reason to churn through the fallback.
|
|
437
|
+
dream: (text) => parseProposals(text).length > 0 || /abstain/i.test(text),
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// Resolve a task's engine-success validator; an unregistered task is permissive (any non-empty text wins).
|
|
441
|
+
function validatorFor(task) {
|
|
442
|
+
return TASK_VALIDATORS[task] || (() => true);
|
|
443
|
+
}
|
|
444
|
+
|
|
424
445
|
/**
|
|
425
446
|
* Synthesize text for `task` from `prompt` + `blob`, resolving the engine per task (primary → fallback),
|
|
426
447
|
* also reporting WHICH engine produced the text and HOW MANY attempts the producing engine spent (the
|
|
@@ -431,9 +452,10 @@ async function runEngine(engine, { prompt, blob, apiKey, invoke, sleep = default
|
|
|
431
452
|
*/
|
|
432
453
|
async function synthesizeDetailed({ task, prompt, blob, config, apiKey, invoke = defaultInvoke, sleep }) {
|
|
433
454
|
const { primary, fallback } = resolveTask(config || DEFAULTS, task);
|
|
455
|
+
const validate = validatorFor(task); // task-aware success: dream needs parseable proposals; handoff is permissive.
|
|
434
456
|
let attempts = 0;
|
|
435
457
|
for (const engine of [primary, fallback]) {
|
|
436
|
-
const r = await runEngine(engine, { prompt, blob, apiKey, invoke, sleep });
|
|
458
|
+
const r = await runEngine(engine, { prompt, blob, apiKey, invoke, sleep, validate });
|
|
437
459
|
attempts += r.attempts;
|
|
438
460
|
if (r.text) return { text: r.text, engine: engine && engine.engine, attempts };
|
|
439
461
|
}
|