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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.38",
3
+ "version": "0.9.40",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -36,8 +36,8 @@
36
36
  "prepack": "npm run build:tui",
37
37
  "prepublishOnly": "node -e \"if(!process.env.CI){console.error('local npm publish is disabled — run the Release workflow: gh workflow run release -f bump=patch');process.exit(1)}\"",
38
38
  "start": "node src/cli.mjs",
39
- "smoke": "node scripts/smoke.mjs && node --test scripts/ship-mode-test.mjs",
40
- "smoke:all": "npm run smoke && npm run smoke:boot && npm run smoke:tools && npm run smoke:patch && npm run smoke:output && npm run smoke:tui && npm run smoke:freevars && npm run smoke:logguard",
39
+ "smoke": "node scripts/smoke.mjs && node --test scripts/ship-mode-test.mjs && npm run test:memory-routing && node --test scripts/explore-prompt-policy-test.mjs && node --test scripts/routing-corpus-test.mjs",
40
+ "smoke:all": "npm run smoke && npm run smoke:boot && npm run smoke:tools && npm run smoke:patch && npm run smoke:output && npm run smoke:tui && npm run smoke:freevars && npm run smoke:logguard && npm run smoke:live-worker && npm run test:session",
41
41
  "smoke:boot": "node scripts/boot-smoke.mjs",
42
42
  "smoke:compact": "node scripts/compact-smoke.mjs",
43
43
  "smoke:loop": "node scripts/smoke-loop.mjs",
@@ -49,13 +49,18 @@
49
49
  "smoke:tui": "node scripts/tui-render-smoke.mjs && npm run test:tui-queue",
50
50
  "smoke:freevars": "node scripts/freevar-smoke.mjs",
51
51
  "smoke:logguard": "node scripts/log-writer-guard-smoke.mjs",
52
+ "smoke:live-worker": "node scripts/live-worker-smoke.mjs",
53
+ "smoke:agent-tag-reuse": "node scripts/agent-tag-reuse-smoke.mjs",
54
+ "test:agent-terminal-reap": "node scripts/agent-terminal-reap-test.mjs",
52
55
  "test:toolcall": "node --test scripts/toolcall-args-test.mjs",
53
56
  "test:shipmode": "node --test scripts/ship-mode-test.mjs",
54
57
  "test:shellhardening": "node --test scripts/shell-hardening-test.mjs",
55
58
  "test:placeholder": "node --test scripts/compacted-placeholder-scrub-test.mjs",
56
59
  "test:providers": "node --test scripts/provider-toolcall-test.mjs",
57
60
  "test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
58
- "test:tui-queue": "node --test scripts/submit-commandbusy-race-test.mjs scripts/steering-drain-buckets-test.mjs scripts/abort-recovery-test.mjs",
61
+ "test:memory-routing": "node --test scripts/memory-cycle-routing-test.mjs scripts/memory-rule-contract-test.mjs",
62
+ "test:tui-queue": "node --test scripts/submit-commandbusy-race-test.mjs scripts/steering-drain-buckets-test.mjs scripts/abort-recovery-test.mjs scripts/execution-pending-resume-kick-test.mjs scripts/execution-resume-esc-integration-test.mjs",
63
+ "test:session": "node --test scripts/session-orphan-sweep-test.mjs",
59
64
  "test:rebindtail": "node --test scripts/forwarder-rebind-tail-test.mjs",
60
65
  "failures": "node scripts/tool-failures.mjs",
61
66
  "trace:llm": "node scripts/llm-trace-summary.mjs",
@@ -18,6 +18,8 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
18
18
  function makeEngine({ abortSettles = false, recoveryMs = 30 } = {}) {
19
19
  let seq = 0;
20
20
  const notices = [];
21
+ const requeued = [];
22
+ const discardedCompletionKeys = [];
21
23
  let drainCount = 0;
22
24
  let state = { items: [], queued: [], busy: false, commandBusy: false, spinner: null, thinking: null, lastTurn: null };
23
25
  const bag = {
@@ -51,16 +53,24 @@ function makeEngine({ abortSettles = false, recoveryMs = 30 } = {}) {
51
53
  syncContextStats: () => {},
52
54
  denyAllToolApprovals: () => {},
53
55
  updateAgentJobCard: () => {},
54
- requeueEntriesFront: () => {},
56
+ requeueEntriesFront: (entries) => { requeued.push(...entries); },
55
57
  resetStatsAndSyncContext: () => {},
56
58
  flushDeferredExecutionPendingResumeKick: () => {},
59
+ discardExecutionPendingResume: (keys) => { discardedCompletionKeys.push(...keys); },
57
60
  drain: async () => { drainCount += 1; },
58
61
  runTurn: async () => 'ok',
59
62
  };
60
63
  Object.assign(bag, createSessionFlow(bag));
61
64
  bag.drain = async () => { drainCount += 1; };
62
65
  const api = createEngineApiA(bag);
63
- return { api, bag, getNotices: () => notices, getDrainCount: () => drainCount };
66
+ return {
67
+ api,
68
+ bag,
69
+ getNotices: () => notices,
70
+ getDrainCount: () => drainCount,
71
+ getRequeued: () => requeued,
72
+ getDiscardedCompletionKeys: () => discardedCompletionKeys,
73
+ };
64
74
  }
65
75
 
66
76
  test('starved abort → bounded recovery hard-releases busy and re-kicks drain', async () => {
@@ -116,3 +126,34 @@ test('recovery no-ops if a newer turn already owns the store', async () => {
116
126
  assert.equal(bag.flags.leadTurnEpoch, 5, 'recovery must not touch a newer turn epoch');
117
127
  assert.equal(bag.getState().busy, true, 'newer turn stays busy — not force-released');
118
128
  });
129
+
130
+ for (const phase of ['before first delta', 'after response progress']) {
131
+ test(`Esc ${phase} abandons the active completion resume`, () => {
132
+ const {
133
+ api, bag, getRequeued, getDiscardedCompletionKeys,
134
+ } = makeEngine({ abortSettles: true });
135
+ bag.flags.activePromptRestore = {
136
+ text: 'completion response',
137
+ restorable: false,
138
+ committed: false,
139
+ requeueEntries: [{
140
+ mode: 'pending-resume',
141
+ text: 'completion response',
142
+ abortDiscardOnAbort: true,
143
+ }],
144
+ discardExecutionPendingResumeKeys: ['execution_A'],
145
+ };
146
+ bag.set({
147
+ busy: true,
148
+ spinner: { active: true, responseLength: phase === 'after response progress' ? 24 : 0 },
149
+ thinking: phase === 'after response progress' ? { text: 'working' } : null,
150
+ });
151
+
152
+ assert.equal(api.abort().aborted, true);
153
+ assert.equal(bag.getState().busy, false, 'Esc releases the active completion turn');
154
+ assert.equal(bag.getState().spinner, null, 'spinner is cleared');
155
+ assert.equal(bag.getState().thinking, null, 'thinking is cleared');
156
+ assert.deepEqual(getRequeued(), [], 'the completion resume is not requeued');
157
+ assert.deepEqual(getDiscardedCompletionKeys(), ['execution_A'], 'its completion ownership is retired');
158
+ });
159
+ }
@@ -12,10 +12,9 @@
12
12
  // session manager so resolveTag / worker-index behavior is exercised end to end.
13
13
  //
14
14
  // Run: node scripts/agent-tag-reuse-smoke.mjs
15
- import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
15
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
16
  import { join } from 'node:path';
17
17
  import { tmpdir } from 'node:os';
18
- import { createStandaloneAgent } from '../src/standalone/agent-tool.mjs';
19
18
  import { initProviders } from '../src/runtime/agent/orchestrator/providers/registry.mjs';
20
19
  import {
21
20
  closeSession,
@@ -27,6 +26,15 @@ import {
27
26
  } from '../src/runtime/agent/orchestrator/session/manager.mjs';
28
27
 
29
28
  process.env.MIXDOG_AGENT_TRACE_DISABLE = '1';
29
+ process.env.MIXDOG_AGENT_SPAWN_PREP_TIMEOUT_MS = '50';
30
+ // Terminal reap is intentionally no longer environment-configurable. Keep a
31
+ // conflicting value here so this smoke proves the Advanced provider override
32
+ // is the authority; shorten only the test clock, not the configured duration.
33
+ process.env.MIXDOG_AGENT_TERMINAL_REAP_MS = '1000';
34
+ const nativeSetTimeout = globalThis.setTimeout;
35
+ globalThis.setTimeout = (callback, ms, ...args) => nativeSetTimeout(callback, ms === 60_000 ? 500 : ms, ...args);
36
+
37
+ const { createStandaloneAgent } = await import('../src/standalone/agent-tool.mjs');
30
38
 
31
39
  const root = mkdtempSync(join(tmpdir(), 'mixdog-agent-reuse-'));
32
40
  const dataDir = join(root, '.mixdog-data');
@@ -40,6 +48,7 @@ function taskId(text) { return String(text).match(/agent task: (\S+)/)?.[1] || n
40
48
 
41
49
  const askLog = [];
42
50
  let holdBusy = false;
51
+ let delayGeminiInit = false;
43
52
  async function fakeAskSession(sessionId, prompt) {
44
53
  const session = getSession(sessionId);
45
54
  if (session) { session.status = 'running'; session.lastStreamDeltaAt = Date.now(); }
@@ -57,7 +66,11 @@ async function fakeAskSession(sessionId, prompt) {
57
66
  const cfgMod = {
58
67
  loadConfig() {
59
68
  return {
60
- providers: { 'openai-oauth': { enabled: true } },
69
+ providers: delayGeminiInit ? {
70
+ 'openai-oauth': { enabled: true },
71
+ gemini: { enabled: true, apiKey: 'smoke-key' },
72
+ } : { 'openai-oauth': { enabled: true } },
73
+ autoClear: { providerIdleMs: { 'openai-oauth': 60_000 } },
61
74
  agents: {
62
75
  reviewer: { provider: 'openai-oauth', model: 'gpt-5.5', effort: 'low' },
63
76
  worker: { provider: 'openai-oauth', model: 'gpt-5.5', effort: 'low', fast: true },
@@ -72,7 +85,12 @@ const cfgMod = {
72
85
  return { lane: 'agent', scopeKey: `reuse:${ctx.agentId}`, provider: preset.provider, model: preset.model };
73
86
  },
74
87
  };
75
- const reg = { initProviders };
88
+ const reg = {
89
+ async initProviders(providers) {
90
+ if (delayGeminiInit && providers?.gemini) await sleep(100);
91
+ return await initProviders(providers);
92
+ },
93
+ };
76
94
  const mgr = {
77
95
  getSession,
78
96
  listSessions,
@@ -84,14 +102,16 @@ const mgr = {
84
102
  };
85
103
 
86
104
  const ctx = { invocationSource: 'model-tool', cwd: root, clientHostPid: 0xBEEF };
105
+ const peerCtx = { invocationSource: 'model-tool', cwd: root, clientHostPid: 0xCAFE };
87
106
  const agent = createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: root, defaultMode: 'async' });
107
+ const peerAgent = createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: root, defaultMode: 'async' });
88
108
 
89
- async function waitJob(out, pattern, label, tries = 60) {
109
+ async function waitJob(out, pattern, label, tries = 60, context = ctx) {
90
110
  const id = taskId(out);
91
111
  assert(id, `missing task id for ${label}: ${out}`);
92
112
  let last = '';
93
113
  for (let i = 0; i < tries; i += 1) {
94
- last = await agent.execute({ type: 'read', task_id: id }, ctx);
114
+ last = await agent.execute({ type: 'read', task_id: id }, context);
95
115
  if (pattern.test(last)) return last;
96
116
  if (/status: error/.test(last)) break;
97
117
  await sleep(40);
@@ -101,6 +121,32 @@ async function waitJob(out, pattern, label, tries = 60) {
101
121
  function sessionForTag(tag) {
102
122
  return listSessions().find((s) => s?.agentTag === tag && !s.closed) || null;
103
123
  }
124
+ function addPeerTerminalRow(tag, sessionId) {
125
+ const file = join(dataDir, 'agent-workers.json');
126
+ const index = JSON.parse(readFileSync(file, 'utf8'));
127
+ index.workers[sessionId] = {
128
+ tag,
129
+ sessionId,
130
+ agent: 'worker',
131
+ provider: 'openai-oauth',
132
+ status: 'idle',
133
+ stage: 'idle',
134
+ updatedAt: new Date().toISOString(),
135
+ clientHostPid: peerCtx.clientHostPid,
136
+ cwd: root,
137
+ };
138
+ writeFileSync(file, JSON.stringify(index));
139
+ }
140
+ function hasWorkerRow(sessionId) {
141
+ const index = JSON.parse(readFileSync(join(dataDir, 'agent-workers.json'), 'utf8'));
142
+ return Boolean(index.workers?.[sessionId]);
143
+ }
144
+ function removeWorkerRow(sessionId) {
145
+ const file = join(dataDir, 'agent-workers.json');
146
+ const index = JSON.parse(readFileSync(file, 'utf8'));
147
+ delete index.workers?.[sessionId];
148
+ writeFileSync(file, JSON.stringify(index));
149
+ }
104
150
  async function waitSessionForTag(tag, label, tries = 60) {
105
151
  for (let i = 0; i < tries; i += 1) {
106
152
  const s = sessionForTag(tag);
@@ -153,11 +199,14 @@ async function main() {
153
199
 
154
200
  // --- tier 3: terminal trace -> reap and reuse tag (spawn) or auto-respawn (send) ---
155
201
  closeSession(sid, 'smoke-session-closed-trace');
202
+ addPeerTerminalRow('reviewerA', 'sess_peer_trace');
156
203
  const traceSpawnOut = await agent.execute({
157
204
  type: 'spawn', agent: 'reviewer', tag: 'reviewerA', prompt: 'trace reap respawn', cwd: root,
158
205
  }, ctx);
159
206
  assert(!/^Error:/.test(traceSpawnOut), `terminal-trace spawn must reap and reuse tag: ${traceSpawnOut}`);
160
207
  await waitJob(traceSpawnOut, /ack: trace reap respawn/, 'trace reap spawn');
208
+ assert(hasWorkerRow('sess_peer_trace'), 'terminal-trace reap must preserve a peer terminal row with the same tag');
209
+ removeWorkerRow('sess_peer_trace');
161
210
  const traceLive = await waitSessionForTag('reviewerA', 'trace reap spawn live');
162
211
  closeSession(traceLive.id, 'smoke-kill-for-send-respawn');
163
212
  const traceSendOut = await agent.execute({
@@ -168,14 +217,104 @@ async function main() {
168
217
  assert(!/reused: true/.test(traceSendOut), `dead-tag send must not be a live reuse: ${traceSendOut}`);
169
218
  await waitJob(traceSendOut, /ack: auto-respawn follow-up/, 'trace send respawn');
170
219
 
220
+ // A local terminal timer must remove only its own same-tag trace; a peer
221
+ // terminal may legitimately retain the identical tag at the same time.
222
+ const timerPeerStart = await agent.execute({
223
+ type: 'spawn', agent: 'worker', tag: 'timer-peer', prompt: 'timer peer seed', cwd: root,
224
+ }, ctx);
225
+ await waitJob(timerPeerStart, /ack: timer peer seed/, 'timer peer seed');
226
+ const timerPeerSession = (await waitSessionForTag('timer-peer', 'timer peer seed')).id;
227
+ addPeerTerminalRow('timer-peer', 'sess_peer_timer');
228
+ await sleep(700);
229
+ assert(getSession(timerPeerSession)?.closed, 'terminal timer must reap its local session');
230
+ assert(hasWorkerRow('sess_peer_timer'), 'terminal timer must preserve a peer row with the same tag');
231
+
232
+ // Fully reaped tags have no in-memory map or persisted row. Explicit
233
+ // cold-spawn identity is enough to create a replacement.
234
+ const coldStart = await agent.execute({
235
+ type: 'spawn', agent: 'worker', tag: 'cold-reaped', prompt: 'cold reap seed', cwd: root,
236
+ }, ctx);
237
+ await waitJob(coldStart, /ack: cold reap seed/, 'cold reap seed');
238
+ const coldSessionId = (await waitSessionForTag('cold-reaped', 'cold reap seed')).id;
239
+ await sleep(1200);
240
+ assert(!sessionForTag('cold-reaped') && getSession(coldSessionId)?.closed, 'terminal reaper must remove the cold-reaped session');
241
+ const coldSendOut = await agent.execute({
242
+ type: 'send', agent: 'worker', tag: 'cold-reaped', message: 'cold reap replacement', cwd: root,
243
+ }, ctx);
244
+ assert(!/^Error:/.test(coldSendOut), `fully reaped tag must cold-respawn: ${coldSendOut}`);
245
+ assert(/respawned: true/.test(coldSendOut), `fully reaped send must flag respawned: ${coldSendOut}`);
246
+ await waitJob(coldSendOut, /ack: cold reap replacement/, 'cold reap replacement');
247
+
248
+ // A raw session id, typo without retained evidence/identity, and a peer tag
249
+ // must never turn into a local cold spawn.
250
+ const rawSessionOut = await agent.execute({
251
+ type: 'send', sessionId: 'sess_missing', agent: 'worker', message: 'must not spawn', cwd: root,
252
+ }, ctx);
253
+ assert(/^Error:/.test(rawSessionOut), `raw session id must remain an error: ${rawSessionOut}`);
254
+ const typoOut = await agent.execute({
255
+ type: 'send', tag: 'typo-no-evidence', message: 'must not spawn', cwd: root,
256
+ }, ctx);
257
+ assert(/^Error:/.test(typoOut), `unproven typo tag must remain an error: ${typoOut}`);
258
+ const peerStart = await peerAgent.execute({
259
+ type: 'spawn', agent: 'worker', tag: 'peer-owned', prompt: 'peer seed', cwd: root,
260
+ }, peerCtx);
261
+ await waitJob(peerStart, /ack: peer seed/, 'peer seed', 60, peerCtx);
262
+ const peerOut = await agent.execute({
263
+ type: 'send', agent: 'worker', tag: 'peer-owned', message: 'must not take peer tag', cwd: root,
264
+ }, ctx);
265
+ assert(/^Error:/.test(peerOut), `peer-terminal tag must remain an error: ${peerOut}`);
266
+
171
267
  // agent close clears the trace; same tag can spawn fresh again
268
+ addPeerTerminalRow('reviewerA', 'sess_peer_close');
172
269
  await agent.execute({ type: 'close', tag: 'reviewerA' }, ctx);
270
+ assert(hasWorkerRow('sess_peer_close'), 'explicit close must preserve a peer terminal row with the same tag');
271
+ removeWorkerRow('sess_peer_close');
173
272
  const freshAfterClose = await agent.execute({
174
273
  type: 'spawn', agent: 'reviewer', tag: 'reviewerA', prompt: 'post-forget fresh', cwd: root,
175
274
  }, ctx);
176
275
  assert(!/^Error/.test(freshAfterClose), `post-forget spawn must work: ${freshAfterClose}`);
177
276
  await waitJob(freshAfterClose, /ack: post-forget fresh/, 'post-forget spawn');
178
277
 
278
+ // A timed-out spawn prep can bind after its task is already terminal. Its
279
+ // cleanup must remove only that late local session, preserving a peer row
280
+ // and leaving the local tag free for a fresh spawn.
281
+ delayGeminiInit = true;
282
+ const prepOut = await agent.execute({
283
+ type: 'spawn', agent: 'worker', tag: 'prep-peer', provider: 'gemini', model: 'gemini-smoke', prompt: 'late prep cleanup', cwd: root,
284
+ }, ctx);
285
+ assert(/agent task:/.test(prepOut), `prep-timeout spawn must return a task: ${prepOut}`);
286
+ addPeerTerminalRow('prep-peer', 'sess_peer_prep');
287
+ await sleep(200);
288
+ delayGeminiInit = false;
289
+ const prepStatus = await agent.execute({ type: 'read', task_id: taskId(prepOut) }, ctx);
290
+ assert(/status: error|spawn prep timed out/i.test(prepStatus), `prep cleanup path must time out: ${prepStatus}`);
291
+ assert(hasWorkerRow('sess_peer_prep'), 'spawn-prep cleanup must preserve a peer terminal row with the same tag');
292
+ const freshPrep = await agent.execute({
293
+ type: 'spawn', agent: 'worker', tag: 'prep-peer', prompt: 'fresh after prep cleanup', cwd: root,
294
+ }, ctx);
295
+ assert(!/^Error/.test(freshPrep), `fresh same-tag spawn must follow prep cleanup: ${freshPrep}`);
296
+ await waitJob(freshPrep, /ack: fresh after prep cleanup/, 'fresh after prep cleanup');
297
+
298
+ // A stale local tagAgents entry may make explicit close return forgotten:true,
299
+ // but it must not erase a peer terminal's same-tag worker row.
300
+ const staleStart = await agent.execute({
301
+ type: 'spawn', agent: 'worker', tag: 'fallback-peer', prompt: 'stale fallback seed', cwd: root,
302
+ }, ctx);
303
+ await waitJob(staleStart, /ack: stale fallback seed/, 'stale fallback seed');
304
+ const staleSessionId = (await waitSessionForTag('fallback-peer', 'stale fallback seed')).id;
305
+ closeSession(staleSessionId, 'smoke-stale-close-fallback');
306
+ removeWorkerRow(staleSessionId);
307
+ addPeerTerminalRow('fallback-peer', 'sess_peer_fallback');
308
+ const fallbackClose = await agent.execute({ type: 'close', tag: 'fallback-peer' }, ctx);
309
+ assert(/forgotten: true/.test(fallbackClose), `stale close fallback must stay forgotten:true: ${fallbackClose}`);
310
+ assert(hasWorkerRow('sess_peer_fallback'), 'stale close fallback must preserve a peer terminal row with the same tag');
311
+ removeWorkerRow('sess_peer_fallback');
312
+ const fallbackFresh = await agent.execute({
313
+ type: 'spawn', agent: 'worker', tag: 'fallback-peer', prompt: 'fresh after stale fallback', cwd: root,
314
+ }, ctx);
315
+ assert(!/^Error/.test(fallbackFresh), `fresh same-tag spawn must follow stale close fallback: ${fallbackFresh}`);
316
+ await waitJob(fallbackFresh, /ack: fresh after stale fallback/, 'fresh after stale fallback');
317
+
179
318
  process.stdout.write(`agent tag-reuse smoke passed (asks=${askLog.length})\n`);
180
319
  }
181
320
 
@@ -183,5 +322,6 @@ try {
183
322
  await main();
184
323
  } finally {
185
324
  try { agent.closeAll('agent-tag-reuse-smoke-end'); } catch {}
325
+ globalThis.setTimeout = nativeSetTimeout;
186
326
  rmSync(root, { recursive: true, force: true });
187
327
  }
@@ -0,0 +1,127 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import {
6
+ AUTO_CLEAR_PROVIDER_IDLE_MS,
7
+ resolveAgentTerminalReapMs,
8
+ } from '../src/session-runtime/config-helpers.mjs';
9
+
10
+ const root = mkdtempSync(join(tmpdir(), 'mixdog-agent-terminal-reap-'));
11
+ process.env.MIXDOG_DATA_DIR = root;
12
+ process.env.MIXDOG_AGENT_TERMINAL_REAP_MS = '1';
13
+
14
+ function assertEqual(actual, expected, label) {
15
+ assert.equal(actual, expected, `${label}: expected ${expected}, got ${actual}`);
16
+ }
17
+
18
+ try {
19
+ const builtIns = Object.entries(AUTO_CLEAR_PROVIDER_IDLE_MS).filter(([provider]) => provider !== 'default');
20
+ for (const [provider, idleMs] of builtIns) {
21
+ assertEqual(resolveAgentTerminalReapMs({ autoClear: {} }, provider), idleMs, `${provider} built-in`);
22
+ }
23
+
24
+ const overrideConfig = {
25
+ autoClear: {
26
+ idleMs: 12 * 60 * 60 * 1000,
27
+ providerIdleMs: { 'openai-oauth': 60_000, default: 90_000, unlisted: 60_000 },
28
+ },
29
+ };
30
+ assertEqual(resolveAgentTerminalReapMs(overrideConfig, 'openai-oauth'), 60_000, 'provider override');
31
+ assertEqual(
32
+ resolveAgentTerminalReapMs(overrideConfig, 'anthropic'),
33
+ AUTO_CLEAR_PROVIDER_IDLE_MS.anthropic,
34
+ 'global idleMs and default row are ignored for listed providers',
35
+ );
36
+ assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'default'), 90_000, 'default row applies to default provider');
37
+ assert.equal(resolveAgentTerminalReapMs(overrideConfig, 'unlisted'), 90_000, 'default row applies to unlisted provider');
38
+ assert.equal(resolveAgentTerminalReapMs({ autoClear: {} }, 'unknown'), AUTO_CLEAR_PROVIDER_IDLE_MS.default, 'unknown provider uses built-in default');
39
+
40
+ mkdirSync(join(root, 'sessions'), { recursive: true });
41
+ writeFileSync(join(root, 'mixdog-config.json'), JSON.stringify({
42
+ agent: { autoClear: overrideConfig.autoClear },
43
+ }));
44
+ const { sweepStaleSessions } = await import('../src/runtime/agent/orchestrator/session/store.mjs');
45
+ const old = Date.now() - 181_000;
46
+ const known = {
47
+ id: 'sess_known_reap',
48
+ owner: 'agent',
49
+ status: 'idle',
50
+ provider: 'openai-oauth',
51
+ createdAt: old,
52
+ updatedAt: old,
53
+ messages: [],
54
+ };
55
+ const shortOverride = {
56
+ id: 'sess_short_override',
57
+ owner: 'agent',
58
+ status: 'idle',
59
+ provider: 'openai-oauth',
60
+ createdAt: Date.now() - 120_000,
61
+ updatedAt: Date.now() - 120_000,
62
+ messages: [],
63
+ };
64
+ const unknown = {
65
+ id: 'sess_unknown_keep',
66
+ owner: 'agent',
67
+ status: 'idle',
68
+ provider: 'unlisted',
69
+ createdAt: old,
70
+ updatedAt: old,
71
+ messages: [],
72
+ };
73
+ writeFileSync(join(root, 'sessions', `${known.id}.json`), JSON.stringify(known));
74
+ writeFileSync(join(root, 'sessions', `${shortOverride.id}.json`), JSON.stringify(shortOverride));
75
+ writeFileSync(join(root, 'sessions', `${unknown.id}.json`), JSON.stringify(unknown));
76
+ for (const id of [known.id, unknown.id]) utimesSync(join(root, 'sessions', `${id}.json`), old / 1000, old / 1000);
77
+ utimesSync(join(root, 'sessions', `${shortOverride.id}.json`), shortOverride.updatedAt / 1000, shortOverride.updatedAt / 1000);
78
+ const defaultSweep = sweepStaleSessions({ retainOpenSessions: false });
79
+ assert.ok(
80
+ defaultSweep.details.some((detail) => detail.id === shortOverride.id),
81
+ 'short provider override bypasses the default sweep freshness gate',
82
+ );
83
+ assert.ok(defaultSweep.details.some((detail) => detail.id === known.id), 'store reaps a listed provider at its Advanced duration');
84
+ assert.ok(defaultSweep.details.some((detail) => detail.id === unknown.id), 'store reaps an unlisted provider at the default duration');
85
+
86
+ const dataDir = join(root, 'worker-index');
87
+ mkdirSync(dataDir, { recursive: true });
88
+ writeFileSync(join(dataDir, 'agent-workers.json'), JSON.stringify({
89
+ workers: {
90
+ known: {
91
+ tag: 'known',
92
+ sessionId: 'sess_known_row',
93
+ provider: 'openai-oauth',
94
+ status: 'idle',
95
+ updatedAt: new Date(old).toISOString(),
96
+ },
97
+ unknown: {
98
+ tag: 'unknown',
99
+ sessionId: 'sess_unknown_row',
100
+ provider: 'unlisted',
101
+ status: 'idle',
102
+ updatedAt: new Date(old).toISOString(),
103
+ },
104
+ },
105
+ }));
106
+ const { createStandaloneAgent } = await import('../src/standalone/agent-tool.mjs');
107
+ const agent = createStandaloneAgent({
108
+ cfgMod: {
109
+ loadConfig: () => overrideConfig,
110
+ resolveRuntimeSpec: () => ({ lane: 'agent', scopeKey: 'terminal-reap', provider: 'openai-oauth', model: 'test' }),
111
+ },
112
+ reg: {},
113
+ mgr: { listSessions: () => [], getSession: () => null },
114
+ dataDir,
115
+ cwd: root,
116
+ });
117
+ const workers = agent.getStatus().workers;
118
+ assert.ok(!workers.some((worker) => worker.tag === 'known'), 'worker row expires at the provider duration');
119
+ assert.ok(!workers.some((worker) => worker.tag === 'unknown'), 'unlisted worker row expires at the default duration');
120
+ agent.closeAll('agent-terminal-reap-test');
121
+
122
+ process.stdout.write(`agent terminal reap test passed (${builtIns.length} providers)\n`);
123
+ } finally {
124
+ delete process.env.MIXDOG_AGENT_TERMINAL_REAP_MS;
125
+ delete process.env.MIXDOG_DATA_DIR;
126
+ rmSync(root, { recursive: true, force: true });
127
+ }
@@ -0,0 +1,69 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import test from 'node:test';
8
+
9
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
+ const traceModuleUrl = pathToFileURL(join(root, 'src/runtime/agent/orchestrator/agent-trace-io.mjs')).href;
11
+
12
+ test('drainAgentTrace awaits delayed local JSONL append without a memory service', () => {
13
+ const dir = mkdtempSync(join(tmpdir(), 'mixdog-agent-trace-io-test-'));
14
+ const tracePath = join(dir, 'agent-trace.jsonl');
15
+ const loaderPath = join(dir, 'loader.mjs');
16
+ const delayedFsPath = join(dir, 'delayed-fs-promises.mjs');
17
+ try {
18
+ writeFileSync(delayedFsPath, `
19
+ import { appendFile as realAppendFile } from 'node:fs/promises';
20
+ export async function appendFile(...args) {
21
+ await new Promise((resolve) => setTimeout(resolve, Number(process.env.TRACE_APPEND_DELAY_MS)));
22
+ return realAppendFile(...args);
23
+ }
24
+ `);
25
+ writeFileSync(loaderPath, `
26
+ export async function resolve(specifier, context, nextResolve) {
27
+ if (specifier === 'fs/promises' && context.parentURL === ${JSON.stringify(traceModuleUrl)}) {
28
+ return { url: new URL('./delayed-fs-promises.mjs', import.meta.url).href, shortCircuit: true };
29
+ }
30
+ return nextResolve(specifier, context);
31
+ }
32
+ `);
33
+ const child = spawnSync(process.execPath, [
34
+ '--experimental-loader',
35
+ pathToFileURL(loaderPath).href,
36
+ '--input-type=module',
37
+ '-e',
38
+ `
39
+ import { readFileSync } from 'node:fs';
40
+ import { appendAgentTrace, drainAgentTrace } from ${JSON.stringify(traceModuleUrl)};
41
+ appendAgentTrace({ kind: 'delayed-local-test', session_id: 'sess_delayed' });
42
+ const started = Date.now();
43
+ await drainAgentTrace();
44
+ const rows = readFileSync(process.env.MIXDOG_AGENT_TRACE_PATH, 'utf8').trim().split(/\\r?\\n/).map(JSON.parse);
45
+ process.stdout.write(JSON.stringify({ elapsed: Date.now() - started, rows }));
46
+ `,
47
+ ], {
48
+ encoding: 'utf8',
49
+ env: {
50
+ ...process.env,
51
+ MIXDOG_AGENT_TRACE_PATH: tracePath,
52
+ MIXDOG_AGENT_TRACE_DISABLE: '',
53
+ MIXDOG_AGENT_TRACE_LOCAL_DISABLE: '',
54
+ MIXDOG_RUNTIME_ROOT: join(dir, 'no-service'),
55
+ TRACE_APPEND_DELAY_MS: '75',
56
+ },
57
+ timeout: 5000,
58
+ });
59
+ assert.equal(child.status, 0, child.stderr || child.stdout);
60
+ const result = JSON.parse(child.stdout);
61
+ assert.ok(result.elapsed >= 60, `drain returned before delayed append (${result.elapsed}ms)`);
62
+ assert.equal(result.rows.length, 1);
63
+ assert.equal(result.rows[0].kind, 'delayed-local-test');
64
+ assert.equal(result.rows[0].session_id, 'sess_delayed');
65
+ assert.match(readFileSync(tracePath, 'utf8'), /"kind":"delayed-local-test"/);
66
+ } finally {
67
+ rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ });