diff --git a/context/CONTEXT_2026-10-26_18-55-00_client-reliability-9-torrent-implementation.md b/context/CONTEXT_2026-10-26_18-55-00_client-reliability-9-torrent-implementation.md new file mode 100644 index 00000000..d32ab81d --- /dev/null +++ b/context/CONTEXT_2026-10-26_18-55-00_client-reliability-9-torrent-implementation.md @@ -0,0 +1 @@ +Approved dispatch routing read locally: actual baseline independently reproduced12=7pass5fail, no repeat needed. Scope gridHealth+optional probePeers lifecycle only. Goal final-owner null settlement/resource cleanup/fresh identity; preserve shared/standalone/live/cap4/FIFO/TTL/deferred destroy safety. Spec and integrity written first. Focused offline/mutants/targeted lint allowed; no full/build/browser/submission until resource grant. diff --git a/decisions/DECISIONS_2026-10-26_19-15-00_client-reliability-9-torrent.md b/decisions/DECISIONS_2026-10-26_19-15-00_client-reliability-9-torrent.md new file mode 100644 index 00000000..6f2974eb --- /dev/null +++ b/decisions/DECISIONS_2026-10-26_19-15-00_client-reliability-9-torrent.md @@ -0,0 +1,6 @@ +- Grid publishes owner/release/inflight promise before synchronous client IO so disposal or same-source registration inside constructor/add is safe; cancelled identity removed synchronously and cache guarded. +- Optional third probePeers lifecycle follows existing owner-record convention; standalone behavior unchanged. Cancel settlesnull after deferred temporary-torrent destroy call returns/throws; timers clear immediately, cancel hook/owned listeners removed at final teardown. +- Retain inert error handler through deferred teardown, even cancellation insideadd; repeated/late callbacks are settled guards. Shared probeClient remains alive; queue slot only releases after destroy, preserving event unwind/cap4/FIFO. +- Original tests367/417 contract corrected only after routed spec+integrity note; preserve separate shared-live/cache/TTL/timeout controls and strict noobsoletecache/freshrequest/resource assertions. +- No live WebTorrent transport/byte teardown qualification from fake IO. Async low-level network shutdown not measured; existing teardown call ordering preserved. +- Stop at completedfocused boundary for requested kit restart; no commit/merge/submission before restart, all snapshots saved. diff --git a/docs/spec_changes/2026-10-26-client-reliability-9-active-torrent.md b/docs/spec_changes/2026-10-26-client-reliability-9-active-torrent.md new file mode 100644 index 00000000..21f59cfa --- /dev/null +++ b/docs/spec_changes/2026-10-26-client-reliability-9-active-torrent.md @@ -0,0 +1,11 @@ +# Client9 active torrent ownership contract correction + +Authority: dispatch docs/agents/ROUTING-2026-10-26-client9-torrent.md (17:38UTC), read locally by client after interpreter18:53 relay. Independent exact12cases=7pass/5fail/naturalexit1/network0/cleanup0 confirms client baseline. This note precedes any prior expectation edits. + +Source: docs/performance-efficiency-plan.md Stage3 explicitly extends owner cancellation to active torrent probes and says removed cards do not retain probes. Earlier queued-only implementation intentionally left running probes eligible across final-owner disposal, a new same-source owner and cache completion. + +Old tests/grid-health-lifecycle.test.mjs scenarios originally at367 and417 permit last-owner active work to be rejoined and ownerless success cached. New contract: final-owner disposal synchronously marks old work cancelled/unavailable; it cannot be rejoined, cache, or paint. Timer/cancel hooks are cleaned, cancelled promise settles null, and safe temporary-torrent destruction happens after event unwind before queue slot release exactly once. A fresh same-source owner requests fresh work; old callbacks/finalizer cannot delete/cache/paint over it. Cancellation does not depend on an AbortController or cooperative transport. + +Retain strict independent controls: one leaving owner cannot cancel another live owner, active healthy/error/timeout caches retain existing TTL behavior, standalone helper callers preserve results, queue cap4/priority/FIFO and destroy-before-slot safety remain. The shared probe client and unrelated torrents must survive. Replace the two superseded scenarios with equally strict sharing-with-live-owner and obsolete-cache/fresh-request assertions, and add real grid+probe resource/settlement/identity tests. No silent deletion or relaxed assertions. + +Scope only js/gridHealth.js ownership/inflight/cache and js/webtorrent.js probePeers optional lifecycle, plus focused tests/notes. No app, playback/service-worker engine, URL/HLS, magnet/tracker policy, concurrency/TTL/config changes. Current offline source defect does not qualify real peers/network byte release, browser decoding/playback, CPU, RangeGET or all Stage3. Full/build/browser/queue still need separate resource grant. diff --git a/js/gridHealth.js b/js/gridHealth.js index 8d59c06c..e4b4e707 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -195,20 +195,48 @@ function queueProbe(magnet, cacheKey, priority = 0, webSeeds = [], owner) { if (probeInflight.has(cacheKey)) { const record = probeInflight.get(cacheKey); - record.owners.add(owner); - return record; + if (!record.cancelled) { + record.owners.add(owner); + owner.release = () => releaseProbe(record, owner, cacheKey); + return record; + } } const record = { owners: new Set([owner]), cancelled: false }; + owner.release = () => releaseProbe(record, owner, cacheKey); + // Publish ownership and the result before starting IO: client.add() can + // synchronously unregister a card or register another owner of this source. + let resolveJob; + let rejectJob; + record.promise = new Promise((resolve, reject) => { + resolveJob = resolve; + rejectJob = reject; + }) + .then((result) => { + if (record.cancelled) return null; + const normalized = normalizeResult(result); + if (cacheKey) setCacheEntry(cacheKey, normalized); + return normalized; + }) + .finally(() => { + if (cacheKey && probeInflight.get(cacheKey) === record) { + probeInflight.delete(cacheKey); + } + }); + if (cacheKey) probeInflight.set(cacheKey, record); record.job = probeQueue.run( () => torrentClient - .probePeers(magnet, { - timeoutMs: PROBE_TIMEOUT_MS, - maxWebConns: 2, - polls: PROBE_POLL_COUNT, - urlList: webSeeds, - }) + .probePeers( + magnet, + { + timeoutMs: PROBE_TIMEOUT_MS, + maxWebConns: 2, + polls: PROBE_POLL_COUNT, + urlList: webSeeds, + }, + record + ) .catch((err) => ({ healthy: false, peers: 0, @@ -219,26 +247,7 @@ function queueProbe(magnet, cacheKey, priority = 0, webSeeds = [], owner) { })), priority ); - record.promise = record.job.promise - .then((result) => { - if (record.cancelled) { - return null; - } - const normalized = normalizeResult(result); - if (cacheKey) { - setCacheEntry(cacheKey, normalized); - } - return normalized; - }) - .finally(() => { - if (cacheKey && probeInflight.get(cacheKey) === record) { - probeInflight.delete(cacheKey); - } - }); - - if (cacheKey) { - probeInflight.set(cacheKey, record); - } + record.job.promise.then(resolveJob, rejectJob); return record; } @@ -246,13 +255,13 @@ function queueProbe(magnet, cacheKey, priority = 0, webSeeds = [], owner) { function releaseProbe(record, owner, cacheKey) { owner.active = false; record.owners.delete(owner); - // A running probe keeps its existing lifecycle and cache behavior. Only work - // still waiting for a slot can be cancelled when its last card unregisters. - if (!record.owners.size && record.job?.cancel()) { + if (!record.owners.size && !record.cancelled) { record.cancelled = true; if (probeInflight.get(cacheKey) === record) { probeInflight.delete(cacheKey); } + record.job?.cancel(); + record.cancelActive?.(); } } @@ -524,10 +533,8 @@ function handleCardVisible({ card, pendingByCard, liveCards, priority = 0 }) { const webSeeds = webSeed ? [webSeed] : []; const owner = { active: true }; - const record = queueProbe(magnet, infoHash, priority, webSeeds, owner); - owner.release = () => releaseProbe(record, owner, infoHash); - pendingByCard.set(card, owner); + const record = queueProbe(magnet, infoHash, priority, webSeeds, owner); record.promise .then((result) => { diff --git a/js/webtorrent.js b/js/webtorrent.js index 6edb580d..3eb1be7a 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -77,12 +77,15 @@ export class TorrentClient { /** * Helper to check if a magnet link has active peers without starting a full - * download. Used for "Stream Health" badges/indicators in the UI. + * download. Optional lifecycle belongs to grid cards; standalone callers + * retain their normal wire/error/timeout results. */ async probePeers( magnetURI, - { timeoutMs = 8000, maxWebConns = 2, polls = 3, urlList = [] } = {} + { timeoutMs = 8000, maxWebConns = 2, polls = 3, urlList = [] } = {}, + lifecycle ) { + if (lifecycle?.cancelled || lifecycle?.owners?.size === 0) return null; const magnet = typeof magnetURI === "string" ? magnetURI.trim() : ""; if (!magnet) { return { @@ -137,6 +140,26 @@ export class TorrentClient { let torrent = null; let timeoutId = null; let pollId = null; + let adding = false; + let disposalStarted = false; + let result = null; + const cancelActive = () => { + lifecycle.cancelled = true; + finalize(null); + }; + const settleHealthy = () => { + const peers = Math.max(1, Math.floor(normalizeNumber(torrent?.numPeers, 1))); + finalize({ healthy: true, peers, reason: "peer" }); + }; + const settleError = (err) => { + const peers = Math.max(0, Math.floor(normalizeNumber(torrent?.numPeers, 0))); + finalize({ + reason: "error", + error: toError(err), + peers, + webseedOnly: peers === 0 && hasWebSeed, + }); + }; // A successful probe commonly settles from WebTorrent's `wire` event. // Do not destroy the temporary torrent while that event is still being @@ -144,21 +167,23 @@ export class TorrentClient { // listeners return, and synchronous destruction can leave its bitfield // null (`recalculate(...).fill` in the bundled client). The next task // guarantees the callback has fully unwound before cleanup. - const disposeProbeTorrent = (target, onDisposed = () => {}) => { - if (!target) { - onDisposed(); - return; - } + const disposeProbeTorrent = () => { + if (adding || disposalStarted) return; + disposalStarted = true; const dispose = () => { try { - target.destroy({ destroyStore: true }); + torrent?.destroy({ destroyStore: true }); } catch (err) { // A completed or already-destroyed probe is harmless. } finally { - onDisposed(); + torrent?.removeListener("wire", settleHealthy); + torrent?.removeListener("error", settleError); + if (lifecycle?.cancelActive === cancelActive) lifecycle.cancelActive = null; + resolve(lifecycle?.cancelled ? null : result); } }; - setTimeout(dispose, 0); + if (torrent) setTimeout(dispose, 0); + else dispose(); }; const finalize = (overrides = {}) => { @@ -179,10 +204,10 @@ export class TorrentClient { ? performance.now() : Date.now(); - const isHealthy = Boolean(overrides.healthy); - const peersCount = Number.isFinite(overrides.peers) ? overrides.peers : 0; + const isHealthy = Boolean(overrides?.healthy); + const peersCount = Number.isFinite(overrides?.peers) ? overrides.peers : 0; - const result = { + result = overrides === null ? null : { healthy: isHealthy, peers: peersCount, reason: isHealthy ? "peer" : "timeout", @@ -192,13 +217,18 @@ export class TorrentClient { durationMs: Math.max(0, endedAt - startedAt), ...overrides, }; - emit("torrent-probe-result", result); + if (result) emit("torrent-probe-result", result); // Keep the grid-health queue slot occupied until the delayed teardown // completes. Otherwise the next probe can begin before this probe has // detached its tracker listeners, briefly exceeding listener limits. - disposeProbeTorrent(torrent, () => resolve(result)); + disposeProbeTorrent(); }; + if (lifecycle) lifecycle.cancelActive = cancelActive; + if (lifecycle?.cancelled || lifecycle?.owners?.size === 0) { + cancelActive(); + return; + } try { const addOptions = { announce: trackers, @@ -225,6 +255,7 @@ export class TorrentClient { if (hasExplicitWebSeed) { addOptions.urlList = urlList; } + adding = true; torrent = client.add(augmentedMagnet, addOptions); } catch (err) { finalize({ @@ -233,26 +264,18 @@ export class TorrentClient { peers: 0, webseedOnly: hasWebSeed, }); + } finally { + adding = false; + } + // Even cancellation inside add() must absorb a late error until the + // returned torrent can be safely destroyed on the next task. + torrent?.on("error", settleError); + if (settled) { + disposeProbeTorrent(); return; } - - const settleHealthy = () => { - const peers = Math.max(1, Math.floor(normalizeNumber(torrent?.numPeers, 1))); - finalize({ healthy: true, peers, reason: "peer" }); - }; - torrent.once("wire", settleHealthy); - - torrent.once("error", (err) => { - const peers = Math.max(0, Math.floor(normalizeNumber(torrent?.numPeers, 0))); - finalize({ - healthy: false, - reason: "error", - error: toError(err), - peers, - webseedOnly: peers === 0 && hasWebSeed, - }); - }); + if (settled) return; if (safeTimeout > 0) { timeoutId = setTimeout(() => { diff --git a/test_logs/TEST_INTEGRITY_2026-10-26_client-reliability-9-active-torrent.md b/test_logs/TEST_INTEGRITY_2026-10-26_client-reliability-9-active-torrent.md new file mode 100644 index 00000000..188fad5d --- /dev/null +++ b/test_logs/TEST_INTEGRITY_2026-10-26_client-reliability-9-active-torrent.md @@ -0,0 +1,40 @@ +# Client9 active torrent test integrity + +Written before editing prior367/417 expectations. Exact authority and old/new contract: docs/spec_changes/2026-10-26-client-reliability-9-active-torrent.md. Prior failed baseline and original queued-only suite preserved in investigation evidence; production files and old assertions are in ab1ea6de parent history. + +```yaml +test_integrity_note: + change_type: [new_tests, spec_correction] + scenarios: + - id: SCN-active-torrent-last-owner + given: real grid observer and active probe with one card owner + when: the final owner unregisters + then: cancelled work settles null, clears timers/hooks and destroys temporary torrent after event unwind before slot release, without cache or stale badge writes + - id: SCN-active-torrent-shared-and-replacement + given: shared live owners or a same-source replacement after final-owner loss + when: one owner leaves or old callbacks and finalizer run + then: live shared work is preserved and cancelled identity cannot affect fresh work + - id: SCN-active-torrent-live-standalone + given: a live owner or standalone probe caller + when: wire, error or normal timeout occurs + then: existing results, cache TTL, shared probe client and bounded queue ordering remain + observable_outcomes: + - temporary torrent lifetime, destroy ordering/count, timers/listeners/hook, returned results + - actual card badge and persisted cache values, new work count, FIFO progress and resource cap + determinism_controls: + - fake WebTorrent IO, controlled clock and observer delivery, map-backed persistence IO + - isolated mutant copies, no public fetch/WebSocket, no sleeps or retries + anti_cheat_rationale: + prevents: + - cached obsolete results or stale identity mutation + - cancelling a still shared live owner + - immediate unsafe destroy or premature queue-slot release + - mocked probe implementation bypassing real resource cleanup + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: '' +``` + +Contract correction changes outcomes, not assertion strength: original last-owner-gap reuse/cache expectations become fresh-work/no-obsolete-cache/resource assertions, while separate live-owner/shared/cache/timeout controls remain. Mutant mappings and exact validation results will be recorded before handoff; no passing or mutant-kill counts claimed yet. + +Final focused27new+11corrected+22adjacent=60/60;19unique mutants all killed,20variant executions with added extended run; all27newscenarios covered. Full per-scenario mappings and failures: state/evidence/client9-torrent-impl-20261026/new-scenario-mutant-map.json and mutants/coverage.json. No mutants survived; all mutated suite runs cancelled0, natural failure exits. Restored state is automatic because only isolated copies were changed. Full/build/browser/landing pending separately. diff --git a/test_logs/TEST_LOG_2026-10-26_19-05-00_client-reliability-9-torrent-focused.md b/test_logs/TEST_LOG_2026-10-26_19-05-00_client-reliability-9-torrent-focused.md new file mode 100644 index 00000000..208d65bc --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-26_19-05-00_client-reliability-9-torrent-focused.md @@ -0,0 +1,16 @@ +# Client9 focused implementation validation (in progress) + +- Explicit dispatch17:38 routing read before implementation; baseline independently reproduced, no repeat. Spec change + integrity note written before old367/417 expectation edits. +- `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD node tests/grid-health-active-torrent.test.mjs > /tmp/client9-torrent-focused-20261026-1.log 2>&1`: naturalexit0,24/24,0fail/0cancelled (~355ms). +- `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD node tests/grid-health-lifecycle.test.mjs > /tmp/client9-grid-corrected-20261026-1.log 2>&1`: naturalexit0,11/11,0fail/0cancelled (~263ms); explicit active sharing/cache spec correction only, live/cache controls retained. +- `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD node scripts/run-targeted-tests.mjs tests/grid-health-active-torrent.test.mjs tests/grid-health-lifecycle.test.mjs tests/grid-health.test.mjs tests/webtorrent-regression.test.mjs tests/webtorrent-handlers.test.mjs tests/minimal-webtorrent.test.mjs > /tmp/client9-torrent-targeted-20261026-2.log 2>&1`: naturalexit0,6files/58scenarios=25+11+6+3+12+1 pass,0fail/0cancelled. Includes added late error after cancellation inside add() and add-throw/shared-client reuse. No broad runner or browser involved. +- Later small test additions strengthen queued prune helper invocation and bound reentrant add callback in fixture; final focused rerun pending. Mutants and targeted lint pending. No full/build/browser/submission/landing or actual peers/network/CPU/wholeStage3 qualification. + +## Restart handoff after focused boundary + +- Final targeted command (same6files as above) output `/tmp/client9-torrent-targeted-20261026-final.log`: naturalexit0,60/60=27+11+6+3+12+1,0fail/0cancelled. Added strict queued-helper prune assertion, missing AbortController cancellation and error+throwing teardown queued progress. No tests weakened. Product delta remains2routed files, old suite only2explicit spec corrections. +- Mutants: `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD python3 /tmp/client9-torrent-mutants-20261026.py > /tmp/client9-torrent-mutants-20261026.log 2>&1`: naturalexit0 harness;19/19unique mutations killed by actual assertion failures, no survivors; each mutated new/old suite run exits naturally and reports cancelled0. Initial25new scenarios all covered. Additional isolated skip-destroy run with final27scenario file: naturalexit1,25assertion failures/0cancelled, including both added cases. Total19unique mutants/20variant executions; all27newscenarios have a recorded killing variant. No worktree mutation/restoration required: copies under/tmp only. Product formatting after initial mutation run changed no behavior; final focused60 covers current source. +- Targeted lint: `env -u NODE_TEST_CONTEXT -u UNIT_TEST_SHARD npm run lint:file-size`, `lint:innerhtml`, `lint:pool-access` each exit0 (separate logs). Same `lint:hex` first exits2 because spawnSync git EPERM; unchanged host-escalated command approved and exits0. Both results retained. No full npm run lint/all11 claim; remaining checks and unchanged full/build/browser/normal gate require later resource grant. +- Current upstream unstable04c8c7cf is4kit/setup commits ahead; read its newest2026-10-26 RULE-CHANGES via git show. Runtime js/tests/config/package inputs diff0 versus current committedHEAD; our workingtree changes remain local and uncommitted. No merge before this requested restart. Updated rules make swap occupancy advisory with ampleRAM/no swap-in; old blanket holds are superseded, but no heavy grant inferred. +- Durable copy: `/home/user/.local/state/agent-fleet/bitvid/evidence/client9-torrent-impl-20261026/` contains final4candidate files, original+finalfocused outputs, all targeted lint outputs incl EPERM+host pass, mutant runner/19variant copies+logs/summary, extended variant, per-new-scenario mutant map and manifest.sha256. Existing failure baselines and earlier evidence commits retained. No new commit/submission/landing; HEAD remainsab1ea6dee1da7eebd2b5c08f3d5935dfbfb045d0. +- STOP/SAFE TO RESTART per interpreter185753/190045 mails. Next resumed step: read live rules/mailbox/task and saved snapshots; inspect final2-file delta and tests, finish decision/integrity/READY notes, then commit named paths usingfleet tool and merge currentunstable (neverrebase). Focused60 need not repeat unless source changes; merged-tree/runtime equality determines remainingcheck need. Explicit resource grant still required before full/build/browser/submission. Real peers/browser/CPU/wholeStage3 unverified. No process stops, discard/stash/reset or kit work by client. diff --git a/test_logs/TEST_LOG_2026-10-26_19-07-50_client-reliability-9-resumed-ready.md b/test_logs/TEST_LOG_2026-10-26_19-07-50_client-reliability-9-resumed-ready.md new file mode 100644 index 00000000..9c62fbe4 --- /dev/null +++ b/test_logs/TEST_LOG_2026-10-26_19-07-50_client-reliability-9-resumed-ready.md @@ -0,0 +1,8 @@ +# Client9 resumed candidate review + +- Restart HEAD `ab1ea6dee1da7eebd2b5c08f3d5935dfbfb045d0`; unstable `04c8c7cf61eed29424f561297ef49c6cdfa91a0a`. Reclaimed existing item9; mailbox empty. Read local rules and newer unstable rules (2026-10-26) plus saved dispatch torrent routing. No repeated baseline or new scope. +- Review: final-owner synchronous cancellation/inflight removal, optional probePeers hook, prompt timer cleanup and deferred temporary-torrent destruction before slot release; shared client/owners and identity guards preserved. Product files only gridHealth.js and webtorrent.js; two explicitly authorized old expectations corrected. No product edits this turn. +- `sha256sum` of four candidate source/test files matches durable manifest exactly. Existing six-file focused evidence remains 60/60, fail0/cancelled0; 19 unique killed mutants, 20 variant executions/all27 new scenarios covered. This is preserved prior-run evidence, not a new test run. +- `git diff --check`: exit0. `git diff ab1ea6de unstable -- js tests config package.json package-lock.json`: exit0, empty. Upstream kit changes do not change those validation inputs. No duplicate focused run justified. +- Fresh MCP health19:07:29UTC: TCPU97.1C exceeds85C threshold; load11.58, availableRAM64.2%, swapInPages0. Heavy work held; no test/build/browser/submission launched. Hold owner dispatch; next allowed light work candidate commit/merge/runtime comparison. Need fresh resource clearance and explicit slot. +- Remaining full lint, merged-tree normal full gate, build/browser qualification and durable landing/ancestry. No live peer/network teardown, playback, CPU improvement or whole Stage3 qualification. diff --git a/tests/grid-health-active-torrent.test.mjs b/tests/grid-health-active-torrent.test.mjs new file mode 100644 index 00000000..9d5ef6a8 --- /dev/null +++ b/tests/grid-health-active-torrent.test.mjs @@ -0,0 +1,435 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { EventEmitter } from 'node:events'; +import { JSDOM } from 'jsdom'; +import './test-helpers/setup-localstorage.mjs'; +const root = new URL('../', import.meta.url).href; +const { torrentClient } = await import(`${root}js/webtorrent.js`); +const { attachHealthBadges } = await import(`${root}js/gridHealth.js`); +const { readTorrentProbeFromStorage } = await import(`${root}js/utils/storage.js`); +const { getCardLivenessPolicy, setCardLivenessPolicy } = await import(`${root}js/constants.js`); +const turn = () => new Promise(resolve => setImmediate(resolve)); +let nextHash = 100000; +const magnet = () => `magnet:?xt=urn:btih:${(nextHash++).toString(16).padStart(40, '0')}`; + +class Clock { + now = 0; next = 1; jobs = new Map(); + add(fn, ms, interval = false) { + const id = this.next++; + this.jobs.set(id, { fn, ms, at: this.now + ms, interval }); + return id; + } + tick(ms) { + const end = this.now + ms; + let steps = 0; + while (true) { + const entry = [...this.jobs].filter(([, job]) => job.at <= end) + .sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0]; + if (!entry) break; + assert.ok(++steps < 1000, 'fake clock must remain bounded'); + const [id, job] = entry; + this.now = job.at; + if (job.interval) job.at += job.ms; + else this.jobs.delete(id); + job.fn(); + } + this.now = end; + } + counts() { + const all = [...this.jobs.values()]; + return { timeout: all.filter(x => !x.interval && x.ms > 0).length, + poll: all.filter(x => x.interval).length, + teardown: all.filter(x => !x.interval && x.ms === 0).length }; + } +} + +function fixture(t) { + const dom = new JSDOM('', { url: 'https://example.invalid' }); + Object.defineProperty(dom.window, 'innerHeight', { value: 20 }); + const names = ['window', 'document', 'HTMLElement', 'localStorage', 'IntersectionObserver', + 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'fetch', 'WebSocket', 'AbortController']; + const saved = new Map(names.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const previousPolicy = getCardLivenessPolicy(); + const clock = new Clock(); + const config = {}; + // Isolate persistence IO: JSDOM StorageImpl schedules unrelated storage-event timers. + // Production read/writeTorrentProbeToStorage still executes against this map. + const store = new Map(); + const writes = []; + const memoryStorage = { getItem: key => store.get(key) ?? null, + setItem: (key, value) => { writes.push([key, value]); store.set(String(key), String(value)); }, + removeItem: key => store.delete(key), clear: () => store.clear() }; + const observers = [], torrents = [], calls = [], disposals = [], updates = [], timeline = []; + let networkAttempts = 0, peak = 0; + class Observer { + constructor(callback) { this.callback = callback; this.cards = []; observers.push(this); } + observe(card) { this.cards.push(card); } + takeRecords() { return []; } + disconnect() { this.disconnected = true; } + show() { + this.callback(this.cards.map((target, index) => ({ target, isIntersecting: true, + intersectionRatio: 1, intersectionRect: { top: index * 20, width: 100, height: 20 } }))); + } + } + class FakeTorrent extends EventEmitter { + constructor(source, options) { + super(); this.source = source; this.options = options; this.numPeers = 0; + this.destroyed = false; this.destroyCount = 0; this.emitting = false; + } + on(event, listener) { + super.on(event, listener); + if (config.syncEvent === event && !this.syncEmitted) { + this.syncEmitted = true; + if (event === 'wire') this.wire(); else this.error(); + } + return this; + } + destroy(options) { + assert.deepEqual(options, { destroyStore: true }); + this.destroyedDuringEvent = this.emitting; + this.destroyedDuringAdd = config.adding === true; + this.destroyed = true; this.destroyCount++; + if (config.throwDestroy) throw new Error('controlled destroy exception'); + this.removeAllListeners(); + timeline.push(['destroy', this.source, clock.now]); + } + wire() { this.numPeers = 1; this.emitting = true; + try { this.emit('wire', {}); } finally { this.emitting = false; } } + error() { this.emit('error', new Error('controlled fake torrent error')); } + } + class FakeClient { + constructor() { config.onConstruct?.(); } + setMaxListeners() {} + destroy() { assert.fail('shared probe client must never be destroyed'); } + add(source, options) { + if (config.throwAdd) throw new Error('controlled add exception'); + const torrent = new FakeTorrent(source, options); + torrents.push(torrent); + peak = Math.max(peak, torrents.filter(x => !x.destroyed).length); + timeline.push(['add', source, clock.now]); + config.adding = true; + try { config.onAdd?.(torrent); } finally { config.adding = false; } + return torrent; + } + } + Object.assign(globalThis, { window: dom.window, document: dom.window.document, + HTMLElement: dom.window.HTMLElement, localStorage: memoryStorage, + IntersectionObserver: Observer, + setTimeout: (fn, ms = 0) => clock.add(fn, ms), clearTimeout: id => clock.jobs.delete(id), + setInterval: (fn, ms) => clock.add(fn, ms, true), clearInterval: id => clock.jobs.delete(id), + fetch: () => { networkAttempts++; throw new Error('network forbidden'); }, + WebSocket: class { constructor() { networkAttempts++; throw new Error('network forbidden'); } } + }); + const savedClient = { probeClient: torrentClient.probeClient, WebTorrentClass: torrentClient.WebTorrentClass, + probePeers: torrentClient.probePeers }; + torrentClient.probeClient = null; + torrentClient.WebTorrentClass = FakeClient; + // Transparent measurement wrapper: execute the actual production method, do not stub its logic. + torrentClient.probePeers = function(source, options, lifecycle) { + const call = { source, options, lifecycle, settled: false, result: undefined }; + calls.push(call); + return savedClient.probePeers.call(this, source, options, lifecycle).then(result => { + call.settled = true; call.result = result; return result; + }); + }; + setCardLivenessPolicy('show-pending'); + function feed(sources) { + const container = document.createElement('section'); + const cards = sources.map(source => { + const card = document.createElement('article'); + card.className = 'card'; card.dataset.videoId = source; card.dataset.magnet = source; + const badge = document.createElement('span'); badge.className = 'torrent-health-badge'; + card.append(badge); container.append(card); return card; + }); + document.body.append(container); + const events = []; + const dispose = attachHealthBadges(container, { onUpdate: event => { events.push(event); updates.push(event); } }); + disposals.push(dispose); + return { container, cards, dispose, events, observer: observers.at(-1), + show() { this.observer.show(); } }; + } + const cache = source => readTorrentProbeFromStorage(source.match(/btih:([a-f0-9]{40})/)[1]); + const snapshot = source => ({ started: torrents.length, activeTorrents: torrents.filter(x => !x.destroyed).length, + destroyed: torrents.filter(x => x.destroyed).length, + listeners: torrents.reduce((n, t) => n + t.eventNames().reduce((m, e) => m + t.listenerCount(e), 0), 0), + unsettled: calls.filter(x => !x.settled).length, timers: clock.counts(), + cache: source ? cache(source) : undefined, peak, networkAttempts }); + async function drain() { await turn(); clock.tick(0); await turn(); } + async function cleanup() { + disposals.forEach(dispose => dispose()); + for (let i = 0; i < 10 && (calls.some(x => !x.settled) || clock.jobs.size); i++) { + torrents.filter(x => !x.destroyed).forEach(t => t.wire()); + await drain(); + } + const final = snapshot(); + + try { + assert.equal(final.activeTorrents, 0); assert.equal(final.unsettled, 0); + assert.equal(final.listeners, 0); assert.equal(clock.jobs.size, 0); + assert.equal(networkAttempts, 0); + assert.ok(torrents.every(x => x.destroyCount === 1), 'destroy exactly once'); + assert.ok(torrents.every(x => !x.destroyedDuringEvent && !x.destroyedDuringAdd), 'destroy after event/add unwind'); + assert.ok(calls.every(x => !x.lifecycle?.cancelActive), 'cancel hook removed'); + } finally { + Object.assign(torrentClient, savedClient); + setCardLivenessPolicy(previousPolicy); + dom.window.close(); + for (const [key, desc] of saved) { + if (desc) Object.defineProperty(globalThis, key, desc); else delete globalThis[key]; + } + } + } + t.after(cleanup); + return { feed, cache, snapshot, drain, clock, torrents, calls, timeline, config, writes, + probe: (source, lifecycle) => torrentClient.probePeers(source, { timeoutMs: 20000 }, lifecycle), + get peak() { return peak; } }; +} + +const cases = [ + ['final-owner-cleanup', async f => { + const source = magnet(), feed = f.feed([source]); feed.show(); + assert.equal(f.torrents.length, 1); + feed.dispose(); await f.drain(); + assert.equal(f.snapshot().activeTorrents, 0, 'last owner must not retain an active torrent'); + assert.equal(f.snapshot().unsettled, 0, 'last owner must settle without original timeout'); + assert.equal(f.calls[0].result, null); + assert.equal(f.calls[0].lifecycle.cancelActive, null); + assert.equal(f.clock.jobs.size, 0); + assert.equal(f.cache(source), null); + }], + ['disposed-late-success-no-cache', async f => { + const source = magnet(), feed = f.feed([source]); feed.show(); feed.dispose(); + f.torrents[0].wire(); await f.drain(); + assert.equal(feed.cards[0].dataset.streamHealthState, 'checking', 'stale badge guard retained'); + assert.equal(f.cache(source), null, 'obsolete active completion must not populate persistent cache'); + }], + ['same-turn-fresh-rejoin', async f => { + const source = magnet(), old = f.feed([source]); old.show(); old.dispose(); + const replacement = f.feed([source]); replacement.show(); + await f.drain(); + assert.equal(f.calls.length, 2, 'new same-turn owner must start a fresh probe'); + }], + ['later-fresh-rejoin', async f => { + const source = magnet(), old = f.feed([source]); old.show(); old.dispose(); await f.drain(); + const replacement = f.feed([source]); replacement.show(); await f.drain(); + assert.equal(f.calls.length, 2, 'new later owner must start a fresh probe'); + }], + ['disposal-releases-fifo-capacity', async f => { + const active = Array.from({ length: 4 }, () => f.feed([magnet()])); active.forEach(x => x.show()); + const queued = f.feed([magnet()]); queued.show(); assert.equal(f.calls.length, 4); + active[0].dispose(); await f.drain(); + assert.equal(f.calls.length, 5, 'queued live owner must gain released obsolete active capacity'); + assert.ok(f.peak <= 4); + }], + ['shared-live-owner', async f => { + const source = magnet(), old = f.feed([source]), live = f.feed([source]); old.show(); live.show(); + assert.equal(f.calls.length, 1); old.dispose(); await f.drain(); + assert.equal(f.snapshot().activeTorrents, 1); assert.equal(f.snapshot().timers.timeout, 1); + f.torrents[0].wire(); await f.drain(); + assert.equal(live.cards[0].dataset.streamHealthState, 'healthy'); + assert.equal(old.cards[0].dataset.streamHealthState, 'checking'); assert.ok(f.cache(source)); + }], + ['live-wire-cache', async f => { + const source = magnet(), live = f.feed([source]); live.show(); f.torrents[0].wire(); await f.drain(); + assert.equal(live.cards[0].dataset.streamHealthState, 'healthy'); assert.ok(f.cache(source)); + const cached = f.feed([source]); cached.show(); assert.equal(f.calls.length, 1); + assert.equal(cached.cards[0].dataset.streamHealthState, 'healthy'); + }], + ['live-error-cleanup-cache', async f => { + const source = magnet(), live = f.feed([source]); live.show(); f.torrents[0].error(); await f.drain(); + assert.equal(live.cards[0].dataset.streamHealthState, 'unhealthy'); + assert.equal(f.calls[0].result.reason, 'error'); assert.ok(f.cache(source)); + assert.equal(f.snapshot().activeTorrents, 0); assert.equal(f.clock.jobs.size, 0); + }], + ['live-timeout-cleanup-cache', async f => { + const source = magnet(), live = f.feed([source]); live.show(); + f.clock.tick(19999); await turn(); assert.equal(f.calls[0].settled, false); + f.clock.tick(1); await turn(); + assert.equal(live.cards[0].dataset.streamHealthState, 'unhealthy'); + assert.equal(f.calls[0].result.reason, 'timeout'); assert.ok(f.cache(source)); + assert.equal(f.snapshot().activeTorrents, 0); assert.equal(f.clock.jobs.size, 0); + }], + ['queued-cancel-priority-fifo', async f => { + const active = f.feed(Array.from({ length: 4 }, magnet)); active.show(); + const firstSource = magnet(), lowSource = magnet(), equalSource = magnet(), cancelledSource = magnet(); + const batch = f.feed([firstSource, lowSource]); batch.show(); + const equal = f.feed([equalSource]); equal.show(); + const cancelled = f.feed([cancelledSource]); cancelled.show(); cancelled.dispose(); + for (const [slot, expected] of [[0, firstSource], [4, equalSource], [5, lowSource]]) { + f.torrents[slot].wire(); await f.drain(); + assert.ok(f.torrents.at(-1).source.includes(expected.slice(-40))); + } + assert.equal(f.torrents.some(x => x.source.includes(cancelledSource.slice(-40))), false); + assert.equal(f.calls.some(x => x.source === cancelledSource), false, 'pruned queued job never invokes helper'); + assert.ok(f.peak <= 4); assert.equal(f.cache(cancelledSource), null); + }], + ['deferred-teardown-precedes-next-slot', async f => { + const active = f.feed(Array.from({ length: 4 }, magnet)); active.show(); + const queued = f.feed([magnet()]); queued.show(); + f.torrents[0].wire(); await turn(); + assert.equal(f.calls.length, 4, 'slot retained until delayed destroy'); + assert.equal(f.snapshot().timers.teardown, 1); + f.clock.tick(0); await turn(); assert.equal(f.calls.length, 5); assert.equal(f.peak, 4); + const destroyIndex = f.timeline.findIndex(x => x[0] === 'destroy'); + assert.equal(f.timeline[destroyIndex + 1][0], 'add'); + }], + ['late-observer-disposed-guard', async f => { + const old = f.feed([magnet()]); old.show(); old.dispose(); + const before = old.events.length; old.observer.show(); f.torrents[0].wire(); await f.drain(); + assert.equal(old.events.length, before); assert.equal(f.calls.length, 1); + assert.equal(old.cards[0].dataset.streamHealthState, 'checking'); + }], + ['old-callbacks-cannot-affect-replacement', async f => { + const source = magnet(), old = f.feed([source]); old.show(); + const wire = f.torrents[0].listeners('wire')[0]; + const error = f.torrents[0].listeners('error')[0]; + const timers = [...f.clock.jobs.values()].map(job => job.fn); + old.dispose(); + assert.equal(f.calls[0].lifecycle.cancelled, true, 'cancelled identity unavailable synchronously'); + const replacement = f.feed([source]); replacement.show(); + await f.drain(); + wire(); error(new Error('late')); timers.forEach(fn => fn()); await f.drain(); + const shared = f.feed([source]); shared.show(); + assert.equal(f.calls.length, 2, 'old finalizer must not delete replacement identity'); + assert.equal(f.cache(source), null); + assert.equal(replacement.cards[0].dataset.streamHealthState, 'checking'); + f.torrents[1].wire(); await f.drain(); + assert.equal(f.writes.length, 1, 'only the new live result is cached'); + assert.equal(replacement.cards[0].dataset.streamHealthState, 'healthy'); + assert.equal(shared.cards[0].dataset.streamHealthState, 'healthy'); + assert.equal(old.cards[0].dataset.streamHealthState, 'checking'); + }], + ['repeated-release-and-noncooperative-io', async f => { + const live = f.feed([magnet()]); live.show(); + const cancel = f.calls[0].lifecycle.cancelActive; + const lateWire = f.torrents[0].listeners('wire')[0]; + live.dispose(); live.dispose(); cancel(); cancel(); + // No fake transport completion or abort event is required to settle. + await f.drain(); + assert.equal(f.calls[0].settled, true); assert.equal(f.calls[0].result, null); + assert.equal(f.torrents[0].destroyCount, 1); + lateWire(); cancel(); await f.drain(); + assert.equal(f.calls[0].result, null); assert.equal(f.torrents[0].destroyCount, 1); + }], + ['cancel-before-probe-start', async f => { + const lifecycle = { cancelled: true, owners: new Set() }; + const promise = f.probe(magnet(), lifecycle); await f.drain(); + assert.equal(f.calls[0].settled, true); assert.equal(await promise, null); + assert.equal(f.torrents.length, 0); assert.equal(torrentClient.probeClient, null); + assert.equal(f.clock.jobs.size, 0); + }], + ['cancel-during-client-construction', async f => { + const live = f.feed([magnet()]); + f.config.onConstruct = () => live.dispose(); live.show(); await f.drain(); + assert.equal(f.calls[0].settled, true); assert.equal(f.calls[0].result, null); + assert.equal(f.torrents.length, 0); assert.equal(f.clock.jobs.size, 0); + assert.equal(f.calls[0].lifecycle.cancelActive, null); + }], + ['cancel-during-add-defers-destroy', async f => { + const source = magnet(), live = f.feed([source]); + f.config.onAdd = () => live.dispose(); live.show(); + assert.equal(f.calls[0].lifecycle.cancelled, true); + assert.equal(f.calls[0].settled, false); + assert.equal(f.torrents[0].destroyCount, 0); + assert.equal(f.clock.counts().timeout, 0); assert.equal(f.clock.counts().poll, 0); + f.torrents[0].error(); // Noncooperative IO can report an error before deferred destruction. + await f.drain(); + assert.equal(f.calls[0].settled, true); assert.equal(f.calls[0].result, null); + assert.equal(f.torrents[0].destroyCount, 1); + assert.equal(f.torrents[0].destroyedDuringAdd, false); assert.equal(f.cache(source), null); + }], + ['same-source-owner-registers-during-add', async f => { + const source = magnet(), first = f.feed([source]); + let shared; + f.config.onAdd = () => { + f.config.onAdd = null; + shared = f.feed([source]); shared.show(); first.dispose(); + }; + first.show(); assert.equal(f.calls.length, 1); + assert.equal(f.calls[0].lifecycle.cancelled, false); + f.torrents[0].wire(); await f.drain(); + assert.equal(shared.cards[0].dataset.streamHealthState, 'healthy'); + assert.equal(first.cards[0].dataset.streamHealthState, 'checking'); + }], + ['cancel-on-wire-unwind-before-destroy', async f => { + const live = f.feed([magnet()]); live.show(); + f.torrents[0].on('wire', () => live.dispose()); + f.torrents[0].wire(); + assert.equal(f.torrents[0].destroyCount, 0); + await f.drain(); + assert.equal(f.calls[0].settled, true); assert.equal(f.calls[0].result, null); + assert.equal(f.torrents[0].destroyedDuringEvent, false); assert.equal(f.writes.length, 0); + }], + ['cancel-on-error-unwind-before-destroy', async f => { + const live = f.feed([magnet()]); live.show(); + f.torrents[0].on('error', () => live.dispose()); + f.torrents[0].error(); + assert.equal(f.torrents[0].destroyCount, 0); + await f.drain(); + assert.equal(f.calls[0].result, null); assert.equal(f.writes.length, 0); + assert.equal(f.torrents[0].destroyedDuringEvent, false); + }], + ['throwing-destroy-still-settles-and-cleans-own-listeners', async f => { + const live = f.feed([magnet()]); live.show(); + f.config.throwDestroy = true; live.dispose(); await f.drain(); + assert.equal(f.calls[0].settled, true); assert.equal(f.calls[0].result, null); + assert.equal(f.torrents[0].destroyCount, 1); + assert.equal(f.torrents[0].listenerCount('wire'), 0); + assert.equal(f.torrents[0].listenerCount('error'), 0); + assert.equal(f.calls[0].lifecycle.cancelActive, null); + }], + ['standalone-ownerless-caller-stays-live', async f => { + const source = magnet(), promise = f.probe(source); + assert.equal(f.calls[0].lifecycle, undefined); + f.torrents[0].wire(); await f.drain(); assert.equal(f.calls[0].settled, true); + const result = await promise; + assert.equal(result.healthy, true); assert.equal(result.reason, 'peer'); + assert.equal(f.torrents[0].destroyCount, 1); + }], + ['synchronous-wire-listener-registration', async f => { + f.config.syncEvent = 'wire'; const promise = f.probe(magnet()); + assert.equal(f.torrents[0].destroyCount, 0); + await f.drain(); assert.equal(f.calls[0].settled, true); + assert.equal((await promise).healthy, true); assert.equal(f.clock.jobs.size, 0); + }], + ['synchronous-error-listener-registration', async f => { + f.config.syncEvent = 'error'; const promise = f.probe(magnet()); + await f.drain(); assert.equal(f.calls[0].settled, true); + assert.equal((await promise).reason, 'error'); assert.equal(f.clock.jobs.size, 0); + }], + ['add-error-cleans-hook-and-keeps-shared-client', async f => { + const lifecycle = { cancelled: false, owners: new Set([{}]) }; + f.config.throwAdd = true; const promise = f.probe(magnet(), lifecycle); + await f.drain(); assert.equal(f.calls[0].settled, true); + assert.equal((await promise).reason, 'error'); assert.equal(lifecycle.cancelActive, null); + assert.equal(f.torrents.length, 0); assert.equal(f.clock.jobs.size, 0); + const client = torrentClient.probeClient; + f.config.throwAdd = false; const next = f.probe(magnet()); + f.torrents[0].wire(); await f.drain(); assert.equal(f.calls[1].settled, true); + assert.equal((await next).healthy, true); assert.equal(torrentClient.probeClient, client); + }], + ['missing-AbortController-still-cancels', async f => { + globalThis.AbortController = undefined; + const source = magnet(), live = f.feed([source]); live.show(); live.dispose(); + await f.drain(); assert.equal(f.calls[0].settled, true); + assert.equal(f.calls[0].result, null); assert.equal(f.cache(source), null); + assert.equal(f.clock.jobs.size, 0); assert.equal(f.torrents[0].destroyCount, 1); + }], + ['error-and-throwing-teardown-release-queued-capacity-once', async f => { + const active = f.feed(Array.from({ length: 4 }, magnet)); active.show(); + const source = magnet(), queued = f.feed([source]); queued.show(); + f.config.throwDestroy = true; + f.torrents[0].error(); await turn(); + assert.equal(f.calls.length, 4, 'error result still holds slot until teardown'); + f.clock.tick(0); await turn(); + assert.equal(f.calls.length, 5); assert.equal(f.torrents[0].destroyCount, 1); + assert.equal(f.torrents[0].listenerCount('error'), 0); + f.config.throwDestroy = false; + f.torrents[4].wire(); await f.drain(); + assert.equal(queued.cards[0].dataset.streamHealthState, 'healthy'); assert.equal(f.peak, 4); + }] +]; + +for (const [name, scenario] of cases) { + test(name, async t => { await scenario(fixture(t)); }); +} diff --git a/tests/grid-health-lifecycle.test.mjs b/tests/grid-health-lifecycle.test.mjs index f6bd78bb..f757f17a 100644 --- a/tests/grid-health-lifecycle.test.mjs +++ b/tests/grid-health-lifecycle.test.mjs @@ -369,13 +369,13 @@ test("active probe remains shared while disposed owners and late observer entrie const source = magnet(); const first = f.feed([source]); first.observer.show(); - first.dispose(); const liveUpdates = []; const live = f.feed([source], { onUpdate: (event) => liveUpdates.push(event) }); + live.observer.show(); // Retain a live owner BEFORE releasing the first. + first.dispose(); first.observer.show(); // An already-delivered entry may arrive after disconnect. - live.observer.show(); assert.equal( liveUpdates.some((event) => event.card === first.cards[0]), false, @@ -414,7 +414,7 @@ test("repeated old disposal cannot cancel a reattached live registration", async assert.equal(renewed.cards[0].dataset.streamHealthState, "healthy"); }); -test("disposed active completion remains cached for a later live owner", async (t) => { +test("disposed active completion cannot cache and a later owner requests fresh work", async (t) => { const f = fixture(t); const source = magnet(); const old = f.feed([source]); @@ -427,10 +427,15 @@ test("disposed active completion remains cached for a later live owner", async ( live.observer.show(); assert.equal( f.calls.length, - 1, - "ownerless active completion must retain its healthy cache result" + 2, + "ownerless active completion must not replace a fresh probe" ); + assert.equal(readTorrentProbeFromStorage(source.slice(-40)), null); + assert.equal(live.cards[0].dataset.streamHealthState, "checking"); + f.calls[1].finish(); + await flush(); assert.equal(live.cards[0].dataset.streamHealthState, "healthy"); + assert.equal(readTorrentProbeFromStorage(source.slice(-40)).value.healthy, true); }); test("completed healthy and negative probes retain existing cache TTL behavior", async (t) => { diff --git a/todo/TODO_2026-10-26_18-55-00_client-reliability-9-torrent-implementation.md b/todo/TODO_2026-10-26_18-55-00_client-reliability-9-torrent-implementation.md new file mode 100644 index 00000000..917a5ac9 --- /dev/null +++ b/todo/TODO_2026-10-26_18-55-00_client-reliability-9-torrent-implementation.md @@ -0,0 +1,7 @@ +- [x] Read exact dispatch routing and newest rules/current tree. +- [x] Write explicit spec correction and integrity note before superseded expectations. +- [ ] Implement narrow grid ownership and optional helper lifecycle. +- [ ] Focused real-chain/helper tests, edge callbacks, shared/live/legacy/queue controls. +- [ ] Every new scenario rejects a deliberate restored mutant. +- [ ] Targeted lint and evidence/READY handoff. +- [ ] Later full/build/browser/normal landing under separate resource grant. diff --git a/todo/TODO_2026-10-26_19-07-50_client-reliability-9-ready.md b/todo/TODO_2026-10-26_19-07-50_client-reliability-9-ready.md new file mode 100644 index 00000000..0a3f9100 --- /dev/null +++ b/todo/TODO_2026-10-26_19-07-50_client-reliability-9-ready.md @@ -0,0 +1,6 @@ +- [x] Resume rules/mailbox/item9 claim and saved candidate review. +- [x] Four candidate hashes exactly match saved focused/mutant evidence; no repeat baseline/tests. +- [x] Record thermal hold and READY scope/validation limits. +- [ ] Commit named files via fleet; merge unstable without rebase; inspect runtime equality. +- [ ] Dispatch fresh resource grant for remaining lint/full/build/browser/submission. +- [ ] Normal queue durable landing plus unstable ancestry before closing item9. diff --git a/todo/TODO_2026-10-26_19-15-00_client-reliability-9-restart.md b/todo/TODO_2026-10-26_19-15-00_client-reliability-9-restart.md new file mode 100644 index 00000000..fd142e62 --- /dev/null +++ b/todo/TODO_2026-10-26_19-15-00_client-reliability-9-restart.md @@ -0,0 +1,8 @@ +- [x] Narrow implementation and explicit spec/integrity correction preserved. +- [x] Final focused6files60/60/0cancelled. +- [x] 19unique mutants killed; all27newcases covered, no surviving variants. +- [x] Targetedlint4pass; sandboxhexEPERM plus unchangedhostpass retained. +- [x] Durable candidate/log/mutant snapshots+manifest saved; SAFE TO RESTART. +- [ ] Resume: review final delta/notes; finish READY and commit named paths. +- [ ] Merge currentunstable (never rebase), verify runtime equality/checks. +- [ ] Explicit grant for remaining lint/full/build/browser/submission; durable landing/ancestry before closure.