@jjh32456/dsh-longlive 1.6.6 → 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 -4
- 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/compaction/engine-core.js +144 -97
- 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/hot-truncate.test.mjs +113 -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
|
@@ -30,6 +30,10 @@ export const FOCUS_ACTION_DETAIL_MAX = 60;
|
|
|
30
30
|
export const LAST_CONCLUSION_MAX = 120;
|
|
31
31
|
export const TIMELINE_MAX = 8;
|
|
32
32
|
export const ROSTER_MAX = 256;
|
|
33
|
+
// R3: a declared goal older than this is no longer "what the session is doing
|
|
34
|
+
// right now". The board says so instead of letting the heartbeat timestamp
|
|
35
|
+
// masquerade as the goal's freshness.
|
|
36
|
+
export const FOCUS_STALE_MS = 30 * 60 * 1000;
|
|
33
37
|
|
|
34
38
|
const STATE_PRIORITY = Object.freeze({ running: 0, blocked: 1, waiting: 2, idle: 3 });
|
|
35
39
|
|
|
@@ -331,9 +335,38 @@ export function createBoardBuilder(options = {}) {
|
|
|
331
335
|
if (args.saturated === true) warnings.push('LIVE_ROWS_SATURATED');
|
|
332
336
|
|
|
333
337
|
const metadata = Array.isArray(args.metadata) ? args.metadata : null;
|
|
338
|
+
// A host that cannot list sessions (no metadata source) leaves row origin
|
|
339
|
+
// unknown. A non-empty list is treated as authoritative; an empty/absent
|
|
340
|
+
// one falls into the fail-safe heuristic below.
|
|
341
|
+
const metadataAvailable = Array.isArray(metadata) && metadata.length > 0;
|
|
334
342
|
const metaMap = new Map();
|
|
335
343
|
for (const item of metadata ?? []) metaMap.set(item.sessionId, item);
|
|
336
344
|
|
|
345
|
+
/**
|
|
346
|
+
* R4.6: main vs subagent origin for one live row.
|
|
347
|
+
* With metadata: the host list is authoritative; a row missing from it is
|
|
348
|
+
* `unknown` (fail-safe: never counted as active/running/focus).
|
|
349
|
+
* Without metadata: the host contract (verified against real DSH session
|
|
350
|
+
* dirs on this machine) is root sessions `session-<uuid>` / subagent and
|
|
351
|
+
* worker sessions bare UUID ids; a bare UUID row is therefore `unknown`
|
|
352
|
+
* until proven otherwise, everything else stays `main` so a host that
|
|
353
|
+
* cannot list sessions never silently drops its rows.
|
|
354
|
+
*/
|
|
355
|
+
const originOfSession = (sessionId) => {
|
|
356
|
+
if (typeof sessionId !== 'string' || sessionId === '') return 'unknown';
|
|
357
|
+
if (metadataAvailable) {
|
|
358
|
+
const meta = metaMap.get(sessionId) ?? null;
|
|
359
|
+
if (!meta) return 'unknown';
|
|
360
|
+
return meta.origin === 'subagent' ? 'subagent' : 'main';
|
|
361
|
+
}
|
|
362
|
+
return isBareUuidSessionId(sessionId) ? 'unknown' : 'main';
|
|
363
|
+
};
|
|
364
|
+
const originMap = new Map();
|
|
365
|
+
for (const entry of scan.entries) {
|
|
366
|
+
if (entry.sessionId) originMap.set(entry.sessionId, originOfSession(entry.sessionId));
|
|
367
|
+
}
|
|
368
|
+
const originRank = (entry) => originMap.get(entry.sessionId) ?? 'unknown';
|
|
369
|
+
|
|
337
370
|
const liveEntries = scan.entries.filter((entry) => !entry.corrupt && entry.sessionId);
|
|
338
371
|
const liveMap = new Map(liveEntries.map((entry) => [entry.sessionId, entry]));
|
|
339
372
|
const liveCount = liveEntries.length;
|
|
@@ -366,26 +399,46 @@ export function createBoardBuilder(options = {}) {
|
|
|
366
399
|
roster.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
|
|
367
400
|
if (roster.length > ROSTER_MAX) roster.length = ROSTER_MAX;
|
|
368
401
|
|
|
369
|
-
const
|
|
370
|
-
|
|
402
|
+
const metadataUnknownSessions = roster.filter((item) => item.unknown === true && item.origin !== 'subagent').length;
|
|
403
|
+
// Active/focus set: rows we can prove are main sessions and that are not
|
|
404
|
+
// dead/stale/corrupt (ACTIVE_STATE_SET is exactly the declared-state band).
|
|
405
|
+
const activeEntries = liveEntries.filter((entry) => originRank(entry) === 'main'
|
|
406
|
+
&& ACTIVE_STATE_SET.has(entry.effectiveState));
|
|
407
|
+
// Rows whose origin we cannot prove: fail-safe — never counted active or
|
|
408
|
+
// shown in focus, but reported as unknown instead of silently believed
|
|
409
|
+
// (R4.6 边界:metadata 缺失时的 subagent 行不得再抬高 running/active).
|
|
410
|
+
const unknownOriginEntries = liveEntries.filter((entry) => originRank(entry) === 'unknown'
|
|
411
|
+
&& ACTIVE_STATE_SET.has(entry.effectiveState));
|
|
371
412
|
// A live row we can see but cannot decode still proves a session exists.
|
|
372
413
|
// DESIGN §9 counts such a row as active (state unknown), while a zero-byte
|
|
373
414
|
// file is an interrupted write and is ignored (and later cleaned).
|
|
374
415
|
const unreadableActive = scan.entries.filter((entry) => entry.corrupt === true
|
|
375
416
|
&& entry.sessionId
|
|
376
|
-
&&
|
|
417
|
+
&& originRank(entry) === 'main'
|
|
377
418
|
&& fileHasBytes(entry.file)).length;
|
|
378
|
-
|
|
379
|
-
|
|
419
|
+
const unreadableUnknown = scan.entries.filter((entry) => entry.corrupt === true
|
|
420
|
+
&& entry.sessionId
|
|
421
|
+
&& originRank(entry) === 'unknown'
|
|
422
|
+
&& fileHasBytes(entry.file)).length;
|
|
423
|
+
// Sessions we cannot classify (no metadata + child-shaped id, or a live row
|
|
424
|
+
// missing from the host's session list) are reported as unknown, never as
|
|
425
|
+
// running/main. Subagents are counted only in counts.subagents.
|
|
426
|
+
const unknownSessions = metadataUnknownSessions + unknownOriginEntries.length + unreadableUnknown;
|
|
380
427
|
const allActive = activeEntries.length + unreadableActive;
|
|
381
428
|
if (activeEntries.length > Math.max(1, maxSessions)) warnings.push('FOCUS_LIMITED');
|
|
382
|
-
|
|
383
|
-
|
|
429
|
+
// R4.7: pulse counts share the active/focus set (main sessions, not
|
|
430
|
+
// dead/stale/corrupt) and count the row's DECLARED state. effectiveState
|
|
431
|
+
// alone cannot answer this: a fresh heartbeat returns `running` for every
|
|
432
|
+
// row, so a live declared-blocked row would never count as blocked (the
|
|
433
|
+
// R4.7 first-round over-fix; RE-R47.1).
|
|
434
|
+
const running = activeEntries.filter((entry) => entry.state === 'running').length;
|
|
435
|
+
const blocked = activeEntries.filter((entry) => entry.state === 'blocked').length;
|
|
384
436
|
const subagents = roster.filter((item) => item.origin === 'subagent').length;
|
|
385
437
|
|
|
386
438
|
const proposals = readProposalSummaries(proposalsDir);
|
|
387
439
|
const countsProposals = proposalCounts(proposals);
|
|
388
440
|
const conclusions = readBridgeConclusions(bridgeDir);
|
|
441
|
+
const atMs = at.getTime();
|
|
389
442
|
|
|
390
443
|
const focus = activeEntries
|
|
391
444
|
.map((entry) => {
|
|
@@ -395,19 +448,30 @@ export function createBoardBuilder(options = {}) {
|
|
|
395
448
|
// detail (already capped to 60 chars), skipping lifecycle-only noise.
|
|
396
449
|
const conclusion = conclusions.get(entry.sessionId)
|
|
397
450
|
?? conclusionFromAction(lastAction);
|
|
451
|
+
// R3: focus.updatedAt is the goal's own clock; heartbeatAt must never
|
|
452
|
+
// pretend to be it. Rows that never declared a goal (or rows written
|
|
453
|
+
// before the goal clock existed) get null — unknown, not fabricated —
|
|
454
|
+
// and are never marked stale because there is no goal to be stale.
|
|
455
|
+
const goalUpdatedMs = Date.parse(entry.goalUpdatedAt ?? '');
|
|
456
|
+
const goalUpdatedAt = Number.isFinite(goalUpdatedMs) ? entry.goalUpdatedAt : null;
|
|
457
|
+
const declared = goalText !== '';
|
|
458
|
+
const goalAgeMs = declared && goalUpdatedAt !== null ? Math.max(0, atMs - goalUpdatedMs) : null;
|
|
459
|
+
const stale = goalAgeMs !== null && goalAgeMs > FOCUS_STALE_MS;
|
|
398
460
|
return {
|
|
399
461
|
sessionId: entry.sessionId,
|
|
400
462
|
goal: goalText || '(未声明)',
|
|
401
|
-
updatedAt:
|
|
463
|
+
updatedAt: goalUpdatedAt,
|
|
464
|
+
goalAgeMs,
|
|
465
|
+
stale,
|
|
402
466
|
lastAction,
|
|
403
467
|
lastConclusion: lastConclusionOf(conclusion),
|
|
404
468
|
queuedCount: queuedCountOf(entry.queued),
|
|
405
469
|
state: entry.effectiveState,
|
|
406
|
-
declared
|
|
470
|
+
declared,
|
|
407
471
|
};
|
|
408
472
|
})
|
|
409
473
|
.sort((a, b) => {
|
|
410
|
-
// DESIGN §3.2: declared goal → running → freshest
|
|
474
|
+
// DESIGN §3.2: declared goal → running → freshest goal write.
|
|
411
475
|
if (a.declared !== b.declared) return a.declared ? -1 : 1;
|
|
412
476
|
const pa = STATE_PRIORITY[a.state] ?? 9;
|
|
413
477
|
const pb = STATE_PRIORITY[b.state] ?? 9;
|
|
@@ -423,7 +487,11 @@ export function createBoardBuilder(options = {}) {
|
|
|
423
487
|
lastAction: item.lastAction,
|
|
424
488
|
lastConclusion: item.lastConclusion,
|
|
425
489
|
queuedCount: item.queuedCount,
|
|
490
|
+
// R3.2: the stale flag is the single tree-wide signal (focus item);
|
|
491
|
+
// omitted when fresh so the persisted 6-field shape is unchanged.
|
|
492
|
+
...(item.stale ? { stale: true } : {}),
|
|
426
493
|
}));
|
|
494
|
+
const focusStale = focus.some((item) => item.stale === true);
|
|
427
495
|
|
|
428
496
|
const timeline = countsProposals.applied
|
|
429
497
|
.filter((item) => item.title !== '')
|
|
@@ -468,7 +536,9 @@ export function createBoardBuilder(options = {}) {
|
|
|
468
536
|
activeSessions: allActive,
|
|
469
537
|
unknownSessions,
|
|
470
538
|
focusVisible: focus.length,
|
|
471
|
-
stale:
|
|
539
|
+
// In-memory aggregate of R3 staleness (focus items carry stale:true).
|
|
540
|
+
// Never persisted: board.json shows it on the individual focus row.
|
|
541
|
+
stale: focusStale,
|
|
472
542
|
counts: {
|
|
473
543
|
active: allActive,
|
|
474
544
|
running,
|
|
@@ -608,9 +678,18 @@ function cleanBoardTmp(boardFile, nowMs) {
|
|
|
608
678
|
return removed;
|
|
609
679
|
}
|
|
610
680
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
681
|
+
/**
|
|
682
|
+
* R4.6 fail-safe: the host's child-session id shape. Real DSH session dirs on
|
|
683
|
+
* this machine show root/main sessions as `session-<uuid>` while spawned
|
|
684
|
+
* subagent/worker sessions get a bare UUID. When the host exposes no session
|
|
685
|
+
* metadata this is the only honest signal available, so a bare-UUID row is
|
|
686
|
+
* treated as *unknown origin* (reported in unknownSessions, never counted as
|
|
687
|
+
* running/active/main). Requires metadata to be certain: with metadata the
|
|
688
|
+
* host list wins and this heuristic is not consulted.
|
|
689
|
+
*/
|
|
690
|
+
export function isBareUuidSessionId(sessionId) {
|
|
691
|
+
return typeof sessionId === 'string'
|
|
692
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId);
|
|
614
693
|
}
|
|
615
694
|
|
|
616
695
|
function verifyBoardWrite(file, board) {
|
|
@@ -623,7 +702,8 @@ function verifyBoardWrite(file, board) {
|
|
|
623
702
|
|
|
624
703
|
/** Format one focus row for the fixed hint template. */
|
|
625
704
|
export function renderFocusLine(item) {
|
|
626
|
-
|
|
705
|
+
const stale = item?.stale === true ? ' · stale(目标超 30 分钟未更新)' : '';
|
|
706
|
+
return `- ${item.sessionId} ${item.state ?? ''} · 目标${quoted(item.goal, FOCUS_GOAL_MAX)} · ${timeOf(item.updatedAt)}${stale}`;
|
|
627
707
|
}
|
|
628
708
|
|
|
629
709
|
export function timeOf(iso) {
|
|
@@ -180,6 +180,17 @@ function relativeMinutes(iso, nowMs) {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
const RECENT_ACTION_MS = 10 * 60 * 1000;
|
|
183
|
+
// R3.4: the stale flag must be visible in the shared hint, not only in
|
|
184
|
+
// board.json. Same signal as focus.stale:true — the hint never re-judges it.
|
|
185
|
+
const FOCUS_STALE_MINUTES = 30;
|
|
186
|
+
|
|
187
|
+
/** User-visible stale suffix for one focus row; '' when the goal is fresh. */
|
|
188
|
+
function focusStaleMarker(item, nowMs) {
|
|
189
|
+
if (!item || item.stale !== true) return '';
|
|
190
|
+
const age = relativeMinutes(item.updatedAt, nowMs);
|
|
191
|
+
const goalAge = age === null || age === '刚刚' ? '目标超 30 分钟未更新' : `目标 ${age}写入,超 ${FOCUS_STALE_MINUTES} 分钟未更新`;
|
|
192
|
+
return ` · stale(${goalAge})`;
|
|
193
|
+
}
|
|
183
194
|
|
|
184
195
|
/**
|
|
185
196
|
* L2 presence upgrade: sessions other than this one whose `lastAction.at` moved
|
|
@@ -238,6 +249,9 @@ export function renderHint(board, options = {}) {
|
|
|
238
249
|
// Presence upgrade: 12-char prefix instead of a full opaque session id.
|
|
239
250
|
const sessionId = sessionPrefix(item.sessionId, 12);
|
|
240
251
|
let line = `- ${sessionId} 目标「${goal}」`;
|
|
252
|
+
// R3.4: stale goals must be visible to the other session, with the goal's
|
|
253
|
+
// own write time — never silently dropped from the shared hint.
|
|
254
|
+
line += focusStaleMarker(item, nowMs);
|
|
241
255
|
const action = item.lastAction && typeof item.lastAction === 'object' ? item.lastAction : null;
|
|
242
256
|
if (action) {
|
|
243
257
|
const kind = neutralizeEnvelope(oneLine(action.kind, 24));
|
|
@@ -607,8 +607,13 @@ export class RouterService {
|
|
|
607
607
|
pulse: {
|
|
608
608
|
...(board.pulse ?? {}),
|
|
609
609
|
active: board.activeSessions ?? 0,
|
|
610
|
-
|
|
611
|
-
|
|
610
|
+
// R4.2: same projection as board.json — never a second, hardcoded
|
|
611
|
+
// number. Before this, status.json said 0 while board.json said 34.
|
|
612
|
+
running: board.pulse?.running ?? 0,
|
|
613
|
+
// R4.7: blocked is the same pulse field as the board's — never the
|
|
614
|
+
// Router's own panelState wearing the same name with another value.
|
|
615
|
+
// Router state is already reported as status.state.
|
|
616
|
+
blocked: board.pulse?.blocked ?? 0,
|
|
612
617
|
unknownSessions: board.unknownSessions ?? 0,
|
|
613
618
|
queuedProposals: proposals.queue?.depth ?? 0,
|
|
614
619
|
approvedProposals: proposalCounts.queued ?? 0,
|
|
@@ -1567,7 +1572,18 @@ export class RouterService {
|
|
|
1567
1572
|
if (this.deps.installListener === false) return;
|
|
1568
1573
|
if (typeof this.ctx.on !== 'function') return;
|
|
1569
1574
|
try {
|
|
1570
|
-
|
|
1575
|
+
// R4.4: the real host emits `session/event` as TWO positional args
|
|
1576
|
+
// `(session, event)` (recorded DSH logs, 2026-11-06), while several
|
|
1577
|
+
// shipped tests drive the legacy one-arg envelope. Accept both arities:
|
|
1578
|
+
// two-arg -> event is arg 2 and arg 1 is the session; one-arg -> arg 1 is
|
|
1579
|
+
// the event (which may still carry `.session` inside).
|
|
1580
|
+
const listener = (...args) => {
|
|
1581
|
+
const twoArg = args.length >= 2 && args[1] != null;
|
|
1582
|
+
const event = twoArg ? args[1] : args[0];
|
|
1583
|
+
const session = twoArg ? args[0] : null;
|
|
1584
|
+
return this.handleSessionEvent(event, session);
|
|
1585
|
+
};
|
|
1586
|
+
const disposer = this.ctx.on('session/event', listener, { prepend: false });
|
|
1571
1587
|
if (typeof disposer === 'function') this.listenerDisposers.push(disposer);
|
|
1572
1588
|
} catch (error) {
|
|
1573
1589
|
this.#log('warn', `session/event listener install failed: ${errorMessage(error)}`);
|
|
@@ -1699,9 +1715,9 @@ export class RouterService {
|
|
|
1699
1715
|
}
|
|
1700
1716
|
}
|
|
1701
1717
|
|
|
1702
|
-
#onTurnEnd(event, via) {
|
|
1718
|
+
#onTurnEnd(event, via, sessionArg = null) {
|
|
1703
1719
|
if (this.disposed) return;
|
|
1704
|
-
const session = this.#sessionOfEvent(event);
|
|
1720
|
+
const session = sessionArg ?? this.#sessionOfEvent(event);
|
|
1705
1721
|
const id = sessionIdOf(session) ?? sessionIdOf(event);
|
|
1706
1722
|
if (!id) return;
|
|
1707
1723
|
const header = session?.header ?? {};
|
|
@@ -1740,20 +1756,20 @@ export class RouterService {
|
|
|
1740
1756
|
}
|
|
1741
1757
|
}
|
|
1742
1758
|
|
|
1743
|
-
#handleLiveEvent(event) {
|
|
1759
|
+
#handleLiveEvent(event, sessionArg = null) {
|
|
1744
1760
|
const type = event?.type;
|
|
1745
1761
|
if (type === 'session/created') {
|
|
1746
|
-
this.#onSessionCreated(this.#sessionOfEvent(event), 'session/event');
|
|
1762
|
+
this.#onSessionCreated(sessionArg ?? this.#sessionOfEvent(event), 'session/event');
|
|
1747
1763
|
return true;
|
|
1748
1764
|
}
|
|
1749
1765
|
if (type === 'turn/end') {
|
|
1750
|
-
this.#onTurnEnd(event, 'turn/end');
|
|
1766
|
+
this.#onTurnEnd(event, 'turn/end', sessionArg);
|
|
1751
1767
|
return true;
|
|
1752
1768
|
}
|
|
1753
1769
|
if (type === 'agent/status') {
|
|
1754
1770
|
const status = event?.data?.state ?? event?.data?.status ?? event?.status;
|
|
1755
1771
|
if (status === 'idle') {
|
|
1756
|
-
this.#onTurnEnd(event, 'agent/status');
|
|
1772
|
+
this.#onTurnEnd(event, 'agent/status', sessionArg);
|
|
1757
1773
|
return true;
|
|
1758
1774
|
}
|
|
1759
1775
|
}
|
|
@@ -1764,10 +1780,10 @@ export class RouterService {
|
|
|
1764
1780
|
* turn/end 唤醒:只认 reason.kind === 'completed',2 秒防抖 + 5 分钟合并。
|
|
1765
1781
|
* 返回 {scheduled, coalesced?, debounced?, ignored?},方便单测。
|
|
1766
1782
|
*/
|
|
1767
|
-
handleSessionEvent(event) {
|
|
1783
|
+
handleSessionEvent(event, session = null) {
|
|
1768
1784
|
// L1 heartbeat runs for every turn boundary, independent of reflectOnTurn
|
|
1769
1785
|
// and of reason.kind (an aborted turn still ends).
|
|
1770
|
-
try { this.#handleLiveEvent(event); } catch (error) {
|
|
1786
|
+
try { this.#handleLiveEvent(event, session); } catch (error) {
|
|
1771
1787
|
this.#log('warn', `live heartbeat failed: ${errorMessage(error)}`);
|
|
1772
1788
|
}
|
|
1773
1789
|
const type = event?.type;
|