mixdog 0.9.0 → 0.9.2

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 (240) hide show
  1. package/package.json +10 -3
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -105,31 +105,50 @@ const _checkedConnect = checkedConnect
105
105
  // native PG db shim
106
106
  // ---------------------------------------------------------------------------
107
107
 
108
- function makeCompatDb(pgPool, schema) {
108
+ function makeCompatDb(pgPool, schema, dataDir) {
109
109
  const db = {
110
110
  // query: use pool directly for single-statement queries
111
111
  query: async (sql, params) => {
112
- const client = await _checkedConnect(pgPool, schema)
113
- try {
114
- return await client.query(sql, params)
115
- } finally {
116
- client.release()
117
- }
112
+ return await withPgRetry(dataDir, schema, async (pool) => {
113
+ const client = await _checkedConnect(pool, schema)
114
+ try {
115
+ return await client.query(sql, params)
116
+ } finally {
117
+ client.release()
118
+ }
119
+ })
118
120
  },
119
121
 
120
122
  // exec: multi-statement SQL (semicolon-separated); single client for session state
121
123
  exec: async (sql) => {
122
- const client = await _checkedConnect(pgPool, schema)
124
+ // Not retried: exec runs arbitrary multi-statement SQL where a partial
125
+ // failure mid-sequence is unobservable from here — replaying the whole
126
+ // string after recovery risks double-applying already-committed
127
+ // statements. On ECONNREFUSED, trigger recovery in the background (so
128
+ // the NEXT call gets a fresh pool) and propagate the original error.
129
+ const pool = instances.get(`${resolve(dataDir)}|${schema}`)?.pool ?? pgPool
123
130
  try {
124
- await client.query(sql)
125
- } finally {
126
- client.release()
131
+ const client = await _checkedConnect(pool, schema)
132
+ try {
133
+ await client.query(sql)
134
+ } finally {
135
+ client.release()
136
+ }
137
+ } catch (err) {
138
+ if (err?.code === 'ECONNREFUSED') _recoverPgConnection(dataDir, schema).catch(() => {})
139
+ throw err
127
140
  }
128
141
  },
129
142
 
130
143
  // transaction: check out one client, BEGIN, run callback(tx), COMMIT or ROLLBACK
131
144
  transaction: async (fn) => {
132
- const client = await _checkedConnect(pgPool, schema)
145
+ // Not retried same rationale as exec(): a COMMIT that fails with
146
+ // ECONNREFUSED leaves the transaction's applied/rolled-back state
147
+ // unknown to this process; blindly replaying fn() could double-apply
148
+ // side effects. Recover in the background for the next caller and
149
+ // propagate the original error immediately.
150
+ const pool = instances.get(`${resolve(dataDir)}|${schema}`)?.pool ?? pgPool
151
+ const client = await _checkedConnect(pool, schema)
133
152
  try {
134
153
  await client.query('BEGIN')
135
154
  const tx = {
@@ -141,6 +160,7 @@ function makeCompatDb(pgPool, schema) {
141
160
  return result
142
161
  } catch (err) {
143
162
  try { await client.query('ROLLBACK') } catch {}
163
+ if (err?.code === 'ECONNREFUSED') _recoverPgConnection(dataDir, schema).catch(() => {})
144
164
  throw err
145
165
  } finally {
146
166
  client.release()
@@ -150,12 +170,116 @@ function makeCompatDb(pgPool, schema) {
150
170
  // close: drain pool
151
171
  close: () => pgPool.end(),
152
172
 
153
- // Internal access for callers that need raw pool
154
- _pool: pgPool,
173
+ // Internal access for callers that need raw pool. Getter (not a fixed
174
+ // field) so callers that hold onto `db` across a recovery cycle (e.g.
175
+ // trace-store's checkedConnect/pool.connect() call sites) transparently
176
+ // see the refreshed pool after withPgRetry swaps the cached instance —
177
+ // otherwise db._pool would keep pointing at the dead pre-recovery pool.
178
+ get _pool() {
179
+ return instances.get(`${resolve(dataDir)}|${schema}`)?.pool ?? pgPool
180
+ },
155
181
  }
156
182
  return db
157
183
  }
158
184
 
185
+ // ---------------------------------------------------------------------------
186
+ // Self-healing: pool query fails with ECONNREFUSED (target pg_port down while
187
+ // the server process itself stays alive) → discard the stale pool via
188
+ // closePgInstance and re-run ensurePgInstance so supervisor-pg restarts/
189
+ // re-attaches PG, then retry the failed operation exactly once against the
190
+ // fresh pool.
191
+ //
192
+ // Guards against runaway restart loops:
193
+ // - _recoverInFlight: at most one recovery coroutine per dataDir at a time;
194
+ // concurrent callers await the same promise instead of racing restarts.
195
+ // - _lastRecoverAt / RECOVER_COOLDOWN_MS: after a recovery attempt (success
196
+ // or failure) further ECONNREFUSED hits within the cooldown window
197
+ // surface the original error instead of re-triggering PG restart.
198
+ // ---------------------------------------------------------------------------
199
+
200
+ const RECOVER_COOLDOWN_MS = 15_000
201
+ const _lastRecoverAt = new Map() // dataDirKey → epoch ms of last recovery attempt
202
+ const _recoverInFlight = new Map() // dataDirKey → Promise<boolean>
203
+
204
+ function _instanceKeysForDataDir(dataDirKey) {
205
+ const prefix = `${dataDirKey}|`
206
+ return Array.from(instances.keys()).filter(k => k.startsWith(prefix))
207
+ }
208
+
209
+ async function _recoverPgConnection(dataDir, schema) {
210
+ const dataDirKey = resolve(dataDir)
211
+ // In-flight check FIRST: a concurrent recovery already running for this
212
+ // dataDir must be awaited by every caller (even ones arriving inside the
213
+ // cooldown window that starts once that recovery begins) — otherwise a
214
+ // caller that lands between the in-flight recovery's start and its cooldown
215
+ // stamp would see neither in-flight nor cooldown and could trigger a second
216
+ // redundant restart cycle.
217
+ if (_recoverInFlight.has(dataDirKey)) return _recoverInFlight.get(dataDirKey)
218
+ const now = Date.now()
219
+ const last = _lastRecoverAt.get(dataDirKey) || 0
220
+ if (now - last < RECOVER_COOLDOWN_MS) {
221
+ // Cooldown active — do not hammer PG restart on every failing query.
222
+ return false
223
+ }
224
+
225
+ const p = (async () => {
226
+ _lastRecoverAt.set(dataDirKey, Date.now())
227
+ __mixdogMemoryLog(`[pg-adapter] ECONNREFUSED on pool query — recovering PG for dataDir=${dataDirKey}\n`)
228
+ // memory + trace schemas share one PG cluster/port; discard every cached
229
+ // pool for this dataDir so a dead-port pool is never reused after restart.
230
+ // Track every schema whose pool was actually closed here so ALL of them
231
+ // get re-ensured below (not just the schema of the caller that happened
232
+ // to trigger this recovery) — otherwise a sibling schema's cached `db`
233
+ // handle is left pointing at an ended pool (its `_pool` getter falls back
234
+ // to the stale `pgPool` closure var) and its next query throws a
235
+ // "Cannot use a pool after calling end" TypeError instead of recovering.
236
+ const closedSchemas = new Set()
237
+ for (const key of _instanceKeysForDataDir(dataDirKey)) {
238
+ const sch = key.slice(dataDirKey.length + 1)
239
+ try { await closePgInstance(dataDir, { schema: sch }) } catch {}
240
+ closedSchemas.add(sch)
241
+ }
242
+ closedSchemas.add(schema) // the triggering schema, even if it had no cached instance yet
243
+ try {
244
+ for (const sch of closedSchemas) {
245
+ await ensurePgInstance(dataDir, { schema: sch })
246
+ }
247
+ __mixdogMemoryLog(`[pg-adapter] PG reconnect recovery complete for dataDir=${dataDirKey}\n`)
248
+ return true
249
+ } catch (e) {
250
+ __mixdogMemoryLog(`[pg-adapter] PG reconnect recovery failed for dataDir=${dataDirKey}: ${e?.message || e}\n`)
251
+ return false
252
+ }
253
+ })()
254
+ _recoverInFlight.set(dataDirKey, p)
255
+ try {
256
+ return await p
257
+ } finally {
258
+ _recoverInFlight.delete(dataDirKey)
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Run `fn(pool)` against the current pool for (dataDir, schema); on
264
+ * ECONNREFUSED, recover PG (see _recoverPgConnection) and retry once against
265
+ * the refreshed pool. Non-ECONNREFUSED errors and cooldown/in-flight misses
266
+ * propagate the original error unchanged.
267
+ */
268
+ async function withPgRetry(dataDir, schema, fn) {
269
+ const key = `${resolve(dataDir)}|${schema}`
270
+ const pool0 = instances.get(key)?.pool
271
+ try {
272
+ return await fn(pool0)
273
+ } catch (err) {
274
+ if (err?.code !== 'ECONNREFUSED') throw err
275
+ const recovered = await _recoverPgConnection(dataDir, schema)
276
+ if (!recovered) throw err
277
+ const pool1 = instances.get(key)?.pool
278
+ if (!pool1) throw err
279
+ return await fn(pool1)
280
+ }
281
+ }
282
+
159
283
  // ---------------------------------------------------------------------------
160
284
  // Instance bootstrap — extensions + schemas (idempotent)
161
285
  // ---------------------------------------------------------------------------
@@ -299,7 +423,7 @@ export async function ensurePgInstance(dataDir, opts = {}) {
299
423
  await bootstrapInstance(pgPool, resolve(dataDir))
300
424
 
301
425
  // 5. Build the compat db shim.
302
- const db = makeCompatDb(pgPool, schema)
426
+ const db = makeCompatDb(pgPool, schema, dataDir)
303
427
 
304
428
  const result = { db, pool: pgPool, host, port, runtimeDir, pgdataDir }
305
429
  instances.set(key, result)
@@ -48,6 +48,15 @@ function platformKey() {
48
48
  return `${os}-${process.arch}`
49
49
  }
50
50
 
51
+ function platformKeyCandidates() {
52
+ const primary = platformKey()
53
+ const candidates = [primary]
54
+ if (process.platform === 'win32' && process.arch === 'arm64') {
55
+ candidates.push('win32-x64')
56
+ }
57
+ return candidates
58
+ }
59
+
51
60
  // Fail-closed asset validation. A selected manifest asset is usable only if it
52
61
  // is not explicitly marked unsupported AND carries a real downloadable payload:
53
62
  // non-empty url, a well-formed 64-hex sha256, and a positive integer size.
@@ -365,25 +374,35 @@ export async function ensureRuntime(dataDir) {
365
374
  gcRuntimeDir(runtimeBaseDir, readActiveVersion(runtimeBaseDir))
366
375
 
367
376
  const manifest = await loadManifest(key)
368
- const pkey = platformKey()
369
- const asset = manifest.assets?.[pkey]
370
- if (!asset) {
371
- // Platform/arch absent from the manifest entirely (e.g. win32-arm64).
372
- // The memory PG runtime cannot start here; fail with a single clear,
373
- // actionable message. The memory worker's init().catch reports this as
374
- // degraded and the rest of mixdog (agent, tools) keeps working without
375
- // memory.
376
- const supported = Object.keys(manifest.assets || {})
377
- .filter((k) => isUsableAsset(manifest.assets[k]))
378
- .join(', ') || '(none)'
379
- throw new Error(
380
- `[runtime-fetcher] memory runtime not available on ${pkey}: ` +
381
- `no runtime asset for this platform/arch in the manifest. ` +
382
- `Supported: ${supported}. ` +
383
- `Memory is disabled on this platform; the rest of mixdog continues to work.`
384
- )
377
+ const pkey = platformKey()
378
+ let selectedKey = null
379
+ let asset = null
380
+ for (const candidateKey of platformKeyCandidates()) {
381
+ const candidateAsset = manifest.assets?.[candidateKey]
382
+ if (isUsableAsset(candidateAsset)) {
383
+ selectedKey = candidateKey
384
+ asset = candidateAsset
385
+ break
386
+ }
385
387
  }
386
- if (!isUsableAsset(asset)) {
388
+ if (!asset) {
389
+ const primaryAsset = manifest.assets?.[pkey]
390
+ if (!primaryAsset) {
391
+ // Platform/arch absent from the manifest entirely (e.g. an exotic arch).
392
+ // The memory PG runtime cannot start here; fail with a single clear,
393
+ // actionable message. The memory worker's init().catch reports this as
394
+ // degraded and the rest of mixdog (agent, tools) keeps working without
395
+ // memory.
396
+ const supported = Object.keys(manifest.assets || {})
397
+ .filter((k) => isUsableAsset(manifest.assets[k]))
398
+ .join(', ') || '(none)'
399
+ throw new Error(
400
+ `[runtime-fetcher] memory runtime not available on ${pkey}: ` +
401
+ `no runtime asset for this platform/arch in the manifest. ` +
402
+ `Supported: ${supported}. ` +
403
+ `Memory is disabled on this platform; the rest of mixdog continues to work.`
404
+ )
405
+ }
387
406
  // Platform/arch present but explicitly marked unsupported or carrying a
388
407
  // placeholder/TBD payload (e.g. linux-arm64). Same graceful-degrade path.
389
408
  throw new Error(
@@ -393,6 +412,12 @@ export async function ensureRuntime(dataDir) {
393
412
  )
394
413
  }
395
414
 
415
+ if (selectedKey !== pkey) {
416
+ __mixdogMemoryLog(
417
+ '[runtime-fetcher] win32-arm64 has no native runtime; using win32-x64 under emulation\n'
418
+ )
419
+ }
420
+
396
421
  const { url, sha256, size } = asset
397
422
  const version = `pg${manifest.pg?.major}.${manifest.pg?.minor}+pgvector-${manifest.pgvector?.version}`
398
423
 
@@ -1,21 +1,24 @@
1
1
  import crypto from 'node:crypto'
2
+ import { isInternalRuntimeNotificationText } from '../../shared/tool-execution-contract.mjs'
2
3
 
3
4
  // Side-effect-free helpers for ingest_session (recall fast-track hydration).
4
5
  // Extracted from memory/index.mjs so the pure logic (stable identity, sensitive
5
6
  // redaction, role/content shaping) can be unit-tested without importing the
6
7
  // MCP server entrypoint and its heavy boot-time side effects.
7
8
 
8
- // Roles we persist from an in-memory session transcript. Map provider/runtime
9
- // role spellings onto the canonical set; unknown roles are dropped.
10
- const INGEST_SESSION_ROLES = new Set(['user', 'assistant', 'tool', 'system', 'developer'])
9
+ // Roles we persist from an in-memory session transcript (conversation only).
10
+ // Map provider/runtime spellings onto canonical roles; only user/assistant are
11
+ // kept so recall-fasttrack memory does not duplicate protected system prefix.
12
+ const INGEST_SESSION_ROLES = new Set(['user', 'assistant'])
11
13
 
12
14
  export function normalizeIngestRole(role) {
13
15
  const raw = String(role || '').trim().toLowerCase()
14
16
  if (!raw) return null
15
- if (raw === 'tool_result' || raw === 'function' || raw === 'tool-result') return 'tool'
16
- if (raw === 'human') return 'user'
17
- if (raw === 'ai' || raw === 'model') return 'assistant'
18
- return INGEST_SESSION_ROLES.has(raw) ? raw : null
17
+ let canonical = raw
18
+ if (raw === 'tool_result' || raw === 'function' || raw === 'tool-result') canonical = 'tool'
19
+ else if (raw === 'human') canonical = 'user'
20
+ else if (raw === 'ai' || raw === 'model') canonical = 'assistant'
21
+ return INGEST_SESSION_ROLES.has(canonical) ? canonical : null
19
22
  }
20
23
 
21
24
  // Extract the first textual content block from a message content field.
@@ -192,3 +195,109 @@ export function sessionMessageContent(m) {
192
195
  }
193
196
  return parts.join('\n')
194
197
  }
198
+
199
+ // ── Pure-conversation ingest shaping ──────────────────────────────────────
200
+ //
201
+ // ingest_session persists ONLY real conversation (human prompts + model reply
202
+ // prose). sessionMessageContent (above) is the structured-handoff shaper that
203
+ // inlines tool_call / tool_result traces and is consumed by smoke tests; it is
204
+ // intentionally LEFT UNCHANGED. The functions below are ingest-only and strip
205
+ // everything that is mechanical/synthetic so memory episode rows are pure
206
+ // conversation with zero loss of genuine human/model text.
207
+
208
+ // Mirror of compact.mjs SUMMARY_PREFIX (the compaction handoff anchor). Copied
209
+ // locally rather than imported because compact.mjs lives under
210
+ // agent/orchestrator/session and pulls in heavy context/offload modules —
211
+ // importing it into the memory layer would create a layering dependency (memory
212
+ // → orchestrator) and risk a boot-time cycle. Keep this string byte-identical
213
+ // to compact.mjs:9; if that anchor changes, update this copy.
214
+ const SUMMARY_PREFIX_INGEST = 'A previous model worked on this task and produced the compacted handoff summary below. Build on the work already done and avoid duplicating it; treat the summary as authoritative context for continuing the task. You also retain the preserved recent turns that follow.'
215
+
216
+ // Anchored strip of the deterministic user-turn prefix envelopes that
217
+ // manager.mjs prepends to the SINGLE real user message (manager.mjs:3166-3201
218
+ // via prefixUserTurnContent / prefixSessionStartContent / buildSessionStartBlock).
219
+ // Zero-loss design: every rule is anchored to the START of the message and only
220
+ // removes the EXACT shapes manager.mjs produces. A `# Task` / `# Session` etc.
221
+ // appearing mid-message in the human's own text is never touched. When in
222
+ // doubt the rules UNDER-strip (leave content) rather than delete human text.
223
+ function stripUserTurnPrefixEnvelopes(text) {
224
+ let out = String(text ?? '')
225
+ // 1) Leading `# Session` block (buildSessionStartBlock: `# Session\nCwd: ...
226
+ // \nModel: ...\nWorkflow: ...`, joined by prefixSessionStartContent with a
227
+ // trailing `\n\n`). FIELD-ANCHORED: only strip when the line(s) right after
228
+ // `# Session\n` are EXACTLY the fixed fields buildSessionStartBlock emits
229
+ // (`Cwd: `, `Model: `, `Workflow: `, each on its own line) before the
230
+ // blank-line terminator. A human doc that merely STARTS with a `# Session`
231
+ // heading followed by free prose (e.g. `프로젝트 회의록입니다`) does NOT match
232
+ // — its next line is not a `Cwd:/Model:/Workflow:` field — so it is
233
+ // preserved verbatim (zero-loss). Anchored ^.
234
+ out = out.replace(/^# Session\n(?:(?:Cwd|Model|Workflow): [^\n]*\n)+(?:\n|$)/, '')
235
+ // 2) Leading `# Additional context\n<body>\n\n` (manager.mjs:3168). Anchored
236
+ // to start; body runs up to the next `# ` section boundary or end. The
237
+ // `\n\n` separator manager.mjs emits is included.
238
+ out = out.replace(/^# Additional context\n[\s\S]*?(?=\n# |$)/, '')
239
+ out = out.replace(/^\n+/, '')
240
+ // 3) Leading `# Prefetch\n<body>\n\n` (manager.mjs:3171). Same anchoring.
241
+ out = out.replace(/^# Prefetch\n[\s\S]*?(?=\n# |$)/, '')
242
+ out = out.replace(/^\n+/, '')
243
+ // 4) Leading `# Task\n` marker (prefixUserTurnContent: `${contextBlock}# Task\n${content}`).
244
+ // Remove ONLY the marker line; everything after it is the human prompt and
245
+ // is preserved verbatim. Anchored ^ so a `# Task` in the human's own prose
246
+ // (after real text precedes it) is never removed.
247
+ out = out.replace(/^# Task\n/, '')
248
+ return out
249
+ }
250
+
251
+ // Ingest-only message shaper: returns ONLY the human/model prose text, NEVER
252
+ // inlining tool_call / tool_result traces. For user messages, the deterministic
253
+ // manager.mjs prefix envelopes are stripped so only the human's actual prompt
254
+ // remains. The <system-reminder> block is left in place here because
255
+ // cleanMemoryText already removes it downstream (text-utils.cjs:28).
256
+ export function sessionMessageContentForIngest(m) {
257
+ const base = firstTextContent(m?.content)
258
+ if (!base) return ''
259
+ if (normalizeIngestRole(m?.role) === 'user') {
260
+ return stripUserTurnPrefixEnvelopes(base)
261
+ }
262
+ return base
263
+ }
264
+
265
+ // Row-exclusion predicate for ingest_session. Synthetic / non-conversation
266
+ // rows (reference-files injections, compaction summaries, protected-context
267
+ // `.` acks, internal runtime nudges) are dropped ENTIRELY — they are noise,
268
+ // not conversation. Mirrors the predicates in manager.mjs / compact.mjs but is
269
+ // reimplemented locally to avoid a memory→orchestrator layering dependency.
270
+ export function shouldExcludeIngestMessage(m) {
271
+ if (!m || typeof m !== 'object') return true
272
+ const role = normalizeIngestRole(m?.role)
273
+ const raw = firstTextContent(m?.content)
274
+ // `Reference files:` synthetic user rows (manager.mjs isReferenceFilesMessage).
275
+ if (role === 'user' && typeof raw === 'string' && /^Reference files:\s*/i.test(raw.trimStart())) {
276
+ return true
277
+ }
278
+ // Attachment-only placeholder rows (e.g. Discord backend discord.mjs:724
279
+ // `"(attachment)"` fallback when a message carries no text, only files).
280
+ // Carries zero retrievable content — excluded so recall isn't polluted
281
+ // with bare "(attachment)" memory rows.
282
+ if (role === 'user' && typeof raw === 'string' && raw.trim() === '(attachment)') {
283
+ return true
284
+ }
285
+ // Compaction summary user rows (compact.mjs isSummaryMessage / SUMMARY_PREFIX).
286
+ if (role === 'user' && typeof raw === 'string' && raw.startsWith(SUMMARY_PREFIX_INGEST)) {
287
+ return true
288
+ }
289
+ // Protected-context `.` ack assistant rows (compact.mjs isProtectedContextAckMessage):
290
+ // a bare `.` with no tool calls. cleanMemoryText leaves a lone `.` non-empty
291
+ // (no \p{L}\p{N}), so it would otherwise survive the empty-skip — exclude it.
292
+ if (role === 'assistant' && typeof raw === 'string' && raw.trim() === '.' && !Array.isArray(m?.toolCalls)) {
293
+ return true
294
+ }
295
+ // Internal runtime nudge `[mixdog-runtime] ...` user rows (loop.mjs:2014-2020)
296
+ // and other internal runtime notifications (tool-execution-contract).
297
+ if (role === 'user') {
298
+ const text = typeof raw === 'string' ? raw : ''
299
+ if (/^\[mixdog-runtime\]/.test(text.trimStart())) return true
300
+ if (isInternalRuntimeNotificationText(text)) return true
301
+ }
302
+ return false
303
+ }
@@ -56,7 +56,7 @@ async function init(client) {
56
56
  session_id TEXT,
57
57
  iteration INTEGER,
58
58
  kind TEXT NOT NULL,
59
- role TEXT,
59
+ agent TEXT,
60
60
  model TEXT,
61
61
  tool_name TEXT,
62
62
  tool_ms INTEGER,
@@ -103,12 +103,18 @@ async function init(client) {
103
103
  END $$
104
104
  `)
105
105
 
106
+ // Drift repair MUST run before index creation: an old cluster whose
107
+ // trace_events predates the `agent` column would otherwise die right below
108
+ // at idx_trace_agent_ts (CREATE INDEX references the missing column) before
109
+ // initAgentTables() ever gets a chance to repair it. Reviewer High fix.
110
+ await migrateSchemaDrift(client)
111
+
106
112
  // BRIN on ts — ~1000× smaller than btree for append-only timeseries; ideal
107
113
  // for time-window range scans where rows arrive in roughly ts order.
108
114
  await client.query(`CREATE INDEX IF NOT EXISTS idx_trace_ts_brin ON trace_events USING BRIN (ts) WITH (pages_per_range = 32)`)
109
115
  await client.query(`CREATE INDEX IF NOT EXISTS idx_trace_kind_ts ON trace_events(kind, ts DESC)`)
110
116
  await client.query(`CREATE INDEX IF NOT EXISTS idx_trace_session ON trace_events(session_id, ts)`)
111
- await client.query(`CREATE INDEX IF NOT EXISTS idx_trace_role_ts ON trace_events(role, ts DESC)`)
117
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_trace_agent_ts ON trace_events(agent, ts DESC)`)
112
118
  await client.query(`CREATE INDEX IF NOT EXISTS idx_trace_model_ts ON trace_events(model, ts DESC)`)
113
119
  await client.query(`CREATE INDEX IF NOT EXISTS idx_trace_tool ON trace_events(tool_name) WHERE kind = 'tool'`)
114
120
  // Span-tree and cross-schema recall↔trace correlation — partial indexes so
@@ -122,13 +128,45 @@ async function init(client) {
122
128
  await ensureCurrentAndNextMonthPartitions(client)
123
129
  }
124
130
 
131
+ // ---------------------------------------------------------------------------
132
+ // Schema-drift repair — ALTER TABLE ... ADD COLUMN IF NOT EXISTS
133
+ // ---------------------------------------------------------------------------
134
+ // Observed failure: pg.log repeatedly logs
135
+ // column "agent" of relation "trace_events" does not exist
136
+ // and the equivalent for agent_sessions — an older cluster/pgdata created
137
+ // before the `agent` column was added to these two tables, so every INSERT/
138
+ // UPSERT touching it fails forever until manually patched. CREATE TABLE IF
139
+ // NOT EXISTS above is a no-op once the table exists, so it never repairs an
140
+ // already-created table with a stale column set. Run these idempotent
141
+ // ADD COLUMN migrations on every boot (init() and initAgentTables()) so
142
+ // drifted clusters self-heal without a manual ALTER.
143
+ async function migrateSchemaDrift(client) {
144
+ // Bounded lock wait: nullable ADD COLUMN is metadata-only once the ACCESS
145
+ // EXCLUSIVE lock is held, but acquiring that lock can queue behind live
146
+ // readers/writers indefinitely and wedge boot (reviewer Medium). 5s is
147
+ // generous for a metadata change; on timeout we leave the drift in place
148
+ // (inserts keep failing as before — no worse) instead of hanging startup.
149
+ await client.query(`SET LOCAL lock_timeout = '5s'`).catch(() => {})
150
+ try {
151
+ await client.query(`ALTER TABLE IF EXISTS trace_events ADD COLUMN IF NOT EXISTS agent TEXT`)
152
+ await client.query(`ALTER TABLE IF EXISTS agent_sessions ADD COLUMN IF NOT EXISTS agent TEXT`)
153
+ } finally {
154
+ await client.query(`SET LOCAL lock_timeout = DEFAULT`).catch(() => {})
155
+ }
156
+ }
157
+
125
158
  // ---------------------------------------------------------------------------
126
159
  // Agent-specific analytic tables (added post-init via initAgentTables)
127
160
  // ---------------------------------------------------------------------------
128
161
 
129
- // Called once per openTraceDatabase boot (after the advisory lock is released).
130
- // Safe to call concurrently all DDL is IF NOT EXISTS.
162
+ // Called once per openTraceDatabase boot, inside the BOOTSTRAP advisory-lock
163
+ // scope (see openTraceDatabase) so concurrent first-boot processes don't race
164
+ // this DDL. All statements remain IF NOT EXISTS as a second line of defense.
131
165
  export async function initAgentTables(client) {
166
+ // Repair schema drift on already-created tables before anything else
167
+ // (trace_events already exists at this point via init(); agent_sessions
168
+ // is created just below in this same function).
169
+ await migrateSchemaDrift(client)
132
170
  // ── agent_calls: one row per tool invocation ─────────────────────────────
133
171
  await client.query(`
134
172
  CREATE TABLE IF NOT EXISTS agent_calls (
@@ -175,7 +213,7 @@ export async function initAgentTables(client) {
175
213
  await client.query(`
176
214
  CREATE TABLE IF NOT EXISTS agent_sessions (
177
215
  session_id TEXT PRIMARY KEY,
178
- role TEXT,
216
+ agent TEXT,
179
217
  model TEXT,
180
218
  started_at TIMESTAMPTZ,
181
219
  last_seen_at TIMESTAMPTZ,
@@ -186,6 +224,10 @@ export async function initAgentTables(client) {
186
224
  total_output_tokens BIGINT NOT NULL DEFAULT 0
187
225
  )
188
226
  `)
227
+
228
+ // Repair drift again post-creation for agent_sessions (belt-and-suspenders;
229
+ // no-op when the table was freshly created above with the column present).
230
+ await migrateSchemaDrift(client)
189
231
  }
190
232
 
191
233
  // ---------------------------------------------------------------------------
@@ -289,14 +331,14 @@ export async function insertAgentCalls(db, events) {
289
331
  // Upsert session summaries — accumulate from tool+llm rows in this batch
290
332
  const sessionMap = new Map()
291
333
  for (const r of toolRows) {
292
- const s = sessionMap.get(r.session_id) ?? { tool_calls: 0, llm_calls: 0, max_iteration: 0, total_input: 0n, total_output: 0n, ts0: r.ts, ts1: r.ts, role: null, model: null }
334
+ const s = sessionMap.get(r.session_id) ?? { tool_calls: 0, llm_calls: 0, max_iteration: 0, total_input: 0n, total_output: 0n, ts0: r.ts, ts1: r.ts, agent: null, model: null }
293
335
  s.tool_calls += 1
294
336
  if (r.iteration != null && r.iteration > s.max_iteration) s.max_iteration = r.iteration
295
337
  if (r.ts < s.ts0) s.ts0 = r.ts; if (r.ts > s.ts1) s.ts1 = r.ts
296
338
  sessionMap.set(r.session_id, s)
297
339
  }
298
340
  for (const r of llmRows) {
299
- const s = sessionMap.get(r.session_id) ?? { tool_calls: 0, llm_calls: 0, max_iteration: 0, total_input: 0n, total_output: 0n, ts0: r.ts, ts1: r.ts, role: null, model: null }
341
+ const s = sessionMap.get(r.session_id) ?? { tool_calls: 0, llm_calls: 0, max_iteration: 0, total_input: 0n, total_output: 0n, ts0: r.ts, ts1: r.ts, agent: null, model: null }
300
342
  s.llm_calls += 1
301
343
  s.total_input += BigInt(r.input_tokens ?? 0)
302
344
  s.total_output += BigInt(r.output_tokens ?? 0)
@@ -305,13 +347,13 @@ export async function insertAgentCalls(db, events) {
305
347
  if (r.ts < s.ts0) s.ts0 = r.ts; if (r.ts > s.ts1) s.ts1 = r.ts
306
348
  sessionMap.set(r.session_id, s)
307
349
  }
308
- // Also pick up role from preset_assign events in the same batch
350
+ // Also pick up agent from preset_assign events in the same batch
309
351
  for (const ev of events) {
310
- if (ev.kind === 'preset_assign' && ev.role) {
352
+ if (ev.kind === 'preset_assign' && ev.agent) {
311
353
  const sid = ev.session_id ?? ev.sessionId ?? null
312
354
  if (!sid) continue
313
355
  const s = sessionMap.get(sid)
314
- if (s) s.role = ev.role
356
+ if (s) s.agent = ev.agent
315
357
  }
316
358
  }
317
359
  // Fix 5 — upsert sessions for preset_assign-only batches (no tool/llm rows yet)
@@ -326,32 +368,32 @@ export async function insertAgentCalls(db, events) {
326
368
  tool_calls: 0, llm_calls: 0, max_iteration: 0,
327
369
  total_input: 0n, total_output: 0n,
328
370
  ts0: tsIso, ts1: tsIso,
329
- role: ev.role ?? null, model: ev.model ?? null,
371
+ agent: ev.agent ?? null, model: ev.model ?? null,
330
372
  })
331
373
  }
332
374
 
333
375
  // Coalesce agent_sessions upserts: batch all sessions in one unnest INSERT.
334
376
  // Also within the same transaction.
335
377
  if (sessionMap.size > 0) {
336
- const sids = [], roles = [], models = [], ts0s = [], ts1s = [],
378
+ const sids = [], agents = [], models = [], ts0s = [], ts1s = [],
337
379
  tcalls = [], lcalls = [], maxiters = [], tinputs = [], toutputs = []
338
380
  for (const [sid, s] of sessionMap) {
339
- sids.push(sid); roles.push(s.role); models.push(s.model)
381
+ sids.push(sid); agents.push(s.agent); models.push(s.model)
340
382
  ts0s.push(s.ts0); ts1s.push(s.ts1)
341
383
  tcalls.push(s.tool_calls); lcalls.push(s.llm_calls)
342
384
  maxiters.push(s.max_iteration)
343
385
  tinputs.push(String(s.total_input)); toutputs.push(String(s.total_output))
344
386
  }
345
387
  await client.query(`
346
- INSERT INTO agent_sessions (session_id, role, model, started_at, last_seen_at, tool_calls, llm_calls, max_iteration, total_input_tokens, total_output_tokens)
347
- SELECT u.session_id, u.role, u.model,
388
+ INSERT INTO agent_sessions (session_id, agent, model, started_at, last_seen_at, tool_calls, llm_calls, max_iteration, total_input_tokens, total_output_tokens)
389
+ SELECT u.session_id, u.agent, u.model,
348
390
  u.started_at::timestamptz, u.last_seen_at::timestamptz,
349
391
  u.tool_calls::int, u.llm_calls::int, u.max_iteration::int,
350
392
  u.total_input_tokens::bigint, u.total_output_tokens::bigint
351
393
  FROM unnest($1::text[],$2::text[],$3::text[],$4::text[],$5::text[],$6::int[],$7::int[],$8::int[],$9::text[],$10::text[])
352
- AS u(session_id,role,model,started_at,last_seen_at,tool_calls,llm_calls,max_iteration,total_input_tokens,total_output_tokens)
394
+ AS u(session_id,agent,model,started_at,last_seen_at,tool_calls,llm_calls,max_iteration,total_input_tokens,total_output_tokens)
353
395
  ON CONFLICT (session_id) DO UPDATE SET
354
- role = COALESCE(EXCLUDED.role, agent_sessions.role),
396
+ agent = COALESCE(EXCLUDED.agent, agent_sessions.agent),
355
397
  model = COALESCE(EXCLUDED.model, agent_sessions.model),
356
398
  started_at = LEAST(agent_sessions.started_at, EXCLUDED.started_at),
357
399
  last_seen_at = GREATEST(agent_sessions.last_seen_at, EXCLUDED.last_seen_at),
@@ -360,7 +402,7 @@ export async function insertAgentCalls(db, events) {
360
402
  max_iteration = GREATEST(agent_sessions.max_iteration, EXCLUDED.max_iteration),
361
403
  total_input_tokens = agent_sessions.total_input_tokens + EXCLUDED.total_input_tokens,
362
404
  total_output_tokens = agent_sessions.total_output_tokens + EXCLUDED.total_output_tokens
363
- `, [sids, roles, models, ts0s, ts1s, tcalls, lcalls, maxiters, tinputs, toutputs])
405
+ `, [sids, agents, models, ts0s, ts1s, tcalls, lcalls, maxiters, tinputs, toutputs])
364
406
  }
365
407
 
366
408
  await client.query('COMMIT')
@@ -513,14 +555,19 @@ export async function openTraceDatabase(dataDir) {
513
555
  // are pre-created for this boot.
514
556
  await ensureCurrentAndNextMonthPartitions(client)
515
557
  }
558
+ // Agent-specific analytic tables — moved inside the advisory-lock
559
+ // scope (was previously run after client.release() with no lock,
560
+ // letting concurrent first-boot processes race the agent_calls/
561
+ // agent_llm/agent_sessions CREATE TABLE + migrateSchemaDrift DDL).
562
+ // Reuses the same lock-holding client/session; still idempotent
563
+ // (IF NOT EXISTS everywhere) but no longer racy across processes.
564
+ await initAgentTables(client)
516
565
  } finally {
517
566
  await client.query(`SELECT pg_advisory_unlock(${BOOTSTRAP_LOCK_KEY})`)
518
567
  }
519
568
  } finally {
520
569
  client.release()
521
570
  }
522
- // Agent-specific analytic tables — idempotent, no advisory lock needed.
523
- await initAgentTables(db)
524
571
 
525
572
  dbs.set(key, db)
526
573
 
@@ -557,7 +604,7 @@ export async function openTraceDatabase(dataDir) {
557
604
  // ---------------------------------------------------------------------------
558
605
 
559
606
  const TRACE_COLS = [
560
- 'ts', 'session_id', 'iteration', 'kind', 'role', 'model',
607
+ 'ts', 'session_id', 'iteration', 'kind', 'agent', 'model',
561
608
  'tool_name', 'tool_ms', 'input_tokens', 'output_tokens',
562
609
  'cached_tokens', 'cache_write_tokens', 'duration_ms',
563
610
  'error_message', 'payload', 'parent_span_id', 'entry_id',
@@ -755,7 +802,7 @@ export async function insertTraceEvents(db, events) {
755
802
  ev.session_id ?? null,
756
803
  ev.iteration != null ? Number(ev.iteration) : null,
757
804
  String(ev.kind ?? 'unknown'),
758
- ev.role ?? null,
805
+ ev.agent ?? null,
759
806
  ev.model ?? null,
760
807
  ev.tool_name ?? null,
761
808
  ev.tool_ms != null ? Number(ev.tool_ms) : null,