fix(pet): wait for autosave before archiving a source change

Queue explicit browser archives after an in-flight save so changing sources
retains inputs accepted during that save. Preserve the cancel path for an actual
save failure. Align Android checkpoint clock, phase and jitter bounds with the
shared core, and show browser replay times in hours for long segments.

Validation: pet typecheck/build and 44 tests passed/0 failed. The previous
5c70db4 controller fails the new autosave overlap regression at the expected
false save-failure prompt; its real-failure cancellation control passes.
Android assemble/lint and 9 device tests passed/0 failed, including long-clock
native import and continuation. Browser reload displays 30:00:00 and restores
its segment. npm test: 66 package + 12 SDK + 446 web passed/0 failed;
npm run check:web passed with 2 existing warnings.
This commit is contained in:
CodeWhale Bot committed 2026-09-12 11:50:27 -07:00
1 parent 5c70db41f0
commit 45a1130c19
7 files changed
+134 -14

No files matched your search

+11 -5
View File
@@ -17,11 +17,11 @@ npm test
npm run check:web
```
The pet suite currently contains 42 tests: event occupancy/unknown coverage,
The pet suite currently contains 44 tests: event occupancy/unknown coverage,
late failures, human request pairing, read-only local SSE reconnect/cursor
recovery and cancellation after garbage collection, deterministic world/score/PCM,
checkpoint integrity and continuation, immutable segment boundaries, pending input
retention, expression-version validation and legacy replay.
retention, browser saves overlapping source changes, expression-version validation and legacy replay.
The standalone verifier compares 380 checkpoints across baseline and edge tapes,
each animated and still, under expression versions 1 and 2. Version 1 must also
match pinned pre-transformation golden digests. Omission of Swift is explicit in its output.
@@ -75,7 +75,7 @@ cd pet/android
```
Use JDK 17 and Android SDK 35; connect a device/emulator for the second command.
The eight instrumentation tests run the real QuickJS binding, Kotlin renderer,
The nine instrumentation tests run the real QuickJS binding, Kotlin renderer,
PCM cursor, storage/recovery, immutable segments and Compose lifecycle. See [Android](android/README.md).
## Review direction
@@ -89,7 +89,7 @@ open junctions; the same particle identities return to the whale at rest.
## Local evidence before publication (2026-09-12)
- Packaged pet: 42 tests passed; existing Whalesong consumers: 306 passed after
- Packaged pet: 44 tests passed; existing Whalesong consumers: 306 passed after
canonical source relocation. These are overlapping suites, not additive coverage.
- Product Node gate: 66 package, 12 SDK and 446 web tests passed; production web
check subsequently passed with the GitHub release fetch available.
@@ -103,7 +103,7 @@ open junctions; the same particle identities return to the whale at rest.
and the applicable safety/security checks. Each later commit needs its own verdict.
- Apple: ten checkpoint workflows passed; iOS Simulator exercised two-hour
restoration and visible corruption recovery without overwriting the damaged file.
- Android: debug APK and lint build pass; eight instrumentation tests pass on
- Android: debug APK and lint build pass; nine instrumentation tests pass on
Android 15 ARM64, including 4,800 frames, checkpoint continuation, exact PCM,
lifecycle and storage recovery. The emulator audio sink runs without host
speaker output; this does not establish physical listening or power quality.
@@ -122,11 +122,17 @@ open junctions; the same particle identities return to the whale at rest.
5.64 MB and continues from a 0.31 MB active habitat; a failed save keeps the
live world intact. Android rotates 4,096 applied inputs, rejects a competing
writer, restores exact continuation and detects a changed archive.
A constructed long-clock checkpoint also checks the Android renderer's
phase/clock/jitter bounds against the shared core and continues in both engines.
- Browser: changing sources and reopening an earlier imported recording restores
its pose and full future timeline. Earlier recordings remain available after
reload. A 30-hour segment seeks from its retained origin through its final
checkpoint with Still enabled. iOS Simulator exports an earlier recording
through Files and reports a successful save.
- Browser save ordering: controller tests defer storage completion while changing
worlds and accepting an input. The previous `5c70db4` controller fails by
treating the pending save as a failure; the correction waits, archives the new
input, and still preserves the source when a real failed save is canceled.
- Authenticated read-only attachment to an existing local Runtime 0.9.13 session
journal passed. An 18-second recorder run produced 45 contiguous unknown buckets
and stopped cleanly in 41 ms after Ctrl+C, without treating historical work as
+2
View File
@@ -105,6 +105,8 @@ holes are rejected; reconnects resume from the last accepted Runtime cursor.
The recorder seals the preceding observation interval against a fixed clock.
It retains request lifetimes and counts a delayed error once at receipt time.
Raw prompts, arguments, results and tokens do not enter the pet recording.
The standalone recorder currently stops after 24 hours per JSONL file; its
continuous native-companion handoff remains part of the live-attachment work.
The thin wire is JSONL, one flat version-1 `PetBucket` per line: PetState plus
`sequence`, `simTimeMs`, `durationMs`, thirteen-element `onsets` and `activeMs`,
+4 -2
View File
@@ -57,6 +57,8 @@ fun archOf(key: String) = when (key) {
private val UNKNOWN_RGB = doubleArrayOf(115.0, 132.0, 146.0)
private val REST_RGB = doubleArrayOf(122.0, 214.0, 240.0)
// Keep the native checkpoint boundary aligned with the shared world clock.
private const val PET_MAX_SECONDS = 100.0 * 365.0 * 86_400.0
private fun lerp(a: Double, b: Double, t: Double) = a + (b - a) * t
private fun clamp(v: Double, lo: Double = 0.0, hi: Double = 1.0) = min(hi, max(lo, v))
@@ -277,9 +279,9 @@ class PetSim(points: List<Pair<Double, Double>>, seed: Int = 0xC0FFEE.toInt(), e
require(c.version == 1 && c.expressionVersion in 1..2 && c.body.size == p.size && c.particles.size == p.size)
require(c.body.withIndex().all { (i, b) -> b == listOf(p[i].hx, p[i].hy, p[i].s) })
require(c.particles.all { row -> row.size == 8 && row.withIndex().all { (i, v) ->
finite(v, if (i == 4 || i == 5) 0.0 else -8.0, if (i == 4 || i == 5) 1_000_000.0 else 8.0)
finite(v, if (i == 4 || i == 5) 0.0 else -8.0, if (i == 4 || i == 5) 2 * PET_MAX_SECONDS else 8.0)
} })
require(finite(c.phase, 0.0, 100_000.0) && finite(c.clock, 0.0, 86_400.0) && finite(c.tear, 0.0, 1.0))
require(finite(c.phase, 0.0, PET_MAX_SECONDS) && finite(c.clock, 0.0, PET_MAX_SECONDS) && finite(c.tear, 0.0, 1.0))
require(c.previous in CHANNELS.indices && c.current in CHANNELS.indices && c.color.size == 3 && c.color.all { finite(it, 0.0, 255.0) })
require(listOf(c.frame.r, c.frame.g, c.frame.b).all { finite(it, 0.0, 255.0) } && finite(c.frame.alpha, 0.0, 1.0) && finite(c.frame.work, 0.0, 1.0))
require(c.frame.channel == CHANNELS[c.current].key && c.frame.arch == CHANNELS[c.current].arch)
+3 -2
View File
@@ -51,10 +51,11 @@ cover that binding's older runtime. There are no Java host bindings or remote
script loads. Gradle packages `../ios/Resources/pet-native.js` and its demo
directly; run `npm --prefix pet run sync` after changing the canonical core.
Eight instrumentation tests exercise the real embedded engine, 4,800 shared
Nine instrumentation tests exercise the real embedded engine, 4,800 shared
world frames and Kotlin digests, 3,000 additional checkpoint continuation
frames, version/import boundaries, sample-exact PCM, atomic storage/recovery,
the Compose pause/still/audio/background lifecycle, and recovery export after
a store conflict or with 90,000 pending interactions beyond the autosave limit.
They also check segment publication, exact continuation and archive corruption. The separate
They also check segment publication, exact continuation, archive corruption and
native restoration of long particle clocks beyond the former 24-hour limit. The separate
`verify.sh` retains all 380 pure Kotlin conformance checkpoints.
@@ -96,6 +96,24 @@ class PetNativeTest {
}
}
@Test fun longParticleClocksRestoreAndContinueInBothEngines() {
// Construct a long-clock boundary fixture; this is not a 30-hour device run.
core().use { original ->
val saved = JSONObject(original.recording())
val particle = saved.getJSONObject("checkpoint").getJSONObject("sim")
particle.put("clock", 108_000.0).put("phase", 108_000.0)
particle.getJSONArray("particles").getJSONArray(0).put(4, 1_000_001.0)
core(saved.toString()).use { restored ->
repeat(60) { assertEquals(restored.tick(true).digest, petDigest(restored.sim)) }
core(restored.recording()).use { again ->
repeat(60) { assertEquals(restored.tick(true).digest, again.tick(true).digest) }
}
}
particle.put("clock", 100.0 * 365.0 * 86_400.0 + 1.0)
assertThrows(Exception::class.java) { core(saved.toString()).close() }
}
}
@Test fun exportRetainsTheExactUnsavedVisitAfterAStoreConflict() {
val directory = File(context.cacheDir, "pet-export-${java.util.UUID.randomUUID()}").apply { mkdirs() }
try {
+16 -5
View File
@@ -19,11 +19,14 @@ let world: PetWorld, seconds = 0, last = 0, accumulator = 0, paused = false;
let restoring = false, generation = 0, liveGeneration = 0, liveTimer = 0;
let imported: { world: PetWorld; name: string } | undefined;
const library = new TraceLibrary();
let savedRevision: number | undefined, saving = false, persistenceReady = false, persistenceFailed = false;
let savedRevision: number | undefined, saving: Promise<boolean> | undefined, persistenceReady = false, persistenceFailed = false;
let audio: AudioContext | undefined, anchor = 0, sound = false;
const playing = new Set<AudioBufferSourceNode>();
const duration = () => Number(seek.max);
const timeLabel = (n: number) => `${Math.floor(n / 60)}:${String(Math.floor(n % 60)).padStart(2, '0')}`;
const timeLabel = (n: number) => {
const minutes = Math.floor(n / 60), seconds = String(Math.floor(n % 60)).padStart(2, '0');
return minutes < 60 ? `${minutes}:${seconds}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}:${seconds}`;
};
function silence() { for (const node of playing) { try { node.stop(); } catch { /* Already ended. */ } } playing.clear(); }
function play(voices: readonly PetVoice[]) {
@@ -74,8 +77,17 @@ async function refreshArchives() {
for (const entry of entries) list.add(new Option(`${entry.name} · ${timeLabel(entry.startSeconds)}–${timeLabel(entry.seconds)}`, String(entry.key)));
}
async function persist(archiveCurrent = false): Promise<boolean> {
if (!world || restoring || saving || !persistenceReady || persistenceFailed) return false;
saving = true;
if (saving) {
if (!archiveCurrent) return saving;
await saving;
return persist(true);
}
if (!world || restoring || !persistenceReady || persistenceFailed) return false;
const task = saveCurrent(archiveCurrent); saving = task;
try { return await task; }
finally { if (saving === task) saving = undefined; }
}
async function saveCurrent(archiveCurrent: boolean): Promise<boolean> {
try {
const owner = world;
const snapshot: SavedHabitat = { petPersistenceVersion: 1, seconds: owner.frame.timeMs / 1000,
@@ -94,7 +106,6 @@ async function persist(archiveCurrent = false): Promise<boolean> {
return true;
}
catch (error) { persistenceFailed = true; get('persistence').textContent = error instanceof Error ? error.message : 'Unable to save the habitat. Save a replay file to keep it.'; return false; }
finally { saving = false; }
}
async function mayLeave(): Promise<boolean> {
return await persist(true) || window.confirm('This visit could not be saved. Cancel to keep it and export a replay, or leave without saving its latest progress.');
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
import * as world from '../dist/core/pet-world.js';
import * as telemetry from '../dist/core/pet-telemetry.js';
import * as demo from '../dist/core/pet-demo.js';
import * as ingest from '../dist/core/ingest.js';
import * as sim from '../dist/core/pet-sim.js';
import * as audio from '../dist/core/pet-audio.js';
/** Run the compiled browser controller and real world with minimal DOM sinks.
* Only storage completion is controlled: this tests the async caller contract,
* not IndexedDB's transaction implementation or browser rendering. */
async function browser() {
const nodes = new Map(), saves = [], intervals = [];
let complete, fail, confirmations = 0;
const context = new Proxy({}, { get: (object, key) => object[key] ?? (() => {}) });
const node = id => {
if (!nodes.has(id)) nodes.set(id, { value: id === 'mode' ? 'wild' : '', textContent: '', checked: false,
clientWidth: 800, clientHeight: 400, getContext: () => context,
setAttribute() {}, addEventListener() {}, replaceChildren() {}, add() {},
querySelector: () => node('replay-option'), getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 400 }) });
return nodes.get(id);
};
class TraceLibrary {
async getHabitat() { return undefined; }
async petArchives() { return []; }
saveHabitat(habitat, revision, archive) {
saves.push(structuredClone({ habitat, revision, archive }));
if (saves.length === 1) return new Promise((resolve, reject) => { complete = resolve; fail = reject; });
return Promise.resolve(saves.length);
}
}
const points = await readFile(new URL('../public/whale-points.tsv', import.meta.url), 'utf8');
// Imports bind to the real core above, while the controller body stays intact.
const controller = (await readFile(new URL('../dist/ui/pet.js', import.meta.url), 'utf8')).replace(/^import .*;\r?\n/gm, '');
const environment = { ...world, ...telemetry, ...demo, ...ingest, ...sim, ...audio, TraceLibrary,
document: { hidden: false, getElementById: node, addEventListener() {} },
window: { addEventListener() {}, confirm() { confirmations++; return false; } },
matchMedia: () => ({ matches: false, addEventListener() {} }),
fetch: async () => ({ ok: true, text: async () => points }),
Option: class {}, requestAnimationFrame() {}, setInterval: callback => intervals.push(callback),
setTimeout, clearTimeout, structuredClone, TextEncoder, devicePixelRatio: 1 };
await vm.runInNewContext(`(async () => { ${controller}\n })()`, environment);
assert.equal(intervals.length, 1, 'Controller starts its autosave after loading');
return { node, saves, autosave: intervals[0], complete: () => complete(1), fail: () => fail(new Error('Disk unavailable')),
confirmations: () => confirmations };
}
test('browser source change waits for autosave and archives inputs accepted during that save', async () => {
const page = await browser();
page.autosave();
assert.equal(page.saves.length, 1);
page.node('feed').onclick();
page.node('mode').value = 'demo';
let finished = false;
const changing = page.node('mode').onchange().then(() => { finished = true; });
await new Promise(resolve => setImmediate(resolve));
assert.equal(page.confirmations(), 0, 'An in-flight save is not a failed save');
assert.equal(finished, false);
assert.equal(page.saves.length, 1, 'Wait for the current revision before archiving');
page.complete(); await changing;
assert.equal(page.saves.length, 2);
assert.equal(page.saves[1].revision, 1);
assert.equal(page.saves[1].archive.interactions.length, 1);
assert.equal(page.saves[1].archive.interactions[0].kind, 'food');
assert.equal(page.saves[1].archive.source, 'wild');
assert.equal(page.node('mode').value, 'demo');
});
test('browser failed autosave asks before leaving and cancel preserves the current source', async () => {
const page = await browser();
page.autosave(); page.node('mode').value = 'demo';
const changing = page.node('mode').onchange();
page.fail(); await changing;
assert.equal(page.confirmations(), 1);
assert.equal(page.node('mode').value, 'wild');
assert.equal(page.saves.length, 1, 'No archive or replacement after the failed write');
});