fix: harden long conversation continuity
Memind CI / Test, build, and release guards (push) Successful in 1m28s
Memind CI / Test, build, and release guards (push) Successful in 1m28s
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user