oxedyne/daimond/www/js/peer.test.mjs
120 KiB, 22 runs
created by r2519314175:1415, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* ============================================================ |
| 2 | Test — the persistent desktop peer, STEP 1: prove the seam. |
| 3 | ------------------------------------------------------------ |
| 4 | Drives the REAL www/js/identity.js, www/js/post.js and |
| 5 | www/js/peer.js in two independent simulated tabs of ONE account |
| 6 | (PHONE and LAPTOP), plus a THIRD tab of a DIFFERENT account |
| 7 | (STRANGER), through the whole errand seam of dev/PEER_DESIGN.md |
| 8 | step 7.1: |
| 9 | |
| 10 | A (PHONE) seals a minimal errand envelope to its OWN account |
| 11 | and drops it in an in-memory post box (the `{to,addr,envelope}` |
| 12 | shape `send` posts). B (LAPTOP), the same account, collects it, |
| 13 | routes by the sealed `t` tag, runs the turn (a MOCK LLM), folds |
| 14 | the answer into the transcript as an append, and pushes the |
| 15 | parcel; it also posts a `done` report. A pulls the parcel and |
| 16 | the answer is merged in by the ordinary append-only union |
| 17 | (mergeMessages, daimond.js:997) -- no peer-specific merge. |
| 18 | |
| 19 | And the property step 1 exists to prove: |
| 20 | |
| 21 | the errand opens ONLY for the same account. STRANGER, a |
| 22 | different identity with a different sealing key, CANNOT open |
| 23 | the sealed envelope -- `DaimondPost.unseal` refuses it. |
| 24 | |
| 25 | No gateway is involved: the post box and the parcel store are |
| 26 | in-memory stand-ins for `/api/post` and `/api/sync`, and the |
| 27 | seal, the open and the fold are the real client code. |
| 28 | |
| 29 | Run: node www/js/peer.test.mjs |
| 30 | (Node 20+, whose WebCrypto implements X25519 and Ed25519 -- the |
| 31 | real engine the seal and the identity run on.) |
| 32 | ============================================================ */ |
| 33 | import { readFileSync } from 'node:fs'; |
| 34 | import { fileURLToPath } from 'node:url'; |
| 35 | import { dirname, join } from 'node:path'; |
| 36 | import { webcrypto } from 'node:crypto'; |
| 37 | |
| 38 | const HERE = dirname(fileURLToPath(import.meta.url)); |
| 39 | const real = webcrypto; |
| 40 | let failures = 0; |
| 41 | function check(name, cond) { |
| 42 | if (cond) { console.log(' ok ' + name); } |
| 43 | else { console.log(' FAIL ' + name); failures++; } |
| 44 | } |
| 45 | const toHex = (bytes) => { |
| 46 | const b = new Uint8Array(bytes); |
| 47 | let s = ''; |
| 48 | for (let i = 0; i < b.length; i++) s += ('0' + b[i].toString(16)).slice(-2); |
| 49 | return s; |
| 50 | }; |
| 51 | const encU8 = (s) => new TextEncoder().encode(s); |
| 52 | const b64Bytes = (u8) => Buffer.from(u8).toString('base64'); |
| 53 | const httpResp = (obj) => ({ status: 200, json: async () => obj }); |
| 54 | const eqBytes = (a, b) => { |
| 55 | const x = new Uint8Array(a), y = new Uint8Array(b); |
| 56 | if (x.length !== y.length) return false; |
| 57 | for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false; |
| 58 | return true; |
| 59 | }; |
| 60 | |
| 61 | // ── One simulated tab ────────────────────────────────────── |
| 62 | // |
| 63 | // A Map-backed localStorage, a no-op document/window, the encoders and base64, |
| 64 | // and the real WebCrypto. Each context loads the four app scripts as the classic |
| 65 | // IIFEs they are and attaches their globals onto its own `window`, so two |
| 66 | // contexts are two independent devices with independent storage. |
| 67 | function makeTab() { |
| 68 | const store = new Map(); |
| 69 | const localStorage = { |
| 70 | getItem: (k) => (store.has(k) ? store.get(k) : null), |
| 71 | setItem: (k, v) => store.set(k, String(v)), |
| 72 | removeItem: (k) => store.delete(k), |
| 73 | }; |
| 74 | const win = {}; |
| 75 | win.addEventListener = () => {}; |
| 76 | win.dispatchEvent = () => true; |
| 77 | win.matchMedia = () => ({ matches: false, addListener: () => {}, addEventListener: () => {} }); |
| 78 | const noEl = { |
| 79 | addEventListener: () => {}, appendChild: () => {}, setAttribute: () => {}, |
| 80 | querySelector: () => null, querySelectorAll: () => [], remove: () => {}, |
| 81 | style: {}, classList: { add: () => {}, remove: () => {}, toggle: () => {} }, |
| 82 | }; |
| 83 | const document = { |
| 84 | readyState: 'complete', |
| 85 | addEventListener: () => {}, |
| 86 | querySelector: () => null, |
| 87 | querySelectorAll: () => [], |
| 88 | getElementById: () => null, |
| 89 | createElement: () => Object.assign({}, noEl), |
| 90 | body: noEl, |
| 91 | }; |
| 92 | const btoa = (s) => Buffer.from(s, 'binary').toString('base64'); |
| 93 | const atob = (s) => Buffer.from(s, 'base64').toString('binary'); |
| 94 | function EventShim(t) { this.type = t; } |
| 95 | |
| 96 | function loadScript(rel, extra) { |
| 97 | let body = readFileSync(join(HERE, rel), 'utf8'); |
| 98 | if (extra) body += extra; |
| 99 | // A REAL browser makes `window` the global object, so the app scripts refer |
| 100 | // to their siblings by a bare `DaimondIdentity` / `DaimondPost` after a |
| 101 | // `window.X &&` guard. `new Function` gives them no such global, so free |
| 102 | // identifiers are resolved against this tab's `window` with an enclosing |
| 103 | // `with(window)` -- the one construct that puts an object in the scope chain. |
| 104 | // Host built-ins the scripts also read bare (crypto, btoa, TextEncoder, ...) |
| 105 | // are NOT properties of `window`, so they fall through `with` to the named |
| 106 | // parameters below. The outer function is non-strict (no directive), which |
| 107 | // is what makes `with` legal; each app script keeps its own inner 'use strict'. |
| 108 | const fn = new Function( |
| 109 | 'window', 'document', 'crypto', 'localStorage', 'btoa', 'atob', |
| 110 | 'TextEncoder', 'TextDecoder', 'Event', |
| 111 | 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', |
| 112 | 'console', 'globalThis', |
| 113 | 'with (window) {\n' + body + '\n}'); |
| 114 | fn(win, document, real, localStorage, btoa, atob, |
| 115 | TextEncoder, TextDecoder, EventShim, |
| 116 | setTimeout, clearTimeout, setInterval, clearInterval, |
| 117 | console, globalThis); |
| 118 | } |
| 119 | // The vendored bundle's top-level `var DaimondNoble` is wrapper-local here (a |
| 120 | // browser turns it into a window property), so publish it explicitly, exactly |
| 121 | // as curvefallback.test.mjs does. |
| 122 | loadScript('vendor/noble-curves.min.js', '\n;window.DaimondNoble = DaimondNoble;'); |
| 123 | loadScript('curvefallback.js'); |
| 124 | loadScript('identity.js'); |
| 125 | loadScript('post.js'); |
| 126 | loadScript('peer.js'); |
| 127 | return win; |
| 128 | } |
| 129 | |
| 130 | // ── The in-memory transports ─────────────────────────────── |
| 131 | |
| 132 | /// The post box: `/api/post` reduced to its safety-relevant shape. A row is |
| 133 | /// `{ seq, kind:'post', to, addr, envelope }`; a re-post of the same address |
| 134 | /// collapses to one row (address is content, post.js). `collect(since)` returns |
| 135 | /// rows above a watermark, which is all the peer path needs of it here. |
| 136 | function makePostBox() { |
| 137 | let seq = 0; |
| 138 | const rows = []; |
| 139 | return { |
| 140 | post(body) { |
| 141 | if (rows.some((r) => r.addr === body.addr)) return { ok: true, addr: body.addr }; |
| 142 | seq += 1; |
| 143 | rows.push({ seq, kind: 'post', to: body.to, addr: body.addr, envelope: body.envelope }); |
| 144 | return { ok: true, addr: body.addr }; |
| 145 | }, |
| 146 | collect(since) { return rows.filter((r) => r.seq > (since | 0)); }, |
| 147 | top() { return seq; }, |
| 148 | }; |
| 149 | } |
| 150 | |
| 151 | /// The parcel store: `/api/sync` reduced to one versioned blob of chats, and the |
| 152 | /// append-only union that opens it on the far side. The union is a faithful port |
| 153 | /// of daimond.js `mergeMessages` (:997) and `mergeInto` (:1794) -- union by mid, |
| 154 | /// fuller copy wins, time order -- because THAT is the merge an errand result |
| 155 | /// rides home on, and step 1 is proving it rides home with no new rule. |
| 156 | function makeParcelStore() { |
| 157 | let version = 0; |
| 158 | let chats = []; |
| 159 | return { |
| 160 | push(next) { version += 1; chats = JSON.parse(JSON.stringify(next)); return version; }, |
| 161 | pull() { return { version, chats: JSON.parse(JSON.stringify(chats)) }; }, |
| 162 | version() { return version; }, |
| 163 | }; |
| 164 | } |
| 165 | function unionMessages(a, b) { |
| 166 | const at = {}, out = []; |
| 167 | (a || []).concat(b || []).forEach((m) => { |
| 168 | const had = at[m.mid]; |
| 169 | if (had === undefined) { at[m.mid] = out.length; out.push(m); return; } |
| 170 | if ((out[had].elided || 0) && !(m.elided || 0)) out[had] = m; // fuller wins |
| 171 | }); |
| 172 | out.sort((x, y) => { |
| 173 | if ((x.ts || 0) !== (y.ts || 0)) return (x.ts || 0) - (y.ts || 0); |
| 174 | return String(x.mid).localeCompare(String(y.mid)); |
| 175 | }); |
| 176 | return out; |
| 177 | } |
| 178 | function mergeInto(base, incoming) { |
| 179 | const byId = {}; |
| 180 | (base || []).forEach((c) => { if (c && c.id) byId[c.id] = c; }); |
| 181 | (incoming || []).forEach((c) => { |
| 182 | if (!c || !c.id) return; |
| 183 | const st = byId[c.id]; |
| 184 | if (!st) { byId[c.id] = c; return; } |
| 185 | const fresh = (c.updatedAt || 0) > (st.updatedAt || 0) ? c : st; |
| 186 | const merged = Object.assign({}, fresh); |
| 187 | merged.messages = unionMessages(st.messages, c.messages); |
| 188 | byId[c.id] = merged; |
| 189 | }); |
| 190 | return Object.keys(byId).map((id) => byId[id]); |
| 191 | } |
| 192 | |
| 193 | async function main() { |
| 194 | const PASS_A = 'correct horse battery staple frigate'; |
| 195 | const PASS_S = 'a wholly different eight word passphrase indeed today'; |
| 196 | |
| 197 | const box = makePostBox(); |
| 198 | const parcel = makeParcelStore(); |
| 199 | |
| 200 | // ── Set-up: two tabs of one account, one tab of another ──── |
| 201 | console.log('Set-up — three tabs, two accounts'); |
| 202 | const phone = makeTab(); |
| 203 | const laptop = makeTab(); |
| 204 | const stranger = makeTab(); |
| 205 | |
| 206 | await phone.DaimondIdentity.create('Phone', PASS_A); |
| 207 | check('phone account created and holds a sealing key', |
| 208 | !!phone.DaimondIdentity.sealingKeyRaw() |
| 209 | && phone.DaimondIdentity.sealingKeyRaw().length === 32); |
| 210 | |
| 211 | // LAPTOP becomes the SAME account by adopting the bundle, then unlocking. |
| 212 | const bundle = phone.DaimondIdentity.exportBundle(); |
| 213 | check('laptop adopts the account bundle', laptop.DaimondIdentity.importBundle(bundle)); |
| 214 | const un = await laptop.DaimondIdentity.unlock(PASS_A); |
| 215 | check('laptop unlocks the shared account', !!un && un.ok === true && laptop.DaimondIdentity.isUnlocked()); |
| 216 | check('laptop and phone hold the SAME sealing key (same account)', |
| 217 | eqBytes(phone.DaimondIdentity.sealingKeyRaw(), laptop.DaimondIdentity.sealingKeyRaw())); |
| 218 | |
| 219 | // STRANGER is a different identity entirely. |
| 220 | await stranger.DaimondIdentity.create('Stranger', PASS_S); |
| 221 | check('stranger is a DIFFERENT account (different sealing key)', |
| 222 | !eqBytes(phone.DaimondIdentity.sealingKeyRaw(), stranger.DaimondIdentity.sealingKeyRaw())); |
| 223 | |
| 224 | // ── The PER-DEVICE id: distinct even on paired devices ───── |
| 225 | // |
| 226 | // Pairing copies the account keypair whole, so publicKeyB64url() is IDENTICAL on |
| 227 | // phone and laptop -- it CANNOT be the device id. The peer keys holder/ |
| 228 | // dispatchedBy/presence on deviceId(), which is minted per device and never |
| 229 | // travels in the bundle, so the two devices are distinguishable. On the pre-fix |
| 230 | // code (device id === publicKeyB64url) the distinctness check below fails. |
| 231 | console.log('\nDevice id — paired devices SHARE the account key but hold DISTINCT device ids'); |
| 232 | check('paired phone and laptop share the account public key (the trap)', |
| 233 | phone.DaimondIdentity.publicKeyB64url() === laptop.DaimondIdentity.publicKeyB64url()); |
| 234 | // Defensive access: on the pre-fix code deviceId() does not exist, so these read |
| 235 | // null and the checks FAIL cleanly (rather than crashing the run) -- which is the |
| 236 | // evidence that the distinctness the peer needs is absent before the fix. |
| 237 | const devId = (tab) => (tab.DaimondIdentity.deviceId ? tab.DaimondIdentity.deviceId() : null); |
| 238 | const phoneDev = devId(phone); |
| 239 | const laptopDev = devId(laptop); |
| 240 | check('phone and laptop mint DISTINCT device ids despite the shared account key', |
| 241 | !!phoneDev && !!laptopDev && phoneDev !== laptopDev); |
| 242 | check('a device id is NOT the account public key (would collide across paired devices)', |
| 243 | !!phoneDev && phoneDev !== phone.DaimondIdentity.publicKeyB64url()); |
| 244 | check('deviceId() is stable per device (a second read returns the same id)', |
| 245 | !!phoneDev && devId(phone) === phoneDev); |
| 246 | |
| 247 | // The two consequences the shared-id bug caused, proven on the two REAL device |
| 248 | // ids. Both checks pass now and FAIL if the device id collapses back to the |
| 249 | // account key (phoneDev === laptopDev). |
| 250 | const NOWD = 1700000000000; |
| 251 | const Pd = phone.DaimondPeer; |
| 252 | // (1) PRESENCE: a peer must see the OTHER device. With one shared id the only |
| 253 | // entry is self, freshestPeer self-excludes it, and auto-dispatch is dead. |
| 254 | const presenceBoth = { |
| 255 | [phoneDev]: { name: 'phone', lastSeen: NOWD - 500 }, |
| 256 | [laptopDev]: { name: 'laptop', lastSeen: NOWD - 200 }, |
| 257 | }; |
| 258 | const seen = Pd.freshestPeer(presenceBoth, phoneDev, NOWD); |
| 259 | check('presence lists the OTHER device (freshestPeer returns the laptop, not self)', |
| 260 | !!seen && seen.deviceId === laptopDev); |
| 261 | check('a shared id would self-exclude (proof the bug killed auto-dispatch)', |
| 262 | Pd.freshestPeer({ [phoneDev]: { name: 'me', lastSeen: NOWD } }, phoneDev, NOWD) === null); |
| 263 | // (2) THE LEASE (the money risk): the foreign-holder test must FIRE between two |
| 264 | // distinct ids, and MUST NOT when holder === self -- the same-holder blind spot |
| 265 | // that let both paired devices run and bill the same turn. |
| 266 | const dispTurn = { why: 'dispatched', iturn: 'turn-money' }; |
| 267 | const leaseByLaptop = { turnId: 'turn-money', holder: laptopDev, mode: 'running', expiry: NOWD + 60000, renewedAt: NOWD }; |
| 268 | const leaseBySelf = { turnId: 'turn-money', holder: phoneDev, mode: 'running', expiry: NOWD + 60000, renewedAt: NOWD }; |
| 269 | check('foreign-holder detection FIRES: a lease held by the laptop reads peer-held on the phone', |
| 270 | Pd.dispatchState(dispTurn, leaseByLaptop, phoneDev, NOWD) === 'peer-held'); |
| 271 | check('same-holder is the money bug: a lease whose holder EQUALS self is NOT peer-held', |
| 272 | Pd.dispatchState(dispTurn, leaseBySelf, phoneDev, NOWD) === 'reclaimable'); |
| 273 | // And the CAS itself: two DISTINCT device ids racing one turn from one base -> one |
| 274 | // wins, one stands down. With a shared holder id the merge could not tell them |
| 275 | // apart and both would keep their claim. |
| 276 | { |
| 277 | const L = phone.DaimondLease; |
| 278 | L.forget(); |
| 279 | const cas = makeCas({}); |
| 280 | const snap1 = await cas.read(); |
| 281 | const snap2 = await cas.read(); |
| 282 | const r1 = await L.takeFrom(snap1, 'turn-money', { holder: phoneDev, eid: 'e1' }, cas, () => NOWD); |
| 283 | const r2 = await L.takeFrom(snap2, 'turn-money', { holder: laptopDev, eid: 'e2' }, cas, () => NOWD + 1); |
| 284 | check('two DISTINCT device ids race one turn: exactly one holds it (no double-bill)', |
| 285 | (r1.won ? 1 : 0) + (r2.won ? 1 : 0) === 1); |
| 286 | check('the committed lease names exactly one of the two distinct device ids', |
| 287 | [phoneDev, laptopDev].includes(cas.peekLeases()['turn-money'].holder)); |
| 288 | L.forget(); |
| 289 | } |
| 290 | |
| 291 | // ── The phone dispatches ─────────────────────────────────── |
| 292 | console.log('\nDispatch — phone seals an errand to itself and posts it'); |
| 293 | |
| 294 | // Persist-first: the prompt is pushed to the parcel BEFORE the errand, so a |
| 295 | // peer can never claim an errand whose prompt it cannot yet read (§4.1). |
| 296 | const chat0 = { |
| 297 | id: 'chat-1', name: 'Arithmetic', |
| 298 | messages: [{ mid: 'm-user-1', role: 'user', content: 'What is 2+2?', ts: 1000 }], |
| 299 | updatedAt: 1000, |
| 300 | }; |
| 301 | const vPrompt = parcel.push([chat0]); |
| 302 | check('prompt parcel pushed before the errand', vPrompt === 1); |
| 303 | |
| 304 | const model = { provider: 'openrouter', model: 'test/model', url: 'https://openrouter.ai/api/v1/chat/completions' }; |
| 305 | const errand = phone.DaimondPeer.makeErrand({ |
| 306 | turnId: 'turn-1', chatId: 'chat-1', prompt: 'What is 2+2?', |
| 307 | model, parcelVersion: vPrompt, dispatchedBy: 'phone-device', |
| 308 | }); |
| 309 | const sealed = await phone.DaimondPeer.sealForSelf(errand); |
| 310 | check('errand sealed to a {to, addr, envelope} post body', |
| 311 | !!sealed.to && !!sealed.addr && !!sealed.envelope); |
| 312 | const phonePubHex = toHex(await phone.DaimondIdentity.publicKeyRaw()); |
| 313 | const phonePubB64url = phone.DaimondIdentity.publicKeyB64url(); |
| 314 | // The delivery address is the BASE64URL account form the gateway binds an account |
| 315 | // to -- NOT the hex of the raw key. A hex `to` matched no account and every post |
| 316 | // 404'd ("No account holds that key"), which is the whole reason a dispatch never |
| 317 | // arrived. These two checks (b64url form, and NOT the old hex) fail on that code. |
| 318 | check('errand `to` is the account\'s b64url public address (the form the gateway binds)', |
| 319 | sealed.to === phonePubB64url); |
| 320 | check('errand `to` is NOT the hex of the raw key (the 404 bug)', |
| 321 | sealed.to !== phonePubHex && phonePubB64url !== phonePubHex); |
| 322 | box.post(sealed); |
| 323 | check('post box holds exactly one row after the post', box.top() === 1); |
| 324 | |
| 325 | // ── Same-account-ONLY: the negative that matters ─────────── |
| 326 | console.log('\nSeal — the errand opens for the account and for NOBODY else'); |
| 327 | // The stranger must be UNLOCKED and holding its own private sealing key, or a |
| 328 | // refusal here would prove nothing -- a locked device fails to open everything. |
| 329 | check('stranger is unlocked with its own key (so the refusal is meaningful)', |
| 330 | stranger.DaimondIdentity.isUnlocked()); |
| 331 | let strangerOpened = false, strangerWhy = ''; |
| 332 | try { await stranger.DaimondPeer.openEnvelope(sealed.envelope); strangerOpened = true; } |
| 333 | catch (e) { strangerOpened = false; strangerWhy = String(e && e.message || e); } |
| 334 | check('a DIFFERENT account CANNOT open the sealed errand', strangerOpened === false); |
| 335 | check('the refusal is the crypto "not for you", not an incidental error', |
| 336 | /not sealed/i.test(strangerWhy)); |
| 337 | |
| 338 | // ── The laptop collects, claims-nothing (step 1), runs, pushes ─ |
| 339 | console.log('\nPeer — laptop collects, runs the turn, folds and pushes'); |
| 340 | let ranErrand = null, pushedVersion = 0; |
| 341 | const rows = box.collect(0); |
| 342 | const tally = await laptop.DaimondPeer.routeRows(rows, { |
| 343 | // A row the laptop cannot open should never happen in this run; if it does, |
| 344 | // name why, so a regression reads as a sentence rather than a silent zero. |
| 345 | onOther: (row, e) => { console.log(' note: a row did not open —', e && e.message); }, |
| 346 | onErrand: async (err) => { |
| 347 | ranErrand = err; |
| 348 | check('laptop opened the errand to the same fields the phone sealed', |
| 349 | err.turnId === 'turn-1' && err.chatId === 'chat-1' |
| 350 | && err.prompt === 'What is 2+2?' && err.model && err.model.provider === 'openrouter' |
| 351 | && err.parcelVersion === vPrompt); |
| 352 | |
| 353 | // Reconstruct: pull the parcel to >= the errand's version, find the chat. |
| 354 | const snap = parcel.pull(); |
| 355 | check('laptop pulled the parcel to at least the errand version', |
| 356 | snap.version >= err.parcelVersion); |
| 357 | const chat = snap.chats.find((c) => c.id === err.chatId); |
| 358 | check('laptop reconstructed the chat carrying the prompt', |
| 359 | !!chat && chat.messages.length === 1 && chat.messages[0].role === 'user'); |
| 360 | |
| 361 | // Run the turn — a MOCK LLM stands in for runTurn's provider call. |
| 362 | const answer = mockRunTurn(err.prompt); |
| 363 | laptop.DaimondPeer.foldAssistant(chat, { |
| 364 | mid: 'm-asst-1', turnId: err.turnId, text: answer, model: err.model, ts: 2000, |
| 365 | }); |
| 366 | // Push the parcel under the ordinary path; the assistant message is a |
| 367 | // pure append. |
| 368 | pushedVersion = parcel.push(snap.chats); |
| 369 | |
| 370 | // Post the report — the nudge, not the answer. |
| 371 | const report = laptop.DaimondPeer.makeReport({ |
| 372 | eid: err.eid, turnId: err.turnId, chatId: err.chatId, |
| 373 | status: 'done', parcelVersion: pushedVersion, |
| 374 | }); |
| 375 | const sealedRep = await laptop.DaimondPeer.sealForSelf(report); |
| 376 | box.post(sealedRep); |
| 377 | }, |
| 378 | }); |
| 379 | check('laptop routed exactly one errand', tally.errands === 1); |
| 380 | check('the turn ran and produced an answer', !!ranErrand && pushedVersion === 2); |
| 381 | |
| 382 | // ── The phone returns and collects ───────────────────────── |
| 383 | console.log('\nReturn — phone pulls the parcel and the answer is merged in'); |
| 384 | |
| 385 | // The phone's local view is still the prompt-only chat it dispatched. |
| 386 | const phoneLocal = [JSON.parse(JSON.stringify(chat0))]; |
| 387 | const snap = parcel.pull(); |
| 388 | const merged = mergeInto(phoneLocal, snap.chats); |
| 389 | const mchat = merged.find((c) => c.id === 'chat-1'); |
| 390 | check('merged chat carries BOTH the prompt and the answer', !!mchat && mchat.messages.length === 2); |
| 391 | const asst = mchat.messages.find((m) => m.role === 'assistant'); |
| 392 | check('the assistant answer is the peer\'s, folded under the turn id', |
| 393 | !!asst && asst.content === mockRunTurn('What is 2+2?') && asst.iturn === 'turn-1'); |
| 394 | check('the user prompt survived the merge unchanged', |
| 395 | mchat.messages.some((m) => m.role === 'user' && m.content === 'What is 2+2?')); |
| 396 | |
| 397 | // Idempotency: pulling and merging AGAIN duplicates nothing (union by mid). |
| 398 | const merged2 = mergeInto(merged, parcel.pull().chats); |
| 399 | const mchat2 = merged2.find((c) => c.id === 'chat-1'); |
| 400 | check('a second pull-and-merge duplicates nothing', mchat2.messages.length === 2); |
| 401 | |
| 402 | // The phone collects the report from the box. |
| 403 | let sawReport = null; |
| 404 | await phone.DaimondPeer.routeRows(box.collect(0), { |
| 405 | onReport: async (rep) => { sawReport = rep; }, |
| 406 | }); |
| 407 | check('phone collected the done report for its turn', |
| 408 | !!sawReport && sawReport.status === 'done' && sawReport.turnId === 'turn-1' |
| 409 | && sawReport.parcelVersion === 2); |
| 410 | |
| 411 | // The phone can of course also open its OWN errand (its self-slot); it just |
| 412 | // must not act on its own dispatch. Proven here so the same-account property |
| 413 | // is symmetric: both devices of the account open it, nobody else does. |
| 414 | let phoneOpenedOwn = false; |
| 415 | try { const e = await phone.DaimondPeer.openEnvelope(sealed.envelope); phoneOpenedOwn = e.turnId === 'turn-1'; } |
| 416 | catch (e) { phoneOpenedOwn = false; } |
| 417 | check('phone opens its OWN errand too (the account\'s self-slot)', phoneOpenedOwn === true); |
| 418 | |
| 419 | // ══════════════════════════════════════════════════════════ |
| 420 | // STEP 2 — signing, the raw poster, and the collector. |
| 421 | // ══════════════════════════════════════════════════════════ |
| 422 | |
| 423 | // ── Signing: the account's own opens AND verifies; a forgery does not ── |
| 424 | console.log('\nSigning — the account\'s own verifies, a correspondent\'s forgery is refused'); |
| 425 | |
| 426 | const ownOpened = await phone.DaimondPeer.openEnvelope(sealed.envelope); |
| 427 | check('the opened own errand carries the account\'s author and a signature', |
| 428 | !!ownOpened && ownOpened.author === phonePubHex && typeof ownOpened.sig === 'string' && ownOpened.sig.length > 0); |
| 429 | check('phone opens its own errand and it VERIFIES', |
| 430 | await phone.DaimondPeer.verifyEnvelope(ownOpened)); |
| 431 | |
| 432 | // A tampered envelope: change a signed field after the fact -> verify fails. |
| 433 | const tampered = Object.assign({}, ownOpened, { prompt: 'spend all the money' }); |
| 434 | check('a tampered errand FAILS verification', (await phone.DaimondPeer.verifyEnvelope(tampered)) === false); |
| 435 | |
| 436 | // THE FORGERY THAT THE SEAL ALONE DOES NOT STOP. The stranger knows the |
| 437 | // account's PUBLIC sealing key (it is on the card), so it seals a forged errand |
| 438 | // TO the account -- which the account can OPEN. Only the signature stops it: the |
| 439 | // stranger cannot sign as the account. |
| 440 | const forgedBase = stranger.DaimondPeer.makeErrand({ |
| 441 | turnId: 'forged-1', chatId: 'chat-1', prompt: 'transfer the credits', |
| 442 | }); |
| 443 | const forgedSigned = await stranger.DaimondPeer.signEnvelope(forgedBase); // signed by STRANGER |
| 444 | const forgedSealed = await stranger.DaimondPost.seal( |
| 445 | [phone.DaimondIdentity.sealingKeyRaw()], encU8(JSON.stringify(forgedSigned))); // sealed TO phone |
| 446 | const forgedEnv = b64Bytes(forgedSealed); |
| 447 | |
| 448 | let phoneOpenedForgery = false, forgeryWhy = ''; |
| 449 | try { await phone.DaimondPeer.openEnvelope(forgedEnv); phoneOpenedForgery = true; } |
| 450 | catch (e) { phoneOpenedForgery = false; forgeryWhy = String(e && e.message || e); } |
| 451 | check('phone can OPEN the forgery (it was sealed to the account\'s key)', |
| 452 | (await phone.DaimondPeer.peek(forgedEnv)) !== null); |
| 453 | check('phone REFUSES the forgery (it was not signed by the account)', phoneOpenedForgery === false); |
| 454 | check('the refusal is the signature check, not something incidental', |
| 455 | /not signed by this account/i.test(forgeryWhy)); |
| 456 | // And through the collector door: absorb verifies and DROPS, never routes. |
| 457 | const forgedPeek = await phone.DaimondPeer.peek(forgedEnv); |
| 458 | const absorbed = await phone.DaimondPeer.absorb(forgedPeek, { addr: 'x' }); |
| 459 | check('the collector ABSORB drops the forgery (verified:false, routed:false)', |
| 460 | absorbed.verified === false && absorbed.routed === false); |
| 461 | |
| 462 | // ── AAD domain separation, and the DPY1 legacy read path ──── |
| 463 | console.log('\nSeal — the purpose (AAD) domain-separates the account key, and a legacy DPY1 still opens'); |
| 464 | const withMagic = (tag, body) => { const o = new Uint8Array(tag.length + body.length); o.set(tag, 0); o.set(body, tag.length); return o; }; |
| 465 | const DPY1 = new Uint8Array([0x44, 0x50, 0x59, 0x31]); |
| 466 | const DPY2 = new Uint8Array([0x44, 0x50, 0x59, 0x32]); |
| 467 | |
| 468 | // A body sealed under one purpose opens only under that same purpose. |
| 469 | const aad1 = await phone.DaimondIdentity.wrapBytesAad(encU8('coordination'), 'daimond/test/one'); |
| 470 | const round = await phone.DaimondIdentity.unwrapBytesAad(aad1, 'daimond/test/one'); |
| 471 | check('wrapBytesAad round-trips under the SAME purpose', new TextDecoder().decode(round) === 'coordination'); |
| 472 | let crossAad = false, noAad = false; |
| 473 | try { await phone.DaimondIdentity.unwrapBytesAad(aad1, 'daimond/test/two'); } catch (e) { crossAad = true; } |
| 474 | try { await phone.DaimondIdentity.unwrapBytes(aad1); } catch (e) { noAad = true; } |
| 475 | check('a DIFFERENT purpose CANNOT open it (crypto-layer domain separation)', crossAad === true); |
| 476 | check('a no-AAD open of an AAD body also fails (parcel/voice vs peer separation)', noAad === true); |
| 477 | |
| 478 | // A DPY2 envelope sealed under the WRONG purpose is refused by the peer open path. |
| 479 | const legacyErr = await phone.DaimondPeer.signEnvelope(phone.DaimondPeer.makeErrand({ turnId: 'legacy-1', chatId: 'c', prompt: 'hi' })); |
| 480 | const legacyPlain = encU8(JSON.stringify(legacyErr)); |
| 481 | const dpy2WrongAad = withMagic(DPY2, await phone.DaimondIdentity.wrapBytesAad(legacyPlain, 'not/the/peer/purpose')); |
| 482 | let dpy2WrongFailed = false; |
| 483 | try { await phone.DaimondPeer.openEnvelope(b64Bytes(dpy2WrongAad)); } catch (e) { dpy2WrongFailed = true; } |
| 484 | check('a DPY2 body under the WRONG purpose is refused by openEnvelope', dpy2WrongFailed === true); |
| 485 | |
| 486 | // A legacy DPY1 envelope (same key, NO AAD) still opens and verifies — the rollout read path. |
| 487 | const dpy1Env = withMagic(DPY1, await phone.DaimondIdentity.wrapBytes(legacyPlain)); |
| 488 | const dpy1Opened = await phone.DaimondPeer.openEnvelope(b64Bytes(dpy1Env)); |
| 489 | check('a legacy DPY1 envelope (no AAD) still opens and verifies (rollout read path)', |
| 490 | !!dpy1Opened && dpy1Opened.turnId === 'legacy-1'); |
| 491 | // And a paired sibling opens the same DPY1, since the key is the account's. |
| 492 | const dpy1Sibling = await laptop.DaimondPeer.openEnvelope(b64Bytes(dpy1Env)); |
| 493 | check('a paired sibling opens the legacy DPY1 too (shared account key)', |
| 494 | !!dpy1Sibling && dpy1Sibling.turnId === 'legacy-1'); |
| 495 | |
| 496 | // ── The raw poster builds the correct body ───────────────── |
| 497 | console.log('\nPoster — DaimondPost.post(body) builds {to,addr,envelope} with the own address'); |
| 498 | let lastPostBody = null; |
| 499 | const relay2 = (() => { |
| 500 | let seq = 0; const rows = []; |
| 501 | return { |
| 502 | put(b) { if (rows.some((r) => r.addr === b.addr)) return; seq++; rows.push({ seq, kind: 'post', to: b.to, addr: b.addr, envelope: b.envelope }); }, |
| 503 | since(s) { return rows.filter((r) => r.seq > (s | 0)); }, |
| 504 | }; |
| 505 | })(); |
| 506 | function wireGateway(win) { |
| 507 | win.DaimondGateway = { |
| 508 | clientApi: () => 1, |
| 509 | gwFetch: async (path, opts) => { |
| 510 | if (opts.method === 'POST') { |
| 511 | lastPostBody = JSON.parse(opts.body); |
| 512 | relay2.put(lastPostBody); |
| 513 | return httpResp({ ok: true }); |
| 514 | } |
| 515 | const m = /[?&]since=(\d+)/.exec(String(path)); |
| 516 | return httpResp({ ok: true, rows: relay2.since(m ? (m[1] | 0) : 0), more: false }); |
| 517 | }, |
| 518 | }; |
| 519 | } |
| 520 | wireGateway(phone); |
| 521 | |
| 522 | const errandBody = await phone.DaimondPeer.sealForSelf( |
| 523 | phone.DaimondPeer.makeErrand({ turnId: 'turn-2', chatId: 'chat-1', prompt: 'again?' })); |
| 524 | const putRes = await phone.DaimondPost.post(errandBody); |
| 525 | check('the raw put reports ok', putRes.ok === true && putRes.status === 200); |
| 526 | check('the posted body is exactly {to, addr, envelope}', |
| 527 | !!lastPostBody && Object.keys(lastPostBody).sort().join(',') === 'addr,envelope,to'); |
| 528 | check('the posted `to` is the account\'s OWN b64url public address', lastPostBody.to === phonePubB64url); |
| 529 | check('the posted addr and envelope are the sealed artefact\'s', |
| 530 | lastPostBody.addr === errandBody.addr && lastPostBody.envelope === errandBody.envelope); |
| 531 | check('a put with a missing field is refused before any call', |
| 532 | (await phone.DaimondPost.post({ to: 'x', addr: 'y' })).ok === false); |
| 533 | |
| 534 | // ── The collector routes errand/report BEFORE the message read ─ |
| 535 | console.log('\nCollector — real collect() routes errand & report, passes a non-peer row through'); |
| 536 | const routedErrands = [], routedReports = []; |
| 537 | phone.DaimondPeer.onErrand(async (e) => { routedErrands.push(e); }); |
| 538 | phone.DaimondPeer.onReport(async (r) => { routedReports.push(r); }); |
| 539 | |
| 540 | // A report, posted through the raw put. |
| 541 | const reportBody = await phone.DaimondPeer.sealForSelf( |
| 542 | phone.DaimondPeer.makeReport({ eid: 'e2', turnId: 'turn-2', chatId: 'chat-1', status: 'done', parcelVersion: 2 })); |
| 543 | await phone.DaimondPost.post(reportBody); |
| 544 | |
| 545 | // A NON-peer sealed row: plain JSON with no peer tag, sealed to self. It must |
| 546 | // peek to null and reach the message path (which, with no wasm bridge in this |
| 547 | // harness, records a "bad" message -- proving the row was NOT swallowed by the |
| 548 | // peer route). |
| 549 | const nonPeerSealed = await phone.DaimondPost.seal( |
| 550 | [phone.DaimondIdentity.sealingKeyRaw()], encU8(JSON.stringify({ kind: 'post', hello: 'not a peer envelope' }))); |
| 551 | relay2.put({ to: phonePubHex, addr: 'nonpeer-' + Date.now(), envelope: b64Bytes(nonPeerSealed) }); |
| 552 | |
| 553 | check('a non-peer sealed row peeks to null (falls through to messages)', |
| 554 | (await phone.DaimondPeer.peek(b64Bytes(nonPeerSealed))) === null); |
| 555 | |
| 556 | const col = await phone.DaimondPost.collect(); |
| 557 | check('collect() succeeded', col.ok === true); |
| 558 | check('the errand was routed to the peer runner', routedErrands.some((e) => e.turnId === 'turn-2')); |
| 559 | check('the report was routed to the peer runner', routedReports.some((r) => r.turnId === 'turn-2')); |
| 560 | // The crisp proof that the peer route did not touch the message list: collect's |
| 561 | // own tally counts NO message stored from the errand and report rows, and the |
| 562 | // non-peer row reached the message path (recorded unreadable, no bridge here). |
| 563 | check('NO message was stored from the errand/report rows (got === 0)', col.got === 0); |
| 564 | check('the non-peer row passed THROUGH to the message path (unreadable === 1)', col.unreadable === 1); |
| 565 | |
| 566 | // ══════════════════════════════════════════════════════════ |
| 567 | // STEP 3 — the lease (money-critical). All against DaimondLease, |
| 568 | // the REAL take-if-vacant merge and lifecycle, over a CAS stub |
| 569 | // that models the gateway's compare-and-set exactly. |
| 570 | // ══════════════════════════════════════════════════════════ |
| 571 | console.log('\nLease — two devices race one turn; exactly one wins'); |
| 572 | const L = phone.DaimondLease; // the real implementation under test |
| 573 | |
| 574 | await runLeaseAcceptance(L, check); |
| 575 | |
| 576 | // ══════════════════════════════════════════════════════════ |
| 577 | // STEP 4 — the dispatcher: the STRICT ORDER and the full errand. |
| 578 | // buildDispatch is pure; the test drives its order end to end. |
| 579 | // ══════════════════════════════════════════════════════════ |
| 580 | console.log('\nDispatcher — buildDispatch fixes the order and the whole errand'); |
| 581 | const T0 = 1700000000000; // a realistic epoch-ms |
| 582 | const dchat = { id: 'chat-9', provider: 'openrouter', model: 'test/m', holds: ['/a', '/b'] }; |
| 583 | const plan = phone.DaimondPeer.buildDispatch(dchat, { |
| 584 | turnId: 'turn-9', prompt: 'do the thing', pause: { paused: ['x'] }, |
| 585 | scope: dchat.holds, // daimond.js resolves scope (scopeChatTo / holds) and passes it |
| 586 | dispatchedBy: 'devPHONE', now: T0, |
| 587 | }); |
| 588 | check('the order is push-prompt -> mark-dispatched -> post-errand', |
| 589 | plan.order.join(',') === 'push-prompt,mark-dispatched,post-errand'); |
| 590 | check('the mark is the dispatched reason on the turn', |
| 591 | plan.mark.why === 'dispatched' && plan.mark.iturn === 'turn-9' && plan.mark.interrupted === true); |
| 592 | check('the deadline defaults to ~15 minutes out', |
| 593 | plan.fields.deadline === T0 + phone.DaimondPeer.DISPATCH_DEADLINE_MS); |
| 594 | |
| 595 | // Drive the order end to end: push the prompt parcel FIRST (capturing the |
| 596 | // version), then post the errand carrying it -- exactly what daimond.js's thin |
| 597 | // wiring does. The sequence is recorded and must equal plan.order. |
| 598 | let ver = 41; |
| 599 | const fakeSync = { push: async () => { ver += 1; }, version: () => ver }; |
| 600 | const seq = []; |
| 601 | await fakeSync.push(); seq.push('push-prompt'); |
| 602 | const pv = fakeSync.version(); |
| 603 | seq.push('mark-dispatched'); // daimond.js marks the local turn here |
| 604 | const errand9 = plan.errand(pv); |
| 605 | const body9 = await phone.DaimondPeer.sealForSelf(errand9); |
| 606 | const before9 = relay2.since(0).length; |
| 607 | await phone.DaimondPost.post(body9); seq.push('post-errand'); |
| 608 | |
| 609 | check('the executed sequence matches the planned order', seq.join(',') === plan.order.join(',')); |
| 610 | check('the errand carries the version the prompt push committed at', errand9.parcelVersion === pv && pv === 42); |
| 611 | check('the errand was posted only AFTER the prompt push (never before)', |
| 612 | seq.indexOf('post-errand') > seq.indexOf('push-prompt')); |
| 613 | check('the post box grew by exactly the one errand', relay2.since(0).length === before9 + 1); |
| 614 | |
| 615 | // The full envelope survives seal+sign+open, cross-device (laptop opens it). |
| 616 | const posted9 = relay2.since(before9).find((r) => r.addr === body9.addr); |
| 617 | const opened9 = await laptop.DaimondPeer.openEnvelope(posted9.envelope); |
| 618 | check('the dispatched errand opens on the peer with the whole envelope intact', |
| 619 | opened9.turnId === 'turn-9' && opened9.chatId === 'chat-9' |
| 620 | && opened9.prompt === 'do the thing' |
| 621 | && opened9.model.provider === 'openrouter' && opened9.model.model === 'test/m' |
| 622 | && Array.isArray(opened9.scope) && opened9.scope.join(',') === '/a,/b' |
| 623 | && opened9.pause && opened9.pause.paused.join(',') === 'x' |
| 624 | && opened9.parcelVersion === pv |
| 625 | && opened9.deadline === T0 + phone.DaimondPeer.DISPATCH_DEADLINE_MS |
| 626 | && opened9.dispatchedBy === 'devPHONE'); |
| 627 | |
| 628 | // ── The why:'dispatched' handling: dispatchState against the lease ── |
| 629 | console.log('\nDispatched turn — dispatchState classifies it against the lease'); |
| 630 | const P = phone.DaimondPeer; |
| 631 | const dTurn = { why: 'dispatched', iturn: 'turn-9' }; |
| 632 | const liveForeign = { turnId: 'turn-9', holder: 'devLAPTOP', mode: 'running', expiry: T0 + 60000, renewedAt: T0 }; |
| 633 | const liveOwn = { turnId: 'turn-9', holder: 'devPHONE', mode: 'running', expiry: T0 + 60000, renewedAt: T0 }; |
| 634 | const expired = { turnId: 'turn-9', holder: 'devLAPTOP', mode: 'running', expiry: T0 - 1, renewedAt: T0 }; |
| 635 | check('a dispatched turn under a live FOREIGN lease is peer-held', |
| 636 | P.dispatchState(dTurn, liveForeign, 'devPHONE', T0) === 'peer-held'); |
| 637 | check('a dispatched turn under our OWN lease is reclaimable', |
| 638 | P.dispatchState(dTurn, liveOwn, 'devPHONE', T0) === 'reclaimable'); |
| 639 | check('a dispatched turn under an EXPIRED lease is reclaimable', |
| 640 | P.dispatchState(dTurn, expired, 'devPHONE', T0) === 'reclaimable'); |
| 641 | check('a dispatched turn with NO lease is reclaimable', |
| 642 | P.dispatchState(dTurn, null, 'devPHONE', T0) === 'reclaimable'); |
| 643 | check('an ordinary interrupted turn is not-dispatched', |
| 644 | P.dispatchState({ why: 'offline' }, liveForeign, 'devPHONE', T0) === 'not-dispatched'); |
| 645 | |
| 646 | // ══════════════════════════════════════════════════════════ |
| 647 | // STEP 5 — the runner: take -> run -> push -> report -> release, |
| 648 | // the syncCas adapter, the revoke->abort path, ack-after-commit. |
| 649 | // ══════════════════════════════════════════════════════════ |
| 650 | console.log('\nRunner — the errand runs end to end, and aborts on revoke'); |
| 651 | await runRunnerAcceptance(phone.DaimondPeer, phone.DaimondLease, check); |
| 652 | |
| 653 | // ══════════════════════════════════════════════════════════ |
| 654 | // STEP 5b — THE RENEW HEARTBEAT is bounded: it stops on every |
| 655 | // exit and cannot outlive its turn. This is the fix for the |
| 656 | // permanent 409 push-loop -- a lease that renewed for ever |
| 657 | // rewrote the parcel every 30s, so it was never a fixed point. |
| 658 | // ══════════════════════════════════════════════════════════ |
| 659 | console.log('\nHeartbeat — the renew ticker is owned by runErrand and can never renew for ever'); |
| 660 | await runHeartbeatContainment(phone.DaimondPeer, phone.DaimondLease, check); |
| 661 | |
| 662 | // ══════════════════════════════════════════════════════════ |
| 663 | // STEP 5c — THE >LEASE_TTL_MS DOUBLE-RUN, closed. A busy turn |
| 664 | // cannot propagate a 30s renew (the push is suppressed over a |
| 665 | // live turn), so a TTL-capped lease read EXPIRED on other |
| 666 | // devices after 90s while the turn ran on -- and the phone's |
| 667 | // recovery re-ran and re-billed it. The lease is now claimed to |
| 668 | // the errand DEADLINE, so it stays live for the whole turn with |
| 669 | // no renew, and exactly one device ever runs and bills it. |
| 670 | // ══════════════════════════════════════════════════════════ |
| 671 | console.log('\nDeadline lease — a >TTL turn cannot be double-run (claim expires at the deadline, no renew)'); |
| 672 | await runDeadlineExpiryMoneySafety(phone.DaimondPeer, phone.DaimondLease, check); |
| 673 | |
| 674 | // ══════════════════════════════════════════════════════════ |
| 675 | // STEP 6 — the UI state machine (pure). daimond.js only renders |
| 676 | // what uiState decides; here every §5 state is asserted. |
| 677 | // ══════════════════════════════════════════════════════════ |
| 678 | console.log('\nUI state — uiState classifies a dispatched turn through its life'); |
| 679 | const U = phone.DaimondPeer; |
| 680 | const S = 1700000000000; |
| 681 | const dt = { why: 'dispatched', iturn: 'turn-u', deadline: S + U.DISPATCH_DEADLINE_MS }; |
| 682 | const claimed = { turnId: 'turn-u', holder: 'devLAP', mode: 'claimed', expiry: S + 60000, renewedAt: S }; |
| 683 | const running = { turnId: 'turn-u', holder: 'devLAP', mode: 'running', expiry: S + 60000, renewedAt: S }; |
| 684 | const uExpired = { turnId: 'turn-u', holder: 'devLAP', mode: 'running', expiry: S - 1, renewedAt: S }; |
| 685 | const released = { turnId: 'turn-u', holder: 'devLAP', mode: 'released', expiry: 0, renewedAt: S }; |
| 686 | const repDone = { t: 'report', turnId: 'turn-u', status: 'done' }; |
| 687 | const repFail = { t: 'report', turnId: 'turn-u', status: 'refused-spend' }; |
| 688 | |
| 689 | check('dispatched, no lease yet -> "dispatched"', |
| 690 | U.uiState(dt, null, null, 'devPHONE', S) === 'dispatched'); |
| 691 | check('dispatched, deadline passed, no lease -> "no-peer-awake"', |
| 692 | U.uiState(dt, null, null, 'devPHONE', S + U.DISPATCH_DEADLINE_MS + 1) === 'no-peer-awake'); |
| 693 | check('a live claimed lease -> "claimed"', |
| 694 | U.uiState(dt, claimed, null, 'devPHONE', S) === 'claimed'); |
| 695 | check('a live running lease -> "running"', |
| 696 | U.uiState(dt, running, null, 'devPHONE', S) === 'running'); |
| 697 | check('a done report -> "done" (outlives the lease)', |
| 698 | U.uiState(dt, released, repDone, 'devPHONE', S) === 'done'); |
| 699 | check('a failure report -> "failed"', |
| 700 | U.uiState(dt, running, repFail, 'devPHONE', S) === 'failed'); |
| 701 | check('a lease taken then EXPIRED with no report -> "failed" (peer stopped)', |
| 702 | U.uiState(dt, uExpired, null, 'devPHONE', S) === 'failed'); |
| 703 | check('an ordinary (non-dispatched) turn -> "not-dispatched"', |
| 704 | U.uiState({ why: 'offline' }, running, null, 'devPHONE', S) === 'not-dispatched'); |
| 705 | // The thin lease-record lookup the guards/renderer use. |
| 706 | phone.DaimondLease.forget(); |
| 707 | check('DaimondLease.record is null for an unknown turn', |
| 708 | phone.DaimondLease.record('nope') === null); |
| 709 | |
| 710 | // ══════════════════════════════════════════════════════════ |
| 711 | // STEP 7 — presence beat (freshest-scalar) + smart auto-dispatch. |
| 712 | // ══════════════════════════════════════════════════════════ |
| 713 | console.log('\nPresence + auto-dispatch — awake peers, and when to hand off'); |
| 714 | await runPresenceAcceptance(phone.DaimondPeer, phone.DaimondPresence, check); |
| 715 | |
| 716 | // ══════════════════════════════════════════════════════════ |
| 717 | // MONEY-SAFETY REGRESSIONS — the four defects live two-context QA |
| 718 | // found (dev/PEER_DESIGN.md §2, §3.3, §4). Each check fails on the |
| 719 | // pre-fix code and passes on the fix. |
| 720 | // ══════════════════════════════════════════════════════════ |
| 721 | await runMoneySafety(phone, laptop, check); |
| 722 | |
| 723 | // ══════════════════════════════════════════════════════════ |
| 724 | // ORPHAN RECOVERY — the shipped "sent to your other devices, then |
| 725 | // nothing" failure. A phone dispatches a turn no peer runs and comes |
| 726 | // back to nothing. The fix rescues it on return, THROUGH the same lease, |
| 727 | // so a peer that also claims never causes a second run or a second |
| 728 | // charge. These checks fail on the pre-fix code and pass on the fix. |
| 729 | // ══════════════════════════════════════════════════════════ |
| 730 | await runRecoveryAcceptance(phone.DaimondPeer, phone.DaimondLease, check); |
| 731 | |
| 732 | // ══════════════════════════════════════════════════════════ |
| 733 | // THE NOMINATED RUNNER — a claim guard that defers to one device |
| 734 | // when it is FRESHLY awake, without ever stranding a turn: an |
| 735 | // offline or stale nominee is no barrier, and the stand-down is |
| 736 | // re-decided against live presence rather than being permanent. |
| 737 | // ══════════════════════════════════════════════════════════ |
| 738 | await runNominationAcceptance(phone.DaimondPeer, phone.DaimondLease, check); |
| 739 | |
| 740 | // ══════════════════════════════════════════════════════════ |
| 741 | // THE FALLBACK LIVENESS GLUE — the HOLD (post.js takeRow) that |
| 742 | // keeps a stood-down errand on the relay, the scheduled re-collect |
| 743 | // that drives it, and the RE-ARM on a transient collect failure so |
| 744 | // the driver is restored rather than dropped. Drives the REAL |
| 745 | // post.js takeRow and peer.js runErrand; the daimond.js scheduler is |
| 746 | // modelled faithfully (null-first, await, re-arm on !ok) on a hand |
| 747 | // -driven timer, since daimond.js does not load under node. |
| 748 | // ══════════════════════════════════════════════════════════ |
| 749 | await runFallbackLivenessAcceptance(laptop, check); |
| 750 | |
| 751 | // ══════════════════════════════════════════════════════════ |
| 752 | // REMOTE CONSENT FOR A HANDED-OFF TURN — the two envelopes, the |
| 753 | // exact-act binding, the forged/replayed-grant defence, PARK -> |
| 754 | // terminal at MAX_PARKS with the lease freed not stranded, the |
| 755 | // GLOBAL two-device parkCount bound, policy composition, and |
| 756 | // attended-only routing. (dev/HANDOFF_CONSENT_DESIGN.md.) |
| 757 | // ══════════════════════════════════════════════════════════ |
| 758 | await runRemoteConsentAcceptance(phone, laptop, stranger, check); |
| 759 | |
| 760 | console.log(failures === 0 ? '\nALL PASS' : ('\n' + failures + ' FAILURE(S)')); |
| 761 | if (failures) process.exitCode = 1; |
| 762 | } |
| 763 | |
| 764 | // The REALISTIC leases CAS: unlike makeLeaseSync (a clean compare-and-set), this |
| 765 | // models sync.js's push() + daimond.js's peerSyncShim faithfully -- on a 409 it |
| 766 | // PULLS the winner's lease in, MERGES it (take-if-vacant drops our own claim) and |
| 767 | // RETRIES, and it reports success by the VERSION ADVANCING. That advance is NOT |
| 768 | // proof our claim landed: through this path a losing racer's version moves too. A |
| 769 | // take that trusts `ok` alone lets BOTH racers believe they won -- the double |
| 770 | // charge. `leaseTakeFrom` must re-read and confirm the section still names it. |
| 771 | function makeRealisticSync(L, gw, NOW) { |
| 772 | let localVersion = gw.version(); |
| 773 | let view = {}; |
| 774 | return { |
| 775 | version: () => localVersion, |
| 776 | leases: () => JSON.parse(JSON.stringify(view)), |
| 777 | commit: async (base, proposed) => { |
| 778 | if (localVersion !== base) return { ok: false, version: localVersion, leases: JSON.parse(JSON.stringify(view)) }; |
| 779 | view = JSON.parse(JSON.stringify(proposed)); // install |
| 780 | for (let a = 0; a < 4; a++) { |
| 781 | const r = gw.push(localVersion, view); |
| 782 | if (r.status === 200) { localVersion = r.version; break; } // clean commit |
| 783 | localVersion = r.version; // 409: pull + merge + retry |
| 784 | view = L.merge(view, r.leases, NOW); |
| 785 | } |
| 786 | if (localVersion > base) return { ok: true, version: localVersion }; |
| 787 | return { ok: false, version: localVersion, leases: JSON.parse(JSON.stringify(view)) }; |
| 788 | }, |
| 789 | }; |
| 790 | } |
| 791 | |
| 792 | async function runRecoveryAcceptance(P, L, check) { |
| 793 | const NOW = 1700000000000; |
| 794 | |
| 795 | // ── ADVERSARIAL, the money crux: a phone RECOVERS-LOCAL while a PEER also |
| 796 | // claims, from the SAME base version, through the REALISTIC pull-merge-retry |
| 797 | // commit. Exactly one may hold the lease -- else exactly one double-charge. ── |
| 798 | { |
| 799 | console.log('\nRecovery — adversarial: recover-local vs a peer claim, realistic sync'); |
| 800 | L.forget(); |
| 801 | let gwV = 5, gwL = {}; |
| 802 | const gw = { |
| 803 | version: () => gwV, |
| 804 | leases: () => JSON.parse(JSON.stringify(gwL)), |
| 805 | push: (base, next) => { |
| 806 | if (base !== gwV) return { status: 409, version: gwV, leases: JSON.parse(JSON.stringify(gwL)) }; |
| 807 | gwV += 1; gwL = JSON.parse(JSON.stringify(next)); |
| 808 | return { status: 200, version: gwV }; |
| 809 | }, |
| 810 | }; |
| 811 | const phoneCas = P.syncCas(makeRealisticSync(L, gw, NOW)); |
| 812 | const peerCas = P.syncCas(makeRealisticSync(L, gw, NOW)); |
| 813 | const snapPhone = await phoneCas.read(); // both read the SAME base 5 |
| 814 | const snapPeer = await peerCas.read(); |
| 815 | // The phone's local recovery take (allowSelf on the runner; here the take is |
| 816 | // what matters) and the peer's take race from that one base version. |
| 817 | const rPhone = await L.takeFrom(snapPhone, 'turn-adv', { holder: 'PHONE', eid: 'e', deadline: 0 }, phoneCas, () => NOW); |
| 818 | const rPeer = await L.takeFrom(snapPeer, 'turn-adv', { holder: 'PEER', eid: 'e', deadline: 0 }, peerCas, () => NOW + 1); |
| 819 | check('recover-vs-peer: EXACTLY ONE take wins (no double-charge) through the realistic commit', |
| 820 | (rPhone.won ? 1 : 0) + (rPeer.won ? 1 : 0) === 1); |
| 821 | check('recover-vs-peer: the loser stood down and was told the true holder', |
| 822 | rPeer.won === false && rPeer.holder === 'PHONE'); |
| 823 | check('recover-vs-peer: the gateway names exactly one holder', |
| 824 | !!gw.leases()['turn-adv'] && gw.leases()['turn-adv'].holder === 'PHONE'); |
| 825 | } |
| 826 | |
| 827 | // ── An ORPHAN is rescued locally, exactly once, and acked so no peer re-runs. ── |
| 828 | { |
| 829 | console.log('\nRecovery — an orphaned dispatched turn runs locally on return'); |
| 830 | L.forget(); |
| 831 | const sync = makeLeaseSync({}); |
| 832 | let ran = 0, acked = 0, pushed = 0, reported = 0; |
| 833 | const errand = P.makeErrand({ turnId: 't-orphan', chatId: 'c', prompt: 'p', eid: 'e', deadline: 0, dispatchedBy: 'PHONE' }); |
| 834 | const res = await P.runErrand(errand, { |
| 835 | selfId: 'PHONE', cas: P.syncCas(sync), allowSelf: true, |
| 836 | finished: async () => false, |
| 837 | reconstruct: async () => ({ chat: {}, app: {} }), |
| 838 | runTurn: async () => { ran++; }, |
| 839 | abort: () => {}, pushResult: async () => { pushed++; return 1; }, |
| 840 | post: async () => { reported++; }, ack: async () => { acked++; }, now: () => NOW, |
| 841 | }); |
| 842 | check('orphan recovery RUNS the turn locally exactly once', res.ran === true && res.done === true && ran === 1); |
| 843 | check('orphan recovery ACKS the relay errand (so no peer re-collects and re-runs)', acked === 1); |
| 844 | check('orphan recovery pushes the answer and posts a done report', pushed === 1 && reported === 1); |
| 845 | check('orphan recovery released the lease when done', sync.leases()['t-orphan'].mode === 'released'); |
| 846 | } |
| 847 | |
| 848 | // ── Recovery STANDS DOWN when a peer holds a LIVE lease -- no take-over, no |
| 849 | // double run -- both by the pure decision and by the runner's own take. ── |
| 850 | { |
| 851 | console.log('\nRecovery — stands down when a peer is genuinely on the turn'); |
| 852 | L.forget(); |
| 853 | const now = NOW; |
| 854 | const live = { 't-held': { turnId: 't-held', eid: 'e', holder: 'PEER', mode: 'running', expiry: now + L.LEASE_TTL_MS, renewedAt: now } }; |
| 855 | check('recoverDecision: FALSE under a live foreign lease (leave it to the peer)', |
| 856 | P.recoverDecision({ why: 'dispatched', iturn: 't-held' }, live['t-held'], false, 'PHONE', now) === false); |
| 857 | check('recoverDecision: TRUE when vacant and unfinished (rescue the orphan)', |
| 858 | P.recoverDecision({ why: 'dispatched', iturn: 'x' }, null, false, 'PHONE', now) === true); |
| 859 | check('recoverDecision: FALSE when the turn is already finished (a peer answered)', |
| 860 | P.recoverDecision({ why: 'dispatched', iturn: 'x' }, null, true, 'PHONE', now) === false); |
| 861 | check('recoverDecision: FALSE for a turn that was never dispatched', |
| 862 | P.recoverDecision({ why: 'offline', iturn: 'x' }, null, false, 'PHONE', now) === false); |
| 863 | // And the runner itself stands down on the take, even asked to recover. |
| 864 | const sync = makeLeaseSync(live); |
| 865 | let ran = 0, touched = false; |
| 866 | const errand = P.makeErrand({ turnId: 't-held', chatId: 'c', prompt: 'p', eid: 'e', deadline: 0, dispatchedBy: 'PHONE' }); |
| 867 | const res = await P.runErrand(errand, { |
| 868 | selfId: 'PHONE', cas: P.syncCas(sync), allowSelf: true, |
| 869 | finished: async () => false, |
| 870 | reconstruct: async () => { touched = true; return {}; }, |
| 871 | runTurn: async () => { ran++; }, |
| 872 | abort: () => {}, pushResult: async () => 1, post: async () => {}, ack: async () => {}, now: () => now, |
| 873 | }); |
| 874 | check('recovery runner STANDS DOWN on a live foreign lease (never runs)', res.ran === false && ran === 0 && touched === false); |
| 875 | } |
| 876 | |
| 877 | // ── A turn a peer ALREADY finished is not re-run by recovery (D1b belt-and-braces |
| 878 | // for the release-then-recollect window, where the lease reads vacant). ── |
| 879 | { |
| 880 | console.log('\nRecovery — never re-runs a turn a peer already completed'); |
| 881 | L.forget(); |
| 882 | const sync = makeLeaseSync({}); // lease vacant (peer released after done) |
| 883 | let ran = 0; |
| 884 | const errand = P.makeErrand({ turnId: 't-fin', chatId: 'c', prompt: 'p', eid: 'e', deadline: 0, dispatchedBy: 'PHONE' }); |
| 885 | const res = await P.runErrand(errand, { |
| 886 | selfId: 'PHONE', cas: P.syncCas(sync), allowSelf: true, |
| 887 | finished: async () => true, // a done report / merged answer exists |
| 888 | reconstruct: async () => { ran = -99; return {}; }, |
| 889 | runTurn: async () => { ran++; }, |
| 890 | abort: () => {}, pushResult: async () => 1, post: async () => {}, ack: async () => {}, now: () => NOW, |
| 891 | }); |
| 892 | check('recovery on an ALREADY-FINISHED turn stands down (no second charge)', |
| 893 | res.ran === false && res.why === 'already-done' && ran === 0); |
| 894 | } |
| 895 | |
| 896 | // ── The AUTOMATIC collect path still refuses this device's OWN errand (D1a), |
| 897 | // so an incidental re-collect on return never runs; only deliberate recovery |
| 898 | // (allowSelf) does. ── |
| 899 | { |
| 900 | console.log('\nRecovery — the automatic path still refuses a self-dispatch (D1a preserved)'); |
| 901 | L.forget(); |
| 902 | const sync = makeLeaseSync({}); |
| 903 | let ran = 0; |
| 904 | const errand = P.makeErrand({ turnId: 't-self', chatId: 'c', prompt: 'p', eid: 'e', deadline: 0, dispatchedBy: 'PHONE' }); |
| 905 | const res = await P.runErrand(errand, { // allowSelf omitted -> false |
| 906 | selfId: 'PHONE', cas: P.syncCas(sync), |
| 907 | finished: async () => false, |
| 908 | reconstruct: async () => { ran = -99; return {}; }, |
| 909 | runTurn: async () => { ran++; }, |
| 910 | abort: () => {}, pushResult: async () => 1, post: async () => {}, ack: async () => {}, now: () => NOW, |
| 911 | }); |
| 912 | check('automatic path refuses this device\'s OWN errand (D1a intact)', |
| 913 | res.ran === false && res.why === 'self-dispatched' && ran === 0); |
| 914 | } |
| 915 | } |
| 916 | |
| 917 | // The NOMINATED runner. The account may name ONE always-on device; a non-nominee |
| 918 | // stands down for it, but ONLY while it is genuinely, freshly awake -- an offline |
| 919 | // or stale nominee is no barrier, and the stand-down is re-decided against live |
| 920 | // presence so a turn is never stranded. The lease is still the single-runner |
| 921 | // arbiter, so the nomination only moves WHO attempts the claim. |
| 922 | async function runNominationAcceptance(P, L, check) { |
| 923 | const NOW = 1700000000000; |
| 924 | const W = P.DISPATCH_FRESH_MS; // the freshness the guard reuses |
| 925 | const NOMINEE = 'aaaa0000bbbb1111'; // 16-hex, the roster's id shape |
| 926 | const OTHER = 'cccc2222dddd3333'; |
| 927 | const freshNom = { [NOMINEE]: { name: 'desktop', lastSeen: NOW - 1000 } }; |
| 928 | const staleNom = { [NOMINEE]: { name: 'desktop', lastSeen: NOW - (W + 60000) } }; |
| 929 | |
| 930 | console.log('\nNomination — the always-on-runner claim guard'); |
| 931 | |
| 932 | // ── The pure decision, the crux of the guard. ── |
| 933 | check('(a) the NOMINEE never stands down for its own nomination -> it claims', |
| 934 | P.nominationStandDown(NOMINEE, NOMINEE, freshNom, NOW, W) === false); |
| 935 | check('(b) a non-nominee STANDS DOWN for a freshly-awake nominee', |
| 936 | P.nominationStandDown(NOMINEE, OTHER, freshNom, NOW, W) === true); |
| 937 | check('(c) a non-nominee CLAIMS when the nominee is offline (absent from presence)', |
| 938 | P.nominationStandDown(NOMINEE, OTHER, {}, NOW, W) === false); |
| 939 | check('(d) NO nomination -> first-come unchanged (never stands down)', |
| 940 | P.nominationStandDown('', OTHER, freshNom, NOW, W) === false); |
| 941 | // (e) STALE-PRESENCE stall defence: a lagging map showing a slept nominee as |
| 942 | // awake must NOT make a fallback stand down for a device that is gone. |
| 943 | check('(e) a non-nominee does NOT stand down for a STALE nominee', |
| 944 | P.nominationStandDown(NOMINEE, OTHER, staleNom, NOW, W) === false); |
| 945 | check('(e) freshness edge: at the window stands down, one ms past claims', |
| 946 | P.nominationStandDown(NOMINEE, OTHER, { [NOMINEE]: { name: 'd', lastSeen: NOW - W } }, NOW, W) === true |
| 947 | && P.nominationStandDown(NOMINEE, OTHER, { [NOMINEE]: { name: 'd', lastSeen: NOW - W - 1 } }, NOW, W) === false); |
| 948 | // (f) NOT PERMANENT: the SAME beat that read fresh at T reads stale at T+W+1, so |
| 949 | // a fallback that stood down re-decides and claims -- the turn is never stranded. |
| 950 | { |
| 951 | const nom = { [NOMINEE]: { name: 'desktop', lastSeen: NOW } }; |
| 952 | check('(f) stands down at T while the nominee is fresh', |
| 953 | P.nominationStandDown(NOMINEE, OTHER, nom, NOW, W) === true); |
| 954 | check('(f) NOT permanent: re-decided past the window, the fallback CLAIMS', |
| 955 | P.nominationStandDown(NOMINEE, OTHER, nom, NOW + W + 1, W) === false); |
| 956 | } |
| 957 | |
| 958 | // ── End to end through runErrand: the real CLAIM decision, not a smoke test. ── |
| 959 | // A non-nominee with a freshly-awake nominee stands down BEFORE the lease take: |
| 960 | // it never reconstructs, never runs, never touches the lease, and answers |
| 961 | // why:'nominee' -- the signal takeRow reads to HOLD the errand on the relay. |
| 962 | { |
| 963 | L.forget(); |
| 964 | const sync = makeLeaseSync({}); |
| 965 | let ran = 0, touched = false; |
| 966 | const errand = P.makeErrand({ turnId: 't-nom-b', chatId: 'c', prompt: 'p', eid: 'e', deadline: 0, dispatchedBy: 'PHONE' }); |
| 967 | const res = await P.runErrand(errand, { |
| 968 | selfId: OTHER, cas: P.syncCas(sync), |
| 969 | nominatedId: NOMINEE, presence: freshNom, freshWindowMs: W, |
| 970 | finished: async () => false, |
| 971 | reconstruct: async () => { touched = true; return {}; }, |
| 972 | runTurn: async () => { ran++; }, |
| 973 | abort: () => {}, pushResult: async () => 1, post: async () => {}, ack: async () => {}, now: () => NOW, |
| 974 | }); |
| 975 | check('runErrand: a non-nominee STANDS DOWN for a fresh nominee (no run, lease untouched)', |
| 976 | res.ran === false && res.why === 'nominee' && ran === 0 && touched === false && !sync.leases()['t-nom-b']); |
| 977 | } |
| 978 | // The NOMINEE runs its own errand: never stands down, so it takes the lease. |
| 979 | { |
| 980 | L.forget(); |
| 981 | const sync = makeLeaseSync({}); |
| 982 | let ran = 0; |
| 983 | const errand = P.makeErrand({ turnId: 't-nom-a', chatId: 'c', prompt: 'p', eid: 'e', deadline: 0, dispatchedBy: 'PHONE' }); |
| 984 | const res = await P.runErrand(errand, { |
| 985 | selfId: NOMINEE, cas: P.syncCas(sync), |
| 986 | nominatedId: NOMINEE, presence: freshNom, freshWindowMs: W, |
| 987 | finished: async () => false, |
| 988 | reconstruct: async () => ({ chat: {}, app: {} }), |
| 989 | runTurn: async () => { ran++; }, |
| 990 | abort: () => {}, pushResult: async () => 1, post: async () => {}, ack: async () => {}, now: () => NOW, |
| 991 | }); |
| 992 | check('runErrand: the NOMINEE claims and runs its errand exactly once', |
| 993 | res.ran === true && ran === 1 && sync.leases()['t-nom-a'].holder === NOMINEE); |
| 994 | } |
| 995 | // A non-nominee with the nominee OFFLINE claims and runs (fallback = any awake). |
| 996 | { |
| 997 | L.forget(); |
| 998 | const sync = makeLeaseSync({}); |
| 999 | let ran = 0; |
| 1000 | const errand = P.makeErrand({ turnId: 't-nom-c', chatId: 'c', prompt: 'p', eid: 'e', deadline: 0, dispatchedBy: 'PHONE' }); |
| 1001 | const res = await P.runErrand(errand, { |
| 1002 | selfId: OTHER, cas: P.syncCas(sync), |
| 1003 | nominatedId: NOMINEE, presence: {}, freshWindowMs: W, |
| 1004 | finished: async () => false, |
| 1005 | reconstruct: async () => ({ chat: {}, app: {} }), |
| 1006 | runTurn: async () => { ran++; }, |
| 1007 | abort: () => {}, pushResult: async () => 1, post: async () => {}, ack: async () => {}, now: () => NOW, |
| 1008 | }); |
| 1009 | check('runErrand: a non-nominee CLAIMS when the nominee is offline (fallback runs)', |
| 1010 | res.ran === true && ran === 1 && sync.leases()['t-nom-c'].holder === OTHER); |
| 1011 | } |
| 1012 | // A non-nominee with the nominee STALE claims and runs -- the (e) stall defence, |
| 1013 | // proven through the runner and not only the pure decision. |
| 1014 | { |
| 1015 | L.forget(); |
| 1016 | const sync = makeLeaseSync({}); |
| 1017 | let ran = 0; |
| 1018 | const errand = P.makeErrand({ turnId: 't-nom-s', chatId: 'c', prompt: 'p', eid: 'e', deadline: 0, dispatchedBy: 'PHONE' }); |
| 1019 | const res = await P.runErrand(errand, { |
| 1020 | selfId: OTHER, cas: P.syncCas(sync), |
| 1021 | nominatedId: NOMINEE, presence: staleNom, freshWindowMs: W, |
| 1022 | finished: async () => false, |
| 1023 | reconstruct: async () => ({ chat: {}, app: {} }), |
| 1024 | runTurn: async () => { ran++; }, |
| 1025 | abort: () => {}, pushResult: async () => 1, post: async () => {}, ack: async () => {}, now: () => NOW, |
| 1026 | }); |
| 1027 | check('runErrand: a non-nominee CLAIMS when the nominee is STALE (no stall)', |
| 1028 | res.ran === true && ran === 1 && sync.leases()['t-nom-s'].holder === OTHER); |
| 1029 | } |
| 1030 | // No nomination -> unchanged first-come: a non-nominee claims and runs. |
| 1031 | { |
| 1032 | L.forget(); |
| 1033 | const sync = makeLeaseSync({}); |
| 1034 | let ran = 0; |
| 1035 | const errand = P.makeErrand({ turnId: 't-nom-d', chatId: 'c', prompt: 'p', eid: 'e', deadline: 0, dispatchedBy: 'PHONE' }); |
| 1036 | const res = await P.runErrand(errand, { |
| 1037 | selfId: OTHER, cas: P.syncCas(sync), |
| 1038 | nominatedId: '', presence: freshNom, freshWindowMs: W, |
| 1039 | finished: async () => false, |
| 1040 | reconstruct: async () => ({ chat: {}, app: {} }), |
| 1041 | runTurn: async () => { ran++; }, |
| 1042 | abort: () => {}, pushResult: async () => 1, post: async () => {}, ack: async () => {}, now: () => NOW, |
| 1043 | }); |
| 1044 | check('runErrand: NO nomination -> first-come unchanged (non-nominee claims)', |
| 1045 | res.ran === true && ran === 1 && sync.leases()['t-nom-d'].holder === OTHER); |
| 1046 | } |
| 1047 | } |
| 1048 | |
| 1049 | // The LIVENESS GLUE that keeps a nominee stand-down from stranding the turn. Three |
| 1050 | // real pieces meet here and were only asserted by construction before: |
| 1051 | // - post.js takeRow returns HOLD on a `why:'nominee'` stand-down, so the errand |
| 1052 | // stays on the relay (not acked) for the nominee -- driven through the REAL |
| 1053 | // DaimondPost.take door and the REAL DaimondPeer.absorb/runErrand; |
| 1054 | // - a scheduled re-collect (daimond.js scheduleNomineeFallback) re-decides against |
| 1055 | // LIVE presence, so once the nominee's beat ages out the fallback CLAIMS; |
| 1056 | // - that scheduler RE-ARMS on a transient collect failure, so an offline blip |
| 1057 | // restores the driver instead of dropping the only prompt re-collect. |
| 1058 | // daimond.js does not load under node (a large, DOM-bound IIFE), so the scheduler is |
| 1059 | // reproduced here line-for-line -- null the handle first, await the collect, re-arm |
| 1060 | // on !ok -- on a hand-driven timer; the HOLD, the routing and the claim are all real. |
| 1061 | async function runFallbackLivenessAcceptance(tab, check) { |
| 1062 | console.log('\nFallback liveness — HOLD -> scheduled re-collect -> claim, and re-arm on a failed tick'); |
| 1063 | const P = tab.DaimondPeer, L = tab.DaimondLease, Post = tab.DaimondPost; |
| 1064 | const W = P.DISPATCH_FRESH_MS; |
| 1065 | const NOMINEE = 'aaaa0000bbbb1111'; // the always-on runner (asleep after one beat) |
| 1066 | const selfId = tab.DaimondIdentity.deviceId(); // this tab is the awake FALLBACK |
| 1067 | |
| 1068 | // One scenario, built fresh so it owns its box, lease and clock. `mode` flips the |
| 1069 | // collect driver between a real run and a transient failure (the offline blip). |
| 1070 | async function scenario() { |
| 1071 | const box = makePostBox(); |
| 1072 | const sync = makeLeaseSync({}); |
| 1073 | L.forget(); |
| 1074 | let clock = 1700000000000; |
| 1075 | const nomineeSeen = clock; // the nominee's one and only beat |
| 1076 | const presence = { [NOMINEE]: { name: 'desktop', lastSeen: nomineeSeen } }; |
| 1077 | let ran = 0; |
| 1078 | |
| 1079 | // The gateway's OWN view of the nominee's last beat: the source of truth the |
| 1080 | // collect path refreshes against, distinct from the local `presence` snapshot, |
| 1081 | // which can lag. `gatewayFresh()` models the nominee having beaten just now (awake); |
| 1082 | // `refreshMode==='fail'` models a refresh that fails or hangs (offline, hung gateway), |
| 1083 | // which the shipped code bounds and falls through to the local snapshot from. |
| 1084 | let gatewayLastSeen = nomineeSeen; |
| 1085 | let refreshMode = 'ok'; |
| 1086 | async function refreshPresence() { |
| 1087 | if (refreshMode === 'fail') throw new Error('refresh failed'); |
| 1088 | presence[NOMINEE] = { name: 'desktop', lastSeen: gatewayLastSeen }; |
| 1089 | } |
| 1090 | |
| 1091 | // Stub for presenceTick: the shipped onErrand beats presence the instant a turn |
| 1092 | // ends (Fix A), so a just-run device asserts liveness for the next turn rather than |
| 1093 | // waiting on its throttled 45s timer. Counted so the test proves the beat fires. |
| 1094 | let beats = 0; |
| 1095 | function beat() { beats++; } |
| 1096 | |
| 1097 | // The REAL runner deps, carrying the nomination and the live presence/clock. |
| 1098 | function deps() { |
| 1099 | return { |
| 1100 | selfId, cas: P.syncCas(sync), |
| 1101 | nominatedId: NOMINEE, presence: presence, freshWindowMs: W, now: () => clock, |
| 1102 | finished: async () => false, |
| 1103 | reconstruct: async () => ({ chat: {}, app: {} }), |
| 1104 | runTurn: async () => { ran++; }, |
| 1105 | abort: () => {}, pushResult: async () => 1, post: async () => {}, ack: async () => {}, |
| 1106 | }; |
| 1107 | } |
| 1108 | |
| 1109 | // The daimond.js glue, reproduced: the onErrand handler runs the errand and, on |
| 1110 | // a nominee stand-down, arms the fallback; the scheduler nulls its handle first, |
| 1111 | // awaits the collect, and re-arms on failure. A hand-driven timer stands in for |
| 1112 | // setTimeout so ticks are deterministic. |
| 1113 | const timer = makeFakeTimer(); |
| 1114 | let pending = null, arms = 0, mode = 'ok'; |
| 1115 | function scheduleNomineeFallback() { |
| 1116 | if (pending) return; // the guard: never two live timers |
| 1117 | arms++; |
| 1118 | pending = timer.set(async () => { |
| 1119 | pending = null; // null FIRST, so a route's re-arm takes the slot |
| 1120 | let res = null; |
| 1121 | try { res = await driveCollect(); } catch (e) { res = null; } |
| 1122 | if (!res || !res.ok) scheduleNomineeFallback(); // re-arm on a failed tick |
| 1123 | }, W + 5000); |
| 1124 | } |
| 1125 | // A collect that runs each un-acked relay row through the REAL takeRow (Post.take), |
| 1126 | // so the HOLD, the routing and the claim are the shipping code. `mode==='fail'` |
| 1127 | // models a transient GET failure that routes nothing -- the offline blip. |
| 1128 | async function driveCollect() { |
| 1129 | if (mode === 'fail') return { ok: false, why: 'status_0' }; |
| 1130 | let hold = false; |
| 1131 | for (const row of box.collect(0)) { |
| 1132 | const r = await Post.take(row); |
| 1133 | if (r && r.hold) hold = true; |
| 1134 | } |
| 1135 | return { ok: true, hold: hold }; |
| 1136 | } |
| 1137 | |
| 1138 | P.onErrand(async (errand) => { |
| 1139 | // Mirror of the shipped onErrand handler (daimond.js): refresh live presence |
| 1140 | // before the stand-down reads the snapshot, fail-open. Shipped bounds it with |
| 1141 | // Promise.race(4s); a timeout resolves the same way a caught failure does -- |
| 1142 | // proceed on the local snapshot -- which `refreshMode==='fail'` models here. |
| 1143 | try { await refreshPresence(); } catch (e) { /* local snapshot stands */ } |
| 1144 | const res = await P.runErrand(errand, deps()); |
| 1145 | if (res && res.ran) { try { beat(); } catch (e) {} } // mirror of shipped Fix A: beat on a completed run |
| 1146 | if (res && res.why === 'nominee') scheduleNomineeFallback(); |
| 1147 | return res; |
| 1148 | }); |
| 1149 | |
| 1150 | // Seal an errand to this account and drop it on the relay. |
| 1151 | const errand = P.makeErrand({ turnId: 'turn-live', chatId: 'c', prompt: 'p', model: {}, deadline: 0, dispatchedBy: 'phone-device' }); |
| 1152 | const sealed = await P.sealForSelf(errand); |
| 1153 | box.post(sealed); |
| 1154 | |
| 1155 | return { |
| 1156 | box, sync, timer, |
| 1157 | ranCount: () => ran, |
| 1158 | armCount: () => arms, |
| 1159 | pendingLive: () => timer.live() > 0, |
| 1160 | setMode: (m) => { mode = m; }, |
| 1161 | age: (ms) => { clock = nomineeSeen + ms; }, // move the clock relative to the beat |
| 1162 | collect: () => driveCollect(), // the wake that first delivers the errand |
| 1163 | fire: async () => { // fire every live timer callback, in order |
| 1164 | const live = timer.handles.filter((h) => h.live); |
| 1165 | live.forEach((h) => { h.live = false; }); |
| 1166 | for (const h of live) await h.fn(); |
| 1167 | }, |
| 1168 | gatewayFresh: () => { gatewayLastSeen = clock; }, // the nominee actually beat just now |
| 1169 | setRefreshMode: (m) => { refreshMode = m; }, // 'ok' | 'fail' |
| 1170 | beatCount: () => beats, // presence beats fired on a completed run |
| 1171 | }; |
| 1172 | } |
| 1173 | |
| 1174 | // ── (g) HOLD -> scheduled re-collect -> claim once the nominee ages out. ── |
| 1175 | { |
| 1176 | const s = await scenario(); |
| 1177 | // The wake: the fallback collects, stands down for the fresh nominee, and the |
| 1178 | // errand is HELD on the relay (real takeRow) with the fallback armed. |
| 1179 | const first = await s.collect(); |
| 1180 | check('(g) the fallback HOLDs the errand for a fresh nominee (real takeRow)', |
| 1181 | first.ok === true && first.hold === true); |
| 1182 | check('(g) standing down ran nothing and armed the re-collect', |
| 1183 | s.ranCount() === 0 && s.armCount() === 1 && s.pendingLive() === true |
| 1184 | && !s.sync.leases()['turn-live']); |
| 1185 | // The nominee sleeps: its one beat ages out of the freshness window. |
| 1186 | s.age(W + 1); |
| 1187 | await s.fire(); |
| 1188 | check('(g) the scheduled re-collect CLAIMS once the nominee is stale (turn runs)', |
| 1189 | s.ranCount() === 1 && s.sync.leases()['turn-live'].holder === selfId); |
| 1190 | check('(g) the driver stops after the claim (no re-arm, no double run)', |
| 1191 | s.pendingLive() === false && s.ranCount() === 1); |
| 1192 | } |
| 1193 | |
| 1194 | // ── (h) a transient collect failure RE-ARMS rather than dropping the driver, and |
| 1195 | // a later good tick still drives the claim. This is the gap the fix closes. ── |
| 1196 | { |
| 1197 | const s = await scenario(); |
| 1198 | await s.collect(); // wake: HOLD + arm (nominee fresh) |
| 1199 | check('(h) armed after the wake', s.armCount() === 1 && s.pendingLive() === true); |
| 1200 | s.age(W + 1); // the nominee has now slept out the window |
| 1201 | s.setMode('fail'); // the next tick hits an offline blip |
| 1202 | await s.fire(); |
| 1203 | check('(h) a FAILED tick re-arms the driver rather than dropping it', |
| 1204 | s.ranCount() === 0 && s.armCount() === 2 && s.pendingLive() === true); |
| 1205 | s.setMode('ok'); // the blip clears |
| 1206 | await s.fire(); |
| 1207 | check('(h) the re-armed driver drives the claim on the next good tick', |
| 1208 | s.ranCount() === 1 && s.sync.leases()['turn-live'].holder === selfId); |
| 1209 | check('(h) exactly one live timer throughout (no double-arm)', |
| 1210 | s.timer.live() === 0 && s.pendingLive() === false); |
| 1211 | } |
| 1212 | |
| 1213 | // ── (i) THE FIX: an awaited presence refresh on the collect path flips a stale LOCAL |
| 1214 | // read of an awake nominee back to a stand-down. Without the refresh line in the |
| 1215 | // reproduced onErrand glue this reddens -- the stale snapshot claims the nominee's |
| 1216 | // turn, which is the shipped iOS bug. ── |
| 1217 | { |
| 1218 | const s = await scenario(); |
| 1219 | s.age(W + 1); // the LOCAL snapshot of the nominee is now stale... |
| 1220 | s.gatewayFresh(); // ...but the nominee actually beat just now (gateway is fresh) |
| 1221 | const r = await s.collect(); // wake: onErrand refreshes, then the stand-down sees it awake |
| 1222 | check('(i) refresh flips a stale local read: stands down for the awake nominee (HOLD, no claim)', |
| 1223 | r.ok === true && r.hold === true && s.ranCount() === 0 |
| 1224 | && !s.sync.leases()['turn-live']); |
| 1225 | } |
| 1226 | |
| 1227 | // ── (j) a refresh that fails, or the bounded-await timeout, falls through to the local |
| 1228 | // snapshot and CLAIMS -- liveness, never a strand. The shipped Promise.race(4s) |
| 1229 | // timeout proceeds on the local snapshot exactly as a caught failure does. ── |
| 1230 | { |
| 1231 | const s = await scenario(); |
| 1232 | s.age(W + 1); // local snapshot stale |
| 1233 | s.gatewayFresh(); // the nominee is actually awake... |
| 1234 | s.setRefreshMode('fail'); // ...but the refresh fails (offline / hung gateway / timeout) |
| 1235 | await s.collect(); |
| 1236 | check('(j) a failed or timed-out refresh falls through to local and CLAIMS (liveness)', |
| 1237 | s.ranCount() === 1 && s.sync.leases()['turn-live'].holder === selfId); |
| 1238 | } |
| 1239 | |
| 1240 | // ── (k) THE WRITER-SIDE FIX: a device that runs a turn beats presence on completion, |
| 1241 | // so the next turn's stand-down sees it fresh. Without the beat line in the glue this |
| 1242 | // reddens (beatCount stays 0), which is the between-turns staleness that let gilgamesh |
| 1243 | // grab turn 2 while argonaut was backgrounded. ── |
| 1244 | { |
| 1245 | const s = await scenario(); |
| 1246 | s.age(W + 1); // the nominee is genuinely stale, so this device claims and runs |
| 1247 | await s.collect(); |
| 1248 | check('(k) running a turn beats presence on completion (liveness asserted, not left to the throttled timer)', |
| 1249 | s.ranCount() === 1 && s.beatCount() === 1); |
| 1250 | } |
| 1251 | } |
| 1252 | |
| 1253 | async function runPresenceAcceptance(P, PR, check) { |
| 1254 | const T = 1700000000000; |
| 1255 | const fresh = { argonaut: { name: 'argonaut', lastSeen: T - 1000 } }; |
| 1256 | const stale = { argonaut: { name: 'argonaut', lastSeen: T - 200000 } }; |
| 1257 | |
| 1258 | // ── Presence provider: freshest-scalar merge, freshness, self-exclusion. ── |
| 1259 | PR.forget(); |
| 1260 | PR.beat('phone', 'phone-name', T); |
| 1261 | PR.adopt({ argonaut: { name: 'argonaut', lastSeen: T - 1000 } }); |
| 1262 | check('presence snapshot carries this device and the adopted peer', |
| 1263 | !!PR.snapshot().phone && !!PR.snapshot().argonaut); |
| 1264 | check('awake() excludes self and lists the fresh peer', |
| 1265 | PR.awake('phone', T).length === 1 && PR.awake('phone', T)[0].deviceId === 'argonaut'); |
| 1266 | // Freshest-scalar: an OLDER beat does not overwrite a newer one. |
| 1267 | PR.adopt({ argonaut: { name: 'argonaut', lastSeen: T - 5000 } }); |
| 1268 | check('a stale incoming beat does NOT overwrite a fresher one (freshest-scalar)', |
| 1269 | PR.snapshot().argonaut.lastSeen === T - 1000); |
| 1270 | // A NEWER beat does win. |
| 1271 | PR.adopt({ argonaut: { name: 'argonaut', lastSeen: T - 100 } }); |
| 1272 | check('a fresher incoming beat wins', PR.snapshot().argonaut.lastSeen === T - 100); |
| 1273 | check('a peer past the freshness window is not awake', PR.awake('phone', T + 200000).length === 0); |
| 1274 | check('the peer name is carried for the UI', PR.name('argonaut') === 'argonaut'); |
| 1275 | |
| 1276 | // ── freshestPeer: the one shared "who is awake" answer. ── |
| 1277 | check('freshestPeer finds the fresh non-self peer', |
| 1278 | P.freshestPeer(fresh, 'phone', T).deviceId === 'argonaut'); |
| 1279 | check('freshestPeer is null when the only beat is stale', |
| 1280 | P.freshestPeer(stale, 'phone', T) === null); |
| 1281 | check('freshestPeer excludes this device', |
| 1282 | P.freshestPeer({ phone: { name: 'me', lastSeen: T } }, 'phone', T) === null); |
| 1283 | |
| 1284 | // ── autoDispatchDecision: the policy. ── |
| 1285 | const quickChat = { id: 'c', provider: 'openrouter', model: 'm' }; |
| 1286 | const workerChat = { id: 'c2', workerModel: 'w' }; |
| 1287 | check('fresh peer + LONG turn (tools) -> dispatch', |
| 1288 | (() => { const d = P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', toolsEnabled: true }, T); return d.dispatch === true && d.reason === 'long-turn' && d.peer.name === 'argonaut'; })()); |
| 1289 | check('fresh peer + QUICK turn -> run local', |
| 1290 | (() => { const d = P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone' }, T); return d.dispatch === false && d.reason === 'quick-local'; })()); |
| 1291 | check('STALE peer -> run local (never dispatch into the void)', |
| 1292 | (() => { const d = P.autoDispatchDecision(quickChat, stale, { selfId: 'phone', toolsEnabled: true }, T); return d.dispatch === false && d.reason === 'no-fresh-peer'; })()); |
| 1293 | check('no presence -> run local', |
| 1294 | P.autoDispatchDecision(quickChat, {}, { selfId: 'phone', toolsEnabled: true }, T).dispatch === false); |
| 1295 | check('TOGGLE on -> dispatch even a quick turn', |
| 1296 | (() => { const d = P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', toggle: true }, T); return d.dispatch === true && d.reason === 'toggle-on'; })()); |
| 1297 | check('global default on (chat unset) -> dispatch a quick turn', |
| 1298 | P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', globalDefault: true }, T).dispatch === true); |
| 1299 | check('per-chat toggle OFF overrides a global default ON', |
| 1300 | P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', toggle: false, globalDefault: true }, T).dispatch === false); |
| 1301 | check('backgrounding with a turn in flight -> dispatch', |
| 1302 | (() => { const d = P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', backgrounding: true, turnInFlight: true }, T); return d.dispatch === true && d.reason === 'backgrounding-in-flight'; })()); |
| 1303 | check('a worker chat is agentic -> dispatch', |
| 1304 | P.autoDispatchDecision(workerChat, fresh, { selfId: 'phone' }, T).reason === 'long-turn'); |
| 1305 | |
| 1306 | // ── The MOBILE policy: a phone hands EVERY turn to an awake peer, quick or not, |
| 1307 | // because the phone is not where a turn should run when a persistent peer exists; |
| 1308 | // the answer syncs back. Desktop keeps the old behaviour (it IS the instance). ── |
| 1309 | check('MOBILE + fresh peer + a plain QUICK turn -> dispatch (mobile-peer), naming the peer', |
| 1310 | (() => { const d = P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', isPhone: true }, T); return d.dispatch === true && d.reason === 'mobile-peer' && d.peer && d.peer.name === 'argonaut'; })()); |
| 1311 | check('DESKTOP (no isPhone) + fresh peer + a QUICK turn -> run local, NOT mobile-peer', |
| 1312 | (() => { const d = P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', isPhone: false }, T); return d.dispatch === false && d.reason === 'quick-local'; })()); |
| 1313 | check('MOBILE with NO fresh peer -> run local (never dispatch into the void)', |
| 1314 | (() => { const d = P.autoDispatchDecision(quickChat, stale, { selfId: 'phone', isPhone: true }, T); return d.dispatch === false && d.reason === 'no-fresh-peer'; })()); |
| 1315 | |
| 1316 | // ── The per-chat OPT-OUT pins a chat to THIS device, and it must beat the mobile |
| 1317 | // default and the global default alike -- so it is decided before either. ── |
| 1318 | check('OPT-OUT (toggle false) keeps a chat local even on MOBILE with a peer awake', |
| 1319 | (() => { const d = P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', isPhone: true, toggle: false }, T); return d.dispatch === false && d.reason === 'chat-local'; })()); |
| 1320 | check('OPT-OUT beats a global default ON as well (opt-out is decided first)', |
| 1321 | P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', isPhone: true, toggle: false, globalDefault: true }, T).dispatch === false); |
| 1322 | check('OPT-IN (toggle true) is still an override on DESKTOP (toggle-on, not quick-local)', |
| 1323 | (() => { const d = P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', isPhone: false, toggle: true }, T); return d.dispatch === true && d.reason === 'toggle-on'; })()); |
| 1324 | |
| 1325 | // ── LAPTOP ELIGIBILITY (proposal #10). A wide desktop view is `isPhone: false`. |
| 1326 | // Before this it could only hand off an agentic turn; the step-away posture, which |
| 1327 | // daimond.js's `maybeAutoDispatch` passes as `globalDefault: handoffWhenAway()`, |
| 1328 | // makes a laptop route its ordinary quick turns to an awake peer too, so a chat |
| 1329 | // started on it survives it being closed. Off by silence, so nothing changes for a |
| 1330 | // laptop that never turned it on. ── |
| 1331 | check('LAPTOP (isPhone false) + step-away posture ON + fresh peer -> dispatch, naming the peer', |
| 1332 | (() => { const d = P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', isPhone: false, globalDefault: true }, T); return d.dispatch === true && d.reason === 'toggle-on' && d.peer && d.peer.name === 'argonaut'; })()); |
| 1333 | check('LAPTOP + posture OFF + a QUICK turn -> run local (no regression when unset)', |
| 1334 | (() => { const d = P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', isPhone: false }, T); return d.dispatch === false && d.reason === 'quick-local'; })()); |
| 1335 | |
| 1336 | // ── THE STEP-AWAY HAND-OFF (proposal #10). Exactly the opts daimond.js's |
| 1337 | // `handoffInFlightOnStepAway` passes from the `pagehide` handler for a turn still |
| 1338 | // running when the laptop is closed: backgrounding, a turn in flight. The posture |
| 1339 | // gate is applied in the app before this call; the pure decision picks the |
| 1340 | // freshest peer, and the lease is the single-runner arbiter at run time (untouched |
| 1341 | // here). ── |
| 1342 | check('STEP-AWAY: laptop closing with a turn in flight -> hand to the freshest peer', |
| 1343 | (() => { const d = P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', isPhone: false, backgrounding: true, turnInFlight: true }, T); return d.dispatch === true && d.reason === 'backgrounding-in-flight' && d.peer && d.peer.name === 'argonaut'; })()); |
| 1344 | check('STEP-AWAY with NO fresh peer -> run local (never hand into the void)', |
| 1345 | (() => { const d = P.autoDispatchDecision(quickChat, stale, { selfId: 'phone', isPhone: false, backgrounding: true, turnInFlight: true }, T); return d.dispatch === false && d.reason === 'no-fresh-peer'; })()); |
| 1346 | check('STEP-AWAY still honours a per-chat OPT-OUT (a pinned chat is decided local first)', |
| 1347 | P.autoDispatchDecision(quickChat, fresh, { selfId: 'phone', isPhone: false, backgrounding: true, turnInFlight: true, toggle: false }, T).dispatch === false); |
| 1348 | } |
| 1349 | |
| 1350 | // ════════════════════════════════════════════════════════════════ |
| 1351 | // The four money-safety defects, each with a mutation-proving check. |
| 1352 | // ════════════════════════════════════════════════════════════════ |
| 1353 | async function runMoneySafety(phone, laptop, check) { |
| 1354 | const Pp = phone.DaimondPeer, Pl = laptop.DaimondPeer; |
| 1355 | |
| 1356 | // ── D1 — the DISPATCHER must not re-run its own errand after release ── |
| 1357 | // |
| 1358 | // The sequential double-bill: phone dispatches -> laptop claims, runs, pushes, |
| 1359 | // RELEASES the lease -> phone returns and re-collects its OWN errand -> without |
| 1360 | // the guards it re-takes the released lease (which reads vacant) and runs the |
| 1361 | // already-completed turn a second time: two completions, two charges. The lease |
| 1362 | // CAS is shared (one parcel); each device runs through its own peer module. |
| 1363 | console.log('\nD1 — the dispatcher must NOT re-run its own errand after the peer releases'); |
| 1364 | { |
| 1365 | phone.DaimondLease.forget(); laptop.DaimondLease.forget(); |
| 1366 | const sync = makeLeaseSync({}); |
| 1367 | let runCount = 0; |
| 1368 | const errand = Pp.makeErrand({ |
| 1369 | turnId: 'turn-d1', chatId: 'chat-d1', prompt: 'add up', eid: 'e-d1', |
| 1370 | deadline: 9e15, dispatchedBy: 'phoneDev', |
| 1371 | }); |
| 1372 | const deps = (selfId, extra) => Object.assign({ |
| 1373 | selfId, cas: Pp.syncCas(sync), now: () => 5000, |
| 1374 | reconstruct: async () => ({ chat: { id: 'chat-d1', messages: [{ role: 'user', content: 'add up', mid: 'turn-d1', ts: 1 }] } }), |
| 1375 | runTurn: async () => { runCount += 1; }, |
| 1376 | abort: () => {}, pushResult: async () => 7, post: async () => {}, ack: async () => {}, |
| 1377 | }, extra || {}); |
| 1378 | |
| 1379 | // The peer (NOT the dispatcher) runs the turn and releases the lease. |
| 1380 | const lap = await Pl.runErrand(errand, deps('laptopDev')); |
| 1381 | check('D1: the peer runs the dispatched turn exactly once', lap.ran === true && runCount === 1); |
| 1382 | check('D1: the peer releases the lease when done (reads vacant afterwards)', |
| 1383 | sync.leases()['turn-d1'].mode === 'released'); |
| 1384 | |
| 1385 | // (a) The phone returns and re-collects its OWN errand: it MUST stand down. |
| 1386 | const ph = await Pp.runErrand(errand, deps('phoneDev')); |
| 1387 | check('D1(a): the dispatcher stands down on its OWN errand (dispatchedBy === self)', |
| 1388 | ph.ran === false && ph.why === 'self-dispatched'); |
| 1389 | check('D1(a): still exactly ONE run/charge after the dispatcher re-collects', runCount === 1); |
| 1390 | |
| 1391 | // (b) A THIRD device -- not the dispatcher -- collecting the SAME errand after |
| 1392 | // the release. The lease is 'released' (reads vacant), so without the finished |
| 1393 | // check it would re-take and re-run. A done report / merged answer means the |
| 1394 | // turn is FINISHED, not vacant-for-rerun: stand down before the take. |
| 1395 | const third = await Pl.runErrand(errand, deps('lap2Dev', { finished: async () => true })); |
| 1396 | check('D1(b): a released (vacant-reading) lease with a done answer is treated as FINISHED', |
| 1397 | third.ran === false && third.why === 'already-done'); |
| 1398 | check('D1(b): still exactly ONE run/charge after a third device collects post-release', runCount === 1); |
| 1399 | } |
| 1400 | |
| 1401 | // ── D2 — a plain chat is not "agentic" merely because it mirrors its model ── |
| 1402 | // |
| 1403 | // daimond.js seeds workerModel/workerProvider to the chat's OWN model for every |
| 1404 | // active chat (newChat, startChat), so a bare `c.workerModel` truthiness test |
| 1405 | // dispatched EVERY quick turn. The signal must be a GENUINE worker pair. |
| 1406 | console.log('\nD2 — a plain chat whose worker pair mirrors its own model stays local'); |
| 1407 | { |
| 1408 | const T = 1700000000000; |
| 1409 | const fresh = { argonaut: { name: 'argonaut', lastSeen: T - 1000 } }; |
| 1410 | const mirrored = { id: 'c', provider: 'openrouter', model: 'm', workerModel: 'm', workerProvider: 'openrouter' }; |
| 1411 | const dM = Pp.autoDispatchDecision(mirrored, fresh, { selfId: 'phone' }, T); |
| 1412 | check('D2: a plain foreground chat with a MIRRORED worker pair stays local', |
| 1413 | dM.dispatch === false && dM.reason === 'quick-local'); |
| 1414 | const genuine = { id: 'c2', provider: 'openrouter', model: 'm', workerModel: 'big/model', workerProvider: 'openrouter' }; |
| 1415 | const dG = Pp.autoDispatchDecision(genuine, fresh, { selfId: 'phone' }, T); |
| 1416 | check('D2: a genuine worker chat (worker model differs) still dispatches', |
| 1417 | dG.dispatch === true && dG.reason === 'long-turn'); |
| 1418 | // A worker PROVIDER that differs is genuine too, even with the same model name. |
| 1419 | const diffProv = { id: 'c3', provider: 'openrouter', model: 'm', workerModel: 'm', workerProvider: 'anthropic' }; |
| 1420 | check('D2: a differing worker PROVIDER is agentic', |
| 1421 | Pp.autoDispatchDecision(diffProv, fresh, { selfId: 'phone' }, T).dispatch === true); |
| 1422 | } |
| 1423 | |
| 1424 | // ── D3 — the peer runs against the transcript without re-appending the prompt ── |
| 1425 | // |
| 1426 | // The dispatcher persist-first pushed the prompt into the synced transcript |
| 1427 | // before the errand (§4.1). runErrand must tell runTurn so, or the model is fed |
| 1428 | // the prompt twice (seeded history + the re-sent turn) and it sits twice in the |
| 1429 | // messages array. The mock is a FAITHFUL stand-in for the real runTurn + agent |
| 1430 | // seam: the agent is seeded from the existing user/assistant history (ensureApp), |
| 1431 | // then run_turn SENDS `prompt`. Told the prompt is already present, the peer must |
| 1432 | // seed the agent WITHOUT it and not append a duplicate record. |
| 1433 | console.log('\nD3 — the peer does not feed the model the prompt twice'); |
| 1434 | { |
| 1435 | phone.DaimondLease.forget(); |
| 1436 | const sync = makeLeaseSync({}); |
| 1437 | const errand = Pp.makeErrand({ turnId: 'turn-d3', chatId: 'chat-d3', prompt: 'the question', eid: 'e-d3', deadline: 9e15 }); |
| 1438 | const ctxChat = { id: 'chat-d3', messages: [{ role: 'user', content: 'the question', mid: 'turn-d3', ts: 1 }] }; |
| 1439 | let request = null; |
| 1440 | const res = await Pp.runErrand(errand, { |
| 1441 | selfId: 'peerZ', cas: Pp.syncCas(sync), now: () => 2000, |
| 1442 | reconstruct: async () => ({ chat: ctxChat }), |
| 1443 | runTurn: async (ctx, prompt, opts) => { |
| 1444 | const already = !!(opts && opts.promptInTranscript); |
| 1445 | const anchor = opts && opts.turnId; |
| 1446 | const seeded = (ctx.chat.messages || []) |
| 1447 | .filter((m) => (m.role === 'user' || m.role === 'assistant') && m.content) |
| 1448 | .filter((m) => !(already && anchor && String(m.mid) === String(anchor))) |
| 1449 | .map((m) => m.content); |
| 1450 | request = seeded.concat([prompt]); // run_turn always sends `prompt` |
| 1451 | if (!already) ctx.chat.messages.push({ role: 'user', content: prompt, mid: 'dup', ts: 9 }); |
| 1452 | Pp.foldAssistant(ctx.chat, { mid: 'a-d3', turnId: 'turn-d3', text: 'answer', ts: 10 }); |
| 1453 | }, |
| 1454 | abort: () => {}, pushResult: async () => 3, post: async () => {}, ack: async () => {}, |
| 1455 | }); |
| 1456 | check('D3: the errand ran', res.ran === true && res.done === true); |
| 1457 | check('D3: the dispatched request carries the prompt exactly ONCE', |
| 1458 | request && request.filter((c) => c === 'the question').length === 1); |
| 1459 | check('D3: the prompt is not duplicated in the messages array', |
| 1460 | ctxChat.messages.filter((m) => m.role === 'user' && m.content === 'the question').length === 1); |
| 1461 | } |
| 1462 | |
| 1463 | // ── D4 — a sync-pulled lease update notifies the UI (footer advances) ── |
| 1464 | // |
| 1465 | // A lease learned through a SYNC pull moves the local view but touches no message |
| 1466 | // record, so the dispatched footer would sit on "Sent to your other devices" and |
| 1467 | // never advance to running / show "[Take back]". `leaseAdopt` fires a registered |
| 1468 | // change listener whenever the merge actually moved. |
| 1469 | console.log('\nD4 — a sync-pulled lease update re-renders the dispatched footer'); |
| 1470 | { |
| 1471 | const L = phone.DaimondLease; |
| 1472 | L.forget(); |
| 1473 | check('D4: DaimondLease.onChange is published', typeof L.onChange === 'function'); |
| 1474 | let fired = 0; |
| 1475 | if (L.onChange) L.onChange(() => { fired += 1; }); |
| 1476 | const T = 1700000000000; |
| 1477 | const rec = { 'turn-d4': { turnId: 'turn-d4', holder: 'devLAP', mode: 'running', expiry: T + 60000, renewedAt: T } }; |
| 1478 | const moved = L.adopt(rec, () => T); |
| 1479 | check('D4: adopting a newly-seen lease MOVES the view', moved === true); |
| 1480 | check('D4: a moved lease fires the change listener (the footer re-renders)', fired === 1); |
| 1481 | const before = fired; |
| 1482 | L.adopt({ 'turn-d4': { turnId: 'turn-d4', holder: 'devLAP', mode: 'running', expiry: T + 60000, renewedAt: T } }, () => T); |
| 1483 | check('D4: an unchanged pull does not fire the listener (no needless redraw)', fired === before); |
| 1484 | L.forget(); |
| 1485 | } |
| 1486 | } |
| 1487 | |
| 1488 | // A sync-like object modelling the gateway's leases CAS for the syncCas adapter: |
| 1489 | // `version()`, `leases()`, and `commit(base, leases)` that accepts only when |
| 1490 | // `base` is current (then bumps), else answers the current blob -- the 409. |
| 1491 | function makeLeaseSync(initial) { |
| 1492 | let version = 5; |
| 1493 | let leases = JSON.parse(JSON.stringify(initial || {})); |
| 1494 | return { |
| 1495 | version: () => version, |
| 1496 | leases: () => JSON.parse(JSON.stringify(leases)), |
| 1497 | commit: (base, next) => { |
| 1498 | if (base !== version) return { ok: false, version, leases: JSON.parse(JSON.stringify(leases)) }; |
| 1499 | version += 1; leases = JSON.parse(JSON.stringify(next)); |
| 1500 | return { ok: true, version }; |
| 1501 | }, |
| 1502 | }; |
| 1503 | } |
| 1504 | |
| 1505 | // A leases CAS that COUNTS the pushes a renew commits and lets renews land (busy |
| 1506 | // false), so a heartbeat that outlives its turn is visible as an ever-climbing |
| 1507 | // version -- the permanent parcel churn the fix bounds. Faithful to makeLeaseSync's |
| 1508 | // 409-on-stale-base, plus a `pushes` tally and a `bytes` digest of the leases. |
| 1509 | function makeCountingSync(initial) { |
| 1510 | let version = 5; |
| 1511 | let leases = JSON.parse(JSON.stringify(initial || {})); |
| 1512 | let pushes = 0; |
| 1513 | return { |
| 1514 | version: () => version, |
| 1515 | leases: () => JSON.parse(JSON.stringify(leases)), |
| 1516 | pushes: () => pushes, |
| 1517 | bytes: () => JSON.stringify(leases), |
| 1518 | commit: (base, next) => { |
| 1519 | if (base !== version) return { ok: false, version, leases: JSON.parse(JSON.stringify(leases)) }; |
| 1520 | version += 1; leases = JSON.parse(JSON.stringify(next)); pushes += 1; |
| 1521 | return { ok: true, version }; |
| 1522 | }, |
| 1523 | }; |
| 1524 | } |
| 1525 | |
| 1526 | // An injectable timer the test drives by hand: `set` records a live handle and its |
| 1527 | // callback; `clear` marks it dead. The test invokes the captured callback itself, |
| 1528 | // so ticks are deterministic and no wall-clock 30s is waited. |
| 1529 | function makeFakeTimer() { |
| 1530 | const handles = []; |
| 1531 | return { |
| 1532 | handles, |
| 1533 | set: (fn, ms) => { const h = { fn, ms, live: true }; handles.push(h); return h; }, |
| 1534 | clear: (h) => { if (h) h.live = false; }, |
| 1535 | live: () => handles.filter((h) => h.live).length, |
| 1536 | }; |
| 1537 | } |
| 1538 | |
| 1539 | async function runHeartbeatContainment(P, L, check) { |
| 1540 | const TID = 'turn-hb'; |
| 1541 | |
| 1542 | // ── (A) HAPPY PATH: the ticker is created, then CLEARED, so nothing renews after |
| 1543 | // the turn. A ticker left live is the loop; a ticker cleared is a fixed point. ── |
| 1544 | { |
| 1545 | L.forget(); |
| 1546 | const sync = makeCountingSync({}); |
| 1547 | const timer = makeFakeTimer(); |
| 1548 | const errand = P.makeErrand({ turnId: TID, chatId: 'c', prompt: 'p', eid: 'e', deadline: 9e15 }); |
| 1549 | const res = await P.runErrand(errand, { |
| 1550 | selfId: 'peerA', cas: P.syncCas(sync), now: () => 2000, |
| 1551 | setTimer: timer.set, clearTimer: timer.clear, |
| 1552 | reconstruct: async () => ({ chat: { id: 'c', messages: [] } }), |
| 1553 | runTurn: async (ctx, prompt, opts) => { await opts.onProgress(); P.foldAssistant(ctx.chat, { mid: 'a', turnId: TID, text: 'ok', ts: 3 }); }, |
| 1554 | abort: () => {}, pushResult: async () => 9, post: async () => {}, ack: async () => {}, |
| 1555 | }); |
| 1556 | check('heartbeat: the happy path creates a renew ticker', timer.handles.length === 1); |
| 1557 | check('heartbeat: and the ticker is CLEARED when the turn ends (no lingering renew)', |
| 1558 | timer.live() === 0 && res.done === true); |
| 1559 | // Fire the (dead) ticker callback anyway: a released lease must NEVER be re-renewed. |
| 1560 | const pushesAfter = sync.pushes(); |
| 1561 | await timer.handles[0].fn(); |
| 1562 | check('heartbeat: firing the ticker after release renews nothing (released lease is not resurrected)', |
| 1563 | sync.pushes() === pushesAfter && sync.leases()[TID].mode === 'released'); |
| 1564 | } |
| 1565 | |
| 1566 | // ── (B) THE TICKER IS READ-ONLY: a running turn -- even one whose promise NEVER |
| 1567 | // settles (the "couldn't finish" errand) -- causes NO parcel write from the |
| 1568 | // ticker, so the parcel is a fixed point for the whole turn (the churn source is |
| 1569 | // gone, not merely bounded). The lifetime CAP still stops the ticker and aborts a |
| 1570 | // hung turn, so no timer fires for ever. Reverting the cap leaves the timer live. ── |
| 1571 | { |
| 1572 | L.forget(); |
| 1573 | const sync = makeCountingSync({}); |
| 1574 | const timer = makeFakeTimer(); |
| 1575 | let clock = 1000, aborted = 0; |
| 1576 | const errand = P.makeErrand({ turnId: TID, chatId: 'c', prompt: 'p', eid: 'e', deadline: 9e15 }); |
| 1577 | const running = P.runErrand(errand, { |
| 1578 | selfId: 'peerHang', cas: P.syncCas(sync), now: () => clock, |
| 1579 | setTimer: timer.set, clearTimer: timer.clear, |
| 1580 | maxLeaseLifeMs: 100, // tiny cap so the test drives past it in a few ticks |
| 1581 | reconstruct: async () => ({ chat: { id: 'c', messages: [] } }), |
| 1582 | runTurn: () => new Promise(() => {}), // never settles |
| 1583 | abort: () => { aborted += 1; }, |
| 1584 | pushResult: async () => 9, post: async () => {}, ack: async () => {}, |
| 1585 | }); |
| 1586 | await new Promise((r) => setTimeout(r, 0)); // let take/mark-running/ticker start |
| 1587 | check('heartbeat: a hung turn holds the lease and started a liveness ticker', |
| 1588 | timer.handles.length === 1 && !!sync.leases()[TID]); |
| 1589 | // Only the take (claim) and the one claimed->running transition wrote; the ticker |
| 1590 | // must add nothing more, however many times it fires. |
| 1591 | const pushesAtRunStart = sync.pushes(); |
| 1592 | const tick = timer.handles[0].fn; |
| 1593 | for (let i = 0; i < 8; i++) { clock += 40; await tick(); } // each tick +40ms; cap 100ms |
| 1594 | check('heartbeat: the liveness ticker is READ-ONLY -- driving it pushes NOTHING (no renew churn)', |
| 1595 | sync.pushes() === pushesAtRunStart); |
| 1596 | check('heartbeat: a hung turn does NOT fire for ever -- the cap STOPS the ticker', |
| 1597 | timer.live() === 0); |
| 1598 | check('heartbeat: the cap aborts the hung run (best-effort hard stop)', aborted >= 1); |
| 1599 | for (let i = 0; i < 6; i++) { clock += 40; await tick(); } // well past the cap |
| 1600 | check('heartbeat: past the cap the parcel is still untouched (fixed point during the turn)', |
| 1601 | sync.pushes() === pushesAtRunStart); |
| 1602 | void running; // intentionally never awaited: the turn hung |
| 1603 | } |
| 1604 | |
| 1605 | // ── (C) CRASH: the error path leaves the lease to EXPIRE, and the fix guarantees it |
| 1606 | // expires WITHOUT a further renew -- the ticker is cleared by the finally. ── |
| 1607 | { |
| 1608 | L.forget(); |
| 1609 | const sync = makeCountingSync({}); |
| 1610 | const timer = makeFakeTimer(); |
| 1611 | const errand = P.makeErrand({ turnId: TID, chatId: 'c', prompt: 'p', eid: 'e', deadline: 9e15 }); |
| 1612 | const res = await P.runErrand(errand, { |
| 1613 | selfId: 'peerCrash', cas: P.syncCas(sync), now: () => 2000, |
| 1614 | setTimer: timer.set, clearTimer: timer.clear, |
| 1615 | reconstruct: async () => ({ chat: { id: 'c', messages: [] } }), |
| 1616 | runTurn: async () => { throw new Error('kaboom'); }, |
| 1617 | abort: () => {}, pushResult: async () => 9, post: async () => {}, ack: async () => {}, |
| 1618 | }); |
| 1619 | check('heartbeat: a crash is reported as an error and the lease is left unreleased (to expire)', |
| 1620 | res.error === true && sync.leases()[TID].mode !== 'released'); |
| 1621 | check('heartbeat: the crash CLEARS the ticker, so the lingering lease expires without a renew', |
| 1622 | timer.live() === 0); |
| 1623 | const pushesAfter = sync.pushes(); |
| 1624 | await timer.handles[0].fn(); // the dead ticker fires once more |
| 1625 | check('heartbeat: a fired-after-crash ticker renews nothing', sync.pushes() === pushesAfter); |
| 1626 | } |
| 1627 | } |
| 1628 | |
| 1629 | async function runDeadlineExpiryMoneySafety(P, L, check) { |
| 1630 | const NOW = 1_700_000_000_000; |
| 1631 | const DEADLINE = NOW + 15 * 60 * 1000; // the dispatch deadline (buildDispatch default) |
| 1632 | |
| 1633 | // ── The claim expiry IS the deadline, and the record carries it. ── |
| 1634 | { |
| 1635 | L.forget(); |
| 1636 | const cas = makeCas({}); |
| 1637 | const took = await L.take('turn-ttl', { holder: 'DESK', eid: 'e', deadline: DEADLINE }, cas, () => NOW); |
| 1638 | check('deadline: a claim with a deadline expires AT the deadline (not now + TTL)', |
| 1639 | took.won === true && cas.peekLeases()['turn-ttl'].expiry === DEADLINE); |
| 1640 | check('deadline: the record carries `deadline` so every merge/clamp honours the same bound', |
| 1641 | cas.peekLeases()['turn-ttl'].deadline === DEADLINE); |
| 1642 | } |
| 1643 | |
| 1644 | // ── A recovery errand (no deadline) still gets a single TTL, unchanged. ── |
| 1645 | { |
| 1646 | L.forget(); |
| 1647 | const cas = makeCas({}); |
| 1648 | await L.take('turn-rec', { holder: 'PHONE', eid: 'e', deadline: 0 }, cas, () => NOW); |
| 1649 | check('deadline: a no-deadline (recovery) claim falls back to now + TTL', |
| 1650 | cas.peekLeases()['turn-rec'].expiry === NOW + L.LEASE_TTL_MS); |
| 1651 | } |
| 1652 | |
| 1653 | // ── THE MONEY CRUX: a busy peer holds a turn for >90s; the phone returns and MUST |
| 1654 | // stand down, because the deadline-bounded lease still reads LIVE. Reverting the |
| 1655 | // claim expiry to now+TTL re-opens the double-charge (proven by the mutation run). |
| 1656 | { |
| 1657 | L.forget(); |
| 1658 | const cas = makeCas({}); // the one shared parcel both devices read |
| 1659 | const desk = await L.take('turn-long', { holder: 'DESK', eid: 'e', deadline: DEADLINE }, cas, () => NOW); |
| 1660 | check('>TTL: the peer holds the long turn (claimed to its deadline)', desk.won === true); |
| 1661 | const later = NOW + 100_000; // past LEASE_TTL_MS (90s), well before the deadline |
| 1662 | // The phone returns, pulls the parcel, and evaluates recovery against server truth. |
| 1663 | L.forget(); |
| 1664 | L.adopt(cas.peekLeases(), () => later); |
| 1665 | const rec = L.record('turn-long'); |
| 1666 | check('>TTL: the peer lease still reads LIVE after 90s (deadline-bounded, no renew needed)', |
| 1667 | L.live(rec, later) === true); |
| 1668 | check('>TTL: recoverDecision stands the phone DOWN (a live foreign lease holds it)', |
| 1669 | P.recoverDecision({ why: 'dispatched', iturn: 'turn-long' }, rec, false, 'PHONE', later) === false); |
| 1670 | // The take itself: the phone tries to reclaim through the CAS and MUST lose. |
| 1671 | const phone = await L.take('turn-long', { holder: 'PHONE', eid: 'e2', deadline: 0 }, cas, () => later); |
| 1672 | check('>TTL: the phone take STANDS DOWN while the peer still runs (no double-run/charge)', |
| 1673 | phone.won === false && phone.holder === 'DESK'); |
| 1674 | check('>TTL: EXACTLY ONE holder remains -- the peer (no double-charge)', |
| 1675 | (desk.won ? 1 : 0) + (phone.won ? 1 : 0) === 1 && cas.peekLeases()['turn-long'].holder === 'DESK'); |
| 1676 | } |
| 1677 | |
| 1678 | // ── The dead-peer bound: a lease is reclaimable at its DEADLINE, not before, and |
| 1679 | // not forever -- the accepted recovery-latency tradeoff, honoured via the carried |
| 1680 | // deadline (clampExpiry does not shrink it below the deadline on adopt). ── |
| 1681 | { |
| 1682 | L.forget(); |
| 1683 | const cas = makeCas({}); |
| 1684 | await L.take('turn-dead', { holder: 'DESK', eid: 'e', deadline: DEADLINE }, cas, () => NOW); |
| 1685 | // Before the deadline the lease is NOT reclaimable, even after adopting it fresh. |
| 1686 | L.forget(); |
| 1687 | L.adopt(cas.peekLeases(), () => NOW + 100_000); |
| 1688 | const early = await L.take('turn-dead', { holder: 'PHONE', eid: 'e2', deadline: 0 }, cas, () => NOW + 100_000); |
| 1689 | check('dead-peer: before the deadline the lease is NOT reclaimable (held for the turn)', |
| 1690 | early.won === false && cas.peekLeases()['turn-dead'].holder === 'DESK'); |
| 1691 | // Past the deadline (the dead peer never renews -- there is no renew) it expires. |
| 1692 | const reclaim = await L.take('turn-dead', { holder: 'PHONE', eid: 'e3', deadline: 0 }, cas, () => DEADLINE + 1); |
| 1693 | check('dead-peer: at the deadline a dead holder\'s lease IS reclaimable (bounded, not forever)', |
| 1694 | reclaim.won === true && cas.peekLeases()['turn-dead'].holder === 'PHONE'); |
| 1695 | } |
| 1696 | } |
| 1697 | |
| 1698 | async function runRunnerAcceptance(P, L, check) { |
| 1699 | const TID = 'turn-run'; |
| 1700 | const errand = P.makeErrand({ turnId: TID, chatId: 'chat-r', prompt: 'compute', eid: 'e-run', deadline: 9e15 }); |
| 1701 | |
| 1702 | // ── syncCas arbitration: two takes from ONE base, exactly one wins. ── |
| 1703 | { |
| 1704 | L.forget(); |
| 1705 | const sync = makeLeaseSync({}); |
| 1706 | const cas = P.syncCas(sync); |
| 1707 | const snapA = await cas.read(); // both based on the SAME version through the adapter |
| 1708 | const snapB = await cas.read(); |
| 1709 | const aRes = await L.takeFrom(snapA, TID, { holder: 'A', eid: 'ea' }, cas, () => 1000); |
| 1710 | const bRes = await L.takeFrom(snapB, TID, { holder: 'B', eid: 'eb' }, cas, () => 1001); |
| 1711 | check('syncCas: exactly one take wins through the adapter', (aRes.won ? 1 : 0) + (bRes.won ? 1 : 0) === 1); |
| 1712 | check('syncCas: the loser stood down (commit 409 -> adopt -> retry)', aRes.won === true && bRes.won === false); |
| 1713 | check('syncCas: the committed sync names the winner', sync.leases()[TID].holder === 'A'); |
| 1714 | } |
| 1715 | |
| 1716 | // ── Happy path: take -> reconstruct -> run -> push -> report -> complete -> ack -> release. ── |
| 1717 | { |
| 1718 | L.forget(); |
| 1719 | const sync = makeLeaseSync({}); |
| 1720 | let pushed = 0, report = null, acked = 0; |
| 1721 | const ctxChat = { id: 'chat-r', messages: [{ role: 'user', content: 'compute', mid: 'u1', ts: 1 }] }; |
| 1722 | const res = await P.runErrand(errand, { |
| 1723 | selfId: 'peerA', cas: P.syncCas(sync), now: () => 2000, |
| 1724 | reconstruct: async () => ({ chat: ctxChat }), |
| 1725 | runTurn: async (ctx, prompt, opts) => { |
| 1726 | await opts.onProgress(); // a journal event -> lease renew |
| 1727 | P.foldAssistant(ctx.chat, { mid: 'a1', turnId: TID, text: 'the answer is 42', ts: 3 }); |
| 1728 | }, |
| 1729 | abort: () => {}, |
| 1730 | pushResult: async () => { pushed += 1; return 9; }, |
| 1731 | post: async (rep) => { report = rep; }, |
| 1732 | ack: async () => { acked += 1; }, |
| 1733 | }); |
| 1734 | check('the runner completes the errand', res.ran === true && res.done === true); |
| 1735 | check('the runner order is take,reconstruct,run,push,report,complete,ack,release', |
| 1736 | res.trace.join(',') === 'take,reconstruct,run,push,report,complete,ack,release'); |
| 1737 | check('the answer was folded into the transcript', |
| 1738 | ctxChat.messages.some((m) => m.role === 'assistant' && m.content === 'the answer is 42')); |
| 1739 | check('the transcript was pushed exactly once', pushed === 1); |
| 1740 | check('a done report was posted carrying the pushed version', |
| 1741 | !!report && report.t === 'report' && report.status === 'done' && report.parcelVersion === 9); |
| 1742 | check('the errand was acked exactly once, AFTER the push', |
| 1743 | acked === 1 && res.trace.indexOf('ack') > res.trace.indexOf('push')); |
| 1744 | check('the lease ends released', sync.leases()[TID].mode === 'released'); |
| 1745 | } |
| 1746 | |
| 1747 | // ── Stand down: a peer already holds the lease, so the runner does not run. ── |
| 1748 | { |
| 1749 | L.forget(); |
| 1750 | const sync = makeLeaseSync({ [TID]: { turnId: TID, eid: 'other', holder: 'peerB', mode: 'running', expiry: 9e15, renewedAt: 1 } }); |
| 1751 | let touched = false, acked = 0; |
| 1752 | const res = await P.runErrand(errand, { |
| 1753 | selfId: 'peerA', cas: P.syncCas(sync), now: () => 2000, |
| 1754 | reconstruct: async () => { touched = true; return { chat: {} }; }, |
| 1755 | runTurn: async () => { touched = true; }, |
| 1756 | abort: () => {}, pushResult: async () => 1, post: async () => {}, ack: async () => { acked += 1; }, |
| 1757 | }); |
| 1758 | check('the runner STANDS DOWN when a peer holds the lease', res.ran === false); |
| 1759 | check('a stood-down runner never reconstructs or runs', touched === false && res.trace.join(',') === 'take'); |
| 1760 | check('a stood-down runner never acks', acked === 0); |
| 1761 | } |
| 1762 | |
| 1763 | // ── Revoke -> hard abort: the phone takes the turn back mid-run. ── |
| 1764 | { |
| 1765 | L.forget(); |
| 1766 | const sync = makeLeaseSync({}); |
| 1767 | const cas = P.syncCas(sync); |
| 1768 | const now = () => 1000; |
| 1769 | let abortFired = false, pushed = 0, acked = 0; |
| 1770 | const res = await P.runErrand(errand, { |
| 1771 | selfId: 'peerA', cas, now, |
| 1772 | reconstruct: async () => ({ chat: { id: 'chat-r', messages: [] } }), |
| 1773 | runTurn: async (ctx, prompt, opts) => { |
| 1774 | for (let i = 0; i < 6; i++) { |
| 1775 | if (i === 2) { await L.revoke(TID, cas, now); } // the phone's take-back |
| 1776 | await opts.onProgress(); |
| 1777 | if (abortFired) throw new Error('aborted by signal'); |
| 1778 | } |
| 1779 | }, |
| 1780 | abort: () => { abortFired = true; }, |
| 1781 | pushResult: async () => { pushed += 1; return 9; }, |
| 1782 | post: async () => {}, ack: async () => { acked += 1; }, |
| 1783 | }); |
| 1784 | check('a revoked lease HARD-ABORTS the turn', res.aborted === true && abortFired === true); |
| 1785 | check('an aborted run never pushes or acks (no double-bill commit)', pushed === 0 && acked === 0); |
| 1786 | check('the abort is recorded and the run never completed', |
| 1787 | res.trace.indexOf('abort') >= 0 && res.trace.indexOf('push') < 0); |
| 1788 | } |
| 1789 | |
| 1790 | // ── Crash before commit: no ack, so the errand is NOT dropped (step-2 gap closed). ── |
| 1791 | { |
| 1792 | L.forget(); |
| 1793 | const sync = makeLeaseSync({}); |
| 1794 | let acked = 0, pushed = 0; |
| 1795 | const res = await P.runErrand(errand, { |
| 1796 | selfId: 'peerA', cas: P.syncCas(sync), now: () => 2000, |
| 1797 | reconstruct: async () => ({ chat: { id: 'chat-r', messages: [] } }), |
| 1798 | runTurn: async () => { throw new Error('kaboom'); }, |
| 1799 | abort: () => {}, pushResult: async () => { pushed += 1; return 9; }, |
| 1800 | post: async () => {}, ack: async () => { acked += 1; }, |
| 1801 | }); |
| 1802 | check('a crash before commit is reported as an error', res.error === true); |
| 1803 | check('a crashed runner never acks (the errand survives on the relay to re-collect)', |
| 1804 | acked === 0 && pushed === 0); |
| 1805 | check('a crashed runner leaves the lease unreleased, to EXPIRE for the phone', |
| 1806 | !!sync.leases()[TID] && sync.leases()[TID].mode !== 'released'); |
| 1807 | } |
| 1808 | } |
| 1809 | |
| 1810 | // A compare-and-set that models /api/sync: one versioned {version, leases} blob. |
| 1811 | // `read()` hands back a COPY; `write(base, leases)` accepts ONLY when `base` is the |
| 1812 | // current version (then bumps it), else answers the current blob -- the 409. Copies |
| 1813 | // throughout, so a caller cannot mutate the server's state by holding a reference. |
| 1814 | function makeCas(initialLeases) { |
| 1815 | let version = 5; // an arbitrary non-zero base |
| 1816 | let leases = JSON.parse(JSON.stringify(initialLeases || {})); |
| 1817 | return { |
| 1818 | read: async () => ({ version, leases: JSON.parse(JSON.stringify(leases)) }), |
| 1819 | write: async (base, next) => { |
| 1820 | if (base !== version) return { ok: false, version, leases: JSON.parse(JSON.stringify(leases)) }; |
| 1821 | version += 1; |
| 1822 | leases = JSON.parse(JSON.stringify(next)); |
| 1823 | return { ok: true, version }; |
| 1824 | }, |
| 1825 | peekVersion: () => version, |
| 1826 | peekLeases: () => JSON.parse(JSON.stringify(leases)), |
| 1827 | }; |
| 1828 | } |
| 1829 | |
| 1830 | async function runLeaseAcceptance(L, check) { |
| 1831 | const TID = 'turn-race'; |
| 1832 | |
| 1833 | // ── The core race: A and B both read v5, then both attempt a TAKE. ── |
| 1834 | // B is given a strictly LATER clock than A, so a freshest-scalar merge would |
| 1835 | // hand B the turn -- which is precisely the double claim the mutation test |
| 1836 | // below forces. The correct rule ignores the clock across holders. |
| 1837 | { |
| 1838 | L.forget(); |
| 1839 | const cas = makeCas({}); |
| 1840 | const snapA = await cas.read(); // both based on the SAME version 5 |
| 1841 | const snapB = await cas.read(); |
| 1842 | const nowA = () => 1000; |
| 1843 | const nowB = () => 1001; // B's clock reads later |
| 1844 | const aRes = await L.takeFrom(snapA, TID, { holder: 'A', eid: 'eA' }, cas, nowA); |
| 1845 | const bRes = await L.takeFrom(snapB, TID, { holder: 'B', eid: 'eB' }, cas, nowB); |
| 1846 | |
| 1847 | check('device A wins the take (committed at the lower version)', aRes.won === true); |
| 1848 | check('device B STANDS DOWN (a live foreign lease beat its claim)', bRes.won === false); |
| 1849 | check('B was told who holds it', bRes.holder === 'A'); |
| 1850 | check('EXACTLY ONE device won -- no double run', (aRes.won ? 1 : 0) + (bRes.won ? 1 : 0) === 1); |
| 1851 | check('the committed parcel names A as the holder', cas.peekLeases()[TID].holder === 'A'); |
| 1852 | check('the version advanced exactly once (one claim committed)', cas.peekVersion() === 6); |
| 1853 | } |
| 1854 | |
| 1855 | // ── take-if-vacant keeps the winner when a fresh device adopts the parcel ── |
| 1856 | { |
| 1857 | const fresh = { forget: true }; // simulate a third device's local view via merge() |
| 1858 | // A third device, having pulled the parcel that names A, must keep A even if |
| 1859 | // it holds a stale local claim of its own for the same turn. |
| 1860 | const localClaimC = { [TID]: { turnId: TID, eid: 'eC', holder: 'C', mode: 'claimed', expiry: 9e15, renewedAt: 2000 } }; |
| 1861 | const parcelWithA = { [TID]: { turnId: TID, eid: 'eA', holder: 'A', mode: 'running', expiry: 9e15, renewedAt: 1000 } }; |
| 1862 | const merged = L.merge(localClaimC, parcelWithA, 3000); |
| 1863 | check('adopting a parcel that names A drops a local claim by C (incoming wins)', |
| 1864 | merged[TID].holder === 'A'); |
| 1865 | } |
| 1866 | |
| 1867 | // ── An expired lease is vacant and reclaimable. ── |
| 1868 | { |
| 1869 | L.forget(); |
| 1870 | const now = () => 100000; |
| 1871 | const dead = { [TID]: { turnId: TID, eid: 'eDead', holder: 'Dead', mode: 'running', expiry: 100000 - 1, renewedAt: 1 } }; |
| 1872 | const cas = makeCas(dead); |
| 1873 | const res = await L.take(TID, { holder: 'Reclaimer', eid: 'eR' }, cas, now); |
| 1874 | check('an EXPIRED lease is reclaimable (a new device takes it)', res.won === true); |
| 1875 | check('the reclaimer is now the holder', cas.peekLeases()[TID].holder === 'Reclaimer'); |
| 1876 | } |
| 1877 | |
| 1878 | // ── A renew keeps a live lease held against a would-be reclaimer. ── |
| 1879 | { |
| 1880 | L.forget(); |
| 1881 | let clock = 100000; |
| 1882 | const now = () => clock; |
| 1883 | const cas = makeCas({}); |
| 1884 | const held = await L.take(TID, { holder: 'Holder', eid: 'eH' }, cas, now); |
| 1885 | check('the holder takes the lease', held.won === true); |
| 1886 | // Time advances past the ORIGINAL expiry but the holder renews first. |
| 1887 | clock = 100000 + 40000; // > RENEW cadence, < TTL |
| 1888 | const rnw = await L.renew(TID, 'Holder', cas, now); |
| 1889 | check('the holder renews its live lease', rnw.ok === true); |
| 1890 | // A reclaimer tries at a moment past the ORIGINAL expiry but before the |
| 1891 | // RENEWED one -- it must stand down, because the renew kept the lease live. |
| 1892 | clock = 100000 + 95000; // past original 90s TTL, within the renewed one |
| 1893 | const late = await L.take(TID, { holder: 'LateComer', eid: 'eL' }, cas, now); |
| 1894 | check('a renew keeps the lease held (a late reclaimer stands down)', late.won === false); |
| 1895 | check('the late reclaimer was told the holder still holds it', late.holder === 'Holder'); |
| 1896 | |
| 1897 | // And once the holder RELEASES, the turn is reclaimable again. |
| 1898 | const rel = await L.release(TID, 'Holder', cas, now); |
| 1899 | check('the holder can release the lease', rel.ok === true); |
| 1900 | clock = 100000 + 96000; |
| 1901 | const after = await L.take(TID, { holder: 'NextUp', eid: 'eN' }, cas, now); |
| 1902 | check('after release the turn is reclaimable', after.won === true); |
| 1903 | } |
| 1904 | |
| 1905 | // ── HARDENING a: a far-future expiry (fast-clock holder) is CLAMPED. ── |
| 1906 | { |
| 1907 | L.forget(); |
| 1908 | const now = 1000000000000; // a realistic epoch-ms, past 2^31 |
| 1909 | const TTL = L.LEASE_TTL_MS; |
| 1910 | const farFuture = { [TID]: { turnId: TID, eid: 'eFast', holder: 'Fast', |
| 1911 | mode: 'running', expiry: now + 10 * TTL, renewedAt: now } }; |
| 1912 | const merged = L.merge({}, farFuture, now); |
| 1913 | check('an adopted far-future expiry is CLAMPED to now + TTL', |
| 1914 | merged[TID].expiry === now + TTL); |
| 1915 | // And so it is reclaimable at the NORMAL ttl, not parked for 10x it. |
| 1916 | const cas = makeCas(merged); |
| 1917 | const late = () => now + TTL + 1; |
| 1918 | const res = await L.take(TID, { holder: 'Rescuer', eid: 'eRes' }, cas, late); |
| 1919 | check('a clamped lease is reclaimable at the normal TTL (not parked)', res.won === true); |
| 1920 | } |
| 1921 | |
| 1922 | // ── HARDENING b: equal renewedAt, 'released' beats 'running'. ── |
| 1923 | { |
| 1924 | const t = 5000; |
| 1925 | const running = { turnId: TID, eid: 'e', holder: 'H', mode: 'running', expiry: 9e12, renewedAt: t }; |
| 1926 | const released = { turnId: TID, eid: 'e', holder: 'H', mode: 'released', expiry: 0, renewedAt: t }; |
| 1927 | check('equal renewedAt: released beats running (incoming released)', |
| 1928 | L.mergeOne(running, released, 100).mode === 'released'); |
| 1929 | check('equal renewedAt: released beats running (local released)', |
| 1930 | L.mergeOne(released, running, 100).mode === 'released'); |
| 1931 | } |
| 1932 | } |
| 1933 | |
| 1934 | // ── Remote consent for a handed-off turn ─────────────────── |
| 1935 | // |
| 1936 | // The two envelopes (round-trip + the exact-act binding), the forged/replayed-grant |
| 1937 | // defence, PARK reporting-then-releasing with a terminal fail at MAX_PARKS, the |
| 1938 | // GLOBAL two-device parkCount bound (the money guarantee), policy composition, and |
| 1939 | // attended-only routing. All against the REAL DaimondPeer under node. |
| 1940 | async function runRemoteConsentAcceptance(phone, laptop, stranger, check) { |
| 1941 | const P = phone.DaimondPeer; |
| 1942 | const Pl = laptop.DaimondPeer; |
| 1943 | const Ps = stranger.DaimondPeer; |
| 1944 | const MAX = P.MAX_PARKS; |
| 1945 | |
| 1946 | // ── A. The two envelopes: round-trip, exact-act binding, fresh cid. ── |
| 1947 | console.log('\nRemote consent — the ask/grant round-trip and the exact-act binding'); |
| 1948 | { |
| 1949 | const ask = P.makeAsk({ eid: 'e1', turnId: 't1', chatId: 'c1', tool: 'web_type', |
| 1950 | host: 'shop.test', detail: 'card 4111 1111 1111 1111', |
| 1951 | deadline: 1700000000000 + 60000, dispatchedBy: 'devPHONE' }); |
| 1952 | const askBody = await P.sealForSelf(ask); |
| 1953 | // The attended peer (same account) opens AND verifies the runner's question. |
| 1954 | const opened = await Pl.openEnvelope(askBody.envelope); |
| 1955 | check('consent-ask opens on an attended peer with tool/host/detail intact (uncut)', |
| 1956 | opened.t === 'consent-ask' && opened.tool === 'web_type' && opened.host === 'shop.test' |
| 1957 | && opened.detail === 'card 4111 1111 1111 1111' && opened.turnId === 't1' |
| 1958 | && opened.cid === ask.cid && opened.dispatchedBy === 'devPHONE'); |
| 1959 | |
| 1960 | // The attended device answers: a grant naming the SAME cid. |
| 1961 | const grant = Pl.makeGrant({ cid: ask.cid, eid: 'e1', turnId: 't1', verdict: 'allow', by: 'devLAPTOP' }); |
| 1962 | const grantBody = await Pl.sealForSelf(grant); |
| 1963 | const gOpened = await P.openEnvelope(grantBody.envelope); |
| 1964 | check('consent-grant opens on the runner with the verdict and cid intact', |
| 1965 | gOpened.t === 'consent-grant' && gOpened.verdict === 'allow' |
| 1966 | && gOpened.cid === ask.cid && gOpened.turnId === 't1'); |
| 1967 | check('the grant does NOT carry tool/host/detail (runner replays the EXACT held act, never re-derives)', |
| 1968 | gOpened.tool === undefined && gOpened.host === undefined && gOpened.detail === undefined); |
| 1969 | |
| 1970 | const ask2 = P.makeAsk({ turnId: 't1', tool: 'web_type' }); |
| 1971 | check('a FRESH cid is minted on every ask (a replayed grant matches no new cid)', |
| 1972 | ask2.cid && ask2.cid !== ask.cid); |
| 1973 | } |
| 1974 | |
| 1975 | // ── B. Forged / replayed grant rejected. ── |
| 1976 | console.log('\nRemote consent — a forged or replayed grant authorises nothing'); |
| 1977 | { |
| 1978 | // A STRANGER (different account) seals a grant; the runner cannot open it (it |
| 1979 | // is sealed to another account), and were the bytes forced in the signature is |
| 1980 | // not this account's -- either way it is refused before it reaches deliverGrant. |
| 1981 | const strangerGrant = Ps.makeGrant({ cid: 'deadbeef', turnId: 't1', verdict: 'allow', by: 'devEVIL' }); |
| 1982 | const sBody = await Ps.sealForSelf(strangerGrant); |
| 1983 | let why = ''; |
| 1984 | try { await P.openEnvelope(sBody.envelope); } |
| 1985 | catch (e) { why = String(e && e.message || e); } |
| 1986 | check('a grant sealed by a STRANGER account is refused by the runner (open/verify throws)', |
| 1987 | /not sealed|not signed|not for this device/i.test(why)); |
| 1988 | |
| 1989 | // The spent-cid drop mirrors daimond.js `deliverGrant`: only an OUTSTANDING cid |
| 1990 | // resolves a waiting runner, and it is spent on first use, so a captured grant |
| 1991 | // replayed after the act ran authorises nothing. |
| 1992 | const outstanding = Object.create(null); |
| 1993 | outstanding['cidLIVE'] = { turnId: 't1' }; |
| 1994 | function deliver(g) { |
| 1995 | const w = outstanding[g.cid]; |
| 1996 | if (!w) return 'dropped'; // spent / unknown / replayed |
| 1997 | if (String(g.turnId) !== String(w.turnId)) return 'dropped'; |
| 1998 | delete outstanding[g.cid]; // spend the cid |
| 1999 | return 'resolved'; |
| 2000 | } |
| 2001 | check('a grant for an OUTSTANDING cid resolves the waiting runner exactly once', |
| 2002 | deliver({ cid: 'cidLIVE', turnId: 't1', verdict: 'allow' }) === 'resolved'); |
| 2003 | check('the SAME grant REPLAYED after the cid is spent is dropped (authorises nothing)', |
| 2004 | deliver({ cid: 'cidLIVE', turnId: 't1', verdict: 'allow' }) === 'dropped'); |
| 2005 | check('a grant for an UNKNOWN cid is dropped', |
| 2006 | deliver({ cid: 'nope', turnId: 't1', verdict: 'allow' }) === 'dropped'); |
| 2007 | } |
| 2008 | |
| 2009 | // The deps a park test runs an errand over: the turn SPENDS (increments runCount) |
| 2010 | // then egressAllowed aborts it for consent (runTurn throws), and parkRequested |
| 2011 | // tells runErrand to park rather than treat the abort as a crash. |
| 2012 | function parkDeps(sync, selfId, reports, runCounter, terminalGuard) { |
| 2013 | return { |
| 2014 | selfId, cas: P.syncCas(sync), now: () => 5000, maxParks: MAX, |
| 2015 | // FINISHED stands a device down once a TERMINAL report exists, mirroring |
| 2016 | // daimond.js: a device that collects the aborted report must not respend a |
| 2017 | // turn that already failed clean. Absent otherwise. |
| 2018 | finished: terminalGuard ? (async () => terminalGuard()) : undefined, |
| 2019 | reconstruct: async () => ({ chat: { id: 'c', messages: [] }, app: {} }), |
| 2020 | runTurn: async () => { if (runCounter) runCounter.n += 1; throw new Error('aborted for consent'); }, |
| 2021 | parkRequested: () => ({ why: null }), // let the default sentences stand |
| 2022 | abort: () => {}, pushResult: async () => 1, |
| 2023 | post: async (r) => { reports.push(r); }, ack: async () => {}, |
| 2024 | }; |
| 2025 | } |
| 2026 | |
| 2027 | // ── C. PARK reports then releases; parked below the bound, terminal at it. ── |
| 2028 | console.log('\nRemote consent — PARK reports then releases; terminal at MAX_PARKS'); |
| 2029 | { |
| 2030 | phone.DaimondLease.forget(); |
| 2031 | const sync = makeLeaseSync({}); |
| 2032 | const reports = [], rc = { n: 0 }; |
| 2033 | const errand = P.makeErrand({ turnId: 't-park', chatId: 'c', prompt: 'go', eid: 'e', deadline: 9e15, parkCount: 0 }); |
| 2034 | const res = await P.runErrand(errand, parkDeps(sync, 'runnerDev', reports, rc)); |
| 2035 | check('a park below the bound is NOT terminal', res.parked === true && res.terminal === false); |
| 2036 | check('the turn spent exactly once before parking', rc.n === 1); |
| 2037 | check('a park posts a PARKED report carrying the bumped GLOBAL count', |
| 2038 | reports.length === 1 && reports[0].status === 'parked' && reports[0].parkCount === 1); |
| 2039 | check('a park RELEASES the lease (not stranded)', sync.leases()['t-park'].mode === 'released'); |
| 2040 | check('a park REPORTS before it RELEASES (mirror the reconstruct-fail order)', |
| 2041 | res.trace.indexOf('report') < res.trace.indexOf('release')); |
| 2042 | |
| 2043 | phone.DaimondLease.forget(); |
| 2044 | const sync2 = makeLeaseSync({}); |
| 2045 | const reports2 = [], rc2 = { n: 0 }; |
| 2046 | const errandN = P.makeErrand({ turnId: 't-term', chatId: 'c', prompt: 'go', eid: 'e2', deadline: 9e15, parkCount: MAX - 1 }); |
| 2047 | const res2 = await P.runErrand(errandN, parkDeps(sync2, 'runnerDev', reports2, rc2)); |
| 2048 | check('a park AT the bound is TERMINAL', res2.terminal === true && res2.parked === false); |
| 2049 | check('the terminal park posts an ABORTED report, the count at MAX_PARKS', |
| 2050 | reports2.length === 1 && reports2[0].status === 'aborted' && reports2[0].parkCount === MAX); |
| 2051 | check('the terminal report INFORMS the user (permission / did not run)', |
| 2052 | /permission/i.test(reports2[0].why) && /did not run/i.test(reports2[0].why)); |
| 2053 | check('the terminal park RELEASES the lease (never stranded)', sync2.leases()['t-term'].mode === 'released'); |
| 2054 | check('the terminal park REPORTS before it RELEASES', |
| 2055 | res2.trace.indexOf('report') < res2.trace.indexOf('release')); |
| 2056 | } |
| 2057 | |
| 2058 | // ── D. GLOBAL parkCount: two devices re-dispatching cannot exceed MAX_PARKS. ── |
| 2059 | // |
| 2060 | // The money guarantee. The count rides the errand and the parked report (SYNCED), |
| 2061 | // so a re-dispatch bumps from the true GLOBAL total, and the single-runner lease |
| 2062 | // plus the terminal-report stand-down cap the respend at MAX_PARKS -- NOT MAX_PARKS |
| 2063 | // per device, which a device-local counter would have allowed. |
| 2064 | console.log('\nRemote consent — GLOBAL parkCount: two devices cannot exceed MAX_PARKS total'); |
| 2065 | { |
| 2066 | phone.DaimondLease.forget(); laptop.DaimondLease.forget(); |
| 2067 | const sync = makeLeaseSync({}); |
| 2068 | const reports = [], rc = { n: 0 }; |
| 2069 | const TID = 't-global'; |
| 2070 | const terminal = () => reports.some((r) => r.turnId === TID && r.status === 'aborted'); |
| 2071 | |
| 2072 | // Dispatch #1 (parkCount 0): one runner spends and parks -> parked report, count 1. |
| 2073 | const r1 = await Pl.runErrand( |
| 2074 | P.makeErrand({ turnId: TID, chatId: 'c', prompt: 'go', eid: 'e0', deadline: 9e15, parkCount: 0 }), |
| 2075 | parkDeps(sync, 'lapDev', reports, rc, terminal)); |
| 2076 | check('two-device: the first dispatch spends once and parks (GLOBAL count -> 1)', |
| 2077 | r1.parked === true && rc.n === 1 && reports[reports.length - 1].parkCount === 1); |
| 2078 | |
| 2079 | // BOTH devices read the SAME parked report's count and re-dispatch errand(parkCount 1) |
| 2080 | // for the SAME turnId, over the ONE shared lease. Exactly one re-runs; the other |
| 2081 | // stands down on the terminal report the winner posts. |
| 2082 | const carried = reports[reports.length - 1].parkCount; // the GLOBAL count off the synced report |
| 2083 | check('two-device: the re-dispatch reads the count off the SYNCED report (not a device-local zero)', |
| 2084 | carried === 1); |
| 2085 | const rA = await phone.DaimondPeer.runErrand( |
| 2086 | P.makeErrand({ turnId: TID, chatId: 'c', prompt: 'go', eid: 'eA', deadline: 9e15, parkCount: carried }), |
| 2087 | parkDeps(sync, 'phoneDev', reports, rc, terminal)); |
| 2088 | const rB = await laptop.DaimondPeer.runErrand( |
| 2089 | P.makeErrand({ turnId: TID, chatId: 'c', prompt: 'go', eid: 'eB', deadline: 9e15, parkCount: carried }), |
| 2090 | parkDeps(sync, 'lap2Dev', reports, rc, terminal)); |
| 2091 | check('two-device: exactly ONE re-run spends; the other STANDS DOWN on the terminal report', |
| 2092 | rc.n === 2 && (rA.ran === false || rB.ran === false)); |
| 2093 | check('two-device: the re-run is TERMINAL at MAX_PARKS (' + MAX + ')', |
| 2094 | (rA.terminal || rB.terminal) === true |
| 2095 | && reports.some((r) => r.status === 'aborted' && r.parkCount === MAX)); |
| 2096 | check('two-device: TOTAL respends never exceed MAX_PARKS (' + MAX + ') — the spend cap holds', |
| 2097 | rc.n <= MAX); |
| 2098 | check('two-device: the lease is RELEASED after the loop (not stranded)', |
| 2099 | sync.leases()[TID].mode === 'released'); |
| 2100 | } |
| 2101 | |
| 2102 | // ── E. Policy composition: a covered act never asks. ── |
| 2103 | console.log('\nRemote consent — policy composition: a covered act never asks'); |
| 2104 | { |
| 2105 | const now = 1700000000000; |
| 2106 | const attended = { lap: { name: 'lap', lastSeen: now - 1000, attended: true, attendedAt: now - 1000 } }; |
| 2107 | const cov = P.consentRouteDecision(attended, 'self', now, { covered: true }); |
| 2108 | check('a COVERED act resolves ALLOW with no ask (composes with the synced policy)', |
| 2109 | cov.action === 'allow' && cov.verdict === 'allow'); |
| 2110 | check('a covered act never asks even with an attended peer present', |
| 2111 | P.consentRouteDecision(attended, 'self', now, { covered: true }).action !== 'ask'); |
| 2112 | const unc = P.consentRouteDecision(attended, 'self', now, { covered: false }); |
| 2113 | check('an UNCOVERED act with an attended peer ASKS that peer', |
| 2114 | unc.action === 'ask' && unc.peer && unc.peer.deviceId === 'lap'); |
| 2115 | } |
| 2116 | |
| 2117 | // ── F. Attended-only routing: an awake-but-unwatched device parks. ── |
| 2118 | console.log('\nRemote consent — attended-only routing; an awake-but-unwatched device parks'); |
| 2119 | { |
| 2120 | const now = 1700000000000; |
| 2121 | const awakeNotAttended = { lap: { name: 'lap', lastSeen: now - 1000, attended: false, attendedAt: 0 } }; |
| 2122 | check('an AWAKE but unattended peer is NOT asked -> park', |
| 2123 | P.consentRouteDecision(awakeNotAttended, 'self', now, { covered: false }).action === 'park'); |
| 2124 | check('attendedPeer is null for an awake-but-unattended map', |
| 2125 | P.attendedPeer(awakeNotAttended, 'self', now) === null); |
| 2126 | const attendedFresh = { lap: { name: 'lap', lastSeen: now - 1000, attended: true, attendedAt: now - 1000 } }; |
| 2127 | check('a FRESH attended peer is routable', |
| 2128 | P.attendedPeer(attendedFresh, 'self', now).deviceId === 'lap'); |
| 2129 | const attendedStale = { lap: { name: 'lap', lastSeen: now - 1000, attended: true, attendedAt: now - 10 * 60 * 1000 } }; |
| 2130 | check('an attended peer whose attention has AGED OUT is not routable -> park', |
| 2131 | P.consentRouteDecision(attendedStale, 'self', now, { covered: false }).action === 'park'); |
| 2132 | const indeterminate = { lap: { name: 'lap', lastSeen: now - 1000 } }; // no attention signal |
| 2133 | check('attention-INDETERMINATE (no signal) fails SAFE to park', |
| 2134 | P.consentRouteDecision(indeterminate, 'self', now, { covered: false }).action === 'park'); |
| 2135 | } |
| 2136 | } |
| 2137 | |
| 2138 | /// The mock model turn. `runTurn` (daimond.js:17552) is the real engine the peer |
| 2139 | /// runs; step 1 stands a deterministic answer in for the provider call, because |
| 2140 | /// the seam -- not the model -- is what is under test. |
| 2141 | function mockRunTurn(prompt) { |
| 2142 | return 'The answer to "' + prompt + '" is 4.'; |
| 2143 | } |
| 2144 | |
| 2145 | main().catch((e) => { console.error('test crashed:', e); process.exitCode = 1; }); |