@yeaft/webchat-agent 1.0.675 → 1.0.677
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
Binary file
|
package/package.json
CHANGED
|
@@ -2034,14 +2034,18 @@ export class ConversationStore {
|
|
|
2034
2034
|
* copySession 验证前缀未被替换、删改或插入;仅追加新行不会使边界失效。
|
|
2035
2035
|
*
|
|
2036
2036
|
* @param {string} sessionId Agent-owned Session identity
|
|
2037
|
+
* @param {{beforeMessageId?: string}} [options] Recovery fence at a known durable human request
|
|
2037
2038
|
* @returns {object} Opaque serializable boundary for options.throughBoundary
|
|
2038
2039
|
* @throws {Error} invalid_fork_boundary for a missing Session identity
|
|
2039
2040
|
*/
|
|
2040
|
-
captureSessionCopyBoundary(sessionId) {
|
|
2041
|
+
captureSessionCopyBoundary(sessionId, options = {}) {
|
|
2041
2042
|
if (typeof sessionId !== 'string' || !sessionId) {
|
|
2042
2043
|
throw historyError('invalid_fork_boundary', 'Session identity is required');
|
|
2043
2044
|
}
|
|
2044
|
-
const { rows, streamId } = this.#loadDurableSession(sessionId);
|
|
2045
|
+
const { rows: durableRows, streamId } = this.#loadDurableSession(sessionId);
|
|
2046
|
+
const rows = Object.hasOwn(options, 'beforeMessageId')
|
|
2047
|
+
? durableRows.slice(0, this.#sessionCopyBeforeMessageCutoff(durableRows, streamId, options.beforeMessageId))
|
|
2048
|
+
: durableRows;
|
|
2045
2049
|
return {
|
|
2046
2050
|
version: 1,
|
|
2047
2051
|
sessionId,
|
|
@@ -2052,6 +2056,43 @@ export class ConversationStore {
|
|
|
2052
2056
|
};
|
|
2053
2057
|
}
|
|
2054
2058
|
|
|
2059
|
+
#sessionCopyBeforeMessageCutoff(rows, streamId, beforeMessageId) {
|
|
2060
|
+
const fence = typeof beforeMessageId === 'string' ? beforeMessageId.trim() : '';
|
|
2061
|
+
if (!fence) throw historyError('invalid_fork_boundary', 'beforeMessageId must be a nonempty string');
|
|
2062
|
+
const index = rows.findIndex(row => row.id === fence);
|
|
2063
|
+
if (index < 0) throw historyError('fork_boundary_not_found', `Human message not found: ${fence}`);
|
|
2064
|
+
if (!isHumanRequest(rows[index])) throw historyError('invalid_fork_boundary', 'beforeMessageId must identify a durable human request');
|
|
2065
|
+
const user = rows[index];
|
|
2066
|
+
// A multi-VP legacy request can have several persisted human variants.
|
|
2067
|
+
// Fence before the first variant, never before just the running VP.
|
|
2068
|
+
const originStream = row => row.origin?.streamId || streamId;
|
|
2069
|
+
const variant = row => isHumanRequest(row) && user.clientMessageId
|
|
2070
|
+
&& row.clientMessageId === user.clientMessageId && originStream(row) === originStream(user);
|
|
2071
|
+
let humanCutoff = user.clientMessageId ? rows.findIndex(variant) : index;
|
|
2072
|
+
// Follow only this request's causal closure, not every turn in the
|
|
2073
|
+
// suffix: a late answer to an older request must not pull the fence back
|
|
2074
|
+
// into (and truncate) that older request. Client/VP identities are scoped
|
|
2075
|
+
// by origin stream: a merged Session may contain independent collisions.
|
|
2076
|
+
const vpTurnKey = row => row.turnId
|
|
2077
|
+
? JSON.stringify([originStream(row), row.speakerVpId || row.vpId || '', row.turnId]) : null;
|
|
2078
|
+
const clientKey = (row, id) => JSON.stringify([originStream(row), id]);
|
|
2079
|
+
const excludedRows = new Set(rows.filter(row => row === user || variant(row)));
|
|
2080
|
+
let previousSize;
|
|
2081
|
+
do {
|
|
2082
|
+
previousSize = excludedRows.size;
|
|
2083
|
+
const excluded = new Set([...excludedRows].map(row => row.id));
|
|
2084
|
+
const clients = new Set([...excludedRows].filter(row => row.clientMessageId)
|
|
2085
|
+
.map(row => clientKey(row, row.clientMessageId)));
|
|
2086
|
+
const turns = new Set([...excludedRows].map(vpTurnKey).filter(Boolean));
|
|
2087
|
+
for (const row of rows) {
|
|
2088
|
+
if (excluded.has(row.causalRootId) || clients.has(clientKey(row, row.causalRootId))
|
|
2089
|
+
|| turns.has(vpTurnKey(row))) excludedRows.add(row);
|
|
2090
|
+
}
|
|
2091
|
+
} while (excludedRows.size > previousSize);
|
|
2092
|
+
humanCutoff = Math.min(humanCutoff, rows.findIndex(row => excludedRows.has(row)));
|
|
2093
|
+
return humanCutoff;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2055
2096
|
#sessionCopyPrefixDigest(rows) {
|
|
2056
2097
|
const hash = createHash('sha256');
|
|
2057
2098
|
for (const row of rows) hash.update(JSON.stringify(row)).update('\n');
|
|
@@ -2136,40 +2177,7 @@ export class ConversationStore {
|
|
|
2136
2177
|
}
|
|
2137
2178
|
}
|
|
2138
2179
|
if (Object.prototype.hasOwnProperty.call(options, 'beforeMessageId')) {
|
|
2139
|
-
|
|
2140
|
-
if (!fence) throw historyError('invalid_fork_boundary', 'beforeMessageId must be a nonempty string');
|
|
2141
|
-
const index = rows.findIndex(row => row.id === fence);
|
|
2142
|
-
if (index < 0) throw historyError('fork_boundary_not_found', `Human message not found: ${fence}`);
|
|
2143
|
-
if (!isHumanRequest(rows[index])) throw historyError('invalid_fork_boundary', 'beforeMessageId must identify a durable human request');
|
|
2144
|
-
const user = rows[index];
|
|
2145
|
-
// A multi-VP legacy request can have several persisted human variants.
|
|
2146
|
-
// Fence before the first variant, never before just the running VP.
|
|
2147
|
-
const originStream = row => row.origin?.streamId || streamId;
|
|
2148
|
-
const variant = row => isHumanRequest(row) && user.clientMessageId
|
|
2149
|
-
&& row.clientMessageId === user.clientMessageId && originStream(row) === originStream(user);
|
|
2150
|
-
let humanCutoff = user.clientMessageId ? rows.findIndex(variant) : index;
|
|
2151
|
-
// Follow only this request's causal closure, not every turn in the
|
|
2152
|
-
// suffix: a late answer to an older request must not pull the fence back
|
|
2153
|
-
// into (and truncate) that older request. Client/VP identities are scoped
|
|
2154
|
-
// by origin stream: a merged Session may contain independent collisions.
|
|
2155
|
-
const vpTurnKey = row => row.turnId
|
|
2156
|
-
? JSON.stringify([originStream(row), row.speakerVpId || row.vpId || '', row.turnId]) : null;
|
|
2157
|
-
const clientKey = (row, id) => JSON.stringify([originStream(row), id]);
|
|
2158
|
-
const excludedRows = new Set(rows.filter(row => row === user || variant(row)));
|
|
2159
|
-
let previousSize;
|
|
2160
|
-
do {
|
|
2161
|
-
previousSize = excludedRows.size;
|
|
2162
|
-
const excluded = new Set([...excludedRows].map(row => row.id));
|
|
2163
|
-
const clients = new Set([...excludedRows].filter(row => row.clientMessageId)
|
|
2164
|
-
.map(row => clientKey(row, row.clientMessageId)));
|
|
2165
|
-
const turns = new Set([...excludedRows].map(vpTurnKey).filter(Boolean));
|
|
2166
|
-
for (const row of rows) {
|
|
2167
|
-
if (excluded.has(row.causalRootId) || clients.has(clientKey(row, row.causalRootId))
|
|
2168
|
-
|| turns.has(vpTurnKey(row))) excludedRows.add(row);
|
|
2169
|
-
}
|
|
2170
|
-
} while (excludedRows.size > previousSize);
|
|
2171
|
-
humanCutoff = Math.min(humanCutoff, rows.findIndex(row => excludedRows.has(row)));
|
|
2172
|
-
cutoff = Math.min(cutoff, humanCutoff);
|
|
2180
|
+
cutoff = Math.min(cutoff, this.#sessionCopyBeforeMessageCutoff(rows, streamId, options.beforeMessageId));
|
|
2173
2181
|
}
|
|
2174
2182
|
if (Object.prototype.hasOwnProperty.call(options, 'throughTurnId')) {
|
|
2175
2183
|
const throughTurnId = typeof options.throughTurnId === 'string'
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
import { delimiter, join, resolve } from 'node:path';
|
|
19
19
|
import { COLLAB_TOOL_POLICY } from './tools/registry.js';
|
|
20
20
|
import { createFullRegistry } from './tools/index.js';
|
|
21
|
-
import { existsSync, lstatSync } from 'node:fs';
|
|
21
|
+
import { existsSync, lstatSync, readFileSync, unlinkSync } from 'node:fs';
|
|
22
|
+
import { writeAtomic } from './storage/atomic.js';
|
|
22
23
|
import { randomUUID } from 'node:crypto';
|
|
23
24
|
import { DEFAULT_YEAFT_DIR } from './init.js';
|
|
24
25
|
import { Engine } from './engine.js';
|
|
@@ -146,7 +147,7 @@ import { isPromptableAgentStatus, isTerminalAgentStatus, STATUS } from './sub-ag
|
|
|
146
147
|
import { consumeNotificationForAgent } from './sub-agent/notifications.js';
|
|
147
148
|
import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
|
|
148
149
|
import { recordAgentSessionCreated, recordAgentTurn } from '../metrics.js';
|
|
149
|
-
import { TASK_RESULT_DELIVERY, isTerminalTaskStatus, taskResultDeliveryFor } from './tasks/store.js';
|
|
150
|
+
import { TaskStore, TASK_RESULT_DELIVERY, isTerminalTaskStatus, taskResultDeliveryFor } from './tasks/store.js';
|
|
150
151
|
import { formatTaskResultForVp } from './tasks/result-format.js';
|
|
151
152
|
|
|
152
153
|
const LEGACY_SKILL_COMMAND_PREFIX = 'skill:';
|
|
@@ -559,27 +560,113 @@ function hasSessionRuntimeWork(sessionId) {
|
|
|
559
560
|
return false;
|
|
560
561
|
}
|
|
561
562
|
|
|
563
|
+
// Only an opaque prefix token is persisted, never conversation text or runtime
|
|
564
|
+
// state. It survives Agent restart so orphaned task-result reentry can still
|
|
565
|
+
// fork the same safe history. ConversationStore validates it when copying.
|
|
566
|
+
function sessionCopyBoundaryPath(sessionId) {
|
|
567
|
+
const root = resolveSessionYeaftDir(ctx.CONFIG?.yeaftDir, sessionId);
|
|
568
|
+
return join(sessionsRoot(root), sessionId, 'copy-boundary.json');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function persistSessionCopyBoundary(sessionId, epoch) {
|
|
572
|
+
if (!epoch.boundary) return;
|
|
573
|
+
try {
|
|
574
|
+
writeAtomic(sessionCopyBoundaryPath(sessionId), JSON.stringify({
|
|
575
|
+
...JSON.parse(epoch.boundary), taskIds: [...epoch.taskIds],
|
|
576
|
+
}), { mode: 0o600 });
|
|
577
|
+
} catch (error) {
|
|
578
|
+
console.warn('[Yeaft] Session copy boundary persistence failed:', sessionId, error?.code || 'write_failed');
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function clearSessionCopyBoundary(sessionId) {
|
|
583
|
+
try { unlinkSync(sessionCopyBoundaryPath(sessionId)); } catch (error) {
|
|
584
|
+
if (error?.code !== 'ENOENT') console.warn('[Yeaft] Session copy boundary cleanup failed:', sessionId, error?.code || 'unlink_failed');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
562
588
|
function settleSessionCopyEpoch(sessionId) {
|
|
563
589
|
const epoch = sessionCopyEpochs.get(sessionId);
|
|
564
|
-
if (epoch && epoch.pending === 0 && !hasSessionRuntimeWork(sessionId))
|
|
590
|
+
if (epoch && epoch.pending === 0 && epoch.awaitingRecovery.size === 0 && !hasSessionRuntimeWork(sessionId)) {
|
|
591
|
+
sessionCopyEpochs.delete(sessionId);
|
|
592
|
+
clearSessionCopyBoundary(sessionId);
|
|
593
|
+
}
|
|
565
594
|
}
|
|
566
595
|
|
|
567
|
-
|
|
596
|
+
// Before runtime boot has awaited, inspect the persisted recovery inventory.
|
|
597
|
+
// Once TaskManager exists, its startup queue owns that inventory (it has already
|
|
598
|
+
// changed running tasks to orphaned). No task metadata is mutated here.
|
|
599
|
+
function sessionCopyRecoveryTasks(sessionId) {
|
|
600
|
+
if (!ctx.CONFIG?.yeaftDir) return [];
|
|
601
|
+
const manager = session?.taskManager;
|
|
602
|
+
const tasks = manager
|
|
603
|
+
? (manager.pendingStartupEvents || []).map(event => event.task)
|
|
604
|
+
: new TaskStore({ yeaftDir: ctx.CONFIG?.yeaftDir }).loadActiveTasks();
|
|
605
|
+
return tasks.filter(task => task?.sessionId === sessionId && task.ownerVpId
|
|
606
|
+
&& taskResultDeliveryFor(task) === TASK_RESULT_DELIVERY.MODEL_REENTRY);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function recoverSessionCopyTask(sessionId, epoch, task) {
|
|
610
|
+
if (epoch.unrecoverable || epoch.taskIds.has(task.id)) return;
|
|
611
|
+
try {
|
|
612
|
+
const root = resolveSessionYeaftDir(ctx.CONFIG?.yeaftDir, sessionId);
|
|
613
|
+
const token = new ConversationStore(root).captureSessionCopyBoundary(sessionId, {
|
|
614
|
+
beforeMessageId: task.source?.causalRootId,
|
|
615
|
+
});
|
|
616
|
+
if (!epoch.boundary || token.rowCount < JSON.parse(epoch.boundary).rowCount) {
|
|
617
|
+
epoch.boundary = JSON.stringify(token);
|
|
618
|
+
}
|
|
619
|
+
epoch.taskIds.add(task.id);
|
|
620
|
+
} catch {
|
|
621
|
+
// One orphan's known root cannot authorize another orphan's unknown input.
|
|
622
|
+
epoch.boundary = null;
|
|
623
|
+
epoch.unrecoverable = true;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function beginSessionActivity(sessionId, { recoveryTask = null, unknownBoundary = false } = {}) {
|
|
568
628
|
settleSessionCopyEpoch(sessionId);
|
|
569
629
|
let epoch = sessionCopyEpochs.get(sessionId);
|
|
630
|
+
const previousBoundary = epoch?.boundary;
|
|
631
|
+
const previousTaskCount = epoch?.taskIds.size;
|
|
570
632
|
if (!epoch) {
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
633
|
+
const recovering = sessionCopyRecoveryTasks(sessionId);
|
|
634
|
+
if (unknownBoundary && recoveryTask && !recovering.some(task => task.id === recoveryTask.id)) recovering.push(recoveryTask);
|
|
635
|
+
epoch = { boundary: null, taskIds: new Set(), awaitingRecovery: new Set(recovering.map(task => task.id)), unrecoverable: false, pending: 0 };
|
|
636
|
+
// A checkpoint is useful only for the tasks that actually owned it. A
|
|
637
|
+
// leftover file after a completed query must not truncate new idle history.
|
|
638
|
+
if (recovering.length) {
|
|
639
|
+
try {
|
|
640
|
+
const saved = readFileSync(sessionCopyBoundaryPath(sessionId), 'utf8');
|
|
641
|
+
const token = JSON.parse(saved);
|
|
642
|
+
if (token?.version === 1 && token.sessionId === sessionId && Array.isArray(token.taskIds)
|
|
643
|
+
&& recovering.some(task => token.taskIds.includes(task.id))) {
|
|
644
|
+
epoch.boundary = saved;
|
|
645
|
+
epoch.taskIds = new Set(token.taskIds);
|
|
646
|
+
}
|
|
647
|
+
} catch { /* Missing/invalid checkpoint never authorizes unrestricted copy. */ }
|
|
648
|
+
for (const task of recovering) recoverSessionCopyTask(sessionId, epoch, task);
|
|
649
|
+
} else if (!unknownBoundary && !hasSessionRuntimeWork(sessionId)) {
|
|
574
650
|
try {
|
|
575
651
|
const root = resolveSessionYeaftDir(ctx.CONFIG?.yeaftDir, sessionId);
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
652
|
+
epoch.boundary = JSON.stringify(new ConversationStore(root).captureSessionCopyBoundary(sessionId));
|
|
653
|
+
} catch (error) {
|
|
654
|
+
console.warn('[Yeaft] Session copy boundary capture failed:', sessionId, error?.code || 'capture_failed');
|
|
655
|
+
}
|
|
579
656
|
}
|
|
580
|
-
epoch = { boundary, pending: 0 };
|
|
581
657
|
sessionCopyEpochs.set(sessionId, epoch);
|
|
582
658
|
}
|
|
659
|
+
if (recoveryTask) {
|
|
660
|
+
if (unknownBoundary) {
|
|
661
|
+
recoverSessionCopyTask(sessionId, epoch, recoveryTask);
|
|
662
|
+
epoch.awaitingRecovery.delete(recoveryTask.id);
|
|
663
|
+
}
|
|
664
|
+
else if (epoch.boundary) epoch.taskIds.add(recoveryTask.id);
|
|
665
|
+
}
|
|
666
|
+
if (epoch.boundary !== previousBoundary || epoch.taskIds.size !== previousTaskCount) {
|
|
667
|
+
if (epoch.boundary) persistSessionCopyBoundary(sessionId, epoch);
|
|
668
|
+
else clearSessionCopyBoundary(sessionId);
|
|
669
|
+
}
|
|
583
670
|
epoch.pending += 1;
|
|
584
671
|
let released = false;
|
|
585
672
|
return () => {
|
|
@@ -608,7 +695,10 @@ function trackModelReentryActivity(event) {
|
|
|
608
695
|
return release || null;
|
|
609
696
|
}
|
|
610
697
|
if (!release) {
|
|
611
|
-
release = beginSessionActivity(task.sessionId, {
|
|
698
|
+
release = beginSessionActivity(task.sessionId, {
|
|
699
|
+
unknownBoundary: event.event !== 'started',
|
|
700
|
+
recoveryTask: task,
|
|
701
|
+
});
|
|
612
702
|
modelReentryActivity.set(key, release);
|
|
613
703
|
}
|
|
614
704
|
if (event.event !== 'completed') return null;
|
|
@@ -3934,8 +4024,14 @@ export function handleYeaftCreateSession(msg) {
|
|
|
3934
4024
|
export function handleYeaftCopySession(msg) {
|
|
3935
4025
|
const requestId = msg && msg.requestId;
|
|
3936
4026
|
const sessionId = msg && (msg.sessionId || msg.groupId);
|
|
4027
|
+
let releaseRecovery = null;
|
|
3937
4028
|
try {
|
|
3938
4029
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
4030
|
+
// Copy can be the first request after reconnect, before any send/runtime
|
|
4031
|
+
// boot. Inspect the same persisted recovery inventory, not only live maps.
|
|
4032
|
+
if (!sessionCopyEpochs.has(sessionId) && sessionCopyRecoveryTasks(sessionId).length) {
|
|
4033
|
+
releaseRecovery = beginSessionActivity(sessionId, { unknownBoundary: true });
|
|
4034
|
+
}
|
|
3939
4035
|
const source = decorateSessionsWithRuntimeState(snapshotSessions(yeaftDir))
|
|
3940
4036
|
.find(session => session?.id === sessionId);
|
|
3941
4037
|
if (!source) throw new SessionCrudError('not_found', sessionId);
|
|
@@ -3958,6 +4054,8 @@ export function handleYeaftCopySession(msg) {
|
|
|
3958
4054
|
sendSessionSnapshotBroadcast();
|
|
3959
4055
|
} catch (err) {
|
|
3960
4056
|
sendSessionCrudResult({ op: 'copy', requestId, ok: false, error: sessionErrorPayload(err) });
|
|
4057
|
+
} finally {
|
|
4058
|
+
releaseRecovery?.();
|
|
3961
4059
|
}
|
|
3962
4060
|
}
|
|
3963
4061
|
|