diff --git a/core/planning-suggestions.ts b/core/planning-suggestions.ts new file mode 100644 index 0000000..295b075 --- /dev/null +++ b/core/planning-suggestions.ts @@ -0,0 +1,157 @@ +import { identityKey, type PlanIdentity } from './identity.ts'; +import { prepareSuggestions, type AuthorInput, type AuthorProvider } from './planning-author.ts'; +import type { Diagnostic, EditReply, Plan } from './plan.ts'; + +/** Implemented by the existing Store. No SQL or second persistence writer in core. */ +export interface SuggestionStore { + getPlan(identity: PlanIdentity): Plan; + getSnapshot(identity: PlanIdentity): { id: string; base: string; head: string }; + beginSuggestions(identity: PlanIdentity, expected: { revision: number; snapshotId: string }): string; + completeSuggestions(identity: PlanIdentity, id: string, reply: unknown): void; + settleSuggestion(identity: PlanIdentity, id: string, expected: { revision: number; snapshotId: string }, outcome: { state: 'failed' | 'cancelled' | 'invalidated'; reason: string }): boolean; + getSuggestions(identity: PlanIdentity, id: string): { state: string; revision: number; snapshotId: string | null; reply: EditReply | null; reason: string | null }; +} +export type SuggestionInput = Omit & { snapshotId: string }; +export type SuggestionOutcome = + | { state: 'completed'; id: string; warnings: Diagnostic[] } + | { state: 'failed' | 'cancelled' | 'stale'; id: string; reason: string }; +export interface SuggestionHandle { + readonly id: string; + readonly result: Promise; + cancel(reason?: string): void; +} +interface Active { + handle: SuggestionHandle; + stop: (state: 'failed' | 'cancelled', reason: string) => void; +} +type TerminalOutcome = Extract; +function persistentReason(reason: string): string { + return (reason.trim() || 'Suggestion invocation ended without a reason.').slice(0, 4000); +} +function retainDiagnostic(reason: string | null, diagnostic: string): string { + return reason && reason !== diagnostic ? `${reason} ${diagnostic}` : diagnostic; +} + +/** One instance per runner. Close it before closing Store. Not a cross-process scheduler. */ +export class SuggestionCoordinator { + #store: SuggestionStore; + #provider: AuthorProvider; + #active = new Map(); + #closing = false; + #closePromise?: Promise; + #timeoutMs: number; + constructor(store: SuggestionStore, provider: AuthorProvider, timeoutMs = 120_000) { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647) + throw new Error('Suggestion timeout must fit a positive timer interval.'); + this.#store = store; this.#provider = provider; this.#timeoutMs = timeoutMs; + } + start(input: SuggestionInput): SuggestionHandle { + if (this.#closing) throw new Error('Suggestion coordinator is closing.'); + const identity = { ...input.context.identity }, key = identityKey(identity); + if (this.#active.has(key)) throw new Error('A suggestion invocation is still active for this plan.'); + const snapshotId = input.snapshotId; + const previousPlan = this.#store.getPlan(identity), snapshot = this.#store.getSnapshot(identity); + if (previousPlan.revision !== input.revision) throw new Error('Stale plan revision.'); + if (snapshot.id !== snapshotId || snapshot.base !== input.repo.baseSha) throw new Error('Stale repository snapshot.'); + // Validate before allocating a durable request; no await permits local state changes. + const prepared = prepareSuggestions({ ...input, requestId: 'pending', previousPlan }); + const expected = Object.freeze({ revision: input.revision, snapshotId }); + const id = this.#store.beginSuggestions(identity, expected); + const request = Object.freeze({ ...prepared.request, requestId: id }); + const controller = new AbortController(); + let stopped: Omit | undefined; + const cancellation = () => stopped; + let settled = false; + const reconcile = (outcome: TerminalOutcome): TerminalOutcome => { + const current = this.#store.getSuggestions(identity, id); + if (current.state === 'cancelled') return { id, state: 'cancelled', reason: retainDiagnostic(current.reason, outcome.reason) }; + if (current.state === 'invalidated') return { id, state: 'stale', reason: retainDiagnostic(current.reason, outcome.reason) }; + if (current.state === 'failed') return { id, state: 'failed', reason: retainDiagnostic(current.reason, outcome.reason) }; + if (this.#store.getPlan(identity).revision !== expected.revision || this.#store.getSnapshot(identity).id !== expected.snapshotId) + return { id, state: 'stale', reason: retainDiagnostic(current.reason, outcome.reason) }; + return outcome; + }; + const stop = (state: 'failed' | 'cancelled', reason: string) => { + if (stopped || settled) return; + stopped = { state, reason }; + // Even if storage fails, deliver cancellation to the invocation and retain its slot. + try { + if (!this.#store.settleSuggestion(identity, id, expected, { state, reason: persistentReason(reason) })) { + const reconciled = reconcile({ id, state, reason }); + stopped = { state: reconciled.state, reason: reconciled.reason }; + } + } + catch (error) { stopped.reason += ` Request cleanup failed: ${String(error)}`; } + finally { controller.abort(new Error(reason)); } + }; + const timer = setTimeout(() => stop('failed', 'Suggestion invocation timed out.'), this.#timeoutMs); + let finish!: (result: SuggestionOutcome) => void; + const result = new Promise(resolve => { finish = resolve; }); + const handle: SuggestionHandle = Object.freeze({ id, result, + cancel: (reason = 'Suggestion cancelled by user.') => stop('cancelled', reason) }); + this.#active.set(key, { handle, stop }); + // Defer invocation until the handle owns its slot, including synchronous provider errors. + void Promise.resolve().then(async () => { + let outcome: SuggestionOutcome; + try { + if (stopped) outcome = { id, ...stopped }; + else { + const source = await this.#provider.invoke(request, controller.signal); + const afterInvocation = cancellation(); + if (afterInvocation) outcome = { id, ...afterInvocation }; + else { + const current = this.#store.getSuggestions(identity, id); + if (this.#store.getPlan(identity).revision !== request.revision || + this.#store.getSnapshot(identity).id !== snapshot.id || current.state === 'invalidated') { + outcome = { id, state: 'stale', reason: 'Plan revision or snapshot changed during authoring.' }; + } else if (current.state !== 'pending') { + outcome = { id, state: current.state === 'cancelled' ? 'cancelled' : 'stale', reason: `Suggestion request is ${current.state}.` }; + } else { + const validated = prepared.validate(source); + this.#store.completeSuggestions(identity, id, validated.value); + outcome = { id, state: 'completed', warnings: validated.warnings }; + } + } + } + } catch (error) { + outcome = { id, ...(stopped ?? { state: 'failed' as const, reason: error instanceof Error ? error.message : String(error) }) }; + // A different Store connection may cancel or advance the revision while + // the provider runs or before publication CAS. Keep the original error too. + if (!stopped) { + try { + const current = this.#store.getSuggestions(identity, id); + if (current.state === 'invalidated' || this.#store.getPlan(identity).revision !== request.revision || + this.#store.getSnapshot(identity).id !== snapshot.id) + outcome = { id, state: 'stale', reason: `Plan revision or snapshot changed before publication. ${outcome.reason}` }; + else if (current.state === 'cancelled') + outcome = { id, state: 'cancelled', reason: `Suggestion request was cancelled before publication. ${outcome.reason}` }; + } catch { /* Preserve the original error if durable state cannot be read. */ } + } + } + if (outcome.state !== 'completed') { + const terminal = outcome.state === 'stale' ? 'invalidated' : outcome.state; + try { + if (!this.#store.settleSuggestion(identity, id, expected, { state: terminal, reason: persistentReason(outcome.reason) })) + outcome = reconcile(outcome); + } + catch (error) { + // Keep the original provider/timeout reason, but surface cleanup failure too. + outcome = { ...outcome, reason: `${outcome.reason} Request cleanup failed: ${String(error)}` }; + } + } + clearTimeout(timer); + settled = true; + this.#active.delete(key); + finish(outcome); + }); + return handle; + } + close(): Promise { + if (this.#closePromise) return this.#closePromise; + this.#closing = true; + const active = [...this.#active.values()]; + this.#closePromise = Promise.all(active.map(job => job.handle.result)).then(() => undefined); + for (const job of active) job.stop('cancelled', 'Suggestion coordinator is closing.'); + return this.#closePromise; + } +} diff --git a/docs/implementation/planning-suggestions.md b/docs/implementation/planning-suggestions.md new file mode 100644 index 0000000..bf6a6cd --- /dev/null +++ b/docs/implementation/planning-suggestions.md @@ -0,0 +1,60 @@ +# E3 suggestion lifecycle + +The coordinator is a single in-process owner per runner/store. Construct one instance, +not one per HTTP request. The existing `Store` remains the only durable writer and +the only Apply authority. The coordinator does not allocate plan revisions or trust +provider-supplied identity. It captures the identity, revision, immutable base snapshot +and store-generated request ID before invocation. The caller supplies the snapshot ID +that owns its repository data; admission compares that complete identity before the +Store atomically allocates the request. Input containers are copied. + +| Holder | States and legal transitions | Owner | +| --- | --- | --- | +| Coordinator | open → closing; closing rejects new starts synchronously | Runner-owned coordinator | +| Invocation | pending → running → completed/failed/cancelled/stale; pending may cancel before launch | Coordinator until provider settles | +| Cancellation | first reason retained; durable request settles and abort is requested immediately; invocation remains tracked and its result stays pending until provider termination | Coordinator and D adapter | +| Durable request | pending → ready → consumed, or pending → failed/cancelled/invalidated; plan or snapshot changes invalidate pending/ready while retaining completed history | Existing Store transactions | +| Durable plan | revision advances only through Store import/Apply; advancing invalidates sibling/pending requests | Store | +| Subprocess/container | abort requested → terminating → terminated; promise settles after final termination | D adapter | +| HTTP and UI | not created by E; F rejects admission/drains admitted requests before coordinator close, then closes Store; G preserves drafts and ignores stale responses | F/G | + +The Store persists each request's plan revision, snapshot ID, and terminal reason. +Failed, cancelled, and stale E outcomes use `settleSuggestion`, which can transition +only the exact pending attempt at its captured revision and snapshot. If another +process completed the request first, cleanup loses without changing the ready result. +If another process cancelled or invalidated it first, E rereads the durable state and +reconciles its returned classification while retaining the original diagnostic. +Revision and snapshot changes invalidate durable requests with their cause while +retaining completed replies as stale history. Durable reasons use the Store's bounded +format even when a provider returns an oversized diagnostic. Restart therefore +preserves both the terminal classification and its actionable reason. + +There is one active invocation per plan identity. A new start (including a retry) +cannot replace it, regardless of elapsed time, cancellation, or persisted state. +No lease or wall-clock heuristic releases ownership. Timeouts request abort but +do not settle early. First timeout/cancellation/shutdown reason wins over a later +generic provider abort. Different plan identities remain distinct; the production +runner must additionally enforce its global task limit. + +Before publication, compare the current plan revision and snapshot with the captured +ones, validate every card via E2, then call Store.completeSuggestions, whose CAS +also checks the request is still pending and bound to the current revision/snapshot. +JavaScript has no await between these checks and publication; Store provides the +transactional request CAS across independent processes. +If the provider rejects or publication CAS refuses after external cancellation or +revision advance, read durable state and return cancelled/stale instead of a provider +failure. Retain the original provider/Store diagnostic alongside that classification. +Automatic cleanup uses the same captured binding and pending-state guard. Explicit +user dismissal remains a separate Store operation. E is not a multi-process scheduler. + +`close()` flips admission to closing before aborting invocations and waits for all +providers to settle. It does not close storage. There is no HTTP server, polling, +browser input, retry endpoint or subprocess implementation in this lane. + +Checks use controllable promises and timers with real SQLite to assert both returned +outcomes and durable state. Required cases include import before late completion, +snapshot change, completion racing failure cleanup, external cancellation, bounded +provider errors, timeout followed by an unsettled provider, +retry while cancellation is pending, shutdown admission, and post-submit mutation +of caller-owned input. Apply/replay/independent-process serialization remain Store's +existing acceptance boundary; E4 adds integrated fixtures. diff --git a/test/planning-suggestions.test.ts b/test/planning-suggestions.test.ts new file mode 100644 index 0000000..58cb7b8 --- /dev/null +++ b/test/planning-suggestions.test.ts @@ -0,0 +1,236 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; +import { Store } from '../runner/store.ts'; +import { SuggestionCoordinator, type SuggestionInput } from '../core/planning-suggestions.ts'; +import type { AuthorProvider, AuthorRequest } from '../core/planning-author.ts'; +import type { EditReply, Plan } from '../core/plan.ts'; + +const plan = (): Plan => ({ schema_version: 1, issue: 1, revision: 1, summary: 'Example', questions: [], + items: [{ id: 'P1', title: 'Change', intent: 'Improve', files: [{ path: 'a', kind: 'edit', renamed_from: null, change: 'Change' }], + acceptance: [{ type: 'check', text: 'Works' }], depends_on: [] }] }); +const input = (): SuggestionInput => ({ snapshotId: 'unbound', context: { identity: { repositoryId: 'repo', taskId: 'task', planId: 'plan' }, + issue: 1, baseEntries: [{ path: 'a', kind: 'file' }], pathKey: p => p, allowedCommands: [] }, + revision: 1, repo: { name: 'repo', baseRef: 'main', baseSha: 'a'.repeat(40), paths: ['a'] }, + issue: { number: 1, title: 'Fix', body: '', comments: [] }, approvedLessons: [], feedback: '' }); +const reply = (): EditReply => ({ schema_version: 1, base_revision: 1, reply: 'Suggestion', edits: [{ op: 'set_field', + item: 'P1', summary: 'Rename', reason: 'Clearer', field: 'title', value: 'Updated', file: null, check: null, + check_index: null, depends_on: null, new_item: null }] }); +function deferred() { + let resolve!: (value: string) => void, reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const cleanup: (() => void)[] = []; +afterEach(() => { vi.useRealTimers(); cleanup.splice(0).reverse().forEach(fn => fn()); }); +function fixture(provider?: AuthorProvider) { + const dir = mkdtempSync(join(tmpdir(), 'planning-suggestions-')); + cleanup.push(() => rmSync(dir, { recursive: true, force: true })); + const store = new Store(join(dir, 'state.sqlite')); cleanup.push(() => store.close()); + const value = input(), identity = value.context.identity; + store.createPlan(JSON.stringify(plan()), 'json', value.context, value.repo.baseSha, 'b'.repeat(40)); + value.snapshotId = store.getSnapshot(identity).id; + const pending = deferred(), calls: { request: AuthorRequest; signal: AbortSignal }[] = []; + const coordinator = new SuggestionCoordinator(store, provider ?? { invoke(request, signal) { calls.push({ request, signal }); return pending.promise; } }, 100); + return { store, path: join(dir, 'state.sqlite'), value, identity, pending, calls, coordinator }; +} +it('binds the store request before invocation and publishes only valid replies', async () => { + const f = fixture(); const handle = f.coordinator.start(f.value); + expect(f.store.getSuggestions(f.identity, handle.id).state).toBe('pending'); + await Promise.resolve(); expect(f.calls[0]!.request.requestId).toBe(handle.id); + expect(f.calls[0]!.request.identity).toEqual(f.identity); + f.pending.resolve(JSON.stringify(reply())); + expect(await handle.result).toMatchObject({ state: 'completed' }); + expect(f.store.getSuggestions(f.identity, handle.id).state).toBe('ready'); + handle.cancel(); expect(f.store.getSuggestions(f.identity, handle.id).state).toBe('ready'); + expect(f.store.applySuggestion(f.identity, handle.id, 0, f.value.context).revision).toBe(2); + expect(() => f.store.applySuggestion(f.identity, handle.id, 0, f.value.context)).toThrow(/unavailable/); + await f.coordinator.close(); +}); +it('rejects late completion after import in both outcome and durable state', async () => { + const f = fixture(), handle = f.coordinator.start(f.value); await Promise.resolve(); + f.store.importRevision(JSON.stringify({ ...plan(), summary: 'New user draft' }), 'json', f.value.context, 1); + f.pending.resolve(JSON.stringify(reply())); + expect(await handle.result).toMatchObject({ state: 'stale' }); + expect(f.store.getSuggestions(f.identity, handle.id)).toMatchObject({ state: 'invalidated', reply: null }); + expect(f.store.getPlan(f.identity)).toMatchObject({ revision: 2, summary: 'New user draft' }); + await f.coordinator.close(); +}); +it('rejects a changed snapshot even if the plan revision is unchanged', async () => { + const f = fixture(), handle = f.coordinator.start(f.value); await Promise.resolve(); + f.store.recordHistory(f.identity, { revision: 1, snapshotId: f.store.getSnapshot(f.identity).id }, 'a'.repeat(40), 'c'.repeat(40), []); + f.pending.resolve(JSON.stringify(reply())); + expect(await handle.result).toMatchObject({ state: 'stale' }); + expect(f.store.getSuggestions(f.identity, handle.id)).toMatchObject({ state: 'invalidated', reason: 'Repository snapshot changed.', reply: null }); + expect(f.store.getPlan(f.identity).revision).toBe(1); + await f.coordinator.close(); +}); +it('retains ownership after timeout until provider termination, even across clock jumps', async () => { + vi.useFakeTimers(); const f = fixture(), handle = f.coordinator.start(f.value); await Promise.resolve(); + let settled = false; void handle.result.then(() => { settled = true; }); + await vi.advanceTimersByTimeAsync(100); + expect(f.calls[0]!.signal.aborted).toBe(true); + expect(f.calls[0]!.signal.reason.message).toBe('Suggestion invocation timed out.'); + expect(f.store.getSuggestions(f.identity, handle.id)).toMatchObject({ state: 'failed', reason: 'Suggestion invocation timed out.' }); + expect(settled).toBe(false); + vi.setSystemTime(new Date('2099-01-01')); + expect(() => f.coordinator.start(f.value)).toThrow(/still active/); + handle.cancel('Later user cancellation'); + f.pending.reject(new Error('Generic abort')); + expect(await handle.result).toMatchObject({ state: 'failed', reason: 'Suggestion invocation timed out.' }); + expect(f.store.getSuggestions(f.identity, handle.id).reply).toBeNull(); + await f.coordinator.close(); +}); +it('keeps a cancelled invocation active and preserves its first reason', async () => { + const f = fixture(), handle = f.coordinator.start(f.value); await Promise.resolve(); + handle.cancel('Stop this request'); handle.cancel('Ignored reason'); + expect(() => f.coordinator.start(f.value)).toThrow(/still active/); + f.pending.resolve(JSON.stringify(reply())); + expect(await handle.result).toMatchObject({ state: 'cancelled', reason: 'Stop this request' }); + expect(f.store.getSuggestions(f.identity, handle.id)).toMatchObject({ state: 'cancelled', reply: null }); + const retry = f.coordinator.start(f.value); + expect(retry.id).not.toBe(handle.id); expect((await retry.result).state).toBe('completed'); + await f.coordinator.close(); +}); +it('closes admission first, aborts all jobs and waits for unsettled providers before closing Store', async () => { + const f = fixture(), first = f.coordinator.start(f.value); + const other = input(); other.context.identity.planId = 'other'; + f.store.createPlan(JSON.stringify(plan()), 'json', other.context, 'a'.repeat(40), 'b'.repeat(40)); + other.snapshotId = f.store.getSnapshot(other.context.identity).id; + const second = f.coordinator.start(other); await Promise.resolve(); + const fresh = input(); fresh.context.identity.planId = 'not-yet-running'; + f.store.createPlan(JSON.stringify(plan()), 'json', fresh.context, 'a'.repeat(40), 'b'.repeat(40)); + fresh.snapshotId = f.store.getSnapshot(fresh.context.identity).id; + let closed = false; const closing = f.coordinator.close(); void closing.then(() => { closed = true; }); + expect(f.coordinator.close()).toBe(closing); + expect(() => f.coordinator.start(f.value)).toThrow(/closing/); + expect(() => f.coordinator.start(fresh)).toThrow(/closing/); + expect(f.calls.every(call => call.signal.aborted)).toBe(true); + await Promise.resolve(); expect(closed).toBe(false); + f.pending.reject(new Error('Provider abort')); + for (const job of [first, second]) expect(await job.result).toMatchObject({ state: 'cancelled', reason: 'Suggestion coordinator is closing.' }); + await closing; expect(closed).toBe(true); + expect(f.store.getSuggestions(f.identity, first.id).state).toBe('cancelled'); + expect(f.store.getSuggestions(other.context.identity, second.id).state).toBe('cancelled'); +}); +it('cancels before launch without invoking the provider', async () => { + const f = fixture(), handle = f.coordinator.start(f.value); handle.cancel(); + expect((await handle.result).state).toBe('cancelled'); expect(f.calls).toHaveLength(0); + expect(f.store.getSuggestions(f.identity, handle.id).state).toBe('cancelled'); + await f.coordinator.close(); +}); +it('does not publish externally cancelled requests', async () => { + const f = fixture(), handle = f.coordinator.start(f.value); await Promise.resolve(); + f.store.cancelSuggestions(f.identity, handle.id); f.pending.resolve(JSON.stringify(reply())); + expect((await handle.result).state).toBe('cancelled'); + expect(f.store.getSuggestions(f.identity, handle.id).reply).toBeNull(); + await f.coordinator.close(); +}); +it.each(['{}', '{"schema_version":1,"base_revision":2,"reply":"late","edits":[]}'])('fails malformed or stale output without making it applicable: %s', async output => { + const f = fixture(), handle = f.coordinator.start(f.value); f.pending.resolve(output); + expect((await handle.result).state).toBe('failed'); + expect(f.store.getSuggestions(f.identity, handle.id)).toMatchObject({ state: 'failed', reply: null, reason: expect.any(String) }); + expect(f.store.getPlan(f.identity).revision).toBe(1); await f.coordinator.close(); +}); +it('captures caller input before edits or navigation and cannot target another plan', async () => { + const f = fixture(), identity = { ...f.identity }, handle = f.coordinator.start(f.value); + f.value.context.identity.planId = 'other'; f.value.revision = 2; f.value.issue.body = 'edited composer'; + f.value.context.baseEntries = []; f.pending.resolve(JSON.stringify(reply())); + expect((await handle.result).state).toBe('completed'); + expect(f.calls[0]!.request.identity).toEqual(identity); + expect(f.calls[0]!.request.prompt).not.toContain('edited composer'); + expect(f.store.getSuggestions(identity, handle.id).state).toBe('ready'); + expect(() => f.store.getSuggestions(f.value.context.identity, handle.id)).toThrow(/Unknown/); + await f.coordinator.close(); +}); +it('preserves provider errors and permits a new attempt only after rejection settles', async () => { + const f = fixture({ invoke() { throw new Error('Vendor quota exhausted'); } }); + const first = f.coordinator.start(f.value); + expect(await first.result).toMatchObject({ state: 'failed', reason: 'Vendor quota exhausted' }); + expect(f.store.getSuggestions(f.identity, first.id)).toMatchObject({ state: 'failed', reason: 'Vendor quota exhausted' }); + const second = f.coordinator.start(f.value); expect(second.id).not.toBe(first.id); + await second.result; await f.coordinator.close(); +}); +it('bounds the durable provider reason without leaving the request pending', async () => { + const reason = 'x'.repeat(5000), f = fixture({ invoke() { throw new Error(reason); } }); + const request = f.coordinator.start(f.value); + expect(await request.result).toMatchObject({ state: 'failed', reason }); + expect(f.store.getSuggestions(f.identity, request.id)).toMatchObject({ state: 'failed', reason: 'x'.repeat(4000) }); + await f.coordinator.close(); +}); +it('rejects invalid admission before allocating a request or calling the provider', async () => { + const f = fixture(); const begin = vi.spyOn(f.store, 'beginSuggestions'); + expect(() => f.coordinator.start({ ...f.value, revision: 2 })).toThrow(/Stale/); + f.value.repo.baseSha = 'c'.repeat(40); expect(() => f.coordinator.start(f.value)).toThrow(/snapshot/); + f.value.repo.baseSha = 'a'.repeat(40); f.value.feedback = '\0'; + expect(() => f.coordinator.start(f.value)).toThrow(/NUL/); + expect(begin).not.toHaveBeenCalled(); expect(f.calls).toHaveLength(0); await f.coordinator.close(); +}); +it('rejects a snapshot advanced between plan and snapshot reads before admission', async () => { + const f = fixture(), other = new Store(f.path); cleanup.push(() => other.close()); + const getSnapshot = f.store.getSnapshot.bind(f.store), begin = vi.spyOn(f.store, 'beginSuggestions'); + vi.spyOn(f.store, 'getSnapshot').mockImplementationOnce(identity => { + other.recordHistory(identity, { revision: 1, snapshotId: f.value.snapshotId }, 'a'.repeat(40), 'c'.repeat(40), []); + return getSnapshot(identity); + }); + expect(() => f.coordinator.start(f.value)).toThrow(/Stale repository snapshot/); + expect(begin).not.toHaveBeenCalled(); expect(f.calls).toHaveLength(0); + await f.coordinator.close(); +}); +it.each(['cancelled', 'stale'] as const)('classifies a publication CAS race as %s using durable state', async expected => { + const f = fixture(), complete = f.store.completeSuggestions.bind(f.store); + vi.spyOn(f.store, 'completeSuggestions').mockImplementationOnce((identity, id, reply) => { + if (expected === 'cancelled') f.store.cancelSuggestions(identity, id); + else f.store.importRevision(JSON.stringify({ ...plan(), summary: 'New draft before publication' }), 'json', f.value.context, 1); + // Assert the disputed state at the actual publication boundary, not just the outcome. + expect(f.store.getSuggestions(identity, id).state).toBe(expected === 'cancelled' ? 'cancelled' : 'invalidated'); + complete(identity, id, reply); + }); + const request = f.coordinator.start(f.value); f.pending.resolve(JSON.stringify(reply())); + expect(await request.result).toMatchObject({ state: expected }); + expect(f.store.getSuggestions(f.identity, request.id)).toMatchObject({ reply: null, state: expected === 'cancelled' ? 'cancelled' : 'invalidated' }); + expect(f.store.getPlan(f.identity).revision).toBe(expected === 'cancelled' ? 1 : 2); + await f.coordinator.close(); +}); +it.each(['cancelled', 'stale'] as const)('preserves durable %s state when the provider rejects after an external action', async expected => { + const f = fixture(), request = f.coordinator.start(f.value); await Promise.resolve(); + if (expected === 'cancelled') f.store.cancelSuggestions(f.identity, request.id); + else f.store.importRevision(JSON.stringify(plan()), 'json', f.value.context, 1); + expect(f.store.getSuggestions(f.identity, request.id).state).toBe(expected === 'cancelled' ? 'cancelled' : 'invalidated'); + f.pending.reject(new Error('Provider transport disconnected')); + expect(await request.result).toMatchObject({ state: expected, reason: expect.stringContaining('Provider transport disconnected') }); + expect(f.store.getSuggestions(f.identity, request.id).reply).toBeNull(); + await f.coordinator.close(); +}); +it('does not erase a result completed by another connection during failure cleanup', async () => { + const f = fixture(), request = f.coordinator.start(f.value); await Promise.resolve(); + const other = new Store(f.path); cleanup.push(() => other.close()); + const settle = f.store.settleSuggestion.bind(f.store); + vi.spyOn(f.store, 'settleSuggestion').mockImplementationOnce((identity, id, expected, outcome) => { + other.completeSuggestions(identity, id, reply()); + return settle(identity, id, expected, outcome); + }); + f.pending.reject(new Error('Provider transport disconnected')); + expect(await request.result).toMatchObject({ state: 'failed', reason: 'Provider transport disconnected' }); + expect(f.store.getSuggestions(f.identity, request.id)).toMatchObject({ state: 'ready', reply: reply(), reason: null }); + expect(f.store.applySuggestion(f.identity, request.id, 0, f.value.context).revision).toBe(2); + await f.coordinator.close(); +}); +it.each(['cancelled', 'stale'] as const)('reconciles %s when another connection wins terminal settlement', async expected => { + const f = fixture(), request = f.coordinator.start(f.value); await Promise.resolve(); + const other = new Store(f.path); cleanup.push(() => other.close()); + const settle = f.store.settleSuggestion.bind(f.store); + vi.spyOn(f.store, 'settleSuggestion').mockImplementationOnce((identity, id, binding, outcome) => { + if (expected === 'cancelled') other.cancelSuggestions(identity, id, 'Cancelled elsewhere.'); + else other.recordHistory(identity, binding, 'a'.repeat(40), 'c'.repeat(40), []); + return settle(identity, id, binding, outcome); + }); + f.pending.reject(new Error('Provider transport disconnected')); + expect(await request.result).toMatchObject({ state: expected, reason: expect.stringContaining('Provider transport disconnected') }); + expect(f.store.getSuggestions(f.identity, request.id)).toMatchObject({ + state: expected === 'cancelled' ? 'cancelled' : 'invalidated', + reason: expected === 'cancelled' ? 'Cancelled elsewhere.' : 'Repository snapshot changed.', + }); + await f.coordinator.close(); +});