mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-27 03:51:42 +00:00
E2E measurement on the synthesis branch showed every history fetch (Main
bootstrap, project open, Load-older) ran TWICE: the syncHistory replay
marks historical cards finished, and each finished transition called
scheduleHistorySync(), whose 700ms debounce then refetched the entire
window from a quiet server right after it had just been fetched.
Fix, narrowly scoped to replay-raised transitions:
- _historyReplayActive flag spans the synchronous replay dispatch in
syncHistory (pass 1 suppressed summaries, pass 2 finishLiveCard, the
terminal-resolution sweep — both the rebuildAll batch and the routine
branch), so no live WS frame can ever observe it.
- scheduleHistorySync delegates to createHistoryResyncScheduler
(chat_render_batch.js, pure/injectable for node tests): schedule() is
a no-op while the replay flag is up; otherwise the debounced REAL
syncHistory({includeUser:false}) fetch is unchanged [GPT#12]. Both
live finished-transition callsites stay unconditional.
- node tests: replay-time schedule() is dropped, live schedule() fires
a real 700ms debounced run, and source pins hold the flag around the
replay block and the two untouched live callsites.
Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
148 lines
5.9 KiB
JavaScript
148 lines
5.9 KiB
JavaScript
// perf2 P4 (RENDER-BATCH): pure helpers behind chat.js's rebuildAll replay
|
|
// batch and the "Load older" window escalation. Dependency-free at import time
|
|
// so node tests can exercise them directly.
|
|
|
|
/**
|
|
* perf2 P4 follow-up (double-fetch fix): the debounced post-completion resync
|
|
* behind chat.js's scheduleHistorySync. Finished transitions REPLAYED by
|
|
* syncHistory itself (pass 1 suppressed task summaries, pass 2 / terminal-
|
|
* resolution finishLiveCard) must NOT schedule the resync: those rows just
|
|
* arrived from the canonical history response, so the 700ms refetch was
|
|
* re-downloading the whole window after EVERY history load (Main bootstrap,
|
|
* project open, Load-older, reconnect rebuild). A LIVE completion — a WS
|
|
* frame arriving outside any replay — must keep scheduling a REAL fetch
|
|
* [GPT#12]: a lost task_done is healed only by refetching.
|
|
*/
|
|
export function createHistoryResyncScheduler({
|
|
isReplayActive,
|
|
run,
|
|
debounceMs = 700,
|
|
setTimer = (fn, ms) => setTimeout(fn, ms),
|
|
clearTimer = (id) => clearTimeout(id),
|
|
}) {
|
|
let timer = null;
|
|
return {
|
|
schedule() {
|
|
if (isReplayActive()) return false;
|
|
if (timer != null) clearTimer(timer);
|
|
timer = setTimer(() => {
|
|
timer = null;
|
|
run();
|
|
}, debounceMs);
|
|
return true;
|
|
},
|
|
cancel() {
|
|
if (timer == null) return;
|
|
clearTimer(timer);
|
|
timer = null;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sort key for a top-level timeline node: its stamped `data-ts` epoch, or
|
|
* +Infinity for timestamp-free nodes so they keep the historical "append at
|
|
* the end (before typing)" placement of insertTimelineNode.
|
|
*/
|
|
export function timelineNodeSortKey(node) {
|
|
const raw = node?.dataset?.ts;
|
|
const ts = raw == null || raw === '' ? NaN : Number(raw);
|
|
return Number.isFinite(ts) ? ts : Infinity;
|
|
}
|
|
|
|
/**
|
|
* Stable chronological order for a batch of collected timeline nodes: key is
|
|
* the stamped ts, tie-break is collection (arrival) order. This reproduces
|
|
* insertTimelineNode's semantics over an initially-empty feed — equal
|
|
* timestamps preserve arrival order (chat_chronology pin), undated nodes land
|
|
* at the end in arrival order.
|
|
*/
|
|
export function orderBatchNodes(nodes) {
|
|
return nodes
|
|
.map((node, index) => ({ node, index, key: timelineNodeSortKey(node) }))
|
|
.sort((a, b) => {
|
|
if (a.key === b.key) return a.index - b.index;
|
|
return a.key < b.key ? -1 : 1;
|
|
})
|
|
.map((entry) => entry.node);
|
|
}
|
|
|
|
/**
|
|
* One rebuildAll replay batch: collects top-level nodes destined for the feed
|
|
* (instead of per-row live-DOM chronological insertion), remembers which live
|
|
* cards need their one final meta/count/layout pass, and defers the per-frame
|
|
* typing/status writes to a single application after the mount.
|
|
*
|
|
* mount() performs the ONE DOM insertion of the whole replay: a stable sort,
|
|
* one detached fragment, one insertBefore ahead of the typing indicator
|
|
* (typing stays last). No awaits happen between the feed clearing and this
|
|
* mount — the caller keeps the whole section synchronous [GPT#14].
|
|
*/
|
|
export function createRebuildBatch(doc = null) {
|
|
const nodes = [];
|
|
const seen = new Set();
|
|
const touched = new Set();
|
|
return {
|
|
touched,
|
|
status: null,
|
|
typingHidden: false,
|
|
collect(node) {
|
|
if (!node || seen.has(node)) return;
|
|
seen.add(node);
|
|
nodes.push(node);
|
|
},
|
|
touch(record) {
|
|
if (record) touched.add(record);
|
|
},
|
|
mount(messages, typing = null) {
|
|
if (!messages) return;
|
|
const ordered = orderBatchNodes(nodes);
|
|
const ownerDoc = doc || messages.ownerDocument || globalThis.document;
|
|
const fragment = ownerDoc.createDocumentFragment();
|
|
for (const node of ordered) fragment.appendChild(node);
|
|
if (typing && typing.parentNode === messages) messages.insertBefore(fragment, typing);
|
|
else messages.appendChild(fragment);
|
|
},
|
|
};
|
|
}
|
|
|
|
// "Load older" quota escalation ladder (perf2 P4.5): the DEFAULT request sends
|
|
// no quota params (the server window governs); each click asks for explicitly
|
|
// larger n_human/n_progress until the server-side caps (1500/600).
|
|
export const LOAD_OLDER_QUOTA_STEPS = [
|
|
{ n_human: 400, n_progress: 240 },
|
|
{ n_human: 1500, n_progress: 600 },
|
|
];
|
|
|
|
/** Next explicit-quota step above `current` (null = server default window). */
|
|
export function nextQuotaEscalation(current = null) {
|
|
const currentHuman = Number(current?.n_human) || 0;
|
|
return LOAD_OLDER_QUOTA_STEPS.find((step) => step.n_human > currentHuman) || null;
|
|
}
|
|
|
|
/**
|
|
* Presentation state for the top-of-feed "Load older" control, driven by the
|
|
* SERVER's window truncation verdict (window.complete / window.truncated_by,
|
|
* perf2 P3.2 [Fable#2]) — never by a client guess:
|
|
* - hidden: the window is complete (or the server predates the field);
|
|
* - button: quota-truncated AND a larger explicit quota is still available;
|
|
* - notice: nothing more can be loaded from here — the honest boundary text
|
|
* names BOTH the on-disk archive floor and the subagent lineage cap
|
|
* [GPT#11], so a short-history user is never told about phantom archives.
|
|
*/
|
|
export function loadOlderControlState(windowInfo = null, quota = null) {
|
|
if (!windowInfo || typeof windowInfo !== 'object' || windowInfo.complete === true) {
|
|
return { mode: 'hidden', label: '' };
|
|
}
|
|
const causes = Array.isArray(windowInfo.truncated_by)
|
|
? windowInfo.truncated_by.map(String)
|
|
: [];
|
|
if (causes.includes('quota') && nextQuotaEscalation(quota)) {
|
|
return { mode: 'button', label: 'Load older messages' };
|
|
}
|
|
return {
|
|
mode: 'notice',
|
|
label: 'Older messages stay in on-disk archives, and deep subagent lineage '
|
|
+ 'is capped per window — this view is at its maximum depth.',
|
|
};
|
|
}
|