@yeaft/webchat-agent 1.0.676 → 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.
@@ -1 +1 @@
1
- {"version":"1.0.676"}
1
+ {"version":"1.0.677"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.676",
3
+ "version": "1.0.677",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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
- const fence = typeof options.beforeMessageId === 'string' ? options.beforeMessageId.trim() : '';
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'
@@ -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)) sessionCopyEpochs.delete(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
- function beginSessionActivity(sessionId, { unknownBoundary = false } = {}) {
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
- let boundary = null;
572
- // Legacy/untracked running work has no trustworthy pre-ingress boundary.
573
- if (!unknownBoundary && !hasSessionRuntimeWork(sessionId)) {
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
- const store = new ConversationStore(root);
577
- boundary = JSON.stringify(store.captureSessionCopyBoundary(sessionId));
578
- } catch { /* fail closed for running copies if capture was unavailable */ }
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, { unknownBoundary: event.event !== 'started' });
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