Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b20e3ca
fix: discard distillation replies invalidated by a correction
imxyanua Nov 2, 2026
67191f5
fix: restore drafts displaced by a corrected distillation write
imxyanua Nov 2, 2026
05d14ec
fix: serialize stale worker-lock recovery through atomic admission
imxyanua Nov 2, 2026
71f98f8
fix: preserve worker ownership when lock metadata is unreadable
imxyanua Nov 2, 2026
a638041
fix: recover stranded worker lock admission safely
imxyanua Nov 3, 2026
0354187
fix: preserve draft order when rolling back a correction
imxyanua Nov 3, 2026
48a7f83
fix: preserve work errors when lock cleanup also fails
imxyanua Nov 3, 2026
ebc68e1
Merge branch 'labmain' into HEAD
imxyanua Nov 3, 2026
d1fc44f
Recover admission without spawning on uncontended locks
imxyanua Nov 3, 2026
60a6b2b
Remove corrected drafts without restoring older drafts
imxyanua Nov 3, 2026
5167732
Report failed cleanup of corrected drafts
imxyanua Nov 4, 2026
5dd1271
Keep the Proxy result when a correction invalidates a candidate
imxyanua Nov 4, 2026
58f0c14
Preserve the MCP wording for replaced turns
imxyanua Nov 4, 2026
a0e143e
Make abandoned admission guards recoverable across processes
imxyanua Nov 4, 2026
0490c99
Reclaim stale worker locks after confirmed PID reuse
imxyanua Nov 4, 2026
d00d39a
Keep newer drafts when corrected distill replies arrive late
imxyanua Nov 4, 2026
d4952d9
Remove saved drafts when their turn is corrected
imxyanua Nov 4, 2026
d6f4cd2
Confirm saved drafts before reporting distillation success
imxyanua Nov 4, 2026
32a5c7c
Bind submitted Genes to their exact distillation candidate
imxyanua Nov 4, 2026
0eb59d3
Generate Gene references through the candidate store
imxyanua Nov 4, 2026
f070a49
Block publication of invalidated distillation drafts
imxyanua Nov 4, 2026
792e4cb
Suppress distillation success for revoked drafts
imxyanua Nov 4, 2026
0d59f02
Serialize draft correction with submit dispatch
imxyanua Nov 4, 2026
78d000b
Sync exclusive worker-lock recovery from PR #28
imxyanua Nov 4, 2026
a551020
Cover draft publication during concurrent stale-lock recovery
imxyanua Nov 4, 2026
2d8145e
Sync current main with correction-safe draft publication
imxyanua Nov 5, 2026
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
2 changes: 1 addition & 1 deletion hosts/workbuddy/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function agentGeneRequest({ text, agentGene }, payload) {
'Write the Gene with your own model: answer the request below with the JSON object only, put it between the',
'two marker lines of this command, run the command with your shell tool, and show the user what it prints.',
'',
`${env.join(' ')} node ${shellQuoted(payload.script)} ${AGENT_NAME} evolver-distill --post ${agentGene.writtenFor} <<'${end}'`,
`${env.join(' ')} node ${shellQuoted(payload.script)} ${AGENT_NAME} evolver-distill --post ${shellQuoted(agentGene.writtenFor)} <<'${end}'`,
'<the JSON object>',
end,
'',
Expand Down
2 changes: 1 addition & 1 deletion lib/agent/defaults/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export const mcpReply = Object.freeze({
empty: (hostEvent) => asContent(EMPTY[hostEvent] ?? ''),
command: (result) => asContent(result.text, result.ok === false),
turnEnd: (ended) => asContent(ended?.agentGene
? `Reported. This task qualifies for distillation: call ${MCP_TOOLS.DISTILL} with written_for ${ended.agentGene.writtenFor} and the JSON the request below asks for, or with {"skip": "<why>"} if it holds no method worth reusing.\n\n${ended.agentGene.prompt}`
? `Reported. This task qualifies for distillation: call ${MCP_TOOLS.DISTILL} with written_for "${ended.agentGene.writtenFor}" and the JSON the request below asks for, or with {"skip": "<why>"} if it holds no method worth reusing.\n\n${ended.agentGene.prompt}`
: 'Reported.'),
lifetime: 'turn',
});
Expand Down
2 changes: 1 addition & 1 deletion lib/agent/profile.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const COMMON = Object.freeze({
stateDir: defaultStateDir,
legacy: Object.freeze([]),
reply: Object.freeze({ strategyAt: STRATEGY_AT.PROMPT, toolResultContext: false }),
payload: Object.freeze({ transcriptPath: (payload) => payload.transcript_path ?? null, gene: (payload) => payload.gene, writtenFor: (payload) => Number(payload.written_for), hostTurn: () => null }),
payload: Object.freeze({ transcriptPath: (payload) => payload.transcript_path ?? null, gene: (payload) => payload.gene, writtenFor: (payload) => payload.written_for, hostTurn: () => null }),
commands: Object.freeze({ entry: COMMAND_ENTRIES.CLI_ONLY, parse: typedFromPrompt, scriptArgs: (text) => text, scriptAnswer: (result) => result.text }),
});

Expand Down
5 changes: 4 additions & 1 deletion lib/app/commands/distill.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ export function distillText(outcome) {
case 'busy': return failure('Another process is distilling this session right now.');
case 'declined': return success(`No Gene: the turn holds no method worth reusing (${outcome.reason}).`);
case 'drafted': return success(`Distilled "${outcome.draft.title}" into a draft. Publish it with /evolver-drafts publish 1, or list drafts with /evolver-drafts.`);
case 'draft_missing': return failure('The distilled draft is no longer available.');
case 'stored': return success(shown(outcome.data ?? 'distilled'));
case 'skipped': return failure(`The Proxy kept nothing: ${outcome.reason}.`);
case 'unparsed': return failure('The Gene must be one JSON object with at least a "summary".');
case 'dropped': return failure('A newer turn replaced the one this Gene was written for; nothing was sent.');
case 'dropped': return failure('A newer turn replaced the one this Gene was written for.');
case 'superseded': return failure('A draft with the same ID is already saved.');
case 'cleanup_failed': return failure('A correction invalidated the draft. Evolver could not remove it. Check /evolver-drafts and discard it if it is still listed.');
default: return failure(`Distillation ${outcome.status}${outcome.error ? `: ${outcome.error}` : ''}.`);
}
}
Expand Down
27 changes: 24 additions & 3 deletions lib/app/commands/drafts.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 EvoMap

import { draftNamed, draftsListText, reverifiedOf, submitRequestOf, withoutDraft } from '../../distill.js';
import { DISTILL_TIMEOUT_MS } from '../distill-session.js';
import { draftNamed, draftsListText, reverifiedOf, sameDraftSource, submitRequestOf, withoutDraft } from '../../distill.js';
import { DISTILL_TIMEOUT_MS, draftWasCorrected } from '../distill-session.js';
import { failure, success } from './result.js';

const USAGE = 'Usage: /evolver-drafts [publish|discard <number>]';
Expand All @@ -12,9 +12,21 @@ async function discard({ drafts }, draft) {
return success(`Discarded "${draft.title}".`);
}

const invalidatedDraft = (draft) => failure(`"${draft.title}" was invalidated by a correction and cannot be published. You can discard it.`);

async function publicationBlock(ports, draft) {
if (await draftWasCorrected(ports, draft)) return invalidatedDraft(draft);
if (draft.pendingCandidateCheck) return failure(`"${draft.title}" was not confirmed by distillation and cannot be published. You can discard it.`);
return null;
}

async function publish(ports, draft, signal) {
if (draft.publishable === false || !draft.publish_receipt) {
const blocked = await publicationBlock(ports, draft);
if (blocked) return blocked;
const verified = await ports.proxy.request('POST', '/asset/reverify', { assets: draft.assets, persist: false }, { timeoutMs: DISTILL_TIMEOUT_MS, signal });
const invalidated = await publicationBlock(ports, draft);
if (invalidated) return invalidated;
const promoted = verified.ok && reverifiedOf(verified.data);
if (!promoted) {
return failure(`"${draft.title}" needs trusted Proxy verification before publication: ${verified.error ?? verified.data?.error ?? 'invalid reverification response'}. The draft was kept.`);
Expand All @@ -24,6 +36,7 @@ async function publish(ports, draft, signal) {
await ports.drafts.update((list) => {
const current = list.find((existing) => existing.id === draft.id);
if (!current || current.createdAt !== draft.createdAt
|| (draft.sourceCandidate ? !sameDraftSource(current, draft) : current.sourceCandidate)
|| current.publishable !== draft.publishable
|| Boolean(current.publish_receipt) !== Boolean(draft.publish_receipt)) return undefined;
saved = true;
Expand All @@ -32,8 +45,16 @@ async function publish(ports, draft, signal) {
if (!saved) return failure(`"${draft.title}" changed or was discarded during verification; it was not submitted.`);
draft = next;
}
const blocked = await publicationBlock(ports, draft);
if (blocked) return blocked;
const request = submitRequestOf(draft);
const answered = await ports.proxy.request(request.method, request.path, request.body, { signal });
// 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 = draft.sourceCandidate
? await ports.draftCorrections.withSession(draft.sessionId, async () => (await draftWasCorrected(ports, draft)) ? null : dispatch(), signal)
: dispatch();
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));
return success(`Submitted "${draft.title}" to the EvoMap Hub for review (message ${answered.data?.message_id ?? 'pending'}).`);
Expand Down
71 changes: 57 additions & 14 deletions lib/app/distill-session.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 EvoMap

import { withDraft } from '../distill.js';
import { sameDraftSource, withConfirmedDraft, withDraft, withoutCorrectedDraft } from '../distill.js';
import { candidateSessionOf, claimedDue, claimedOwn, hasDueCandidate, isCurrentCandidate, nextDueAt, settledCandidate, withoutExpiredLeftovers } from '../distill/candidate.js';
import { argumentsOf, declineOf, distillBody, distillPrompt } from '../distill/prompt.js';
import { distillOutcomeOf, isSettled, needsAgentModel } from '../distill/result.js';
Expand All @@ -25,14 +25,54 @@ async function claim({ candidates, owner, clock }, claimOf) {
return claimed;
}

const settle = ({ candidates, clock }, { sessionId, candidate }, outcome, countsFailure) =>
candidates.update((pending) => settledCandidate(pending, sessionId, candidate, { done: isSettled(outcome), counted: countsFailure, now: clock.now() }));
function settle({ candidates, clock }, { sessionId, candidate }, outcome, countsFailure) {
if (outcome?.status === 'dropped' || outcome?.status === 'cleanup_failed') return;
return candidates.update((pending) => settledCandidate(pending, sessionId, candidate, { done: isSettled(outcome), counted: countsFailure, now: clock.now() }));
}

export function draftWasCorrected({ draftCorrections }, draft) {
const source = draft.sourceCandidate;
return source ? draftCorrections.has(draft.sessionId, source.turn, source.sessionEpoch) : false;
}

async function confirmDraftOutcomes(ports, outcomes) {
if (!outcomes.some((outcome) => outcome.status === 'drafted')) return outcomes;
const list = await ports.drafts.read();
return Promise.all(outcomes.map(async (outcome) => {
if (outcome.status !== 'drafted') return outcome;
const saved = Array.isArray(list) && list.some((draft) => !draft.pendingCandidateCheck && sameDraftSource(draft, outcome.draft));
if (saved && !await draftWasCorrected(ports, outcome.draft)) return outcome;
return { status: 'draft_missing', ...(outcome.usage && { usage: outcome.usage }) };
}));
}

async function saveDraft({ drafts, candidates }, { sessionId, candidate }, draft) {
const written = await drafts.update((list) => withDraft(list, { ...draft, pendingCandidateCheck: true }));
if (isCurrentCandidate(await candidates.read(), sessionId, candidate)) {
if (!written) return { status: 'superseded' };
const confirmed = await drafts.update((list) => withConfirmedDraft(list, draft));
return confirmed ? null : { status: 'draft_missing' };
}
if (!written) return { status: 'dropped' };
try {
await drafts.update((list) => withoutCorrectedDraft(list, draft));
return { status: 'dropped' };
} catch {
return { status: 'cleanup_failed' };
}
}

async function post(ports, { sessionId, candidate, trigger }, args, usage, signal) {
const body = distillBody({ platform: ports.identity.agent, sessionId: taskSessionId(ports.identity, sessionId), candidate, args, usage, trigger });
const result = await ports.proxy.request('POST', '/conversation/distill', body, { timeoutMs: DISTILL_TIMEOUT_MS, signal });
const outcome = distillOutcomeOf(result, { title: args.title, sessionId }, ports.clock.now());
if (outcome.draft) await ports.drafts.update((drafts) => withDraft(drafts, outcome.draft));
if (outcome.draft) {
if (!isCurrentCandidate(await ports.candidates.read(), sessionId, candidate)) return { status: 'dropped' };
const draft = { ...outcome.draft, sourceCandidate: { at: candidate.at, turn: candidate.turn, sessionEpoch: candidate.sessionEpoch } };
const correction = await saveDraft(ports, { sessionId, candidate }, draft);
if (correction) return correction;
return { ...outcome, draft, ...(usage && { usage }) };
}
return { ...outcome, ...(usage && { usage }) };
}

Expand Down Expand Up @@ -71,15 +111,17 @@ async function distillClaimed(ports, claimed, focus) {
async function run(ports, claimed, { focus = '', countModelFailures = true } = {}) {
const outcome = await distillClaimed(ports, claimed, focus);
const counts = outcome.status !== 'stopped' && (countModelFailures || !needsAgentModel(outcome));
if (outcome.status !== 'dropped') await settle(ports, claimed, outcome, counts);
await settle(ports, claimed, outcome, counts);
return outcome;
}

// A command run outside any session (a terminal) acts on the newest candidate, or the one a Gene was written for.
async function sessionToDistill({ candidates }, { sessionId, anySession, at }) {
async function sessionToDistill({ candidates }, { sessionId, anySession, writtenFor }) {
if (!anySession) return sessionId;
const pending = await candidates.read();
if (pending[sessionId] || !anySession) return sessionId;
return candidateSessionOf(pending, { at }) ?? sessionId;
if (writtenFor === undefined) return pending[sessionId] ? sessionId : candidateSessionOf(pending) ?? sessionId;
const match = Object.entries(pending).find(([id, candidate]) => candidate && candidates.referenceOf(id, candidate) === writtenFor);
return match?.[0] ?? sessionId;
}

/**
Expand All @@ -94,15 +136,16 @@ export async function distillSession(ports, { sessionId: asked, focus = '', anyS
if (!claimed) return { status: 'busy' };
const outcome = await run(ports, claimed, { focus, countModelFailures: false });
await armDistillWakeUp(ports);
if (!needsAgentModel(outcome)) return (await confirmDraftOutcomes(ports, [outcome]))[0];
const due = (await ports.candidates.read())[sessionId];
return needsAgentModel(outcome) && due ? { ...outcome, agentGene: { writtenFor: due.at, prompt: distillPrompt(due, focus) } } : outcome;
return due ? { ...outcome, agentGene: { writtenFor: ports.candidates.referenceOf(sessionId, due), prompt: distillPrompt(due, focus) } } : outcome;
}

/** Distills the candidates that are due: their session is over (ended, or killed with its owner) or quiet. */
export async function distillDue(ports) {
const outcomes = [];
for (const claimed of (await claim(ports, claimedDue)) ?? []) outcomes.push(await run(ports, claimed));
return outcomes;
return confirmDraftOutcomes(ports, outcomes);
}

/** Whether distillation runs in the background here: a model outside the conversation, and the person did not turn it off. */
Expand Down Expand Up @@ -136,23 +179,23 @@ export async function dropExpiredLeftovers({ candidates, owner, clock }) {
}

/**
* Posts the Gene the agent's own model wrote for the candidate stamped `writtenFor`, or settles it when the agent
* Posts the Gene the agent's own model wrote for the candidate identified by `writtenFor`, or settles it when the agent
* judged it not worth one. A Gene written for a candidate since replaced is not sent.
*/
export async function postAgentGene(ports, { sessionId: asked, text, writtenFor, anySession = false }) {
const sessionId = await sessionToDistill(ports, { sessionId: asked, anySession, at: writtenFor });
const sessionId = await sessionToDistill(ports, { sessionId: asked, anySession, writtenFor });
const declined = declineOf(text);
const args = declined ? null : argumentsOf(text);
if (!declined && !args) return { status: 'unparsed' };
const claimed = await claim(ports, (pending, owner) => claimedOwn(pending, sessionId, owner));
if (!claimed) return (await ports.candidates.read())[sessionId] ? { status: 'busy' } : { status: 'nothing' };
let outcome;
try {
if (claimed.candidate.at !== writtenFor) return { status: 'dropped' };
if (ports.candidates.referenceOf(sessionId, claimed.candidate) !== writtenFor) return { status: 'dropped' };
outcome = declined ? { status: 'declined', reason: declined } : await post(ports, claimed, args, null, AbortSignal.timeout(DISTILL_TIMEOUT_MS));
return outcome;
} finally {
await settle(ports, claimed, outcome, false);
await armDistillWakeUp(ports);
}
return (await confirmDraftOutcomes(ports, [outcome]))[0];
}
10 changes: 8 additions & 2 deletions lib/app/ports.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
* @property {() => Promise<object>} read
* @property {(mutate: Function) => Promise<object|undefined>} update
*
* @typedef {object} DraftCorrections
* @property {<T>(sessionId: string, work: () => T|Promise<T>, signal?: AbortSignal) => Promise<T>} withSession
* @property {(sessionId: string, turn: number, sessionEpoch?: number) => Promise<void>} mark
* @property {(sessionId: string, turn: number, sessionEpoch?: number) => Promise<boolean>} has
*
* @typedef {object} ReportQueue
* @property {(entries: { report: object, sessionId: string }[]) => Promise<void>} enqueue
*
Expand All @@ -42,7 +47,7 @@
* @property {(entry: { sessionId, projectDir, reuses, editedBefore: Map }) => Promise<void>} remember
* @property {(due: { sessionId, projectDir }) => Promise<{ due: object[], commit: () => Promise<void> }>} takeDue
*
* @typedef {object} Proxy
* @typedef {object} Proxy request initiates the transport before returning its promise.
* @property {(method: string, path: string, body?: object, options?: { timeoutMs?: number, signal?: AbortSignal }) =>
* Promise<{ ok: true, data: any } | { ok: false, error: string, status?: number, data?: any }>} request
*
Expand Down Expand Up @@ -104,8 +109,9 @@
* @property {SessionStore} sessions
* @property {TurnNotes} notes
* @property {EditBaseline} baseline
* @property {TableStore} candidates
* @property {TableStore & { referenceOf: (sessionId: string, candidate: object) => string }} candidates
* @property {TableStore} drafts
* @property {DraftCorrections} draftCorrections
* @property {ReportQueue} reports
* @property {Outbox} outbox
* @property {RetentionStore} retention
Expand Down
17 changes: 12 additions & 5 deletions lib/app/prompt.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 EvoMap

import { withoutDraftsForTurn } from '../distill.js';
import { withSessionActivity, withoutCandidate } from '../distill/candidate.js';
import { promptTextOf, recallStrategy, taskTextOf } from '../recall.js';
import { correctionReportsOf, followUpLabelOf } from '../session/follow-ups.js';
Expand All @@ -15,16 +16,22 @@ const LATE_RECIPE_WAIT_MS = 6_000;

/**
* The next prompt is the only verdict on a turn available without asking anyone: a correction drops the
* session's candidate first, then the last turn's reuses are reported again under `:correction`. The session is
* marked corrected before the drop, so a re-judge of that turn writing meanwhile sees the mark and takes its write back.
* session's candidate and that turn's drafts, then its reuses are reported again under `:correction`. Mark the
* turn first so a re-judge sees the correction, and drop the candidate before removing drafts so a late write
* after the removal still sees the candidate is gone.
* Any other prompt is the session's activity, which a candidate waits out before it is distilled.
*/
async function labelFollowUp(ports, sessionId, prompt) {
const { sessions, candidates, reports, clock } = ports;
const { sessions, candidates, drafts, draftCorrections, reports, clock } = ports;
const label = followUpLabelOf(prompt);
if (label === 'correction') {
await sessions.update(sessionId, (stored) => correctedSession(sessionOf(stored)));
const { correctedTurn, sessionEpoch } = await draftCorrections.withSession(sessionId, async () => {
const corrected = await sessions.update(sessionId, (stored) => correctedSession(sessionOf(stored)));
if (corrected.correctedTurn > 0) await draftCorrections.mark(sessionId, corrected.correctedTurn, corrected.sessionEpoch);
return corrected;
});
await candidates.update((pending) => withoutCandidate(pending, sessionId));
if (correctedTurn > 0) await drafts.update((list) => withoutDraftsForTurn(list, sessionId, correctedTurn, sessionEpoch));
} else {
await candidates.update((pending) => withSessionActivity(pending, sessionId, clock.now()));
}
Expand Down Expand Up @@ -102,7 +109,7 @@ export async function promptSubmitted(ports, { sessionId, prompt, turnId = null,
if (!isCommand) await labelFollowUp(ports, sessionId, prompt);
await ports.notes.clear(sessionId);
const opened = await ports.sessions.update(sessionId, (stored) =>
openedTurn(sessionOf(stored), { turnId, turnStart, hostTurn, prompt: prompt ? promptTextOf(prompt) : null, task: taskTextOf(prompt), pid: ports.owner.current(), isCommand }));
openedTurn(sessionOf(stored), { turnId, turnStart, hostTurn, prompt: prompt ? promptTextOf(prompt) : null, task: taskTextOf(prompt), pid: ports.owner.current(), isCommand, now: ports.clock.now() }));
const wantsRecall = !isCommand && ports.settings?.recall !== false;
const { injection, late } = wantsRecall ? await recalled(ports, sessionId, { turn: opened.turn, prompt, waitMs, keepLate, holdForToolResult }) : { injection: '' };
return { turn: opened.turn, injection, ...(late && { late }) };
Expand Down
Loading
Loading