mixdog 0.9.50 → 0.9.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/scripts/abort-recovery-test.mjs +17 -1
- package/scripts/agent-model-liveness-test.mjs +79 -2
- package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
- package/scripts/anthropic-transport-policy-test.mjs +466 -0
- package/scripts/atomic-lock-tryonce-test.mjs +60 -1
- package/scripts/build-tui.mjs +13 -1
- package/scripts/channel-daemon-smoke.mjs +630 -10
- package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
- package/scripts/code-graph-disk-hit-test.mjs +11 -0
- package/scripts/code-graph-root-federation-test.mjs +273 -0
- package/scripts/compact-pressure-test.mjs +55 -1
- package/scripts/compact-smoke.mjs +80 -0
- package/scripts/context-mcp-metering-test.mjs +1350 -19
- package/scripts/deferred-tool-loading-test.mjs +17 -0
- package/scripts/gemini-provider-test.mjs +1053 -0
- package/scripts/hook-bus-test.mjs +23 -0
- package/scripts/internal-tools-normalization-test.mjs +10 -0
- package/scripts/interrupted-turn-history-test.mjs +371 -0
- package/scripts/lifecycle-api-test.mjs +76 -0
- package/scripts/max-output-recovery-test.mjs +55 -0
- package/scripts/mcp-grace-deferred-test.mjs +89 -13
- package/scripts/memory-pg-recovery-test.mjs +59 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
- package/scripts/process-lifecycle-test.mjs +389 -0
- package/scripts/provider-admission-scheduler-test.mjs +582 -0
- package/scripts/provider-contract-test.mjs +268 -0
- package/scripts/provider-toolcall-test.mjs +520 -3
- package/scripts/reactive-compact-persist-smoke.mjs +59 -0
- package/scripts/resource-admission-test.mjs +789 -0
- package/scripts/session-bench-cache-break-test.mjs +102 -0
- package/scripts/session-bench.mjs +101 -42
- package/scripts/shell-failure-diagnostics-test.mjs +73 -4
- package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
- package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
- package/scripts/smoke-loop-failure-summary.mjs +16 -0
- package/scripts/smoke-loop.mjs +2 -7
- package/scripts/steering-drain-buckets-test.mjs +18 -0
- package/scripts/tool-failures.mjs +15 -1
- package/scripts/toolcall-args-test.mjs +14 -6
- package/scripts/tui-transcript-perf-test.mjs +43 -7
- package/scripts/web-fetch-routing-test.mjs +158 -0
- package/src/cli.mjs +15 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
- package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
- package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
- package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
- package/src/runtime/memory/index.mjs +22 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
- package/src/runtime/memory/lib/pg/process.mjs +91 -47
- package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
- package/src/runtime/search/index.mjs +41 -0
- package/src/runtime/search/lib/http-fetch.mjs +154 -0
- package/src/runtime/search/tool-defs.mjs +23 -0
- package/src/runtime/shared/atomic-file.mjs +28 -150
- package/src/runtime/shared/process-lifecycle.mjs +363 -0
- package/src/runtime/shared/process-shutdown.mjs +27 -4
- package/src/runtime/shared/resource-admission.mjs +359 -0
- package/src/runtime/shared/staged-child-result.mjs +19 -0
- package/src/session-runtime/context-status.mjs +38 -27
- package/src/session-runtime/lifecycle-api.mjs +26 -6
- package/src/session-runtime/provider-request-tools.mjs +72 -0
- package/src/session-runtime/runtime-core.mjs +43 -18
- package/src/session-runtime/session-turn-api.mjs +5 -4
- package/src/session-runtime/tool-catalog.mjs +375 -15
- package/src/standalone/agent-tool.mjs +17 -38
- package/src/standalone/channel-daemon-client.mjs +200 -38
- package/src/standalone/channel-daemon-transport.mjs +136 -9
- package/src/standalone/channel-worker.mjs +79 -12
- package/src/standalone/hook-bus/handlers.mjs +4 -0
- package/src/standalone/hook-bus/rules.mjs +7 -1
- package/src/standalone/hook-bus.mjs +10 -2
- package/src/tui/App.jsx +17 -11
- package/src/tui/app/live-spinner-visibility.mjs +20 -0
- package/src/tui/dist/index.mjs +101 -281
- package/src/tui/engine/session-api-ext.mjs +13 -2
- package/src/tui/engine/session-api.mjs +1 -1
- package/src/tui/engine/turn.mjs +10 -6
- package/src/tui/engine.mjs +13 -4
- package/src/tui/index.jsx +4 -1
- package/src/tui/lib/voice-setup.mjs +16 -0
|
@@ -55,6 +55,51 @@ function isTcpPortFree(port) {
|
|
|
55
55
|
|
|
56
56
|
const PG_PORT_MIN = 55432
|
|
57
57
|
const PG_PORT_MAX = 55632
|
|
58
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
59
|
+
|
|
60
|
+
function isPidAlive(pid) {
|
|
61
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
62
|
+
try {
|
|
63
|
+
process.kill(pid, 0)
|
|
64
|
+
return true
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return err?.code === 'EPERM'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readPostmasterInfo(pgdataDir) {
|
|
71
|
+
try {
|
|
72
|
+
const lines = readFileSync(join(pgdataDir, 'postmaster.pid'), 'utf8').split('\n')
|
|
73
|
+
const pid = parseInt(lines[0], 10)
|
|
74
|
+
const port = parseInt(lines[3], 10)
|
|
75
|
+
return {
|
|
76
|
+
pid: Number.isInteger(pid) && pid > 0 ? pid : null,
|
|
77
|
+
port: Number.isInteger(port) && port > 0 ? port : null,
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
return { pid: null, port: null }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pgIsReady(runtimeDir, env, port) {
|
|
85
|
+
const probe = spawnSync(pgBin(runtimeDir, 'pg_isready'), ['-h', '127.0.0.1', '-p', String(port)], {
|
|
86
|
+
env, stdio: 'pipe', timeout: 3_000, windowsHide: true,
|
|
87
|
+
})
|
|
88
|
+
return probe.status === 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function awaitExistingPostmaster({ runtimeDir, pgdataDir, env, waitMs }) {
|
|
92
|
+
const deadline = Date.now() + Math.max(0, Number(waitMs) || 0)
|
|
93
|
+
let info = readPostmasterInfo(pgdataDir)
|
|
94
|
+
while (info.pid && info.port) {
|
|
95
|
+
if (pgIsReady(runtimeDir, env, info.port)) return { state: 'ready', ...info }
|
|
96
|
+
if (!isPidAlive(info.pid)) return { state: 'dead', ...info }
|
|
97
|
+
if (Date.now() >= deadline) return { state: 'alive-not-ready', ...info }
|
|
98
|
+
await delay(250)
|
|
99
|
+
info = readPostmasterInfo(pgdataDir)
|
|
100
|
+
}
|
|
101
|
+
return { state: 'missing', ...info }
|
|
102
|
+
}
|
|
58
103
|
|
|
59
104
|
async function findFreePort(preferred) {
|
|
60
105
|
// I2: clamp out-of-range callers to the valid window.
|
|
@@ -135,7 +180,13 @@ export function reconcileConfV2(runtimeDir, pgdataDir) {
|
|
|
135
180
|
// startPg
|
|
136
181
|
// ---------------------------------------------------------------------------
|
|
137
182
|
|
|
138
|
-
export async function startPg({
|
|
183
|
+
export async function startPg({
|
|
184
|
+
runtimeDir,
|
|
185
|
+
pgdataDir,
|
|
186
|
+
port: preferredPort = 55432,
|
|
187
|
+
logPath,
|
|
188
|
+
existingWaitMs = 15_000,
|
|
189
|
+
}) {
|
|
139
190
|
mkdirSync(pgdataDir, { recursive: true })
|
|
140
191
|
|
|
141
192
|
if (process.platform === 'darwin') {
|
|
@@ -146,41 +197,35 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
|
|
|
146
197
|
// the block was just appended (so the attach path can trigger pg_ctl reload).
|
|
147
198
|
// Fresh-init path falls through to confAppend below which already includes v2.
|
|
148
199
|
const v2Applied = ensureConfV2(pgdataDir)
|
|
200
|
+
const env = libEnv(runtimeDir)
|
|
149
201
|
|
|
150
202
|
// Pre-check: if postmaster.pid exists and the instance is reachable, attach
|
|
151
203
|
// rather than attempting a second pg_ctl start (which would crash the worker).
|
|
152
204
|
const postmasterPidPath = join(pgdataDir, 'postmaster.pid')
|
|
153
205
|
if (existsSync(postmasterPidPath)) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (v2Applied) reconcileConfV2(runtimeDir, pgdataDir)
|
|
176
|
-
return { pid: existingPid, port: existingPort, attached: true }
|
|
177
|
-
}
|
|
178
|
-
// Dead instance — fall through to normal startup; pg_ctl will reclaim stale lock.
|
|
179
|
-
}
|
|
180
|
-
} catch {}
|
|
206
|
+
const existing = await awaitExistingPostmaster({
|
|
207
|
+
runtimeDir, pgdataDir, env, waitMs: existingWaitMs,
|
|
208
|
+
})
|
|
209
|
+
if (existing.state === 'ready') {
|
|
210
|
+
__mixdogMemoryLog(`[pg-process] attaching to existing PG pid=${existing.pid} port=${existing.port}\n`)
|
|
211
|
+
// Route through the single reconcile entry point so v1 → v2 conf
|
|
212
|
+
// upgrades land on already-running instances without restart.
|
|
213
|
+
if (v2Applied) reconcileConfV2(runtimeDir, pgdataDir)
|
|
214
|
+
return { pid: existing.pid, port: existing.port, attached: true }
|
|
215
|
+
}
|
|
216
|
+
if (existing.state === 'alive-not-ready') {
|
|
217
|
+
// A postmaster in startup/shutdown still owns this pgdata even when it no
|
|
218
|
+
// longer accepts connections. Starting the same directory on a "free"
|
|
219
|
+
// alternate port produces the observed endless "another server might be
|
|
220
|
+
// running" loop and can corrupt lifecycle state. The supervisor may
|
|
221
|
+
// finish a graceful stop, but this lower layer must never race it.
|
|
222
|
+
throw new Error(
|
|
223
|
+
`[pg-process] existing PG pid=${existing.pid} port=${existing.port} is alive but not ready; refusing concurrent start`,
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
// Dead instance — fall through to normal startup; pg_ctl reclaims the stale lock.
|
|
181
227
|
}
|
|
182
228
|
|
|
183
|
-
const env = libEnv(runtimeDir)
|
|
184
229
|
const initdb = pgBin(runtimeDir, 'initdb')
|
|
185
230
|
const pgctl = pgBin(runtimeDir, 'pg_ctl')
|
|
186
231
|
|
|
@@ -248,7 +293,6 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
|
|
|
248
293
|
|
|
249
294
|
__mixdogMemoryLog(`[pg-process] pg_ctl start -D ${pgdataDir} -p ${port}\n`)
|
|
250
295
|
|
|
251
|
-
const pgIsReady = pgBin(runtimeDir, 'pg_isready')
|
|
252
296
|
const startArgs = [
|
|
253
297
|
'start',
|
|
254
298
|
'-D', pgdataDir,
|
|
@@ -258,8 +302,6 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
|
|
|
258
302
|
|
|
259
303
|
// Poll-sleep is intentionally NOT unref'd: while startPg is awaiting readiness
|
|
260
304
|
// it must keep the process alive even if the event loop would otherwise drain.
|
|
261
|
-
const sleep = ms => new Promise(res => setTimeout(res, ms))
|
|
262
|
-
|
|
263
305
|
// Read pid + port from postmaster.pid (line 1 = pid, line 4 = port). Returns
|
|
264
306
|
// null unless the file exists, pid > 0, and its port matches ours.
|
|
265
307
|
function readPostmaster() {
|
|
@@ -280,7 +322,7 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
|
|
|
280
322
|
for (let i = 0; i < 5; i++) {
|
|
281
323
|
const pm = readPostmaster()
|
|
282
324
|
if (pm) return pm.pid
|
|
283
|
-
await
|
|
325
|
+
await delay(100)
|
|
284
326
|
}
|
|
285
327
|
return null
|
|
286
328
|
}
|
|
@@ -312,10 +354,7 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
|
|
|
312
354
|
const deadline = Date.now() + 30_000
|
|
313
355
|
// eslint-disable-next-line no-constant-condition
|
|
314
356
|
while (true) {
|
|
315
|
-
|
|
316
|
-
env, stdio: 'pipe', timeout: 3_000, windowsHide: true,
|
|
317
|
-
})
|
|
318
|
-
if (probe.status === 0) {
|
|
357
|
+
if (pgIsReady(runtimeDir, env, port)) {
|
|
319
358
|
const pid = await confirmPid()
|
|
320
359
|
// pid confirmed with matching port → ready. Otherwise keep polling until
|
|
321
360
|
// the cap (postmaster.pid not yet written or port mismatch).
|
|
@@ -324,7 +363,7 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
|
|
|
324
363
|
// pg_ctl exited nonzero before PG became reachable — real startup failure.
|
|
325
364
|
if (closed && exitCode !== 0) return { ready: false, exited: true, stdout, stderr }
|
|
326
365
|
if (Date.now() >= deadline) return { ready: false, timeout: true, stdout, stderr }
|
|
327
|
-
await
|
|
366
|
+
await delay(250)
|
|
328
367
|
}
|
|
329
368
|
}
|
|
330
369
|
|
|
@@ -332,19 +371,19 @@ export async function startPg({ runtimeDir, pgdataDir, port: preferredPort = 554
|
|
|
332
371
|
if (r.ready) return { pid: r.pid, port }
|
|
333
372
|
|
|
334
373
|
const errText = r.stderr || r.stdout || ''
|
|
335
|
-
//
|
|
374
|
+
// A cross-process race can still land after the pre-check. Re-probe and
|
|
375
|
+
// attach if that winner becomes ready; never immediate-stop an unknown live
|
|
376
|
+
// postmaster, because synchronous_commit=off makes a crash-stop lossy.
|
|
336
377
|
if (r.exited && errText.includes('another server might be running')) {
|
|
337
378
|
__mixdogMemoryLog(`[pg-process] pg_ctl start: "another server might be running" — probing status\n`)
|
|
338
379
|
const statusR = spawnSync(pgctl, ['status', '-D', pgdataDir], { env, stdio: 'pipe', timeout: 3_000, windowsHide: true })
|
|
339
380
|
__mixdogMemoryLog(`[pg-process] pg_ctl status: ${statusR.stdout?.toString() || statusR.stderr?.toString() || 'no output'}\n`)
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
} else {
|
|
347
|
-
__mixdogMemoryLog(`[pg-process] immediate stop failed: ${stopR.stderr?.toString() || ''} — treating as degraded\n`)
|
|
381
|
+
const existing = await awaitExistingPostmaster({
|
|
382
|
+
runtimeDir, pgdataDir, env, waitMs: existingWaitMs,
|
|
383
|
+
})
|
|
384
|
+
if (existing.state === 'ready') {
|
|
385
|
+
__mixdogMemoryLog(`[pg-process] attaching to race winner pid=${existing.pid} port=${existing.port}\n`)
|
|
386
|
+
return { pid: existing.pid, port: existing.port, attached: true }
|
|
348
387
|
}
|
|
349
388
|
}
|
|
350
389
|
|
|
@@ -419,6 +458,11 @@ export async function healthcheckPg({ port, host = '127.0.0.1' }) {
|
|
|
419
458
|
host, port, user: 'postgres', database: 'postgres', password: '',
|
|
420
459
|
connectionTimeoutMillis: 2000,
|
|
421
460
|
})
|
|
461
|
+
// A socket can terminate after SELECT 1 resolves but before client.end()
|
|
462
|
+
// finishes. Raw pg.Client instances have no pool-level error listener, so
|
|
463
|
+
// keep this transient health probe from turning that narrow race into an
|
|
464
|
+
// uncaught EventEmitter error.
|
|
465
|
+
client.on('error', () => {})
|
|
422
466
|
await client.connect()
|
|
423
467
|
await client.query('SELECT 1')
|
|
424
468
|
return true
|
|
@@ -399,6 +399,18 @@ async function isPostmasterAlive(pid) {
|
|
|
399
399
|
return isPostgresPid(pid);
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
+
async function waitForPostmasterReady({ pid, port, healthcheckPg, timeoutMs = 30_000 }) {
|
|
403
|
+
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
|
|
404
|
+
while (isPidAlive(pid)) {
|
|
405
|
+
try {
|
|
406
|
+
if (await healthcheckPg({ port })) return 'ready';
|
|
407
|
+
} catch {}
|
|
408
|
+
if (Date.now() >= deadline) return 'alive-not-ready';
|
|
409
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
410
|
+
}
|
|
411
|
+
return 'dead';
|
|
412
|
+
}
|
|
413
|
+
|
|
402
414
|
// ── Internal: spawn a fresh PG instance ─────────────────────────────────────
|
|
403
415
|
|
|
404
416
|
async function _startFresh(dataDir, pgdata, port, runtimeDir) {
|
|
@@ -473,7 +485,7 @@ async function tryReusePgInstance({ pgdata, runtimeDir, healthcheckPg, source =
|
|
|
473
485
|
// ── Internal: full ensure logic (runs exclusively via _ensureInFlight) ────────
|
|
474
486
|
|
|
475
487
|
async function _doEnsure(dataDir) {
|
|
476
|
-
const { healthcheckPg
|
|
488
|
+
const { healthcheckPg } = await _getPgProc();
|
|
477
489
|
const pgdata = join(dataDir, 'pgdata');
|
|
478
490
|
|
|
479
491
|
// Resolve runtimeDir via runtime-fetcher (cache-hits immediately when already downloaded).
|
|
@@ -563,22 +575,47 @@ async function _doEnsure(dataDir) {
|
|
|
563
575
|
__mixdogMemoryLog(
|
|
564
576
|
`[supervisor-pg] pg_port=${existingPort} recorded but healthcheck failed — recovering\n`,
|
|
565
577
|
);
|
|
566
|
-
const pmPid = readPostmasterPid(pgdata);
|
|
567
|
-
if (pmPid && await isPostmasterAlive(pmPid)) {
|
|
568
|
-
// postmaster alive but unhealthy: attempt graceful stop first
|
|
569
|
-
__mixdogMemoryLog(`[supervisor-pg] postmaster PID ${pmPid} alive — attempting graceful stopPg\n`);
|
|
570
|
-
try { await stopPg({ runtimeDir, pgdataDir: pgdata }); } catch (e) {
|
|
571
|
-
__mixdogMemoryLog(`[supervisor-pg] graceful stopPg failed: ${e?.message} — continuing to fresh start\n`);
|
|
572
|
-
}
|
|
573
|
-
} else if (pmPid) {
|
|
574
|
-
// postmaster dead: remove stale postmaster.pid so initdb/start is not blocked
|
|
575
|
-
__mixdogMemoryLog(`[supervisor-pg] postmaster PID ${pmPid} dead — removing stale postmaster.pid\n`);
|
|
576
|
-
try { unlinkSync(join(pgdata, 'postmaster.pid')); } catch {}
|
|
577
|
-
}
|
|
578
578
|
// Clear stale pg fields before restart
|
|
579
579
|
patchActiveInstance({ pg_port: null, pg_started_at: null, pg_pgdata: null }, { timeoutMs: 1000, background: true });
|
|
580
580
|
}
|
|
581
581
|
|
|
582
|
+
// postmaster.pid is the authoritative pgdata owner even when the discovery
|
|
583
|
+
// advert is absent/stale. The old path checked it only inside the
|
|
584
|
+
// `existingPort` branch, then allocated 55433 and tried to start the SAME
|
|
585
|
+
// pgdata while a recovering/rejecting server still owned 55432. Give crash
|
|
586
|
+
// recovery a bounded chance to finish and attach if it does. Never issue a
|
|
587
|
+
// stop from an ensure path: an alive postmaster owns the data directory,
|
|
588
|
+
// and interrupting recovery can turn a transient outage into a restart loop.
|
|
589
|
+
const pm = readPostmasterInfo(pgdata);
|
|
590
|
+
if (pm.pid && pm.port && await isPostmasterAlive(pm.pid)) {
|
|
591
|
+
__mixdogMemoryLog(`[supervisor-pg] postmaster PID ${pm.pid} alive but not ready — awaiting recovery\n`);
|
|
592
|
+
const state = await waitForPostmasterReady({
|
|
593
|
+
pid: pm.pid,
|
|
594
|
+
port: pm.port,
|
|
595
|
+
healthcheckPg,
|
|
596
|
+
});
|
|
597
|
+
if (state === 'ready') {
|
|
598
|
+
__mixdogMemoryLog(`[supervisor-pg] attaching to recovered PG pid=${pm.pid} port=${pm.port}\n`);
|
|
599
|
+
_live = { port: pm.port, pgdata, runtimeDir, proc: null };
|
|
600
|
+
patchActiveInstance({
|
|
601
|
+
pg_port: pm.port,
|
|
602
|
+
pg_started_at: ai?.pg_started_at ?? Date.now(),
|
|
603
|
+
pg_pgdata: pgdata,
|
|
604
|
+
pg_runtime_dir: runtimeDir,
|
|
605
|
+
}, { timeoutMs: 1000, background: true });
|
|
606
|
+
return { host: '127.0.0.1', port: pm.port, runtimeDir, pgdataDir: pgdata };
|
|
607
|
+
}
|
|
608
|
+
if (state === 'alive-not-ready') {
|
|
609
|
+
throw new Error(
|
|
610
|
+
`[supervisor-pg] existing postmaster PID ${pm.pid} remains alive but unhealthy; refusing concurrent start`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (pm.pid) {
|
|
615
|
+
__mixdogMemoryLog(`[supervisor-pg] postmaster PID ${pm.pid} dead — removing stale postmaster.pid\n`);
|
|
616
|
+
try { unlinkSync(join(pgdata, 'postmaster.pid')); } catch {}
|
|
617
|
+
}
|
|
618
|
+
|
|
582
619
|
// ── Allocate a fresh port and spawn ───────────────────────────────────
|
|
583
620
|
const port = await allocatePort();
|
|
584
621
|
return await _startFresh(dataDir, pgdata, port, runtimeDir);
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
updateProviderState,
|
|
33
33
|
} from './lib/state.mjs'
|
|
34
34
|
import { getScrapeCapabilities, scrapeUrls } from './lib/web-tools.mjs'
|
|
35
|
+
import { fetchLoopbackText, fetchPublicImage } from './lib/http-fetch.mjs'
|
|
35
36
|
import { formatResponse } from './lib/formatter.mjs'
|
|
36
37
|
ensureDataDir()
|
|
37
38
|
|
|
@@ -538,6 +539,46 @@ async function handleToolCall(name, rawArgs, options = {}) {
|
|
|
538
539
|
return { content: [{ type: 'text', text: `Fetch failed: ${_cleanErr}` }], isError: true }
|
|
539
540
|
}
|
|
540
541
|
}
|
|
542
|
+
case 'local_fetch':
|
|
543
|
+
case 'image_fetch': {
|
|
544
|
+
let urlArgs
|
|
545
|
+
try {
|
|
546
|
+
urlArgs = searchUrlArgsSchema.parse(normalizeSearchUrlArgs(rawArgs || {}))
|
|
547
|
+
} catch (e) {
|
|
548
|
+
if (e instanceof z.ZodError) {
|
|
549
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'Invalid arguments', details: e.errors }) }], isError: true }
|
|
550
|
+
}
|
|
551
|
+
throw e
|
|
552
|
+
}
|
|
553
|
+
const urls = Array.isArray(urlArgs.url) ? urlArgs.url : [urlArgs.url]
|
|
554
|
+
if (urls.length > 8) return { content: [{ type: 'text', text: 'Error: fetch batch exceeds maximum of 8 URLs.' }], isError: true }
|
|
555
|
+
const fetchSignal = signal
|
|
556
|
+
? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
|
|
557
|
+
: AbortSignal.timeout(timeoutMs)
|
|
558
|
+
try {
|
|
559
|
+
if (name === 'local_fetch') {
|
|
560
|
+
const parts = []
|
|
561
|
+
for (const url of urls) {
|
|
562
|
+
const text = await fetchLoopbackText(url, { signal: fetchSignal })
|
|
563
|
+
const start = urlArgs.startIndex || 0
|
|
564
|
+
const max = urlArgs.maxLength == null ? 50_000 : urlArgs.maxLength
|
|
565
|
+
const body = max === 0 ? text.slice(start) : text.slice(start, start + max)
|
|
566
|
+
parts.push({ type: 'text', text: `${url}\n\n${body}` })
|
|
567
|
+
}
|
|
568
|
+
return { content: parts }
|
|
569
|
+
}
|
|
570
|
+
const parts = []
|
|
571
|
+
for (const url of urls) {
|
|
572
|
+
const image = await fetchPublicImage(url, { signal: fetchSignal })
|
|
573
|
+
parts.push({ type: 'text', text: `Downloaded image: ${url} (${image.mimeType}, ${image.bytes} bytes)` })
|
|
574
|
+
parts.push({ type: 'image', source: { type: 'base64', media_type: image.mimeType, data: image.data } })
|
|
575
|
+
}
|
|
576
|
+
return { content: parts }
|
|
577
|
+
} catch (error) {
|
|
578
|
+
const message = presentErrorText(normalizeErrorMessage(error instanceof Error ? error.message : String(error)), { surface: name })
|
|
579
|
+
return { content: [{ type: 'text', text: `Fetch failed: ${message}` }], isError: true }
|
|
580
|
+
}
|
|
581
|
+
}
|
|
541
582
|
default:
|
|
542
583
|
throw new Error(`Unknown tool: ${name}`)
|
|
543
584
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'fs'
|
|
2
|
+
import dns from 'node:dns'
|
|
3
|
+
import { Agent, fetch as undiciFetch } from 'undici'
|
|
2
4
|
|
|
3
5
|
import {
|
|
4
6
|
assertPublicUrl,
|
|
@@ -128,6 +130,158 @@ export async function readBodyBytesWithCap(response, maxBytes) {
|
|
|
128
130
|
return Buffer.concat(chunks.map((c) => Buffer.from(c)))
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
const SAFE_IMAGE_MIMES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp'])
|
|
134
|
+
|
|
135
|
+
function loopbackHost(hostname) {
|
|
136
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
137
|
+
if (host === 'localhost' || host === '::1') return true
|
|
138
|
+
const match = host.match(/^(\d{1,3})(?:\.(\d{1,3})){3}$/)
|
|
139
|
+
return Boolean(match && Number(match[1]) === 127)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function abortRace(promise, signal) {
|
|
143
|
+
if (!signal) return promise
|
|
144
|
+
if (signal.aborted) return Promise.reject(signal.reason || new Error('local_fetch aborted'))
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const onAbort = () => reject(signal.reason || new Error('local_fetch aborted'))
|
|
147
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
148
|
+
promise.then(
|
|
149
|
+
(value) => { signal.removeEventListener('abort', onAbort); resolve(value) },
|
|
150
|
+
(error) => { signal.removeEventListener('abort', onAbort); reject(error) },
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function pinnedLoopbackFetch(url, options = {}) {
|
|
156
|
+
const parsed = assertLoopbackUrl(url)
|
|
157
|
+
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
158
|
+
const addresses = host === 'localhost'
|
|
159
|
+
? await abortRace(dns.promises.lookup(host, { all: true }), options.signal)
|
|
160
|
+
: [{ address: host, family: host.includes(':') ? 6 : 4 }]
|
|
161
|
+
if (!addresses.length || addresses.some((entry) => !loopbackHost(entry.address))) {
|
|
162
|
+
throw new Error(`Blocked non-loopback local_fetch resolution: ${host}`)
|
|
163
|
+
}
|
|
164
|
+
const pinned = addresses[0]
|
|
165
|
+
const dispatcher = new Agent({
|
|
166
|
+
connect: {
|
|
167
|
+
lookup: (_hostname, opts, cb) => opts?.all
|
|
168
|
+
? cb(null, [{ address: pinned.address, family: pinned.family }])
|
|
169
|
+
: cb(null, pinned.address, pinned.family),
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
let response
|
|
173
|
+
try {
|
|
174
|
+
response = await undiciFetch(url, { ...options, dispatcher })
|
|
175
|
+
} catch (error) {
|
|
176
|
+
dispatcher.destroy().catch(() => {})
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
if (!response.body) {
|
|
180
|
+
dispatcher.destroy().catch(() => {})
|
|
181
|
+
return response
|
|
182
|
+
}
|
|
183
|
+
const reader = response.body.getReader()
|
|
184
|
+
let cleaned = false
|
|
185
|
+
const cleanup = () => {
|
|
186
|
+
if (cleaned) return
|
|
187
|
+
cleaned = true
|
|
188
|
+
dispatcher.destroy().catch(() => {})
|
|
189
|
+
}
|
|
190
|
+
const monitored = new ReadableStream({
|
|
191
|
+
async pull(controller) {
|
|
192
|
+
try {
|
|
193
|
+
const { done, value } = await reader.read()
|
|
194
|
+
if (done) {
|
|
195
|
+
controller.close()
|
|
196
|
+
cleanup()
|
|
197
|
+
} else {
|
|
198
|
+
controller.enqueue(value)
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
controller.error(error)
|
|
202
|
+
cleanup()
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
cancel(reason) {
|
|
206
|
+
reader.cancel(reason).catch(() => {})
|
|
207
|
+
cleanup()
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
return new Response(monitored, {
|
|
211
|
+
status: response.status,
|
|
212
|
+
statusText: response.statusText,
|
|
213
|
+
headers: response.headers,
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function assertLoopbackUrl(url) {
|
|
218
|
+
const parsed = new URL(url)
|
|
219
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
220
|
+
throw new Error(`Blocked non-HTTP protocol: ${parsed.protocol}`)
|
|
221
|
+
}
|
|
222
|
+
if (parsed.username || parsed.password) throw new Error('Blocked loopback URL with userinfo credentials')
|
|
223
|
+
if (!loopbackHost(parsed.hostname)) throw new Error(`Blocked non-loopback local_fetch target: ${parsed.hostname}`)
|
|
224
|
+
return parsed
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function boundedManualFetch(url, { signal, fetchImpl, validateUrl, sameHost = false } = {}) {
|
|
228
|
+
const original = validateUrl(url)
|
|
229
|
+
let currentUrl = original.toString()
|
|
230
|
+
for (let hops = 0; ; hops++) {
|
|
231
|
+
const current = validateUrl(currentUrl)
|
|
232
|
+
if (sameHost && current.hostname.toLowerCase() !== original.hostname.toLowerCase()) {
|
|
233
|
+
throw new Error(`cross-host redirect blocked (redirected_to: ${currentUrl})`)
|
|
234
|
+
}
|
|
235
|
+
const response = await fetchImpl(currentUrl, {
|
|
236
|
+
signal,
|
|
237
|
+
headers: buildHeaders(),
|
|
238
|
+
redirect: 'manual',
|
|
239
|
+
})
|
|
240
|
+
if (!REDIRECT_STATUSES.has(response.status)) return response
|
|
241
|
+
try { await response.body?.cancel() } catch {}
|
|
242
|
+
if (hops >= MAX_REDIRECTS) throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`)
|
|
243
|
+
const location = response.headers.get('location')
|
|
244
|
+
if (!location) throw new Error(`Redirect ${response.status} without Location header`)
|
|
245
|
+
currentUrl = new URL(location, currentUrl).toString()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export async function fetchLoopbackText(url, { signal, fetchImpl = pinnedLoopbackFetch } = {}) {
|
|
250
|
+
const response = await boundedManualFetch(url, {
|
|
251
|
+
signal,
|
|
252
|
+
fetchImpl,
|
|
253
|
+
validateUrl: assertLoopbackUrl,
|
|
254
|
+
})
|
|
255
|
+
if (!response.ok) {
|
|
256
|
+
try { await response.body?.cancel() } catch {}
|
|
257
|
+
throw new Error(`HTTP ${response.status}`)
|
|
258
|
+
}
|
|
259
|
+
return readBodyWithCap(response, MAX_BODY_BYTES)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function fetchPublicImage(url, { signal, fetchImpl = pinnedFetch } = {}) {
|
|
263
|
+
const response = await boundedManualFetch(url, {
|
|
264
|
+
signal,
|
|
265
|
+
fetchImpl,
|
|
266
|
+
validateUrl: (value) => {
|
|
267
|
+
assertPublicUrl(value)
|
|
268
|
+
return new URL(value)
|
|
269
|
+
},
|
|
270
|
+
sameHost: true,
|
|
271
|
+
})
|
|
272
|
+
if (!response.ok) {
|
|
273
|
+
try { await response.body?.cancel() } catch {}
|
|
274
|
+
throw new Error(`HTTP ${response.status}`)
|
|
275
|
+
}
|
|
276
|
+
const mimeType = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
|
277
|
+
if (!SAFE_IMAGE_MIMES.has(mimeType)) {
|
|
278
|
+
try { await response.body?.cancel() } catch {}
|
|
279
|
+
throw new Error(`Blocked unsupported image content-type: ${mimeType || '(missing)'}`)
|
|
280
|
+
}
|
|
281
|
+
const bytes = await readBodyBytesWithCap(response, MAX_BODY_BYTES)
|
|
282
|
+
return { mimeType, data: bytes.toString('base64'), bytes: bytes.byteLength }
|
|
283
|
+
}
|
|
284
|
+
|
|
131
285
|
const CDP_FORBIDDEN_RESPONSE_HEADERS = new Set([
|
|
132
286
|
'content-length',
|
|
133
287
|
'transfer-encoding',
|
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
TOOL_SYNC_EXECUTION_CONTRACT,
|
|
3
3
|
} from '../shared/tool-execution-contract.mjs';
|
|
4
4
|
|
|
5
|
+
const TOOL_DEFS_PLACEHOLDER = Symbol('web-fetch-schema')
|
|
6
|
+
|
|
5
7
|
export const TOOL_DEFS = [
|
|
6
8
|
{
|
|
7
9
|
name: 'search',
|
|
@@ -54,4 +56,25 @@ export const TOOL_DEFS = [
|
|
|
54
56
|
},
|
|
55
57
|
annotations: { title: 'Mixdog Web Fetch', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
56
58
|
},
|
|
59
|
+
{
|
|
60
|
+
name: 'local_fetch',
|
|
61
|
+
title: 'Mixdog Loopback Fetch',
|
|
62
|
+
public: false,
|
|
63
|
+
description: 'Runtime-only loopback HTTP(S) fetch target.',
|
|
64
|
+
inputSchema: TOOL_DEFS_PLACEHOLDER,
|
|
65
|
+
annotations: { title: 'Mixdog Loopback Fetch', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'image_fetch',
|
|
69
|
+
title: 'Mixdog Image Fetch',
|
|
70
|
+
public: false,
|
|
71
|
+
description: 'Runtime-only bounded public image fetch target.',
|
|
72
|
+
inputSchema: TOOL_DEFS_PLACEHOLDER,
|
|
73
|
+
annotations: { title: 'Mixdog Image Fetch', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
74
|
+
},
|
|
57
75
|
]
|
|
76
|
+
|
|
77
|
+
const webFetchSchema = TOOL_DEFS.find((tool) => tool.name === 'web_fetch').inputSchema
|
|
78
|
+
for (const tool of TOOL_DEFS) {
|
|
79
|
+
if (tool.inputSchema === TOOL_DEFS_PLACEHOLDER) tool.inputSchema = webFetchSchema
|
|
80
|
+
}
|