@jjh32456/dsh-longlive 1.6.7 → 1.6.8

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +40 -8
  2. package/QUICKSTART.md +10 -10
  3. package/README.en.md +4 -3
  4. package/README.md +10 -4
  5. package/SPEC.md +2 -2
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +3 -2
  8. package/docs/CONFIG.md +33 -6
  9. package/docs/DEV.md +3 -3
  10. package/docs/FAQ.md +4 -4
  11. package/docs/FEATURES.md +36 -5
  12. package/docs/OPERATIONS.md +1 -1
  13. package/docs/QUICKSTART.md +3 -3
  14. package/docs/README.en.md +3 -2
  15. package/docs/TROUBLESHOOTING.md +3 -3
  16. package/package.json +13 -8
  17. package/src/activity/index.js +649 -0
  18. package/src/activity/model.js +341 -0
  19. package/src/activity/store.js +625 -0
  20. package/src/config.js +29 -2
  21. package/src/features.js +26 -0
  22. package/src/heartbeat.js +156 -23
  23. package/src/identity/handover.js +52 -1
  24. package/src/live.js +22 -2
  25. package/src/recall/privacy.mjs +105 -16
  26. package/src/router/service/board.mjs +95 -15
  27. package/src/router/service/hint.mjs +14 -0
  28. package/src/router/service/service.mjs +27 -11
  29. package/test/activity-realtime.test.mjs +959 -0
  30. package/test/activity-stream.test.mjs +512 -0
  31. package/test/autobind.test.mjs +25 -11
  32. package/test/config-schema.test.mjs +6 -0
  33. package/test/feature-flags.test.mjs +17 -0
  34. package/test/fixtures/activity-append-child.mjs +29 -0
  35. package/test/live.test.mjs +6 -3
  36. package/test/packaging.test.mjs +7 -5
  37. package/test/router-background/router.test.mjs +44 -12
  38. package/test/run-rc.mjs +16 -0
  39. package/test/safety-defaults.test.mjs +5 -3
  40. package/test/state-truth.test.mjs +937 -0
  41. package/test/version-consistency.test.mjs +286 -0
package/src/features.js CHANGED
@@ -9,6 +9,8 @@
9
9
  // identityCard.enabled -> agent/pre-step (open card, prepend:true)
10
10
  // autoRecall.enabled -> agent/pre-step (recall, prepend:false) + session/event
11
11
  // presenceBus.enabled -> session/event (drain this session's bus inbox)
12
+ // sharedBrain.activity.enabled -> session/event (write this turn's activity)
13
+ // + agent/pre-step (inject other sessions' new rows)
12
14
  // autoMaintenance.enabled -> one unref'd timer to the next local midnight
13
15
  // (consolidate report + forget dry-run pending list;
14
16
  // no turn hook, so a failure cannot affect a turn)
@@ -40,6 +42,7 @@ import {
40
42
  import { apply as applyAutoRecall } from './recall/index.js';
41
43
  import { createBus, normalizeSessionId, shouldAckOnTurnEnd } from './bus/bus.mjs';
42
44
  import { createMaintenanceScheduler } from './memory/maintenance.js';
45
+ import { normalizeActivityConfig, registerActivityStream } from './activity/index.js';
43
46
  import {
44
47
  claimFallbackInjection,
45
48
  markReceiptFallbackInjected,
@@ -52,6 +55,9 @@ export const FEATURE_DEFAULTS = Object.freeze({
52
55
  autoRecall: Object.freeze({ enabled: false, topK: 3, budgetTokens: 2000, everyNTurns: 6 }),
53
56
  identityCard: Object.freeze({ enabled: false, path: '<memoryRoot>/STATE.md', maxChars: 800 }),
54
57
  presenceBus: Object.freeze({ enabled: false, pollMs: 3000 }),
58
+ sharedBrain: Object.freeze({
59
+ activity: Object.freeze({ enabled: false, injectMaxChars: 300, maxItems: 5 }),
60
+ }),
55
61
  autoMaintenance: Object.freeze({
56
62
  enabled: false,
57
63
  consolidate: true,
@@ -81,6 +87,8 @@ export function normalizeFeatureConfig(config = {}) {
81
87
  const auto = value.autoRecall && typeof value.autoRecall === 'object' ? value.autoRecall : {};
82
88
  const identity = value.identityCard && typeof value.identityCard === 'object' ? value.identityCard : {};
83
89
  const bus = value.presenceBus && typeof value.presenceBus === 'object' ? value.presenceBus : {};
90
+ const sharedBrain = value.sharedBrain && typeof value.sharedBrain === 'object' ? value.sharedBrain : {};
91
+ const activity = sharedBrain.activity && typeof sharedBrain.activity === 'object' ? sharedBrain.activity : {};
84
92
  const maintenance = value.autoMaintenance && typeof value.autoMaintenance === 'object'
85
93
  ? value.autoMaintenance
86
94
  : {};
@@ -105,6 +113,9 @@ export function normalizeFeatureConfig(config = {}) {
105
113
  pollMs: clampInt(bus.pollMs, FEATURE_DEFAULTS.presenceBus.pollMs, 0, 3600000),
106
114
  root: text(bus.root),
107
115
  },
116
+ sharedBrain: {
117
+ activity: normalizeActivityConfig(activity),
118
+ },
108
119
  autoMaintenance: {
109
120
  enabled: bool(maintenance.enabled),
110
121
  consolidate: maintenance.consolidate !== false,
@@ -566,6 +577,21 @@ export function registerFeatures(ctx, config = {}, options = {}) {
566
577
  enabled.push('presenceBus');
567
578
  }
568
579
 
580
+ if (cfg.sharedBrain.activity.enabled) {
581
+ if (!memoryRoot) {
582
+ warn('[longlive] sharedBrain.activity 已开启但没有可解析的 memoryRoot — 已跳过');
583
+ } else {
584
+ const stream = registerActivityStream(ctx, cfg.sharedBrain.activity, {
585
+ memoryRoot,
586
+ // Test seam only; the host config never carries these.
587
+ now: config.now,
588
+ privacyScan: typeof config.activityPrivacyScan === 'function' ? config.activityPrivacyScan : undefined,
589
+ logger: warn,
590
+ });
591
+ if (stream.enabled) enabled.push('sharedBrain.activity');
592
+ }
593
+ }
594
+
569
595
  if (cfg.autoMaintenance.enabled) {
570
596
  if (!memoryRoot) {
571
597
  warn('[longlive] autoMaintenance 已开启但没有可解析的 memoryRoot — 已跳过');
package/src/heartbeat.js CHANGED
@@ -1,24 +1,65 @@
1
1
  // Auto-heartbeat for the "正在" live registry.
2
2
  //
3
- // Two clocks keep a bound row fresh without the caller passing sessionId:
4
- // 1. turn lifecycle — `session/event` with `turn/start` / `turn/end` for a
5
- // session this writer has already bound;
3
+ // Two clocks keep a row fresh without the caller passing sessionId:
4
+ // 1. turn lifecycle — `session/event` with `turn/start` / `turn/end`. The
5
+ // event itself carries the session id, so turn boundaries maintain EVERY
6
+ // non-subagent session's row (R4.5), bound or not; a missing row is
7
+ // created on the first boundary (128-row cap respected, R4.3);
6
8
  // 2. a fallback interval (`heartbeatMs`, default 15 s, `0` disables), which
7
- // is what keeps the row fresh through long tool calls and idle stretches.
9
+ // keeps rows of sessions bound by a longlive tool call fresh through long
10
+ // tool calls and idle stretches. It never fabricates rows.
8
11
  //
9
- // Identity is bound, never guessed: the id comes from a tool call's explicit
10
- // `exec.agent` (see identity.js). Turn events and the timer only refresh ids
11
- // already in that bound set, so a root-scoped plugin observing every session
12
- // can never heartbeat somebody else's row.
12
+ // Turn boundaries are a state machine, not a heartbeat: `turn/start` writes
13
+ // `state='running'` plus the turn number, `turn/end` writes
14
+ // `idle`/`waiting`/`blocked` plus the turn number (R4.3). It is event driven,
15
+ // so it still runs when the fallback timer is off (`heartbeatMs: 0`); a timer
16
+ // tick, by contrast, only renews `heartbeatAt` and never touches state or
17
+ // `last_action`.
13
18
  //
14
- // Every write is best effort: a missing/corrupt row, an unset memoryRoot or a
15
- // racing writer must never fail a tool call or crash the host.
19
+ // Every write is best effort: a missing/corrupt row, an unset memoryRoot, a
20
+ // full registry or a racing writer must never fail a tool call or crash the
21
+ // host.
16
22
 
17
- import { liveTouch, liveUpdate } from './live.js';
23
+ import { liveGet, liveList, liveTouch, liveUpdate } from './live.js';
18
24
  import { resolveCurrentSessionId } from './identity.js';
19
25
 
20
26
  export const DEFAULT_HEARTBEAT_MS = 15_000;
21
27
  export const MAX_BOUND_SESSIONS = 64;
28
+ /** DESIGN §2.1 live-row cap; mirrored here so turn boundaries cannot blow it up. */
29
+ export const MAX_LIVE_ROWS = 128;
30
+
31
+ /**
32
+ * R4.3 pure state machine: state a live row must carry when a turn starts.
33
+ * @returns {'running'}
34
+ */
35
+ export function stateForTurnStart() {
36
+ return 'running';
37
+ }
38
+
39
+ /**
40
+ * R4.3 pure state machine: state a live row must carry when a turn ends.
41
+ * `waiting`/`blocked` are user-facing promises and survive the turn boundary;
42
+ * anything else falls back to `idle`.
43
+ * @param {unknown} priorState declared state read before the turn/end write
44
+ * @returns {'idle'|'waiting'|'blocked'}
45
+ */
46
+ export function stateForTurnEnd(priorState) {
47
+ return priorState === 'waiting' || priorState === 'blocked' ? priorState : 'idle';
48
+ }
49
+
50
+ /**
51
+ * Pure description of the live-row patch caused by one turn boundary. Kept
52
+ * separate from I/O so the state machine can be pinned without a registry.
53
+ * @param {'turn/start'|'turn/end'} type
54
+ * @param {{turn?:unknown, priorState?:unknown}} [options]
55
+ * @returns {{state:string, turn:number|null, kind:'turn', detail:string}}
56
+ */
57
+ export function turnBoundaryPlan(type, options = {}) {
58
+ const kind = type === 'turn/end' ? 'turn/end' : 'turn/start';
59
+ const state = kind === 'turn/start' ? stateForTurnStart() : stateForTurnEnd(options.priorState);
60
+ const turn = Number.isInteger(options.turn) && options.turn >= 0 ? options.turn : null;
61
+ return { state, turn, kind: 'turn', detail: turn == null ? kind : `${kind} #${turn}` };
62
+ }
22
63
 
23
64
  function cleanId(value) {
24
65
  if (typeof value !== 'string') return null;
@@ -33,12 +74,19 @@ function cleanId(value) {
33
74
  * @param {object} [options]
34
75
  * @param {object|null} [options.ctx] host context (used for `session/event`)
35
76
  * @param {string} [options.memoryRoot] live-registry root; absent disables writes
36
- * @param {number} [options.heartbeatMs] fallback interval; `0` disables all
37
- * automatic writes (binding still happens through the tool resolver)
77
+ * @param {number} [options.heartbeatMs] fallback interval; `0` disables the
78
+ * timer and tool-call touches (binding still happens through the tool
79
+ * resolver). Turn-boundary state writes (`turn/start` → running + turn,
80
+ * `turn/end` → idle/waiting/blocked + turn) are event driven and keep
81
+ * working, because "no timer" is not "no state machine" (R4.3).
38
82
  * @param {() => (Date|number)} [options.now] clock injection for tests
39
83
  * @param {Function} [options.touch] `liveTouch` injection for tests
40
84
  * @param {Function} [options.update] `liveUpdate` injection for tests (first
41
85
  * registration of a session that has no row yet)
86
+ * @param {Function} [options.get] `liveGet` injection for tests (turn/end reads
87
+ * the prior declared state so waiting/blocked survive)
88
+ * @param {number} [options.maxRows] live-row cap for rows created by turn
89
+ * boundaries (default 128, DESIGN §2.1)
42
90
  * @param {Function} [options.setInterval] timer injection for tests
43
91
  * @param {Function} [options.clearInterval]
44
92
  * @param {Function} [options.resolve] `(exec) => {id, source}` injection
@@ -51,6 +99,9 @@ export function createAutoHeartbeat(options = {}) {
51
99
  now,
52
100
  touch = liveTouch,
53
101
  update = liveUpdate,
102
+ // Read side, used only to preserve waiting/blocked across turn/end.
103
+ get = liveGet,
104
+ maxRows = MAX_LIVE_ROWS,
54
105
  setInterval: setIntervalFn = globalThis.setInterval,
55
106
  clearInterval: clearIntervalFn = globalThis.clearInterval,
56
107
  resolve = (exec) => resolveCurrentSessionId({ ctx, exec }),
@@ -61,7 +112,9 @@ export function createAutoHeartbeat(options = {}) {
61
112
  ? memoryRoot.trim()
62
113
  : null;
63
114
  const interval = typeof heartbeatMs === 'number' ? heartbeatMs : Number(heartbeatMs);
64
- // Only a finite positive interval enables automatic writes; 0/invalid = off.
115
+ // Only a finite positive interval enables the fallback timer and the
116
+ // tool-call touches; 0/invalid = those two off. Turn-boundary state writes
117
+ // (R4.3) are event driven and deliberately independent of this flag.
65
118
  const enabled = Number.isFinite(interval) && interval > 0;
66
119
 
67
120
  const bound = new Set();
@@ -156,18 +209,82 @@ export function createAutoHeartbeat(options = {}) {
156
209
  return touched;
157
210
  }
158
211
 
212
+ /**
213
+ * Prior row snapshot for a turn boundary: does it exist, and what state does
214
+ * it declare? Read-only; never throws. `known:false` means the read itself
215
+ * failed, in which case we still attempt the write (liveUpdate is atomic and
216
+ * the row keeps its prior values for omitted fields).
217
+ */
218
+ function readPriorRow(id) {
219
+ if (root == null || typeof get !== 'function') return { known: false, found: false, state: null };
220
+ const config = { memoryRoot: root };
221
+ const at = stamp();
222
+ if (at !== undefined) config.now = at;
223
+ try {
224
+ const got = get({ sessionId: id }, config);
225
+ if (got?.ok === true) {
226
+ return { known: true, found: true, state: typeof got.live?.state === 'string' ? got.live.state : null };
227
+ }
228
+ if (got?.status === 'absent') return { known: true, found: false, state: null };
229
+ // corrupt/unreadable-but-present: it occupies a slot; a write repairs it.
230
+ return { known: true, found: true, state: null };
231
+ } catch {
232
+ return { known: false, found: false, state: null };
233
+ }
234
+ }
235
+
236
+ /** True when a NEW row may still be created without exceeding the 128 cap. */
237
+ function hasRowCapacity() {
238
+ if (root == null || typeof liveList !== 'function') return true;
239
+ const config = { memoryRoot: root };
240
+ const at = stamp();
241
+ if (at !== undefined) config.now = at;
242
+ try {
243
+ const listed = liveList({ includeDead: true }, config);
244
+ const scanned = Number.isInteger(listed?.scanned)
245
+ ? listed.scanned
246
+ : (Array.isArray(listed?.entries) ? listed.entries.length : 0);
247
+ return scanned < maxRows;
248
+ } catch {
249
+ // Cannot tell -> do not create rows, but an existing row may be updated.
250
+ return false;
251
+ }
252
+ }
253
+
254
+ /** One best-effort live-row patch at a turn boundary (state + turn + action). */
255
+ function writeTurnBoundary(id, { state, turn, kind, detail }) {
256
+ if (root == null || typeof update !== 'function') return { ok: false, status: 'disabled' };
257
+ const args = { sessionId: id, state, last_action: { kind, detail } };
258
+ if (Number.isInteger(turn) && turn >= 0) args.turn = turn;
259
+ const config = { memoryRoot: root };
260
+ const at = stamp();
261
+ if (at !== undefined) config.now = at;
262
+ try {
263
+ return update(args, config);
264
+ } catch {
265
+ // A state-machine write is decoration for other sessions, never the
266
+ // turn itself: swallow and let the turn proceed.
267
+ return { ok: false, status: 'error' };
268
+ }
269
+ }
270
+
159
271
  function onSessionEvent(session, event) {
160
272
  let id = null;
273
+ let origin = null;
161
274
  try {
162
- id = cleanId(session?.id) ?? cleanId(session?.header?.id);
275
+ id = cleanId(session?.id)
276
+ ?? cleanId(session?.header?.id)
277
+ ?? cleanId(event?.session?.id)
278
+ ?? cleanId(event?.session?.header?.id);
279
+ const header = session?.header ?? session?.session?.header ?? event?.session?.header ?? {};
280
+ origin = typeof header.origin === 'string' ? header.origin : null;
163
281
  } catch {
164
282
  id = null;
165
283
  }
166
284
  // Auto-truncation watches every session in this agent's scope, not only
167
285
  // the ones bound to the live registry: a session that never called a
168
286
  // longlive tool can still grow past the threshold. It runs before the
169
- // `enabled` gate so `heartbeatMs:0` (live writes off) does not also turn
170
- // maintenance off.
287
+ // live-state gate so `heartbeatMs:0` does not also turn maintenance off.
171
288
  if (typeof onTurnEnd === 'function' && event?.type === 'turn/end' && id) {
172
289
  try {
173
290
  onTurnEnd({ session, event, sessionId: id, turn: event?.data?.turn });
@@ -175,15 +292,31 @@ export function createAutoHeartbeat(options = {}) {
175
292
  /* automation is best effort; never fail the host feed */
176
293
  }
177
294
  }
178
- if (!enabled) return;
179
- if (!id || !bound.has(id)) return;
180
295
  const type = event?.type;
296
+ if (!id) return;
181
297
  if (type !== 'turn/start' && type !== 'turn/end') return;
298
+ // R4.6: subagents are counted separately on the board; they never get a
299
+ // live row from the heartbeat (same rule as the Router's L1 writer).
300
+ if (origin === 'subagent') return;
301
+ if (root == null || typeof update !== 'function') return;
182
302
  const turn = event?.data?.turn;
183
- touchOne(id, {
184
- kind: 'turn',
185
- detail: turn == null ? type : `${type} #${turn}`,
186
- });
303
+ // R4.5: the event carries the session id, so turn boundaries maintain
304
+ // EVERY non-subagent session's row — a session that only runs native tools
305
+ // (never calls a longlive tool, so it is never `bind`ed) must still switch
306
+ // state/turn. Create the row on its first boundary, within the 128 cap.
307
+ const prior = readPriorRow(id);
308
+ if (!prior.found && !hasRowCapacity()) return;
309
+ if (type === 'turn/start') {
310
+ // R4.3 状态机:turn/start 立刻让 live 行 state: 'running' 并写入 turn 号
311
+ // (liveUpdate 一次完成;与心跳 tick 无关,tick 只续 heartbeatAt)。
312
+ const plan = turnBoundaryPlan('turn/start', { turn });
313
+ writeTurnBoundary(id, plan);
314
+ return;
315
+ }
316
+ // turn/end: idle/blocked/waiting 都落 turn 号;waiting/blocked 是给用户
317
+ // 看的承诺,turn 结束不能把它抹成 idle。
318
+ const endPlan = turnBoundaryPlan('turn/end', { turn, priorState: prior.state });
319
+ writeTurnBoundary(id, endPlan);
187
320
  }
188
321
 
189
322
  function tick() {
@@ -416,6 +416,41 @@ function allocateBudget(parts, total) {
416
416
  return budgets;
417
417
  }
418
418
 
419
+ /**
420
+ * R6.2: 「状态卡写于 X 分钟前」——X 是 STATE.md mtime 与 now 的差,不是卡片
421
+ * 生成时间,也不是会话开始时间。拿不到 mtime 时返回 ''(宁可不写,不编造)。
422
+ * @param {unknown} ageMs
423
+ * @returns {string}
424
+ */
425
+ export function stateCardAgeText(ageMs) {
426
+ if (typeof ageMs !== 'number' || !Number.isFinite(ageMs)) return '';
427
+ const minutes = Math.max(0, Math.round(ageMs / 60000));
428
+ return `状态卡写于 ${minutes} 分钟前`;
429
+ }
430
+
431
+ /**
432
+ * Resolve the STATE.md age in ms from explicit inputs, falling back to stat()
433
+ * on `statePath`. Missing/unknown age returns null — the card then omits the
434
+ * sentence instead of inventing a number.
435
+ */
436
+ function resolveStateAgeMs(input, safeNow) {
437
+ if (typeof input.stateAgeMs === 'number' && Number.isFinite(input.stateAgeMs)) {
438
+ return Math.max(0, input.stateAgeMs);
439
+ }
440
+ let mtimeMs = typeof input.stateMtimeMs === 'number' && Number.isFinite(input.stateMtimeMs)
441
+ ? input.stateMtimeMs
442
+ : null;
443
+ if (mtimeMs === null && typeof input.statePath === 'string' && input.statePath.trim() !== '') {
444
+ try {
445
+ mtimeMs = fs.statSync(input.statePath).mtimeMs;
446
+ } catch {
447
+ mtimeMs = null;
448
+ }
449
+ }
450
+ if (mtimeMs === null) return null;
451
+ return Math.max(0, safeNow.getTime() - mtimeMs);
452
+ }
453
+
419
454
  /**
420
455
  * 生成开机卡。纯函数:所有输入显式传入,方便覆盖缺失/超长/中文路径等边界。
421
456
  * @param {{
@@ -424,11 +459,14 @@ function allocateBudget(parts, total) {
424
459
  * maxChars?: number,
425
460
  * now?: Date|number|string,
426
461
  * statePath?: string,
462
+ * stateMtimeMs?: number,
463
+ * stateAgeMs?: number,
427
464
  * handoverPath?: string,
428
465
  * }} [input]
429
466
  * @returns {{
430
467
  * text:string, chars:number, truncated:boolean, warnings:string[],
431
468
  * found:Record<string,boolean>, handoverName:string|null,
469
+ * stateAgeMinutes:number|null,
432
470
  * }}
433
471
  */
434
472
  export function buildOpenCard(input = {}) {
@@ -439,6 +477,8 @@ export function buildOpenCard(input = {}) {
439
477
  ? input.now
440
478
  : new Date(input.now ?? Date.now());
441
479
  const safeNow = Number.isFinite(now.getTime()) ? now : new Date();
480
+ const stateAgeMs = resolveStateAgeMs(input, safeNow);
481
+ const stateAgeMinutes = stateAgeMs === null ? null : Math.max(0, Math.round(stateAgeMs / 60000));
442
482
  const warnings = [];
443
483
  const stateText = typeof input.stateText === 'string' ? normalizeText(input.stateText) : '';
444
484
  if (input.stateText === null || input.stateText === undefined) warnings.push('STATE_MISSING');
@@ -458,7 +498,8 @@ export function buildOpenCard(input = {}) {
458
498
  if (!input.handover) warnings.push('HANDOVER_MISSING');
459
499
  else if (handoverText.trim() === '') warnings.push('HANDOVER_EMPTY');
460
500
 
461
- const header = `【开机卡】${formatCardTime(safeNow)}`;
501
+ const ageText = stateCardAgeText(stateAgeMs);
502
+ const header = `【开机卡】${formatCardTime(safeNow)}${ageText === '' ? '' : ` · ${ageText}`}`;
462
503
  const headings = {
463
504
  where: '▍我在哪',
464
505
  waiting: '▍在等什么',
@@ -516,6 +557,7 @@ export function buildOpenCard(input = {}) {
516
557
  warnings,
517
558
  found: { ...parsed.found, handover: handoverText.trim() !== '' },
518
559
  handoverName,
560
+ stateAgeMinutes,
519
561
  };
520
562
  }
521
563
 
@@ -558,6 +600,14 @@ export function collectOpenCard(options = {}) {
558
600
  const errors = [];
559
601
  const stateRead = readTextFile(statePath);
560
602
  if (!stateRead.ok) errors.push(`STATE_READ_FAILED:${stateRead.error}`);
603
+ // R6.2: the open card must say how old the state card is. Read the mtime
604
+ // once here; a missing file leaves it unknown and the sentence is omitted.
605
+ let stateMtimeMs = null;
606
+ try {
607
+ stateMtimeMs = fs.statSync(statePath).mtimeMs;
608
+ } catch {
609
+ stateMtimeMs = null;
610
+ }
561
611
 
562
612
  let handover = null;
563
613
  let handoverPath = null;
@@ -593,6 +643,7 @@ export function collectOpenCard(options = {}) {
593
643
  maxChars: options.maxChars,
594
644
  now: options.now,
595
645
  statePath,
646
+ stateMtimeMs,
596
647
  handoverPath: handoverPath ?? undefined,
597
648
  });
598
649
 
package/src/live.js CHANGED
@@ -82,6 +82,9 @@ export const LIVE_RECORD_FIELDS = Object.freeze([
82
82
  'state',
83
83
  'turn',
84
84
  'goal',
85
+ // R3: the clock of the *goal*, not of the row. A heartbeat touch must never
86
+ // move it (see liveTouch), and the board renders focus.updatedAt from it.
87
+ 'goalUpdatedAt',
85
88
  'blocked_on',
86
89
  'queued',
87
90
  'last_action',
@@ -410,6 +413,9 @@ function decodeRecord(text, expectedSessionId) {
410
413
  }
411
414
  const updatedMs = parseTime(raw.updatedAt) ?? heartbeatMs;
412
415
  const startedMs = parseTime(raw.startedAt) ?? heartbeatMs;
416
+ // R3 additive field: rows written before this version simply have no goal
417
+ // clock; keep it as '' (unknown) instead of inventing one from heartbeatAt.
418
+ const goalUpdatedMs = parseTime(raw.goalUpdatedAt);
413
419
  if (!Array.isArray(raw.queued) || !raw.queued.every((item) => typeof item === 'string')) {
414
420
  return { ok: false, reason: 'queued 必须是字符串数组' };
415
421
  }
@@ -427,6 +433,7 @@ function decodeRecord(text, expectedSessionId) {
427
433
  state: raw.state,
428
434
  turn: Number.isInteger(raw.turn) && raw.turn >= 0 ? raw.turn : 0,
429
435
  goal: typeof raw.goal === 'string' ? raw.goal : '',
436
+ goalUpdatedAt: goalUpdatedMs != null ? isoTime(goalUpdatedMs) : '',
430
437
  blocked_on: typeof raw.blocked_on === 'string' ? raw.blocked_on : '',
431
438
  queued: [...raw.queued],
432
439
  last_action: normalizeLastAction(raw.last_action, updatedMs),
@@ -485,6 +492,9 @@ function liveEntry(record, { file, root, now, hostname }) {
485
492
  pidAlive: alive,
486
493
  turn: record.turn,
487
494
  goal: record.goal,
495
+ // R3: the goal write clock (ISO) or null when the row never declared one.
496
+ // Never derived from heartbeatAt — that is exactly the lie R3 fixes.
497
+ goalUpdatedAt: record.goalUpdatedAt === '' ? null : record.goalUpdatedAt,
488
498
  blocked_on: record.blocked_on,
489
499
  queued: record.queued,
490
500
  last_action: record.last_action,
@@ -513,6 +523,7 @@ function corruptEntry({ sessionId, file, root, reason, hostHint = null, hostname
513
523
  pidAlive: null,
514
524
  turn: null,
515
525
  goal: '',
526
+ goalUpdatedAt: null,
516
527
  blocked_on: '',
517
528
  queued: [],
518
529
  last_action: null,
@@ -852,6 +863,12 @@ export function liveUpdate(args = {}, config = {}) {
852
863
  const base = prior.record;
853
864
  const created = base == null;
854
865
  const goal = fit ? fit.value : (base?.goal ?? '');
866
+ // R3: `goalUpdatedAt` is the goal's own clock. Only a write that supplies a
867
+ // goal stamps it; patches and heartbeat touches keep the previous value, so
868
+ // "目标 30 分钟没更新" can be told apart from "心跳 5 秒前刚动过".
869
+ const goalUpdatedAt = goalInput !== undefined
870
+ ? isoTime(now)
871
+ : (base?.goalUpdatedAt ?? '');
855
872
  // blocked_on describes a blocked session; anything else clears it, so a
856
873
  // running row cannot keep advertising an obstacle nobody is waiting on.
857
874
  const priorBlockedOn = base?.blocked_on ?? '';
@@ -873,6 +890,7 @@ export function liveUpdate(args = {}, config = {}) {
873
890
  state,
874
891
  turn: turn ?? base?.turn ?? 0,
875
892
  goal,
893
+ goalUpdatedAt,
876
894
  blocked_on: blockedOn,
877
895
  queued: queued.value,
878
896
  last_action: lastAction.value,
@@ -940,8 +958,10 @@ export function liveUpdate(args = {}, config = {}) {
940
958
  * - it never creates a row (`status:'absent'` instead), so merely running a
941
959
  * longlive tool does not fabricate a session row;
942
960
  * - it never repairs/lists corrupt rows (`status:'corrupt'`, bytes untouched);
943
- * - it preserves `state`, `goal`, `blocked_on`, `queued`, `turn`, `startedAt`
944
- * and `ttlMs`: heartbeat says "this writer is still here", not "state changed";
961
+ * - it preserves `state`, `goal`, `goalUpdatedAt`, `blocked_on`, `queued`,
962
+ * `turn`, `startedAt` and `ttlMs`: heartbeat says "this writer is still
963
+ * here", not "state changed". In particular the goal's own clock must not
964
+ * move, or the board could no longer tell a stale goal from a live one;
945
965
  * - `lastAction:false` keeps `last_action` exactly as-is (the timer tick);
946
966
  * otherwise only the action stamp is replaced, bounded like every other write.
947
967
  */