mixdog 0.9.147 → 0.9.148
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 +23 -3
- package/scripts/build-voice-runtime-unix.sh +101 -0
- package/scripts/embedding-quality-probe.mjs +96 -0
- package/scripts/generate-voice-runtime-manifest.mjs +300 -0
- package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
- package/scripts/native-binary-arch.mjs +30 -13
- package/scripts/native-binary-arch.test.mjs +40 -4
- package/scripts/plan-voice-runtime-release.mjs +73 -0
- package/scripts/prepare-native-assets.mjs +50 -9
- package/scripts/prune-embedding-runtime.mjs +3 -3
- package/scripts/sync-voice-runtime-manifest.mjs +46 -0
- package/scripts/verify-release-assets.mjs +106 -6
- package/scripts/verify-voice-runtime-release.mjs +89 -0
- package/scripts/voice-runtime-config.json +52 -0
- package/src/app.mjs +42 -5
- package/src/headless-exec.mjs +58 -32
- package/src/headless-exec.test.mjs +74 -13
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/10-tool-workflow.md +7 -4
- package/src/rules/shared/30-exploration.md +2 -1
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
- package/src/runtime/agent/orchestrator/config.mjs +53 -18
- package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
- package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
- package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
- package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
- package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +17 -11
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +20 -7
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +132 -42
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -3
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
- package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
- package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
- package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
- package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
- package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +45 -8
- package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
- package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
- package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +178 -29
- package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
- package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
- package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
- package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
- package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
- package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
- package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
- package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
- package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
- package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/attachments/store.mjs +11 -3
- package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
- package/src/runtime/channels/lib/event-queue.mjs +51 -41
- package/src/runtime/channels/lib/scheduler.mjs +23 -28
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
- package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
- package/src/runtime/channels/lib/webhook.mjs +39 -20
- package/src/runtime/channels/lib/whisper-server.mjs +46 -1
- package/src/runtime/channels/lib/worker-main.mjs +3 -1
- package/src/runtime/media/adapters/gemini-video.mjs +20 -4
- package/src/runtime/media/jobs.mjs +13 -0
- package/src/runtime/media/renditions.mjs +7 -1
- package/src/runtime/media/store.mjs +42 -22
- package/src/runtime/memory/index.mjs +4 -9
- package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
- package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
- package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
- package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
- package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
- package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
- package/src/runtime/memory/lib/ko-morph.mjs +46 -15
- package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
- package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
- package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
- package/src/runtime/memory/lib/memory.mjs +50 -12
- package/src/runtime/memory/lib/pg/process.mjs +1 -10
- package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
- package/src/runtime/memory/lib/query-handlers.mjs +2 -4
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
- package/src/runtime/shared/background-tasks.mjs +15 -0
- package/src/runtime/shared/background-tasks.test.mjs +78 -0
- package/src/runtime/shared/child-guardian.mjs +2 -10
- package/src/runtime/shared/pid-liveness.mjs +27 -0
- package/src/runtime/shared/runtime-root.mjs +1 -11
- package/src/runtime/shared/service-discovery.mjs +1 -17
- package/src/runtime/shared/singleton-owner.mjs +1 -11
- package/src/runtime/shared/staged-update.mjs +2 -12
- package/src/runtime/shared/tool-status.mjs +5 -1
- package/src/runtime/shared/tool-surface.mjs +15 -7
- package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
- package/src/runtime/shared/turn-snapshot.mjs +137 -13
- package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
- package/src/runtime/shared/webhook-session-run.mjs +16 -2
- package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
- package/src/runtime/web-search/lib/state.mjs +15 -15
- package/src/runtime/web-search/lib/web-tools.mjs +46 -5
- package/src/session-runtime/context-status.mjs +4 -12
- package/src/session-runtime/runtime-core.mjs +20 -1
- package/src/standalone/agent-dispatch-broker.mjs +33 -2
- package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
- package/src/standalone/agent-tool/notify.mjs +3 -1
- package/src/standalone/agent-tool/spawn-flow.mjs +48 -66
- package/src/standalone/agent-tool/tag-registry.mjs +116 -33
- package/src/standalone/agent-tool/tool-def.mjs +2 -2
- package/src/standalone/agent-tool/worker-index.mjs +8 -11
- package/src/standalone/agent-tool/worker-rows.mjs +4 -1
- package/src/standalone/agent-tool.mjs +20 -55
- package/src/standalone/agent-watchdog-registry.mjs +28 -4
- package/src/standalone/channel-client.mjs +47 -15
- package/src/standalone/channel-transport.mjs +44 -25
- package/src/standalone/daemon.mjs +31 -2
- package/src/standalone/hook-bus/config.mjs +25 -9
- package/src/standalone/hook-bus/handlers.mjs +16 -52
- package/src/standalone/hook-bus.mjs +29 -23
- package/src/standalone/memory-runtime-proxy.mjs +1 -16
- package/src/standalone/session-client.mjs +86 -23
- package/src/standalone/session-protocol.mjs +0 -2
- package/src/standalone/session-runtime-host-health.test.mjs +5 -4
- package/src/standalone/session-runtime-host.mjs +101 -6
- package/src/standalone/session-runtime-record.mjs +1 -5
- package/src/standalone/session-runtime-worker.mjs +52 -57
- package/src/standalone/session-service.mjs +4 -1
- package/src/standalone/session-transport.mjs +56 -25
- package/src/tui/App.jsx +40 -49
- package/src/tui/app/app-view.jsx +14 -21
- package/src/tui/app/core-memory-picker.mjs +29 -16
- package/src/tui/app/create-app-pickers.mjs +15 -36
- package/src/tui/app/extension-pickers.mjs +60 -49
- package/src/tui/app/maintenance-pickers.mjs +100 -57
- package/src/tui/app/message-selector.mjs +6 -5
- package/src/tui/app/model-picker.mjs +30 -13
- package/src/tui/app/onboarding-steps.mjs +21 -12
- package/src/tui/app/panel-epoch.mjs +56 -0
- package/src/tui/app/panel-epoch.test.mjs +1009 -0
- package/src/tui/app/panel-surface.mjs +98 -0
- package/src/tui/app/project-picker.mjs +30 -29
- package/src/tui/app/prompt-submit.mjs +192 -97
- package/src/tui/app/prompt-submit.test.mjs +206 -0
- package/src/tui/app/provider-setup-picker.mjs +74 -43
- package/src/tui/app/resume-picker.mjs +11 -8
- package/src/tui/app/route-pickers.mjs +45 -28
- package/src/tui/app/settings-picker.mjs +69 -18
- package/src/tui/app/shell-layout.mjs +1 -3
- package/src/tui/app/slash-commands.mjs +0 -2
- package/src/tui/app/slash-dispatch.mjs +4 -2
- package/src/tui/app/text-entry-policy.mjs +25 -0
- package/src/tui/app/theme-effort-pickers.mjs +11 -16
- package/src/tui/app/usage-context-panels.mjs +21 -25
- package/src/tui/app/use-global-key-input.mjs +3 -2
- package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
- package/src/tui/app/use-prompt-handlers.mjs +3 -2
- package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
- package/src/tui/components/Picker.jsx +1 -1
- package/src/tui/components/TextEntryPanel.jsx +7 -2
- package/src/tui/dist/index.mjs +652 -561
- package/src/tui/lib/voice-setup.mjs +1 -2
- package/src/tui/session/agent-job-feed.mjs +73 -11
- package/src/tui/session/session-api-ext.mjs +5 -6
- package/src/tui/session/session-api.mjs +1 -0
- package/src/tui/session/tool-approval.mjs +15 -5
- package/src/tui/session/tui-steering-persist.mjs +33 -19
- package/src/tui/session/tui-steering-persist.test.mjs +104 -0
- package/src/tui/session-local.mjs +0 -22
- package/scripts/.tmp-coverage-report.mjs +0 -96
- package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
- package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
|
@@ -36,6 +36,29 @@ function finiteInt(value, { min, max, def }) {
|
|
|
36
36
|
if (i > max) return max;
|
|
37
37
|
return i;
|
|
38
38
|
}
|
|
39
|
+
// Belt-and-suspenders ownership guard shared by both tick paths: if this
|
|
40
|
+
// process is not the active owner, do nothing. The runtime should only have
|
|
41
|
+
// started this queue on the owner path, but an ownership hand-off can briefly
|
|
42
|
+
// leave two processes both ticking — this short-circuits that window. This is
|
|
43
|
+
// multi-process active-owner gating, not HTTP authentication.
|
|
44
|
+
// Fail closed: a probe exception must NOT be treated as ownership.
|
|
45
|
+
// Duplicate-owner queue ticks are exactly what this guard prevents.
|
|
46
|
+
// Module-scope on purpose: both tick paths run this exact guard, never a
|
|
47
|
+
// dispatched override.
|
|
48
|
+
function ownerTickAllowed(queue, what) {
|
|
49
|
+
if (!queue.ownerGetter) return true;
|
|
50
|
+
let isOwner = false;
|
|
51
|
+
try { isOwner = !!queue.ownerGetter(); } catch { isOwner = false; }
|
|
52
|
+
if (!isOwner) {
|
|
53
|
+
if (!queue.ownerSkipLogged) {
|
|
54
|
+
logEvent(`queue: skipping ${what} — not owner`);
|
|
55
|
+
queue.ownerSkipLogged = true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
queue.ownerSkipLogged = false;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
39
62
|
class EventQueue {
|
|
40
63
|
config;
|
|
41
64
|
channelId;
|
|
@@ -127,25 +150,7 @@ class EventQueue {
|
|
|
127
150
|
if (this._processQueueRunning) return;
|
|
128
151
|
this._processQueueRunning = true;
|
|
129
152
|
try {
|
|
130
|
-
|
|
131
|
-
// active owner, do nothing. The runtime should only have started this
|
|
132
|
-
// queue on the owner path, but an ownership hand-off can briefly leave
|
|
133
|
-
// two processes both ticking — this short-circuits that window.
|
|
134
|
-
// This is multi-process active-owner gating, not HTTP authentication.
|
|
135
|
-
if (this.ownerGetter) {
|
|
136
|
-
// Fail closed: a probe exception must NOT be treated as ownership.
|
|
137
|
-
// Duplicate-owner queue ticks are exactly what this guard prevents.
|
|
138
|
-
let isOwner = false;
|
|
139
|
-
try { isOwner = !!this.ownerGetter(); } catch { isOwner = false; }
|
|
140
|
-
if (!isOwner) {
|
|
141
|
-
if (!this.ownerSkipLogged) {
|
|
142
|
-
logEvent("queue: skipping tick — not owner");
|
|
143
|
-
this.ownerSkipLogged = true;
|
|
144
|
-
}
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
this.ownerSkipLogged = false;
|
|
148
|
-
}
|
|
153
|
+
if (!ownerTickAllowed(this, "tick")) return;
|
|
149
154
|
const files = this.readQueueFiles();
|
|
150
155
|
if (files.length === 0) return;
|
|
151
156
|
for (const file of files) {
|
|
@@ -167,24 +172,7 @@ class EventQueue {
|
|
|
167
172
|
}
|
|
168
173
|
}
|
|
169
174
|
processBatch() {
|
|
170
|
-
|
|
171
|
-
// active owner, do nothing. The runtime should only have started this
|
|
172
|
-
// queue on the owner path, but an ownership hand-off can briefly leave
|
|
173
|
-
// two processes both ticking — this short-circuits that window.
|
|
174
|
-
// This is multi-process active-owner gating, not HTTP authentication.
|
|
175
|
-
if (this.ownerGetter) {
|
|
176
|
-
// Fail closed: a probe exception must NOT be treated as ownership.
|
|
177
|
-
let isOwner = false;
|
|
178
|
-
try { isOwner = !!this.ownerGetter(); } catch { isOwner = false; }
|
|
179
|
-
if (!isOwner) {
|
|
180
|
-
if (!this.ownerSkipLogged) {
|
|
181
|
-
logEvent("queue: skipping batch tick — not owner");
|
|
182
|
-
this.ownerSkipLogged = true;
|
|
183
|
-
}
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
this.ownerSkipLogged = false;
|
|
187
|
-
}
|
|
175
|
+
if (!ownerTickAllowed(this, "batch tick")) return;
|
|
188
176
|
const files = this.readQueueFiles();
|
|
189
177
|
const lowFiles = files.filter((f) => {
|
|
190
178
|
const item = this.readItem(f);
|
|
@@ -312,14 +300,36 @@ ${p.item.prompt}`).join("\n\n")}`;
|
|
|
312
300
|
}
|
|
313
301
|
}
|
|
314
302
|
readItem(file) {
|
|
303
|
+
let raw;
|
|
315
304
|
try {
|
|
316
|
-
|
|
305
|
+
raw = readFileSync(join(QUEUE_DIR, file), "utf8");
|
|
317
306
|
} catch (err) {
|
|
318
|
-
if (err
|
|
319
|
-
|
|
320
|
-
|
|
307
|
+
if (err?.code === "ENOENT") return null;
|
|
308
|
+
// An IO fault (EACCES/EBUSY/EMFILE/EIO…) says nothing about the record
|
|
309
|
+
// itself — the bytes may be perfectly valid. Leave it queued for the next
|
|
310
|
+
// tick instead of dead-lettering a live event.
|
|
311
|
+
logEvent(`queue: read failed for ${file}: ${err?.message ?? err}`);
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
let item;
|
|
315
|
+
try {
|
|
316
|
+
item = JSON.parse(raw);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
// Corrupt bytes can never execute, and leaving them in queue/ made every
|
|
319
|
+
// tick re-read the same poison file forever. Quarantine under processed/
|
|
320
|
+
// so the payload stays inspectable and the queue moves on.
|
|
321
|
+
logEvent(`queue: corrupt file ${file} — quarantined: ${err?.message ?? err}`);
|
|
322
|
+
this.moveToProcessed(file, "corrupt");
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
326
|
+
// Parses, but is not an event record (null / scalar / array): the same
|
|
327
|
+
// dead end as corrupt bytes, so it is quarantined rather than retried.
|
|
328
|
+
logEvent(`queue: invalid record ${file} — quarantined`);
|
|
329
|
+
this.moveToProcessed(file, "corrupt");
|
|
321
330
|
return null;
|
|
322
331
|
}
|
|
332
|
+
return item;
|
|
323
333
|
}
|
|
324
334
|
// Atomically rename from queue/ to in-progress/. Returns the new
|
|
325
335
|
// filename on success, or null if another worker already claimed it /
|
|
@@ -87,6 +87,27 @@ function validateCronExpression(time) {
|
|
|
87
87
|
}
|
|
88
88
|
if (!valid) throw new Error(`invalid cron expression "${time}": failed node-cron validation. Legacy formats (HH:MM, everyNm, hourly, daily) are no longer supported — use a cron expression instead.`);
|
|
89
89
|
}
|
|
90
|
+
// Scheduler teardown: stop ticking, destroy the cron jobs and release the
|
|
91
|
+
// scheduler lock so a subsequent start() in the same process can re-acquire it.
|
|
92
|
+
// Without the release, the wx-create in start() hits its own live lock
|
|
93
|
+
// (matching INSTANCE_UUID + recent mtime) and refuses to register cron jobs,
|
|
94
|
+
// leaving the scheduler silently idle after a reload/restart cycle.
|
|
95
|
+
// Read-verify-then-unlink so we don't delete another live owner's lock file
|
|
96
|
+
// (mirrors memory releaseLock and the exit handler). Module-scope on purpose:
|
|
97
|
+
// stop() and restart() both run this exact teardown, never a dispatched
|
|
98
|
+
// override.
|
|
99
|
+
function releaseSchedulerRuntime(scheduler) {
|
|
100
|
+
if (scheduler.tickTimer) {
|
|
101
|
+
clearInterval(scheduler.tickTimer);
|
|
102
|
+
scheduler.tickTimer = null;
|
|
103
|
+
}
|
|
104
|
+
scheduler.destroyCronJobs();
|
|
105
|
+
try {
|
|
106
|
+
const content = readFileSync(Scheduler.SCHEDULER_LOCK, "utf8");
|
|
107
|
+
const lockedPid = parseInt(content.split("\n")[0]);
|
|
108
|
+
if (lockedPid === process.pid) unlinkSync(Scheduler.SCHEDULER_LOCK);
|
|
109
|
+
} catch {}
|
|
110
|
+
}
|
|
90
111
|
class Scheduler {
|
|
91
112
|
nonInteractive;
|
|
92
113
|
interactive;
|
|
@@ -417,22 +438,7 @@ ${Scheduler.INSTANCE_UUID}`;
|
|
|
417
438
|
}
|
|
418
439
|
}
|
|
419
440
|
stop() {
|
|
420
|
-
|
|
421
|
-
clearInterval(this.tickTimer);
|
|
422
|
-
this.tickTimer = null;
|
|
423
|
-
}
|
|
424
|
-
this.destroyCronJobs();
|
|
425
|
-
// Release the scheduler lock so a subsequent start() in the same
|
|
426
|
-
// process can re-acquire it. Without this, the wx-create in start()
|
|
427
|
-
// hits its own live lock (matching INSTANCE_UUID + recent mtime) and
|
|
428
|
-
// refuses to register cron jobs, leaving the scheduler silently idle
|
|
429
|
-
// after a reload/restart cycle. Read-verify-then-unlink so we don't
|
|
430
|
-
// delete another live owner's lock file (mirrors memory releaseLock).
|
|
431
|
-
try {
|
|
432
|
-
const content = readFileSync(Scheduler.SCHEDULER_LOCK, "utf8");
|
|
433
|
-
const lockedPid = parseInt(content.split("\n")[0]);
|
|
434
|
-
if (lockedPid === process.pid) unlinkSync(Scheduler.SCHEDULER_LOCK);
|
|
435
|
-
} catch {}
|
|
441
|
+
releaseSchedulerRuntime(this);
|
|
436
442
|
}
|
|
437
443
|
destroyCronJobs() {
|
|
438
444
|
for (const [, task] of this.cronJobs) {
|
|
@@ -542,18 +548,7 @@ ${Scheduler.INSTANCE_UUID}`;
|
|
|
542
548
|
}
|
|
543
549
|
}
|
|
544
550
|
restart() {
|
|
545
|
-
|
|
546
|
-
clearInterval(this.tickTimer);
|
|
547
|
-
this.tickTimer = null;
|
|
548
|
-
}
|
|
549
|
-
this.destroyCronJobs();
|
|
550
|
-
// Read-verify-then-unlink so a non-owner reload can't delete the
|
|
551
|
-
// live owner's lock file (mirrors stop() and the exit handler).
|
|
552
|
-
try {
|
|
553
|
-
const content = readFileSync(Scheduler.SCHEDULER_LOCK, "utf8");
|
|
554
|
-
const lockedPid = parseInt(content.split("\n")[0]);
|
|
555
|
-
if (lockedPid === process.pid) unlinkSync(Scheduler.SCHEDULER_LOCK);
|
|
556
|
-
} catch {}
|
|
551
|
+
releaseSchedulerRuntime(this);
|
|
557
552
|
this.start();
|
|
558
553
|
}
|
|
559
554
|
reloadConfig(nonInteractive, interactive, channelId, options = {}) {
|
|
@@ -40,22 +40,10 @@ import { createGunzip } from 'zlib'
|
|
|
40
40
|
import { renameWithRetrySync, writeFileAtomicSync } from '../../shared/atomic-file.mjs'
|
|
41
41
|
import { streamResponseToFile } from '../../shared/bounded-download.mjs'
|
|
42
42
|
import { windowsProgramRoots, windowsSystemRoot } from '../../agent/orchestrator/tools/builtin/windows-roots.mjs'
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// Selection: explicit voice.model config wins; 'auto' picks the Korean
|
|
48
|
-
// fine-tune only when the device language resolves to Korean.
|
|
49
|
-
export function selectVoiceModelId(voiceConfig = null) {
|
|
50
|
-
const pref = String(voiceConfig?.model || 'auto').trim().toLowerCase()
|
|
51
|
-
if (pref === 'standard' || pref === 'korean') return pref
|
|
52
|
-
// voice.language is the strongest auto signal: Node's ICU default locale
|
|
53
|
-
// often resolves to en-US even on a Korean Windows install, so an explicit
|
|
54
|
-
// transcription language wins over device-locale detection.
|
|
55
|
-
const configured = normalizeWhisperLanguage(voiceConfig?.language)
|
|
56
|
-
if (configured === 'ko') return 'korean'
|
|
57
|
-
if (configured) return 'standard'
|
|
58
|
-
return detectDeviceLanguage() === 'ko' ? 'korean' : 'standard'
|
|
43
|
+
// The standard multilingual model is the only managed model. Legacy
|
|
44
|
+
// voice.model values are accepted by callers but converge here.
|
|
45
|
+
export function selectVoiceModelId() {
|
|
46
|
+
return 'standard'
|
|
59
47
|
}
|
|
60
48
|
|
|
61
49
|
function manifestModelEntry(manifest, modelId = 'standard') {
|
|
@@ -624,7 +612,8 @@ function resolveManagedWhisperModelForId(manifest, dataDir, modelId) {
|
|
|
624
612
|
}
|
|
625
613
|
|
|
626
614
|
// Clean-install policy: the models dir holds ONLY filenames declared by the
|
|
627
|
-
// current manifest
|
|
615
|
+
// current manifest. Anything else — the pre-Q8 F16 weight or retired
|
|
616
|
+
// fine-tunes,
|
|
628
617
|
// interrupted renames, hand-copied experiments — is deleted so a model swap
|
|
629
618
|
// never leaves a 1.6GB orphan behind and resolution never silently falls back
|
|
630
619
|
// to a stale weight.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
|
|
5
|
+
import { selectVoiceModelId } from './voice-runtime-fetcher.mjs'
|
|
6
|
+
|
|
7
|
+
test('managed voice always uses the standard multilingual model', () => {
|
|
8
|
+
assert.equal(selectVoiceModelId(), 'standard')
|
|
9
|
+
assert.equal(selectVoiceModelId({ model: 'korean', language: 'ko' }), 'standard')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('voice manifest contains no language-specific model', async () => {
|
|
13
|
+
const manifestPath = new URL('../data/voice-runtime-manifest.json', import.meta.url)
|
|
14
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
15
|
+
assert.deepEqual(Object.keys(manifest.models), ['standard'])
|
|
16
|
+
assert.equal(manifest.models.standard.id, 'large-v3-turbo-q8_0')
|
|
17
|
+
})
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "../../shared/webhooks-db.mjs";
|
|
10
10
|
import {
|
|
11
11
|
contentDeliveryId,
|
|
12
|
+
extractDeliveryId,
|
|
12
13
|
buildHeadersSummary,
|
|
13
14
|
} from "./webhook/deliveries.mjs";
|
|
14
15
|
import { resolveHookRelayUrl, startHookTunnel } from "./webhook/relay-tunnel.mjs";
|
|
@@ -170,10 +171,17 @@ class WebhookServer {
|
|
|
170
171
|
// Signature has accepted the raw bytes; decode to a UTF-8 string for
|
|
171
172
|
// content-type / JSON / preview handling below.
|
|
172
173
|
const body = rawBody.length === 0 ? "" : rawBody.toString("utf8");
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
174
|
+
// Claim identity = the sender's own delivery id BOUND to the digest of
|
|
175
|
+
// the authenticated body. Keying on the digest alone made dedup permanent
|
|
176
|
+
// per content: two legitimately distinct deliveries with an identical
|
|
177
|
+
// payload (a repeated ping, a re-run of the same job) were suppressed
|
|
178
|
+
// forever. Binding the id to the body still makes a replay of a captured
|
|
179
|
+
// body under its original delivery id a duplicate.
|
|
180
|
+
const bodyDigestId = contentDeliveryId(rawBody);
|
|
181
|
+
const sourceDeliveryId = extractDeliveryId(headers);
|
|
182
|
+
deliveryId = sourceDeliveryId
|
|
183
|
+
? `${String(sourceDeliveryId).slice(0, 120)}:${bodyDigestId}`
|
|
184
|
+
: bodyDigestId;
|
|
177
185
|
// Atomic claim + dedup in one step: INSERT ... ON CONFLICT DO NOTHING.
|
|
178
186
|
// A concurrent duplicate POST of the same id loses the race
|
|
179
187
|
// (claimed:false) and is rejected flat, so the first run is never
|
|
@@ -418,10 +426,16 @@ ${payload}
|
|
|
418
426
|
// for the lifetime of the process and dedup keeps re-running
|
|
419
427
|
// forever. 10 minutes covers the slowest handler run we ship.
|
|
420
428
|
const DISPATCH_TIMEOUT_MS = 10 * 60 * 1000;
|
|
421
|
-
|
|
429
|
+
// Racing a bare timer against the dispatch promise reported failure while
|
|
430
|
+
// the run itself kept going: the delivery row was already `failed` when the
|
|
431
|
+
// late result arrived and flipped it again. The timeout now aborts the
|
|
432
|
+
// dispatch through a signal the dispatcher receives, and the first outcome
|
|
433
|
+
// to land is the only one that writes a terminal status.
|
|
434
|
+
const controller = new AbortController();
|
|
422
435
|
const dispatchP = Promise.resolve(this.bridgeDispatch({
|
|
423
436
|
model: model || null,
|
|
424
437
|
prompt: fullPrompt,
|
|
438
|
+
signal: controller.signal,
|
|
425
439
|
// Endpoint-scoped project/workflow (New-task parity): the webhook row's
|
|
426
440
|
// cwd/workflow define the created session, not the worker's global cwd.
|
|
427
441
|
cwd: extra.cwd || this.config?.cwd || null,
|
|
@@ -435,21 +449,26 @@ ${payload}
|
|
|
435
449
|
event: headers["x-github-event"] || null,
|
|
436
450
|
},
|
|
437
451
|
}));
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
logWebhook(`${name}:
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
452
|
+
let settled = false;
|
|
453
|
+
const timeoutHandle = setTimeout(
|
|
454
|
+
() => controller.abort(new Error(`bridge dispatch timed out after ${DISPATCH_TIMEOUT_MS}ms`)),
|
|
455
|
+
DISPATCH_TIMEOUT_MS,
|
|
456
|
+
);
|
|
457
|
+
const finish = (status, fields, message) => {
|
|
458
|
+
if (settled) return;
|
|
459
|
+
settled = true;
|
|
460
|
+
clearTimeout(timeoutHandle);
|
|
461
|
+
void updateDeliveryStatus(name, deliveryId, status, fields).catch((e) => logWebhook(`${name}: delivery status update failed: ${e?.message || e}`));
|
|
462
|
+
logWebhook(message);
|
|
463
|
+
};
|
|
464
|
+
controller.signal.addEventListener("abort", () => {
|
|
465
|
+
const reason = controller.signal.reason;
|
|
466
|
+
finish("failed", { error: String(reason?.message || reason) }, `${name}: webhook session run aborted: ${reason?.message || reason}`);
|
|
467
|
+
}, { once: true });
|
|
468
|
+
dispatchP.then(
|
|
469
|
+
() => finish("done", {}, `${name}: webhook session run dispatched (id=${deliveryId})`),
|
|
470
|
+
(err) => finish("failed", { error: String(err?.message || err) }, `${name}: webhook session run failed: ${err?.message || err}`),
|
|
471
|
+
);
|
|
453
472
|
res.writeHead(202, { "Content-Type": "application/json" });
|
|
454
473
|
res.end(JSON.stringify({ status: "accepted", handler: "session", id: deliveryId }));
|
|
455
474
|
}
|
|
@@ -53,6 +53,16 @@ const READY_POLL_MS = 250; // TCP probe cadence
|
|
|
53
53
|
const PROBE_CONNECT_MS = 1_000; // per TCP-connect attempt
|
|
54
54
|
const STOP_GRACE_MS = 5_000; // SIGTERM → SIGKILL escalation window
|
|
55
55
|
const INFERENCE_PATH = '/inference'; // whisper-server default --inference-path
|
|
56
|
+
// Idle release. Without it ONE transcription pins the loaded model for the whole
|
|
57
|
+
// host-process lifetime (measured: ~325 MB working set, ~2.1 GB commit with the
|
|
58
|
+
// CUDA build) even when voice is never used again. Reclaim after an idle window;
|
|
59
|
+
// the next ensureReady() rebuilds the SAME contract, so the only cost is one
|
|
60
|
+
// model load. Mirrors the embedding worker's 5-minute window.
|
|
61
|
+
// MIXDOG_WHISPER_IDLE_TIMEOUT_MS overrides; 0 disables the release entirely.
|
|
62
|
+
const IDLE_TIMEOUT_MS = (() => {
|
|
63
|
+
const raw = Number(process.env.MIXDOG_WHISPER_IDLE_TIMEOUT_MS);
|
|
64
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 300_000;
|
|
65
|
+
})();
|
|
56
66
|
|
|
57
67
|
const STATE = Object.freeze({
|
|
58
68
|
STOPPED: 'STOPPED',
|
|
@@ -73,6 +83,7 @@ const mgr = {
|
|
|
73
83
|
startPromise: null, // in-flight ensureReady() start
|
|
74
84
|
inflight: new Set(), // active transcribe AbortControllers
|
|
75
85
|
logTail: '', // recent stdout/stderr for diagnostics
|
|
86
|
+
idleTimer: null, // armed only while READY with nothing in flight
|
|
76
87
|
};
|
|
77
88
|
|
|
78
89
|
function runtimeKeyOf({ serverCmd, modelPath, threadCount, host }) {
|
|
@@ -244,9 +255,35 @@ function detachChildHandlers() {
|
|
|
244
255
|
try { mgr.child.stderr?.removeAllListeners(); } catch {}
|
|
245
256
|
}
|
|
246
257
|
|
|
258
|
+
function clearIdleTimer() {
|
|
259
|
+
if (!mgr.idleTimer) return;
|
|
260
|
+
clearTimeout(mgr.idleTimer);
|
|
261
|
+
mgr.idleTimer = null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Arm ONLY from a resting READY state with nothing in flight, and re-check that
|
|
265
|
+
// same condition when the timer fires: a transcription accepted in between must
|
|
266
|
+
// never have the model pulled out from under it. The timer is unref'd — an idle
|
|
267
|
+
// whisper-server can never keep its host process alive.
|
|
268
|
+
function armIdleTimer() {
|
|
269
|
+
clearIdleTimer();
|
|
270
|
+
if (!IDLE_TIMEOUT_MS) return;
|
|
271
|
+
if (mgr.state !== STATE.READY || !mgr.child || mgr.inflight.size > 0) return;
|
|
272
|
+
mgr.idleTimer = setTimeout(() => {
|
|
273
|
+
mgr.idleTimer = null;
|
|
274
|
+
if (mgr.state !== STATE.READY || !mgr.child || mgr.inflight.size > 0) return;
|
|
275
|
+
mgr.state = STATE.STOPPING;
|
|
276
|
+
void teardown('idle-timeout')
|
|
277
|
+
.catch(() => {})
|
|
278
|
+
.finally(() => { mgr.state = STATE.STOPPED; });
|
|
279
|
+
}, IDLE_TIMEOUT_MS);
|
|
280
|
+
mgr.idleTimer.unref?.();
|
|
281
|
+
}
|
|
282
|
+
|
|
247
283
|
// Deterministic teardown of the current child. Used both for STOPPING and for a
|
|
248
284
|
// contract change. Escalates SIGTERM → SIGKILL after a grace window.
|
|
249
285
|
async function teardown(reason) {
|
|
286
|
+
clearIdleTimer();
|
|
250
287
|
const child = mgr.child;
|
|
251
288
|
const pid = child?.pid;
|
|
252
289
|
// Abort all in-flight transcribe requests first.
|
|
@@ -386,9 +423,11 @@ export async function ensureReady({ serverCmd, modelPath, threadCount, host = '1
|
|
|
386
423
|
}
|
|
387
424
|
const contract = { serverCmd, modelPath, threadCount, host };
|
|
388
425
|
const key = runtimeKeyOf(contract);
|
|
426
|
+
clearIdleTimer();
|
|
389
427
|
|
|
390
428
|
// Already READY on the same contract with a live child → done.
|
|
391
429
|
if (mgr.state === STATE.READY && mgr.runtimeKey === key && mgr.child) {
|
|
430
|
+
armIdleTimer();
|
|
392
431
|
return;
|
|
393
432
|
}
|
|
394
433
|
// A start is already in flight for the SAME contract → await it.
|
|
@@ -402,7 +441,11 @@ export async function ensureReady({ serverCmd, modelPath, threadCount, host = '1
|
|
|
402
441
|
mgr.state = STATE.STOPPED;
|
|
403
442
|
}
|
|
404
443
|
|
|
405
|
-
mgr.startPromise = startServer(contract).finally(() => {
|
|
444
|
+
mgr.startPromise = startServer(contract).finally(() => {
|
|
445
|
+
mgr.startPromise = null;
|
|
446
|
+
// A start that no transcription ever follows must not pin the model either.
|
|
447
|
+
armIdleTimer();
|
|
448
|
+
});
|
|
406
449
|
return mgr.startPromise;
|
|
407
450
|
}
|
|
408
451
|
|
|
@@ -418,6 +461,7 @@ export async function transcribe(wavPath, { language } = {}) {
|
|
|
418
461
|
const host = mgr.host;
|
|
419
462
|
const port = mgr.port;
|
|
420
463
|
const ctrl = new AbortController();
|
|
464
|
+
clearIdleTimer();
|
|
421
465
|
mgr.inflight.add(ctrl);
|
|
422
466
|
try {
|
|
423
467
|
const data = await fs.promises.readFile(wavPath);
|
|
@@ -463,6 +507,7 @@ export async function transcribe(wavPath, { language } = {}) {
|
|
|
463
507
|
throw err;
|
|
464
508
|
} finally {
|
|
465
509
|
mgr.inflight.delete(ctrl);
|
|
510
|
+
armIdleTimer();
|
|
466
511
|
}
|
|
467
512
|
}
|
|
468
513
|
|
|
@@ -264,7 +264,7 @@ function wireWebhookHandlers() {
|
|
|
264
264
|
webhookServer.setEventPipeline(eventPipeline);
|
|
265
265
|
// Webhook fires run as sessions (schedules parity); the Automations
|
|
266
266
|
// session row is the only surface (channel relay retired).
|
|
267
|
-
webhookServer.setBridgeDispatch(async ({ prompt, model, cwd, workflow, attachments, delivery, context }) => {
|
|
267
|
+
webhookServer.setBridgeDispatch(async ({ prompt, model, cwd, workflow, attachments, delivery, context, signal }) => {
|
|
268
268
|
const { runWebhookSession } = await import("../../shared/webhook-session-run.mjs");
|
|
269
269
|
const run = await runWebhookSession({
|
|
270
270
|
name: context?.endpoint || "webhook",
|
|
@@ -273,6 +273,8 @@ function wireWebhookHandlers() {
|
|
|
273
273
|
workflow: workflow || null,
|
|
274
274
|
attachments: attachments || null,
|
|
275
275
|
delivery: delivery || null,
|
|
276
|
+
// Dispatch-timeout cancellation only works if the signal reaches the run.
|
|
277
|
+
signal: signal || null,
|
|
276
278
|
prompt,
|
|
277
279
|
});
|
|
278
280
|
return run;
|
|
@@ -16,6 +16,14 @@ const BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';
|
|
|
16
16
|
const OMNI_TIMEOUT_MS = 600_000;
|
|
17
17
|
const POLL_INTERVAL_MS = 8_000;
|
|
18
18
|
const TOTAL_TIMEOUT_MS = 900_000;
|
|
19
|
+
// Submission and each poll need their own ceiling: an unbounded request keeps
|
|
20
|
+
// the 15-minute budget from ever starting (or ever ending).
|
|
21
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
22
|
+
|
|
23
|
+
function boundedSignal(signal, deadline, capMs = REQUEST_TIMEOUT_MS) {
|
|
24
|
+
const remaining = Math.max(1, deadline - Date.now());
|
|
25
|
+
return AbortSignal.any([signal, AbortSignal.timeout(Math.min(capMs, remaining))].filter(Boolean));
|
|
26
|
+
}
|
|
19
27
|
|
|
20
28
|
function isOmniModel(model) {
|
|
21
29
|
return /omni/i.test(String(model || ''));
|
|
@@ -75,22 +83,27 @@ async function generateViaVeo({ model, prompt, options, references = [], signal,
|
|
|
75
83
|
const duration = Math.trunc(Number(options?.duration) || 0);
|
|
76
84
|
if ([4, 6, 8].includes(duration)) parameters.durationSeconds = duration;
|
|
77
85
|
|
|
86
|
+
// The budget starts at submission, not after it: a hung predictLongRunning
|
|
87
|
+
// POST used to run without any ceiling of its own.
|
|
88
|
+
const deadline = Date.now() + TOTAL_TIMEOUT_MS;
|
|
78
89
|
const started = await fetch(`${BASE_URL}/models/${encodeURIComponent(model)}:predictLongRunning`, {
|
|
79
90
|
method: 'POST',
|
|
80
91
|
headers,
|
|
81
92
|
body: JSON.stringify({ instances: [instance], parameters }),
|
|
82
|
-
signal,
|
|
93
|
+
signal: boundedSignal(signal, deadline),
|
|
83
94
|
});
|
|
84
95
|
if (!started.ok) throw upstreamError('Veo video', started.status, await started.text().catch(() => ''));
|
|
85
96
|
const operation = await started.json();
|
|
86
97
|
if (!operation?.name) throw mediaError('Veo returned no operation name', 'MEDIA_UPSTREAM_FAILED', 502);
|
|
87
98
|
|
|
88
|
-
const deadline = Date.now() + TOTAL_TIMEOUT_MS;
|
|
89
99
|
for (;;) {
|
|
90
100
|
if (signal?.aborted) throw mediaError('canceled', 'MEDIA_CANCELED', 499);
|
|
91
101
|
if (Date.now() > deadline) throw mediaError('Veo poll budget exceeded', 'MEDIA_TIMEOUT', 504);
|
|
92
102
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
93
|
-
const poll = await fetch(`${BASE_URL}/${operation.name}`, {
|
|
103
|
+
const poll = await fetch(`${BASE_URL}/${operation.name}`, {
|
|
104
|
+
headers,
|
|
105
|
+
signal: boundedSignal(signal, deadline),
|
|
106
|
+
});
|
|
94
107
|
if (!poll.ok) throw upstreamError('Veo poll', poll.status, await poll.text().catch(() => ''));
|
|
95
108
|
const data = await poll.json();
|
|
96
109
|
if (!data?.done) {
|
|
@@ -107,7 +120,10 @@ async function generateViaVeo({ model, prompt, options, references = [], signal,
|
|
|
107
120
|
const uri = sample?.video?.uri || sample?.video?.fileUri;
|
|
108
121
|
if (!uri) throw mediaError('Veo finished without a video URI', 'MEDIA_EMPTY_RESULT', 502);
|
|
109
122
|
return {
|
|
110
|
-
|
|
123
|
+
// The download is part of the generation budget: unbounded, it held an
|
|
124
|
+
// active job slot (and the upstream connection) open indefinitely after
|
|
125
|
+
// the poll loop finished.
|
|
126
|
+
bytes: await downloadGeminiMedia(uri, { key, signal: boundedSignal(signal, deadline, TOTAL_TIMEOUT_MS) }),
|
|
111
127
|
mime: 'video/mp4',
|
|
112
128
|
};
|
|
113
129
|
}
|
|
@@ -16,6 +16,10 @@ const JOBS = new Map();
|
|
|
16
16
|
// terminal state, then drop out to bound memory.
|
|
17
17
|
const TERMINAL_TTL_MS = 10 * 60_000;
|
|
18
18
|
const MAX_PROMPT_CHARS = 8_000;
|
|
19
|
+
// Video lanes hold an upstream slot for minutes each. Without a ceiling a
|
|
20
|
+
// caller can start unbounded concurrent generations, each pinning provider
|
|
21
|
+
// quota and a poll loop for its whole budget.
|
|
22
|
+
const MAX_ACTIVE_JOBS = Math.max(1, Number(process.env.MIXDOG_MEDIA_MAX_ACTIVE_JOBS) || 4);
|
|
19
23
|
|
|
20
24
|
function snapshot(job) {
|
|
21
25
|
return {
|
|
@@ -84,6 +88,15 @@ export function startMediaJob({ lane: laneId, kind, model, prompt, options = {},
|
|
|
84
88
|
.slice(0, maxRefs)
|
|
85
89
|
.map((ref) => ({ base64: ref.base64, mime: String(ref.mime || 'image/png') }));
|
|
86
90
|
sweep();
|
|
91
|
+
let active = 0;
|
|
92
|
+
for (const entry of JOBS.values()) if (entry.status === 'running') active += 1;
|
|
93
|
+
if (active >= MAX_ACTIVE_JOBS) {
|
|
94
|
+
throw mediaError(
|
|
95
|
+
`too many media generations are already running (${active}/${MAX_ACTIVE_JOBS})`,
|
|
96
|
+
'MEDIA_TOO_MANY_JOBS',
|
|
97
|
+
429,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
87
100
|
|
|
88
101
|
const controller = new AbortController();
|
|
89
102
|
const job = {
|
|
@@ -90,7 +90,13 @@ function renditionPath(cacheDir, variant, id, extension) {
|
|
|
90
90
|
function cachedRendition(cacheDir, variant, id) {
|
|
91
91
|
for (const [extension, mime] of Object.entries(EXTENSION_MIME)) {
|
|
92
92
|
const path = renditionPath(cacheDir, variant, id, extension);
|
|
93
|
-
|
|
93
|
+
// stat IS the existence check: a separate existsSync leaves a window in
|
|
94
|
+
// which concurrent pruning unlinks the file between the two calls, turning
|
|
95
|
+
// a plain cache miss into a thrown media request.
|
|
96
|
+
try {
|
|
97
|
+
const info = statSync(path);
|
|
98
|
+
if (info.isFile()) return { path, mime, bytes: info.size };
|
|
99
|
+
} catch { /* absent or evicted mid-lookup */ }
|
|
94
100
|
}
|
|
95
101
|
return null;
|
|
96
102
|
}
|