@aiwayds/dsh-topics-memory 0.16.1-next.1 → 0.17.1
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.
- package/lib/commands.js +29 -0
- package/lib/index.d.ts +68 -0
- package/lib/index.js +156 -15
- package/lib/store.d.ts +13 -0
- package/lib/store.js +25 -0
- package/package.json +2 -2
- package/src/commands.ts +26 -0
- package/src/index.ts +218 -13
- package/src/store.ts +25 -0
package/lib/commands.js
CHANGED
|
@@ -112,6 +112,35 @@ async function renderStatus(service) {
|
|
|
112
112
|
const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : '';
|
|
113
113
|
rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`]);
|
|
114
114
|
}
|
|
115
|
+
// Fast-lane heartbeat (0.17.0): pair "observer lane recorded recently"
|
|
116
|
+
// with "no injection round for >48h" — exactly the asymmetry a silent
|
|
117
|
+
// fast-lane death produces (the 0.1.5-rc.2 claim-order incident shape).
|
|
118
|
+
// With autoInject off there is no fast lane to be stale.
|
|
119
|
+
if (cfg.autoInject) {
|
|
120
|
+
const HOUR = 3_600_000;
|
|
121
|
+
const hb = await service.store.readLaneHeartbeat();
|
|
122
|
+
const stamp = (iso) => cell(iso.slice(0, 19).replace('T', ' '));
|
|
123
|
+
const ago = (ms) => {
|
|
124
|
+
const h = ms / HOUR;
|
|
125
|
+
if (!Number.isFinite(h))
|
|
126
|
+
return '';
|
|
127
|
+
return h < 1 ? `${Math.max(1, Math.round(h * 60))} 分钟前` : h < 48 ? `${Math.round(h)}h 前` : `${Math.round(h / 24)} 天前`;
|
|
128
|
+
};
|
|
129
|
+
const lastAt = hb.lastInjectionAt;
|
|
130
|
+
const fastIdleMs = lastAt !== undefined ? Date.now() - Date.parse(lastAt) : Number.POSITIVE_INFINITY;
|
|
131
|
+
const obsAgeMs = hb.lastObservationAt !== undefined ? Date.now() - Date.parse(hb.lastObservationAt) : Number.POSITIVE_INFINITY;
|
|
132
|
+
const obsRecent = obsAgeMs < 24 * HOUR;
|
|
133
|
+
if (lastAt === undefined) {
|
|
134
|
+
rows.push(['快道心跳', obsRecent ? `⚠️ 从未注入,但观察 lane ${ago(obsAgeMs)}仍在记录——快道疑似静默死亡` : '从未注入']);
|
|
135
|
+
}
|
|
136
|
+
else if (fastIdleMs > 48 * HOUR && obsRecent) {
|
|
137
|
+
rows.push(['快道心跳', `⚠️ ${stamp(lastAt)}(${ago(fastIdleMs)})无注入轮次,而观察 lane ${ago(obsAgeMs)}仍在记录——快道疑似静默死亡`]);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const since = ago(fastIdleMs);
|
|
141
|
+
rows.push(['快道心跳', `${stamp(lastAt)}${since === '' ? '' : `(${since})`}`]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
115
144
|
const lastConsolidate = await service.store.readConsolidateState();
|
|
116
145
|
if (lastConsolidate !== undefined) {
|
|
117
146
|
const count = (v) => (Array.isArray(v) ? v.length : 0);
|
package/lib/index.d.ts
CHANGED
|
@@ -17,6 +17,74 @@ export declare function settleBounded(p: Promise<unknown> | undefined, ms: numbe
|
|
|
17
17
|
/** Services consumed at apply time; llm joins via guarded ctx.inject, and the
|
|
18
18
|
* skills registry serves the bundled usage/config guide. */
|
|
19
19
|
export declare const inject: string[];
|
|
20
|
+
/** A firehose event as the replay path reads it: envelope fields only. */
|
|
21
|
+
export interface InboxEventLike {
|
|
22
|
+
seq?: unknown;
|
|
23
|
+
type?: unknown;
|
|
24
|
+
data?: unknown;
|
|
25
|
+
}
|
|
26
|
+
/** The pending-inbox projection replayed from a session-log prefix. */
|
|
27
|
+
export interface PendingInboxWindow {
|
|
28
|
+
'next-turn': readonly unknown[];
|
|
29
|
+
'next-step': readonly unknown[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Fold a session-log prefix into the pending-inbox projection state.
|
|
33
|
+
* Coordinates in the log are already normalize-at-append (dsh-agent-loop
|
|
34
|
+
* clamps start/deleteCount before appending), so each entry applies as a
|
|
35
|
+
* plain toSpliced. Events at or past `upToSeqExclusive` are ignored — pass
|
|
36
|
+
* the current splice's own seq to fold everything BEFORE it. Returns
|
|
37
|
+
* undefined when any entry is malformed enough to break the fold (the
|
|
38
|
+
* caller degrades to the projection read, as before).
|
|
39
|
+
*/
|
|
40
|
+
export declare function replayPendingInbox(events: readonly InboxEventLike[], upToSeqExclusive: number): PendingInboxWindow | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Claimed-text extraction, shared verbatim by the projection read and the
|
|
43
|
+
* log replay: user-kind messages only, text blocks joined per message —
|
|
44
|
+
* the pre-0.17.0 splice-window semantics.
|
|
45
|
+
*/
|
|
46
|
+
export declare function claimedUserText(messages: readonly unknown[]): string;
|
|
47
|
+
/** Where the resolved claimed text actually came from (see resolveClaimedText). */
|
|
48
|
+
export type ClaimedTextSource = 'log-replay' | 'projection';
|
|
49
|
+
export interface ClaimedTextResolution {
|
|
50
|
+
text: string;
|
|
51
|
+
source: ClaimedTextSource;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the claimed text for one splice, ordering the two sources by
|
|
55
|
+
* authority (review CONCERN A, 0.17.1):
|
|
56
|
+
*
|
|
57
|
+
* 1. Session-log replay — preferred UNCONDITIONALLY whenever the event
|
|
58
|
+
* carries a seq and a log prefix is available. The fold reproduces the
|
|
59
|
+
* pre-splice window the claim coordinates (start/removedCount) are
|
|
60
|
+
* defined against, so it is immune to handler order AND to the backlog
|
|
61
|
+
* misalignment: with ≥2 pending messages the post-splice projection
|
|
62
|
+
* read is NON-empty but shifted by one (a claim of msg1 would read
|
|
63
|
+
* msg2's residual text), which the 0.17.0 empty-projection-only gating
|
|
64
|
+
* missed. The replay verdict is FINAL — when the fold succeeds the
|
|
65
|
+
* projection is not consulted, even if the replayed slice comes back
|
|
66
|
+
* empty (the pre-splice window genuinely held no user text).
|
|
67
|
+
* 2. Projection read — the fallback for hosts where the replay cannot arm
|
|
68
|
+
* (no event.seq on the firehose, or snapshotEvents unreachable/failed).
|
|
69
|
+
* Pre-0.1.5 live dispatch precedes the mutation, so this read yields
|
|
70
|
+
* the pre-splice window, as it always did.
|
|
71
|
+
*
|
|
72
|
+
* `source` names the source that ACTUALLY produced the returned text
|
|
73
|
+
* (review CONCERN B): 'log-replay' whenever the replay supplied the
|
|
74
|
+
* verdict (empty included), 'projection' only when the fallback did (or
|
|
75
|
+
* the replay never armed).
|
|
76
|
+
*/
|
|
77
|
+
export declare function resolveClaimedText(input: {
|
|
78
|
+
/** The pending window as read at handler time (post-splice on 0.1.5+). */
|
|
79
|
+
projection: readonly unknown[];
|
|
80
|
+
/** Session-log prefix up to (excluding) `seq`, or undefined when unreachable. */
|
|
81
|
+
logEvents: readonly InboxEventLike[] | undefined;
|
|
82
|
+
/** The splice event's own seq — a number marks a replay-capable host. */
|
|
83
|
+
seq: unknown;
|
|
84
|
+
target: 'next-turn' | 'next-step';
|
|
85
|
+
start: number;
|
|
86
|
+
removedCount: number;
|
|
87
|
+
}): ClaimedTextResolution;
|
|
20
88
|
/**
|
|
21
89
|
* Settings-provider surface the legacy-namespace migration needs. Structurally
|
|
22
90
|
* typed like every other host seam in this file, so bare test harnesses
|
package/lib/index.js
CHANGED
|
@@ -62,6 +62,98 @@ export function settleBounded(p, ms) {
|
|
|
62
62
|
/** Services consumed at apply time; llm joins via guarded ctx.inject, and the
|
|
63
63
|
* skills registry serves the bundled usage/config guide. */
|
|
64
64
|
export const inject = ['systemPrompt', 'tools', 'settings', 'agents', 'llm', 'skills'];
|
|
65
|
+
/**
|
|
66
|
+
* Fold a session-log prefix into the pending-inbox projection state.
|
|
67
|
+
* Coordinates in the log are already normalize-at-append (dsh-agent-loop
|
|
68
|
+
* clamps start/deleteCount before appending), so each entry applies as a
|
|
69
|
+
* plain toSpliced. Events at or past `upToSeqExclusive` are ignored — pass
|
|
70
|
+
* the current splice's own seq to fold everything BEFORE it. Returns
|
|
71
|
+
* undefined when any entry is malformed enough to break the fold (the
|
|
72
|
+
* caller degrades to the projection read, as before).
|
|
73
|
+
*/
|
|
74
|
+
export function replayPendingInbox(events, upToSeqExclusive) {
|
|
75
|
+
const window = { 'next-turn': [], 'next-step': [] };
|
|
76
|
+
try {
|
|
77
|
+
for (const event of events) {
|
|
78
|
+
const seq = event.seq;
|
|
79
|
+
if (typeof seq === 'number' && seq >= upToSeqExclusive)
|
|
80
|
+
break;
|
|
81
|
+
if (event.type !== 'agent/inbox/spliced')
|
|
82
|
+
continue;
|
|
83
|
+
const splice = event.data;
|
|
84
|
+
if (splice === null || typeof splice !== 'object')
|
|
85
|
+
continue;
|
|
86
|
+
const target = splice.target ?? 'next-turn';
|
|
87
|
+
if (target !== 'next-turn' && target !== 'next-step')
|
|
88
|
+
continue;
|
|
89
|
+
const start = Math.trunc(splice.start);
|
|
90
|
+
const removedCount = Math.trunc(splice.removedCount ?? 0);
|
|
91
|
+
if (!Number.isFinite(start) || !Number.isFinite(removedCount) || start < 0 || removedCount < 0)
|
|
92
|
+
continue;
|
|
93
|
+
window[target] = window[target].toSpliced(start, removedCount, ...(splice.inserted ?? []));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return window;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Claimed-text extraction, shared verbatim by the projection read and the
|
|
103
|
+
* log replay: user-kind messages only, text blocks joined per message —
|
|
104
|
+
* the pre-0.17.0 splice-window semantics.
|
|
105
|
+
*/
|
|
106
|
+
export function claimedUserText(messages) {
|
|
107
|
+
let claimedText = '';
|
|
108
|
+
for (const message of messages) {
|
|
109
|
+
const data = message;
|
|
110
|
+
if (data === null || typeof data !== 'object' || data.source?.kind !== 'user')
|
|
111
|
+
continue;
|
|
112
|
+
const text = textOf(data);
|
|
113
|
+
if (text.trim() !== '')
|
|
114
|
+
claimedText = claimedText === '' ? text : `${claimedText}\n${text}`;
|
|
115
|
+
}
|
|
116
|
+
return claimedText;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Resolve the claimed text for one splice, ordering the two sources by
|
|
120
|
+
* authority (review CONCERN A, 0.17.1):
|
|
121
|
+
*
|
|
122
|
+
* 1. Session-log replay — preferred UNCONDITIONALLY whenever the event
|
|
123
|
+
* carries a seq and a log prefix is available. The fold reproduces the
|
|
124
|
+
* pre-splice window the claim coordinates (start/removedCount) are
|
|
125
|
+
* defined against, so it is immune to handler order AND to the backlog
|
|
126
|
+
* misalignment: with ≥2 pending messages the post-splice projection
|
|
127
|
+
* read is NON-empty but shifted by one (a claim of msg1 would read
|
|
128
|
+
* msg2's residual text), which the 0.17.0 empty-projection-only gating
|
|
129
|
+
* missed. The replay verdict is FINAL — when the fold succeeds the
|
|
130
|
+
* projection is not consulted, even if the replayed slice comes back
|
|
131
|
+
* empty (the pre-splice window genuinely held no user text).
|
|
132
|
+
* 2. Projection read — the fallback for hosts where the replay cannot arm
|
|
133
|
+
* (no event.seq on the firehose, or snapshotEvents unreachable/failed).
|
|
134
|
+
* Pre-0.1.5 live dispatch precedes the mutation, so this read yields
|
|
135
|
+
* the pre-splice window, as it always did.
|
|
136
|
+
*
|
|
137
|
+
* `source` names the source that ACTUALLY produced the returned text
|
|
138
|
+
* (review CONCERN B): 'log-replay' whenever the replay supplied the
|
|
139
|
+
* verdict (empty included), 'projection' only when the fallback did (or
|
|
140
|
+
* the replay never armed).
|
|
141
|
+
*/
|
|
142
|
+
export function resolveClaimedText(input) {
|
|
143
|
+
if (typeof input.seq === 'number' && input.logEvents !== undefined) {
|
|
144
|
+
const window = replayPendingInbox(input.logEvents, input.seq);
|
|
145
|
+
if (window !== undefined) {
|
|
146
|
+
return {
|
|
147
|
+
text: claimedUserText(window[input.target].slice(input.start, input.start + input.removedCount)),
|
|
148
|
+
source: 'log-replay',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
text: claimedUserText(input.projection.slice(input.start, input.start + input.removedCount)),
|
|
154
|
+
source: 'projection',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
65
157
|
// dsh-settings 0.1.2-alpha.3 removed the runtime settingsNamespace() helper:
|
|
66
158
|
// register() now brand-checks the namespace at the type level
|
|
67
159
|
// (SettingsNamespaceInput) and validates the same lowercase-hyphenated
|
|
@@ -217,6 +309,22 @@ export function apply(ctx) {
|
|
|
217
309
|
// fail-open, idempotent — see migrateLegacySettings). Fire-and-forget: the
|
|
218
310
|
// write settles on its own; a rejection only skips the migration.
|
|
219
311
|
void migrateLegacySettings(settingsNs, warn);
|
|
312
|
+
// Low-noise observability for the fast lane's early exits. The 2026-09-21
|
|
313
|
+
// silent-death incident (0.1.5-rc.2 claim order) died precisely because
|
|
314
|
+
// every exit path was silent: the injected log stayed empty for weeks
|
|
315
|
+
// while the observer kept recording. First occurrence warns immediately;
|
|
316
|
+
// repeats batch into one count line per SILENT_EXIT_LOG_EVERY occurrences.
|
|
317
|
+
// DSH_TOPICS_DEBUG=1 logs every single one for bench debugging.
|
|
318
|
+
const SILENT_EXIT_LOG_EVERY = 50;
|
|
319
|
+
const debugVerbose = process.env.DSH_TOPICS_DEBUG === '1';
|
|
320
|
+
const silentExitCounts = new Map();
|
|
321
|
+
const silentExit = (key, detail) => {
|
|
322
|
+
const n = (silentExitCounts.get(key) ?? 0) + 1;
|
|
323
|
+
silentExitCounts.set(key, n);
|
|
324
|
+
if (debugVerbose || n === 1 || n % SILENT_EXIT_LOG_EVERY === 0) {
|
|
325
|
+
warn(`dsh-topics-memory 快道本轮未触发(${key},累计 ${n} 次):${detail}`);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
220
328
|
const cfgNow = () => {
|
|
221
329
|
const v = scope.get();
|
|
222
330
|
// Schema defaults may not be applied by bare test harnesses; fill them in.
|
|
@@ -281,6 +389,15 @@ export function apply(ctx) {
|
|
|
281
389
|
}
|
|
282
390
|
}
|
|
283
391
|
const r = service.retrieveSync(query, sessionId, seen === undefined && echo === undefined ? undefined : { exclude: seen, echo }, slow, slowExpired);
|
|
392
|
+
// The service-layer early exit (empty roster — e.g. a probe home with
|
|
393
|
+
// no topics seeded) writes NO injection record and used to be fully
|
|
394
|
+
// silent (the P8 early-exit the incident audit enumerated). autoInject
|
|
395
|
+
// off never reaches here (the spliced branch gates it) and an empty
|
|
396
|
+
// query is pre-filtered by the empty-claimed exit, so rosterSize 0 on
|
|
397
|
+
// this path means exactly "roster empty".
|
|
398
|
+
if (r.outcome.rosterSize === 0) {
|
|
399
|
+
silentExit('no-roster', 'roster 为空(topics 库 0 条)——service 层早退,本轮不写 ilog 记录');
|
|
400
|
+
}
|
|
284
401
|
// Mark only what entered the context this round (hits AND slow picks →
|
|
285
402
|
// dedup filter → assemble → registry mark; budget-dropped slugs stay
|
|
286
403
|
// injectable).
|
|
@@ -466,11 +583,18 @@ export function apply(ctx) {
|
|
|
466
583
|
if (!cfgNow().autoInject)
|
|
467
584
|
return;
|
|
468
585
|
const splice = event.data;
|
|
469
|
-
|
|
586
|
+
// A canceled claim is routine host flow (inbox cancel/remove), not a
|
|
587
|
+
// fault signal — it only logs under DSH_TOPICS_DEBUG, never warns.
|
|
588
|
+
if (!splice.removedCount || splice.outcome === 'canceled') {
|
|
589
|
+
if (debugVerbose)
|
|
590
|
+
silentExit('canceled', `target=${String(splice.target)} removedCount=${String(splice.removedCount)} outcome=${String(splice.outcome)}`);
|
|
470
591
|
return;
|
|
592
|
+
}
|
|
471
593
|
const agent = agents()?.get(session.id);
|
|
472
|
-
if (agent === undefined)
|
|
594
|
+
if (agent === undefined) {
|
|
595
|
+
silentExit('agent-missing', `agents() 无此会话 ${sessionId}——快道文本无法定位`);
|
|
473
596
|
return;
|
|
597
|
+
}
|
|
474
598
|
// The agent-scoped context carries the llm instance this agent's own
|
|
475
599
|
// loop streams through (adapters included) — capture it while alive.
|
|
476
600
|
try {
|
|
@@ -481,24 +605,41 @@ export function apply(ctx) {
|
|
|
481
605
|
catch {
|
|
482
606
|
// scope already unwinding — the root fallback stays
|
|
483
607
|
}
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
608
|
+
const target = (splice.target ?? 'next-turn') === 'next-step' ? 'next-step' : 'next-turn';
|
|
609
|
+
const list = (target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn);
|
|
610
|
+
// Authority — session-log replay (resolveClaimedText, CONCERN A):
|
|
611
|
+
// whenever the event carries a seq, the fold runs FIRST and its
|
|
612
|
+
// verdict is final — with ≥2 pending messages the post-splice
|
|
613
|
+
// projection read is non-empty but shifted by one, so gating the
|
|
614
|
+
// replay on an empty projection would claim msg1 and read msg2.
|
|
615
|
+
const seq = event.seq;
|
|
616
|
+
let logEvents;
|
|
617
|
+
if (typeof seq === 'number') {
|
|
618
|
+
try {
|
|
619
|
+
const snapshot = session.snapshotEvents;
|
|
620
|
+
if (typeof snapshot === 'function')
|
|
621
|
+
logEvents = snapshot.call(session, 0, seq);
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
// contained — the projection fallback inside resolveClaimedText
|
|
625
|
+
// stays the verdict
|
|
626
|
+
}
|
|
494
627
|
}
|
|
495
|
-
|
|
628
|
+
const claimed = resolveClaimedText({ projection: list, logEvents, seq, target, start: splice.start, removedCount: splice.removedCount });
|
|
629
|
+
if (claimed.text.trim() === '') {
|
|
630
|
+
// claimed.source names where the (empty) verdict actually came
|
|
631
|
+
// from (CONCERN B): 'log-replay' = the fold ran and the pre-splice
|
|
632
|
+
// window genuinely held no user text; 'projection' = the replay
|
|
633
|
+
// never armed (no seq / snapshotEvents unreachable) and the
|
|
634
|
+
// projection read was empty too.
|
|
635
|
+
silentExit(`empty-claimed/${claimed.source}`, `target=${target} start=${String(splice.start)} removedCount=${String(splice.removedCount)}——被 claim 窗口重建后仍无用户文本`);
|
|
496
636
|
return;
|
|
637
|
+
}
|
|
497
638
|
const state = turns.get(sessionId) ?? { claimedText: '', injectionText: '' };
|
|
498
|
-
state.claimedText =
|
|
639
|
+
state.claimedText = claimed.text;
|
|
499
640
|
state.injectionText = '';
|
|
500
641
|
turns.set(sessionId, state);
|
|
501
|
-
retrieveForTurn(sessionId,
|
|
642
|
+
retrieveForTurn(sessionId, claimed.text);
|
|
502
643
|
return;
|
|
503
644
|
}
|
|
504
645
|
if (event.type === 'turn/start') {
|
package/lib/store.d.ts
CHANGED
|
@@ -61,6 +61,19 @@ export declare class BundleStore {
|
|
|
61
61
|
topicPath(slug: string): string;
|
|
62
62
|
private observationsPath;
|
|
63
63
|
private injectionsPath;
|
|
64
|
+
/**
|
|
65
|
+
* Fast-lane heartbeat (0.17.0): tail timestamps of the two lane sidecars —
|
|
66
|
+
* the last injection round (`injections.jsonl`) and the last observation
|
|
67
|
+
* (`observations.jsonl`). Feeds the /topics status stale check that makes
|
|
68
|
+
* "observer still recording, injection lane silently dead" visible within
|
|
69
|
+
* a day instead of weeks (the 0.1.5-rc.2 claim-order incident shape).
|
|
70
|
+
* Full-file reads are fine here: /topics status is interactive and both
|
|
71
|
+
* logs are append-per-round small; unreadable logs degrade to undefined.
|
|
72
|
+
*/
|
|
73
|
+
readLaneHeartbeat(): Promise<{
|
|
74
|
+
lastInjectionAt?: string;
|
|
75
|
+
lastObservationAt?: string;
|
|
76
|
+
}>;
|
|
64
77
|
private conflictsPath;
|
|
65
78
|
private distillStatePath;
|
|
66
79
|
/** Last distill-lane run outcome (diagnostics for /topics status and e2e). */
|
package/lib/store.js
CHANGED
|
@@ -74,6 +74,31 @@ export class BundleStore {
|
|
|
74
74
|
injectionsPath() {
|
|
75
75
|
return join(this.metaDir(), 'injections.jsonl');
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Fast-lane heartbeat (0.17.0): tail timestamps of the two lane sidecars —
|
|
79
|
+
* the last injection round (`injections.jsonl`) and the last observation
|
|
80
|
+
* (`observations.jsonl`). Feeds the /topics status stale check that makes
|
|
81
|
+
* "observer still recording, injection lane silently dead" visible within
|
|
82
|
+
* a day instead of weeks (the 0.1.5-rc.2 claim-order incident shape).
|
|
83
|
+
* Full-file reads are fine here: /topics status is interactive and both
|
|
84
|
+
* logs are append-per-round small; unreadable logs degrade to undefined.
|
|
85
|
+
*/
|
|
86
|
+
async readLaneHeartbeat() {
|
|
87
|
+
const tailAt = async (file) => {
|
|
88
|
+
try {
|
|
89
|
+
const lines = (await readFile(file, 'utf8')).trimEnd().split('\n');
|
|
90
|
+
const at = JSON.parse(lines.at(-1) ?? '').at;
|
|
91
|
+
return typeof at === 'string' ? at : undefined;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
lastInjectionAt: await tailAt(this.injectionsPath()),
|
|
99
|
+
lastObservationAt: await tailAt(this.observationsPath()),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
77
102
|
conflictsPath() {
|
|
78
103
|
return join(this.metaDir(), 'conflicts.json');
|
|
79
104
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-topics-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
4
4
|
"description": "dsh-plugin: quality-first topic memory for dsh — a topic keeps the starting question, conclusion, impact and dependencies, not the process; local-first git-traced OKF bundle, budgeted LLM-free injection",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"build": "tsc -p tsconfig.json",
|
|
40
40
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
41
41
|
"pretest": "tsc -p tsconfig.json",
|
|
42
|
-
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs",
|
|
42
|
+
"test": "node --test test/yaml.test.mjs test/okf.test.mjs test/retrieval.test.mjs test/digest.test.mjs test/ilog.test.mjs test/store.test.mjs test/paths.test.mjs test/git.test.mjs test/sync.test.mjs test/tools.test.mjs test/dedup.test.mjs test/commands.test.mjs test/onboard.test.mjs test/delegation.test.mjs test/observer.test.mjs test/distill.test.mjs test/consolidate.test.mjs test/viz.test.mjs test/corpus.test.mjs test/llm-scope.test.mjs test/settings-migration.test.mjs test/quality.test.mjs test/replay-gate.test.mjs test/depends-repair.test.mjs test/skill.test.mjs test/fast-lane.test.mjs",
|
|
43
43
|
"smoke": "node scripts/smoke-boot.mjs"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {},
|
package/src/commands.ts
CHANGED
|
@@ -149,6 +149,32 @@ async function renderStatus(service: TopicsService): Promise<string> {
|
|
|
149
149
|
const gc = typeof lastRun.gcDropped === 'number' && lastRun.gcDropped > 0 ? `,GC 回收 ${lastRun.gcDropped}` : ''
|
|
150
150
|
rows.push(['最近蒸馏', `${outcome}${gc} @ ${cell(String(lastRun.at ?? '').slice(0, 19).replace('T', ' '))}`])
|
|
151
151
|
}
|
|
152
|
+
// Fast-lane heartbeat (0.17.0): pair "observer lane recorded recently"
|
|
153
|
+
// with "no injection round for >48h" — exactly the asymmetry a silent
|
|
154
|
+
// fast-lane death produces (the 0.1.5-rc.2 claim-order incident shape).
|
|
155
|
+
// With autoInject off there is no fast lane to be stale.
|
|
156
|
+
if (cfg.autoInject) {
|
|
157
|
+
const HOUR = 3_600_000
|
|
158
|
+
const hb = await service.store.readLaneHeartbeat()
|
|
159
|
+
const stamp = (iso: string): string => cell(iso.slice(0, 19).replace('T', ' '))
|
|
160
|
+
const ago = (ms: number): string => {
|
|
161
|
+
const h = ms / HOUR
|
|
162
|
+
if (!Number.isFinite(h)) return ''
|
|
163
|
+
return h < 1 ? `${Math.max(1, Math.round(h * 60))} 分钟前` : h < 48 ? `${Math.round(h)}h 前` : `${Math.round(h / 24)} 天前`
|
|
164
|
+
}
|
|
165
|
+
const lastAt = hb.lastInjectionAt
|
|
166
|
+
const fastIdleMs = lastAt !== undefined ? Date.now() - Date.parse(lastAt) : Number.POSITIVE_INFINITY
|
|
167
|
+
const obsAgeMs = hb.lastObservationAt !== undefined ? Date.now() - Date.parse(hb.lastObservationAt) : Number.POSITIVE_INFINITY
|
|
168
|
+
const obsRecent = obsAgeMs < 24 * HOUR
|
|
169
|
+
if (lastAt === undefined) {
|
|
170
|
+
rows.push(['快道心跳', obsRecent ? `⚠️ 从未注入,但观察 lane ${ago(obsAgeMs)}仍在记录——快道疑似静默死亡` : '从未注入'])
|
|
171
|
+
} else if (fastIdleMs > 48 * HOUR && obsRecent) {
|
|
172
|
+
rows.push(['快道心跳', `⚠️ ${stamp(lastAt)}(${ago(fastIdleMs)})无注入轮次,而观察 lane ${ago(obsAgeMs)}仍在记录——快道疑似静默死亡`])
|
|
173
|
+
} else {
|
|
174
|
+
const since = ago(fastIdleMs)
|
|
175
|
+
rows.push(['快道心跳', `${stamp(lastAt)}${since === '' ? '' : `(${since})`}`])
|
|
176
|
+
}
|
|
177
|
+
}
|
|
152
178
|
const lastConsolidate = await service.store.readConsolidateState()
|
|
153
179
|
if (lastConsolidate !== undefined) {
|
|
154
180
|
const count = (v: unknown): number => (Array.isArray(v) ? v.length : 0)
|
package/src/index.ts
CHANGED
|
@@ -86,6 +86,154 @@ export function settleBounded(p: Promise<unknown> | undefined, ms: number): Prom
|
|
|
86
86
|
* skills registry serves the bundled usage/config guide. */
|
|
87
87
|
export const inject = ['systemPrompt', 'tools', 'settings', 'agents', 'llm', 'skills']
|
|
88
88
|
|
|
89
|
+
// ---- Fast-lane claim-text rebuild from the session log (0.17.0/0.17.1) ----
|
|
90
|
+
//
|
|
91
|
+
// dsh 0.1.5-rc.2's SessionProjectionRegistry registers its eager inbox drive
|
|
92
|
+
// at session/event hooksOrder[0]; every later handler — this plugin sits at
|
|
93
|
+
// [10] — observes the projection AFTER the current splice was applied, so the
|
|
94
|
+
// pre-0.1.5 contract "live dispatch precedes projection mutation" is gone and
|
|
95
|
+
// the projection read at `agent/inbox/spliced` time yields the post-splice
|
|
96
|
+
// window. 0.17.0 armed the replay only when that read came back empty —
|
|
97
|
+
// which still mis-fired with ≥2 pending messages (review CONCERN A): the
|
|
98
|
+
// post-splice projection is NON-empty but shifted by one, so a claim of msg1
|
|
99
|
+
// read msg2's residual text and the replay never armed. Since 0.17.1 the
|
|
100
|
+
// replay is the UNCONDITIONAL first source whenever event.seq exists:
|
|
101
|
+
// dsh-agent-loop's InboxProjector funnels every append/prepend/replace/
|
|
102
|
+
// remove/claim/cancel through mutate(), and mutate() appends each normalized
|
|
103
|
+
// splice — target, clamped coordinates, AND the full inserted messages — to
|
|
104
|
+
// the session log as `agent/inbox/spliced`. Folding that log prefix up to
|
|
105
|
+
// (excluding) the current event's seq reproduces the exact pre-splice pending
|
|
106
|
+
// window — the very coordinates the claim is defined against — with zero
|
|
107
|
+
// dependence on dispatch order. The projection read survives only as the
|
|
108
|
+
// fallback for hosts whose events carry no seq (or whose session object
|
|
109
|
+
// exposes no snapshotEvents).
|
|
110
|
+
|
|
111
|
+
/** A firehose event as the replay path reads it: envelope fields only. */
|
|
112
|
+
export interface InboxEventLike {
|
|
113
|
+
seq?: unknown
|
|
114
|
+
type?: unknown
|
|
115
|
+
data?: unknown
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The pending-inbox projection replayed from a session-log prefix. */
|
|
119
|
+
export interface PendingInboxWindow {
|
|
120
|
+
'next-turn': readonly unknown[]
|
|
121
|
+
'next-step': readonly unknown[]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One `agent/inbox/spliced` log entry (shape per dsh-agent-loop mutate()). */
|
|
125
|
+
interface InboxSpliceData {
|
|
126
|
+
target?: string
|
|
127
|
+
start: number
|
|
128
|
+
removedCount?: number
|
|
129
|
+
inserted?: readonly unknown[]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Fold a session-log prefix into the pending-inbox projection state.
|
|
134
|
+
* Coordinates in the log are already normalize-at-append (dsh-agent-loop
|
|
135
|
+
* clamps start/deleteCount before appending), so each entry applies as a
|
|
136
|
+
* plain toSpliced. Events at or past `upToSeqExclusive` are ignored — pass
|
|
137
|
+
* the current splice's own seq to fold everything BEFORE it. Returns
|
|
138
|
+
* undefined when any entry is malformed enough to break the fold (the
|
|
139
|
+
* caller degrades to the projection read, as before).
|
|
140
|
+
*/
|
|
141
|
+
export function replayPendingInbox(events: readonly InboxEventLike[], upToSeqExclusive: number): PendingInboxWindow | undefined {
|
|
142
|
+
const window: { 'next-turn': unknown[]; 'next-step': unknown[] } = { 'next-turn': [], 'next-step': [] }
|
|
143
|
+
try {
|
|
144
|
+
for (const event of events) {
|
|
145
|
+
const seq = event.seq
|
|
146
|
+
if (typeof seq === 'number' && seq >= upToSeqExclusive) break
|
|
147
|
+
if (event.type !== 'agent/inbox/spliced') continue
|
|
148
|
+
const splice = event.data as InboxSpliceData | null | undefined
|
|
149
|
+
if (splice === null || typeof splice !== 'object') continue
|
|
150
|
+
const target = splice.target ?? 'next-turn'
|
|
151
|
+
if (target !== 'next-turn' && target !== 'next-step') continue
|
|
152
|
+
const start = Math.trunc(splice.start)
|
|
153
|
+
const removedCount = Math.trunc(splice.removedCount ?? 0)
|
|
154
|
+
if (!Number.isFinite(start) || !Number.isFinite(removedCount) || start < 0 || removedCount < 0) continue
|
|
155
|
+
window[target] = window[target].toSpliced(start, removedCount, ...(splice.inserted ?? []))
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
return window
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Claimed-text extraction, shared verbatim by the projection read and the
|
|
165
|
+
* log replay: user-kind messages only, text blocks joined per message —
|
|
166
|
+
* the pre-0.17.0 splice-window semantics.
|
|
167
|
+
*/
|
|
168
|
+
export function claimedUserText(messages: readonly unknown[]): string {
|
|
169
|
+
let claimedText = ''
|
|
170
|
+
for (const message of messages) {
|
|
171
|
+
const data = message as UserMessageData | null | undefined
|
|
172
|
+
if (data === null || typeof data !== 'object' || data.source?.kind !== 'user') continue
|
|
173
|
+
const text = textOf(data as UserMessageLike)
|
|
174
|
+
if (text.trim() !== '') claimedText = claimedText === '' ? text : `${claimedText}\n${text}`
|
|
175
|
+
}
|
|
176
|
+
return claimedText
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Where the resolved claimed text actually came from (see resolveClaimedText). */
|
|
180
|
+
export type ClaimedTextSource = 'log-replay' | 'projection'
|
|
181
|
+
|
|
182
|
+
export interface ClaimedTextResolution {
|
|
183
|
+
text: string
|
|
184
|
+
source: ClaimedTextSource
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Resolve the claimed text for one splice, ordering the two sources by
|
|
189
|
+
* authority (review CONCERN A, 0.17.1):
|
|
190
|
+
*
|
|
191
|
+
* 1. Session-log replay — preferred UNCONDITIONALLY whenever the event
|
|
192
|
+
* carries a seq and a log prefix is available. The fold reproduces the
|
|
193
|
+
* pre-splice window the claim coordinates (start/removedCount) are
|
|
194
|
+
* defined against, so it is immune to handler order AND to the backlog
|
|
195
|
+
* misalignment: with ≥2 pending messages the post-splice projection
|
|
196
|
+
* read is NON-empty but shifted by one (a claim of msg1 would read
|
|
197
|
+
* msg2's residual text), which the 0.17.0 empty-projection-only gating
|
|
198
|
+
* missed. The replay verdict is FINAL — when the fold succeeds the
|
|
199
|
+
* projection is not consulted, even if the replayed slice comes back
|
|
200
|
+
* empty (the pre-splice window genuinely held no user text).
|
|
201
|
+
* 2. Projection read — the fallback for hosts where the replay cannot arm
|
|
202
|
+
* (no event.seq on the firehose, or snapshotEvents unreachable/failed).
|
|
203
|
+
* Pre-0.1.5 live dispatch precedes the mutation, so this read yields
|
|
204
|
+
* the pre-splice window, as it always did.
|
|
205
|
+
*
|
|
206
|
+
* `source` names the source that ACTUALLY produced the returned text
|
|
207
|
+
* (review CONCERN B): 'log-replay' whenever the replay supplied the
|
|
208
|
+
* verdict (empty included), 'projection' only when the fallback did (or
|
|
209
|
+
* the replay never armed).
|
|
210
|
+
*/
|
|
211
|
+
export function resolveClaimedText(input: {
|
|
212
|
+
/** The pending window as read at handler time (post-splice on 0.1.5+). */
|
|
213
|
+
projection: readonly unknown[]
|
|
214
|
+
/** Session-log prefix up to (excluding) `seq`, or undefined when unreachable. */
|
|
215
|
+
logEvents: readonly InboxEventLike[] | undefined
|
|
216
|
+
/** The splice event's own seq — a number marks a replay-capable host. */
|
|
217
|
+
seq: unknown
|
|
218
|
+
target: 'next-turn' | 'next-step'
|
|
219
|
+
start: number
|
|
220
|
+
removedCount: number
|
|
221
|
+
}): ClaimedTextResolution {
|
|
222
|
+
if (typeof input.seq === 'number' && input.logEvents !== undefined) {
|
|
223
|
+
const window = replayPendingInbox(input.logEvents, input.seq)
|
|
224
|
+
if (window !== undefined) {
|
|
225
|
+
return {
|
|
226
|
+
text: claimedUserText(window[input.target].slice(input.start, input.start + input.removedCount)),
|
|
227
|
+
source: 'log-replay',
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
text: claimedUserText(input.projection.slice(input.start, input.start + input.removedCount)),
|
|
233
|
+
source: 'projection',
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
89
237
|
// dsh-settings 0.1.2-alpha.3 removed the runtime settingsNamespace() helper:
|
|
90
238
|
// register() now brand-checks the namespace at the type level
|
|
91
239
|
// (SettingsNamespaceInput) and validates the same lowercase-hyphenated
|
|
@@ -105,6 +253,10 @@ interface AgentMapLike {
|
|
|
105
253
|
interface SessionEvent {
|
|
106
254
|
type: string
|
|
107
255
|
data: unknown
|
|
256
|
+
/** Log sequence number, present on hosts whose firehose carries appended
|
|
257
|
+
* events (dsh 0.1.5+). The fast-lane log replay keys on it; absent on
|
|
258
|
+
* older hosts, where the replay simply never arms. */
|
|
259
|
+
seq?: number
|
|
108
260
|
}
|
|
109
261
|
|
|
110
262
|
interface UserMessageData {
|
|
@@ -274,6 +426,22 @@ export function apply(ctx: Context): void {
|
|
|
274
426
|
// fail-open, idempotent — see migrateLegacySettings). Fire-and-forget: the
|
|
275
427
|
// write settles on its own; a rejection only skips the migration.
|
|
276
428
|
void migrateLegacySettings(settingsNs as unknown as SettingsNsLike, warn)
|
|
429
|
+
// Low-noise observability for the fast lane's early exits. The 2026-09-21
|
|
430
|
+
// silent-death incident (0.1.5-rc.2 claim order) died precisely because
|
|
431
|
+
// every exit path was silent: the injected log stayed empty for weeks
|
|
432
|
+
// while the observer kept recording. First occurrence warns immediately;
|
|
433
|
+
// repeats batch into one count line per SILENT_EXIT_LOG_EVERY occurrences.
|
|
434
|
+
// DSH_TOPICS_DEBUG=1 logs every single one for bench debugging.
|
|
435
|
+
const SILENT_EXIT_LOG_EVERY = 50
|
|
436
|
+
const debugVerbose = process.env.DSH_TOPICS_DEBUG === '1'
|
|
437
|
+
const silentExitCounts = new Map<string, number>()
|
|
438
|
+
const silentExit = (key: string, detail: string): void => {
|
|
439
|
+
const n = (silentExitCounts.get(key) ?? 0) + 1
|
|
440
|
+
silentExitCounts.set(key, n)
|
|
441
|
+
if (debugVerbose || n === 1 || n % SILENT_EXIT_LOG_EVERY === 0) {
|
|
442
|
+
warn(`dsh-topics-memory 快道本轮未触发(${key},累计 ${n} 次):${detail}`)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
277
445
|
const cfgNow = (): TopicsConfigValue => {
|
|
278
446
|
const v = scope.get() as Partial<TopicsConfigValue> | undefined
|
|
279
447
|
// Schema defaults may not be applied by bare test harnesses; fill them in.
|
|
@@ -351,6 +519,15 @@ export function apply(ctx: Context): void {
|
|
|
351
519
|
}
|
|
352
520
|
}
|
|
353
521
|
const r = service.retrieveSync(query, sessionId, seen === undefined && echo === undefined ? undefined : { exclude: seen, echo }, slow, slowExpired)
|
|
522
|
+
// The service-layer early exit (empty roster — e.g. a probe home with
|
|
523
|
+
// no topics seeded) writes NO injection record and used to be fully
|
|
524
|
+
// silent (the P8 early-exit the incident audit enumerated). autoInject
|
|
525
|
+
// off never reaches here (the spliced branch gates it) and an empty
|
|
526
|
+
// query is pre-filtered by the empty-claimed exit, so rosterSize 0 on
|
|
527
|
+
// this path means exactly "roster empty".
|
|
528
|
+
if (r.outcome.rosterSize === 0) {
|
|
529
|
+
silentExit('no-roster', 'roster 为空(topics 库 0 条)——service 层早退,本轮不写 ilog 记录')
|
|
530
|
+
}
|
|
354
531
|
// Mark only what entered the context this round (hits AND slow picks →
|
|
355
532
|
// dedup filter → assemble → registry mark; budget-dropped slugs stay
|
|
356
533
|
// injectable).
|
|
@@ -529,9 +706,17 @@ export function apply(ctx: Context): void {
|
|
|
529
706
|
if (event.type === 'agent/inbox/spliced') {
|
|
530
707
|
if (!cfgNow().autoInject) return
|
|
531
708
|
const splice = event.data as { target?: string; start: number; removedCount?: number; outcome?: string }
|
|
532
|
-
|
|
709
|
+
// A canceled claim is routine host flow (inbox cancel/remove), not a
|
|
710
|
+
// fault signal — it only logs under DSH_TOPICS_DEBUG, never warns.
|
|
711
|
+
if (!splice.removedCount || splice.outcome === 'canceled') {
|
|
712
|
+
if (debugVerbose) silentExit('canceled', `target=${String(splice.target)} removedCount=${String(splice.removedCount)} outcome=${String(splice.outcome)}`)
|
|
713
|
+
return
|
|
714
|
+
}
|
|
533
715
|
const agent = agents()?.get(session.id)
|
|
534
|
-
if (agent === undefined)
|
|
716
|
+
if (agent === undefined) {
|
|
717
|
+
silentExit('agent-missing', `agents() 无此会话 ${sessionId}——快道文本无法定位`)
|
|
718
|
+
return
|
|
719
|
+
}
|
|
535
720
|
// The agent-scoped context carries the llm instance this agent's own
|
|
536
721
|
// loop streams through (adapters included) — capture it while alive.
|
|
537
722
|
try {
|
|
@@ -542,21 +727,41 @@ export function apply(ctx: Context): void {
|
|
|
542
727
|
} catch {
|
|
543
728
|
// scope already unwinding — the root fallback stays
|
|
544
729
|
}
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
730
|
+
const target = (splice.target ?? 'next-turn') === 'next-step' ? 'next-step' : 'next-turn'
|
|
731
|
+
const list = (target === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn) as readonly UserMessageLike[]
|
|
732
|
+
// Authority — session-log replay (resolveClaimedText, CONCERN A):
|
|
733
|
+
// whenever the event carries a seq, the fold runs FIRST and its
|
|
734
|
+
// verdict is final — with ≥2 pending messages the post-splice
|
|
735
|
+
// projection read is non-empty but shifted by one, so gating the
|
|
736
|
+
// replay on an empty projection would claim msg1 and read msg2.
|
|
737
|
+
const seq = event.seq
|
|
738
|
+
let logEvents: readonly InboxEventLike[] | undefined
|
|
739
|
+
if (typeof seq === 'number') {
|
|
740
|
+
try {
|
|
741
|
+
const snapshot = (session as unknown as {
|
|
742
|
+
snapshotEvents?: (fromSeq?: number, toSeqExclusive?: number) => readonly InboxEventLike[]
|
|
743
|
+
}).snapshotEvents
|
|
744
|
+
if (typeof snapshot === 'function') logEvents = snapshot.call(session, 0, seq)
|
|
745
|
+
} catch {
|
|
746
|
+
// contained — the projection fallback inside resolveClaimedText
|
|
747
|
+
// stays the verdict
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
const claimed = resolveClaimedText({ projection: list, logEvents, seq, target, start: splice.start, removedCount: splice.removedCount })
|
|
751
|
+
if (claimed.text.trim() === '') {
|
|
752
|
+
// claimed.source names where the (empty) verdict actually came
|
|
753
|
+
// from (CONCERN B): 'log-replay' = the fold ran and the pre-splice
|
|
754
|
+
// window genuinely held no user text; 'projection' = the replay
|
|
755
|
+
// never armed (no seq / snapshotEvents unreachable) and the
|
|
756
|
+
// projection read was empty too.
|
|
757
|
+
silentExit(`empty-claimed/${claimed.source}`, `target=${target} start=${String(splice.start)} removedCount=${String(splice.removedCount)}——被 claim 窗口重建后仍无用户文本`)
|
|
758
|
+
return
|
|
553
759
|
}
|
|
554
|
-
if (claimedText.trim() === '') return
|
|
555
760
|
const state = turns.get(sessionId) ?? { claimedText: '', injectionText: '' }
|
|
556
|
-
state.claimedText =
|
|
761
|
+
state.claimedText = claimed.text
|
|
557
762
|
state.injectionText = ''
|
|
558
763
|
turns.set(sessionId, state)
|
|
559
|
-
retrieveForTurn(sessionId,
|
|
764
|
+
retrieveForTurn(sessionId, claimed.text)
|
|
560
765
|
return
|
|
561
766
|
}
|
|
562
767
|
if (event.type === 'turn/start') {
|
package/src/store.ts
CHANGED
|
@@ -140,6 +140,31 @@ export class BundleStore {
|
|
|
140
140
|
return join(this.metaDir(), 'injections.jsonl')
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Fast-lane heartbeat (0.17.0): tail timestamps of the two lane sidecars —
|
|
145
|
+
* the last injection round (`injections.jsonl`) and the last observation
|
|
146
|
+
* (`observations.jsonl`). Feeds the /topics status stale check that makes
|
|
147
|
+
* "observer still recording, injection lane silently dead" visible within
|
|
148
|
+
* a day instead of weeks (the 0.1.5-rc.2 claim-order incident shape).
|
|
149
|
+
* Full-file reads are fine here: /topics status is interactive and both
|
|
150
|
+
* logs are append-per-round small; unreadable logs degrade to undefined.
|
|
151
|
+
*/
|
|
152
|
+
async readLaneHeartbeat(): Promise<{ lastInjectionAt?: string; lastObservationAt?: string }> {
|
|
153
|
+
const tailAt = async (file: string): Promise<string | undefined> => {
|
|
154
|
+
try {
|
|
155
|
+
const lines = (await readFile(file, 'utf8')).trimEnd().split('\n')
|
|
156
|
+
const at = (JSON.parse(lines.at(-1) ?? '') as { at?: unknown }).at
|
|
157
|
+
return typeof at === 'string' ? at : undefined
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
lastInjectionAt: await tailAt(this.injectionsPath()),
|
|
164
|
+
lastObservationAt: await tailAt(this.observationsPath()),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
143
168
|
private conflictsPath(): string {
|
|
144
169
|
return join(this.metaDir(), 'conflicts.json')
|
|
145
170
|
}
|