mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-11-06 06:00:28 +00:00
fix(missions): enforce current body provenance at shared promotion
The guarded completion path rejected stale pending content, but need() could fall through to the generic coverage-only promote helper and display that same rejected value. A held B prefix could replace last-good A after the expected descriptor had advanced to C or the entry was invalidated. Apply one current-value eligibility rule at the sole pending-to-display assignment: non-stale entry, exact expected digest, current metadata mark and a live original operation for pending data. Reuse it in completion; all accept, need, pump and revalidation paths now reach the same guard. Discard unpromoted current data when its operation ends. Keep ordinary one-time policy and last-good display unchanged. The original twenty-case matrix and eighteen actual producer/native/API/ reader-helper cases retain A through descriptor/invalidation contractions, release and need extension. Fresh eligible B still promotes, outstanding peers keep their original budget, and explicit Retry observes C. Removing only the central guard reproduces thirty-four intended failures; restored source hashes match. UI types, actual production build, 255 scoped unit/contracts and 129 serial affected-browser checks pass. Original real 30-second semantic deadlines and both 31-second page-back regressions remain green. No server, native, transport, dependency or public-text changes.
This commit is contained in:
parent
9a734f3ccd
commit
a7ad14f06a
3 changed files with 215 additions and 10 deletions
|
|
@ -591,8 +591,11 @@ fulfilled demand ends its operation; a later user extension or explicit Retry ca
|
|||
start a fresh one. Need reductions and consumer releases reconsider aggregate coverage
|
||||
even during a running continuation: fresh covered demand ends that read immediately,
|
||||
while stale/invalidated data and outstanding peers keep their original operation.
|
||||
Pending-value promotion retains its deadline check. Failed outstanding operations retain
|
||||
their budget through automatic revalidation. Technical details exposes the scoped persisted-field/raw-page reader for
|
||||
Every current pending promotion shares the store's stale/mark, exact expected digest and
|
||||
live-operation deadline eligibility, including accept, demand changes and completion.
|
||||
Ending an operation discards unpromoted current data and retains its last good display.
|
||||
Failed outstanding operations retain their budget through automatic revalidation.
|
||||
Technical details exposes the scoped persisted-field/raw-page reader for
|
||||
compact current maps; archived daily readers retain their receipt-bound sections.
|
||||
|
||||
## Settlement, result and watched cursors
|
||||
|
|
|
|||
|
|
@ -102,9 +102,15 @@ export function createMissionContentStore(fetch: MissionContentFetch, budget = M
|
|||
return !body || satisfies(entry, body.digest, body.revision) && marked(entry, body.mark)
|
||||
}
|
||||
|
||||
/** Current display provenance; pending values additionally require their live operation. */
|
||||
const currentValueEligible = (entry: Entry, body: MissionBody) => !entry.stale
|
||||
&& (!entry.expected || body.digest === entry.expected.digest) && marked(entry, body.mark)
|
||||
&& (body !== entry.pending || Boolean(entry.operation && !entry.operation.signal.aborted && performance.now() < entry.operation.deadline))
|
||||
|
||||
const endOperation = (entry: Entry) => {
|
||||
entry.operation?.controller.abort()
|
||||
entry.operation = undefined
|
||||
if (entry.ref.passage) entry.pending = undefined
|
||||
}
|
||||
const cancel = (entry: Entry) => {
|
||||
entry.controller?.abort()
|
||||
|
|
@ -126,10 +132,11 @@ export function createMissionContentStore(fetch: MissionContentFetch, budget = M
|
|||
}
|
||||
|
||||
const promote = (entry: Entry) => {
|
||||
if (entry.pending && (!entry.display || covers(entry.pending, need(entry)))) {
|
||||
entry.display = entry.pending
|
||||
entry.pending = undefined
|
||||
}
|
||||
if (!entry.pending || entry.display && !covers(entry.pending, need(entry))) return false
|
||||
if (entry.ref.passage && !currentValueEligible(entry, entry.pending)) return false
|
||||
entry.display = entry.pending
|
||||
entry.pending = undefined
|
||||
return true
|
||||
}
|
||||
|
||||
const accept = (entry: Entry, query: { digest?: string; offset: number }, response: MissionContentResponse, mark: string | undefined) => {
|
||||
|
|
@ -247,10 +254,8 @@ export function createMissionContentStore(fetch: MissionContentFetch, budget = M
|
|||
if (!entry.consumers.size || entries.get(entry.key) !== entry) return
|
||||
const shown = entry.pending ?? entry.display
|
||||
// Current aggregate demand can finish while a now-unnecessary continuation is held.
|
||||
if (entry.ref.passage && !entry.stale && shown && fresh(entry) && covers(shown, need(entry))
|
||||
&& (!entry.expected || shown.digest === entry.expected.digest)
|
||||
&& (!entry.pending || !entry.operation || !entry.operation.signal.aborted && performance.now() < entry.operation.deadline)) {
|
||||
promote(entry)
|
||||
if (entry.ref.passage && shown && currentValueEligible(entry, shown) && covers(shown, need(entry))
|
||||
&& (!entry.pending || promote(entry))) {
|
||||
cancel(entry) // Revokes both read and operation before any late result can publish.
|
||||
entry.status = "ready"; entry.parked = false
|
||||
publish(entry)
|
||||
|
|
|
|||
197
packages/ui/tests/contracts/mission-current-promotion.test.ts
Normal file
197
packages/ui/tests/contracts/mission-current-promotion.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { createComputed, createRoot } from "solid-js"
|
||||
import { contentDigest } from "../../../server/src/missions/content-digest"
|
||||
import { currentPassageFixture } from "../../../server/src/opencode/missions/native-current-passage-test-fixture"
|
||||
import { serverApi } from "../../src/lib/api-client"
|
||||
import { missionBodyNeed } from "../../src/lib/mission-body-pages"
|
||||
import { createMissionContentStore, type MissionBodyConsumer, type MissionBodyRef } from "../../src/stores/mission-content-store"
|
||||
|
||||
const A = "aaaaaaaaaa", B = "bbbbbbbbbb", C = "cccccccccc"
|
||||
const flush = async () => { for (let tick = 0; tick < 60; tick++) await Promise.resolve() }
|
||||
function observe(handle: MissionBodyConsumer) {
|
||||
const digests: string[] = []
|
||||
const dispose = createRoot(dispose => { createComputed(() => { const digest = handle.state().body?.digest; if (digest) digests.push(digest) }); return dispose })
|
||||
return { digests, dispose }
|
||||
}
|
||||
async function pending(owner: "daily" | "scheduled", prefixNeed = 3) {
|
||||
let value = A, revision = 1, held = false, resolve!: (value: ReturnType<typeof answer>) => void, reject!: (error: Error) => void
|
||||
const ref: MissionBodyRef = { instanceId: "fixture", projectID: "project", missionID: "msn_pending", selector: { kind: "mission", field: "notes" },
|
||||
passage: { owner, projectID: "project", scheduleID: "schedule", passageID: "passage", missionID: "msn_pending", digest: "a".repeat(43) } }
|
||||
const answer = (offset: number, text = value, at = revision) => ({ available: true as const, version: 1 as const, projectID: ref.projectID,
|
||||
missionID: ref.missionID, revision: at, content: { ...ref.selector, digest: contentDigest(text), offset, characters: text.length,
|
||||
text: text.slice(offset, offset + 5), ...(offset === 0 ? { next: 5 } : {}) } })
|
||||
const calls: Array<{ offset: number; signal: AbortSignal; deadline?: number }> = []
|
||||
const store = createMissionContentStore(async (_ref, query, signal, deadline) => {
|
||||
calls.push({ offset: query.offset, signal, deadline })
|
||||
if (value === B && query.offset === 5 && !held) { held = true; return new Promise((yes, no) => { resolve = yes; reject = no }) }
|
||||
return answer(query.offset)
|
||||
})
|
||||
const whole = store.use(ref, "connection"), prefix = store.use(ref, "connection")
|
||||
whole.need(10); prefix.need(prefixNeed); await flush()
|
||||
assert.equal(prefix.state().body?.text, A)
|
||||
const seen = observe(prefix)
|
||||
value = B; revision = 2; whole.expect({ digest: contentDigest(B), characters: 10 }, 2); await flush()
|
||||
assert.ok(held); assert.equal(prefix.state().body?.text, A, "B must actually be pending before the provenance change")
|
||||
const heldCall = calls.at(-1)!, before = calls.length
|
||||
return { store, ref, whole, prefix, calls, heldCall, before, seen,
|
||||
current() { value = C; revision = 3 },
|
||||
settle(failure = false) { if (failure) reject(new Error("Late B failure")); else resolve(answer(5, B, 2)) },
|
||||
dispose() { whole.release(); prefix.release(); resolve(answer(5, B, 2)); seen.dispose() },
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the review's exact 20-case baseline: six old-code failures and fourteen controls.
|
||||
for (const owner of ["daily", "scheduled"] as const) for (const change of ["descriptor", "same-revision", "invalidate", "expired", "valid"] as const)
|
||||
for (const trigger of ["reduce", "release"] as const) test(`${owner} pending baseline ${change}/${trigger}`, async t => {
|
||||
let clock = 0
|
||||
t.mock.method(performance, "now", () => clock)
|
||||
const f = await pending(owner)
|
||||
try {
|
||||
clock = change === "expired" ? 31_000 : 10_000
|
||||
if (change === "descriptor" || change === "same-revision") {
|
||||
f.current(); f.prefix.expect({ digest: contentDigest(C), characters: 10 }, change === "descriptor" ? 3 : 2)
|
||||
}
|
||||
if (change === "invalidate") { f.current(); f.store.invalidate() }
|
||||
if (trigger === "reduce") f.whole.need(3); else f.whole.release()
|
||||
assert.equal(f.prefix.state().body?.text, change === "valid" ? B.slice(0, 5) : A)
|
||||
assert.equal(f.calls.length, f.before, "contraction neither restarts nor renews an outstanding operation")
|
||||
assert.equal(f.heldCall.signal.aborted, change === "valid")
|
||||
if (change !== "valid") assert.ok(f.seen.digests.every(digest => digest === contentDigest(A)), "no transient B publication")
|
||||
f.settle(); await flush()
|
||||
if (change === "valid") {
|
||||
assert.equal(f.prefix.state().body?.text, B.slice(0, 5)); assert.equal(f.prefix.state().status, "ready")
|
||||
assert.equal(f.calls.length, f.before)
|
||||
} else {
|
||||
assert.equal(f.prefix.state().body?.text, A); assert.ok(!f.seen.digests.includes(contentDigest(B)))
|
||||
assert.ok(f.calls.slice(f.before).length <= 1, "at most the existing coalesced invalidation read")
|
||||
assert.ok(f.calls.slice(f.before).every(call => call.deadline === f.heldCall.deadline))
|
||||
if (change === "invalidate") f.prefix.expect({ digest: contentDigest(C), characters: 10 }, 3)
|
||||
clock += 1_000; f.prefix.retry(); await flush()
|
||||
const expected = change === "expired" ? B : C
|
||||
assert.equal(f.prefix.state().body?.text, expected.slice(0, 5)); assert.equal(f.prefix.state().status, "ready")
|
||||
assert.ok(f.calls.at(-1)!.deadline! > f.heldCall.deadline!)
|
||||
}
|
||||
} finally { f.dispose(); await flush() }
|
||||
})
|
||||
|
||||
for (const owner of ["daily", "scheduled"] as const) for (const change of ["descriptor", "same-revision", "invalidate", "mark"] as const)
|
||||
for (const late of ["success", "failure"] as const) test(`${owner} pending need increase and automatic accept/revalidation preserve ${change} after late ${late}`, async t => {
|
||||
let clock = 0
|
||||
t.mock.method(performance, "now", () => clock)
|
||||
const f = await pending(owner, 1)
|
||||
try {
|
||||
clock = 10_000; f.current()
|
||||
if (change === "descriptor" || change === "same-revision") f.prefix.expect({ digest: contentDigest(C), characters: 10 }, change === "descriptor" ? 3 : 2)
|
||||
else if (change === "invalidate") f.store.invalidate()
|
||||
else f.prefix.version("current-mark")
|
||||
f.whole.release(); f.prefix.need(4)
|
||||
assert.equal(f.prefix.state().body?.text, A); assert.ok(!f.seen.digests.includes(contentDigest(B)))
|
||||
assert.equal(f.calls.length, f.before); assert.equal(f.heldCall.signal.aborted, false)
|
||||
f.settle(late === "failure"); await flush()
|
||||
assert.equal(f.prefix.state().body?.text, A); assert.ok(!f.seen.digests.includes(contentDigest(B)), "automatic accept cannot publish old B")
|
||||
assert.ok(f.calls.length <= f.before + 1); assert.ok(f.calls.every(call => call.deadline === 30_000))
|
||||
if (change === "invalidate" || change === "mark") f.prefix.expect({ digest: contentDigest(C), characters: 10 }, 3)
|
||||
clock = 20_000; f.prefix.retry(); await flush()
|
||||
assert.equal(f.prefix.state().body?.text, C.slice(0, 5)); assert.equal(f.prefix.state().status, "ready")
|
||||
assert.equal(f.calls.at(-1)!.deadline, 50_000); assert.ok(!f.seen.digests.includes(contentDigest(B)))
|
||||
} finally { f.dispose(); await flush() }
|
||||
})
|
||||
|
||||
for (const owner of ["daily", "scheduled"] as const) test(`${owner} an ended operation cannot promote its cached pending value on new user demand`, async t => {
|
||||
let clock = 0
|
||||
t.mock.method(performance, "now", () => clock)
|
||||
const f = await pending(owner, 10)
|
||||
let reopened: MissionBodyConsumer | undefined
|
||||
try {
|
||||
f.whole.release(); f.prefix.release(); assert.equal(f.heldCall.signal.aborted, true)
|
||||
clock = 45_000; reopened = f.store.use(f.ref, "connection"); reopened.need(3)
|
||||
assert.equal(reopened.state().body?.text, A, "cached unpromoted B has no live validation operation")
|
||||
assert.equal(f.calls.length, f.before + 1, "new demand must actually read B under a fresh operation")
|
||||
assert.equal(f.calls.at(-1)!.deadline, 75_000); assert.notEqual(f.calls.at(-1)!.signal, f.heldCall.signal)
|
||||
f.settle(); await flush()
|
||||
assert.equal(reopened.state().body?.text, B.slice(0, 5), "the old held tail cannot extend the newly validated prefix")
|
||||
assert.equal(reopened.state().status, "ready")
|
||||
} finally { reopened?.release(); f.dispose(); await flush() }
|
||||
})
|
||||
|
||||
async function waitFor(predicate: () => boolean, message: string) {
|
||||
const until = performance.now() + 5_000
|
||||
while (!predicate() && performance.now() < until) await delay(5)
|
||||
assert.ok(predicate(), message)
|
||||
}
|
||||
async function started(promise: Promise<void>) {
|
||||
const cancel = new AbortController()
|
||||
try { await Promise.race([promise, delay(5_000, undefined, { signal: cancel.signal }).then(() => { throw Error("Semantic continuation did not start") })]) }
|
||||
finally { cancel.abort() }
|
||||
}
|
||||
|
||||
for (const owner of ["daily", "scheduled"] as const) for (const change of ["descriptor", "same-revision", "invalidate"] as const)
|
||||
for (const trigger of ["reduce", "release", "increase"] as const) test(`${owner} actual reader pending ${change}/${trigger} retains A until header-bound C`, { timeout: 20_000 }, async t => {
|
||||
const f = await currentPassageFixture(owner), original = globalThis.fetch
|
||||
let releaseA!: () => void, releaseB!: () => void, enterA!: () => void, enterB!: () => void
|
||||
const gateA = new Promise<void>(resolve => { releaseA = resolve }), gateB = new Promise<void>(resolve => { releaseB = resolve })
|
||||
const startedA = new Promise<void>(resolve => { enterA = resolve }), startedB = new Promise<void>(resolve => { enterB = resolve })
|
||||
let whole: MissionBodyConsumer | undefined, prefix: MissionBodyConsumer | undefined, seen: ReturnType<typeof observe> | undefined
|
||||
try {
|
||||
let map = (await f.control.snapshot()).missions[0]
|
||||
const make = (lead: string) => { const first = lead + "\n\n" + "\u0001".repeat(12_000) + "\n"; return first + "\u0001".repeat(20_000 - first.length) }
|
||||
const a = make("OLD_A"), b = make("LONGER_PENDING_B_MARKER_THAN_A"), c = "CURRENT_C\n".padEnd(20_000, "c")
|
||||
const da = contentDigest(a), db = contentDigest(b), dc = contentDigest(c)
|
||||
const update = async (notes: string, requestID: string) => { map = (await f.control.update({ missionID: map.id, requestID,
|
||||
expectedRevision: map.revision, objective: map.objective, notes })).mission }
|
||||
await update(a, "page-a")
|
||||
const source = (await f.read()).source!, operations: Array<{ deadline?: number; signal: AbortSignal }> = []
|
||||
const header = async () => {
|
||||
const result = await f.page({ source, selector: { source: "mission", missionID: map.id, header: true } })
|
||||
assert.ok(result.available && !result.page.next); assert.ok(Buffer.byteLength(JSON.stringify(result)) <= 65_536)
|
||||
const value = JSON.parse(result.page.pieces.map(piece => piece.text).join("")); assert.equal(value.root.revision, map.revision)
|
||||
return value.root
|
||||
}
|
||||
let pendingOffset = 0
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
const input = JSON.parse(init!.body as string); assert.deepEqual(input.source, source)
|
||||
if (input.body.offset > 0 && input.body.digest === da) { enterA(); await gateA; init!.signal!.throwIfAborted() }
|
||||
if (input.body.offset > 0 && input.body.digest === db) {
|
||||
const captured = await f.page(input) // A legal B observation, held before the C edit.
|
||||
pendingOffset = input.body.offset; enterB(); await gateB
|
||||
return new Response(JSON.stringify(captured)) // Deliberately late, even if its owner has cancelled.
|
||||
}
|
||||
const value = await f.page(input); assert.ok(Buffer.byteLength(JSON.stringify(value)) <= 65_536)
|
||||
return new Response(JSON.stringify(value))
|
||||
}
|
||||
const store = createMissionContentStore((ref, query, signal, deadline) => {
|
||||
operations.push({ signal, deadline })
|
||||
return serverApi.fetchMissionCurrentBody(ref.instanceId, source, { missionID: source.missionID, ...ref.selector, ...query }, signal, deadline)
|
||||
})
|
||||
const ref: MissionBodyRef = { instanceId: "fixture", projectID: source.projectID, missionID: source.missionID, passage: source, selector: { kind: "mission", field: "notes" } }
|
||||
whole = store.use(ref, "connection"); prefix = store.use(ref, "connection")
|
||||
const rootA = await header(); whole.expect(rootA.bodies.notes, rootA.revision); whole.need(9_001); prefix.need(9_001)
|
||||
await waitFor(() => (prefix!.state().body?.text.length ?? 0) >= 9_001, "A prefix must be displayed")
|
||||
const first = prefix.state().body!; assert.equal(first.digest, da); assert.ok(first.text.length < first.characters)
|
||||
seen = observe(prefix)
|
||||
const pageTwo = missionBodyNeed(first, 1, 9_000), pageOne = missionBodyNeed(first, 0, 9_000)
|
||||
whole.need(pageTwo); prefix.need(trigger === "increase" ? 9_001 : pageOne); await started(startedA)
|
||||
await update(b, "page-b"); const rootB = await header(); whole.expect(rootB.bodies.notes, rootB.revision); releaseA(); await started(startedB)
|
||||
assert.equal(prefix.state().body?.digest, da); assert.ok(pendingOffset > first.text.length && pendingOffset < pageTwo)
|
||||
const operation = operations.at(-1)!, before = operations.length
|
||||
await update(c, "page-c"); const rootC = await header(); assert.equal(rootC.bodies.notes.digest, dc)
|
||||
assert.deepEqual((await f.read()).source, source, "notes edits preserve frozen passage identity")
|
||||
if (change === "invalidate") store.invalidate()
|
||||
else whole.expect(rootC.bodies.notes, change === "same-revision" ? rootB.revision : rootC.revision)
|
||||
if (trigger === "reduce") whole.need(pageOne)
|
||||
else { whole.release(); if (trigger === "increase") prefix.need(pageOne) }
|
||||
t.diagnostic(JSON.stringify({ owner, change, trigger, oldPrefix: first.text.length, pendingPrefix: pendingOffset, pageTwo, pageOne,
|
||||
expected: change === "invalidate" ? "invalidated" : dc, display: prefix.state().body?.digest, source: source.digest }))
|
||||
assert.equal(prefix.state().body?.digest, da, "the actual page-need helper must never newly publish B under C/invalidation")
|
||||
assert.ok(!seen.digests.includes(db)); assert.equal(operations.length, before); assert.equal(operation.signal.aborted, false)
|
||||
releaseB(); await waitFor(() => prefix!.state().status === "error", "old B or its invalidated revalidation must park with A")
|
||||
await delay(0); assert.equal(prefix.state().body?.digest, da); assert.ok(!seen.digests.includes(db))
|
||||
assert.ok(operations.length <= before + 1); assert.ok(operations.slice(1).every(read => read.deadline === operation.deadline))
|
||||
if (change === "invalidate") prefix.expect(rootC.bodies.notes, rootC.revision)
|
||||
prefix.retry(); await waitFor(() => prefix!.state().body?.digest === dc, "explicit fresh Retry must adopt real C")
|
||||
assert.equal(prefix.state().status, "ready"); assert.ok(operations.at(-1)!.deadline! > operation.deadline!)
|
||||
assert.notEqual(operations.at(-1)!.signal, operation.signal); assert.ok(!seen.digests.includes(db))
|
||||
} finally { whole?.release(); prefix?.release(); releaseA(); releaseB(); seen?.dispose(); globalThis.fetch = original; await f.dispose(); await flush() }
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue