mixdog 0.9.11 → 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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/scripts/internal-comms-bench.mjs +1 -0
  3. package/scripts/internal-comms-smoke.mjs +11 -7
  4. package/src/lib/rules-builder.cjs +15 -11
  5. package/src/mixdog-session-runtime.mjs +48 -0
  6. package/src/rules/agent/00-common.md +5 -17
  7. package/src/rules/agent/00-core.md +21 -0
  8. package/src/rules/lead/lead-brief.md +7 -0
  9. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +39 -2
  10. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +0 -25
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +120 -3
  12. package/src/runtime/agent/orchestrator/agent-trace.mjs +43 -1
  13. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +48 -3
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +42 -4
  16. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +24 -3
  18. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +6 -0
  19. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +61 -6
  20. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -2
  22. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +40 -4
  23. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +0 -62
  24. package/src/runtime/agent/orchestrator/session/compact.mjs +0 -2
  25. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +12 -9
  26. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +12 -0
  27. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +48 -157
  28. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +6 -23
  29. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +0 -13
  30. package/src/runtime/agent/orchestrator/session/loop.mjs +32 -77
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +33 -45
  32. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +7 -6
  33. package/src/runtime/agent/orchestrator/session/manager.mjs +27 -14
  34. package/src/runtime/agent/orchestrator/stall-policy.mjs +16 -0
  35. package/src/runtime/channels/index.mjs +28 -1
  36. package/src/runtime/channels/lib/memory-client.mjs +200 -15
  37. package/src/runtime/memory/index.mjs +11 -11
  38. package/src/runtime/memory/lib/cycle-scheduler.mjs +6 -4
  39. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +22 -17
  40. package/src/runtime/memory/lib/memory-embed.mjs +35 -1
  41. package/src/session-runtime/tool-catalog.mjs +1 -0
  42. package/src/session-runtime/tool-defs.mjs +28 -0
  43. package/src/standalone/agent-tool.mjs +89 -7
  44. package/src/tui/dist/index.mjs +35 -12
  45. package/src/tui/engine.mjs +55 -12
  46. package/src/workflows/default/WORKFLOW.md +2 -0
  47. package/src/workflows/sequential/WORKFLOW.md +2 -0
  48. 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
- try { resolve(JSON.parse(data)) }
53
- catch { resolve({ raw: data }) }
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
- try {
91
- fs.mkdirSync(BUFFER_DIR, { recursive: true })
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: ${e.message}\n`)
108
- return { ok: false }
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
- if (result.ok) {
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
- await setCycleLastRun('cycle2_last_error', result.error || 'unknown error')
334
- log(`[cycle2] failed: ${result.error}\n`)
335
- markCycleDone('cycle2', false, result.error || 'unknown error')
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) and the 5-digit batch-id domain must stay disjoint
52
- // see the ordinalToId invariant in runUnifiedGate.
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 5-digit batch id OR the 1-based row ordinal shown in the
92
- // numbered Entries block. The two domains are disjoint (asserted in
93
- // runUnifiedGate), so an exact-id hit always wins and an unmatched value
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 5-digit
337
- // batch id or the row ordinal 1..N; the parser resolves both. The two domains
338
- // MUST be disjoint or an ordinal could shadow a real id (ids are 5-digit,
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 minBatchId = Math.min(...[...statusById.keys()])
346
- if (Number.isFinite(minBatchId) && minBatchId <= rows.length) {
347
- __mixdogMemoryLog(`[cycle2] batch id ${minBatchId} collides with ordinal range 1..${rows.length} — skipping batch (no safe id resolution)\n`)
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
- __mixdogMemoryLog(`[embed-sync] ${updates.length - writtenIds.length} stale-write(s) skipped — text changed since read\n`)
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
  }
@@ -66,6 +66,7 @@ export const DEFERRED_DEFAULT_LEAD_TOOLS = Object.freeze([
66
66
  'search',
67
67
  'web_fetch',
68
68
  'cwd',
69
+ 'session_manage',
69
70
  'Skill',
70
71
  'tool_search',
71
72
  ]);
@@ -69,6 +69,34 @@ export const SKILL_TOOL = {
69
69
  },
70
70
  };
71
71
 
72
+ // Owner-directed session reset tool. `clear` mirrors /clear (full wipe);
73
+ // `compact_clear` mirrors the auto-clear path (summarize via the configured
74
+ // compactType, then reset — context carries forward in the summary). The
75
+ // reset is SCHEDULED: it runs when the current turn ends, never mid-turn,
76
+ // because the live transcript is still feeding the loop. Lead-session only;
77
+ // the runtime executor rejects agent-worker callers.
78
+ export const SESSION_MANAGE_TOOL = {
79
+ name: 'session_manage',
80
+ title: 'Session Manage',
81
+ annotations: {
82
+ title: 'Session Manage',
83
+ readOnlyHint: false,
84
+ destructiveHint: true,
85
+ idempotentHint: false,
86
+ openWorldHint: false,
87
+ agentHidden: true,
88
+ },
89
+ description: 'Reset this conversation on explicit user request. action=clear wipes all context (like /clear); action=compact_clear summarizes first and carries context forward (auto-clear style). Applies when the current turn ends.',
90
+ inputSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ action: { type: 'string', enum: ['clear', 'compact_clear'], description: 'clear = full wipe; compact_clear = summarize then reset.' },
94
+ },
95
+ required: ['action'],
96
+ additionalProperties: false,
97
+ },
98
+ };
99
+
72
100
  export const LEAD_DISALLOWED_TOOLS = Object.freeze([]);
73
101
  export const AGENT_HIDDEN_WRAPPER_TOOLS = new Set([]);
74
102
 
@@ -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 last = mgr.getSessionLastProgressAt(sessionId);
981
- if (watchdogPolicy.idleStaleMs > 0 && last && now - last > watchdogPolicy.idleStaleMs) {
982
- try { controller.abort(new Error(`agent task stale (${watchdogPolicy.idleStaleMs}ms without progress)`)); } catch {}
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
- try { controller.abort(abortErr); } catch {}
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