mixdog 0.9.84 → 0.9.86
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 +7 -1
- package/package.json +3 -2
- package/scripts/channel-daemon-stub.mjs +2 -1
- package/src/defaults/agents.json +12 -0
- package/src/defaults/skills/setup/SKILL.md +44 -12
- package/src/help.mjs +40 -9
- package/src/lib/keychain-cjs.cjs +11 -1
- package/src/rules/agent/43-title-agent.md +22 -0
- package/src/rules/shared/01-tool.md +3 -3
- package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +30 -3
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +38 -4
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +183 -41
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +12 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
- package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +44 -73
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
- package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
- package/src/runtime/agent/orchestrator/tools/token-manifest.json +5 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +6 -8
- package/src/runtime/channels/lib/parent-bridge.mjs +27 -15
- package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
- package/src/runtime/channels/lib/worker-ipc.mjs +17 -4
- package/src/runtime/media/adapters/gemini-video.mjs +1 -1
- package/src/runtime/media/renditions.mjs +1 -1
- package/src/runtime/media/store.mjs +1 -1
- package/src/runtime/memory/index.mjs +14 -5
- package/src/runtime/memory/lib/agent-ipc.mjs +11 -12
- package/src/runtime/shared/atomic-file.mjs +76 -38
- package/src/runtime/shared/automation-attachments.mjs +3 -3
- package/src/runtime/shared/child-guardian.mjs +15 -5
- package/src/runtime/shared/child-spawn-gate.mjs +6 -12
- package/src/runtime/shared/err-text.mjs +1 -0
- package/src/runtime/shared/resource-admission.mjs +7 -3
- package/src/runtime/shared/safe-ipc-send.mjs +34 -0
- package/src/runtime/shared/turn-snapshot.mjs +0 -3
- package/src/session-runtime/lifecycle-api.mjs +43 -12
- package/src/session-runtime/prewarm.mjs +35 -0
- package/src/session-runtime/remote-control.mjs +8 -47
- package/src/session-runtime/remote-transcript.mjs +3 -7
- package/src/session-runtime/route-preparation.mjs +24 -0
- package/src/session-runtime/runtime-core.mjs +42 -86
- package/src/session-runtime/session-turn-api.mjs +9 -2
- package/src/session-runtime/workflow-agents-api.mjs +3 -0
- package/src/standalone/agent-shard/shard-child.mjs +300 -0
- package/src/standalone/agent-shard/shard-pool.mjs +442 -0
- package/src/standalone/agent-tool.mjs +222 -0
- package/src/standalone/channel-daemon-transport.mjs +126 -185
- package/src/standalone/channel-daemon.mjs +11 -11
- package/src/standalone/channel-worker.mjs +21 -19
- package/src/standalone/memory-runtime-proxy.mjs +98 -23
- package/src/tui/App.jsx +16 -9
- package/src/tui/app/channel-pickers.mjs +3 -8
- package/src/tui/app/route-pickers.mjs +117 -152
- package/src/tui/app/settings-picker.mjs +90 -89
- package/src/tui/app/slash-commands.mjs +7 -7
- package/src/tui/app/slash-dispatch.mjs +23 -58
- package/src/tui/app/theme-effort-pickers.mjs +3 -1
- package/src/tui/components/StatusLine.jsx +1 -1
- package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
- package/src/tui/dist/index.mjs +358 -380
- package/src/tui/engine/live-share.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +24 -9
- package/src/tui/engine/session-flow.mjs +20 -10
- package/src/tui/engine/turn.mjs +28 -16
- package/src/tui/engine.mjs +24 -2
- package/src/tui/figures.mjs +0 -1
- package/src/ui/statusline-format.mjs +0 -1
- package/src/hooks/lib/permission-evaluator.cjs +0 -24
- package/src/lib/config-cjs.cjs +0 -61
- package/src/runtime/shared/launcher-control.mjs +0 -258
- package/src/runtime/shared/workspace-router.mjs +0 -259
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// mutex-guarded turn loop (askSession) plus the abort-aware call wrapper
|
|
4
4
|
// (_api_call_with_interrupt). Behavior-preserving move — every runtime
|
|
5
5
|
// helper it used in manager.mjs is now imported from its split module.
|
|
6
|
-
import { createHash } from 'crypto';
|
|
6
|
+
import { createHash, randomUUID } from 'crypto';
|
|
7
7
|
import { getProvider } from '../../providers/registry.mjs';
|
|
8
8
|
import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
|
|
9
|
-
import { loadSession, saveSessionAsync, saveSessionAsyncDeferred, readSessionLifecycleFromDisk } from '../store.mjs';
|
|
9
|
+
import { loadSession, saveSession, saveSessionAsync, saveSessionAsyncDeferred, readSessionLifecycleFromDisk } from '../store.mjs';
|
|
10
10
|
import { createAbortController } from '../../../../shared/abort-controller.mjs';
|
|
11
11
|
import { logLlmCall } from '../../../../shared/llm/usage-log.mjs';
|
|
12
12
|
import { appendAgentTrace } from '../../agent-trace.mjs';
|
|
@@ -56,6 +56,12 @@ import {
|
|
|
56
56
|
persistCompactedOutgoingAfterAskFailure,
|
|
57
57
|
} from './message-sanitize.mjs';
|
|
58
58
|
import { createTurnInterruptionTracker } from './turn-interruption.mjs';
|
|
59
|
+
import {
|
|
60
|
+
clearTurnCheckpoint,
|
|
61
|
+
recoverTurnCheckpoint,
|
|
62
|
+
turnMessagesForCheckpoint,
|
|
63
|
+
writeTurnCheckpoint,
|
|
64
|
+
} from './turn-checkpoint.mjs';
|
|
59
65
|
import { _getAgentLoop } from './runtime-loaders.mjs';
|
|
60
66
|
import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
|
|
61
67
|
import { recordProviderContextBaseline } from '../loop/compact-policy.mjs';
|
|
@@ -208,6 +214,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
208
214
|
// Hoisted so the outer finally (which runs once after the whole turn loop)
|
|
209
215
|
// can compare against the last turn's generation.
|
|
210
216
|
let askGeneration = 0;
|
|
217
|
+
let _crashRecoveryChecked = false;
|
|
211
218
|
try {
|
|
212
219
|
// Turn loop (pendingMessages pattern): run the current prompt, then drain
|
|
213
220
|
// any `agent type=send` messages that were queued while this turn was in
|
|
@@ -262,6 +269,17 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
262
269
|
if (preSession.closed === true) {
|
|
263
270
|
throw new SessionClosedError(sessionId, 'session already closed');
|
|
264
271
|
}
|
|
272
|
+
if (!_crashRecoveryChecked) {
|
|
273
|
+
_crashRecoveryChecked = true;
|
|
274
|
+
const recovery = recoverTurnCheckpoint(preSession);
|
|
275
|
+
if (recovery.changed) {
|
|
276
|
+
saveSession(preSession, {
|
|
277
|
+
sync: true,
|
|
278
|
+
expectedGeneration: preSession.generation,
|
|
279
|
+
});
|
|
280
|
+
if (recovery.turnToken) clearTurnCheckpoint(sessionId, recovery.turnToken);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
265
283
|
// A prior crash/partial-save during compaction may have pinned
|
|
266
284
|
// compaction.lastStage='compacting'. This ask is starting fresh, so
|
|
267
285
|
// recover the stale transient stage before the loop's pre-send compact
|
|
@@ -306,7 +324,93 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
306
324
|
let cancelledUserTurnContent = '';
|
|
307
325
|
let _turnOutgoing = null;
|
|
308
326
|
const _turnInterruption = createTurnInterruptionTracker();
|
|
327
|
+
const _turnCheckpointToken = randomUUID();
|
|
328
|
+
const _turnCheckpointStartedAt = Date.now();
|
|
329
|
+
const _TURN_CHECKPOINT_THROTTLE_MS = 150;
|
|
330
|
+
let _turnCheckpointTimer = null;
|
|
331
|
+
let _turnCheckpointStopped = false;
|
|
332
|
+
let _turnCheckpointLastAt = 0;
|
|
333
|
+
let _turnCheckpointWarned = false;
|
|
334
|
+
const _flushTurnCheckpoint = () => {
|
|
335
|
+
if (_turnCheckpointStopped || !_turnOutgoing || !cancelledUserTurnContent) return false;
|
|
336
|
+
try {
|
|
337
|
+
const written = writeTurnCheckpoint({
|
|
338
|
+
sessionId,
|
|
339
|
+
generation: askGeneration,
|
|
340
|
+
turnToken: _turnCheckpointToken,
|
|
341
|
+
startedAt: _turnCheckpointStartedAt,
|
|
342
|
+
currentUserContent: cancelledUserTurnContent,
|
|
343
|
+
turnMessages: turnMessagesForCheckpoint(_turnOutgoing, cancelledUserTurnContent),
|
|
344
|
+
interruption: _turnInterruption.snapshot(),
|
|
345
|
+
});
|
|
346
|
+
if (written) _turnCheckpointLastAt = Date.now();
|
|
347
|
+
return written;
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (!_turnCheckpointWarned) {
|
|
350
|
+
_turnCheckpointWarned = true;
|
|
351
|
+
try { process.stderr.write(`[turn-checkpoint] write failed session=${sessionId}: ${error?.message || error}\n`); } catch {}
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
const _scheduleTurnCheckpoint = (immediate = false) => {
|
|
357
|
+
if (_turnCheckpointStopped || !_turnOutgoing) return;
|
|
358
|
+
if (immediate || Date.now() - _turnCheckpointLastAt >= _TURN_CHECKPOINT_THROTTLE_MS) {
|
|
359
|
+
if (_turnCheckpointTimer) clearTimeout(_turnCheckpointTimer);
|
|
360
|
+
_turnCheckpointTimer = null;
|
|
361
|
+
_flushTurnCheckpoint();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (_turnCheckpointTimer) return;
|
|
365
|
+
const waitMs = Math.max(1, _TURN_CHECKPOINT_THROTTLE_MS - (Date.now() - _turnCheckpointLastAt));
|
|
366
|
+
_turnCheckpointTimer = setTimeout(() => {
|
|
367
|
+
_turnCheckpointTimer = null;
|
|
368
|
+
_flushTurnCheckpoint();
|
|
369
|
+
}, waitMs);
|
|
370
|
+
_turnCheckpointTimer.unref?.();
|
|
371
|
+
};
|
|
372
|
+
const _stopTurnCheckpoint = () => {
|
|
373
|
+
_turnCheckpointStopped = true;
|
|
374
|
+
if (_turnCheckpointTimer) clearTimeout(_turnCheckpointTimer);
|
|
375
|
+
_turnCheckpointTimer = null;
|
|
376
|
+
};
|
|
309
377
|
const _sessionStartMetaInjectedBeforeTurn = preSession.sessionStartMetaInjected === true;
|
|
378
|
+
let _interruptionSnapshot = null;
|
|
379
|
+
const _prepareCloseSnapshot = (abortReason) => {
|
|
380
|
+
if (_interruptionSnapshot) return _interruptionSnapshot;
|
|
381
|
+
if (!activeSession) return null;
|
|
382
|
+
_stopTurnCheckpoint();
|
|
383
|
+
activeSession.liveTurnMessages = null;
|
|
384
|
+
_turnInterruption.restoreTombstonedText();
|
|
385
|
+
const finalized = _turnInterruption.finalize({
|
|
386
|
+
turnOutgoing: _turnOutgoing || activeSession.messages,
|
|
387
|
+
currentUserContent: cancelledUserTurnContent,
|
|
388
|
+
abortReason,
|
|
389
|
+
});
|
|
390
|
+
activeSession.messages = finalized.messages;
|
|
391
|
+
delete activeSession.activeTurnCheckpoint;
|
|
392
|
+
if (!finalized.responsePreserved) {
|
|
393
|
+
if (finalized.userTurnPreserved) {
|
|
394
|
+
// A non-user detach keeps the provisional prompt but makes
|
|
395
|
+
// the opaque provider continuation unsafe to reuse.
|
|
396
|
+
activeSession.providerState = undefined;
|
|
397
|
+
} else {
|
|
398
|
+
activeSession.sessionStartMetaInjected = _sessionStartMetaInjectedBeforeTurn;
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
activeSession.providerState = undefined;
|
|
402
|
+
}
|
|
403
|
+
activeSession.updatedAt = Date.now();
|
|
404
|
+
activeSession.lastUsedAt = Date.now();
|
|
405
|
+
runtime.session = activeSession;
|
|
406
|
+
_interruptionSnapshot = finalized;
|
|
407
|
+
return finalized;
|
|
408
|
+
};
|
|
409
|
+
// closeSession is synchronous and generation-first by design. Expose a
|
|
410
|
+
// turn-local hook so it can canonicalize the in-flight transcript
|
|
411
|
+
// before bumpSessionGeneration()/markSessionClosed() writes the disk
|
|
412
|
+
// snapshot and invalidates the ordinary cancellation cleanup save.
|
|
413
|
+
runtime.prepareCloseSnapshot = _prepareCloseSnapshot;
|
|
310
414
|
try {
|
|
311
415
|
const session = activeSession;
|
|
312
416
|
const provider = getProvider(session.provider);
|
|
@@ -421,6 +525,15 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
421
525
|
// user turn is introduced.
|
|
422
526
|
session.messages = filterModelVisibleSessionMessages(outgoing);
|
|
423
527
|
session.liveTurnMessages = outgoing;
|
|
528
|
+
session.activeTurnCheckpoint = {
|
|
529
|
+
version: 1,
|
|
530
|
+
turnToken: _turnCheckpointToken,
|
|
531
|
+
startedAt: _turnCheckpointStartedAt,
|
|
532
|
+
};
|
|
533
|
+
// The sidecar lands synchronously before provider execution. Even
|
|
534
|
+
// if the async canonical-session preflight save has not reached its
|
|
535
|
+
// worker when the process is killed, recovery still has the prompt.
|
|
536
|
+
_scheduleTurnCheckpoint(true);
|
|
424
537
|
saveSessionAsync(session, { expectedGeneration: askGeneration }).catch((err) => {
|
|
425
538
|
try { process.stderr.write(`[session] preflight user-turn save failed: ${err?.message || err}\n`); } catch {}
|
|
426
539
|
});
|
|
@@ -454,6 +567,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
454
567
|
const agentLoop = await _getAgentLoop();
|
|
455
568
|
const _trackTextDelta = (chunk) => {
|
|
456
569
|
_turnInterruption.recordTextDelta(chunk);
|
|
570
|
+
_scheduleTurnCheckpoint();
|
|
457
571
|
if (typeof askOpts?.onTextDelta === 'function') askOpts.onTextDelta(chunk);
|
|
458
572
|
};
|
|
459
573
|
const _trackTextReset = async (detail) => {
|
|
@@ -470,23 +584,28 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
470
584
|
}
|
|
471
585
|
if (!acknowledged) return false;
|
|
472
586
|
_turnInterruption.tombstoneText(detail?.chars);
|
|
587
|
+
_scheduleTurnCheckpoint(true);
|
|
473
588
|
return true;
|
|
474
589
|
};
|
|
475
590
|
const _trackReasoningDelta = (chunk) => {
|
|
476
591
|
_turnInterruption.recordReasoningDelta(chunk);
|
|
592
|
+
_scheduleTurnCheckpoint();
|
|
477
593
|
if (typeof askOpts?.onReasoningDelta === 'function') askOpts.onReasoningDelta(chunk);
|
|
478
594
|
};
|
|
479
595
|
const _trackAssistantText = (text) => {
|
|
480
596
|
_turnInterruption.recordAssistantText(text);
|
|
597
|
+
_scheduleTurnCheckpoint(true);
|
|
481
598
|
if (typeof askOpts?.onAssistantText === 'function') askOpts.onAssistantText(text);
|
|
482
599
|
};
|
|
483
600
|
const _trackedOnToolCall = async (iteration, calls) => {
|
|
484
601
|
_turnInterruption.recordToolCalls(calls);
|
|
602
|
+
_scheduleTurnCheckpoint(true);
|
|
485
603
|
if (typeof onToolCall === 'function') return await onToolCall(iteration, calls);
|
|
486
604
|
return undefined;
|
|
487
605
|
};
|
|
488
606
|
const _trackToolResult = (message) => {
|
|
489
607
|
_turnInterruption.recordToolResult(message);
|
|
608
|
+
_scheduleTurnCheckpoint(true);
|
|
490
609
|
if (typeof askOpts?.onToolResult === 'function') askOpts.onToolResult(message);
|
|
491
610
|
};
|
|
492
611
|
const priorToolApprovalHook = session.toolApprovalHook;
|
|
@@ -505,10 +624,22 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
505
624
|
onReasoningDelta: _trackReasoningDelta,
|
|
506
625
|
onAssistantText: _trackAssistantText,
|
|
507
626
|
takeAssistantTranscriptMetadata: _takeAssistantTranscriptMetadata,
|
|
508
|
-
onAssistantMessageCommitted: () =>
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
627
|
+
onAssistantMessageCommitted: () => {
|
|
628
|
+
_turnInterruption.markAssistantMessageCommitted();
|
|
629
|
+
_scheduleTurnCheckpoint(true);
|
|
630
|
+
},
|
|
631
|
+
onAssistantToolCallObserved: (call, detail) => {
|
|
632
|
+
_turnInterruption.recordToolCalls([call], detail);
|
|
633
|
+
_scheduleTurnCheckpoint(true);
|
|
634
|
+
},
|
|
635
|
+
onProviderSendStarted: () => {
|
|
636
|
+
_turnInterruption.markProviderSendStarted();
|
|
637
|
+
_scheduleTurnCheckpoint(true);
|
|
638
|
+
},
|
|
639
|
+
onToolPhaseStarted: () => {
|
|
640
|
+
_turnInterruption.markToolPhaseStarted();
|
|
641
|
+
_scheduleTurnCheckpoint(true);
|
|
642
|
+
},
|
|
512
643
|
onUsageDelta: (d) => {
|
|
513
644
|
persistIterationMetrics(d).catch(() => {});
|
|
514
645
|
// provider_send usage arrives before agentLoop appends
|
|
@@ -531,7 +662,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
531
662
|
},
|
|
532
663
|
onToolResult: _trackToolResult,
|
|
533
664
|
onToolApproval: typeof askOpts?.onToolApproval === 'function' ? askOpts.onToolApproval : undefined,
|
|
534
|
-
onCompactEvent:
|
|
665
|
+
onCompactEvent: (event) => {
|
|
666
|
+
_scheduleTurnCheckpoint(true);
|
|
667
|
+
try { askOpts?.onCompactEvent?.(event); } catch {}
|
|
668
|
+
},
|
|
535
669
|
// Claude Code parity: mid-chain queued prompt/notification
|
|
536
670
|
// drain is owned by agentLoop at provider-continuation
|
|
537
671
|
// boundaries (after a tool batch, before the next send).
|
|
@@ -552,7 +686,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
552
686
|
// post-loop _pendingTail drain below.
|
|
553
687
|
return out;
|
|
554
688
|
},
|
|
555
|
-
onSteerMessage:
|
|
689
|
+
onSteerMessage: (text, detail) => {
|
|
690
|
+
_scheduleTurnCheckpoint(true);
|
|
691
|
+
try { askOpts?.onSteerMessage?.(text, detail); } catch {}
|
|
692
|
+
},
|
|
556
693
|
notifyFn: typeof askOpts?.notifyFn === 'function' ? askOpts.notifyFn : undefined,
|
|
557
694
|
promptCacheKey: session.promptCacheKey || sessionId,
|
|
558
695
|
// Provider-scoped cache key (mixdog-codex, mixdog-claude…).
|
|
@@ -605,6 +742,8 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
605
742
|
// Turn committed into session.messages; drop the live-turn alias so
|
|
606
743
|
// contextStatus() reverts to the authoritative committed transcript.
|
|
607
744
|
session.liveTurnMessages = null;
|
|
745
|
+
_stopTurnCheckpoint();
|
|
746
|
+
delete session.activeTurnCheckpoint;
|
|
608
747
|
const _assistantTranscriptMeta = persistedAssistantTranscriptMetadata(_rawTranscriptMeta);
|
|
609
748
|
if (result.content || result.reasoningContent) {
|
|
610
749
|
// Max-output recovery returns the complete concatenated text to
|
|
@@ -672,6 +811,12 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
672
811
|
process.stderr.write(`[session] empty-final persisted sessionId=${sessionId} stopReason=${_emptyStop ?? 'unknown'} iterations=${result?.iterations ?? 0} toolCallsTotal=${result?.toolCallsTotal ?? 0} outTokens=${_emptyUsage?.outputTokens ?? 0} hasThinking=${_thinkingStr} blockTypes=${_blockTypesStr}\n`);
|
|
673
812
|
} catch {}
|
|
674
813
|
}
|
|
814
|
+
// The terminal assistant message is now canonical. A close racing
|
|
815
|
+
// any later await should persist this committed session as-is,
|
|
816
|
+
// never re-finalize the pre-terminal outgoing array.
|
|
817
|
+
if (runtime.prepareCloseSnapshot === _prepareCloseSnapshot) {
|
|
818
|
+
runtime.prepareCloseSnapshot = null;
|
|
819
|
+
}
|
|
675
820
|
session.updatedAt = Date.now();
|
|
676
821
|
session.lastUsedAt = Date.now();
|
|
677
822
|
applyAskTerminalUsageTotals(session, result, {
|
|
@@ -806,6 +951,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
806
951
|
? saveSessionAsyncDeferred
|
|
807
952
|
: saveSessionAsync;
|
|
808
953
|
const terminalSave = saveTerminalSession(session, { expectedGeneration: askGeneration });
|
|
954
|
+
terminalSave.then(
|
|
955
|
+
() => clearTurnCheckpoint(sessionId, _turnCheckpointToken),
|
|
956
|
+
() => {},
|
|
957
|
+
);
|
|
809
958
|
finalizePendingMessageDelivery(
|
|
810
959
|
session,
|
|
811
960
|
_turnPendingEntries,
|
|
@@ -827,10 +976,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
827
976
|
markSessionDone(sessionId, { empty: isEmptyFinal });
|
|
828
977
|
_result = terminalResultPreview;
|
|
829
978
|
} catch (err) {
|
|
979
|
+
_stopTurnCheckpoint();
|
|
830
980
|
// Cancellation/error paths bypass the commit point above; drop the
|
|
831
981
|
// live-turn alias so contextStatus() stops estimating from the
|
|
832
982
|
// stale in-flight array once the turn unwinds.
|
|
833
|
-
if (activeSession)
|
|
983
|
+
if (activeSession) {
|
|
984
|
+
activeSession.liveTurnMessages = null;
|
|
985
|
+
delete activeSession.activeTurnCheckpoint;
|
|
986
|
+
}
|
|
834
987
|
// Restore before ANY finalization path. In particular, cancellation
|
|
835
988
|
// can race the acknowledged non-streaming restart and surface as a
|
|
836
989
|
// SessionClosedError; its interruption snapshot must include the
|
|
@@ -840,34 +993,15 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
840
993
|
const currentRuntime = _getRuntimeEntry(sessionId);
|
|
841
994
|
if (!currentRuntime?.closed) {
|
|
842
995
|
if (activeSession) {
|
|
843
|
-
const finalized =
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
});
|
|
848
|
-
activeSession.messages = finalized.messages;
|
|
996
|
+
const finalized = _prepareCloseSnapshot(err.reason);
|
|
997
|
+
if (currentRuntime?.prepareCloseSnapshot === _prepareCloseSnapshot) {
|
|
998
|
+
currentRuntime.prepareCloseSnapshot = null;
|
|
999
|
+
}
|
|
849
1000
|
if (!finalized.responsePreserved) {
|
|
850
1001
|
releasePendingMessages(sessionId, _turnPendingEntries);
|
|
851
|
-
if (finalized.userTurnPreserved) {
|
|
852
|
-
// Non-user abort (app quit / engine dispose /
|
|
853
|
-
// watchdog): the just-sent user turn stays in
|
|
854
|
-
// history, so the session-start meta it carries
|
|
855
|
-
// remains consumed, and the opaque provider
|
|
856
|
-
// continuation no longer matches — force full
|
|
857
|
-
// transcript replay on the next send.
|
|
858
|
-
activeSession.providerState = undefined;
|
|
859
|
-
} else {
|
|
860
|
-
activeSession.sessionStartMetaInjected = _sessionStartMetaInjectedBeforeTurn;
|
|
861
|
-
}
|
|
862
1002
|
} else {
|
|
863
1003
|
recordPendingMessageDelivery(activeSession, _turnPendingEntries);
|
|
864
|
-
// The opaque provider continuation now points at a
|
|
865
|
-
// request that ended mid-turn. Force full transcript
|
|
866
|
-
// replay on the next send instead of reusing it.
|
|
867
|
-
activeSession.providerState = undefined;
|
|
868
1004
|
}
|
|
869
|
-
activeSession.updatedAt = Date.now();
|
|
870
|
-
activeSession.lastUsedAt = Date.now();
|
|
871
1005
|
try {
|
|
872
1006
|
const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
873
1007
|
if (finalized.responsePreserved) {
|
|
@@ -878,6 +1012,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
878
1012
|
} else {
|
|
879
1013
|
await durableSave;
|
|
880
1014
|
}
|
|
1015
|
+
clearTurnCheckpoint(sessionId, _turnCheckpointToken);
|
|
881
1016
|
} catch { /* cancellation cleanup is best-effort */ }
|
|
882
1017
|
if (currentRuntime) currentRuntime.session = activeSession;
|
|
883
1018
|
} else releasePendingMessages(sessionId, _turnPendingEntries);
|
|
@@ -887,6 +1022,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
887
1022
|
// can render it as "cancelled" rather than a red failure.
|
|
888
1023
|
throw err;
|
|
889
1024
|
}
|
|
1025
|
+
if (runtime.prepareCloseSnapshot === _prepareCloseSnapshot) {
|
|
1026
|
+
runtime.prepareCloseSnapshot = null;
|
|
1027
|
+
}
|
|
890
1028
|
// A reset acknowledgement removes the live partial before the
|
|
891
1029
|
// non-streaming request starts. If that restart fails, restore the
|
|
892
1030
|
// tombstone and persist the one exposed partial as interruption
|
|
@@ -894,18 +1032,11 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
894
1032
|
const preserveProviderPartial = restoredResetText
|
|
895
1033
|
|| err?.liveTextEmitted === true
|
|
896
1034
|
|| err?.unsafeToRetry === true;
|
|
1035
|
+
let _errorStateDurable = false;
|
|
897
1036
|
if (preserveProviderPartial && activeSession && _turnInterruption.hasResponseStarted()) {
|
|
898
|
-
const finalized =
|
|
899
|
-
turnOutgoing: _turnOutgoing || activeSession.messages,
|
|
900
|
-
currentUserContent: cancelledUserTurnContent,
|
|
901
|
-
abortReason: 'provider-error',
|
|
902
|
-
});
|
|
903
|
-
activeSession.messages = finalized.messages;
|
|
1037
|
+
const finalized = _prepareCloseSnapshot('provider-error');
|
|
904
1038
|
if (finalized.responsePreserved) recordPendingMessageDelivery(activeSession, _turnPendingEntries);
|
|
905
1039
|
else releasePendingMessages(sessionId, _turnPendingEntries);
|
|
906
|
-
activeSession.providerState = undefined;
|
|
907
|
-
activeSession.updatedAt = Date.now();
|
|
908
|
-
activeSession.lastUsedAt = Date.now();
|
|
909
1040
|
try {
|
|
910
1041
|
const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
911
1042
|
if (finalized.responsePreserved) {
|
|
@@ -916,6 +1047,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
916
1047
|
} else {
|
|
917
1048
|
await durableSave;
|
|
918
1049
|
}
|
|
1050
|
+
_errorStateDurable = true;
|
|
919
1051
|
} catch { /* provider-failure history persistence is best-effort */ }
|
|
920
1052
|
const currentRuntime = _getRuntimeEntry(sessionId);
|
|
921
1053
|
if (currentRuntime) currentRuntime.session = activeSession;
|
|
@@ -935,11 +1067,19 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
935
1067
|
activeSession, _turnPendingEntries, durableSave,
|
|
936
1068
|
() => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
|
|
937
1069
|
);
|
|
1070
|
+
_errorStateDurable = true;
|
|
938
1071
|
} catch {}
|
|
939
1072
|
} else {
|
|
940
1073
|
releasePendingMessages(sessionId, _turnPendingEntries);
|
|
941
1074
|
}
|
|
942
1075
|
}
|
|
1076
|
+
if (!_errorStateDurable && activeSession) {
|
|
1077
|
+
try {
|
|
1078
|
+
await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
|
|
1079
|
+
_errorStateDurable = true;
|
|
1080
|
+
} catch { /* retain checkpoint when canonical persistence fails */ }
|
|
1081
|
+
}
|
|
1082
|
+
if (_errorStateDurable) clearTurnCheckpoint(sessionId, _turnCheckpointToken);
|
|
943
1083
|
markSessionError(sessionId, err && err.message ? err.message : String(err));
|
|
944
1084
|
throw err;
|
|
945
1085
|
}
|
|
@@ -984,6 +1124,8 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
984
1124
|
return _result;
|
|
985
1125
|
}
|
|
986
1126
|
} finally {
|
|
1127
|
+
// A thrown setup/provider path must never leave a delayed checkpoint
|
|
1128
|
+
// writer alive after the turn lock is released.
|
|
987
1129
|
// Clear the controller only if it's still ours (closeSession may have
|
|
988
1130
|
// swapped it). Leave the rest of the runtime entry intact so agent type=list
|
|
989
1131
|
// can still surface the final stage (done/error/cancelling).
|
|
@@ -569,18 +569,30 @@ export function hydratePendingMessages(sessionId, options = {}) {
|
|
|
569
569
|
|
|
570
570
|
function clearPersistedPendingMessages(sessionId) {
|
|
571
571
|
if (!isValidPendingSessionId(sessionId)) return;
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
572
|
+
// ASYNC, chained behind this session's persist tail. The previous SYNC
|
|
573
|
+
// variant self-deadlocked: closeSession ran it on the event loop while an
|
|
574
|
+
// in-flight async spool op (persist/ack/hydrate) in the SAME process held
|
|
575
|
+
// the lock — the sync wait starved the holder's continuations, so every
|
|
576
|
+
// such race burned the full 8s lock timeout (observed as an 8s shard-child
|
|
577
|
+
// stall after each one-shot session close). In-memory queues are already
|
|
578
|
+
// dropped synchronously by _dropPendingMessageState; only the durable
|
|
579
|
+
// spool row lags behind, exactly like the ack path.
|
|
580
|
+
const preceding = _pendingPersistTails.get(sessionId) || Promise.resolve();
|
|
581
|
+
const operation = preceding.catch(() => {}).then(() => updateJsonAtomic(pendingMessagesPath(), (raw) => {
|
|
582
|
+
const next = normalizePendingStore(raw);
|
|
583
|
+
if (!Object.prototype.hasOwnProperty.call(next.sessions, sessionId)) return undefined;
|
|
584
|
+
delete next.sessions[sessionId];
|
|
585
|
+
if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
|
|
586
|
+
next.updatedAt = Date.now();
|
|
587
|
+
return next;
|
|
588
|
+
}, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false }))
|
|
589
|
+
.catch((err) => {
|
|
590
|
+
try { process.stderr.write(`[session] pending-message clear failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
|
|
591
|
+
});
|
|
592
|
+
_pendingPersistTails.set(sessionId, operation);
|
|
593
|
+
operation.finally(() => {
|
|
594
|
+
if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
|
|
595
|
+
}).catch(() => {});
|
|
584
596
|
}
|
|
585
597
|
|
|
586
598
|
function shouldEvictPendingSession(sessionId, ttlMs, entryTouchedAt, now = Date.now()) {
|
|
@@ -913,5 +925,12 @@ export function _dropPendingMessageState(id, { clearPersisted = true } = {}) {
|
|
|
913
925
|
}
|
|
914
926
|
|
|
915
927
|
setImmediate(() => {
|
|
928
|
+
// Spool hygiene belongs to the lead/daemon process. Worker-mode children
|
|
929
|
+
// (channels worker, agent shard) share the same spool file; a boot-time
|
|
930
|
+
// SYNC sweep in every child contends on the cross-process lock against
|
|
931
|
+
// the lead's writes (observed in the first live shard run: "atomic lock
|
|
932
|
+
// timeout after 8000ms: session-pending-messages.json.lock") for zero
|
|
933
|
+
// benefit — the lead already sweeps.
|
|
934
|
+
if (process.env.MIXDOG_WORKER_MODE === '1') return;
|
|
916
935
|
try { sweepOrphanedPendingMessages(); } catch { /* ignore */ }
|
|
917
936
|
});
|
|
@@ -11,6 +11,7 @@ import { SessionClosedError } from './session-errors.mjs';
|
|
|
11
11
|
import { _dropPendingMessageState } from './pending-messages.mjs';
|
|
12
12
|
import { _stopToolActivityHeartbeat, _getRuntimeEntry, _clearSessionRuntime } from './runtime-liveness.mjs';
|
|
13
13
|
import { _closeBashSessionLazy } from './runtime-loaders.mjs';
|
|
14
|
+
import { clearTurnCheckpoint } from './turn-checkpoint.mjs';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Close a session. Plants a `closed=true` tombstone on disk with a bumped
|
|
@@ -37,10 +38,17 @@ export function closeSession(id, reason = 'manual', opts = {}) {
|
|
|
37
38
|
// resuming. Only truly-empty scratch sessions should still tombstone.
|
|
38
39
|
const tombstone = opts.tombstone !== false;
|
|
39
40
|
if (!id) return false;
|
|
41
|
+
const entry = _getRuntimeEntry(id);
|
|
42
|
+
// askSession owns the interruption tracker that can merge committed
|
|
43
|
+
// iterations, the current partial response, and observed tool results into
|
|
44
|
+
// one canonical transcript. Finalize that in-memory snapshot synchronously
|
|
45
|
+
// BEFORE the lifecycle generation changes; once generation is bumped, the
|
|
46
|
+
// normal cancellation cleanup save is intentionally rejected as stale.
|
|
47
|
+
try { entry?.prepareCloseSnapshot?.(reason); } catch { /* best-effort */ }
|
|
40
48
|
_stopToolActivityHeartbeat(id);
|
|
41
49
|
// Prefer in-memory runtime session — allBashSessionIds may not be persisted
|
|
42
50
|
// yet for shells opened in the current turn (BL-bash-disk-sync).
|
|
43
|
-
const inMemory =
|
|
51
|
+
const inMemory = entry?.session;
|
|
44
52
|
const persisted = inMemory || loadSession(id);
|
|
45
53
|
const bashSessionId = persisted?.implicitBashSessionId || null;
|
|
46
54
|
// Collect all persistent bash shells created during this session.
|
|
@@ -62,8 +70,10 @@ export function closeSession(id, reason = 'manual', opts = {}) {
|
|
|
62
70
|
// write could silently overwrite the session after the user resumes
|
|
63
71
|
// it back (BL: burned-session late-save clobber).
|
|
64
72
|
const newGen = tombstone ? markSessionClosed(id, reason) : bumpSessionGeneration(id, reason);
|
|
73
|
+
// prepareCloseSnapshot was folded into the synchronous generation write
|
|
74
|
+
// above. Only now is it safe to remove the crash fallback.
|
|
75
|
+
if (typeof newGen === 'number') clearTurnCheckpoint(id);
|
|
65
76
|
// 2. Mark runtime as closed so post-await validation in askSession fires.
|
|
66
|
-
const entry = _getRuntimeEntry(id);
|
|
67
77
|
if (entry) {
|
|
68
78
|
entry.closed = true;
|
|
69
79
|
entry.closedReason = reason;
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import { getAgentRuntimeSync, warnAgentRuntimeResolveFailureOnce } from './agent-runtime-singleton.mjs';
|
|
35
35
|
import { mintSessionId } from './session-id.mjs';
|
|
36
36
|
import { providerCacheKey } from './provider-cache-key.mjs';
|
|
37
|
+
import { clearTurnCheckpoint, recoverTurnCheckpoint } from './turn-checkpoint.mjs';
|
|
37
38
|
|
|
38
39
|
function buildSessionProviderCacheOpts(providerName, sessionId, agent = null) {
|
|
39
40
|
// Keep this in sync with createSession's provider-cache policy: only
|
|
@@ -657,6 +658,19 @@ export async function resumeSession(sessionId, preset, options = {}) {
|
|
|
657
658
|
}
|
|
658
659
|
return attached;
|
|
659
660
|
}
|
|
661
|
+
const crashRecovery = recoverTurnCheckpoint(session);
|
|
662
|
+
if (crashRecovery.changed) {
|
|
663
|
+
// Resume must not expose a recovered frame that exists only in memory.
|
|
664
|
+
// Persist synchronously, then remove the sidecar; a second crash at
|
|
665
|
+
// either boundary therefore leaves at least one complete copy.
|
|
666
|
+
saveSession(session, {
|
|
667
|
+
sync: true,
|
|
668
|
+
expectedGeneration: session.generation,
|
|
669
|
+
});
|
|
670
|
+
if (crashRecovery.turnToken) {
|
|
671
|
+
clearTurnCheckpoint(sessionId, crashRecovery.turnToken);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
660
674
|
const oldTools = session.tools || [];
|
|
661
675
|
const cached = _preparedResumes.get(sessionId);
|
|
662
676
|
_preparedResumes.delete(sessionId);
|