mixdog 0.9.38 → 0.9.40

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 (124) hide show
  1. package/package.json +9 -4
  2. package/scripts/abort-recovery-test.mjs +43 -2
  3. package/scripts/agent-tag-reuse-smoke.mjs +146 -6
  4. package/scripts/agent-terminal-reap-test.mjs +127 -0
  5. package/scripts/agent-trace-io-test.mjs +69 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +224 -0
  7. package/scripts/execution-completion-dedup-test.mjs +157 -0
  8. package/scripts/execution-pending-resume-kick-test.mjs +57 -2
  9. package/scripts/execution-resume-esc-integration-test.mjs +176 -0
  10. package/scripts/explore-bench.mjs +38 -2
  11. package/scripts/explore-prompt-policy-test.mjs +152 -11
  12. package/scripts/find-fuzzy-hidden-test.mjs +122 -0
  13. package/scripts/internal-comms-bench-test.mjs +226 -0
  14. package/scripts/internal-comms-bench.mjs +185 -58
  15. package/scripts/internal-comms-smoke.mjs +171 -23
  16. package/scripts/live-worker-smoke.mjs +38 -2
  17. package/scripts/memory-cycle-routing-test.mjs +111 -0
  18. package/scripts/memory-rule-contract-test.mjs +93 -0
  19. package/scripts/output-style-smoke.mjs +2 -2
  20. package/scripts/rg-runner-test.mjs +240 -0
  21. package/scripts/routing-corpus-test.mjs +349 -0
  22. package/scripts/routing-corpus.mjs +211 -32
  23. package/scripts/session-orphan-sweep-test.mjs +83 -0
  24. package/scripts/steering-drain-buckets-test.mjs +316 -2
  25. package/scripts/tool-smoke.mjs +21 -13
  26. package/scripts/tool-tui-presentation-test.mjs +202 -0
  27. package/scripts/tui-transcript-perf-test.mjs +279 -0
  28. package/src/agents/heavy-worker/AGENT.md +10 -7
  29. package/src/agents/reviewer/AGENT.md +6 -4
  30. package/src/agents/worker/AGENT.md +7 -5
  31. package/src/rules/agent/00-common.md +4 -4
  32. package/src/rules/agent/00-core.md +11 -14
  33. package/src/rules/agent/20-skip-protocol.md +3 -3
  34. package/src/rules/agent/30-explorer.md +50 -60
  35. package/src/rules/agent/40-cycle1-agent.md +15 -24
  36. package/src/rules/agent/41-cycle2-agent.md +33 -57
  37. package/src/rules/agent/42-cycle3-agent.md +28 -42
  38. package/src/rules/lead/01-general.md +7 -10
  39. package/src/rules/lead/lead-brief.md +11 -14
  40. package/src/rules/lead/lead-tool.md +6 -5
  41. package/src/rules/shared/01-tool.md +44 -45
  42. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +44 -1
  43. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +20 -5
  44. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +21 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +17 -38
  46. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +17 -8
  47. package/src/runtime/agent/orchestrator/session/context-utils.mjs +270 -78
  48. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +2 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -0
  50. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +123 -0
  51. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +15 -2
  52. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +67 -2
  53. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +2 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +232 -43
  56. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +43 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +27 -13
  59. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +25 -20
  60. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +6 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +118 -53
  62. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +106 -29
  63. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +8 -1
  64. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +4 -2
  65. package/src/runtime/agent/orchestrator/tools/builtin/read-range-index.mjs +3 -1
  66. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +4 -1
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +33 -2
  68. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +151 -64
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +37 -13
  70. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +84 -49
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +172 -23
  72. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +68 -5
  74. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +17 -3
  75. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +24 -10
  76. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +6 -3
  77. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -7
  78. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +1 -0
  79. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -4
  81. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +4 -3
  82. package/src/runtime/memory/lib/memory-cycle3.mjs +12 -2
  83. package/src/runtime/shared/buffered-appender.mjs +13 -2
  84. package/src/runtime/shared/tool-primitives.mjs +4 -1
  85. package/src/runtime/shared/tool-status.mjs +27 -0
  86. package/src/runtime/shared/tool-surface.mjs +6 -3
  87. package/src/session-runtime/config-helpers.mjs +14 -0
  88. package/src/session-runtime/context-status.mjs +1 -0
  89. package/src/session-runtime/effort.mjs +6 -2
  90. package/src/session-runtime/lifecycle-api.mjs +4 -0
  91. package/src/session-runtime/model-recency.mjs +5 -2
  92. package/src/session-runtime/runtime-core.mjs +36 -2
  93. package/src/session-runtime/session-turn-api.mjs +12 -0
  94. package/src/session-runtime/tool-catalog.mjs +34 -0
  95. package/src/standalone/agent-tool/notify.mjs +13 -0
  96. package/src/standalone/agent-tool.mjs +53 -70
  97. package/src/standalone/explore-tool.mjs +6 -7
  98. package/src/tui/App.jsx +33 -1
  99. package/src/tui/app/model-options.mjs +5 -3
  100. package/src/tui/app/model-picker.mjs +12 -24
  101. package/src/tui/app/transcript-window.mjs +10 -10
  102. package/src/tui/app/use-transcript-window.mjs +38 -56
  103. package/src/tui/components/ToolExecution.jsx +11 -6
  104. package/src/tui/components/TranscriptItem.jsx +1 -1
  105. package/src/tui/components/tool-execution/surface-detail.mjs +24 -10
  106. package/src/tui/components/tool-execution/text-format.mjs +10 -19
  107. package/src/tui/dist/index.mjs +866 -300
  108. package/src/tui/engine/agent-job-feed.mjs +216 -19
  109. package/src/tui/engine/agent-response-tail.mjs +68 -0
  110. package/src/tui/engine/notification-plan.mjs +16 -0
  111. package/src/tui/engine/session-api.mjs +14 -2
  112. package/src/tui/engine/session-flow.mjs +22 -2
  113. package/src/tui/engine/tool-card-results.mjs +64 -37
  114. package/src/tui/engine/tool-result-status.mjs +75 -21
  115. package/src/tui/engine/turn.mjs +172 -77
  116. package/src/tui/engine.mjs +199 -39
  117. package/src/tui/index.jsx +2 -2
  118. package/src/workflows/bench/WORKFLOW.md +25 -35
  119. package/src/workflows/default/WORKFLOW.md +38 -32
  120. package/src/workflows/solo/WORKFLOW.md +19 -22
  121. package/scripts/_jitter-fuzz.mjs +0 -44410
  122. package/scripts/_jitter-fuzz2.mjs +0 -44400
  123. package/scripts/_jitter-probe.mjs +0 -44397
  124. package/scripts/_jp2.mjs +0 -45614
@@ -0,0 +1,123 @@
1
+ // Process-local delivered-completion registry.
2
+ //
3
+ // Purpose: a task-completion body that was already injected into the active TUI
4
+ // loop and ACKed (modelVisibleDelivered) must never ALSO be enqueued into — or
5
+ // drained from — the session pending queue one turn later. The TUI ack and the
6
+ // racing background/fallback enqueue run on different code paths within the SAME
7
+ // process, so this in-memory registry lets an enqueue/drain site recognize "this
8
+ // completion was already delivered" and skip it.
9
+ //
10
+ // Recorded keys (either one matching = already delivered):
11
+ // - meta.execution_id (when present)
12
+ // - a hash of the model-visible completion text
13
+ //
14
+ // Bounded + TTL'd so a long-lived process never accumulates unbounded entries.
15
+ // Both record and a matching lookup REFRESH the entry (sliding TTL), so an
16
+ // ACKed completion survives across long-idle turns as long as it keeps being
17
+ // checked. Headless/API sessions never ack, so the registry stays empty for
18
+ // them and marked in-memory completions drain exactly once (behavior unchanged).
19
+ //
20
+ // Residual risk: with a 6h sliding TTL the only remaining eviction path is the
21
+ // bounded-size cap — if MORE than DELIVERED_MAX_ENTRIES distinct completions are
22
+ // delivered before an older ACKed key is next checked, that oldest key is
23
+ // evicted and its (now un-refreshed) completion could enqueue/drain a second
24
+ // time. This requires >512 live completions between a delivery and its drain,
25
+ // which is far outside normal single-process interactive use.
26
+ import { createHash } from 'node:crypto';
27
+
28
+ const DELIVERED_TTL_MS = 6 * 60 * 60 * 1000; // 6h, sliding (refreshed on record + hit)
29
+ const DELIVERED_MAX_ENTRIES = 512;
30
+ // key -> expiresAt (ms). Insertion order = age order (Map guarantee), so the
31
+ // oldest key is always the first — used for bounded-size eviction.
32
+ const _delivered = new Map();
33
+
34
+ export function hashCompletionText(text) {
35
+ const value = String(text ?? '').trim();
36
+ if (!value) return null;
37
+ return createHash('sha1').update(value).digest('hex');
38
+ }
39
+
40
+ function execKey(executionId) {
41
+ const id = executionId == null ? '' : String(executionId).trim();
42
+ return id ? `id:${id}` : null;
43
+ }
44
+
45
+ function textKey(text) {
46
+ const h = hashCompletionText(text);
47
+ return h ? `tx:${h}` : null;
48
+ }
49
+
50
+ function pruneExpired(now) {
51
+ for (const [key, expiresAt] of _delivered) {
52
+ if (expiresAt <= now) _delivered.delete(key);
53
+ }
54
+ }
55
+
56
+ function enforceBound() {
57
+ while (_delivered.size > DELIVERED_MAX_ENTRIES) {
58
+ const oldest = _delivered.keys().next().value;
59
+ if (oldest === undefined) break;
60
+ _delivered.delete(oldest);
61
+ }
62
+ }
63
+
64
+ // Record a delivered completion under its execution_id (when present) and its
65
+ // model-visible text hash. Both keys point at the same expiry so a later
66
+ // enqueue/drain matching EITHER is recognized as already delivered. Re-recording
67
+ // an existing key refreshes its expiry (sliding TTL).
68
+ export function recordDeliveredCompletion({ executionId, text } = {}) {
69
+ const now = Date.now();
70
+ pruneExpired(now);
71
+ const expiresAt = now + DELIVERED_TTL_MS;
72
+ let recorded = false;
73
+ for (const key of [execKey(executionId), textKey(text)]) {
74
+ if (!key) continue;
75
+ _delivered.delete(key); // re-insert to refresh age order
76
+ _delivered.set(key, expiresAt);
77
+ recorded = true;
78
+ }
79
+ if (recorded) enforceBound();
80
+ return recorded;
81
+ }
82
+
83
+ export function isDeliveredCompletion({ executionId, text } = {}) {
84
+ const now = Date.now();
85
+ const keys = [execKey(executionId), textKey(text)].filter(Boolean);
86
+ let hit = false;
87
+ for (const key of keys) {
88
+ const expiresAt = _delivered.get(key);
89
+ if (expiresAt === undefined) continue;
90
+ if (expiresAt <= now) { _delivered.delete(key); continue; }
91
+ hit = true;
92
+ }
93
+ if (!hit) return false;
94
+ // Sliding refresh: extend every present key (and move it to the age tail) so
95
+ // a long-idle drain that checks it later still recognizes it as delivered.
96
+ const expiresAt = now + DELIVERED_TTL_MS;
97
+ for (const key of keys) {
98
+ if (!_delivered.has(key)) continue;
99
+ _delivered.delete(key);
100
+ _delivered.set(key, expiresAt);
101
+ }
102
+ return true;
103
+ }
104
+
105
+ // One diagnostic stderr line per dedup skip, naming the site
106
+ // (mirror/fallback/notify-enqueue/drain) so the actual racing edge is
107
+ // empirically confirmable in logs.
108
+ export function logDuplicateSkip(site, { executionId, text } = {}) {
109
+ try {
110
+ const id = executionId == null ? '' : String(executionId).trim();
111
+ const h = hashCompletionText(text);
112
+ process.stderr.write(
113
+ `[completion-dedup] skip site=${site}`
114
+ + `${id ? ` execution_id=${id}` : ''}`
115
+ + `${h ? ` hash=${h.slice(0, 12)}` : ''}\n`,
116
+ );
117
+ } catch { /* ignore */ }
118
+ }
119
+
120
+ // Test-only reset hook.
121
+ export function _clearDeliveredCompletions() {
122
+ _delivered.clear();
123
+ }
@@ -13,10 +13,22 @@ import { nonNegativeIntEnv } from './env-utils.mjs';
13
13
  const CLEANUP_INTERVAL_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INTERVAL_MS', 5 * 60 * 1000); // check every 5 minutes
14
14
  const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS', CLEANUP_INTERVAL_MS > 0 ? CLEANUP_INTERVAL_MS : 0);
15
15
  const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_MS', 250);
16
- const TOMBSTONE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h far longer than any realistic ask race window
16
+ // Tombstone unlink TTL. The guarded resurrection race (temp-write→rename inside
17
+ // _doSave) resolves in microseconds, so 1h is still vastly longer than any
18
+ // realistic in-flight ask race — while short enough that matured tombstones are
19
+ // reclaimed promptly instead of accumulating for a full day.
20
+ const TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1000; // 1h
17
21
  let _cleanupTimer = null;
18
22
  let _cleanupInitialTimer = null;
19
23
 
24
+ // A session is "live" when it still owns a non-closed runtime entry. Passed to
25
+ // the retention cap so the active/current and any in-flight session is never
26
+ // pruned by the open-session max-age/max-count bounds.
27
+ function _isSessionLive(id) {
28
+ const entry = _getRuntimeEntry(id);
29
+ return !!(entry && entry.closed !== true);
30
+ }
31
+
20
32
  function _previewIds(items, limit = 5) {
21
33
  const ids = (items || []).slice(0, limit).map((item) => item.id).filter(Boolean);
22
34
  if (ids.length === 0) return '';
@@ -29,6 +41,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
29
41
  try {
30
42
  const result = sweepStaleSessions({
31
43
  tombstoneMaxAgeMs: includeTombstones ? TOMBSTONE_MAX_AGE_MS : 0,
44
+ isSessionLive: _isSessionLive,
32
45
  });
33
46
  const {
34
47
  cleaned,
@@ -81,7 +94,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
81
94
  * Rationale: closeSession() leaves the tombstone on disk as the authoritative
82
95
  * resurrection-blocker for racing saveSession() calls. That race resolves in
83
96
  * microseconds (the window inside _doSave between temp write and rename), so
84
- * 24h is vastly safe. After the TTL expires we reclaim the disk slot.
97
+ * 1h is vastly safe. After the TTL expires we reclaim the disk slot.
85
98
  *
86
99
  * Uses `getStoredSessionsRaw()` rather than `listStoredSessions()` because the
87
100
  * latter's inline 30-min idle cleanup would race-unlink tombstones before we
@@ -1,10 +1,12 @@
1
1
  // Steering / pending-message queue with sync buffered + atomic-file persistence.
2
2
  // Extracted verbatim from manager.mjs (behavior-preserving).
3
3
  import { join } from 'path';
4
+ import { readFileSync } from 'fs';
4
5
  import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
5
6
  import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
6
7
  import { promptContentText, isInternalRuntimeNotificationText } from './prompt-utils.mjs';
7
8
  import { loadSession } from '../store.mjs';
9
+ import { isDeliveredCompletion, logDuplicateSkip } from './delivered-completions.mjs';
8
10
 
9
11
  const _sessionPendingMessages = new Map();
10
12
  const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
@@ -24,6 +26,31 @@ function isCompletionNotificationEntry(entry) {
24
26
  && entry.notificationKind === COMPLETION_NOTIFICATION_KIND;
25
27
  }
26
28
 
29
+ // Pre-marker completion notifications were persisted as plain strings,
30
+ // indistinguishable from genuine user/steering messages except by their
31
+ // model-visible wrapper shape. Such stale strings must never replay into a
32
+ // resumed session, but they carry no notificationKind marker, so the marker
33
+ // check alone leaves them behind. Because this is a SILENT-drop path, the
34
+ // shared lenient wrapper detector is too broad (a user message quoting a
35
+ // completion card could be dropped), so this uses its OWN strict recognizer:
36
+ // the string must be a verbatim full-card paste and nothing else —
37
+ // (1) start with the exact instruction preamble + "\n\nResult:\n",
38
+ // (2) have EVERY non-empty body line quoted with "> " (100%),
39
+ // (3) carry no extra leading/trailing prose (whitespace-trim only).
40
+ // Conservative by design: a false negative just keeps a legacy string, but a
41
+ // false positive on genuine user text would silently drop a real message.
42
+ const LEGACY_COMPLETION_CARD_PREAMBLE_RE = /^The async \S+ task \S+ has finished \([^)]*\) - review this result in your next step\.\n\nResult:\n/;
43
+ function isLegacyUnmarkedCompletionNotification(entry) {
44
+ if (typeof entry !== 'string') return false;
45
+ const value = entry.trim();
46
+ const match = LEGACY_COMPLETION_CARD_PREAMBLE_RE.exec(value);
47
+ if (!match) return false;
48
+ const body = value.slice(match[0].length);
49
+ const lines = body.split(/\r?\n/).filter((line) => line.length > 0);
50
+ if (lines.length === 0) return false;
51
+ return lines.every((line) => line.startsWith('> '));
52
+ }
53
+
27
54
  // Canonical completion-enqueue tagger. Every deferred tool/agent completion
28
55
  // notification MUST be enqueued through this so drain can discard it on resume
29
56
  // (never replay out-of-order). Pass the model-visible completion text (or an
@@ -393,8 +420,21 @@ export function drainPendingMessages(sessionId) {
393
420
  // in-memory queue is empty, so keeping it only ever delivers live entries.
394
421
  // Genuine user/steering messages carry no marker and are kept in order in
395
422
  // both paths.
396
- const memoryKept = memory;
397
- const persistedKept = persisted.filter((m) => !isCompletionNotificationEntry(m));
423
+ // Drain-time belt: drop MARKED in-memory completion entries whose text hash
424
+ // was already delivered+ACKed (TUI execution-ui) this process — those would
425
+ // double-inject next turn. Only marked completion entries are eligible;
426
+ // genuine user/steering entries carry no marker and are always kept.
427
+ const memoryKept = memory.filter((m) => {
428
+ if (!isCompletionNotificationEntry(m)) return true;
429
+ const text = pendingMessageText(m);
430
+ if (text && isDeliveredCompletion({ text })) {
431
+ logDuplicateSkip('drain', { text });
432
+ return false;
433
+ }
434
+ return true;
435
+ });
436
+ const persistedKept = persisted.filter((m) => !isCompletionNotificationEntry(m)
437
+ && !isLegacyUnmarkedCompletionNotification(m));
398
438
  const memoryVisible = modelVisiblePendingMessages(memoryKept);
399
439
  const persistedVisible = modelVisiblePendingMessages(persistedKept);
400
440
  if (memoryVisible.length === 0) return persistedVisible;
@@ -419,6 +459,31 @@ export function drainPendingMessages(sessionId) {
419
459
  return out;
420
460
  }
421
461
 
462
+ // Snapshot queued entries without draining them. Compaction uses this to keep
463
+ // sidecars referenced by a message that is waiting for the next turn, whether
464
+ // it is still in memory, buffered for persistence, or already on disk.
465
+ export function _getPendingMessagesForSession(sessionId) {
466
+ if (!isValidPendingSessionId(sessionId)) return [];
467
+ const queued = [
468
+ ...(_sessionPendingMessages.get(sessionId) || []),
469
+ ...(_pendingPersistBuffers.get(sessionId) || []),
470
+ ];
471
+ let raw;
472
+ try {
473
+ raw = readFileSync(pendingMessagesPath(), 'utf8');
474
+ } catch (err) {
475
+ if (err?.code === 'ENOENT') return queued;
476
+ throw err;
477
+ }
478
+ try {
479
+ const persisted = normalizePendingStore(JSON.parse(raw)).sessions[sessionId];
480
+ if (Array.isArray(persisted)) queued.push(...persisted);
481
+ } catch (err) {
482
+ throw err;
483
+ }
484
+ return queued;
485
+ }
486
+
422
487
  // Cleanup hook for closeSession — drop the in-memory queue and buffered-persist
423
488
  // entry so both Maps do not accumulate one entry per closed session.
424
489
  export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
@@ -24,7 +24,7 @@ import { _closeBashSessionLazy } from './runtime-loaders.mjs';
24
24
  * and recreates the session in its pre-close state.
25
25
  *
26
26
  * Long-term cleanup: `sweepTombstones()` below unlinks tombstones older than
27
- * TOMBSTONE_MAX_AGE_MS (24h — vastly longer than any realistic in-flight race).
27
+ * TOMBSTONE_MAX_AGE_MS (1h — vastly longer than the microsecond in-flight race).
28
28
  */
29
29
  export function closeSession(id, reason = 'manual', opts = {}) {
30
30
  // tombstone=false: detach runtime resources (heartbeat, bash shells,
@@ -163,6 +163,8 @@ export function bumpUsageMetricsTurnId(session) {
163
163
  if (!session || typeof session !== 'object') return 0;
164
164
  const next = (Number(session.usageMetricsTurnId) || 0) + 1;
165
165
  session.usageMetricsTurnId = next;
166
+ const seen = _metricSeenIter.get(session.id);
167
+ if (seen) seen.clear();
166
168
  return next;
167
169
  }
168
170