Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions lib/app/session-end.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 EvoMap

import { withClosedRange } from '../distill/range.js';
import { rangeOf, withClosedRange } from '../distill/range.js';
import { endedSession, isEarlierTurn, markedReported, pendingReportsOf, sessionOf, unreportedOf } from '../session/state.js';
import { TASKS } from './ports.js';
import { closeUnfinishedTurn, settledSessionOf, withoutPendingControls } from './turn-end.js';
Expand Down Expand Up @@ -30,6 +30,7 @@ export async function sessionEnded(ports, { sessionId, projectDir, turnId = null
const due = pending ? pending.map(({ asset }) => asset) : unreportedOf(ending, sessionId, Infinity);
const queued = pending ? pending.map(({ entry }) => entry) : turnReportsOf(ports.identity, due, { reason: 'aborted', checks: [] });
const controls = ending.pendingControls;
const endingRange = rangeOf(await ports.candidates.read(), sessionId);
await ports.reports.enqueue([...queued, ...controls]);
// A prompt uses the same fence to open its turn after this session and its range have ended together.
const ended = await ports.sessions.withSession(sessionId, async () => {
Expand All @@ -40,7 +41,7 @@ export async function sessionEnded(ports, { sessionId, projectDir, turnId = null
return endedSession(withoutPendingControls(marked, controls));
});
if (!saved) return false;
await ports.candidates.update((pending) => withClosedRange(pending, sessionId));
await ports.candidates.update((pending) => withClosedRange(pending, sessionId, endingRange));
await ports.notes.clear(sessionId, session);
return true;
});
Expand Down
10 changes: 7 additions & 3 deletions lib/distill/range.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,13 @@ export function withSessionActivity(pending, sessionId, now) {
return { ...pending, [sessionId]: withActivity(range, now) };
}

/** A session that ended keeps its range due, so a distillation that failed at its end is tried again. */
export const withClosedRange = (pending, sessionId) =>
(pending?.[sessionId] ? { ...pending, [sessionId]: { ...rangeOf(pending, sessionId), closed: true } } : undefined);
/** A session end keeps the range it found due, leaving a replacement range alone. */
export function withClosedRange(pending, sessionId, expected) {
const range = rangeOf(pending, sessionId);
if (!range || !expected || range.at !== expected.at || range.sessionEpoch !== expected.sessionEpoch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close late same-session ranges when the snapshot is empty

If the final turnEnded has already set open=false but is still awaiting its record or evidence, the bounded settling wait can expire before it writes a candidate. If that write lands after this snapshot but before the later candidate update, endingRange is null and this guard skips closing the new range; while its owner is still live, the range remains pending until an idle or error-episode trigger instead of becoming immediately due at session end. Preserve the ending turn and epoch so late writes from that turn can be closed without closing a new-prompt replacement.

Useful? React with 👍 / 👎.

|| !expected.turns.some((read) => range.turns.some((turn) => turn.turn === read.turn))) return undefined;
return { ...pending, [sessionId]: { ...range, closed: true } };
}

const isHeld = (range, isGone) => Boolean(range.claimedBy) && !isGone(range.claimedBy);
const isOver = (range, isGone) => Boolean(range.closed) || isGone(range.pid);
Expand Down
40 changes: 38 additions & 2 deletions test/app/turn-lifecycle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { test } from 'node:test';

import { runBackgroundTasks } from '../../lib/app/background.js';
import { TASKS } from '../../lib/app/ports.js';
import { MIN_WAKE_DELAY_MS } from '../../lib/app/distill-session.js';
import { MIN_WAKE_DELAY_MS, distillSession } from '../../lib/app/distill-session.js';
import { lastTurnOf, promptSubmitted } from '../../lib/app/prompt.js';
import { sessionEnded } from '../../lib/app/session-end.js';
import { sessionStarted } from '../../lib/app/session-start.js';
Expand All @@ -17,7 +17,7 @@ import { EPISODE_QUIET_MS } from '../../lib/distill/range.js';
import { sessionOf as sessionOfStored } from '../../lib/session/state.js';
import { contentHashOf, fileStoresOf } from '../../lib/runtime/store/file-stores.js';
import { ASSET, PROMPT, memoryPorts } from './memory-ports.js';
import { fixTurn } from './work-turns.js';
import { DRAFTED, IR, fixTurn, modelAnswering } from './work-turns.js';

const WAIT = { waitMs: 5_000 };
const nativePassingCheck = JSON.parse(readFileSync(new URL('../fixtures/node-spec-passing-errors.json', import.meta.url), 'utf8'));
Expand Down Expand Up @@ -643,6 +643,42 @@ test('a prompt waits until session and range cleanup finish before opening a new
assert.deepEqual([session.open, session.turn, session.turnId], [true, resumed.turn, 'next']);
});

test('late session cleanup cannot close a replacement range after a prompt bypasses the held lifecycle lock', async (t) => {
const { ports } = memoryPorts({ model: modelAnswering(IR), distillAnswer: { status: 'stored', gene: DRAFTED.gene } });
const dir = mkdtempSync(join(tmpdir(), 'evolver-session-end-fallback-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const { sessions, candidates } = fileStoresOf({ dir, agent: 'testagent' });
Object.assign(ports, { sessions, candidates });
await verifiedTurn(ports, 's1');
await end(ports, 's1', { model: 'model-x' });
const closingRange = Promise.withResolvers();
const resume = Promise.withResolvers();
const updateCandidates = ports.candidates.update;
ports.candidates.update = async (...args) => {
ports.candidates.update = updateCandidates;
closingRange.resolve();
await resume.promise;
return updateCandidates(...args);
};
const ending = sessionEnded(ports, { sessionId: 's1', projectDir: '/proj' });
await closingRange.promise;
try {
const next = await promptSubmitted(ports, { sessionId: 's1', prompt: 'continue the repair', turnId: 'next', ...WAIT });
assert.equal(next.skipped.code, 'ELOCKED');
assert.equal((await distillSession(ports, { sessionId: 's1' })).status, 'stored');
await toolFinished(ports, { sessionId: 's1', turnId: 'next', note: { kind: 'edit', paths: ['/proj/lib/next.js'] } });
await toolFinished(ports, { sessionId: 's1', turnId: 'next', note: { kind: 'shell', command: 'npm test', exit: 0 } });
await end(ports, 's1', { turnId: 'next', model: 'model-x' });
assert.deepEqual((await ports.candidates.read()).s1.turns.map(({ turn }) => turn), [next.turn]);
} finally {
resume.resolve();
await ending;
}
const current = (await ports.candidates.read()).s1;
assert.notEqual(current.closed, true);
assert.equal((await ports.sessions.read('s1')).turnId, 'next');
});

test('a note that raced past the open-turn check is still kept out of the next turn', async () => {
const { ports } = memoryPorts();
await promptSubmitted(ports, { sessionId: 's1', prompt: PROMPT, turnId: 'g2', ...WAIT });
Expand Down
17 changes: 16 additions & 1 deletion test/distill.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';

import { draftNamed, draftOf, draftsListText, skipReasonOf, submitRequestOf, withDraft, withoutDraft } from '../lib/distill.js';
import { baseFor, isCurrentRange, settledRange, withCorrection, withoutExpiredLeftovers, withoutWorkTurn, withWorkTurn } from '../lib/distill/range.js';
import { baseFor, isCurrentRange, settledRange, withClosedRange, withCorrection, withoutExpiredLeftovers, withoutWorkTurn, withWorkTurn } from '../lib/distill/range.js';
import { corpusEntryOf } from '../lib/distill/corpus.js';

test('settling what a distillation read cannot touch a range made at the same time or in another session epoch', () => {
Expand All @@ -19,6 +19,21 @@ test('settling what a distillation read cannot touch a range made at the same ti
}
});

test('session cleanup closes only the range it observed, including legacy session epochs', () => {
const read = { at: 1_000, sessionEpoch: 1, turns: [{ turn: 1, prompt: 'p', steps: [], answer: '', at: 1_000 }] };
assert.equal(withClosedRange({ s: read }, 's', read).s.closed, true);
for (const replacement of [
{ ...read, at: 2_000 },
{ ...read, sessionEpoch: 2 },
{ ...read, turns: [{ ...read.turns[0], turn: 2 }] },
]) {
assert.equal(withClosedRange({ s: replacement }, 's', read), undefined);
}
assert.equal(withClosedRange({ s: read }, 's', null), undefined);
const { sessionEpoch, ...legacy } = read;
assert.equal(withClosedRange({ s: legacy }, 's', legacy).s.closed, true);
});

const RESULT = {
ok: true,
distill_id: 'abc123',
Expand Down
Loading