@aiwayds/dsh-topics-memory 0.16.0 → 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 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
- if (!splice.removedCount || splice.outcome === 'canceled')
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 list = ((splice.target ?? 'next-turn') === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn);
485
- // Live dispatch precedes projection mutation: read the pre-splice window.
486
- const claimed = list.slice(splice.start, splice.start + splice.removedCount);
487
- let claimedText = '';
488
- for (const message of claimed) {
489
- if (message.source?.kind !== 'user')
490
- continue;
491
- const text = textOf(message);
492
- if (text.trim() !== '')
493
- claimedText = claimedText === '' ? text : `${claimedText}\n${text}`;
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
- if (claimedText.trim() === '')
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 = claimedText;
639
+ state.claimedText = claimed.text;
499
640
  state.injectionText = '';
500
641
  turns.set(sessionId, state);
501
- retrieveForTurn(sessionId, claimedText);
642
+ retrieveForTurn(sessionId, claimed.text);
502
643
  return;
503
644
  }
504
645
  if (event.type === 'turn/start') {
@@ -527,6 +668,10 @@ export function apply(ctx) {
527
668
  if (event.type === 'turn/end')
528
669
  dispatchSlowLane(sessionId);
529
670
  }));
671
+ // Session-start boot chain dedup: rc.2's creation transaction emits BOTH
672
+ // `agent/session-start` and `agent/created`, so the pair collapses to one
673
+ // chain run per session; teardown clears the entry.
674
+ const bootedSessions = new Set();
530
675
  // ---- Session teardown: real cordis events, not session/event types ----
531
676
  // dsh-session 0.1.2-alpha.4's SessionEventMap has no `agent/disposed` or
532
677
  // `session/disposed` event type — the old `event.type === 'agent/disposed'`
@@ -551,6 +696,7 @@ export function apply(ctx) {
551
696
  observer.onSessionEvent(sessionId, name, undefined);
552
697
  injectedBySession.delete(sessionId);
553
698
  slowLane.clear(sessionId);
699
+ bootedSessions.delete(sessionId);
554
700
  // A teardown-triggered run (session-end distill) reads the payload
555
701
  // capture lazily: drop the entry here only when no run can still read
556
702
  // it. The slow lane's in-flight pipeline reads it too (shared caller);
@@ -559,74 +705,90 @@ export function apply(ctx) {
559
705
  }));
560
706
  }
561
707
  // ---- Sync lifecycle: pull on session start, local commit on dispose ----
562
- ctx.on('session/event', ((session, event) => {
563
- if (event.type === 'agent/session-start') {
564
- void sync
565
- .pull()
566
- .catch(() => undefined)
567
- .finally(() => store.ensure().catch(() => undefined))
568
- .then(() => {
569
- // Boot replay: the observations JSONL is the durable distill queue.
570
- // Whatever the previous exit skipped (local-only exit since the
571
- // no-network exit commit) or a killed run left unmarked is still
572
- // undistilled here. Empty pool and no-model are cheap no-ops inside
573
- // the distiller; the per-session dedup guards double runs.
574
- void store
575
- .undistilledObservations(1)
576
- .then((pending) => {
577
- if (pending.length === 0)
578
- return;
579
- // Same trigger-time capture the observer callback does: the
580
- // replay run's caller resolves candidates lazily and a fresh
581
- // session has no prior capture to lean on.
582
- try {
583
- captureFromAgent(agents()?.get(session.id));
584
- }
585
- catch {
586
- // contained — the run below fails with the readable
587
- // no-adapter detail instead
588
- }
589
- const run = distiller.request(String(session.id), 'boot-replay');
590
- if (run !== undefined)
591
- void run.catch(() => undefined);
592
- })
593
- .catch(() => undefined);
594
- // Consolidation cadence check — after the pull (freshest pool),
595
- // fully fire-and-forget: cadence/single-flight gates live inside
596
- // maybeRun, and a no-model boot just skips (no stamp advanced).
597
- // Failures are NOT silent at the host log: the 2026-09-09 real-host
598
- // test showed a dead distill route would otherwise hide here with
599
- // zero observable trace (the state file only advances on success).
708
+ // Session start is an agent-bus event, never a session/event firehose type:
709
+ // the firehose only carries Session.append types. Tarball-verified emission
710
+ // sites: `agent/session-start` (sync, payload { agent, source }) is emitted
711
+ // by dsh-agent-loop up to 0.1.5; 0.1.6-alpha.1 merges the startup
712
+ // notification into the serial `agent/created` announcement (payload
713
+ // { agent, source, signal? }) and drops the old name. So one registration
714
+ // per name — a host that never emits a name simply never calls that
715
+ // listener. Only { agent } is structural; the rest is defensive.
716
+ const runBootChain = (sessionId) => {
717
+ void sync
718
+ .pull()
719
+ .catch(() => undefined)
720
+ .finally(() => store.ensure().catch(() => undefined))
721
+ .then(() => {
722
+ // Boot replay: the observations JSONL is the durable distill queue.
723
+ // Whatever the previous exit skipped (local-only exit since the
724
+ // no-network exit commit) or a killed run left unmarked is still
725
+ // undistilled here. Empty pool and no-model are cheap no-ops inside
726
+ // the distiller; the per-session dedup guards double runs.
727
+ void store
728
+ .undistilledObservations(1)
729
+ .then((pending) => {
730
+ if (pending.length === 0)
731
+ return;
732
+ // Same trigger-time capture the observer callback does: the
733
+ // replay run's caller resolves candidates lazily and a fresh
734
+ // session has no prior capture to lean on.
600
735
  try {
601
- captureFromAgent(agents()?.get(session.id));
736
+ captureFromAgent(agents()?.get(sessionId));
602
737
  }
603
738
  catch {
604
- // contained — the run below fails with a readable no-model detail
739
+ // contained — the run below fails with the readable
740
+ // no-adapter detail instead
741
+ }
742
+ const run = distiller.request(sessionId, 'boot-replay');
743
+ if (run !== undefined)
744
+ void run.catch(() => undefined);
745
+ })
746
+ .catch(() => undefined);
747
+ // Consolidation cadence check — after the pull (freshest pool),
748
+ // fully fire-and-forget: cadence/single-flight gates live inside
749
+ // maybeRun, and a no-model boot just skips (no stamp advanced).
750
+ // Failures are NOT silent at the host log: the 2026-09-09 real-host
751
+ // test showed a dead distill route would otherwise hide here with
752
+ // zero observable trace (the state file only advances on success).
753
+ try {
754
+ captureFromAgent(agents()?.get(sessionId));
755
+ }
756
+ catch {
757
+ // contained — the run below fails with a readable no-model detail
758
+ }
759
+ void consolidator
760
+ .maybeRun({ sessionId })
761
+ .then((r) => {
762
+ if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
763
+ warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`);
605
764
  }
606
- void consolidator
607
- .maybeRun({ sessionId: String(session.id) })
608
- .then((r) => {
609
- if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
610
- warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`);
765
+ })
766
+ .catch(() => undefined);
767
+ // Deprecated-TTL sweep — pure local rule, no model, runs even when
768
+ // the distill route is unconfigured. Drops are logged: deletion is
769
+ // the one housekeeping action the user should always see happened.
770
+ const ttl = cfgNow().deprecatedTtlDays;
771
+ if (ttl > 0) {
772
+ void dropExpiredDeprecated(service, ttl)
773
+ .then((dropped) => {
774
+ if (dropped.length > 0) {
775
+ warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`);
611
776
  }
612
777
  })
613
778
  .catch(() => undefined);
614
- // Deprecated-TTL sweep — pure local rule, no model, runs even when
615
- // the distill route is unconfigured. Drops are logged: deletion is
616
- // the one housekeeping action the user should always see happened.
617
- const ttl = cfgNow().deprecatedTtlDays;
618
- if (ttl > 0) {
619
- void dropExpiredDeprecated(service, ttl)
620
- .then((dropped) => {
621
- if (dropped.length > 0) {
622
- warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`);
623
- }
624
- })
625
- .catch(() => undefined);
626
- }
627
- });
628
- }
629
- }));
779
+ }
780
+ })
781
+ .catch(() => undefined);
782
+ };
783
+ for (const name of ['agent/session-start', 'agent/created']) {
784
+ ctx.on(name, ((payload) => {
785
+ const sessionId = String(payload?.agent?.id ?? '');
786
+ if (sessionId === '' || bootedSessions.has(sessionId))
787
+ return;
788
+ bootedSessions.add(sessionId);
789
+ runBootChain(sessionId);
790
+ }));
791
+ }
630
792
  ctx.effect(() => {
631
793
  void store.ensure().catch(() => undefined);
632
794
  // Async disposer: cordis awaits it during unload (Disposable may be
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.16.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
- if (!splice.removedCount || splice.outcome === 'canceled') return
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) return
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 list = ((splice.target ?? 'next-turn') === 'next-step' ? agent.inbox.nextStep : agent.inbox.nextTurn) as readonly UserMessageLike[]
546
- // Live dispatch precedes projection mutation: read the pre-splice window.
547
- const claimed = list.slice(splice.start, splice.start + splice.removedCount)
548
- let claimedText = ''
549
- for (const message of claimed) {
550
- if ((message as UserMessageData).source?.kind !== 'user') continue
551
- const text = textOf(message as UserMessageLike)
552
- if (text.trim() !== '') claimedText = claimedText === '' ? text : `${claimedText}\n${text}`
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 = claimedText
761
+ state.claimedText = claimed.text
557
762
  state.injectionText = ''
558
763
  turns.set(sessionId, state)
559
- retrieveForTurn(sessionId, claimedText)
764
+ retrieveForTurn(sessionId, claimed.text)
560
765
  return
561
766
  }
562
767
  if (event.type === 'turn/start') {
@@ -586,6 +791,11 @@ export function apply(ctx: Context): void {
586
791
  }) as never,
587
792
  )
588
793
 
794
+ // Session-start boot chain dedup: rc.2's creation transaction emits BOTH
795
+ // `agent/session-start` and `agent/created`, so the pair collapses to one
796
+ // chain run per session; teardown clears the entry.
797
+ const bootedSessions = new Set<string>()
798
+
589
799
  // ---- Session teardown: real cordis events, not session/event types ----
590
800
  // dsh-session 0.1.2-alpha.4's SessionEventMap has no `agent/disposed` or
591
801
  // `session/disposed` event type — the old `event.type === 'agent/disposed'`
@@ -609,6 +819,7 @@ export function apply(ctx: Context): void {
609
819
  observer.onSessionEvent(sessionId, name, undefined)
610
820
  injectedBySession.delete(sessionId)
611
821
  slowLane.clear(sessionId)
822
+ bootedSessions.delete(sessionId)
612
823
  // A teardown-triggered run (session-end distill) reads the payload
613
824
  // capture lazily: drop the entry here only when no run can still read
614
825
  // it. The slow lane's in-flight pipeline reads it too (shared caller);
@@ -618,70 +829,85 @@ export function apply(ctx: Context): void {
618
829
  }
619
830
 
620
831
  // ---- Sync lifecycle: pull on session start, local commit on dispose ----
621
- ctx.on('session/event' as never, ((session: { id: unknown }, event: SessionEvent) => {
622
- if (event.type === 'agent/session-start') {
623
- void sync
624
- .pull()
625
- .catch(() => undefined)
626
- .finally(() => store.ensure().catch(() => undefined))
627
- .then(() => {
628
- // Boot replay: the observations JSONL is the durable distill queue.
629
- // Whatever the previous exit skipped (local-only exit since the
630
- // no-network exit commit) or a killed run left unmarked is still
631
- // undistilled here. Empty pool and no-model are cheap no-ops inside
632
- // the distiller; the per-session dedup guards double runs.
633
- void store
634
- .undistilledObservations(1)
635
- .then((pending) => {
636
- if (pending.length === 0) return
637
- // Same trigger-time capture the observer callback does: the
638
- // replay run's caller resolves candidates lazily and a fresh
639
- // session has no prior capture to lean on.
640
- try {
641
- captureFromAgent(agents()?.get(session.id) as unknown)
642
- } catch {
643
- // contained — the run below fails with the readable
644
- // no-adapter detail instead
645
- }
646
- const run = distiller.request(String(session.id), 'boot-replay')
647
- if (run !== undefined) void run.catch(() => undefined)
648
- })
649
- .catch(() => undefined)
650
- // Consolidation cadence check — after the pull (freshest pool),
651
- // fully fire-and-forget: cadence/single-flight gates live inside
652
- // maybeRun, and a no-model boot just skips (no stamp advanced).
653
- // Failures are NOT silent at the host log: the 2026-09-09 real-host
654
- // test showed a dead distill route would otherwise hide here with
655
- // zero observable trace (the state file only advances on success).
656
- try {
657
- captureFromAgent(agents()?.get(session.id) as unknown)
658
- } catch {
659
- // contained — the run below fails with a readable no-model detail
660
- }
661
- void consolidator
662
- .maybeRun({ sessionId: String(session.id) })
663
- .then((r) => {
664
- if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
665
- warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`)
832
+ // Session start is an agent-bus event, never a session/event firehose type:
833
+ // the firehose only carries Session.append types. Tarball-verified emission
834
+ // sites: `agent/session-start` (sync, payload { agent, source }) is emitted
835
+ // by dsh-agent-loop up to 0.1.5; 0.1.6-alpha.1 merges the startup
836
+ // notification into the serial `agent/created` announcement (payload
837
+ // { agent, source, signal? }) and drops the old name. So one registration
838
+ // per name — a host that never emits a name simply never calls that
839
+ // listener. Only { agent } is structural; the rest is defensive.
840
+ const runBootChain = (sessionId: string) => {
841
+ void sync
842
+ .pull()
843
+ .catch(() => undefined)
844
+ .finally(() => store.ensure().catch(() => undefined))
845
+ .then(() => {
846
+ // Boot replay: the observations JSONL is the durable distill queue.
847
+ // Whatever the previous exit skipped (local-only exit since the
848
+ // no-network exit commit) or a killed run left unmarked is still
849
+ // undistilled here. Empty pool and no-model are cheap no-ops inside
850
+ // the distiller; the per-session dedup guards double runs.
851
+ void store
852
+ .undistilledObservations(1)
853
+ .then((pending) => {
854
+ if (pending.length === 0) return
855
+ // Same trigger-time capture the observer callback does: the
856
+ // replay run's caller resolves candidates lazily and a fresh
857
+ // session has no prior capture to lean on.
858
+ try {
859
+ captureFromAgent(agents()?.get(sessionId) as unknown)
860
+ } catch {
861
+ // contained — the run below fails with the readable
862
+ // no-adapter detail instead
863
+ }
864
+ const run = distiller.request(sessionId, 'boot-replay')
865
+ if (run !== undefined) void run.catch(() => undefined)
866
+ })
867
+ .catch(() => undefined)
868
+ // Consolidation cadence check — after the pull (freshest pool),
869
+ // fully fire-and-forget: cadence/single-flight gates live inside
870
+ // maybeRun, and a no-model boot just skips (no stamp advanced).
871
+ // Failures are NOT silent at the host log: the 2026-09-09 real-host
872
+ // test showed a dead distill route would otherwise hide here with
873
+ // zero observable trace (the state file only advances on success).
874
+ try {
875
+ captureFromAgent(agents()?.get(sessionId) as unknown)
876
+ } catch {
877
+ // contained — the run below fails with a readable no-model detail
878
+ }
879
+ void consolidator
880
+ .maybeRun({ sessionId })
881
+ .then((r) => {
882
+ if (r !== undefined && !r.ok && r.reason !== 'no-clusters') {
883
+ warn(`dsh-topics-memory 整理 lane 未执行:${r.reason ?? 'unknown'}${r.detail !== undefined ? `(${r.detail})` : ''}`)
884
+ }
885
+ })
886
+ .catch(() => undefined)
887
+ // Deprecated-TTL sweep — pure local rule, no model, runs even when
888
+ // the distill route is unconfigured. Drops are logged: deletion is
889
+ // the one housekeeping action the user should always see happened.
890
+ const ttl = cfgNow().deprecatedTtlDays
891
+ if (ttl > 0) {
892
+ void dropExpiredDeprecated(service, ttl)
893
+ .then((dropped) => {
894
+ if (dropped.length > 0) {
895
+ warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`)
666
896
  }
667
897
  })
668
898
  .catch(() => undefined)
669
- // Deprecated-TTL sweep — pure local rule, no model, runs even when
670
- // the distill route is unconfigured. Drops are logged: deletion is
671
- // the one housekeeping action the user should always see happened.
672
- const ttl = cfgNow().deprecatedTtlDays
673
- if (ttl > 0) {
674
- void dropExpiredDeprecated(service, ttl)
675
- .then((dropped) => {
676
- if (dropped.length > 0) {
677
- warn(`dsh-topics-memory:按 ${ttl} 天 TTL 删除了 ${dropped.length} 条 deprecated topic(${dropped.map((s) => `topics/${s}`).join('、')});git 历史可找回`)
678
- }
679
- })
680
- .catch(() => undefined)
681
- }
682
- })
683
- }
684
- }) as never)
899
+ }
900
+ })
901
+ .catch(() => undefined)
902
+ }
903
+ for (const name of ['agent/session-start', 'agent/created'] as const) {
904
+ ctx.on(name as never, ((payload: { agent?: { id?: unknown } }) => {
905
+ const sessionId = String(payload?.agent?.id ?? '')
906
+ if (sessionId === '' || bootedSessions.has(sessionId)) return
907
+ bootedSessions.add(sessionId)
908
+ runBootChain(sessionId)
909
+ }) as never)
910
+ }
685
911
 
686
912
  ctx.effect(
687
913
  () => {
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
  }