@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.
- package/CHANGELOG.md +40 -8
- package/QUICKSTART.md +10 -10
- package/README.en.md +4 -3
- package/README.md +10 -4
- package/SPEC.md +2 -2
- package/cordis.patch.yml +9 -0
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CONFIG.md +33 -6
- package/docs/DEV.md +3 -3
- package/docs/FAQ.md +4 -4
- package/docs/FEATURES.md +36 -5
- package/docs/OPERATIONS.md +1 -1
- package/docs/QUICKSTART.md +3 -3
- package/docs/README.en.md +3 -2
- package/docs/TROUBLESHOOTING.md +3 -3
- package/package.json +13 -8
- package/src/activity/index.js +649 -0
- package/src/activity/model.js +341 -0
- package/src/activity/store.js +625 -0
- package/src/config.js +29 -2
- package/src/features.js +26 -0
- package/src/heartbeat.js +156 -23
- package/src/identity/handover.js +52 -1
- package/src/live.js +22 -2
- package/src/recall/privacy.mjs +105 -16
- package/src/router/service/board.mjs +95 -15
- package/src/router/service/hint.mjs +14 -0
- package/src/router/service/service.mjs +27 -11
- package/test/activity-realtime.test.mjs +959 -0
- package/test/activity-stream.test.mjs +512 -0
- package/test/autobind.test.mjs +25 -11
- package/test/config-schema.test.mjs +6 -0
- package/test/feature-flags.test.mjs +17 -0
- package/test/fixtures/activity-append-child.mjs +29 -0
- package/test/live.test.mjs +6 -3
- package/test/packaging.test.mjs +7 -5
- package/test/router-background/router.test.mjs +44 -12
- package/test/run-rc.mjs +16 -0
- package/test/safety-defaults.test.mjs +5 -3
- package/test/state-truth.test.mjs +937 -0
- 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
|
|
4
|
-
// 1. turn lifecycle — `session/event` with `turn/start` / `turn/end
|
|
5
|
-
// session
|
|
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
|
-
//
|
|
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
|
-
//
|
|
10
|
-
// `
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
|
15
|
-
// racing writer must never fail a tool call or crash the
|
|
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
|
|
37
|
-
*
|
|
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
|
|
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)
|
|
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
|
-
//
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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() {
|
package/src/identity/handover.js
CHANGED
|
@@ -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
|
|
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`, `
|
|
944
|
-
* and `ttlMs`: heartbeat says "this writer is still
|
|
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
|
*/
|