mixdog 0.9.87 → 0.9.89
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 +13 -7
- package/scripts/tool-failures.mjs +60 -24
- package/src/agents/heavy-worker/AGENT.md +3 -4
- package/src/agents/worker/AGENT.md +1 -2
- package/src/cli.mjs +8 -0
- package/src/defaults/cycle3-review-prompt.md +4 -4
- package/src/rules/agent/00-core.md +3 -0
- package/src/rules/agent/42-cycle3-agent.md +5 -6
- package/src/rules/lead/01-general.md +2 -0
- package/src/rules/shared/01-tool.md +29 -35
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
- package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
- package/src/runtime/agent/orchestrator/config.mjs +29 -11
- package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
- package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
- package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
- package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
- package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
- package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
- package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
- package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
- package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
- package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
- package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
- package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
- package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
- package/src/runtime/media/lanes.mjs +2 -2
- package/src/runtime/media/renditions.mjs +101 -17
- package/src/runtime/media/renditions.test.mjs +54 -0
- package/src/runtime/media/store.mjs +111 -43
- package/src/runtime/media/store.test.mjs +53 -11
- package/src/runtime/memory/index.mjs +26 -50
- package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
- package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
- package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
- package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
- package/src/runtime/memory/lib/http-router.mjs +4 -0
- package/src/runtime/memory/lib/ko-morph.mjs +49 -6
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
- package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
- package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
- package/src/runtime/memory/lib/query-handlers.mjs +26 -28
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/atomic-file.mjs +5 -1
- package/src/runtime/shared/child-guardian.mjs +73 -2
- package/src/runtime/shared/provider-api-key.mjs +22 -5
- package/src/runtime/shared/tool-execution-contract.mjs +53 -0
- package/src/session-runtime/config-helpers.mjs +6 -5
- package/src/session-runtime/lifecycle-api.mjs +16 -4
- package/src/session-runtime/media-api.mjs +31 -16
- package/src/session-runtime/prewarm.mjs +2 -24
- package/src/session-runtime/runtime-core.mjs +33 -15
- package/src/session-runtime/runtime-tunables.mjs +0 -3
- package/src/session-runtime/session-lifecycle.mjs +0 -6
- package/src/session-runtime/session-title.mjs +228 -0
- package/src/session-runtime/session-turn-api.mjs +11 -7
- package/src/session-runtime/workflow-agents-api.mjs +16 -0
- package/src/session-runtime/workflow.mjs +7 -5
- package/src/standalone/agent-tool/job-views.mjs +90 -36
- package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
- package/src/standalone/agent-tool/worker-index.mjs +8 -0
- package/src/standalone/agent-tool/worker-rows.mjs +2 -1
- package/src/standalone/agent-tool.mjs +17 -17
- package/src/standalone/agent-watchdog-registry.mjs +19 -2
- package/src/standalone/channel-daemon.mjs +8 -0
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/standalone/memory-runtime-proxy.mjs +4 -7
- package/src/standalone/projects.mjs +4 -1
- package/src/standalone/usage-dashboard.mjs +25 -3
- package/src/tui/App.jsx +5 -3
- package/src/tui/app/resume-picker.mjs +7 -2
- package/src/tui/app/route-pickers.mjs +11 -117
- package/src/tui/app/slash-dispatch.mjs +13 -3
- package/src/tui/app/transcript-row-estimate.mjs +1 -1
- package/src/tui/app/transcript-window.mjs +17 -93
- package/src/tui/app/use-transcript-scroll.mjs +32 -2
- package/src/tui/app/use-transcript-window.mjs +64 -114
- package/src/tui/components/Markdown.jsx +19 -2
- package/src/tui/components/Message.jsx +8 -3
- package/src/tui/components/ToolExecution.jsx +7 -3
- package/src/tui/dist/index.mjs +243 -223
- package/src/tui/engine/live-share.mjs +15 -5
- package/src/tui/engine/render-timing.mjs +2 -2
- package/src/tui/engine/session-api-ext.mjs +74 -4
- package/src/tui/engine/session-flow.mjs +3 -1
- package/src/tui/engine.mjs +2 -2
- package/src/tui/hooks/useSharedTick.mjs +0 -2
- package/src/tui/index.jsx +5 -5
- package/src/ui/statusline-agents.mjs +44 -11
- package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
- package/src/workflows/default/WORKFLOW.md +3 -3
- package/src/workflows/solo/WORKFLOW.md +5 -3
- package/src/runtime/media/index.mjs +0 -18
- package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
- package/src/standalone/agent-shard/shard-child.mjs +0 -300
- package/src/standalone/agent-shard/shard-pool.mjs +0 -443
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
crossTurnDedupStub,
|
|
51
51
|
ITERATION_CAP_REFUSAL_STUB,
|
|
52
52
|
} from './loop/completion-guards.mjs';
|
|
53
|
+
import { STOP_HOOK_SOURCE, createToolFailureStopHook } from './loop/stop-hooks.mjs';
|
|
53
54
|
import { isEditProgressTool } from './loop/completion-guards.mjs';
|
|
54
55
|
import { agentContextOverflowError } from './loop/context-overflow.mjs';
|
|
55
56
|
import { positiveTokenInt } from './loop/env.mjs';
|
|
@@ -80,10 +81,16 @@ import {
|
|
|
80
81
|
} from './loop/tool-helpers.mjs';
|
|
81
82
|
import {
|
|
82
83
|
compactToolCallsForHistory,
|
|
84
|
+
compactSettledToolCallBodies,
|
|
83
85
|
restoreToolCallBodyForId,
|
|
84
86
|
} from './loop/stored-tool-args.mjs';
|
|
85
87
|
import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
|
|
86
|
-
import {
|
|
88
|
+
import {
|
|
89
|
+
classifyTerminationReason,
|
|
90
|
+
INCOMPLETE_STOP_REASONS,
|
|
91
|
+
isOutputLimitStopReason,
|
|
92
|
+
providerContinuationSignal,
|
|
93
|
+
} from './loop/termination.mjs';
|
|
87
94
|
import { createSteeringLadder } from './loop/steering-ladder.mjs';
|
|
88
95
|
import { runPreSendCompactPass } from './pre-send-compact.mjs';
|
|
89
96
|
import { createEagerDispatcher } from './eager-dispatch.mjs';
|
|
@@ -210,14 +217,31 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
210
217
|
// Reasoning/thinking deltas, tool calls, and the final answer are kept.
|
|
211
218
|
const suppressMidTurnText = isAgentOwner(sessionRef);
|
|
212
219
|
if (suppressMidTurnText) opts.onTextDelta = undefined;
|
|
220
|
+
// Deferred mutation-body compaction: bodies left verbatim by a previous
|
|
221
|
+
// turn's push (deferBodies below) collapse to markers now — the model has
|
|
222
|
+
// already seen them on that turn's follow-up send. Failed bodies stay
|
|
223
|
+
// verbatim for retry.
|
|
224
|
+
compactSettledToolCallBodies(messages);
|
|
225
|
+
// ---- Codex turn stop hook (refs/codex core/src/session/turn.rs:372-404) --
|
|
226
|
+
// A no-tool assistant message is TERMINAL. Only a structured provider
|
|
227
|
+
// follow-up signal (end_turn=false / pause_turn), pending input, tool
|
|
228
|
+
// calls/results, or a stop hook that blocks with a continuation prompt keep
|
|
229
|
+
// sampling alive. The single hook below is structural: after an unresolved
|
|
230
|
+
// real tool failure it blocks the first terminal message ONCE, records the
|
|
231
|
+
// continuation prompt, and then stays inactive for the rest of the turn.
|
|
232
|
+
const _toolFailureStopHook = createToolFailureStopHook();
|
|
213
233
|
const pushToolResultMessage = (message) => {
|
|
214
234
|
messages.push(message);
|
|
235
|
+
_toolFailureStopHook.observeToolResult(message);
|
|
215
236
|
try { opts.onToolResult?.(message); } catch {}
|
|
216
237
|
};
|
|
217
238
|
const drainSteeringIntoMessages = (stage = 'mid-turn', options = {}) => {
|
|
218
239
|
if (typeof opts.drainSteering !== 'function') return false;
|
|
219
240
|
let steerMsgs = [];
|
|
220
|
-
|
|
241
|
+
// The stage rides along so the host can scope which queues a drain may
|
|
242
|
+
// consume (mid-turn tool-batch boundary vs the terminal pending-input
|
|
243
|
+
// check that precedes the stop hooks).
|
|
244
|
+
try { steerMsgs = opts.drainSteering(sessionId, { ...options, stage }) || []; }
|
|
221
245
|
catch { steerMsgs = []; }
|
|
222
246
|
const mergedMessages = [];
|
|
223
247
|
for (const entry of Array.isArray(steerMsgs) ? steerMsgs : []) {
|
|
@@ -288,13 +312,34 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
288
312
|
const providerMetadata = resp.providerMetadata && typeof resp.providerMetadata === 'object'
|
|
289
313
|
? resp.providerMetadata
|
|
290
314
|
: null;
|
|
291
|
-
|
|
315
|
+
// Anthropic native server-tool turns (web search / code execution /
|
|
316
|
+
// native MCP) carry `server_tool_use` + `*_tool_result` blocks that
|
|
317
|
+
// exist ONLY in this ordered verbatim list — they cannot be rebuilt
|
|
318
|
+
// from content/thinkingBlocks/toolCalls, and dropping them breaks the
|
|
319
|
+
// resumed turn (a result block is only valid right after its call
|
|
320
|
+
// block). Attach them so Anthropic lowering replays the turn as-is.
|
|
321
|
+
// Guarded on no client tool calls: this committer only handles
|
|
322
|
+
// continuation/intermediate turns. A MIXED turn (native blocks +
|
|
323
|
+
// client tool_use) is committed by the tool-call branch below, which
|
|
324
|
+
// keeps both the compacted toolCalls and these blocks.
|
|
325
|
+
const assistantBlocks = Array.isArray(resp.assistantBlocks)
|
|
326
|
+
&& resp.assistantBlocks.length
|
|
327
|
+
&& !resp.toolCalls?.length
|
|
328
|
+
? resp.assistantBlocks
|
|
329
|
+
: null;
|
|
330
|
+
// A native-only turn (server tool blocks, no flattened text) is real
|
|
331
|
+
// assistant output and must stay committable — not treated as empty.
|
|
332
|
+
if (!content && !reasoningContent && !reasoningItems && !thinkingBlocks && !assistantBlocks) return false;
|
|
292
333
|
const message = attachAssistantTranscriptMetadata({
|
|
293
334
|
role: 'assistant',
|
|
294
335
|
content,
|
|
336
|
+
// assistantBlocks already contains the thinking blocks verbatim in
|
|
337
|
+
// stream order, so thinkingBlocks is redundant (and would be
|
|
338
|
+
// double-counted by the context estimator) when it is present.
|
|
339
|
+
...(assistantBlocks ? { assistantBlocks } : {}),
|
|
295
340
|
// Anthropic adaptive-thinking signatures must be replayed verbatim
|
|
296
341
|
// before the continuation turn, just like tool-call trajectories.
|
|
297
|
-
...(thinkingBlocks ? { thinkingBlocks } : {}),
|
|
342
|
+
...(thinkingBlocks && !assistantBlocks ? { thinkingBlocks } : {}),
|
|
298
343
|
...(reasoningItems ? { reasoningItems } : {}),
|
|
299
344
|
...(reasoningContent ? { reasoningContent } : {}),
|
|
300
345
|
...(providerMetadata ? { providerMetadata } : {}),
|
|
@@ -334,6 +379,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
334
379
|
let _refusalRetryUsed = false;
|
|
335
380
|
let _maxOutputRecoveryCount = 0;
|
|
336
381
|
const _maxOutputContentParts = [];
|
|
382
|
+
// Count of structured provider continuation signals honored this turn
|
|
383
|
+
// (endTurn === false / stopReason === 'pause_turn'). Diagnostic only; the
|
|
384
|
+
// hard iteration cap remains the sole bound on how long a provider may
|
|
385
|
+
// keep declaring "not done" inside one user turn.
|
|
386
|
+
let _providerContinuationCount = 0;
|
|
337
387
|
// Claude Code parity: queued prompt/task notifications are attached after a
|
|
338
388
|
// tool batch, before the continuation provider send. Normal batches drain
|
|
339
389
|
// up to 'next'; a Sleep-like tool grants a 'later' flush.
|
|
@@ -760,6 +810,42 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
760
810
|
});
|
|
761
811
|
continue;
|
|
762
812
|
}
|
|
813
|
+
// Structured provider continuation signal. The provider declared
|
|
814
|
+
// the assistant turn unfinished, so this text is mid-turn output,
|
|
815
|
+
// not a final answer: commit it EXACTLY ONCE to history/UI (single
|
|
816
|
+
// pushIntermediateAssistantResponse → onAssistantMessageCommitted)
|
|
817
|
+
// and immediately resume sampling in the same user turn. Output
|
|
818
|
+
// limit stops keep the bounded max-output recovery ladder above and
|
|
819
|
+
// refusals keep the bounded refusal retry; both own their own
|
|
820
|
+
// continuation semantics. No lexical/progress-text heuristic.
|
|
821
|
+
const continuationSignal = !isOutputLimitStop && stopReason !== 'refusal'
|
|
822
|
+
? providerContinuationSignal(response)
|
|
823
|
+
: null;
|
|
824
|
+
if (continuationSignal && pushIntermediateAssistantResponse(response)) {
|
|
825
|
+
if (hasContent && !suppressMidTurnText) {
|
|
826
|
+
try { opts.onAssistantText?.(response.content); } catch { /* best-effort */ }
|
|
827
|
+
}
|
|
828
|
+
_providerContinuationCount += 1;
|
|
829
|
+
_emptyNudgeStreak = 0;
|
|
830
|
+
try {
|
|
831
|
+
appendAgentTrace({
|
|
832
|
+
sessionId,
|
|
833
|
+
iteration: iterations,
|
|
834
|
+
kind: 'provider_continuation',
|
|
835
|
+
payload: {
|
|
836
|
+
signal: continuationSignal,
|
|
837
|
+
stop_reason: stopReason,
|
|
838
|
+
count: _providerContinuationCount,
|
|
839
|
+
content_len: typeof response.content === 'string' ? response.content.length : 0,
|
|
840
|
+
},
|
|
841
|
+
agent: sessionAgent || null,
|
|
842
|
+
});
|
|
843
|
+
} catch { /* best-effort */ }
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
// A continuation signal with nothing committable (no text, no
|
|
847
|
+
// reasoning) falls through to the bounded empty-turn handling
|
|
848
|
+
// below: re-sending an unchanged transcript would livelock.
|
|
763
849
|
if (!hasContent && !isHidden) {
|
|
764
850
|
_emptyNudgeStreak += 1;
|
|
765
851
|
if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
|
|
@@ -774,14 +860,49 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
774
860
|
if (isIncompleteStop) {
|
|
775
861
|
nudgeMsg = `[mixdog-runtime] Previous turn ended mid-synthesis (stopReason=${stopReason}) with empty content. Continue — emit your final handoff (fragments, file:line) with your synthesis so far, or call more tools to finish.`;
|
|
776
862
|
} else {
|
|
777
|
-
// Vary the nudge per attempt — a byte-identical repeat
|
|
778
|
-
// reinforces the empty-completion pattern it is trying to
|
|
779
|
-
// break (the request context stays effectively constant).
|
|
780
863
|
nudgeMsg = `[mixdog-runtime] Your previous response was empty (no handoff text and no tool call) — attempt ${_emptyNudgeStreak}/${EMPTY_NUDGE_MAX}. Either emit your final handoff text now, or continue with tool calls. Do not return an empty turn.`;
|
|
781
864
|
}
|
|
782
865
|
messages.push({ role: 'user', content: nudgeMsg });
|
|
783
866
|
continue;
|
|
784
867
|
}
|
|
868
|
+
// Codex `has_pending_input` (turn.rs:304-318): queued user input is
|
|
869
|
+
// folded into needs_follow_up and evaluated BEFORE the stop hooks,
|
|
870
|
+
// so real steering always wins over a synthetic continuation prompt.
|
|
871
|
+
// Commit the terminal text first (beforeAppend), then resume.
|
|
872
|
+
if (!_capFinalToolsDisabled && drainSteeringIntoMessages('terminal', {
|
|
873
|
+
maxPriority: 'next',
|
|
874
|
+
beforeAppend: () => { pushIntermediateAssistantResponse(response); },
|
|
875
|
+
})) {
|
|
876
|
+
_emptyNudgeStreak = 0;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
// Codex parity (turn.rs:372-404): this no-tool message ends the turn
|
|
880
|
+
// unless a stop hook blocks it. The unresolved-tool-failure hook may
|
|
881
|
+
// block exactly once — commit the assistant text, record the
|
|
882
|
+
// structural continuation prompt, resume sampling. Skipped on the
|
|
883
|
+
// hard-cap final turn (tool use is already forbidden) and for hidden
|
|
884
|
+
// roles, whose own role rules define a text-only terminal contract.
|
|
885
|
+
if (!_capFinalToolsDisabled && !isHidden) {
|
|
886
|
+
const _hookPrompt = _toolFailureStopHook.takeContinuationPrompt();
|
|
887
|
+
if (_hookPrompt) {
|
|
888
|
+
pushIntermediateAssistantResponse(response);
|
|
889
|
+
messages.push({
|
|
890
|
+
role: 'user',
|
|
891
|
+
content: _hookPrompt,
|
|
892
|
+
meta: { source: STOP_HOOK_SOURCE, tool: _toolFailureStopHook.lastFailedTool || null },
|
|
893
|
+
});
|
|
894
|
+
try {
|
|
895
|
+
appendAgentTrace({
|
|
896
|
+
sessionId,
|
|
897
|
+
iteration: iterations,
|
|
898
|
+
kind: 'steer',
|
|
899
|
+
payload: { tag: 'tool_failure_stop_hook', tool: _toolFailureStopHook.lastFailedTool || null },
|
|
900
|
+
agent: sessionAgent || null,
|
|
901
|
+
});
|
|
902
|
+
} catch { /* best-effort */ }
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
785
906
|
if (_maxOutputContentParts.length > 0) {
|
|
786
907
|
const terminalSegment = typeof response.content === 'string' ? response.content : '';
|
|
787
908
|
response = {
|
|
@@ -823,13 +944,33 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
823
944
|
// response.content (the real result rides the later final-answer
|
|
824
945
|
// turn). Blank it so it never accumulates as input tokens.
|
|
825
946
|
content: suppressMidTurnText ? '' : (response.content || ''),
|
|
826
|
-
|
|
947
|
+
// deferBodies: mutation bodies (patch / old_string / ...) stay
|
|
948
|
+
// verbatim through the send that answers this batch, then the
|
|
949
|
+
// compactSettledToolCallBodies sweep below collapses them. This
|
|
950
|
+
// keeps a model that patches twice in a row from ever seeing (and
|
|
951
|
+
// copying) a compacted marker where its own last patch should be.
|
|
952
|
+
toolCalls: compactToolCallsForHistory(calls, { deferBodies: true }),
|
|
953
|
+
// MIXED Anthropic turn (native server tools + client tool_use):
|
|
954
|
+
// the ordered `server_tool_use` / `*_tool_result` blocks exist ONLY
|
|
955
|
+
// in this verbatim list and are order-bound (a result block is
|
|
956
|
+
// valid only right after its call block), so they must ride the
|
|
957
|
+
// tool-call history message too. toolCalls above stays authoritative
|
|
958
|
+
// for execution/recovery/interruption; both Anthropic lowerers
|
|
959
|
+
// prefer assistantBlocks verbatim (which already contains the
|
|
960
|
+
// text/thinking/tool_use blocks), so nothing is emitted twice and
|
|
961
|
+
// non-Anthropic providers simply ignore the field.
|
|
962
|
+
...(Array.isArray(response.assistantBlocks) && response.assistantBlocks.length
|
|
963
|
+
? { assistantBlocks: response.assistantBlocks }
|
|
964
|
+
: {}),
|
|
827
965
|
// Anthropic adaptive thinking: prior-turn thinking blocks must be
|
|
828
966
|
// returned verbatim (signature intact; empty thinking allowed) and
|
|
829
967
|
// are REQUIRED back before tool_use blocks on tool-continuation
|
|
830
968
|
// turns. Store them so toAnthropicMessages can build assistantBlocks
|
|
831
|
-
// = [...thinking, tool_use...].
|
|
969
|
+
// = [...thinking, tool_use...]. Skipped when assistantBlocks is
|
|
970
|
+
// present: those blocks already carry the thinking verbatim, and a
|
|
971
|
+
// second copy would double-count in the context estimator.
|
|
832
972
|
...(Array.isArray(response.thinkingBlocks) && response.thinkingBlocks.length
|
|
973
|
+
&& !(Array.isArray(response.assistantBlocks) && response.assistantBlocks.length)
|
|
833
974
|
? { thinkingBlocks: response.thinkingBlocks }
|
|
834
975
|
: {}),
|
|
835
976
|
...(Array.isArray(response.reasoningItems) && response.reasoningItems.length
|
|
@@ -842,8 +983,13 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
842
983
|
? { providerMetadata: response.providerMetadata }
|
|
843
984
|
: {}),
|
|
844
985
|
}, opts);
|
|
986
|
+
// Settle earlier deferred bodies before this turn's message lands:
|
|
987
|
+
// every previous call already has its result row, so successful bodies
|
|
988
|
+
// compact to markers while failed ones keep their full retry text.
|
|
989
|
+
compactSettledToolCallBodies(messages);
|
|
845
990
|
messages.push(_assistantTurnMsg);
|
|
846
991
|
try { opts.onAssistantMessageCommitted?.(_assistantTurnMsg); } catch {}
|
|
992
|
+
const _callsToExecute = calls;
|
|
847
993
|
// Hard-cap final turn: tools are disabled but the model still emitted
|
|
848
994
|
// tool calls. Do NOT execute them — push a refusal stub for each.
|
|
849
995
|
if (_capFinalToolsDisabled) {
|
|
@@ -860,7 +1006,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
860
1006
|
}
|
|
861
1007
|
try { opts.onToolPhaseStarted?.(); } catch {}
|
|
862
1008
|
({ dedupStubTotal: _dedupStubTotal, editCount: _editCount } = await processToolBatch({
|
|
863
|
-
calls, messages, tools, cwd, sessionId, sessionRef, signal, opts,
|
|
1009
|
+
calls: _callsToExecute, messages, tools, cwd, sessionId, sessionRef, signal, opts,
|
|
864
1010
|
iterations, assistantTurnMsg: _assistantTurnMsg,
|
|
865
1011
|
pending: eager.pending, epoch: eager.epoch, startEagerRun: eager.startEagerRun,
|
|
866
1012
|
crossTurnCalls: _crossTurnCalls, crossTurnCap: _CROSS_TURN_CAP,
|
|
@@ -869,8 +1015,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
869
1015
|
pushToolResultMessage, throwIfAborted,
|
|
870
1016
|
repeatFailLimit: REPEAT_FAIL_LIMIT,
|
|
871
1017
|
}));
|
|
1018
|
+
// Settle the stop hook on the batch that actually executed.
|
|
1019
|
+
_toolFailureStopHook.endBatch(_callsToExecute);
|
|
872
1020
|
_toolBatchJustCompleted = true;
|
|
873
|
-
_lastToolBatchHadSleep =
|
|
1021
|
+
_lastToolBatchHadSleep = _callsToExecute.some(isSleepLikeToolCall);
|
|
874
1022
|
}
|
|
875
1023
|
// Classify WHY the loop ended so agent-tool can promote an empty/abnormal
|
|
876
1024
|
// finish to an explicit Lead-facing error instead of a silent empty
|
|
@@ -891,5 +1039,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
891
1039
|
providerStateUpdated,
|
|
892
1040
|
terminationReason,
|
|
893
1041
|
maxLoopIterations,
|
|
1042
|
+
providerContinuations: _providerContinuationCount,
|
|
894
1043
|
};
|
|
895
1044
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// serial batch loop.
|
|
8
8
|
import { normalizeToolEnvelope } from './tool-envelope.mjs';
|
|
9
9
|
import { isInvalidToolArgsMarker } from '../providers/openai-compat-stream.mjs';
|
|
10
|
-
import { _intraTurnSig, _isReadTool, _isScopedCacheableTool, _stripMcpPrefix } from './loop/tool-classify.mjs';
|
|
10
|
+
import { _intraTurnSig, _isMutationTool, _isReadTool, _isScopedCacheableTool, _isShellTool, _stripMcpPrefix } from './loop/tool-classify.mjs';
|
|
11
11
|
import { tryReadCached, tryScopedToolCached } from './read-dedup.mjs';
|
|
12
12
|
import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
|
|
13
13
|
import { executeTool } from './loop/tool-exec.mjs';
|
|
@@ -40,6 +40,18 @@ export function createEagerDispatcher({
|
|
|
40
40
|
// resets at the turn boundary without leaking across getIterations().
|
|
41
41
|
const _eagerInFlightSigs = new Map();
|
|
42
42
|
const epoch = { mutation: 0 };
|
|
43
|
+
// Patch→shell ordering insurance: a shell call that appears AFTER an
|
|
44
|
+
// apply_patch in the same assistant turn must not eager-start before
|
|
45
|
+
// that patch has executed (serial body runs both in call order).
|
|
46
|
+
// Reads are already safe via the mutationEpoch re-execution gate;
|
|
47
|
+
// only shell's side effects would consume pre-patch file state.
|
|
48
|
+
let _streamSawMutation = false;
|
|
49
|
+
const _hasEarlierMutation = (calls, index) => {
|
|
50
|
+
for (let k = 0; k < index; k += 1) {
|
|
51
|
+
if (_isMutationTool(calls[k]?.name)) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
};
|
|
43
55
|
const startEagerTool = (call) => {
|
|
44
56
|
if (!call?.id || pending.has(call.id) || !isParallelDispatchable(call.name)) return null;
|
|
45
57
|
// Never eager-execute a call whose arguments failed to parse
|
|
@@ -163,6 +175,9 @@ export function createEagerDispatcher({
|
|
|
163
175
|
// later calls keep starting in parallel past it.
|
|
164
176
|
if (!call?.id || !isParallelDispatchable(call.name)) continue;
|
|
165
177
|
if (dupSet && dupSet.has(call.id)) continue;
|
|
178
|
+
// Patch→shell insurance: leave a shell that follows an
|
|
179
|
+
// apply_patch to the serial body so it runs after the patch.
|
|
180
|
+
if (_isShellTool(call.name) && _hasEarlierMutation(calls, j)) continue;
|
|
166
181
|
// A null return here is NOT a state barrier. It means a
|
|
167
182
|
// non-barrier stub — intra-turn in-flight dup, repeat-failure /
|
|
168
183
|
// cross-turn dedup, pre-dispatch-deny, invalid-args, or a cache
|
|
@@ -173,7 +188,9 @@ export function createEagerDispatcher({
|
|
|
173
188
|
}
|
|
174
189
|
};
|
|
175
190
|
const onToolCall = (call) => {
|
|
191
|
+
if (_isMutationTool(call?.name)) { _streamSawMutation = true; return; }
|
|
176
192
|
if (!isParallelDispatchable(call?.name)) return;
|
|
193
|
+
if (_streamSawMutation && _isShellTool(call.name)) return;
|
|
177
194
|
startEagerTool(call);
|
|
178
195
|
};
|
|
179
196
|
return { pending, epoch, startEagerTool, startEagerRun, onToolCall };
|
|
@@ -1,66 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* for JSON.parse on the session-store save-guard hot path.
|
|
2
|
+
* Authoritative top-level lifecycle read (`id` / `closed` / `generation`) for
|
|
3
|
+
* a serialized session JSON document.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* entire messages array, unescaping every string) on the hot path even
|
|
11
|
-
* though only two scalar fields are ever consulted.
|
|
5
|
+
* ONE verdict, no fallbacks. Every caller of this module makes a destructive
|
|
6
|
+
* or ownership-deciding choice (drop a save, delete a file, close a session,
|
|
7
|
+
* deliver cross-process input), so the module answers with exactly two
|
|
8
|
+
* outcomes:
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* naively-anchored regex cannot tell that occurrence apart from the real
|
|
17
|
-
* top-level lifecycle field, so it is spoofable. This scanner walks the raw
|
|
18
|
-
* text as a real (bracket-depth + string-escape aware) tokenizer, but only
|
|
19
|
-
* *interprets* key/value pairs at depth 1 (directly inside the root
|
|
20
|
-
* object); every nested object/array/string value — including the entire
|
|
21
|
-
* `messages` array — is skipped by depth counting alone, never allocated
|
|
22
|
-
* or parsed. That keeps it both spoof-proof (nested `"closed"` can never be
|
|
23
|
-
* mistaken for the top-level one) and cheap (no JS object/array/string
|
|
24
|
-
* allocation for content we don't care about).
|
|
10
|
+
* • a lifecycle record — the document is a single, complete, well-formed
|
|
11
|
+
* JSON object whose top-level lifecycle fields are unambiguous;
|
|
12
|
+
* • LIFECYCLE_SCAN_CONFLICT — "unreadable": ANY doubt at all.
|
|
25
13
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
14
|
+
* There is deliberately no third "couldn't tell, parse it yourself" state.
|
|
15
|
+
* The previous incarnation was a hand-rolled depth-1 tokenizer that returned
|
|
16
|
+
* `null` for everything it did not positively confirm and let each caller
|
|
17
|
+
* fall back to `JSON.parse` — which silently resolves duplicate keys
|
|
18
|
+
* LAST-WINS, so a record like `{"id":"mine",…,"id":"other"}` (or a malformed
|
|
19
|
+
* separator the scanner refused) was accepted as this session's own state by
|
|
20
|
+
* the very code paths the scanner existed to protect. The fallback WAS the
|
|
21
|
+
* vulnerability. It is gone.
|
|
29
22
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
23
|
+
* Strategy (simplification over more parser patches):
|
|
24
|
+
* 1. strict full `JSON.parse` — the only validator. It rejects truncation,
|
|
25
|
+
* missing/trailing commas, missing colons, trailing garbage after the
|
|
26
|
+
* root object, and any non-object root, with no bespoke grammar of ours
|
|
27
|
+
* to get subtly wrong;
|
|
28
|
+
* 2. an explicit top-level duplicate-key walk, because `JSON.parse` alone
|
|
29
|
+
* is last-wins and cannot report that a key appeared twice. ANY repeated
|
|
30
|
+
* top-level key is a conflict — not just the lifecycle three: consumers
|
|
31
|
+
* of this record RESERIALIZE it (the tombstone/detach barriers rewrite
|
|
32
|
+
* the whole document) and ACT on other top-level fields (`status`,
|
|
33
|
+
* `updatedAt`, `owner`, `messages` drive sweep maturity, blank-scratch
|
|
34
|
+
* reaping and transcript reads), so a last-wins `status`/`updatedAt`
|
|
35
|
+
* would silently decide a delete or a rewrite. The walk runs on a
|
|
36
|
+
* document ALREADY proven well-formed by step 1, so it only counts
|
|
37
|
+
* depth-1 keys (skipping nested objects/arrays/strings by bracket
|
|
38
|
+
* depth) and never has to double as a validator;
|
|
39
|
+
* 3. type validation of the three lifecycle fields. Our writer only ever
|
|
40
|
+
* emits a non-empty string `id`, a boolean `closed` and a numeric
|
|
41
|
+
* `generation`; anything else present under those names is not a record
|
|
42
|
+
* we can speak for → unreadable.
|
|
43
|
+
*
|
|
44
|
+
* Duplicate detection is type- and order-independent by construction: the
|
|
45
|
+
* walk counts key OCCURRENCES before any value is interpreted, so
|
|
46
|
+
* `{"id":7,"id":"sess_a"}` (non-string first) and `{"closed":false,
|
|
47
|
+
* "closed":true}` conflict exactly like the string-first case.
|
|
48
|
+
*
|
|
49
|
+
* NESTED duplicates stay compatible on purpose: a duplicate key inside
|
|
50
|
+
* `messages[i].content` (a pasted JSON blob, a tool result) is message DATA.
|
|
51
|
+
* It is skipped by bracket depth, is never an ownership or action input, and
|
|
52
|
+
* the whole nested value is reserialized verbatim by JSON.stringify from the
|
|
53
|
+
* parsed document — the last-wins resolution there cannot move a lifecycle,
|
|
54
|
+
* sweep or delete decision.
|
|
55
|
+
*
|
|
56
|
+
* The parsed document is returned alongside the lifecycle fields (`doc`) so
|
|
57
|
+
* consumers that need the whole record (the sweep reads owner/status/
|
|
58
|
+
* timestamps/messages) reuse THIS parse instead of parsing again — a second
|
|
59
|
+
* parse would be both slower and a second, divergent authority.
|
|
41
60
|
*/
|
|
42
61
|
|
|
43
|
-
|
|
44
|
-
|
|
62
|
+
// Single authoritative rejection marker: ambiguous OR malformed OR
|
|
63
|
+
// wrong-typed. Frozen and deliberately carrying no `id`/`closed`/`generation`
|
|
64
|
+
// so a lenient consumer that forgets the check still reads nothing usable.
|
|
65
|
+
export const LIFECYCLE_SCAN_CONFLICT = Object.freeze({ conflict: 'unreadable' });
|
|
66
|
+
|
|
67
|
+
/** True for the rejection marker (and for anything that isn't a record). */
|
|
68
|
+
export function isLifecycleUnreadable(result) {
|
|
69
|
+
return !result || typeof result !== 'object' || result.conflict !== undefined;
|
|
45
70
|
}
|
|
46
71
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// (e.g. a concatenated second document, or corruption) must not be silently
|
|
50
|
-
// accepted as if `raw` were exactly one well-formed object.
|
|
51
|
-
function isTrailingWhitespaceOnly(raw, i) {
|
|
52
|
-
const len = raw.length;
|
|
53
|
-
while (i < len) {
|
|
54
|
-
if (!isWs(raw[i])) return false;
|
|
55
|
-
i++;
|
|
56
|
-
}
|
|
57
|
-
return true;
|
|
72
|
+
function isWs(ch) {
|
|
73
|
+
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
// Advances past a JSON string literal starting at raw[i] === '"'.
|
|
61
|
-
// Escape handling only needs to skip exactly one char after each `\` —
|
|
62
|
-
//
|
|
63
|
-
//
|
|
77
|
+
// Escape handling only needs to skip exactly one char after each `\` — even
|
|
78
|
+
// for `\u0041`-style escapes the trailing hex digits can never be `"` or `\`,
|
|
79
|
+
// so a naive 2-char skip never misses the real closing quote.
|
|
64
80
|
function skipString(raw, i) {
|
|
65
81
|
const len = raw.length;
|
|
66
82
|
i++; // opening quote
|
|
@@ -73,11 +89,8 @@ function skipString(raw, i) {
|
|
|
73
89
|
return i;
|
|
74
90
|
}
|
|
75
91
|
|
|
76
|
-
// Advances past one JSON value
|
|
77
|
-
//
|
|
78
|
-
// counting (any `{`/`[` opens, any `}`/`]` closes) with string-awareness so
|
|
79
|
-
// braces inside string content never perturb the count — never descends
|
|
80
|
-
// into the structure to interpret its keys.
|
|
92
|
+
// Advances past one JSON value. Objects/arrays are skipped by string-aware
|
|
93
|
+
// bracket-depth counting — never descended into, never allocated.
|
|
81
94
|
function skipValue(raw, i) {
|
|
82
95
|
const len = raw.length;
|
|
83
96
|
const c = raw[i];
|
|
@@ -99,79 +112,96 @@ function skipValue(raw, i) {
|
|
|
99
112
|
return i;
|
|
100
113
|
}
|
|
101
114
|
|
|
102
|
-
|
|
115
|
+
/**
|
|
116
|
+
* True when EVERY depth-1 key occurs exactly once.
|
|
117
|
+
*
|
|
118
|
+
* PRECONDITION: `raw` already parsed cleanly as a JSON object (see
|
|
119
|
+
* readTopLevelLifecycleRecord). That is what lets this be a counter rather
|
|
120
|
+
* than a validator: separator/grammar correctness was already decided by
|
|
121
|
+
* JSON.parse. Returns false on ANY repeated top-level key, or when the walk
|
|
122
|
+
* cannot complete (which cannot happen for a well-formed document — treated
|
|
123
|
+
* as unreadable rather than assumed unique).
|
|
124
|
+
*/
|
|
125
|
+
function hasUniqueTopLevelKeys(raw) {
|
|
126
|
+
// A Set (not an object) so a document key named `constructor`/`__proto__`
|
|
127
|
+
// is counted as data, never as an inherited property.
|
|
128
|
+
const seen = new Set();
|
|
103
129
|
const len = raw.length;
|
|
104
130
|
let i = 0;
|
|
105
131
|
while (i < len && isWs(raw[i])) i++;
|
|
106
|
-
if (raw[i] !== '{') return
|
|
132
|
+
if (raw[i] !== '{') return false;
|
|
107
133
|
i++;
|
|
108
|
-
const result = {};
|
|
109
|
-
let found = 0;
|
|
110
134
|
while (i < len) {
|
|
111
135
|
while (i < len && isWs(raw[i])) i++;
|
|
112
|
-
if (i >= len) return
|
|
113
|
-
if (raw[i] === '}')
|
|
114
|
-
i++;
|
|
115
|
-
return isTrailingWhitespaceOnly(raw, i) ? result : null;
|
|
116
|
-
}
|
|
136
|
+
if (i >= len) return false;
|
|
137
|
+
if (raw[i] === '}') return true;
|
|
117
138
|
if (raw[i] === ',') { i++; continue; }
|
|
118
|
-
if (raw[i] !== '"') return
|
|
139
|
+
if (raw[i] !== '"') return false;
|
|
119
140
|
const keyStart = i;
|
|
120
141
|
i = skipString(raw, i);
|
|
121
142
|
let key;
|
|
122
|
-
try { key = JSON.parse(raw.slice(keyStart, i)); } catch { return
|
|
143
|
+
try { key = JSON.parse(raw.slice(keyStart, i)); } catch { return false; }
|
|
123
144
|
while (i < len && isWs(raw[i])) i++;
|
|
124
|
-
if (raw[i] !== ':') return
|
|
145
|
+
if (raw[i] !== ':') return false;
|
|
125
146
|
i++;
|
|
126
147
|
while (i < len && isWs(raw[i])) i++;
|
|
127
|
-
if (i >= len) return
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (stack.length === 0) {
|
|
162
|
-
// Root object closed with '}' — only trust it if
|
|
163
|
-
// nothing but whitespace follows to EOF.
|
|
164
|
-
return isTrailingWhitespaceOnly(raw, i) ? result : null;
|
|
165
|
-
}
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
i++;
|
|
169
|
-
}
|
|
170
|
-
return null; // ran off the end (or hit EOF) before the root object closed: truncated
|
|
171
|
-
}
|
|
172
|
-
} else {
|
|
173
|
-
i = skipValue(raw, i);
|
|
174
|
-
}
|
|
148
|
+
if (i >= len) return false;
|
|
149
|
+
// Recorded BEFORE the value is looked at, so a duplicate is detected
|
|
150
|
+
// regardless of the value types or which copy comes first.
|
|
151
|
+
if (seen.has(key)) return false;
|
|
152
|
+
seen.add(key);
|
|
153
|
+
i = skipValue(raw, i);
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Read `raw` as a session record.
|
|
160
|
+
* Returns LIFECYCLE_SCAN_CONFLICT (unreadable) or
|
|
161
|
+
* `{ doc, id, closed, generation }` where each lifecycle field is `undefined`
|
|
162
|
+
* when the document simply does not carry it (legacy records predate them).
|
|
163
|
+
*/
|
|
164
|
+
export function readTopLevelLifecycleRecord(raw) {
|
|
165
|
+
if (typeof raw !== 'string') return LIFECYCLE_SCAN_CONFLICT;
|
|
166
|
+
let doc;
|
|
167
|
+
// Strict and complete: trailing bytes, truncation, missing/trailing
|
|
168
|
+
// commas and every other separator fault are rejected here.
|
|
169
|
+
try { doc = JSON.parse(raw); } catch { return LIFECYCLE_SCAN_CONFLICT; }
|
|
170
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return LIFECYCLE_SCAN_CONFLICT;
|
|
171
|
+
// JSON.parse is last-wins for duplicate keys; this is where that is caught
|
|
172
|
+
// — for EVERY top-level key, since consumers reserialize and act on the
|
|
173
|
+
// whole record, not only on the lifecycle three.
|
|
174
|
+
if (!hasUniqueTopLevelKeys(raw)) return LIFECYCLE_SCAN_CONFLICT;
|
|
175
|
+
const has = (key) => Object.prototype.hasOwnProperty.call(doc, key);
|
|
176
|
+
const record = { doc, id: undefined, closed: undefined, generation: undefined };
|
|
177
|
+
if (has('id')) {
|
|
178
|
+
// Identity we cannot positively confirm is worse than no identity: a
|
|
179
|
+
// consumer must not read an empty/non-string id as "probably ours".
|
|
180
|
+
if (typeof doc.id !== 'string' || !doc.id) return LIFECYCLE_SCAN_CONFLICT;
|
|
181
|
+
record.id = doc.id;
|
|
175
182
|
}
|
|
176
|
-
|
|
183
|
+
if (has('closed')) {
|
|
184
|
+
if (typeof doc.closed !== 'boolean') return LIFECYCLE_SCAN_CONFLICT;
|
|
185
|
+
record.closed = doc.closed;
|
|
186
|
+
}
|
|
187
|
+
if (has('generation')) {
|
|
188
|
+
if (typeof doc.generation !== 'number' || !Number.isFinite(doc.generation)) return LIFECYCLE_SCAN_CONFLICT;
|
|
189
|
+
record.generation = doc.generation;
|
|
190
|
+
}
|
|
191
|
+
return record;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Lifecycle fields only (no parsed document) for callers that consult nothing
|
|
196
|
+
* else. Same two outcomes: the fields present at top level, or
|
|
197
|
+
* LIFECYCLE_SCAN_CONFLICT.
|
|
198
|
+
*/
|
|
199
|
+
export function scanTopLevelLifecycle(raw) {
|
|
200
|
+
const record = readTopLevelLifecycleRecord(raw);
|
|
201
|
+
if (isLifecycleUnreadable(record)) return LIFECYCLE_SCAN_CONFLICT;
|
|
202
|
+
const out = {};
|
|
203
|
+
if (record.id !== undefined) out.id = record.id;
|
|
204
|
+
if (record.closed !== undefined) out.closed = record.closed;
|
|
205
|
+
if (record.generation !== undefined) out.generation = record.generation;
|
|
206
|
+
return out;
|
|
177
207
|
}
|