mixdog 0.9.17 → 0.9.19
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 +3 -2
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/output-style-smoke.mjs +2 -2
- package/scripts/recall-bench-cases.json +11 -0
- package/scripts/recall-bench.mjs +91 -2
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +5 -2
- package/scripts/tool-smoke.mjs +101 -27
- package/src/agents/debugger/AGENT.md +3 -3
- package/src/agents/heavy-worker/AGENT.md +7 -10
- package/src/agents/maintainer/AGENT.md +1 -2
- package/src/agents/reviewer/AGENT.md +1 -2
- package/src/agents/worker/AGENT.md +5 -8
- package/src/defaults/agents.json +3 -0
- package/src/mixdog-session-runtime.mjs +23 -6
- package/src/rules/agent/00-core.md +4 -3
- package/src/rules/agent/30-explorer.md +53 -22
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/lead/lead-tool.md +3 -2
- package/src/rules/shared/01-tool.md +24 -29
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
- package/src/runtime/channels/backends/discord-attachments.mjs +2 -2
- package/src/runtime/channels/backends/discord-gateway.mjs +26 -3
- package/src/runtime/channels/backends/discord.mjs +139 -7
- package/src/runtime/channels/index.mjs +290 -76
- package/src/runtime/channels/lib/crash-log.mjs +21 -3
- package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
- package/src/runtime/channels/lib/memory-client.mjs +32 -14
- package/src/runtime/channels/lib/output-forwarder.mjs +156 -14
- package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
- package/src/runtime/channels/lib/runtime-paths.mjs +48 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
- package/src/runtime/channels/lib/voice-transcription.mjs +4 -2
- package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
- package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
- package/src/runtime/memory/lib/query-handlers.mjs +36 -4
- package/src/runtime/memory/lib/recall-format.mjs +106 -6
- package/src/runtime/memory/lib/session-ingest.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +10 -4
- package/src/runtime/shared/background-tasks.mjs +4 -2
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-result-summary.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +30 -1
- package/src/session-runtime/config-lifecycle.mjs +1 -1
- package/src/session-runtime/cwd-plugins.mjs +46 -3
- package/src/session-runtime/mcp-glue.mjs +24 -3
- package/src/session-runtime/output-styles.mjs +44 -10
- package/src/session-runtime/workflow.mjs +16 -1
- package/src/standalone/channel-worker.mjs +88 -7
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/App.jsx +57 -77
- package/src/tui/app/channel-pickers.mjs +45 -0
- package/src/tui/app/slash-commands.mjs +0 -1
- package/src/tui/app/slash-dispatch.mjs +0 -16
- package/src/tui/app/transcript-window.mjs +66 -1
- package/src/tui/app/use-mouse-input.mjs +9 -2
- package/src/tui/app/use-prompt-handlers.mjs +7 -94
- package/src/tui/app/use-transcript-scroll.mjs +5 -1
- package/src/tui/app/use-transcript-window.mjs +74 -8
- package/src/tui/components/PromptInput.jsx +33 -64
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +4744 -4806
- package/src/tui/engine.mjs +109 -20
- package/src/tui/lib/voice-setup.mjs +166 -0
- package/src/tui/paste-attachments.mjs +12 -5
- package/src/tui/prompt-history-store.mjs +125 -12
- package/scripts/bench/cache-probe-tasks.json +0 -8
- package/scripts/bench/lead-review-tasks-r3.json +0 -20
- package/scripts/bench/lead-review-tasks.json +0 -20
- package/scripts/bench/r4-mixed-tasks.json +0 -20
- package/scripts/bench/r5-orchestrated-task.json +0 -7
- package/scripts/bench/review-tasks.json +0 -20
- package/scripts/bench/round-codex.json +0 -114
- package/scripts/bench/round-mixdog-lead-r3.json +0 -269
- package/scripts/bench/round-mixdog-lead.json +0 -269
- package/scripts/bench/round-mixdog.json +0 -126
- package/scripts/bench/round-r10-bigsample.json +0 -679
- package/scripts/bench/round-r11-codexalign.json +0 -257
- package/scripts/bench/round-r13-clientmeta.json +0 -464
- package/scripts/bench/round-r14-betafeatures.json +0 -466
- package/scripts/bench/round-r15-fulldefault.json +0 -462
- package/scripts/bench/round-r16-sessionid.json +0 -466
- package/scripts/bench/round-r17-wirebytes.json +0 -456
- package/scripts/bench/round-r18-prewarm.json +0 -468
- package/scripts/bench/round-r19-clean.json +0 -472
- package/scripts/bench/round-r20-prewarm-clean.json +0 -475
- package/scripts/bench/round-r21-delta-retry.json +0 -473
- package/scripts/bench/round-r22-full-probe.json +0 -693
- package/scripts/bench/round-r23-itemprobe.json +0 -701
- package/scripts/bench/round-r24-shapefix.json +0 -677
- package/scripts/bench/round-r25-serial.json +0 -464
- package/scripts/bench/round-r26-parallel3.json +0 -671
- package/scripts/bench/round-r27-parallel10.json +0 -894
- package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
- package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
- package/scripts/bench/round-r30-instid.json +0 -253
- package/scripts/bench/round-r31-upgradeprobe.json +0 -256
- package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
- package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
- package/scripts/bench/round-r34-orchestrated.json +0 -120
- package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
- package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
- package/scripts/bench/round-r4-codex.json +0 -114
- package/scripts/bench/round-r4-mixed.json +0 -225
- package/scripts/bench/round-r5-gpt-lead.json +0 -259
- package/scripts/bench/round-r6-codex.json +0 -114
- package/scripts/bench/round-r6-solo.json +0 -257
- package/scripts/bench/round-r7-full.json +0 -254
- package/scripts/bench/round-r8-fulldefault.json +0 -255
- package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
- package/src/tui/lib/voice-recorder.mjs +0 -469
|
@@ -190,25 +190,42 @@ function atomicWrite(targetPath, contents) {
|
|
|
190
190
|
catch (e) { try { fs.unlinkSync(tmp) } catch {}; throw e }
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
// In-memory dedupeKey(filePath) -> absolute buffer path index. Replaces the
|
|
194
|
+
// per-event O(N) readdir+read+parse dedupe scan in bufferToDisk. Seeded once
|
|
195
|
+
// (lazily) from disk so cross-restart dedupe (one buffered ingest per
|
|
196
|
+
// transcriptPath) still holds; maintained incrementally on write. A stale
|
|
197
|
+
// entry (file drained/renamed) is caught by an existsSync guard at the call
|
|
198
|
+
// site, so no drain/replay format or TTL semantics change.
|
|
199
|
+
const _dedupeIndex = new Map()
|
|
200
|
+
let _dedupeIndexSeeded = false
|
|
201
|
+
function seedDedupeIndex(kind) {
|
|
202
|
+
if (_dedupeIndexSeeded) return
|
|
203
|
+
_dedupeIndexSeeded = true
|
|
204
|
+
let existing = []
|
|
205
|
+
try { existing = fs.readdirSync(BUFFER_DIR) } catch {}
|
|
206
|
+
for (const name of existing) {
|
|
207
|
+
if (!name.startsWith(`${kind}-`) || !name.endsWith('.json')) continue
|
|
208
|
+
try {
|
|
209
|
+
const prev = JSON.parse(fs.readFileSync(path.join(BUFFER_DIR, name), 'utf8'))
|
|
210
|
+
if (prev && prev.filePath != null) _dedupeIndex.set(prev.filePath, path.join(BUFFER_DIR, name))
|
|
211
|
+
} catch {}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
193
215
|
function bufferToDisk(kind, payload, { dedupeKey = null } = {}) {
|
|
194
216
|
try {
|
|
195
217
|
fs.mkdirSync(BUFFER_DIR, { recursive: true })
|
|
196
218
|
if (dedupeKey != null) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// Atomic overwrite (tmp+rename); preserves the original (oldest)
|
|
206
|
-
// ordering slot without exposing a half-written file to a drainer.
|
|
207
|
-
atomicWrite(full, JSON.stringify(payload, null, 2))
|
|
208
|
-
return full
|
|
209
|
-
}
|
|
210
|
-
} catch {}
|
|
219
|
+
// In-memory index (seeded once from disk) replaces the per-event O(N)
|
|
220
|
+
// readdir+read+parse scan. Overwrite the existing buffered file for this
|
|
221
|
+
// dedupeKey in place (atomic tmp+rename) to keep its oldest ordering slot.
|
|
222
|
+
seedDedupeIndex(kind)
|
|
223
|
+
const idx = _dedupeIndex.get(dedupeKey)
|
|
224
|
+
if (idx && fs.existsSync(idx)) {
|
|
225
|
+
atomicWrite(idx, JSON.stringify(payload, null, 2))
|
|
226
|
+
return idx
|
|
211
227
|
}
|
|
228
|
+
if (idx) _dedupeIndex.delete(dedupeKey)
|
|
212
229
|
}
|
|
213
230
|
enforceBufferCap()
|
|
214
231
|
const random = Math.random().toString(36).slice(2, 10)
|
|
@@ -216,6 +233,7 @@ function bufferToDisk(kind, payload, { dedupeKey = null } = {}) {
|
|
|
216
233
|
// ordering under a lexicographic sort.
|
|
217
234
|
const bufferPath = path.join(BUFFER_DIR, `${kind}-${Date.now()}-${random}.json`)
|
|
218
235
|
atomicWrite(bufferPath, JSON.stringify(payload, null, 2))
|
|
236
|
+
if (dedupeKey != null) _dedupeIndex.set(dedupeKey, bufferPath)
|
|
219
237
|
return bufferPath
|
|
220
238
|
} catch (bufErr) {
|
|
221
239
|
process.stderr.write(`[memory-client] Failed to buffer ${kind}: ${bufErr.message}\n`)
|
|
@@ -28,8 +28,24 @@ import {
|
|
|
28
28
|
buildToolLine
|
|
29
29
|
} from "./tool-format.mjs";
|
|
30
30
|
|
|
31
|
+
// Per-item send watchdog. drainQueue awaits ONE backend send at a time; a hung
|
|
32
|
+
// send would otherwise never settle, so drainQueue never returns and `sending`
|
|
33
|
+
// stays true forever — wedging ALL outbound behind the queue head. The watchdog
|
|
34
|
+
// does NOT abandon the send (racing a still-running send against a retry would
|
|
35
|
+
// double-deliver): it only pokes the backend to reset its wedged client so the
|
|
36
|
+
// AWAITED send fails fast and `sending` releases on real settlement. Sized above
|
|
37
|
+
// the backend's worst-case per-attempt retries.
|
|
38
|
+
const SEND_ITEM_TIMEOUT_MS = 120_000;
|
|
39
|
+
// After the watchdog resets the backend, wait at most this long for the
|
|
40
|
+
// abandoned send to actually settle before releasing the queue.
|
|
41
|
+
const SEND_SETTLE_CAP_MS = 15_000;
|
|
42
|
+
const TAIL_RECOVERY_BYTES = 256 * 1024;
|
|
43
|
+
|
|
31
44
|
class OutputForwarder {
|
|
32
45
|
ownerGetter = null;
|
|
46
|
+
// Generation of the in-flight supervised send. Bumped when a send is
|
|
47
|
+
// abandoned (settlement cap) so a late/zombie completion can't double-advance.
|
|
48
|
+
_activeSendGen = 0;
|
|
33
49
|
setOwnerGetter(fn) {
|
|
34
50
|
this.ownerGetter = fn;
|
|
35
51
|
}
|
|
@@ -60,6 +76,9 @@ class OutputForwarder {
|
|
|
60
76
|
lastFileSize = 0;
|
|
61
77
|
readFileSize = 0;
|
|
62
78
|
watchingPath = "";
|
|
79
|
+
// Set by readNewLines when a capped tick left more complete bytes on disk,
|
|
80
|
+
// so the poll callback re-arms a flush without waiting on a fresh fs event.
|
|
81
|
+
_pendingReadMore = false;
|
|
63
82
|
watcher = null;
|
|
64
83
|
idleTimer = null;
|
|
65
84
|
onIdleCallback = null;
|
|
@@ -80,6 +99,11 @@ class OutputForwarder {
|
|
|
80
99
|
// into the next pending text payload so we don't grow unbounded but
|
|
81
100
|
// also don't lose content.
|
|
82
101
|
static SEND_QUEUE_MAX = 200;
|
|
102
|
+
// Cap the bytes read per streaming tick so a large burst (e.g. a big tool
|
|
103
|
+
// result written at once) is amortized across ticks instead of allocating a
|
|
104
|
+
// multi-MB buffer and blocking the loop on one readSync. The cursor only
|
|
105
|
+
// advances by bytes actually consumed; the remainder is picked up next tick.
|
|
106
|
+
static MAX_TICK_READ_BYTES = 2 * 1024 * 1024;
|
|
83
107
|
// Persisted final-flush ledger so a forwarder restart can resume final
|
|
84
108
|
// forwarding instead of giving up after 5 short retries.
|
|
85
109
|
pendingFinalFlush = false;
|
|
@@ -111,20 +135,35 @@ class OutputForwarder {
|
|
|
111
135
|
try {
|
|
112
136
|
const stat = existsSync(this.transcriptPath) ? statSync(this.transcriptPath) : null;
|
|
113
137
|
const currentSize = stat?.size ?? 0;
|
|
138
|
+
const persisted = this.statusState?.read?.();
|
|
139
|
+
const sameTranscript = persisted?.transcriptPath &&
|
|
140
|
+
sameResolvedPath(persisted.transcriptPath, this.transcriptPath);
|
|
114
141
|
let fileSize;
|
|
115
142
|
if (options.replayFromStart) {
|
|
116
143
|
fileSize = 0;
|
|
117
144
|
} else if (options.catchUpFromPersisted) {
|
|
118
|
-
const persisted = this.statusState?.read?.();
|
|
119
145
|
const persistedSize = typeof persisted?.lastFileSize === "number" ? persisted.lastFileSize : -1;
|
|
120
|
-
const sameTranscript = persisted?.transcriptPath &&
|
|
121
|
-
sameResolvedPath(persisted.transcriptPath, this.transcriptPath);
|
|
122
146
|
fileSize = (sameTranscript && persistedSize >= 0)
|
|
123
147
|
? Math.min(Math.max(persistedSize, 0), currentSize)
|
|
124
148
|
: currentSize;
|
|
125
149
|
} else {
|
|
126
150
|
fileSize = currentSize;
|
|
127
151
|
}
|
|
152
|
+
if (options.recoverUnsyncedTail && fileSize === currentSize && currentSize > 0) {
|
|
153
|
+
const sentCount = Number(persisted?.sentCount ?? 0);
|
|
154
|
+
const lastSentTime = Number(persisted?.lastSentTime ?? 0);
|
|
155
|
+
const hasSentHash = typeof persisted?.lastSentHash === "string" && persisted.lastSentHash.length > 0;
|
|
156
|
+
const noSendEvidence = (!Number.isFinite(sentCount) || sentCount <= 0)
|
|
157
|
+
&& (!Number.isFinite(lastSentTime) || lastSentTime <= 0)
|
|
158
|
+
&& !hasSentHash;
|
|
159
|
+
if (!sameTranscript || noSendEvidence) {
|
|
160
|
+
const tailOffset = this.findLastAssistantTextOffset(currentSize);
|
|
161
|
+
if (tailOffset >= 0 && tailOffset < currentSize) {
|
|
162
|
+
fileSize = tailOffset;
|
|
163
|
+
dropTrace("context.recoverUnsyncedTail", { path: this.transcriptPath, offset: tailOffset, currentSize, sameTranscript: !!sameTranscript });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
128
167
|
this.lastFileSize = fileSize;
|
|
129
168
|
this.readFileSize = fileSize;
|
|
130
169
|
} catch {
|
|
@@ -132,6 +171,46 @@ class OutputForwarder {
|
|
|
132
171
|
this.readFileSize = 0;
|
|
133
172
|
}
|
|
134
173
|
}
|
|
174
|
+
findLastAssistantTextOffset(fileSize) {
|
|
175
|
+
if (!this.transcriptPath || fileSize <= 0) return -1;
|
|
176
|
+
const bytesToRead = Math.min(fileSize, TAIL_RECOVERY_BYTES);
|
|
177
|
+
const startOffset = fileSize - bytesToRead;
|
|
178
|
+
let fd = null;
|
|
179
|
+
try {
|
|
180
|
+
fd = openSync(this.transcriptPath, "r");
|
|
181
|
+
const buf = Buffer.alloc(bytesToRead);
|
|
182
|
+
readSync(fd, buf, 0, bytesToRead, startOffset);
|
|
183
|
+
const raw = buf.toString("utf8");
|
|
184
|
+
let chunkStart = 0;
|
|
185
|
+
if (startOffset > 0) {
|
|
186
|
+
const firstNewline = raw.indexOf("\n");
|
|
187
|
+
if (firstNewline < 0) return -1;
|
|
188
|
+
chunkStart = firstNewline + 1;
|
|
189
|
+
}
|
|
190
|
+
let cursor = startOffset + chunkStart;
|
|
191
|
+
let lastOffset = -1;
|
|
192
|
+
for (const line of raw.slice(chunkStart).split("\n")) {
|
|
193
|
+
const lineOffset = cursor;
|
|
194
|
+
cursor += Buffer.byteLength(line, "utf8") + 1;
|
|
195
|
+
if (!line.trim()) continue;
|
|
196
|
+
try {
|
|
197
|
+
const entry = JSON.parse(line);
|
|
198
|
+
if (entry.isSidechain || entry.type !== "assistant") continue;
|
|
199
|
+
const parts = entry.message?.content;
|
|
200
|
+
if (!Array.isArray(parts)) continue;
|
|
201
|
+
if (parts.some((c) => c?.type === "text" && typeof c.text === "string" && c.text.trim())) {
|
|
202
|
+
lastOffset = lineOffset;
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return lastOffset;
|
|
208
|
+
} catch {
|
|
209
|
+
return -1;
|
|
210
|
+
} finally {
|
|
211
|
+
if (fd != null) closeSync(fd);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
135
214
|
/** Reset counters for new turn */
|
|
136
215
|
reset() {
|
|
137
216
|
this.sentCount = 0;
|
|
@@ -146,12 +225,13 @@ class OutputForwarder {
|
|
|
146
225
|
}
|
|
147
226
|
}
|
|
148
227
|
/** Read new bytes from transcript file since readFileSize */
|
|
149
|
-
readNewLines() {
|
|
228
|
+
readNewLines(uncapped = false) {
|
|
150
229
|
if (!this.transcriptPath || !existsSync(this.transcriptPath)) {
|
|
151
230
|
return { lines: [], nextFileSize: this.readFileSize };
|
|
152
231
|
}
|
|
153
232
|
let fd = null;
|
|
154
233
|
try {
|
|
234
|
+
this._pendingReadMore = false;
|
|
155
235
|
const stat = this._pendingStat ?? statSync(this.transcriptPath);
|
|
156
236
|
this._pendingStat = null;
|
|
157
237
|
// File shrank below our cursor: the transcript was rotated out from
|
|
@@ -166,16 +246,33 @@ class OutputForwarder {
|
|
|
166
246
|
return { lines: [], nextFileSize: this.readFileSize };
|
|
167
247
|
}
|
|
168
248
|
const startOffset = this.readFileSize;
|
|
249
|
+
const available = stat.size - startOffset;
|
|
250
|
+
// Cap the per-tick read (streaming path). The final-flush path passes
|
|
251
|
+
// uncapped=true to drain the whole tail in one shot.
|
|
252
|
+
let readLen = uncapped ? available : Math.min(available, OutputForwarder.MAX_TICK_READ_BYTES);
|
|
169
253
|
fd = openSync(this.transcriptPath, "r");
|
|
170
|
-
|
|
171
|
-
readSync(fd, buf, 0,
|
|
254
|
+
let buf = Buffer.alloc(readLen);
|
|
255
|
+
readSync(fd, buf, 0, readLen, startOffset);
|
|
172
256
|
// Only advance readFileSize to the last newline boundary. A trailing
|
|
173
257
|
// partial line (writer still appending) must be re-read next tick;
|
|
174
258
|
// otherwise the parse-failed slice is silently consumed forever.
|
|
175
|
-
|
|
259
|
+
let lastNl = buf.lastIndexOf(0x0a);
|
|
260
|
+
// The capped window split a single JSONL line larger than the cap (no
|
|
261
|
+
// newline inside it). Fall back to reading the full available range this
|
|
262
|
+
// tick so the line boundary is found — matches the pre-cap behavior and
|
|
263
|
+
// avoids getting stuck forever on an oversize line.
|
|
264
|
+
if (lastNl < 0 && readLen < available) {
|
|
265
|
+
readLen = available;
|
|
266
|
+
buf = Buffer.alloc(readLen);
|
|
267
|
+
readSync(fd, buf, 0, readLen, startOffset);
|
|
268
|
+
lastNl = buf.lastIndexOf(0x0a);
|
|
269
|
+
}
|
|
176
270
|
const consumed = lastNl >= 0 ? lastNl + 1 : 0;
|
|
177
271
|
const nextFileSize = startOffset + consumed;
|
|
178
272
|
this.readFileSize = nextFileSize;
|
|
273
|
+
// Capped read left more complete bytes on disk: signal the poll callback
|
|
274
|
+
// to re-tick so a burst drains without waiting on another fs event.
|
|
275
|
+
this._pendingReadMore = readLen < available && nextFileSize > startOffset;
|
|
179
276
|
const text = consumed > 0 ? buf.slice(0, consumed).toString("utf8") : "";
|
|
180
277
|
return {
|
|
181
278
|
lines: text ? text.split("\n").filter((l) => l.trim()) : [],
|
|
@@ -193,8 +290,8 @@ class OutputForwarder {
|
|
|
193
290
|
lastToolName = "";
|
|
194
291
|
lastToolFilePath = "";
|
|
195
292
|
/** Extract new assistant text + tool logs from transcript since readFileSize */
|
|
196
|
-
extractNewText() {
|
|
197
|
-
const { lines: newLines, nextFileSize } = this.readNewLines();
|
|
293
|
+
extractNewText(uncapped = false) {
|
|
294
|
+
const { lines: newLines, nextFileSize } = this.readNewLines(uncapped);
|
|
198
295
|
let newText = "";
|
|
199
296
|
for (const l of newLines) {
|
|
200
297
|
try {
|
|
@@ -282,6 +379,7 @@ class OutputForwarder {
|
|
|
282
379
|
}
|
|
283
380
|
async deliverQueueItem(item) {
|
|
284
381
|
const targetChannelId = item.channelId ?? this.channelId;
|
|
382
|
+
const gen = ++this._activeSendGen;
|
|
285
383
|
if (!item.text || !targetChannelId) {
|
|
286
384
|
this.commitReadProgress(item.nextFileSize);
|
|
287
385
|
return;
|
|
@@ -314,10 +412,13 @@ class OutputForwarder {
|
|
|
314
412
|
// re-sending chunks that already landed. The token is opaque here —
|
|
315
413
|
// the forwarder only stores/passes/clears it, never interprets it.
|
|
316
414
|
await this.cb.send(targetChannelId, formatted, item._resumeToken ? { resumeToken: item._resumeToken } : undefined);
|
|
415
|
+
dropTrace("discord.send.ok", null);
|
|
416
|
+
// A zombie completion (send abandoned past the settlement cap, gen bumped)
|
|
417
|
+
// must not mutate ANY item/queue state — including the resume token.
|
|
418
|
+
if (gen !== this._activeSendGen) return;
|
|
317
419
|
// Full success — drop any stale token so a later reuse of this item
|
|
318
420
|
// object can't carry a dead token.
|
|
319
421
|
item._resumeToken = undefined;
|
|
320
|
-
dropTrace("discord.send.ok", null);
|
|
321
422
|
} catch (err) {
|
|
322
423
|
// Persist the opaque resume token (if any) on the item so the requeued
|
|
323
424
|
// retry resumes from the failed chunk.
|
|
@@ -349,7 +450,18 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
349
450
|
}
|
|
350
451
|
/** Forward new assistant text to Discord. Returns true if text was sent. */
|
|
351
452
|
async forwardNewText() {
|
|
352
|
-
if (!this._isOwner())
|
|
453
|
+
if (!this._isOwner()) {
|
|
454
|
+
// Silent no-send is invisible and has masked real ownership-gate bugs
|
|
455
|
+
// (a live seat that never forwards). Surface it, rate-limited to one
|
|
456
|
+
// line per 60s so a persistent non-owner state stays visible without
|
|
457
|
+
// flooding stderr on every poll tick.
|
|
458
|
+
const now = Date.now();
|
|
459
|
+
if (!this._lastOwnerGateLogAt || now - this._lastOwnerGateLogAt >= 60_000) {
|
|
460
|
+
this._lastOwnerGateLogAt = now;
|
|
461
|
+
process.stderr.write(`[output-forwarder] forwardNewText skipped: not seat owner (channelId=${this.channelId ?? "none"})\n`);
|
|
462
|
+
}
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
353
465
|
if (!this.channelId) return false;
|
|
354
466
|
const { text: newText, nextFileSize } = this.extractNewText();
|
|
355
467
|
if (!newText) {
|
|
@@ -428,6 +540,31 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
428
540
|
_kickDrain() {
|
|
429
541
|
this.drainQueue().catch(err => process.stderr.write(`[output-forwarder] drainQueue rejected: ${err?.message || err}\n`));
|
|
430
542
|
}
|
|
543
|
+
/** Supervise a per-item send. The underlying promise is AWAITED to real
|
|
544
|
+
* settlement so `sending` is never released early (no unsupervised send
|
|
545
|
+
* racing a retry → duplicate). The watchdog's only job is to reset the
|
|
546
|
+
* backend's wedged client so the awaited send fails fast. */
|
|
547
|
+
_sendSupervised(promise, label) {
|
|
548
|
+
const settled = Promise.resolve(promise);
|
|
549
|
+
settled.catch(() => {}); // never leak an unhandled rejection on late settle
|
|
550
|
+
return new Promise((resolve, reject) => {
|
|
551
|
+
let capTimer = null;
|
|
552
|
+
const watchdog = setTimeout(() => {
|
|
553
|
+
process.stderr.write(`[output-forwarder] ${label} watchdog fired after ${SEND_ITEM_TIMEOUT_MS}ms; resetting backend\n`);
|
|
554
|
+
Promise.resolve(this.cb?.resetBackend?.()).catch(() => {});
|
|
555
|
+
// Bound the post-reset settlement wait; past the cap, invalidate the
|
|
556
|
+
// in-flight send (gen bump → zombie can't double-advance) and release.
|
|
557
|
+
capTimer = setTimeout(() => {
|
|
558
|
+
this._activeSendGen++;
|
|
559
|
+
reject(new Error(`${label} abandoned after ${SEND_SETTLE_CAP_MS}ms settlement cap`));
|
|
560
|
+
}, SEND_SETTLE_CAP_MS);
|
|
561
|
+
}, SEND_ITEM_TIMEOUT_MS);
|
|
562
|
+
settled.then(
|
|
563
|
+
(v) => { clearTimeout(watchdog); if (capTimer) clearTimeout(capTimer); resolve(v); },
|
|
564
|
+
(e) => { clearTimeout(watchdog); if (capTimer) clearTimeout(capTimer); reject(e); }
|
|
565
|
+
);
|
|
566
|
+
});
|
|
567
|
+
}
|
|
431
568
|
/** Drain both priority lanes sequentially. finalLane drains first when non-empty. */
|
|
432
569
|
async drainQueue() {
|
|
433
570
|
if (this.sending) return;
|
|
@@ -440,9 +577,9 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
440
577
|
const item = lane[0];
|
|
441
578
|
try {
|
|
442
579
|
if (item.type === "text") {
|
|
443
|
-
await this.deliverQueueItem(item);
|
|
580
|
+
await this._sendSupervised(this.deliverQueueItem(item), "deliverQueueItem");
|
|
444
581
|
} else if (item.type === "toolLog") {
|
|
445
|
-
await this.processToolLog(item);
|
|
582
|
+
await this._sendSupervised(this.processToolLog(item), "processToolLog");
|
|
446
583
|
}
|
|
447
584
|
lane.shift();
|
|
448
585
|
} catch (err) {
|
|
@@ -524,7 +661,9 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
524
661
|
await this.cb.removeReaction(channelId, this.userMessageId, this.emoji);
|
|
525
662
|
} catch {} // best-effort: remove reaction is non-critical
|
|
526
663
|
}
|
|
527
|
-
|
|
664
|
+
// Final flush drains the whole remaining tail (uncapped) so a turn's
|
|
665
|
+
// last write can't be split across ticks with nothing left to re-tick it.
|
|
666
|
+
const { text: newText, nextFileSize } = this.extractNewText(true);
|
|
528
667
|
if (newText) {
|
|
529
668
|
const finalItem = { type: "text", text: newText, nextFileSize, bufferText: newText, channelId };
|
|
530
669
|
try {
|
|
@@ -725,6 +864,9 @@ ${_bt.trim()}` : _bt.trim();
|
|
|
725
864
|
if (hadText) {
|
|
726
865
|
this.resetIdleTimer();
|
|
727
866
|
}
|
|
867
|
+
// A capped tick left more complete bytes on disk: re-arm a flush so the
|
|
868
|
+
// burst drains even if the writer has gone quiet (no further fs event).
|
|
869
|
+
if (this._pendingReadMore) this.scheduleWatchFlush();
|
|
728
870
|
}).catch(err => process.stderr.write(`[output-forwarder] forwardNewText rejected: ${err?.message || err}\n`));
|
|
729
871
|
}, 100);
|
|
730
872
|
}
|
|
@@ -33,9 +33,15 @@ function createOwnerHeartbeat({
|
|
|
33
33
|
function getBridgeOwnershipSnapshot() {
|
|
34
34
|
return currentOwnerState();
|
|
35
35
|
}
|
|
36
|
-
function claimBridgeOwnership(reason) {
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
function claimBridgeOwnership(reason, options = {}) {
|
|
37
|
+
// Returns true only when THIS instance actually holds the seat after the
|
|
38
|
+
// write. With options.timeoutMs:0 (try-once) a contended lock throws
|
|
39
|
+
// ELOCKCONTENDED — the caller catches it and treats the seat as busy.
|
|
40
|
+
const refreshOpts = Number.isFinite(options.timeoutMs) ? { timeoutMs: options.timeoutMs } : undefined;
|
|
41
|
+
const res = refreshActiveInstance(getInstanceId(), undefined, refreshOpts);
|
|
42
|
+
const claimed = res?.instanceId === getInstanceId();
|
|
43
|
+
if (claimed) logOwnership(`claimed owner (${reason})`);
|
|
44
|
+
return claimed;
|
|
39
45
|
}
|
|
40
46
|
function startOwnerHeartbeat() {
|
|
41
47
|
if (ownerHeartbeatTimer) return;
|
|
@@ -49,7 +55,10 @@ function createOwnerHeartbeat({
|
|
|
49
55
|
// onlyIfOwned: re-checks ownership INSIDE the file lock so a newer
|
|
50
56
|
// owner claiming the seat between currentOwnerState() and the locked
|
|
51
57
|
// write is never overwritten (see refreshActiveInstance CAS guard).
|
|
52
|
-
|
|
58
|
+
// Try-once (timeoutMs:0): the heartbeat must never block on the
|
|
59
|
+
// active-instance lock. On contention refreshActiveInstance throws and
|
|
60
|
+
// we simply skip this tick — the next tick catches up.
|
|
61
|
+
if (currentOwnerState().owned) refreshActiveInstance(getInstanceId(), undefined, { onlyIfOwned: true, timeoutMs: 0 });
|
|
53
62
|
} catch (e) {
|
|
54
63
|
process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
55
64
|
}
|
|
@@ -211,6 +211,34 @@ function writeActiveInstance(state) {
|
|
|
211
211
|
ensureRuntimeDirs();
|
|
212
212
|
writeJsonFile(ACTIVE_INSTANCE_FILE, state);
|
|
213
213
|
}
|
|
214
|
+
// Non-blocking ownership probe for the periodic refresh/heartbeat tick. Reads
|
|
215
|
+
// active-instance WITHOUT taking the lock (never blocks). Distinguishes:
|
|
216
|
+
// 'absent' — file does not exist → seat is claimable
|
|
217
|
+
// 'stale' — parseable but owner PID is dead → seat is claimable
|
|
218
|
+
// 'live' — parseable, owner PID alive → seat is held (state.instanceId)
|
|
219
|
+
// 'unknown' — file present but unreadable/partial (concurrent atomic rename)
|
|
220
|
+
// → INDETERMINATE; callers must treat as busy and NEVER claim.
|
|
221
|
+
// This is the read-side guard for "locked/unreadable = busy/unknown owner,
|
|
222
|
+
// never claimable/no-owner": a torn read during another writer's rename must
|
|
223
|
+
// not be mistaken for an empty seat.
|
|
224
|
+
function probeActiveOwner() {
|
|
225
|
+
try { statSync(ACTIVE_INSTANCE_FILE); } catch { return { status: 'absent', state: null }; }
|
|
226
|
+
let raw = readJsonFile(ACTIVE_INSTANCE_FILE, null);
|
|
227
|
+
if (!raw) {
|
|
228
|
+
// Transient partial content during an atomic rename — retry once.
|
|
229
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
230
|
+
raw = readJsonFile(ACTIVE_INSTANCE_FILE, null);
|
|
231
|
+
if (!raw) {
|
|
232
|
+
// Re-check existence to disambiguate a completed delete (absent) from a
|
|
233
|
+
// still-unreadable file (unknown/busy).
|
|
234
|
+
try { statSync(ACTIVE_INSTANCE_FILE); } catch { return { status: 'absent', state: null }; }
|
|
235
|
+
return { status: 'unknown', state: null };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const staleReason = activeInstanceStaleReason(raw);
|
|
239
|
+
if (staleReason) return { status: 'stale', state: raw, staleReason };
|
|
240
|
+
return { status: 'live', state: raw };
|
|
241
|
+
}
|
|
214
242
|
function buildActiveInstanceState(instanceId, meta) {
|
|
215
243
|
const gatewayMeta = Object.fromEntries(
|
|
216
244
|
Object.entries(meta || {}).filter(([k]) => k.startsWith('gateway_'))
|
|
@@ -233,6 +261,11 @@ function buildActiveInstanceState(instanceId, meta) {
|
|
|
233
261
|
}
|
|
234
262
|
function refreshActiveInstance(instanceId, meta, options) {
|
|
235
263
|
ensureRuntimeDirs();
|
|
264
|
+
// Periodic refresh/heartbeat callers pass options.timeoutMs:0 (try-once) so
|
|
265
|
+
// the ownership tick never blocks on the active-instance lock — contention
|
|
266
|
+
// throws ELOCKCONTENDED, which the caller treats as "busy, skip this tick".
|
|
267
|
+
const writeOpts = { compact: true, fsync: false, fsyncDir: false, renameFallback: 'truncate' };
|
|
268
|
+
if (options && Number.isFinite(options.timeoutMs)) writeOpts.timeoutMs = options.timeoutMs;
|
|
236
269
|
return updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
|
|
237
270
|
const prevForPreserve = curRaw;
|
|
238
271
|
const prev = activeInstanceStaleReason(curRaw) ? null : curRaw;
|
|
@@ -360,8 +393,21 @@ function refreshActiveInstance(instanceId, meta, options) {
|
|
|
360
393
|
}
|
|
361
394
|
}
|
|
362
395
|
}
|
|
396
|
+
// Worker-owned seat: when THIS process is the channels worker and it is
|
|
397
|
+
// the seat owner (no distinct TUI/supervisor Lead — ownerLeadPid resolves
|
|
398
|
+
// to our own pid), keep ui_heartbeat_at fresh. A headless worker never
|
|
399
|
+
// runs the TUI render loop, so an inherited heartbeat (written by a prior
|
|
400
|
+
// TUI session and carried forward in prevRest) would otherwise go stale at
|
|
401
|
+
// UI_HEARTBEAT_STALE_MS and falsely flag a live worker-owned seat. A
|
|
402
|
+
// TUI-owned seat has a distinct supervisor (ownerLeadPid !== our pid), so
|
|
403
|
+
// this branch is a no-op there and TUI staleness is preserved.
|
|
404
|
+
const workerOwnsSeat = process.env.MIXDOG_WORKER_MODE === "1"
|
|
405
|
+
&& identity.ownerLeadPid === process.pid;
|
|
406
|
+
if (workerOwnsSeat) {
|
|
407
|
+
next.ui_heartbeat_at = Date.now();
|
|
408
|
+
}
|
|
363
409
|
return { ...preservedExtra, ...next };
|
|
364
|
-
},
|
|
410
|
+
}, writeOpts);
|
|
365
411
|
}
|
|
366
412
|
const SERVER_PID_FILE = join(
|
|
367
413
|
RUNTIME_ROOT,
|
|
@@ -512,6 +558,7 @@ export {
|
|
|
512
558
|
notePreviousServerIfAny,
|
|
513
559
|
makeInstanceId,
|
|
514
560
|
readActiveInstance,
|
|
561
|
+
probeActiveOwner,
|
|
515
562
|
refreshActiveInstance,
|
|
516
563
|
releaseOwnedChannelLocks,
|
|
517
564
|
touchUiHeartbeat,
|
|
@@ -19,10 +19,12 @@ function createToolDispatch({
|
|
|
19
19
|
const {
|
|
20
20
|
getBridgeRuntimeConnected,
|
|
21
21
|
getChannelBridgeActive,
|
|
22
|
+
getOwned,
|
|
22
23
|
setChannelBridgeActive,
|
|
23
24
|
writeBridgeState,
|
|
24
25
|
stopServerTyping,
|
|
25
26
|
claimBridgeOwnership,
|
|
27
|
+
notifyRemoteAcquired,
|
|
26
28
|
refreshBridgeOwnership,
|
|
27
29
|
bindPersistedTranscriptIfAny,
|
|
28
30
|
stopOwnedRuntime,
|
|
@@ -48,13 +50,21 @@ function createToolDispatch({
|
|
|
48
50
|
setChannelBridgeActive(active);
|
|
49
51
|
writeBridgeState(active);
|
|
50
52
|
if (active) {
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
53
|
+
// Claim the seat only when we are NOT already the owner — a
|
|
54
|
+
// genuine /remote takeover or re-occupy after being superseded.
|
|
55
|
+
// When this session already owns the seat (e.g. the boot-time
|
|
56
|
+
// activate that fires right after start() claimed after READY),
|
|
57
|
+
// skip the redundant claim so boot performs ONE claim, not a
|
|
58
|
+
// remote-start + re-activate double claim (the latter forced a
|
|
59
|
+
// double reconnect). refreshBridgeOwnership below still re-pins
|
|
60
|
+
// the forwarder binding onto THIS session either way.
|
|
61
|
+
if (getOwned?.() !== true) {
|
|
62
|
+
claimBridgeOwnership(wasActive ? "re-activate takeover" : "bridge activated");
|
|
63
|
+
// Genuine acquire transition (we were NOT the owner) — tell the
|
|
64
|
+
// parent to flip remote ON. Not fired when we already own the
|
|
65
|
+
// seat, so an idempotent re-activate never re-notifies.
|
|
66
|
+
notifyRemoteAcquired?.();
|
|
67
|
+
}
|
|
58
68
|
try {
|
|
59
69
|
await refreshBridgeOwnership({ restoreBinding: true });
|
|
60
70
|
// An already-connected owner returns early from
|
|
@@ -167,9 +167,11 @@ function createVoiceTranscription({ getConfig, dataDir }) {
|
|
|
167
167
|
if (attachmentId && result) _voiceTranscriptCache.set(attachmentId, result);
|
|
168
168
|
return result;
|
|
169
169
|
} catch (err) {
|
|
170
|
-
|
|
170
|
+
// Propagate ALL real failures so the caller's retry (network-class) and
|
|
171
|
+
// failure marker (index.mjs catch) fire; null is reserved strictly for a
|
|
172
|
+
// legit empty transcript.
|
|
171
173
|
process.stderr.write(`mixdog: voice.transcription failed: ${err}\n`);
|
|
172
|
-
|
|
174
|
+
throw err;
|
|
173
175
|
}
|
|
174
176
|
}
|
|
175
177
|
|
|
@@ -50,8 +50,14 @@ export function buildRecallScopeFilter(offset, options = {}, tableAlias = '') {
|
|
|
50
50
|
]
|
|
51
51
|
const params = []
|
|
52
52
|
let next = offset
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// Treat null AND undefined as "no bound". Number(null) === 0 (finite), so a
|
|
54
|
+
// caller forwarding a null ts bound would otherwise inject `ts >= 0` /
|
|
55
|
+
// `ts <= 0` — the latter silently drops every row (epoch-ms ts > 0). Callers
|
|
56
|
+
// pass null for "absent" throughout the recall path; never coerce it to 0.
|
|
57
|
+
const tsFrom = options.ts_from == null ? null
|
|
58
|
+
: (Number.isFinite(Number(options.ts_from)) ? Number(options.ts_from) : null)
|
|
59
|
+
const tsTo = options.ts_to == null ? null
|
|
60
|
+
: (Number.isFinite(Number(options.ts_to)) ? Number(options.ts_to) : null)
|
|
55
61
|
if (tsFrom != null) { clauses.push(`${p}ts >= $${next++}`); params.push(tsFrom) }
|
|
56
62
|
if (tsTo != null) { clauses.push(`${p}ts <= $${next++}`); params.push(tsTo) }
|
|
57
63
|
const excludeStatuses = Array.isArray(options.excludeStatuses)
|