mixdog 0.9.12 → 0.9.13
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 +1 -1
- package/scripts/internal-comms-bench.mjs +1 -0
- package/scripts/internal-comms-smoke.mjs +11 -7
- package/src/lib/rules-builder.cjs +15 -11
- package/src/mixdog-session-runtime.mjs +10 -0
- package/src/rules/agent/00-common.md +5 -17
- package/src/rules/agent/00-core.md +21 -0
- package/src/rules/lead/lead-brief.md +7 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
- package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
- package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
- package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
- package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
- package/src/runtime/agent/orchestrator/session/manager.mjs +27 -14
- package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
- package/src/runtime/channels/index.mjs +28 -1
- package/src/runtime/channels/lib/memory-client.mjs +200 -15
- package/src/runtime/memory/index.mjs +11 -11
- package/src/runtime/memory/lib/cycle-scheduler.mjs +6 -4
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +22 -17
- package/src/runtime/memory/lib/memory-embed.mjs +35 -1
- package/src/standalone/agent-tool.mjs +89 -7
- package/src/workflows/default/WORKFLOW.md +2 -0
- package/src/workflows/sequential/WORKFLOW.md +2 -0
- package/src/workflows/solo/WORKFLOW.md +2 -0
|
@@ -31,7 +31,7 @@ async function getMemoryPort() {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
async function memoryFetch(method, endpoint, body = null, timeoutMs = 10_000) {
|
|
34
|
+
async function memoryFetch(method, endpoint, body = null, timeoutMs = 10_000, { throwOnError = false } = {}) {
|
|
35
35
|
const port = await getMemoryPort()
|
|
36
36
|
return new Promise((resolve, reject) => {
|
|
37
37
|
if (!port) { reject(new Error('active-instance.json missing memory_port')); return }
|
|
@@ -49,8 +49,20 @@ async function memoryFetch(method, endpoint, body = null, timeoutMs = 10_000) {
|
|
|
49
49
|
let data = ''
|
|
50
50
|
res.on('data', chunk => { data += chunk })
|
|
51
51
|
res.on('end', () => {
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
const status = res.statusCode || 0
|
|
53
|
+
let parsed
|
|
54
|
+
try { parsed = JSON.parse(data) }
|
|
55
|
+
catch { parsed = { raw: data } }
|
|
56
|
+
// Live callers keep lenient semantics (resolve on any response).
|
|
57
|
+
// Drain replay passes throwOnError so a non-2xx status or an
|
|
58
|
+
// {error} body is treated as a FAILED replay — the buffer file is
|
|
59
|
+
// then kept for retry instead of being unlinked (data-loss guard).
|
|
60
|
+
if (throwOnError && (status < 200 || status >= 300 || (parsed && parsed.error != null))) {
|
|
61
|
+
const detail = parsed && parsed.error != null ? String(parsed.error) : `HTTP ${status}`
|
|
62
|
+
reject(new Error(`memory replay rejected: ${detail}`))
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
resolve(parsed)
|
|
54
66
|
})
|
|
55
67
|
})
|
|
56
68
|
req.on('error', reject)
|
|
@@ -61,6 +73,10 @@ async function memoryFetch(method, endpoint, body = null, timeoutMs = 10_000) {
|
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
const BUFFER_DIR = path.join(RUNTIME_ROOT, 'memory-buffer')
|
|
76
|
+
const DEAD_DIR = path.join(BUFFER_DIR, 'dead')
|
|
77
|
+
const MAX_DRAIN_ATTEMPTS = 5
|
|
78
|
+
const MAX_BUFFER_FILES = 500
|
|
79
|
+
let _draining = false
|
|
64
80
|
|
|
65
81
|
function normalizeTs(ts) {
|
|
66
82
|
if (typeof ts === 'number' && Number.isFinite(ts)) {
|
|
@@ -87,16 +103,8 @@ export async function appendEntry(data) {
|
|
|
87
103
|
return await memoryFetch('POST', '/entry', payload, 3_000)
|
|
88
104
|
} catch (e) {
|
|
89
105
|
process.stderr.write(`[memory-client] appendEntry failed (${e.message}) — buffering\n`)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const random = Math.random().toString(36).slice(2, 10)
|
|
93
|
-
const bufferPath = path.join(BUFFER_DIR, `entry-${Date.now()}-${random}.json`)
|
|
94
|
-
fs.writeFileSync(bufferPath, JSON.stringify(payload, null, 2))
|
|
95
|
-
return { ok: false, buffered: true, path: bufferPath }
|
|
96
|
-
} catch (bufErr) {
|
|
97
|
-
process.stderr.write(`[memory-client] Failed to buffer entry: ${bufErr.message}\n`)
|
|
98
|
-
return { ok: false }
|
|
99
|
-
}
|
|
106
|
+
const bufferPath = bufferToDisk('entry', payload)
|
|
107
|
+
return bufferPath ? { ok: false, buffered: true, path: bufferPath } : { ok: false }
|
|
100
108
|
}
|
|
101
109
|
}
|
|
102
110
|
|
|
@@ -104,8 +112,185 @@ export async function ingestTranscript(filePath, { cwd } = {}) {
|
|
|
104
112
|
try {
|
|
105
113
|
return await memoryFetch('POST', '/ingest-transcript', { filePath, ...(cwd ? { cwd } : {}) })
|
|
106
114
|
} catch (e) {
|
|
107
|
-
process.stderr.write(`[memory-client] ingestTranscript failed
|
|
108
|
-
|
|
115
|
+
process.stderr.write(`[memory-client] ingestTranscript failed (${e.message}) — buffering\n`)
|
|
116
|
+
// Dedupe by transcriptPath: replace any already-buffered ingest for the
|
|
117
|
+
// same file so a re-ingest storm cannot fan out to N buffer files.
|
|
118
|
+
const bufferPath = bufferToDisk('ingest', { filePath, ...(cwd ? { cwd } : {}) }, { dedupeKey: filePath })
|
|
119
|
+
return bufferPath ? { ok: false, buffered: true, path: bufferPath } : { ok: false }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Persist a failed request so the drainer can replay it once the memory
|
|
124
|
+
// service publishes its port. `kind` selects the replay endpoint on drain.
|
|
125
|
+
// dedupeKey (ingest): if an existing kind-* file already carries the same
|
|
126
|
+
// key, overwrite it in place instead of writing a new file — one buffered
|
|
127
|
+
// ingest per transcriptPath. Enforces MAX_BUFFER_FILES (drop-oldest+warn).
|
|
128
|
+
// Atomic write: stage to a unique tmp file in the SAME dir, then rename over
|
|
129
|
+
// the target. rename() is atomic on a single filesystem, so a concurrent
|
|
130
|
+
// reader/drainer never observes a half-written buffer file.
|
|
131
|
+
function atomicWrite(targetPath, contents) {
|
|
132
|
+
const tmp = `${targetPath}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
133
|
+
fs.writeFileSync(tmp, contents)
|
|
134
|
+
try { fs.renameSync(tmp, targetPath) }
|
|
135
|
+
catch (e) { try { fs.unlinkSync(tmp) } catch {}; throw e }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function bufferToDisk(kind, payload, { dedupeKey = null } = {}) {
|
|
139
|
+
try {
|
|
140
|
+
fs.mkdirSync(BUFFER_DIR, { recursive: true })
|
|
141
|
+
if (dedupeKey != null) {
|
|
142
|
+
let existing = []
|
|
143
|
+
try { existing = fs.readdirSync(BUFFER_DIR) } catch {}
|
|
144
|
+
for (const name of existing) {
|
|
145
|
+
if (!name.startsWith(`${kind}-`) || !name.endsWith('.json')) continue
|
|
146
|
+
const full = path.join(BUFFER_DIR, name)
|
|
147
|
+
try {
|
|
148
|
+
const prev = JSON.parse(fs.readFileSync(full, 'utf8'))
|
|
149
|
+
if (prev && prev.filePath === dedupeKey) {
|
|
150
|
+
// Atomic overwrite (tmp+rename); preserves the original (oldest)
|
|
151
|
+
// ordering slot without exposing a half-written file to a drainer.
|
|
152
|
+
atomicWrite(full, JSON.stringify(payload, null, 2))
|
|
153
|
+
return full
|
|
154
|
+
}
|
|
155
|
+
} catch {}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
enforceBufferCap()
|
|
159
|
+
const random = Math.random().toString(36).slice(2, 10)
|
|
160
|
+
// Prefix carries the replay kind; timestamp prefix keeps oldest-first
|
|
161
|
+
// ordering under a lexicographic sort.
|
|
162
|
+
const bufferPath = path.join(BUFFER_DIR, `${kind}-${Date.now()}-${random}.json`)
|
|
163
|
+
atomicWrite(bufferPath, JSON.stringify(payload, null, 2))
|
|
164
|
+
return bufferPath
|
|
165
|
+
} catch (bufErr) {
|
|
166
|
+
process.stderr.write(`[memory-client] Failed to buffer ${kind}: ${bufErr.message}\n`)
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Move a buffer file to memory-buffer/dead/ (quarantine, never silent-drop).
|
|
172
|
+
// Returns true on success. On failure NEVER unlinks (no silent payload loss):
|
|
173
|
+
// leaves the file in place and returns false so the caller skips it this pass.
|
|
174
|
+
function moveToDead(name, reason) {
|
|
175
|
+
process.stderr.write(`[memory-client] quarantining ${name} to dead/ (${reason})\n`)
|
|
176
|
+
try {
|
|
177
|
+
fs.mkdirSync(DEAD_DIR, { recursive: true })
|
|
178
|
+
fs.renameSync(path.join(BUFFER_DIR, name), path.join(DEAD_DIR, name))
|
|
179
|
+
return true
|
|
180
|
+
} catch (e) {
|
|
181
|
+
process.stderr.write(`[memory-client] quarantine of ${name} failed (${e.message}) — leaving in place\n`)
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Cap the buffer directory: when at/over MAX_BUFFER_FILES, quarantine oldest
|
|
187
|
+
// files (lexicographic = ts-prefixed = oldest-first) to dead/ — never silently
|
|
188
|
+
// destroy data (MED: cap must preserve for triage, same as poison path).
|
|
189
|
+
function enforceBufferCap() {
|
|
190
|
+
let files
|
|
191
|
+
try {
|
|
192
|
+
files = fs.readdirSync(BUFFER_DIR)
|
|
193
|
+
.filter(f => (f.startsWith('entry-') || f.startsWith('ingest-')) && f.endsWith('.json'))
|
|
194
|
+
.sort()
|
|
195
|
+
} catch { return }
|
|
196
|
+
let over = files.length - (MAX_BUFFER_FILES - 1)
|
|
197
|
+
for (let i = 0; i < files.length && over > 0; i++, over--) {
|
|
198
|
+
moveToDead(files[i], `buffer cap ${MAX_BUFFER_FILES} exceeded — oldest`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Replay buffered entry-*/ingest-* files once the memory port is live.
|
|
203
|
+
// Oldest-first (filename carries a ms timestamp), dedupe-safe (each file is
|
|
204
|
+
// deleted only after a 2xx replay — memoryFetch(throwOnError) rejects on
|
|
205
|
+
// non-2xx/{error}, so a rejected replay keeps the file for retry, no data
|
|
206
|
+
// loss). Retry count is PERSISTED in the filename suffix (`.rN`) so process
|
|
207
|
+
// restarts don't reset the poison cap; after MAX_DRAIN_ATTEMPTS the file is
|
|
208
|
+
// MOVED to memory-buffer/dead/ (not deleted, not left blocking the queue).
|
|
209
|
+
// Reentrancy-guarded.
|
|
210
|
+
//
|
|
211
|
+
// Attempt count lives in the name: `<kind>-<ts>-<rnd>.json` (attempt 0) or
|
|
212
|
+
// `<kind>-<ts>-<rnd>.rN.json` (N prior failures). Parsed/rewritten via rename.
|
|
213
|
+
function parseRetry(name) {
|
|
214
|
+
const m = name.match(/\.r(\d+)\.json$/)
|
|
215
|
+
return m ? Number(m[1]) : 0
|
|
216
|
+
}
|
|
217
|
+
function retryName(name, n) {
|
|
218
|
+
const base = name.replace(/\.r\d+\.json$/, '.json').replace(/\.json$/, '')
|
|
219
|
+
return `${base}.r${n}.json`
|
|
220
|
+
}
|
|
221
|
+
export async function drainBuffer() {
|
|
222
|
+
if (_draining) return { ok: true, skipped: 'in-progress' }
|
|
223
|
+
const port = await getMemoryPort()
|
|
224
|
+
if (!port) return { ok: false, reason: 'no-port' }
|
|
225
|
+
_draining = true
|
|
226
|
+
let drained = 0
|
|
227
|
+
let failed = 0
|
|
228
|
+
// Files that could not be advanced this pass (rename lock/EPERM). Skipped so
|
|
229
|
+
// an un-rewritable file can't wedge the oldest-first queue forever; retried
|
|
230
|
+
// on the next drain (their on-disk .rN is unchanged, so the cap still holds).
|
|
231
|
+
const skipThisPass = new Set()
|
|
232
|
+
try {
|
|
233
|
+
let files
|
|
234
|
+
try {
|
|
235
|
+
files = fs.readdirSync(BUFFER_DIR)
|
|
236
|
+
} catch { return { ok: true, drained: 0 } }
|
|
237
|
+
files = files
|
|
238
|
+
.filter(f => (f.startsWith('entry-') || f.startsWith('ingest-')) && f.endsWith('.json'))
|
|
239
|
+
.sort() // ts-prefixed name => oldest-first
|
|
240
|
+
for (const name of files) {
|
|
241
|
+
if (skipThisPass.has(name)) continue
|
|
242
|
+
const bufferPath = path.join(BUFFER_DIR, name)
|
|
243
|
+
let payload
|
|
244
|
+
try {
|
|
245
|
+
payload = JSON.parse(fs.readFileSync(bufferPath, 'utf8'))
|
|
246
|
+
} catch {
|
|
247
|
+
// Unparseable/corrupt buffer file — a partial write may be in flight,
|
|
248
|
+
// or it may be genuinely corrupt. Do NOT unlink (silent data loss):
|
|
249
|
+
// quarantine to dead/ for triage and move on. If the quarantine move
|
|
250
|
+
// itself fails, skip it for this pass so it can't block oldest-first.
|
|
251
|
+
if (!moveToDead(name, 'unparseable buffer file')) skipThisPass.add(name)
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
const endpoint = name.startsWith('ingest-') ? '/ingest-transcript' : '/entry'
|
|
255
|
+
try {
|
|
256
|
+
// throwOnError: non-2xx status or an {error} body REJECTS, so the
|
|
257
|
+
// file is kept/aged for retry instead of unlinked (HIGH: data loss).
|
|
258
|
+
await memoryFetch('POST', endpoint, payload, 10_000, { throwOnError: true })
|
|
259
|
+
try { fs.unlinkSync(bufferPath) } catch {}
|
|
260
|
+
drained++
|
|
261
|
+
} catch (e) {
|
|
262
|
+
const attempts = parseRetry(name) + 1
|
|
263
|
+
failed++
|
|
264
|
+
if (attempts >= MAX_DRAIN_ATTEMPTS) {
|
|
265
|
+
// Quarantine, don't drop: move to dead/ so a poison record neither
|
|
266
|
+
// wedges the queue nor silently vanishes (recoverable for triage).
|
|
267
|
+
moveToDead(name, `${attempts} failed replays: ${e.message}`)
|
|
268
|
+
} else {
|
|
269
|
+
// Persist the incremented attempt count in the filename so a
|
|
270
|
+
// restart resumes the poison cap instead of resetting it. If the
|
|
271
|
+
// rename fails (EPERM/lock), leave the file as-is but skip it for
|
|
272
|
+
// THIS pass so it can't block the oldest-first queue forever — the
|
|
273
|
+
// next drain re-reads its (unchanged) .rN and retries.
|
|
274
|
+
try {
|
|
275
|
+
fs.renameSync(bufferPath, path.join(BUFFER_DIR, retryName(name, attempts)))
|
|
276
|
+
} catch {
|
|
277
|
+
// Rename lock/EPERM: don't break the whole pass on a file we
|
|
278
|
+
// couldn't even age — skip it and CONTINUE so later buffered
|
|
279
|
+
// files still drain. (Strict oldest-first yields to progress
|
|
280
|
+
// ONLY in this rename-failure case; a normal replay failure
|
|
281
|
+
// below still breaks, since the service is likely down.)
|
|
282
|
+
skipThisPass.add(name)
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Stop the pass on first failure: the service is likely still down,
|
|
287
|
+
// so hammering the rest wastes timeouts. Next drain retries in order.
|
|
288
|
+
break
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
_draining = false
|
|
109
293
|
}
|
|
294
|
+
return { ok: failed === 0, drained, failed }
|
|
110
295
|
}
|
|
111
296
|
|
|
@@ -152,6 +152,17 @@ function touchDaemonIdleTimer(reason = 'activity') {
|
|
|
152
152
|
function advertiseMemoryPort(boundPort, attempt = 0) {
|
|
153
153
|
if (!Number.isFinite(boundPort) || boundPort <= 0) return
|
|
154
154
|
_currentAdvertisedPort = boundPort
|
|
155
|
+
if (!_periodicAdvertiseInstalled) {
|
|
156
|
+
_periodicAdvertiseInstalled = true
|
|
157
|
+
_periodicAdvertiseTimer = setInterval(() => {
|
|
158
|
+
try {
|
|
159
|
+
if (_currentAdvertisedPort != null) {
|
|
160
|
+
advertiseMemoryPort(_currentAdvertisedPort)
|
|
161
|
+
}
|
|
162
|
+
} catch {}
|
|
163
|
+
}, 30_000)
|
|
164
|
+
_periodicAdvertiseTimer.unref?.()
|
|
165
|
+
}
|
|
155
166
|
const dir = RUNTIME_ROOT
|
|
156
167
|
const file = path.join(dir, 'active-instance.json')
|
|
157
168
|
try {
|
|
@@ -177,17 +188,6 @@ function advertiseMemoryPort(boundPort, attempt = 0) {
|
|
|
177
188
|
}
|
|
178
189
|
return next
|
|
179
190
|
}, { compact: true, fsyncDir: true, renameFallback: 'truncate' })
|
|
180
|
-
if (!_periodicAdvertiseInstalled) {
|
|
181
|
-
_periodicAdvertiseInstalled = true
|
|
182
|
-
_periodicAdvertiseTimer = setInterval(() => {
|
|
183
|
-
try {
|
|
184
|
-
if (_currentAdvertisedPort != null) {
|
|
185
|
-
advertiseMemoryPort(_currentAdvertisedPort)
|
|
186
|
-
}
|
|
187
|
-
} catch {}
|
|
188
|
-
}, 30_000)
|
|
189
|
-
_periodicAdvertiseTimer.unref?.()
|
|
190
|
-
}
|
|
191
191
|
} catch (e) {
|
|
192
192
|
const transient = e?.code === 'EPERM' || e?.code === 'EBUSY' || e?.code === 'EACCES'
|
|
193
193
|
if (transient && attempt < 3) {
|
|
@@ -324,15 +324,17 @@ export function createCycleScheduler(deps) {
|
|
|
324
324
|
log('[cycle2] skipped: in flight\n')
|
|
325
325
|
return
|
|
326
326
|
}
|
|
327
|
-
|
|
327
|
+
const gateFailed = result?.gate_failed === true
|
|
328
|
+
if (result.ok && !gateFailed) {
|
|
328
329
|
await setCycleLastRun('cycle2', Date.now())
|
|
329
330
|
await setCycleLastRun('cycle2_last_error', '')
|
|
330
331
|
log('[cycle2] completed\n')
|
|
331
332
|
markCycleDone('cycle2', true)
|
|
332
333
|
} else {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
334
|
+
const err = gateFailed ? 'gate_failed' : (result.error || 'unknown error')
|
|
335
|
+
await setCycleLastRun('cycle2_last_error', err)
|
|
336
|
+
log(`[cycle2] failed: ${err}\n`)
|
|
337
|
+
markCycleDone('cycle2', false, err)
|
|
336
338
|
}
|
|
337
339
|
}
|
|
338
340
|
|
|
@@ -48,8 +48,8 @@ function formatEntriesForPromotePrompt(rows, pidMap, opts = {}) {
|
|
|
48
48
|
const map = pidMap ?? buildPidMap([rows])
|
|
49
49
|
// When numbered, prefix each row with its 1-based prompt-order ordinal so the
|
|
50
50
|
// gate LLM can echo a row number it can see, instead of inventing one. The
|
|
51
|
-
// ordinal domain (1..N)
|
|
52
|
-
//
|
|
51
|
+
// ordinal domain (1..N) vs batch ids that share those integers on a *different*
|
|
52
|
+
// row — see batchOrdinalIdCollides in runUnifiedGate.
|
|
53
53
|
const numbered = opts.numbered === true
|
|
54
54
|
const lines = rows.map((r, i) => {
|
|
55
55
|
const tag = r.project_id ? (map.get(r.project_id) ?? 'C') : 'C'
|
|
@@ -78,6 +78,17 @@ function formatUserCoreForPrompt(rows, pidMap) {
|
|
|
78
78
|
// Parse pipe-format unified verdicts. Each line: <id>|<verb> [|...].
|
|
79
79
|
// Verbs validated against the row's current status via STATUS_ALLOWED_VERBS.
|
|
80
80
|
// Returns { actions, rejected } or null when no parseable lines.
|
|
81
|
+
// True when some entry id K in 1..N is also the row-ordinal label for a different
|
|
82
|
+
// entry (token K is ambiguous between id K and "row K").
|
|
83
|
+
export function batchOrdinalIdCollides(statusById, ordinalToId, rowCount) {
|
|
84
|
+
const n = Math.max(0, Number(rowCount) || 0)
|
|
85
|
+
for (let k = 1; k <= n; k++) {
|
|
86
|
+
if (!statusById.has(k)) continue
|
|
87
|
+
if (ordinalToId.get(k) !== k) return k
|
|
88
|
+
}
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
81
92
|
function parseUnifiedFormat(raw, statusById, ordinalToId = null) {
|
|
82
93
|
if (raw == null) return null
|
|
83
94
|
const text = String(raw).trim()
|
|
@@ -88,10 +99,9 @@ function parseUnifiedFormat(raw, statusById, ordinalToId = null) {
|
|
|
88
99
|
const support = new Map()
|
|
89
100
|
let sawValid = false
|
|
90
101
|
// Resolve a first-field/merge token to a real batch id. The gate may echo
|
|
91
|
-
// either the exact
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// falls back to ordinal lookup; anything else is NaN (line treated invalid).
|
|
102
|
+
// either the exact batch id OR the 1-based row ordinal shown in the numbered
|
|
103
|
+
// Entries block. When batchOrdinalIdCollides is false, an exact-id hit wins
|
|
104
|
+
// and an unmatched value falls back to ordinal lookup; anything else is NaN.
|
|
95
105
|
const resolveId = (tok) => {
|
|
96
106
|
const n = Number(String(tok ?? '').trim())
|
|
97
107
|
if (!Number.isFinite(n)) return NaN
|
|
@@ -333,18 +343,13 @@ export async function runUnifiedGate(db, rows, activeContext, config = {}, optio
|
|
|
333
343
|
|
|
334
344
|
const statusById = new Map(rows.map(r => [Number(r.id), String(r.status)]))
|
|
335
345
|
// Ordinal → batch-id map, keyed by 1-based prompt order (the same order the
|
|
336
|
-
// numbered Entries block uses). The gate may echo either the real
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
// ordinals are <= rows.length, so disjointness always holds in practice).
|
|
340
|
-
// On a violation a row-number line is indistinguishable from an exact-id
|
|
341
|
-
// line, so no safe interpretation exists — skip this batch (gate failure)
|
|
342
|
-
// rather than risk applying a verdict to the wrong entry. The cycle itself
|
|
343
|
-
// proceeds; the batch re-queues for a later run.
|
|
346
|
+
// numbered Entries block uses). The gate may echo either the real batch id
|
|
347
|
+
// or the row ordinal 1..N; the parser resolves both when no integer token is
|
|
348
|
+
// ambiguous (id K present in batch but row K points at a different entry).
|
|
344
349
|
const ordinalToId = new Map(rows.map((r, i) => [i + 1, Number(r.id)]))
|
|
345
|
-
const
|
|
346
|
-
if (
|
|
347
|
-
__mixdogMemoryLog(`[cycle2] batch id ${
|
|
350
|
+
const collideToken = batchOrdinalIdCollides(statusById, ordinalToId, rows.length)
|
|
351
|
+
if (collideToken != null) {
|
|
352
|
+
__mixdogMemoryLog(`[cycle2] batch id ${collideToken} collides with row ordinal ${collideToken} (id ${ordinalToId.get(collideToken)}) — skipping batch (no safe id resolution)\n`)
|
|
348
353
|
return { actions: null, rejected: new Set(), parseOk: false }
|
|
349
354
|
}
|
|
350
355
|
|
|
@@ -430,7 +430,41 @@ async function syncBatchEmbeddings(db, ids, options = {}) {
|
|
|
430
430
|
throwIfAborted(signal)
|
|
431
431
|
const writtenIds = (res.rows || []).map(r => Number(r.id))
|
|
432
432
|
if (writtenIds.length < updates.length) {
|
|
433
|
-
|
|
433
|
+
const writtenSet = new Set(writtenIds)
|
|
434
|
+
const missed = updates.filter((u) => !writtenSet.has(u.id))
|
|
435
|
+
const missedIds = missed.map((u) => u.id)
|
|
436
|
+
const curRows = missedIds.length
|
|
437
|
+
? (await db.query(
|
|
438
|
+
`SELECT id, embedding IS NOT NULL AS has_embedding, element, summary
|
|
439
|
+
FROM memory.entries WHERE id = ANY($1::bigint[]) AND is_root = 1`,
|
|
440
|
+
[missedIds],
|
|
441
|
+
)).rows
|
|
442
|
+
: []
|
|
443
|
+
const curById = new Map(curRows.map((r) => [Number(r.id), r]))
|
|
444
|
+
let staleSkips = 0
|
|
445
|
+
let concurrentSkips = 0
|
|
446
|
+
for (const u of missed) {
|
|
447
|
+
const cur = curById.get(u.id)
|
|
448
|
+
if (!cur) continue
|
|
449
|
+
const textUnchanged =
|
|
450
|
+
(cur.element === u.element || (cur.element == null && u.element == null)) &&
|
|
451
|
+
(cur.summary === u.summary || (cur.summary == null && u.summary == null))
|
|
452
|
+
if (!textUnchanged) {
|
|
453
|
+
staleSkips++
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
if (cur.has_embedding) {
|
|
457
|
+
concurrentSkips++
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
staleSkips++
|
|
461
|
+
}
|
|
462
|
+
if (staleSkips > 0) {
|
|
463
|
+
__mixdogMemoryLog(`[embed-sync] ${staleSkips} stale-write(s) skipped — text changed since read\n`)
|
|
464
|
+
}
|
|
465
|
+
if (concurrentSkips > 0 && process.env.MIXDOG_DEBUG_EMBED) {
|
|
466
|
+
__mixdogMemoryLog(`[embed-sync] ${concurrentSkips} concurrent embed write(s) skipped — already embedded\n`)
|
|
467
|
+
}
|
|
434
468
|
}
|
|
435
469
|
return writtenIds
|
|
436
470
|
}
|
|
@@ -16,9 +16,13 @@ import { updateJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
|
|
|
16
16
|
import { normalizeAgentPermission } from '../runtime/shared/markdown-frontmatter.mjs';
|
|
17
17
|
import { prepareAgentSession } from '../runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
|
|
18
18
|
import {
|
|
19
|
+
abortAgentProgressWatchdog,
|
|
19
20
|
agentWatchdogPolicyActive,
|
|
20
21
|
evaluateAgentWatchdogAbort,
|
|
21
22
|
resolveAgentWatchdogPolicy,
|
|
23
|
+
resolveHandoffMessageStartIndex,
|
|
24
|
+
watchdogPartialHandoffFromError,
|
|
25
|
+
AgentStallAbortError,
|
|
22
26
|
} from '../runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs';
|
|
23
27
|
import { buildAgentTaskProgressFields } from './agent-task-status.mjs';
|
|
24
28
|
import { AGENT_OWNER } from '../runtime/agent/orchestrator/agent-owner.mjs';
|
|
@@ -962,13 +966,15 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
962
966
|
}, notifyContext);
|
|
963
967
|
}
|
|
964
968
|
|
|
965
|
-
function startProgressIdleWatchdog(sessionId, watchdogPolicy) {
|
|
969
|
+
function startProgressIdleWatchdog(sessionId, watchdogPolicy, agent = null) {
|
|
966
970
|
if (!sessionId || !agentWatchdogPolicyActive(watchdogPolicy)) return null;
|
|
967
971
|
if (typeof mgr.getSessionProgressSnapshot !== 'function' && typeof mgr.getSessionLastProgressAt !== 'function') return null;
|
|
968
972
|
if (typeof mgr.linkParentSignalToSession !== 'function') return null;
|
|
969
973
|
const controller = new AbortController();
|
|
974
|
+
const anchorTs = Date.now();
|
|
970
975
|
try { mgr.linkParentSignalToSession(sessionId, controller.signal); } catch { return null; }
|
|
971
976
|
const timer = setInterval(() => {
|
|
977
|
+
if (controller.signal?.aborted) return;
|
|
972
978
|
const now = Date.now();
|
|
973
979
|
const snapshot = typeof mgr.getSessionProgressSnapshot === 'function'
|
|
974
980
|
? mgr.getSessionProgressSnapshot(sessionId)
|
|
@@ -976,15 +982,36 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
976
982
|
const abortErr = snapshot
|
|
977
983
|
? evaluateAgentWatchdogAbort(snapshot, now, watchdogPolicy)
|
|
978
984
|
: null;
|
|
985
|
+
const sess = typeof mgr.getSession === 'function' ? mgr.getSession(sessionId) : null;
|
|
986
|
+
const iteration = typeof sess?.lastIterationIndex === 'number' ? sess.lastIterationIndex : null;
|
|
979
987
|
if (!abortErr && !snapshot) {
|
|
980
|
-
const
|
|
981
|
-
|
|
982
|
-
|
|
988
|
+
const reported = mgr.getSessionLastProgressAt(sessionId);
|
|
989
|
+
const last = reported || anchorTs;
|
|
990
|
+
if (watchdogPolicy.idleStaleMs > 0 && now - last > watchdogPolicy.idleStaleMs) {
|
|
991
|
+
abortAgentProgressWatchdog(controller, {
|
|
992
|
+
sessionId,
|
|
993
|
+
agent,
|
|
994
|
+
error: new AgentStallAbortError(`agent task stale (${watchdogPolicy.idleStaleMs}ms without progress)`),
|
|
995
|
+
policy: watchdogPolicy,
|
|
996
|
+
now,
|
|
997
|
+
anchorTs,
|
|
998
|
+
lastProgressAt: reported,
|
|
999
|
+
iteration,
|
|
1000
|
+
});
|
|
983
1001
|
}
|
|
984
1002
|
return;
|
|
985
1003
|
}
|
|
986
1004
|
if (abortErr) {
|
|
987
|
-
|
|
1005
|
+
abortAgentProgressWatchdog(controller, {
|
|
1006
|
+
sessionId,
|
|
1007
|
+
agent,
|
|
1008
|
+
error: abortErr,
|
|
1009
|
+
snapshot,
|
|
1010
|
+
policy: watchdogPolicy,
|
|
1011
|
+
now,
|
|
1012
|
+
anchorTs,
|
|
1013
|
+
iteration,
|
|
1014
|
+
});
|
|
988
1015
|
}
|
|
989
1016
|
}, 1000);
|
|
990
1017
|
timer.unref?.();
|
|
@@ -1072,7 +1099,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
1072
1099
|
|
|
1073
1100
|
async function runSpawn(prepared, notifyContext = null, job = null) {
|
|
1074
1101
|
const { args, tag, session, agent, preset, presetName, workerCwd, prompt, watchdogPolicy } = prepared;
|
|
1075
|
-
const watchdog = startProgressIdleWatchdog(session.id, watchdogPolicy);
|
|
1102
|
+
const watchdog = startProgressIdleWatchdog(session.id, watchdogPolicy, agent);
|
|
1076
1103
|
let finalStatus = 'idle';
|
|
1077
1104
|
// SubagentStart: a worker session is about to run its first turn.
|
|
1078
1105
|
emitSubagentEvent('start', agent, { session_id: session.id, tag });
|
|
@@ -1086,6 +1113,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
1086
1113
|
status: 'running',
|
|
1087
1114
|
stage: 'running',
|
|
1088
1115
|
});
|
|
1116
|
+
let handoffMsgStart = 0;
|
|
1089
1117
|
try {
|
|
1090
1118
|
const completionValue = (result) => {
|
|
1091
1119
|
// Promote an abnormal finish (iteration cap, truncation, or a public
|
|
@@ -1107,6 +1135,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
1107
1135
|
...(abnormalError ? { error: abnormalError } : {}),
|
|
1108
1136
|
};
|
|
1109
1137
|
};
|
|
1138
|
+
handoffMsgStart = resolveHandoffMessageStartIndex(mgr.getSession(session.id));
|
|
1110
1139
|
const result = await mgr.askSession(session.id, prompt, args.context || null, null, workerCwd, null, {
|
|
1111
1140
|
notifyFn: workerNotifyFn(session.id, notifyContext || {}),
|
|
1112
1141
|
...(job ? {
|
|
@@ -1155,6 +1184,33 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
1155
1184
|
}
|
|
1156
1185
|
return finalValue;
|
|
1157
1186
|
} catch (error) {
|
|
1187
|
+
const partial = watchdogPartialHandoffFromError(error, mgr.getSession(session.id), handoffMsgStart);
|
|
1188
|
+
if (partial) {
|
|
1189
|
+
finalStatus = 'idle';
|
|
1190
|
+
const value = {
|
|
1191
|
+
tag,
|
|
1192
|
+
sessionId: session.id,
|
|
1193
|
+
agent,
|
|
1194
|
+
preset: presetKey(preset) || presetName,
|
|
1195
|
+
provider: preset.provider,
|
|
1196
|
+
model: preset.model,
|
|
1197
|
+
effort: preset.effort || null,
|
|
1198
|
+
fast: preset.fast === true,
|
|
1199
|
+
content: partial,
|
|
1200
|
+
stallAbort: true,
|
|
1201
|
+
};
|
|
1202
|
+
if (job) job._terminalResultValue = value;
|
|
1203
|
+
if (job?.taskId) {
|
|
1204
|
+
try {
|
|
1205
|
+
reconcileBackgroundTask(job.taskId, {
|
|
1206
|
+
status: 'completed',
|
|
1207
|
+
result: value,
|
|
1208
|
+
terminalReason: 'agent-watchdog-partial',
|
|
1209
|
+
});
|
|
1210
|
+
} catch {}
|
|
1211
|
+
}
|
|
1212
|
+
return value;
|
|
1213
|
+
}
|
|
1158
1214
|
finalStatus = 'error';
|
|
1159
1215
|
// Part C: a mid-stream stall (StreamStalledError / ESTREAMSTALL) throws
|
|
1160
1216
|
// here WITHOUT a terminal result, so the finally reconcile below (gated on
|
|
@@ -1229,10 +1285,11 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
1229
1285
|
async function runSend(prepared, notifyContext = null, job = null) {
|
|
1230
1286
|
const { args, session, sessionId, prompt } = prepared;
|
|
1231
1287
|
const sendAgent = session.agent || normalizeAgentName(args.agent);
|
|
1232
|
-
const watchdog = startProgressIdleWatchdog(sessionId, resolveAgentWatchdogPolicy(sendAgent));
|
|
1288
|
+
const watchdog = startProgressIdleWatchdog(sessionId, resolveAgentWatchdogPolicy(sendAgent), sendAgent);
|
|
1233
1289
|
const tag = tagForSession(sessionId);
|
|
1234
1290
|
let finalStatus = 'idle';
|
|
1235
1291
|
upsertWorkerSessionDeferred(session, tag, { status: 'running', stage: 'running' });
|
|
1292
|
+
let handoffMsgStart = 0;
|
|
1236
1293
|
try {
|
|
1237
1294
|
const completionValue = (result) => {
|
|
1238
1295
|
// Same abnormal-empty → error promotion as runSpawn: a reused/`send`
|
|
@@ -1249,6 +1306,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
1249
1306
|
...(abnormalError ? { error: abnormalError } : {}),
|
|
1250
1307
|
};
|
|
1251
1308
|
};
|
|
1309
|
+
handoffMsgStart = resolveHandoffMessageStartIndex(mgr.getSession(sessionId));
|
|
1252
1310
|
const result = await mgr.askSession(sessionId, prompt, args.context || null, null, session.cwd || defaultCwd, null, {
|
|
1253
1311
|
notifyFn: workerNotifyFn(sessionId, notifyContext || {}),
|
|
1254
1312
|
...(job ? {
|
|
@@ -1287,6 +1345,30 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
1287
1345
|
}
|
|
1288
1346
|
return finalValue;
|
|
1289
1347
|
} catch (error) {
|
|
1348
|
+
const partial = watchdogPartialHandoffFromError(error, mgr.getSession(sessionId), handoffMsgStart);
|
|
1349
|
+
if (partial) {
|
|
1350
|
+
finalStatus = 'idle';
|
|
1351
|
+
const value = {
|
|
1352
|
+
tag,
|
|
1353
|
+
sessionId,
|
|
1354
|
+
agent: session.agent || null,
|
|
1355
|
+
provider: session.provider,
|
|
1356
|
+
model: session.model,
|
|
1357
|
+
content: partial,
|
|
1358
|
+
stallAbort: true,
|
|
1359
|
+
};
|
|
1360
|
+
if (job) job._terminalResultValue = value;
|
|
1361
|
+
if (job?.taskId) {
|
|
1362
|
+
try {
|
|
1363
|
+
reconcileBackgroundTask(job.taskId, {
|
|
1364
|
+
status: 'completed',
|
|
1365
|
+
result: value,
|
|
1366
|
+
terminalReason: 'agent-watchdog-partial',
|
|
1367
|
+
});
|
|
1368
|
+
} catch {}
|
|
1369
|
+
}
|
|
1370
|
+
return value;
|
|
1371
|
+
}
|
|
1290
1372
|
finalStatus = 'error';
|
|
1291
1373
|
// Part C (send path mirror): a mid-stream stall throws with no terminal
|
|
1292
1374
|
// result — reconcile to `failed` so the owner is notified rather than the
|
|
@@ -17,6 +17,8 @@ Lead supervises: delegates, coordinates, judges, decides. Route by complexity:
|
|
|
17
17
|
failed at least once.
|
|
18
18
|
|
|
19
19
|
1. Plan — discuss the request with the user, form a plan, wait for approval.
|
|
20
|
+
Only an explicit go-ahead ("do it", "proceed") is approval. Agreement with
|
|
21
|
+
a diagnosis, or pointing out a problem, is NOT execution approval.
|
|
20
22
|
2. Delegate — split into the maximum number of independent scopes.
|
|
21
23
|
- PARALLEL across independent scopes by default (implementation, analysis,
|
|
22
24
|
review, debugging alike). Spawn every scope in the SAME turn. Shared or
|
|
@@ -21,6 +21,8 @@ Lead supervises: delegates, coordinates, judges, decides. Route by complexity:
|
|
|
21
21
|
failed at least once.
|
|
22
22
|
|
|
23
23
|
1. Plan — discuss the request with the user, form a plan, wait for approval.
|
|
24
|
+
Only an explicit go-ahead is approval; diagnosis agreement is not. When
|
|
25
|
+
ambiguous, restate the plan and ask before executing.
|
|
24
26
|
2. Delegate — split into ordered scopes and hand off ONE AT A TIME.
|
|
25
27
|
- NO PARALLEL, NO FAN-OUT. Even when scopes are independent, spawn exactly
|
|
26
28
|
one agent per turn; never multiple in the same turn. Wait for the current
|
|
@@ -9,6 +9,8 @@ agents:
|
|
|
9
9
|
|
|
10
10
|
1. Plan — Lead discusses the request with the user, forms a plan, and waits for
|
|
11
11
|
approval before execution.
|
|
12
|
+
Only an explicit go-ahead is approval; diagnosis agreement is not. When
|
|
13
|
+
ambiguous, restate the plan and ask before executing.
|
|
12
14
|
2. Execute — Lead performs all implementation, research, debugging, review, and
|
|
13
15
|
verification work directly. Delegation to any agent is forbidden.
|
|
14
16
|
3. Verify — Lead checks the result directly, fixes issues directly, and repeats
|