mixdog 0.9.82 → 0.9.84
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/runtime-dependency-cache-key.mjs +94 -0
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +5 -2
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +25 -14
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +38 -1
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +16 -14
- package/src/runtime/agent/orchestrator/session/store.mjs +1 -0
- package/src/runtime/shared/turn-snapshot.mjs +94 -157
- package/src/session-runtime/runtime-core.mjs +10 -7
- package/src/session-runtime/session-turn-api.mjs +18 -7
- package/src/standalone/agent-tool.mjs +32 -0
- package/src/tui/dist/index.mjs +109 -15
- package/src/tui/engine/live-share.mjs +127 -18
- package/src/tui/engine.mjs +12 -1
package/package.json
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import { dirname, join, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
7
|
+
|
|
8
|
+
import { embeddingRuntimeTarget } from './prune-embedding-runtime.mjs'
|
|
9
|
+
|
|
10
|
+
export const RUNTIME_DEPENDENCY_CACHE_SCHEMA = 1
|
|
11
|
+
|
|
12
|
+
function sha256(value) {
|
|
13
|
+
return createHash('sha256').update(value).digest('hex')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeRuntimeLockfile(lockfile) {
|
|
17
|
+
const normalized = structuredClone(
|
|
18
|
+
typeof lockfile === 'string' || Buffer.isBuffer(lockfile)
|
|
19
|
+
? JSON.parse(String(lockfile))
|
|
20
|
+
: lockfile,
|
|
21
|
+
)
|
|
22
|
+
// Deploy updates the package identity before every release, but that does
|
|
23
|
+
// not change the production dependency tree cached by native packagers.
|
|
24
|
+
delete normalized.name
|
|
25
|
+
delete normalized.version
|
|
26
|
+
if (normalized.packages?.['']) {
|
|
27
|
+
delete normalized.packages[''].name
|
|
28
|
+
delete normalized.packages[''].version
|
|
29
|
+
}
|
|
30
|
+
return normalized
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function runtimeDependencyFingerprint({
|
|
34
|
+
lockfile,
|
|
35
|
+
prunerSource,
|
|
36
|
+
target,
|
|
37
|
+
host = `${process.platform}-${process.arch}`,
|
|
38
|
+
nodeAbi = process.versions.modules,
|
|
39
|
+
}) {
|
|
40
|
+
return sha256(JSON.stringify({
|
|
41
|
+
schemaVersion: RUNTIME_DEPENDENCY_CACHE_SCHEMA,
|
|
42
|
+
target,
|
|
43
|
+
host,
|
|
44
|
+
nodeAbi,
|
|
45
|
+
lockfile: normalizeRuntimeLockfile(lockfile),
|
|
46
|
+
prunerSha256: sha256(prunerSource),
|
|
47
|
+
}))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function runtimeDependencyCacheIdentity(rootDir, target) {
|
|
51
|
+
const [lockfile, prunerSource] = await Promise.all([
|
|
52
|
+
readFile(join(rootDir, 'package-lock.json')),
|
|
53
|
+
readFile(join(rootDir, 'scripts', 'prune-embedding-runtime.mjs')),
|
|
54
|
+
])
|
|
55
|
+
const host = `${process.platform}-${process.arch}`
|
|
56
|
+
const nodeAbi = process.versions.modules
|
|
57
|
+
return {
|
|
58
|
+
schemaVersion: RUNTIME_DEPENDENCY_CACHE_SCHEMA,
|
|
59
|
+
target: target.key,
|
|
60
|
+
host,
|
|
61
|
+
nodeAbi,
|
|
62
|
+
fingerprint: runtimeDependencyFingerprint({
|
|
63
|
+
lockfile,
|
|
64
|
+
prunerSource,
|
|
65
|
+
target: target.key,
|
|
66
|
+
host,
|
|
67
|
+
nodeAbi,
|
|
68
|
+
}),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function runtimeDependencyCacheKey(identity) {
|
|
73
|
+
return `runtime-deps-v${identity.schemaVersion}-${identity.target}-${identity.fingerprint}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''
|
|
77
|
+
if (invokedPath === import.meta.url) {
|
|
78
|
+
const optionValue = (name) => {
|
|
79
|
+
const prefix = `--${name}=`
|
|
80
|
+
const argument = process.argv.find((value) => value.startsWith(prefix))
|
|
81
|
+
return argument ? argument.slice(prefix.length) : ''
|
|
82
|
+
}
|
|
83
|
+
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
84
|
+
const target = embeddingRuntimeTarget({
|
|
85
|
+
platform: optionValue('platform') || undefined,
|
|
86
|
+
arch: optionValue('arch') || undefined,
|
|
87
|
+
})
|
|
88
|
+
runtimeDependencyCacheIdentity(rootDir, target)
|
|
89
|
+
.then((identity) => process.stdout.write(`${runtimeDependencyCacheKey(identity)}\n`))
|
|
90
|
+
.catch((error) => {
|
|
91
|
+
process.stderr.write(`Runtime dependency cache key failed: ${error?.message || error}\n`)
|
|
92
|
+
process.exitCode = 1
|
|
93
|
+
})
|
|
94
|
+
}
|
|
@@ -203,8 +203,11 @@ export function contentToText(content, fallback = '') {
|
|
|
203
203
|
|
|
204
204
|
function storedHistoryImagePlaceholder(part) {
|
|
205
205
|
const info = imageInfo(part) || geminiInlineInfo(part);
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
// Inline base64 already gives us its MIME type. Do not call
|
|
207
|
+
// imageUrlFromPart in that case: it would manufacture a second
|
|
208
|
+
// `data:...;base64,<entire payload>` string merely to discard it.
|
|
209
|
+
const url = info ? null : imageUrlFromPart(part);
|
|
210
|
+
const fileUri = info ? null : imageFileUriFromPart(part);
|
|
208
211
|
const mimeType = info?.mimeType || imageMimeFromDataUrl(url) || fileUri?.mimeType || (part?.type === 'image' ? DEFAULT_IMAGE_MIME : '');
|
|
209
212
|
return `[Image omitted from stored history${mimeType ? `: ${mimeType}` : ''}]`;
|
|
210
213
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Periodic idle-session + tombstone sweep extracted verbatim from manager.mjs.
|
|
3
3
|
// Drives sweepStaleSessions on an unref'd interval; closeSession is imported
|
|
4
4
|
// from session-close.mjs (one-way dependency, no cycle).
|
|
5
|
-
import { sweepStaleSessions, evictIdleLiveSessions } from '../store.mjs';
|
|
5
|
+
import { sweepStaleSessions, sweepStaleSessionsCooperative, evictIdleLiveSessions } from '../store.mjs';
|
|
6
6
|
import { sweepOrphanedPendingMessages } from './pending-messages.mjs';
|
|
7
7
|
import {
|
|
8
8
|
_getRuntimeEntry,
|
|
@@ -23,6 +23,7 @@ const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_M
|
|
|
23
23
|
const TOMBSTONE_MAX_AGE_MS = 60 * 60 * 1000; // 1h
|
|
24
24
|
let _cleanupTimer = null;
|
|
25
25
|
let _cleanupInitialTimer = null;
|
|
26
|
+
let _cleanupRun = null;
|
|
26
27
|
|
|
27
28
|
// A session is "live" when it still owns a non-closed runtime entry. Passed to
|
|
28
29
|
// the retention cap so the active/current and any in-flight session is never
|
|
@@ -65,10 +66,10 @@ const _sweepLog = (line) => {
|
|
|
65
66
|
if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(line);
|
|
66
67
|
};
|
|
67
68
|
|
|
68
|
-
function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
|
|
69
|
+
async function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
|
|
69
70
|
const startedAt = Date.now();
|
|
70
71
|
try {
|
|
71
|
-
const result =
|
|
72
|
+
const result = await sweepStaleSessionsCooperative({
|
|
72
73
|
sweepIdle,
|
|
73
74
|
tombstoneMaxAgeMs: includeTombstones ? TOMBSTONE_MAX_AGE_MS : 0,
|
|
74
75
|
isSessionLive: _isSessionLive,
|
|
@@ -144,33 +145,43 @@ export function sweepTombstones() {
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
export function _runCleanupCycle() {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
148
|
+
if (_cleanupRun) return _cleanupRun;
|
|
149
|
+
const run = (async () => {
|
|
150
|
+
// Drain every settled runtime entry on each pass, not just the one or two
|
|
151
|
+
// sessions whose on-disk idle TTL happened to expire in this interval.
|
|
152
|
+
_sweepTerminalSessionRuntimes();
|
|
153
|
+
sweepOrphanedPendingMessages();
|
|
154
|
+
await sweepIdleSessions({ includeTombstones: true });
|
|
155
|
+
// Reclaim same-process session snapshots whose state is durable on disk
|
|
156
|
+
// (memory-leak guard: _liveSessions used to grow for process lifetime).
|
|
157
|
+
try { evictIdleLiveSessions({ isSessionLive: _isSessionLive }); } catch { /* best-effort */ }
|
|
158
|
+
})().catch((error) => {
|
|
159
|
+
try { process.stderr.write(`[agent-session] cleanup cycle failed: ${error?.message || error}\n`); } catch {}
|
|
160
|
+
});
|
|
161
|
+
const tracked = run.finally(() => {
|
|
162
|
+
if (_cleanupRun === tracked) _cleanupRun = null;
|
|
163
|
+
});
|
|
164
|
+
_cleanupRun = tracked;
|
|
165
|
+
return tracked;
|
|
155
166
|
}
|
|
156
167
|
|
|
157
168
|
function _startCleanupInterval() {
|
|
158
169
|
if (_cleanupTimer) return;
|
|
159
170
|
if (CLEANUP_INTERVAL_MS <= 0) return;
|
|
160
|
-
_cleanupTimer = setInterval(_runCleanupCycle, CLEANUP_INTERVAL_MS);
|
|
171
|
+
_cleanupTimer = setInterval(() => { void _runCleanupCycle(); }, CLEANUP_INTERVAL_MS);
|
|
161
172
|
if (_cleanupTimer.unref) _cleanupTimer.unref(); // don't block process exit
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
export function startIdleCleanup() {
|
|
165
176
|
if (_cleanupTimer || _cleanupInitialTimer) return;
|
|
166
177
|
if (CLEANUP_INITIAL_DELAY_MS <= 0) {
|
|
167
|
-
_runCleanupCycle();
|
|
178
|
+
void _runCleanupCycle();
|
|
168
179
|
_startCleanupInterval();
|
|
169
180
|
return;
|
|
170
181
|
}
|
|
171
182
|
_cleanupInitialTimer = setTimeout(() => {
|
|
172
183
|
_cleanupInitialTimer = null;
|
|
173
|
-
_runCleanupCycle();
|
|
184
|
+
void _runCleanupCycle();
|
|
174
185
|
_startCleanupInterval();
|
|
175
186
|
}, CLEANUP_INITIAL_DELAY_MS);
|
|
176
187
|
if (_cleanupInitialTimer.unref) _cleanupInitialTimer.unref();
|
|
@@ -244,7 +244,7 @@ export function getStoredSessionsRaw() {
|
|
|
244
244
|
* Background sweep: delete session files idle longer than ttlMs.
|
|
245
245
|
* Returns { cleaned, remaining, details } for logging.
|
|
246
246
|
*/
|
|
247
|
-
|
|
247
|
+
function* sweepStaleSessionSteps(ttlMs, options = {}) {
|
|
248
248
|
if (ttlMs && typeof ttlMs === 'object') {
|
|
249
249
|
options = ttlMs;
|
|
250
250
|
ttlMs = options.ttlMs;
|
|
@@ -302,6 +302,9 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
302
302
|
let openPruned = 0;
|
|
303
303
|
const openPrunedDetails = [];
|
|
304
304
|
for (const row of summaries) {
|
|
305
|
+
// Cooperative callers pause between records so large stores never hold
|
|
306
|
+
// an interactive host's event loop for the full directory scan.
|
|
307
|
+
yield undefined;
|
|
305
308
|
try {
|
|
306
309
|
if (!row?.id) continue;
|
|
307
310
|
const jsonPath = sessionPath(row.id);
|
|
@@ -593,6 +596,7 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
593
596
|
// session mid-create whose .json write has not landed yet.
|
|
594
597
|
try {
|
|
595
598
|
for (const h of readdirSync(dir).filter(f => f.endsWith('.hb') || f.endsWith('.own'))) {
|
|
599
|
+
yield undefined;
|
|
596
600
|
if (existsSync(join(dir, h.replace(/\.(hb|own)$/, '.json')))) continue;
|
|
597
601
|
let hbMtime = 0;
|
|
598
602
|
try { hbMtime = statSync(join(dir, h)).mtimeMs; } catch { continue; }
|
|
@@ -613,3 +617,36 @@ export function sweepStaleSessions(ttlMs, options = {}) {
|
|
|
613
617
|
}
|
|
614
618
|
return { cleaned, remaining, details, tombstonesCleaned, tombstoneDetails, tombstoneErrors, openPruned, openPrunedDetails };
|
|
615
619
|
}
|
|
620
|
+
|
|
621
|
+
/** Synchronous compatibility surface for explicit maintenance commands/tests. */
|
|
622
|
+
export function sweepStaleSessions(ttlMs, options = {}) {
|
|
623
|
+
const steps = sweepStaleSessionSteps(ttlMs, options);
|
|
624
|
+
let next = steps.next();
|
|
625
|
+
while (!next.done) next = steps.next();
|
|
626
|
+
return next.value;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Interactive-host sweep: preserve the exact synchronous lifecycle decisions
|
|
631
|
+
* while yielding between records. A single large session remains atomic, but a
|
|
632
|
+
* directory worth of reads/parses can no longer become one multi-second task.
|
|
633
|
+
*/
|
|
634
|
+
export async function sweepStaleSessionsCooperative(ttlMs, options = {}) {
|
|
635
|
+
const cooperativeOptions = ttlMs && typeof ttlMs === 'object' ? ttlMs : options;
|
|
636
|
+
const configuredSliceMs = Number(cooperativeOptions?.cooperativeSliceMs);
|
|
637
|
+
const sliceMs = Number.isFinite(configuredSliceMs)
|
|
638
|
+
? Math.min(50, Math.max(0, configuredSliceMs))
|
|
639
|
+
: 8;
|
|
640
|
+
const steps = sweepStaleSessionSteps(ttlMs, options);
|
|
641
|
+
let next = steps.next();
|
|
642
|
+
while (!next.done) {
|
|
643
|
+
const sliceStartedAt = performance.now();
|
|
644
|
+
do {
|
|
645
|
+
next = steps.next();
|
|
646
|
+
} while (!next.done && performance.now() - sliceStartedAt < sliceMs);
|
|
647
|
+
if (!next.done) {
|
|
648
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return next.value;
|
|
652
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Worker } from 'worker_threads';
|
|
2
2
|
import { guardedSaveOptions as _guardedSaveOptions } from './write-guards.mjs';
|
|
3
|
-
import { _ensureLifecycleFields } from './serialize.mjs';
|
|
3
|
+
import { _ensureLifecycleFields, _sessionForDisk } from './serialize.mjs';
|
|
4
4
|
import { setLiveSession, _droppedSaveIds, clearSessionSaveError } from './live-state.mjs';
|
|
5
5
|
import { _cacheSessionSummary, _rollbackCachedSessionSummary, _queueSessionSummaryUpsert } from './summary-cache.mjs';
|
|
6
6
|
|
|
@@ -23,6 +23,17 @@ let _saveWorkerRefCount = 0;
|
|
|
23
23
|
let _deferredSaveReqId = 0;
|
|
24
24
|
export const _deferredSessionSaves = new Map();
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Build the exact payload the worker will persist BEFORE Worker.postMessage
|
|
28
|
+
* structured-clones it on the caller's thread. Inline image/document bytes and
|
|
29
|
+
* transient live-turn aliases are disk-ineligible already; removing them here
|
|
30
|
+
* prevents a multi-megabyte duplicate allocation and long clone pause while
|
|
31
|
+
* preserving canonical text/tool history byte-for-byte.
|
|
32
|
+
*/
|
|
33
|
+
export function _sessionPayloadForSaveWorker(session) {
|
|
34
|
+
return _sessionForDisk(session);
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
function _getOrSpawnWorker() {
|
|
27
38
|
if (_saveWorker) return _saveWorker;
|
|
28
39
|
_saveWorker = new Worker(new URL('../save-session-worker.mjs', import.meta.url), {
|
|
@@ -179,19 +190,10 @@ export function saveSessionAsync(session, opts) {
|
|
|
179
190
|
const id = session.id;
|
|
180
191
|
const summaryVersion = _cacheSessionSummary(session);
|
|
181
192
|
const safeOpts = opts?._sessionWriteGuard ? opts : _guardedSaveOptions(id, opts);
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
// non-cloneable values (a function, and raw messages that can hold functions),
|
|
187
|
-
// which makes structuredClone throw "could not be cloned" for every mid-turn
|
|
188
|
-
// iteration save. The worker strips both via _sessionForDisk anyway, so drop
|
|
189
|
-
// them from the cloned payload here WITHOUT mutating the live session object.
|
|
190
|
-
const clonePayload = (session && typeof session === 'object'
|
|
191
|
-
&& (Object.prototype.hasOwnProperty.call(session, 'liveTurnMessages')
|
|
192
|
-
|| Object.prototype.hasOwnProperty.call(session, 'toolApprovalHook')))
|
|
193
|
-
? (() => { const { liveTurnMessages: _dropLTM, toolApprovalHook: _dropTAH, ...rest } = session; return rest; })()
|
|
194
|
-
: session;
|
|
193
|
+
// Worker.postMessage clones on THIS thread. Project to the canonical disk
|
|
194
|
+
// shape first so media bytes and duplicate live-turn aliases never enter
|
|
195
|
+
// that clone. setLiveSession above deliberately retains the rich original.
|
|
196
|
+
const clonePayload = _sessionPayloadForSaveWorker(session);
|
|
195
197
|
return new Promise((resolve, reject) => {
|
|
196
198
|
const waiter = { resolve, reject };
|
|
197
199
|
if (_saveAsyncInflight.has(id)) {
|
|
@@ -1,184 +1,121 @@
|
|
|
1
|
-
// Turn-scoped
|
|
1
|
+
// Turn-scoped review registry.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// lead edits, subagent edits, background shell jobs, even external editors —
|
|
9
|
-
// which transcript-parsed patches structurally cannot see.
|
|
3
|
+
// The public function names retain the original shadow-snapshot API so desktop
|
|
4
|
+
// capability callers stay compatible. The tracked data now follows Codex's
|
|
5
|
+
// attribution rule instead: only successful apply_patch UI diffs are recorded,
|
|
6
|
+
// each worker keeps its own review, and a Lead turn may read its child reviews
|
|
7
|
+
// without reassigning them to the Lead.
|
|
10
8
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// throw into the turn path. MIXDOG_TURN_SNAPSHOT=0 disables the feature.
|
|
15
|
-
import { execFile } from 'child_process';
|
|
16
|
-
import { createHash } from 'crypto';
|
|
17
|
-
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
18
|
-
import { join, resolve } from 'path';
|
|
19
|
-
import { resolvePluginData } from './plugin-paths.mjs';
|
|
9
|
+
// Lead patches already live in the Lead transcript. Worker patches are
|
|
10
|
+
// delivered through agentLoop.onToolResult and frozen here against the owning
|
|
11
|
+
// Lead turn generation. Shell/background/external-editor writes are excluded.
|
|
20
12
|
|
|
21
13
|
const DISABLED = /^(0|false|off)$/i.test(String(process.env.MIXDOG_TURN_SNAPSHOT || ''));
|
|
22
|
-
const
|
|
23
|
-
const BEGIN_WAIT_CAP_MS = 1_500;
|
|
14
|
+
const TURN_CACHE_MAX = 32;
|
|
24
15
|
const MAX_PATCH_BYTES = 2_000_000;
|
|
25
|
-
const BASE_CACHE_MAX = 32;
|
|
26
|
-
const FAILURE_BACKOFF_MS = 5 * 60_000;
|
|
27
16
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const _failedUntil = new Map(); // gitdir → timestamp
|
|
17
|
+
const _turnsBySession = new Map();
|
|
18
|
+
let _agentTurnSeq = 0;
|
|
31
19
|
|
|
32
|
-
function
|
|
33
|
-
|
|
34
|
-
if (!raw) return '';
|
|
35
|
-
const full = resolve(raw);
|
|
36
|
-
return process.platform === 'win32' ? full.toLowerCase() : full;
|
|
20
|
+
function clean(value) {
|
|
21
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
37
22
|
}
|
|
38
23
|
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
|
|
24
|
+
function trimTurnCache() {
|
|
25
|
+
while (_turnsBySession.size > TURN_CACHE_MAX) {
|
|
26
|
+
const oldest = _turnsBySession.keys().next().value;
|
|
27
|
+
if (oldest === undefined) break;
|
|
28
|
+
_turnsBySession.delete(oldest);
|
|
29
|
+
}
|
|
42
30
|
}
|
|
43
31
|
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
});
|
|
32
|
+
function mergePatches(values) {
|
|
33
|
+
let merged = '';
|
|
34
|
+
for (const value of Array.isArray(values) ? values : [values]) {
|
|
35
|
+
const patch = typeof value === 'string' ? value.trim() : '';
|
|
36
|
+
if (!patch) continue;
|
|
37
|
+
const next = merged ? `${merged}\n${patch}` : patch;
|
|
38
|
+
if (next.length > MAX_PATCH_BYTES) break;
|
|
39
|
+
merged = next;
|
|
40
|
+
}
|
|
41
|
+
return merged;
|
|
55
42
|
}
|
|
56
43
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
_queues.set(gitdir, tail.catch(() => {}));
|
|
62
|
-
return tail;
|
|
44
|
+
function publicAgentReviews(sessionId) {
|
|
45
|
+
const turn = _turnsBySession.get(clean(sessionId));
|
|
46
|
+
if (!turn) return [];
|
|
47
|
+
return [...turn.agents.values()].map((review) => ({ ...review }));
|
|
63
48
|
}
|
|
64
49
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
mkdirSync(gitdir, { recursive: true });
|
|
68
|
-
const init = await _git(['init', '--bare', '--quiet', gitdir]);
|
|
69
|
-
if (init.code !== 0) return false;
|
|
70
|
-
// The shadow repo indexes the project via --work-tree; never let it descend
|
|
71
|
-
// into the project's real .git, and keep bytes stable/gc quiet.
|
|
72
|
-
await _git(['--git-dir', gitdir, 'config', 'core.bare', 'false']);
|
|
73
|
-
await _git(['--git-dir', gitdir, 'config', 'core.autocrlf', 'false']);
|
|
74
|
-
await _git(['--git-dir', gitdir, 'config', 'gc.auto', '0']);
|
|
75
|
-
try {
|
|
76
|
-
mkdirSync(join(gitdir, 'info'), { recursive: true });
|
|
77
|
-
writeFileSync(join(gitdir, 'info', 'exclude'), '.git/\n');
|
|
78
|
-
// First-snapshot cost: reuse the project's own git objects so unchanged
|
|
79
|
-
// blobs need no re-hash/store (the opencode chromium lesson).
|
|
80
|
-
const projectObjects = join(worktree, '.git', 'objects');
|
|
81
|
-
if (existsSync(projectObjects)) {
|
|
82
|
-
mkdirSync(join(gitdir, 'objects', 'info'), { recursive: true });
|
|
83
|
-
writeFileSync(join(gitdir, 'objects', 'info', 'alternates'), `${projectObjects}\n`);
|
|
84
|
-
}
|
|
85
|
-
} catch { /* exclusions/alternates are optimizations, not requirements */ }
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
50
|
+
/** Compatibility no-op: apply_patch tracking has no project warmup cost. */
|
|
51
|
+
export function prewarmTurnSnapshot() {}
|
|
88
52
|
|
|
89
|
-
|
|
90
|
-
async function
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
_failedUntil.set(gitdir, Date.now() + FAILURE_BACKOFF_MS);
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
const base = ['--git-dir', gitdir, '--work-tree', key];
|
|
102
|
-
const add = await _git([...base, 'add', '-A', '--', '.'], { cwd: key });
|
|
103
|
-
if (add.code !== 0) {
|
|
104
|
-
_failedUntil.set(gitdir, Date.now() + FAILURE_BACKOFF_MS);
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
const tree = await _git([...base, 'write-tree'], { cwd: key });
|
|
108
|
-
if (tree.code !== 0) return null;
|
|
109
|
-
const hash = tree.stdout.trim();
|
|
110
|
-
return /^[0-9a-f]{40,64}$/.test(hash) ? hash : null;
|
|
53
|
+
/** Start a new user turn and invalidate the prior turn's child review group. */
|
|
54
|
+
export async function beginTurnSnapshot(_worktree, sessionId) {
|
|
55
|
+
const ownerSessionId = clean(sessionId);
|
|
56
|
+
if (DISABLED || !ownerSessionId) return;
|
|
57
|
+
const generation = (_turnsBySession.get(ownerSessionId)?.generation || 0) + 1;
|
|
58
|
+
_turnsBySession.delete(ownerSessionId);
|
|
59
|
+
_turnsBySession.set(ownerSessionId, {
|
|
60
|
+
generation,
|
|
61
|
+
agents: new Map(),
|
|
111
62
|
});
|
|
63
|
+
trimTurnCache();
|
|
112
64
|
}
|
|
113
65
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
66
|
+
/** Bind one worker execution to the Lead turn that launched it. */
|
|
67
|
+
export function beginAgentTurnReview(ownerSessionId, childSessionId, meta = {}) {
|
|
68
|
+
if (DISABLED) return null;
|
|
69
|
+
const owner = clean(ownerSessionId);
|
|
70
|
+
const child = clean(childSessionId);
|
|
71
|
+
const turn = _turnsBySession.get(owner);
|
|
72
|
+
if (!owner || !child || !turn) return null;
|
|
73
|
+
_agentTurnSeq += 1;
|
|
74
|
+
return {
|
|
75
|
+
id: `agent-review-${_agentTurnSeq}`,
|
|
76
|
+
ownerSessionId: owner,
|
|
77
|
+
generation: turn.generation,
|
|
78
|
+
sessionId: child,
|
|
79
|
+
agent: clean(meta.agent) || null,
|
|
80
|
+
tag: clean(meta.tag) || null,
|
|
81
|
+
completed: false,
|
|
82
|
+
};
|
|
119
83
|
}
|
|
120
84
|
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
})
|
|
137
|
-
|
|
85
|
+
/** Freeze successful worker apply_patch diffs into their owning Lead turn. */
|
|
86
|
+
export function completeAgentTurnReview(handle, patches = []) {
|
|
87
|
+
if (!handle || handle.completed === true) return false;
|
|
88
|
+
handle.completed = true;
|
|
89
|
+
const turn = _turnsBySession.get(clean(handle.ownerSessionId));
|
|
90
|
+
if (!turn || turn.generation !== handle.generation) return false;
|
|
91
|
+
const patch = mergePatches(patches);
|
|
92
|
+
if (!patch) return false;
|
|
93
|
+
const key = clean(handle.sessionId) || handle.id;
|
|
94
|
+
const prior = turn.agents.get(key);
|
|
95
|
+
turn.agents.set(key, {
|
|
96
|
+
sessionId: key,
|
|
97
|
+
agent: handle.agent || prior?.agent || null,
|
|
98
|
+
tag: handle.tag || prior?.tag || null,
|
|
99
|
+
patch: mergePatches([prior?.patch, patch]),
|
|
100
|
+
});
|
|
101
|
+
return true;
|
|
138
102
|
}
|
|
139
103
|
|
|
140
|
-
/**
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (head === base.tree) return { supported: true, files: [], patch: '', baseTree: base.tree, headTree: head };
|
|
153
|
-
const gitdir = _gitDirFor(key);
|
|
154
|
-
const argsBase = ['--git-dir', gitdir];
|
|
155
|
-
const [numstat, patch] = await Promise.all([
|
|
156
|
-
_git([...argsBase, 'diff-tree', '-r', '--numstat', '-z', base.tree, head]),
|
|
157
|
-
_git([...argsBase, 'diff-tree', '-r', '-p', '--no-color', base.tree, head]),
|
|
158
|
-
]);
|
|
159
|
-
if (numstat.code !== 0) return { supported: false, files: [], patch: '' };
|
|
160
|
-
const files = [];
|
|
161
|
-
const fields = numstat.stdout.split('\0').filter(Boolean);
|
|
162
|
-
for (const row of fields) {
|
|
163
|
-
const match = row.match(/^(\d+|-)\t(\d+|-)\t([\s\S]+)$/);
|
|
164
|
-
if (!match) continue;
|
|
165
|
-
files.push({
|
|
166
|
-
name: match[3],
|
|
167
|
-
additions: match[1] === '-' ? 0 : Number(match[1]),
|
|
168
|
-
deletions: match[2] === '-' ? 0 : Number(match[2]),
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
let patchText = patch.code === 0 ? patch.stdout : '';
|
|
172
|
-
if (patchText.length > MAX_PATCH_BYTES) {
|
|
173
|
-
patchText = patchText.slice(0, MAX_PATCH_BYTES);
|
|
174
|
-
const cut = patchText.lastIndexOf('\ndiff --git ');
|
|
175
|
-
if (cut > 0) patchText = patchText.slice(0, cut + 1);
|
|
176
|
-
}
|
|
177
|
-
return { supported: true, files, patch: patchText, baseTree: base.tree, headTree: head };
|
|
104
|
+
/** Return only attributed child reviews; Lead review is transcript-derived. */
|
|
105
|
+
export async function getTurnReviewDiff(_worktree, sessionId) {
|
|
106
|
+
if (DISABLED) return { supported: false, files: [], patch: '', agents: [] };
|
|
107
|
+
const ownerSessionId = clean(sessionId);
|
|
108
|
+
const turn = _turnsBySession.get(ownerSessionId);
|
|
109
|
+
return {
|
|
110
|
+
supported: true,
|
|
111
|
+
files: [],
|
|
112
|
+
patch: '',
|
|
113
|
+
agents: publicAgentReviews(ownerSessionId),
|
|
114
|
+
...(turn ? { generation: turn.generation } : { reason: 'no-turn' }),
|
|
115
|
+
};
|
|
178
116
|
}
|
|
179
117
|
|
|
180
118
|
export function _resetTurnSnapshotForTest() {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
_failedUntil.clear();
|
|
119
|
+
_turnsBySession.clear();
|
|
120
|
+
_agentTurnSeq = 0;
|
|
184
121
|
}
|
|
@@ -1227,18 +1227,21 @@ export async function createMixdogSessionRuntime({
|
|
|
1227
1227
|
const startedAt = performance.now();
|
|
1228
1228
|
bootProfile('session:create:start', { mode, reason });
|
|
1229
1229
|
const promise = (async () => {
|
|
1230
|
+
// Demand-only: this starts only after the user submits (unless an
|
|
1231
|
+
// explicitly enabled prewarm caller asks for a session). Core-memory
|
|
1232
|
+
// startup does not depend on keychain/provider readiness, so overlap the
|
|
1233
|
+
// two cold paths instead of paying their bounded waits serially.
|
|
1234
|
+
const coreMemoryContextPromise = loadCoreMemoryContext();
|
|
1230
1235
|
await awaitKeychainPrewarm();
|
|
1231
1236
|
ensureConfigForRouteProvider();
|
|
1232
1237
|
await resolveMissingRouteModelForFirstTurn();
|
|
1233
1238
|
requireModelRoute();
|
|
1234
1239
|
bootProfile('session:create:route-ready', { ms: (performance.now() - startedAt).toFixed(1) });
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
// display fields, never provider/model that the memory load reads — so run
|
|
1238
|
-
// them concurrently instead of serially on the boot path.
|
|
1240
|
+
// Route effort waits on provider readiness while the already-started
|
|
1241
|
+
// memory load continues independently.
|
|
1239
1242
|
const [, coreMemoryContext] = await Promise.all([
|
|
1240
1243
|
refreshRouteEffort(),
|
|
1241
|
-
|
|
1244
|
+
coreMemoryContextPromise,
|
|
1242
1245
|
]);
|
|
1243
1246
|
bootProfile('session:create:effort-ready', { ms: (performance.now() - startedAt).toFixed(1) });
|
|
1244
1247
|
const providerImpl = reg.getProvider(route.provider);
|
|
@@ -1846,8 +1849,8 @@ export async function createMixdogSessionRuntime({
|
|
|
1846
1849
|
...channelConfigApi,
|
|
1847
1850
|
...providerAuthApi,
|
|
1848
1851
|
...mediaApi,
|
|
1849
|
-
// Turn-scoped
|
|
1850
|
-
//
|
|
1852
|
+
// Turn-scoped attributed review: child apply_patch diffs for this Lead turn.
|
|
1853
|
+
// Lead patches are read from its transcript by the renderer.
|
|
1851
1854
|
getTurnReviewDiff: () => getTurnSnapshotReviewDiff(currentCwd, session?.id),
|
|
1852
1855
|
get id() {
|
|
1853
1856
|
return session?.id || null;
|