From 454acc3f17b7712a73fef98f5bfba7b2fbcd2379 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Wed, 11 Nov 2026 08:01:26 +0700 Subject: [PATCH 1/4] fix: keep replacement drafts after delayed publication --- lib/app/commands/drafts.js | 7 ++++++- test/app/draft-publication.test.js | 25 ++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/app/commands/drafts.js b/lib/app/commands/drafts.js index 23062025..7b91b587 100644 --- a/lib/app/commands/drafts.js +++ b/lib/app/commands/drafts.js @@ -50,6 +50,7 @@ async function publish(ports, draft, signal) { const blocked = await publicationBlock(ports, draft); if (blocked) return blocked; const request = submitRequestOf(draft); + const submitted = JSON.stringify(draft); // Hold the correction lock through dispatch only. Wrap the request promise so the lock never waits for HTTP. const dispatch = () => ({ response: ports.proxy.request(request.method, request.path, request.body, { signal }) }); const started = draftSourcesOf(draft).length > 0 @@ -58,7 +59,11 @@ async function publish(ports, draft, signal) { if (!started) return invalidatedDraft(draft); const answered = await started.response; if (!answered.ok) return failure(`"${draft.title}" was not submitted: ${answered.error}`); - await ports.drafts.update((list) => withoutDraft(list, draft.id)); + // A same-id replacement may have been saved while the Proxy was answering. + await ports.drafts.update((list) => { + const kept = list.filter((current) => current.id !== draft.id || JSON.stringify(current) !== submitted); + return kept.length < list.length ? kept : undefined; + }); return success(`Submitted "${draft.title}" to the EvoMap Hub for review (message ${answered.data?.message_id ?? 'pending'}).`); } diff --git a/test/app/draft-publication.test.js b/test/app/draft-publication.test.js index 6ccc9ed5..d2def83a 100644 --- a/test/app/draft-publication.test.js +++ b/test/app/draft-publication.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, utimesSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; @@ -254,6 +254,29 @@ test('explicit publication preserves the Proxy receipt and its complete bound bu assert.deepEqual(await ports.drafts.read(), []); }); +test('a successful submit leaves a replacement draft with the same id waiting', async (t) => { + const { ports, draft } = await withDraft({ ...RESULT, publishable: true, publish_receipt: RECEIPT }); + const dir = mkdtempSync(join(tmpdir(), 'evolver-draft-replacement-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const { drafts } = fileStoresOf({ dir, agent: 'testagent' }); + ports.drafts = drafts; + await drafts.update(() => [draft]); + + const replacement = { ...draft, createdAt: draft.createdAt + 1, title: 'Replacement' }; + ports.proxy.request = async (method, path, body) => { + assert.equal(path, '/asset/submit'); + assert.deepEqual(body.assets, draft.assets); + assert.deepEqual(body.publish_receipt, RECEIPT); + await drafts.update(() => [replacement]); + return { ok: true, data: { message_id: 'submitted-original' } }; + }; + + const answer = await runCommand(ports, { sessionId: 's1', name: 'evolver-drafts', args: 'publish 1' }); + assert.equal(answer.ok, true, answer.text); + assert.match(answer.text, /submitted-original/); + assert.deepEqual(await drafts.read(), [replacement]); +}); + test('a Proxy-declared unpublishable draft is retained and explains the trusted verification requirement', async () => { const { ports, requests, draft } = await withDraft({ ...RESULT, publishable: false }); ports.proxy.request = async (method, path, body) => { From 254f72af3e7da62d384fb9db9947d58f2572c20e Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Wed, 11 Nov 2026 08:06:48 +0700 Subject: [PATCH 2/4] test: keep a replacement receipt even at the same timestamp --- test/app/draft-publication.test.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/app/draft-publication.test.js b/test/app/draft-publication.test.js index d2def83a..6a018797 100644 --- a/test/app/draft-publication.test.js +++ b/test/app/draft-publication.test.js @@ -254,7 +254,12 @@ test('explicit publication preserves the Proxy receipt and its complete bound bu assert.deepEqual(await ports.drafts.read(), []); }); -test('a successful submit leaves a replacement draft with the same id waiting', async (t) => { +for (const [change, replace] of [ + ['a later creation time', (draft) => ({ ...draft, createdAt: draft.createdAt + 1, title: 'Replacement' })], + ['a different receipt at the same creation time', (draft) => ({ + ...draft, publish_receipt: { ...RECEIPT, receiptId: 'replacement-receipt' }, + })], +]) test(`a successful submit keeps a same-id draft with ${change}`, async (t) => { const { ports, draft } = await withDraft({ ...RESULT, publishable: true, publish_receipt: RECEIPT }); const dir = mkdtempSync(join(tmpdir(), 'evolver-draft-replacement-')); t.after(() => rmSync(dir, { recursive: true, force: true })); @@ -262,7 +267,7 @@ test('a successful submit leaves a replacement draft with the same id waiting', ports.drafts = drafts; await drafts.update(() => [draft]); - const replacement = { ...draft, createdAt: draft.createdAt + 1, title: 'Replacement' }; + const replacement = replace(draft); ports.proxy.request = async (method, path, body) => { assert.equal(path, '/asset/submit'); assert.deepEqual(body.assets, draft.assets); From d947fbefa0ef06def684d4e9af10fb36b9babf11 Mon Sep 17 00:00:00 2001 From: "xyanua." Date: Wed, 11 Nov 2026 08:18:51 +0700 Subject: [PATCH 3/4] fix: retain unknown native owners during finalization --- lib/app/turn-end.js | 4 ++-- test/app/native-turn.test.js | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/app/turn-end.js b/lib/app/turn-end.js index cb3beaad..cf5c2166 100644 --- a/lib/app/turn-end.js +++ b/lib/app/turn-end.js @@ -179,8 +179,8 @@ async function rememberWorkTurn(ports, sessionId, opened, options) { const planned = matching ? baseFor(pending, sessionId, workTurn) : workTurn.head ?? null; const snapshot = projectDir && !deferred ? await snapshotIfReadable(project, projectDir, planned) : null; const base = planned ?? (snapshot?.head ? { root: snapshot.root, head: snapshot.head } : null); - // A detached observer's parent is a hook process, while the open turn carries the native owner. - const pid = expected?.pid ?? (confirmTurnCompletion ? opened.pid : null) ?? owner.current(); + // A detached observer must retain the captured native owner, including an unknown one. + const pid = expected?.pid !== undefined ? expected.pid : (confirmTurnCompletion ? opened.pid : null) ?? owner.current(); const context = { base, project: snapshot, projectDir: projectDir ?? null, model, sessionEpoch: opened.sessionEpoch, pid, native: Boolean(native || expected), source, now: clock.now() }; // If the turn moved to deferred storage during the snapshot, its recovery uses native evidence alone. const canKeep = (stored) => canKeepTurn(sessionOf(stored), opened) && Boolean(hasDeferredTurn(sessionOf(stored), opened)) === deferred; diff --git a/test/app/native-turn.test.js b/test/app/native-turn.test.js index d5b9268f..05c2fc46 100644 --- a/test/app/native-turn.test.js +++ b/test/app/native-turn.test.js @@ -61,6 +61,15 @@ test('native prompt binds turn zero before terminal; Stop hints cannot close it assert.deepEqual(made.sessions.get('session').nativeCursor.mark, { offset: 500, fileId: 'wire' }); }); +test('native finalization preserves an unknown captured owner without probing its own ancestry', async () => { + const made = await pending({ owner: null }); + made.ports.owner.current = () => assert.fail('finalization must use the captured owner'); + const ended = await reconcileNativeTurn(made.ports, at, facts()); + assert.equal(ended.ended.isCandidate, true); + assert.equal((await made.ports.candidates.read()).session.pid, null); + assert.equal(made.queued.length, 1); +}); + test('read errors, partial wire and a native prompt before the pending mark cannot settle a turn', async () => { for (const bad of [{ ...facts(), status: 'partial' }, { ...facts(), bind: { ...facts().bind, mark: 50 } }, { ...facts(), mark: { offset: 500, fileId: 'rotated' } }]) { const made = await pending(); From 06903d3287652f553c25b770f6b1eba9049ee0ac Mon Sep 17 00:00:00 2001 From: forrestlinfeng Date: Wed, 11 Nov 2026 10:09:42 +0800 Subject: [PATCH 4/4] Move submitted-draft removal next to the other draft list updates Co-Authored-By: Claude Opus 5.5 --- lib/app/commands/drafts.js | 9 ++------- lib/distill.js | 7 +++++++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/app/commands/drafts.js b/lib/app/commands/drafts.js index 7b91b587..9e46715d 100644 --- a/lib/app/commands/drafts.js +++ b/lib/app/commands/drafts.js @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -import { draftNamed, draftSourcesOf, draftsListText, reverifiedOf, sameDraftSource, submitRequestOf, withoutDraft } from '../../distill.js'; +import { draftNamed, draftSourcesOf, draftsListText, reverifiedOf, sameDraftSource, submitRequestOf, withoutDraft, withoutSubmittedDraft } from '../../distill.js'; import { DISTILL_TIMEOUT_MS, draftWasCorrected } from '../distill-session.js'; import { failure, success } from './result.js'; @@ -50,7 +50,6 @@ async function publish(ports, draft, signal) { const blocked = await publicationBlock(ports, draft); if (blocked) return blocked; const request = submitRequestOf(draft); - const submitted = JSON.stringify(draft); // Hold the correction lock through dispatch only. Wrap the request promise so the lock never waits for HTTP. const dispatch = () => ({ response: ports.proxy.request(request.method, request.path, request.body, { signal }) }); const started = draftSourcesOf(draft).length > 0 @@ -59,11 +58,7 @@ async function publish(ports, draft, signal) { if (!started) return invalidatedDraft(draft); const answered = await started.response; if (!answered.ok) return failure(`"${draft.title}" was not submitted: ${answered.error}`); - // A same-id replacement may have been saved while the Proxy was answering. - await ports.drafts.update((list) => { - const kept = list.filter((current) => current.id !== draft.id || JSON.stringify(current) !== submitted); - return kept.length < list.length ? kept : undefined; - }); + await ports.drafts.update((list) => withoutSubmittedDraft(list, draft)); return success(`Submitted "${draft.title}" to the EvoMap Hub for review (message ${answered.data?.message_id ?? 'pending'}).`); } diff --git a/lib/distill.js b/lib/distill.js index dd4c3ac1..6b7ee135 100644 --- a/lib/distill.js +++ b/lib/distill.js @@ -89,6 +89,13 @@ export function withoutDraft(drafts, id) { return (Array.isArray(drafts) ? drafts : []).filter((draft) => draft.id !== id); } +// A same-id replacement saved while the Proxy was answering differs from the submitted snapshot and stays. +export function withoutSubmittedDraft(drafts, submitted) { + const snapshot = JSON.stringify(submitted); + const kept = drafts.filter((draft) => draft.id !== submitted.id || JSON.stringify(draft) !== snapshot); + return kept.length < drafts.length ? kept : undefined; +} + // `1` names the first listed draft; anything else is matched as an id prefix // so a copied id works too. export function draftNamed(drafts, selector) {