fix: harden long conversation continuity
Memind CI / Test, build, and release guards (push) Successful in 1m28s

This commit is contained in:
john
2026-07-27 21:39:34 +08:00
parent 5ae166fc44
commit 48c61a3279
16 changed files with 1635 additions and 94 deletions
+68 -17
View File
@@ -85,6 +85,11 @@ export function attachPortalSessionRoutes(
maybeRepairPageDataAfterFinish,
markPageDeliveryContractReadyFn =
markPageDeliveryContractReady,
finishDeliveryRetryDelaysMs = [250, 1_000],
finishDeliveryRetryWaitFn = (delayMs) =>
new Promise((resolve) =>
setTimeout(resolve, delayMs),
),
logger = console,
} = {},
) {
@@ -481,7 +486,7 @@ export function attachPortalSessionRoutes(
// After Finish, refresh the snapshot and persist any newly generated public
// workspace HTML into the asset store before a later restart rebuilds the
// workspace from DB-backed assets only.
const onAfterFinish = async (sid, uid) => {
const finalizeAfterFinishOnce = async (sid, uid) => {
beginSessionPageDelivery(sid);
try {
const apiFetchFn = async (pathname, init) => {
@@ -614,23 +619,33 @@ export function attachPortalSessionRoutes(
].includes(
String(pageDataDelivery?.skipped ?? ''),
);
if (htmlReady && pageDataReady) {
const publicHtmlRelativePaths =
[
...new Set([
...(syncResult?.publicHtmlRelativePaths ??
[]),
...deliveryContractWrites.keys(),
]),
].sort();
const pgRequired = [
...(Array.isArray(messages) ? messages : []),
].some(
(message) =>
message?.role === 'user' &&
message?.metadata?.memindRun?.pgRequired ===
true,
const publicHtmlRelativePaths =
[
...new Set([
...(syncResult?.publicHtmlRelativePaths ??
[]),
...deliveryContractWrites.keys(),
]),
].sort();
const pgRequired = [
...(Array.isArray(messages) ? messages : []),
].some(
(message) =>
message?.role === 'user' &&
message?.metadata?.memindRun?.pgRequired ===
true,
);
if (
publicHtmlRelativePaths.length > 0 &&
(!htmlReady || !pageDataReady)
) {
throw new Error(
'page delivery guards are not ready: '
+ `html=${htmlDelivery?.skipped ?? 'unknown'} `
+ `pageData=${pageDataDelivery?.skipped ?? 'unknown'}`,
);
}
if (htmlReady && pageDataReady) {
for (const relativePath of publicHtmlRelativePaths) {
// A Finish-only write may not have reached the stream callback. This
// also upgrades an early partial stream contract with the definitive
@@ -684,6 +699,42 @@ export function attachPortalSessionRoutes(
endSessionPageDelivery(sid);
}
};
const onAfterFinish = async (sid, uid) => {
const retryDelays = Array.isArray(
finishDeliveryRetryDelaysMs,
)
? finishDeliveryRetryDelaysMs
.map((delayMs) =>
Math.max(0, Number(delayMs) || 0),
)
: [];
let lastError = null;
for (
let attempt = 0;
attempt <= retryDelays.length;
attempt += 1
) {
if (attempt > 0) {
await finishDeliveryRetryWaitFn(
retryDelays[attempt - 1],
);
}
try {
return await finalizeAfterFinishOnce(
sid,
uid,
);
} catch (error) {
lastError = error;
logger.warn(
`[MindSpace] Finish delivery finalization failed for session ${sid} `
+ `(attempt ${attempt + 1}/${retryDelays.length + 1}): `
+ `${error instanceof Error ? error.message : error}`,
);
}
}
throw lastError;
};
return tkmindProxy.proxySessionEvents(
req,
res,
+151
View File
@@ -808,3 +808,154 @@ test('Finish marks streamed HTML contracts ready when final sync omits one path'
'public/shop.html',
]);
});
test('Finish retries delivery finalization after a transient post-Finish failure', async () => {
let hooks = null;
let syncPageAttempts = 0;
const readyPaths = [];
const warnings = [];
const setup = createDependencies({
finishDeliveryRetryDelaysMs: [0],
finishDeliveryRetryWaitFn: async () => {},
getTkmindProxy: () => ({
async resolveTarget(sessionId) {
return `target:${sessionId}`;
},
async apiFetchTo() {
return createUpstream();
},
proxySessionEvents(_req, _res, _sessionId, receivedHooks) {
hooks = receivedHooks;
},
}),
getMindSpacePublicFinish: () => ({
async materializeSessionEvent() {
return {
publicHtmlRelativePaths: [],
publicHtmlArtifacts: [],
};
},
async syncAfterFinish() {
return {
publicHtmlRelativePaths: ['public/survey.html'],
docxSync: { missing: [] },
};
},
async preparePageDataAfterFinish() {
return {
autoBind: {
bound: [],
skipped: [],
errors: [],
},
evaluation: {
structuralPageData: false,
relevantFiles: [],
},
};
},
}),
async syncUserGeneratedPages() {
syncPageAttempts += 1;
if (syncPageAttempts === 1) {
throw new Error('temporary page sync failure');
}
},
async markPageDeliveryContractReadyFn(input) {
readyPaths.push(input.relativePath);
return true;
},
logger: {
warn(...items) {
warnings.push(items.join(' '));
},
},
});
const api = createRouterRecorder();
attachPortalSessionRoutes(api, setup.dependencies);
await api.routes.get('GET /sessions/:sessionId/events')(
createRequest(),
createResponseRecorder(),
() => {},
);
await hooks.onAfterFinish('session-1', 'user-1');
assert.equal(syncPageAttempts, 2);
assert.deepEqual(setup.calls.begin, ['session-1', 'session-1']);
assert.deepEqual(setup.calls.end, ['session-1', 'session-1']);
assert.deepEqual(readyPaths, ['public/survey.html']);
assert.match(warnings[0], /temporary page sync failure/);
});
test('Finish retries when a delivery guard is initially not ready', async () => {
let hooks = null;
let pageDataChecks = 0;
const readyPaths = [];
const setup = createDependencies({
finishDeliveryRetryDelaysMs: [0],
finishDeliveryRetryWaitFn: async () => {},
getTkmindProxy: () => ({
async resolveTarget(sessionId) {
return `target:${sessionId}`;
},
async apiFetchTo() {
return createUpstream();
},
proxySessionEvents(_req, _res, _sessionId, receivedHooks) {
hooks = receivedHooks;
},
}),
getMindSpacePublicFinish: () => ({
async materializeSessionEvent() {
return {
publicHtmlRelativePaths: [],
publicHtmlArtifacts: [],
};
},
async syncAfterFinish() {
return {
publicHtmlRelativePaths: ['public/survey.html'],
docxSync: { missing: [] },
};
},
async preparePageDataAfterFinish() {
return {
autoBind: {
bound: [],
skipped: [],
errors: [],
},
evaluation: {
structuralPageData: true,
relevantFiles: [{ relativePath: 'public/survey.html' }],
},
};
},
}),
async maybeRepairPageDataAfterFinishFn() {
pageDataChecks += 1;
return {
skipped: pageDataChecks === 1 ? 'limit' : 'ok',
};
},
async markPageDeliveryContractReadyFn(input) {
readyPaths.push(input.relativePath);
return true;
},
});
const api = createRouterRecorder();
attachPortalSessionRoutes(api, setup.dependencies);
await api.routes.get('GET /sessions/:sessionId/events')(
createRequest(),
createResponseRecorder(),
() => {},
);
await hooks.onAfterFinish('session-1', 'user-1');
assert.equal(pageDataChecks, 2);
assert.deepEqual(setup.calls.begin, ['session-1', 'session-1']);
assert.deepEqual(setup.calls.end, ['session-1', 'session-1']);
assert.deepEqual(readyPaths, ['public/survey.html']);
});