mixdog 0.9.92 → 0.9.93
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/README.md +147 -51
- package/package.json +6 -5
- package/scripts/code-graph-description-contract.mjs +6 -8
- package/scripts/tmp-cdp-errors.mjs +41 -0
- package/scripts/tmp-cdp-inspect.mjs +41 -0
- package/scripts/tui-transcript-jitter-harness.mjs +2 -18
- package/src/rules/agent/00-core.md +1 -2
- package/src/rules/lead/01-general.md +1 -0
- package/src/rules/shared/01-tool.md +19 -22
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +16 -3
- package/src/runtime/agent/orchestrator/agent-trace.mjs +17 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +9 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +79 -21
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +40 -19
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +18 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +44 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +22 -5
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +35 -29
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +5 -6
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +31 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -0
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +60 -31
- package/src/runtime/agent/orchestrator/session/manager.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +12 -3
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +17 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +101 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -23
- package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -21
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +12 -6
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -3
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +47 -14
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +100 -0
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +32 -2
- package/src/runtime/channels/backends/discord-gateway.mjs +6 -32
- package/src/runtime/channels/lib/inbound-handler.mjs +19 -3
- package/src/runtime/channels/lib/scheduler.mjs +51 -3
- package/src/runtime/channels/lib/worker-main.mjs +4 -0
- package/src/runtime/channels/tool-defs.mjs +4 -2
- package/src/runtime/memory/lib/query-handlers.mjs +11 -3
- package/src/runtime/memory/tool-defs.mjs +5 -5
- package/src/runtime/shared/channel-notification-routing.mjs +8 -2
- package/src/runtime/shared/llm/http-agent.mjs +11 -0
- package/src/session-runtime/lifecycle-api.mjs +26 -1
- package/src/session-runtime/tool-catalog-data.mjs +5 -2
- package/src/session-runtime/workflow.mjs +7 -5
- package/src/standalone/agent-tool/tag-registry.mjs +5 -1
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/dist/index.mjs +68 -45
- package/src/tui/engine/agent-envelope.mjs +52 -3
- package/src/tui/engine/turn.mjs +8 -9
- package/src/tui/engine.mjs +18 -41
- package/src/workflows/solo/WORKFLOW.md +0 -6
- package/src/workflows/solo-bench/WORKFLOW.md +17 -0
|
@@ -59,6 +59,9 @@ function compactStoredToolArgString(value, key = '', opts = {}) {
|
|
|
59
59
|
const isLong = isBody || STORED_TOOL_ARG_LONG_KEY_RE.test(key);
|
|
60
60
|
const limit = isLong ? STORED_TOOL_ARG_LIMIT : Infinity;
|
|
61
61
|
if (value.length <= limit) return value;
|
|
62
|
+
// A marker is about to replace verbatim text — report the mutation so
|
|
63
|
+
// sweep callers can tag the resulting prefix-cache break.
|
|
64
|
+
try { opts.onCompacted?.(); } catch { /* observability only */ }
|
|
62
65
|
const hash = createHash('sha256').update(value).digest('hex').slice(0, 16);
|
|
63
66
|
// Body markers carry the recovery instruction inline: the compaction
|
|
64
67
|
// detectors only require the `[mixdog compacted ...]` shape (no ']' or
|
|
@@ -115,14 +118,19 @@ export function compactToolCallsForHistory(calls, opts = {}) {
|
|
|
115
118
|
// contract as restoreToolCallBodyForId);
|
|
116
119
|
// - a call with no result row yet (current batch / interrupted turn) is
|
|
117
120
|
// left untouched.
|
|
121
|
+
// Returns true when at least one body was actually collapsed to a marker
|
|
122
|
+
// (i.e. the transcript prefix changed), so callers can tag the intentional
|
|
123
|
+
// cache break instead of logging an unexplained input_prefix_mismatch.
|
|
118
124
|
export function compactSettledToolCallBodies(messages) {
|
|
119
|
-
if (!Array.isArray(messages)) return;
|
|
125
|
+
if (!Array.isArray(messages)) return false;
|
|
120
126
|
const resultKinds = new Map();
|
|
121
127
|
for (const message of messages) {
|
|
122
128
|
if (message?.role === 'tool' && message.toolCallId) {
|
|
123
129
|
resultKinds.set(message.toolCallId, message.toolKind || 'normal');
|
|
124
130
|
}
|
|
125
131
|
}
|
|
132
|
+
let changed = false;
|
|
133
|
+
const sweepOpts = { onCompacted: () => { changed = true; } };
|
|
126
134
|
for (const message of messages) {
|
|
127
135
|
if (message?.role !== 'assistant' || !Array.isArray(message.toolCalls)) continue;
|
|
128
136
|
for (const call of message.toolCalls) {
|
|
@@ -130,9 +138,10 @@ export function compactSettledToolCallBodies(messages) {
|
|
|
130
138
|
if (!call.arguments || typeof call.arguments !== 'object') continue;
|
|
131
139
|
const kind = call.id ? resultKinds.get(call.id) : undefined;
|
|
132
140
|
if (kind === undefined || kind === 'error') continue;
|
|
133
|
-
call.arguments = compactStoredToolArgValue(call.arguments);
|
|
141
|
+
call.arguments = compactStoredToolArgValue(call.arguments, '', 0, sweepOpts);
|
|
134
142
|
}
|
|
135
143
|
}
|
|
144
|
+
return changed;
|
|
136
145
|
}
|
|
137
146
|
|
|
138
147
|
// Restore retry-safe long command/script text for ONE failed tool call inside a
|
|
@@ -15,12 +15,11 @@ export function _isMutationTool(name) {
|
|
|
15
15
|
const n = _stripMcpPrefix(name);
|
|
16
16
|
return n === 'apply_patch';
|
|
17
17
|
}
|
|
18
|
-
// Side-effect-free read-only tools that
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// apply_patch-only for epoch-mutation counting and eager-dispatch gating.
|
|
18
|
+
// Side-effect-free read-only tools that may eager-start across an upcoming
|
|
19
|
+
// apply_patch barrier and may keep running after an earlier patch fails.
|
|
20
|
+
// Everything NOT in this set waits for its side-effect segment and is skipped
|
|
21
|
+
// after a failed patch. Kept separate from _isMutationTool, which stays
|
|
22
|
+
// apply_patch-only for epoch counting.
|
|
24
23
|
const ORDERED_GATE_SAFE_READONLY_TOOLS = new Set([
|
|
25
24
|
'read',
|
|
26
25
|
'find',
|
|
@@ -226,8 +226,37 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
|
|
|
226
226
|
return result;
|
|
227
227
|
}
|
|
228
228
|
if (name === 'apply_patch') {
|
|
229
|
-
const patchArgs = typeof args === 'string' ? { patch: args } : args;
|
|
230
|
-
|
|
229
|
+
const patchArgs = typeof args === 'string' ? { patch: args } : { ...(args || {}) };
|
|
230
|
+
// post_shell: optional verification command executed through the normal
|
|
231
|
+
// one-shot shell path ONLY after the patch applies cleanly; a failed
|
|
232
|
+
// patch skips it. Patch text and shell output return as ONE tool
|
|
233
|
+
// result, so patch+verify costs a single call. Runtime-only knob:
|
|
234
|
+
// stripped before executePatchTool sees the args.
|
|
235
|
+
const postShell = typeof patchArgs.post_shell === 'string' && patchArgs.post_shell.trim()
|
|
236
|
+
? patchArgs.post_shell.trim()
|
|
237
|
+
: null;
|
|
238
|
+
delete patchArgs.post_shell;
|
|
239
|
+
const patchResult = await executePatchTool(name, patchArgs, cwd, {
|
|
240
|
+
sessionId: callerSessionId,
|
|
241
|
+
toolCallId: executeOpts.toolCallId || null,
|
|
242
|
+
});
|
|
243
|
+
if (!postShell) return patchResult;
|
|
244
|
+
const patchNorm = normalizeToolEnvelope(patchResult);
|
|
245
|
+
const patchText = typeof patchNorm.result === 'string' ? patchNorm.result : String(patchNorm.result ?? '');
|
|
246
|
+
// Text-based failure detection only: legacy string returns normalize
|
|
247
|
+
// to explicitSuccess:false even on success, so that flag is unusable here.
|
|
248
|
+
const patchFailed = /^Error[\s:[]/.test(patchText.trimStart());
|
|
249
|
+
if (patchFailed) {
|
|
250
|
+
return `${patchText}\n--- post_shell skipped: patch failed ---`;
|
|
251
|
+
}
|
|
252
|
+
const shellRes = await executeBuiltinTool('shell', { command: postShell }, cwd, completionToolOpts);
|
|
253
|
+
const shellNorm = normalizeToolEnvelope(shellRes);
|
|
254
|
+
const shellText = typeof shellNorm.result === 'string' ? shellNorm.result : String(shellNorm.result ?? '');
|
|
255
|
+
const shellFailed = /^Error[\s:[]/.test(shellText.trimStart());
|
|
256
|
+
const header = shellFailed
|
|
257
|
+
? '--- post_shell FAILED (patch is applied; fix and re-verify) ---'
|
|
258
|
+
: '--- post_shell ---';
|
|
259
|
+
return `${patchText}\n\n${header}\n${shellText}`;
|
|
231
260
|
}
|
|
232
261
|
if (isBuiltinTool(name)) {
|
|
233
262
|
// clientHostPid threaded for the same per-terminal job-scope reason as
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
compactTypeForSession,
|
|
26
26
|
} from './context-meta.mjs';
|
|
27
27
|
import { resolveSemanticSummaryModel } from '../loop/compact-policy.mjs';
|
|
28
|
+
import { traceAgentCompact, messagePrefixHash } from '../../agent-trace.mjs';
|
|
28
29
|
import { uncachedInputTokensForProvider } from './usage-metrics.mjs';
|
|
29
30
|
import { pruneOffloadSession } from '../tool-result-offload.mjs';
|
|
30
31
|
import { _getPendingMessagesForSession } from './pending-messages.mjs';
|
|
@@ -302,6 +303,7 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
302
303
|
semanticCompact: false,
|
|
303
304
|
};
|
|
304
305
|
const budget = targetBudgetTokens;
|
|
306
|
+
const compactStartedAt = Date.now();
|
|
305
307
|
try { await opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
|
|
306
308
|
const provider = opts.provider || getProvider(session.provider) || null;
|
|
307
309
|
let compacted;
|
|
@@ -486,6 +488,31 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
486
488
|
lastRecallFastTrackError: recallFastTrackError?.message || null,
|
|
487
489
|
lastError: compactError?.message || semanticCompactError?.message || recallFastTrackError?.message || String(compactError || semanticCompactError || recallFastTrackError || 'compact failed'),
|
|
488
490
|
};
|
|
491
|
+
// compact_meta parity with the loop's pre-send pass: the out-of-loop
|
|
492
|
+
// (post-turn/manual) compaction failure was previously invisible to
|
|
493
|
+
// trace analytics.
|
|
494
|
+
traceAgentCompact({
|
|
495
|
+
sessionId: opts.sessionId || session.id || null,
|
|
496
|
+
stage: mode === 'auto' ? 'post_turn' : 'manual',
|
|
497
|
+
trigger: mode,
|
|
498
|
+
compact_type: compactType,
|
|
499
|
+
compact_changed: false,
|
|
500
|
+
before_count: messages.length,
|
|
501
|
+
after_count: messages.length,
|
|
502
|
+
context_window: positiveContextWindow(session.contextWindow) || null,
|
|
503
|
+
budget_tokens: boundary,
|
|
504
|
+
boundary_tokens: boundary,
|
|
505
|
+
target_budget_tokens: budget,
|
|
506
|
+
reserve_tokens: reserveTokens,
|
|
507
|
+
pressure_tokens: pressureTokens,
|
|
508
|
+
trigger_tokens: triggerTokens,
|
|
509
|
+
message_tokens_est: beforeMessageTokens,
|
|
510
|
+
duration_ms: Date.now() - compactStartedAt,
|
|
511
|
+
provider: session.provider || null,
|
|
512
|
+
model: session.model || null,
|
|
513
|
+
error: session.compaction.lastError,
|
|
514
|
+
error_code: 'compact_failed',
|
|
515
|
+
});
|
|
489
516
|
return {
|
|
490
517
|
changed: false,
|
|
491
518
|
error: session.compaction.lastError,
|
|
@@ -574,6 +601,39 @@ export async function runSessionCompaction(session, opts = {}) {
|
|
|
574
601
|
compactCount: (session.compaction?.compactCount || 0) + (changed ? 1 : 0),
|
|
575
602
|
};
|
|
576
603
|
if (changed) invalidateProviderContextBaseline(session);
|
|
604
|
+
// Observability parity with the loop's pre-send pass: record the
|
|
605
|
+
// out-of-loop mutation as compact_meta and park a one-shot intent so the
|
|
606
|
+
// next turn's first send tags its cache break instead of logging an
|
|
607
|
+
// unexplained input_prefix_mismatch (observed live: a 403k→10k post-turn
|
|
608
|
+
// compact traced as intentional_transition: null with no compact_meta).
|
|
609
|
+
let beforePrefixHash = null;
|
|
610
|
+
try { beforePrefixHash = messagePrefixHash(messages); } catch { /* best-effort */ }
|
|
611
|
+
traceAgentCompact({
|
|
612
|
+
sessionId: pruneSessionId || null,
|
|
613
|
+
stage: mode === 'auto' ? 'post_turn' : 'manual',
|
|
614
|
+
trigger: mode,
|
|
615
|
+
compact_type: compactType,
|
|
616
|
+
compact_changed: changed,
|
|
617
|
+
input_prefix_hash: beforePrefixHash,
|
|
618
|
+
before_count: messages.length,
|
|
619
|
+
after_count: compacted.length,
|
|
620
|
+
before_bytes: beforeEncoded ? Buffer.byteLength(beforeEncoded, 'utf8') : null,
|
|
621
|
+
after_bytes: afterEncoded ? Buffer.byteLength(afterEncoded, 'utf8') : null,
|
|
622
|
+
context_window: positiveContextWindow(session.contextWindow) || null,
|
|
623
|
+
budget_tokens: boundary,
|
|
624
|
+
boundary_tokens: boundary,
|
|
625
|
+
target_budget_tokens: budget,
|
|
626
|
+
reserve_tokens: reserveTokens,
|
|
627
|
+
pressure_tokens: pressureTokens,
|
|
628
|
+
trigger_tokens: triggerTokens,
|
|
629
|
+
message_tokens_est: beforeMessageTokens,
|
|
630
|
+
duration_ms: Date.now() - compactStartedAt,
|
|
631
|
+
provider: session.provider || null,
|
|
632
|
+
model: session.model || null,
|
|
633
|
+
});
|
|
634
|
+
if (changed) {
|
|
635
|
+
session.pendingCacheBreakIntent = mode === 'auto' ? 'post_turn_compaction' : 'manual_compaction';
|
|
636
|
+
}
|
|
577
637
|
return {
|
|
578
638
|
changed,
|
|
579
639
|
reason: unchangedReason,
|
|
@@ -19,16 +19,41 @@ const PENDING_MESSAGES_MODE = 0o600;
|
|
|
19
19
|
const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
20
|
const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
|
|
21
21
|
// Replay window for genuine user/steering entries. A cross-surface submit is
|
|
22
|
-
// meant for a LIVE owner;
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
22
|
+
// meant for a LIVE owner; entries that predate this process and exceeded the
|
|
23
|
+
// window are still DELIVERED (CC parity, reference messageQueueManager.ts:
|
|
24
|
+
// queued user input is never silently discarded), but annotated with an
|
|
25
|
+
// explicit late-delivery header so a session resumed hours later reads them
|
|
26
|
+
// as clearly-late input instead of a surprise self-injection.
|
|
26
27
|
const STALE_USER_INJECTION_TTL_MS = 30 * 60 * 1000;
|
|
27
28
|
|
|
29
|
+
// Ownership epoch for the staleness gate below: entries enqueued while THIS
|
|
30
|
+
// process was already alive were aimed at a live owner that still exists.
|
|
31
|
+
const _PENDING_PROCESS_START_MS = Date.now();
|
|
32
|
+
|
|
28
33
|
function isStaleUserInjection(entry, now = Date.now()) {
|
|
29
34
|
if (isCompletionNotificationEntry(entry)) return false;
|
|
30
35
|
const enqueuedAt = Number(entry?.enqueuedAt) || 0;
|
|
31
|
-
|
|
36
|
+
if (enqueuedAt <= 0) return false;
|
|
37
|
+
// A submit that arrived AFTER this owner process booted is CURRENT input
|
|
38
|
+
// the owner was merely too busy to take yet (observed: remote sends
|
|
39
|
+
// silently discarded after 30m while the owner ground through long
|
|
40
|
+
// turns). It must deliver regardless of age. The stale window only
|
|
41
|
+
// guards entries that PREDATE this process — the resumed-session replay
|
|
42
|
+
// case the TTL was built for (surprise self-injection on re-entry).
|
|
43
|
+
if (enqueuedAt >= _PENDING_PROCESS_START_MS) return false;
|
|
44
|
+
return (now - enqueuedAt) > STALE_USER_INJECTION_TTL_MS;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// CC parity: never silently discard queued user input. A stale entry is
|
|
48
|
+
// delivered with this explicit age-annotated header so neither the user nor
|
|
49
|
+
// the model mistakes it for fresh input.
|
|
50
|
+
function lateDeliveryText(text, entry, now = Date.now()) {
|
|
51
|
+
const value = String(text ?? '');
|
|
52
|
+
if (!value.trim()) return value;
|
|
53
|
+
const enqueuedAt = Number(entry?.enqueuedAt) || 0;
|
|
54
|
+
const ageMinutes = Math.max(1, Math.round((now - enqueuedAt) / 60000));
|
|
55
|
+
const age = ageMinutes >= 120 ? `~${Math.round(ageMinutes / 60)}h` : `~${ageMinutes}m`;
|
|
56
|
+
return `[late delivery: queued ${age} ago, before the current session owner started]\n${value}`;
|
|
32
57
|
}
|
|
33
58
|
// Marker for deferred agent/tool *completion* notifications. Such entries must
|
|
34
59
|
// never be replayed into a later turn on session resume (out-of-order delivery
|
|
@@ -765,7 +790,6 @@ export function hydratePendingMessages(sessionId, options = {}) {
|
|
|
765
790
|
let hydrated = [];
|
|
766
791
|
let alreadyDelivered = [];
|
|
767
792
|
let staleLedgerEntries = [];
|
|
768
|
-
const staleUserEntries = [];
|
|
769
793
|
const ledgerSession = loadSession(sessionId);
|
|
770
794
|
// Durable lifecycle epoch this hydration started under. Publishing claims the
|
|
771
795
|
// durable entries into THIS process's memory, so it must be revalidated
|
|
@@ -797,16 +821,19 @@ export function hydratePendingMessages(sessionId, options = {}) {
|
|
|
797
821
|
return false;
|
|
798
822
|
}
|
|
799
823
|
if (!id || inDelivery.has(id) || acked.has(id)) return false;
|
|
800
|
-
// Stale genuine user/steering entries must not surprise-inject
|
|
801
|
-
// into a session resumed long after they were queued (user:
|
|
802
|
-
// "re-entering the session suddenly injected old messages").
|
|
803
|
-
// Completion entries keep their own resume-drop policy.
|
|
804
|
-
if (isStaleUserInjection(entry)) {
|
|
805
|
-
staleUserEntries.push(entry);
|
|
806
|
-
return false;
|
|
807
|
-
}
|
|
808
824
|
return true;
|
|
809
825
|
});
|
|
826
|
+
// CC parity: stale genuine user/steering entries DELIVER with a
|
|
827
|
+
// late-delivery header instead of being silently dropped (the old
|
|
828
|
+
// behavior discarded them; user report: remote/steering sends
|
|
829
|
+
// silently ignored around owner restarts). Completion entries
|
|
830
|
+
// keep their own resume-drop policy (drain discards them).
|
|
831
|
+
for (const entry of hydrated) {
|
|
832
|
+
if (!isStaleUserInjection(entry)) continue;
|
|
833
|
+
if (typeof entry.message === 'string') {
|
|
834
|
+
entry.message = lateDeliveryText(entry.message, entry);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
810
837
|
// Read-only claim: durable data remains until successful delivery
|
|
811
838
|
// acknowledges these exact ids. A crash here therefore redelivers.
|
|
812
839
|
return undefined;
|
|
@@ -828,13 +855,6 @@ export function hydratePendingMessages(sessionId, options = {}) {
|
|
|
828
855
|
runPendingTestHook('hydrate:betweenCleanups', { sessionId });
|
|
829
856
|
if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
|
|
830
857
|
}
|
|
831
|
-
if (staleUserEntries.length > 0) {
|
|
832
|
-
try { process.stderr.write(`[session] dropped ${staleUserEntries.length} stale queued message(s) (older than ${Math.round(STALE_USER_INJECTION_TTL_MS / 60000)}m) sessionId=${sessionId}\n`); } catch {}
|
|
833
|
-
const cleaned = await acknowledgePendingMessages(sessionId, staleUserEntries, { expectedToken: startToken });
|
|
834
|
-
if (cleaned) cleanupConfirmed.push(...staleUserEntries);
|
|
835
|
-
runPendingTestHook('hydrate:betweenCleanups', { sessionId });
|
|
836
|
-
if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
|
|
837
|
-
}
|
|
838
858
|
if (cleanupConfirmed.length > 0) {
|
|
839
859
|
try {
|
|
840
860
|
// One session save prunes both IDs whose replay spool was removed
|
|
@@ -968,7 +988,19 @@ function modelVisiblePendingMessages(messages) {
|
|
|
968
988
|
}
|
|
969
989
|
|
|
970
990
|
export function _mergePendingMessageEntries(entries) {
|
|
971
|
-
|
|
991
|
+
// CC-parity delivery priority (reference messageQueueManager.ts: user
|
|
992
|
+
// input enqueues at 'next', task notifications at 'later', and dequeue
|
|
993
|
+
// always serves 'next' first). Our single merged turn message is the
|
|
994
|
+
// analogue of that dequeue order: genuine user/steering entries are
|
|
995
|
+
// merged BEFORE deferred completion notifications so queued user input
|
|
996
|
+
// is never buried under system notification text. FIFO is preserved
|
|
997
|
+
// within each group (stable partition).
|
|
998
|
+
const source = Array.isArray(entries) ? entries : [];
|
|
999
|
+
const ordered = [
|
|
1000
|
+
...source.filter((entry) => !isCompletionNotificationEntry(entry)),
|
|
1001
|
+
...source.filter(isCompletionNotificationEntry),
|
|
1002
|
+
];
|
|
1003
|
+
const normalized = ordered
|
|
972
1004
|
.map(normalizePendingMessageEntry)
|
|
973
1005
|
.filter(Boolean);
|
|
974
1006
|
if (normalized.length === 0) return null;
|
|
@@ -1106,7 +1138,6 @@ export function drainForeignUserInjections(sessionId) {
|
|
|
1106
1138
|
}
|
|
1107
1139
|
} catch { /* ledger unavailable — in-memory sets still guard */ }
|
|
1108
1140
|
const taken = [];
|
|
1109
|
-
let droppedStale = 0;
|
|
1110
1141
|
// The mtime memo may ONLY be armed by a scan that actually reached a
|
|
1111
1142
|
// lifecycle-valid decision under the spool lock. Arming it upfront meant a
|
|
1112
1143
|
// drain refused in-lock (a close/detach/reopen landing in the window) still
|
|
@@ -1137,14 +1168,15 @@ export function drainForeignUserInjections(sessionId) {
|
|
|
1137
1168
|
&& !isCompletionNotificationEntry(entry)
|
|
1138
1169
|
&& !isLegacyUnmarkedCompletionNotification(text)
|
|
1139
1170
|
&& text && !isInternalRuntimeNotificationText(text);
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
|
|
1171
|
+
// CC parity: stale foreign submits still deliver, carrying a
|
|
1172
|
+
// late-delivery header instead of being silently removed
|
|
1173
|
+
// (user report: remote sends silently discarded around owner
|
|
1174
|
+
// restarts).
|
|
1175
|
+
if (foreignUser && isStaleUserInjection(entry)) taken.push(lateDeliveryText(text, entry));
|
|
1144
1176
|
else if (foreignUser) taken.push(text);
|
|
1145
1177
|
else kept.push(entry);
|
|
1146
1178
|
}
|
|
1147
|
-
if (taken.length === 0
|
|
1179
|
+
if (taken.length === 0) return undefined;
|
|
1148
1180
|
if (kept.length > 0) next.sessions[sessionId] = kept;
|
|
1149
1181
|
else {
|
|
1150
1182
|
delete next.sessions[sessionId];
|
|
@@ -1158,9 +1190,6 @@ export function drainForeignUserInjections(sessionId) {
|
|
|
1158
1190
|
return [];
|
|
1159
1191
|
}
|
|
1160
1192
|
if (lifecycleDecided) _rememberForeignSpoolScan(sessionId, mtime);
|
|
1161
|
-
if (droppedStale > 0) {
|
|
1162
|
-
try { process.stderr.write(`[session] dropped ${droppedStale} stale foreign submit(s) (older than ${Math.round(STALE_USER_INJECTION_TTL_MS / 60000)}m) sessionId=${sessionId}\n`); } catch {}
|
|
1163
|
-
}
|
|
1164
1193
|
return taken;
|
|
1165
1194
|
}
|
|
1166
1195
|
|
|
@@ -126,7 +126,7 @@ export {
|
|
|
126
126
|
updateSessionManualTitle,
|
|
127
127
|
flushSessionMetrics,
|
|
128
128
|
} from './manager/session-crud.mjs';
|
|
129
|
-
export { deleteSession } from './store.mjs';
|
|
129
|
+
export { deleteSession, listOwnedAgentSessionIds } from './store.mjs';
|
|
130
130
|
// Read-only parsed-session access (desktop pane peek): no resume, no
|
|
131
131
|
// ownership, no liveness side effects.
|
|
132
132
|
export { loadSession } from './store.mjs';
|
|
@@ -211,9 +211,13 @@ export async function sendWithRecovery(ctx) {
|
|
|
211
211
|
// acknowledgement replays on a fresh request instead of
|
|
212
212
|
// failing the turn. Non-acked (or tool-bearing) shapes keep
|
|
213
213
|
// the explicit-failure contract below unchanged.
|
|
214
|
+
// NOTE: no classifyError gate here — an exposed stall is
|
|
215
|
+
// stamped unsafeToRetry, which the general classifier reads as
|
|
216
|
+
// terminal, but that unsafety is exactly what the retraction
|
|
217
|
+
// removes (observed live: make-mips-interpreter failed twice
|
|
218
|
+
// because this guard demanded 'transient' and never fired).
|
|
214
219
|
if (
|
|
215
220
|
transportRetriesUsed < TRANSPORT_RETRY_MAX
|
|
216
|
-
&& classifyError(sendErr) === 'transient'
|
|
217
221
|
&& await retractExposedTextForReplay()
|
|
218
222
|
) {
|
|
219
223
|
const waitMs = TRANSPORT_RETRY_BACKOFF_MS[transportRetriesUsed];
|
|
@@ -325,8 +329,13 @@ export async function sendWithRecovery(ctx) {
|
|
|
325
329
|
// send after a bounded wait instead of failing the turn.
|
|
326
330
|
if (
|
|
327
331
|
transportRetriesUsed < TRANSPORT_RETRY_MAX
|
|
328
|
-
&&
|
|
329
|
-
|
|
332
|
+
&& (
|
|
333
|
+
(outcome.replaySafe === true && classifyError(sendErr) === 'transient')
|
|
334
|
+
|| (
|
|
335
|
+
(classifyError(sendErr) === 'transient' || outcome.stallObserved === true)
|
|
336
|
+
&& await retractExposedTextForReplay()
|
|
337
|
+
)
|
|
338
|
+
)
|
|
330
339
|
) {
|
|
331
340
|
const waitMs = TRANSPORT_RETRY_BACKOFF_MS[transportRetriesUsed];
|
|
332
341
|
try {
|
|
@@ -64,6 +64,16 @@ const RESUMABLE_OPEN_MAX_COUNT = 300;
|
|
|
64
64
|
// idle this long — see the blank-scratch branch in sweepStaleSessions.
|
|
65
65
|
const BLANK_SCRATCH_MAX_AGE_MS = 60 * 60 * 1000; // 1h
|
|
66
66
|
|
|
67
|
+
/** Child-agent transcripts share their visible parent's retention boundary.
|
|
68
|
+
* Presence (including a tombstone or unreadable file) preserves the child;
|
|
69
|
+
* only proven parent absence releases it to ordinary cleanup. */
|
|
70
|
+
function retainedLinkedAgent(session) {
|
|
71
|
+
if (!session || !isAgentOwner(session)) return false;
|
|
72
|
+
const parentId = String(session.ownerSessionId || session.parentSessionId || '').trim();
|
|
73
|
+
if (!/^[A-Za-z0-9_-]+$/.test(parentId) || parentId === session.id) return false;
|
|
74
|
+
return probePath(sessionPath(parentId)).state !== PROBE_ABSENT;
|
|
75
|
+
}
|
|
76
|
+
|
|
67
77
|
|
|
68
78
|
export function listStoredSessions(options = {}) {
|
|
69
79
|
const dir = getStoreDir();
|
|
@@ -423,6 +433,13 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
|
|
|
423
433
|
// messages), so there is no second, divergent parse of this file.
|
|
424
434
|
const actual = record.doc;
|
|
425
435
|
const diskClosed = record.closed === true || actual.status === 'closed';
|
|
436
|
+
if (retainedLinkedAgent(actual)) {
|
|
437
|
+
// Parent-owned agent transcripts are task history, not
|
|
438
|
+
// ephemeral worker cache. This covers both new open sessions
|
|
439
|
+
// and tombstones created by older Mixdog versions.
|
|
440
|
+
remaining++;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
426
443
|
if (diskClosed) {
|
|
427
444
|
// A shared store can be tombstoned by another process while
|
|
428
445
|
// this process still owns an in-flight controller for the same
|
|
@@ -59,6 +59,106 @@ function cleanValue(value) {
|
|
|
59
59
|
return String(value || '').trim();
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
function archivedAgentNotification(content, sessionId) {
|
|
63
|
+
const text = typeof content === 'string' ? content : '';
|
|
64
|
+
const marker = '\n\nResult:\n';
|
|
65
|
+
const markerAt = text.indexOf(marker);
|
|
66
|
+
if (markerAt < 0 || !/The async agent task .* has finished \(/.test(text.slice(0, markerAt))) return null;
|
|
67
|
+
const lines = text.slice(markerAt + marker.length)
|
|
68
|
+
.split(/\r?\n/)
|
|
69
|
+
.map((line) => line.replace(/^>\s?/, ''));
|
|
70
|
+
const divider = lines.findIndex((line) => line.trim() === '');
|
|
71
|
+
if (divider < 0) return null;
|
|
72
|
+
const headers = new Map();
|
|
73
|
+
for (const line of lines.slice(0, divider)) {
|
|
74
|
+
const match = /^([A-Za-z][A-Za-z0-9_]*):\s*(.*?)\s*$/.exec(line);
|
|
75
|
+
if (match) headers.set(match[1], match[2]);
|
|
76
|
+
}
|
|
77
|
+
if (headers.get('surface') !== 'agent' || headers.get('sessionId') !== sessionId) return null;
|
|
78
|
+
const status = cleanValue(headers.get('status')).toLowerCase();
|
|
79
|
+
if (!/^(?:completed|failed|cancelled)$/.test(status)) return null;
|
|
80
|
+
const body = lines.slice(divider + 1).join('\n').trim();
|
|
81
|
+
if (!body) return null;
|
|
82
|
+
return {
|
|
83
|
+
body,
|
|
84
|
+
status,
|
|
85
|
+
tag: cleanValue(headers.get('tag') || headers.get('label')),
|
|
86
|
+
agent: cleanValue(headers.get('agent')),
|
|
87
|
+
provider: cleanValue(headers.get('provider')),
|
|
88
|
+
model: cleanValue(headers.get('model')),
|
|
89
|
+
effort: cleanValue(headers.get('effort')),
|
|
90
|
+
fast: cleanValue(headers.get('fast')).toLowerCase() === 'true',
|
|
91
|
+
finishedAt: Date.parse(cleanValue(headers.get('finished'))) || 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Legacy recovery for child transcripts already unlinked by terminal reaping.
|
|
96
|
+
* The parent owns one canonical body-carrying completion notification, so scan
|
|
97
|
+
* only files containing the exact child id and project the newest valid body. */
|
|
98
|
+
function readArchivedAgentResult(sessionId) {
|
|
99
|
+
const dir = join(dataDir(), 'sessions');
|
|
100
|
+
if (probePath(dir).state !== PROBE_PRESENT) return null;
|
|
101
|
+
let files;
|
|
102
|
+
try { files = readdirSync(dir).filter((file) => file.endsWith('.json')); }
|
|
103
|
+
catch { return null; }
|
|
104
|
+
let best = null;
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
let raw;
|
|
107
|
+
try { raw = readFileSync(join(dir, file), 'utf8'); } catch { continue; }
|
|
108
|
+
if (!raw.includes(sessionId)) continue;
|
|
109
|
+
const record = readTopLevelLifecycleRecord(raw);
|
|
110
|
+
if (isLifecycleUnreadable(record) || record.id === sessionId) continue;
|
|
111
|
+
const parent = record.doc;
|
|
112
|
+
const messages = Array.isArray(parent.messages) ? parent.messages : [];
|
|
113
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
114
|
+
const message = messages[index];
|
|
115
|
+
if (message?.role !== 'user') continue;
|
|
116
|
+
const archived = archivedAgentNotification(message.content, sessionId);
|
|
117
|
+
if (!archived) continue;
|
|
118
|
+
const at = positiveNumber(
|
|
119
|
+
message?.meta?.transcript?.at,
|
|
120
|
+
archived.finishedAt || positiveNumber(parent.updatedAt),
|
|
121
|
+
);
|
|
122
|
+
if (best && best.at > at) continue;
|
|
123
|
+
best = { ...archived, at, parent };
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!best) return null;
|
|
128
|
+
const text = `# Archived agent result\n\n${best.body}`;
|
|
129
|
+
return {
|
|
130
|
+
sessionId,
|
|
131
|
+
items: [{
|
|
132
|
+
id: `archived-agent-result:${sessionId}`,
|
|
133
|
+
kind: 'assistant',
|
|
134
|
+
text,
|
|
135
|
+
status: best.status,
|
|
136
|
+
...(best.at ? { at: best.at } : {}),
|
|
137
|
+
...(best.model ? { model: best.model } : {}),
|
|
138
|
+
...(best.provider ? { provider: best.provider } : {}),
|
|
139
|
+
...(best.agent ? { agent: best.agent } : {}),
|
|
140
|
+
}],
|
|
141
|
+
provider: best.provider,
|
|
142
|
+
model: best.model,
|
|
143
|
+
effort: best.effort,
|
|
144
|
+
fast: best.fast,
|
|
145
|
+
cwd: cleanValue(best.parent.cwd),
|
|
146
|
+
desktopSession: desktopSession(best.parent.desktopSession, best.parent.cwd),
|
|
147
|
+
workflow: null,
|
|
148
|
+
stats: {
|
|
149
|
+
currentContextTokens: 0,
|
|
150
|
+
currentEstimatedContextTokens: 0,
|
|
151
|
+
currentContextSource: null,
|
|
152
|
+
},
|
|
153
|
+
contextWindow: null,
|
|
154
|
+
rawContextWindow: null,
|
|
155
|
+
displayContextWindow: null,
|
|
156
|
+
autoCompactTokenLimit: null,
|
|
157
|
+
archivedAgentResult: true,
|
|
158
|
+
readOnlyDetachedAgent: false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
62
162
|
function activeAgentWorker(row) {
|
|
63
163
|
const statuses = [row?.stage, row?.status]
|
|
64
164
|
.map(cleanValue)
|
|
@@ -421,6 +521,7 @@ export async function readStoredSessionTranscript(id, options = {}) {
|
|
|
421
521
|
// Same strict authority as the store, and the same fail-closed rule: an
|
|
422
522
|
// absent, unreadable, ambiguous or foreign record yields no transcript.
|
|
423
523
|
const read = readTextFile(join(dataDir(), 'sessions', `${sessionId}.json`));
|
|
524
|
+
if (read.state === PROBE_ABSENT) return readArchivedAgentResult(sessionId);
|
|
424
525
|
if (read.state !== PROBE_PRESENT) return null;
|
|
425
526
|
const record = readTopLevelLifecycleRecord(read.text);
|
|
426
527
|
if (isLifecycleUnreadable(record) || record.id !== sessionId) return null;
|
|
@@ -1188,6 +1188,36 @@ export function loadSession(id) {
|
|
|
1188
1188
|
return stored ? _ensureLifecycleFields(stored) : null;
|
|
1189
1189
|
}
|
|
1190
1190
|
|
|
1191
|
+
/** Strictly enumerate child-agent session files linked to one visible parent.
|
|
1192
|
+
* Used only by explicit parent deletion; ordinary close/context switches keep
|
|
1193
|
+
* the relationship intact. */
|
|
1194
|
+
export function listOwnedAgentSessionIds(ownerSessionId) {
|
|
1195
|
+
const ownerId = String(ownerSessionId || '').trim();
|
|
1196
|
+
if (!/^[A-Za-z0-9_-]+$/.test(ownerId)) return [];
|
|
1197
|
+
const dir = getStoreDir();
|
|
1198
|
+
if (probePath(dir).state !== PROBE_PRESENT) return [];
|
|
1199
|
+
let files;
|
|
1200
|
+
try {
|
|
1201
|
+
files = readdirSync(dir).filter((file) => file.endsWith('.json'));
|
|
1202
|
+
} catch {
|
|
1203
|
+
return [];
|
|
1204
|
+
}
|
|
1205
|
+
const ids = [];
|
|
1206
|
+
for (const file of files) {
|
|
1207
|
+
const candidateId = file.slice(0, -5);
|
|
1208
|
+
if (!candidateId || candidateId === ownerId || !/^[A-Za-z0-9_-]+$/.test(candidateId)) continue;
|
|
1209
|
+
try {
|
|
1210
|
+
const record = readTopLevelLifecycleRecord(readFileSync(join(dir, file), 'utf8'));
|
|
1211
|
+
if (isLifecycleUnreadable(record) || record.id !== candidateId) continue;
|
|
1212
|
+
const session = record.doc;
|
|
1213
|
+
if (!isAgentOwner(session)) continue;
|
|
1214
|
+
const linkedOwner = String(session.ownerSessionId || session.parentSessionId || '').trim();
|
|
1215
|
+
if (linkedOwner === ownerId) ids.push(candidateId);
|
|
1216
|
+
} catch { /* unreadable/vanished records are never deletion targets */ }
|
|
1217
|
+
}
|
|
1218
|
+
return ids;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1191
1221
|
|
|
1192
1222
|
export function deleteSession(id, options = {}) {
|
|
1193
1223
|
// Keep caller probes and all vetoes ahead of the non-reentrant lock and
|