newmark-agent 0.6.4 → 0.6.8
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/config.example.json +13 -3
- package/dist/cli-commands.d.ts +1 -1
- package/dist/cli-commands.js +54 -3
- package/dist/cli-discovery.js +3 -0
- package/dist/conversation-utility-host.bundle.cjs +4472 -1767
- package/dist/conversation-utility-host.js +94 -7
- package/dist/core/agent.d.ts +126 -12
- package/dist/core/agent.js +785 -172
- package/dist/core/agentKernelRunner.d.ts +7 -1
- package/dist/core/agentKernelRunner.js +160 -20
- package/dist/core/autoRouter.d.ts +49 -44
- package/dist/core/autoRouter.js +117 -302
- package/dist/core/computerUseSession.js +1 -1
- package/dist/core/config.js +27 -11
- package/dist/core/continuation/contracts.d.ts +1 -1
- package/dist/core/continuation/store.d.ts +91 -1
- package/dist/core/continuation/store.js +291 -61
- package/dist/core/conversationKernel.d.ts +49 -7
- package/dist/core/conversationKernel.js +303 -51
- package/dist/core/conversationStateDocument.d.ts +18 -0
- package/dist/core/conversationStateDocument.js +88 -0
- package/dist/core/conversationVisualBudget.d.ts +74 -0
- package/dist/core/conversationVisualBudget.js +153 -0
- package/dist/core/electronUtilityAgentClient.d.ts +3 -2
- package/dist/core/electronUtilityAgentClient.js +32 -8
- package/dist/core/electronUtilityRuntimePool.d.ts +28 -3
- package/dist/core/electronUtilityRuntimePool.js +93 -24
- package/dist/core/fileWriteObservation.d.ts +41 -0
- package/dist/core/fileWriteObservation.js +454 -0
- package/dist/core/hostRuntimeHooks.d.ts +23 -0
- package/dist/core/hostRuntimeHooks.js +17 -0
- package/dist/core/jevDecision.d.ts +144 -0
- package/dist/core/jevDecision.js +226 -0
- package/dist/core/memoryProbe.d.ts +24 -0
- package/dist/core/memoryProbe.js +104 -0
- package/dist/core/mobilePairing.d.ts +7 -1
- package/dist/core/mobilePairing.js +9 -1
- package/dist/core/performanceDiagnostics.d.ts +1 -1
- package/dist/core/routeDecisionValidator.d.ts +86 -0
- package/dist/core/routeDecisionValidator.js +249 -0
- package/dist/core/routeEligibility.d.ts +52 -0
- package/dist/core/routeEligibility.js +108 -0
- package/dist/core/runtimeMemoryBudget.d.ts +6 -0
- package/dist/core/runtimeMemoryBudget.js +12 -0
- package/dist/core/toolPolicy.d.ts +1 -1
- package/dist/core/toolPolicy.js +1 -1
- package/dist/core/utilityAgentProtocol.d.ts +8 -10
- package/dist/core/utilityHostToolRouter.js +2 -0
- package/dist/core/visualDownscale.d.ts +6 -0
- package/dist/core/visualDownscale.js +137 -0
- package/dist/core/wslAgentClient.d.ts +1 -2
- package/dist/core/wslAgentClient.js +0 -8
- package/dist/core/wslAgentProtocol.d.ts +1 -10
- package/dist/core/wslAgentRuntimePool.d.ts +1 -3
- package/dist/core/wslAgentRuntimePool.js +17 -22
- package/dist/llm/provider.d.ts +3 -1
- package/dist/llm/provider.js +53 -9
- package/dist/main.js +107 -30
- package/dist/preload.js +3 -1
- package/dist/server.js +30 -18
- package/dist/tools/computerUse.d.ts +6 -0
- package/dist/tools/computerUse.js +406 -21
- package/dist/tools/computerUsePowerShellHost.d.ts +6 -1
- package/dist/tools/computerUsePowerShellHost.js +105 -31
- package/dist/tools/index.d.ts +3 -4
- package/dist/tools/index.js +146 -66
- package/dist/tui/src/adapters/core-runtime-adapter.js +13 -1
- package/dist/tui/src/i18n.js +12 -1
- package/dist/tui/src/render.js +9 -2
- package/dist/tui/src/settings-schema.js +19 -0
- package/dist/tui/src/state.js +69 -1
- package/dist/ui/index.html +651 -233
- package/dist/ui/lucide-sprite.svg +0 -8
- package/dist/wsl-agent-host.bundle.cjs +4302 -1767
- package/dist/wsl-agent-host.js +0 -3
- package/package.json +39 -10
package/dist/core/agent.js
CHANGED
|
@@ -68,6 +68,8 @@ const pngjs_1 = require("pngjs");
|
|
|
68
68
|
const modelValidation_1 = require("./modelValidation");
|
|
69
69
|
const modelValidationStore_1 = require("./modelValidationStore");
|
|
70
70
|
const autoRouter_1 = require("./autoRouter");
|
|
71
|
+
const jevDecision_1 = require("./jevDecision");
|
|
72
|
+
const routeDecisionValidator_1 = require("./routeDecisionValidator");
|
|
71
73
|
const agent_context_manager_1 = require("../context/services/agent-context-manager");
|
|
72
74
|
const toolchain_1 = require("../toolchain");
|
|
73
75
|
const agent_runtime_1 = require("../agent-runtime");
|
|
@@ -178,7 +180,7 @@ let CORE_SYSTEM_PROMPT = `You are Newmark Agent, a powerful AI coding assistant
|
|
|
178
180
|
- Be thorough and precise. Verify your work.
|
|
179
181
|
- Use tools appropriately - don't just describe, do it.
|
|
180
182
|
- Deletion safety (intrinsic, non-overridable): file deletion is allowed only ONE file at a time under Agent supervision. Use the delete_file tool with an absolute path for every single-file delete. Never use bash or the terminal to delete files in bulk: recursive deletes (rm -r/-rf, Remove-Item -Recurse, rmdir /s, del /s), wildcard deletes (rm *.log, del *), loop deletes (for/foreach/while + rm), pipe-fed deletes (Get-ChildItem | Remove-Item), find/xargs deletes, and multi-target or multi-statement deletes are hard-blocked by the runtime and will be rejected. To remove a directory, delete its files one by one first, then remove the now-empty directory without a recursive flag.
|
|
181
|
-
- For desktop Computer Use requests, follow observe -> decide -> act -> observe. Start visible takeover with computer_use takeover_start before multi-step desktop control and stop it when finished. Prefer app-scoped actions through app_list/app_observe/app_* when controlling one taskbar application, because this preserves human collaboration around other windows. Prefer target_id from the latest high-priority semantic UI objects, otherwise precise coordinates from the latest observation. Use vision plus UI controls together when the selected model has vision input. Avoid destructive UI actions unless the user asked for them, and do not claim YOLO/OCR perception unless an actual detector/OCR result is present.
|
|
183
|
+
- For desktop Computer Use requests, follow observe -> decide -> act -> observe. For passive waits, prefer one observe call with sparse_wait_ms (up to 5000) over repeated immediate full observations; it still returns a fresh full screenshot/UIA whether signaled or timed out, and no signal is not proof of no change. Start visible takeover with computer_use takeover_start before multi-step desktop control and stop it when finished. Prefer app-scoped actions through app_list/app_observe/app_* when controlling one taskbar application, because this preserves human collaboration around other windows. Prefer target_id from the latest high-priority semantic UI objects, otherwise precise coordinates from the latest observation. Use vision plus UI controls together when the selected model has vision input. Avoid destructive UI actions unless the user asked for them, and do not claim YOLO/OCR perception unless an actual detector/OCR result is present.
|
|
182
184
|
- For browser buttons and scanned document pages, the strict recognition sequence is text layer/DOM -> screenshot to a validated vision model -> local OCR. Do not skip directly to OCR. If OCR is used, treat it as approximate evidence and repair only what surrounding context supports.
|
|
183
185
|
- Multiple tool calls emitted in one provider turn run concurrently. Treat their returned records as one barrier: continue reasoning only after every call in that batch has returned either a successful receipt or a failure receipt.
|
|
184
186
|
- terminal_takeover start creates or reuses a persistent session. Its successful start receipt completes that Build step immediately; continue with write/read or other work without waiting for detach, stop, shell exit, or terminal closure.
|
|
@@ -275,6 +277,8 @@ class Agent {
|
|
|
275
277
|
// Bounded caller-owned peer slots retain protocol compatibility learning
|
|
276
278
|
// between mailbox jobs. They never retain the child Agent or go to disk.
|
|
277
279
|
peerProviderCaches = new Map();
|
|
280
|
+
/** Reuses the provider connection for current-model JEV calls without sharing conversation build state. */
|
|
281
|
+
jevProviderCache = {};
|
|
278
282
|
awaitingAgentKernelRuntime = false;
|
|
279
283
|
pendingAgentKernelQueue = [];
|
|
280
284
|
linkedPlanAccess;
|
|
@@ -294,7 +298,7 @@ class Agent {
|
|
|
294
298
|
finalizingWorkRunId = '';
|
|
295
299
|
agentRunService = null;
|
|
296
300
|
agentRunByWorkRunId = new Map();
|
|
297
|
-
|
|
301
|
+
routeController;
|
|
298
302
|
resolvedDeployment = null;
|
|
299
303
|
fixedDeployment = null;
|
|
300
304
|
preferredConversationModelSelection = null;
|
|
@@ -306,11 +310,21 @@ class Agent {
|
|
|
306
310
|
lastRouteRetryDelayMs = 0;
|
|
307
311
|
routeAttemptStartedAt = 0;
|
|
308
312
|
providerBalanceBlockedUntilByDeployment = new Map();
|
|
309
|
-
explicitlyRatedRoutes = new Map();
|
|
310
313
|
conversationStateCache = new Map();
|
|
311
314
|
conversationStateCacheFingerprint = new Map();
|
|
315
|
+
conversationStateWrittenFingerprint = new Map();
|
|
316
|
+
conversationShardFingerprint = new Map();
|
|
317
|
+
hydratedShardEntries = new WeakSet();
|
|
318
|
+
/**
|
|
319
|
+
* Revision of the durable queue snapshot this runtime is working from. The
|
|
320
|
+
* merge only accepts a writer's queue when that writer has seen the persisted
|
|
321
|
+
* revision, so an outdated in-memory snapshot can neither drop queued work
|
|
322
|
+
* nor resurrect an entry that was already consumed or cleared.
|
|
323
|
+
*/
|
|
324
|
+
continuationRevision = 0;
|
|
312
325
|
conversationStateFlushTimers = new Map();
|
|
313
326
|
conversationStateDirty = new Map();
|
|
327
|
+
conversationStateLastWriteAt = new Map();
|
|
314
328
|
systemPromptCache = null;
|
|
315
329
|
toolDefinitionCache = new Map();
|
|
316
330
|
modelValidationPromise = null;
|
|
@@ -381,8 +395,7 @@ class Agent {
|
|
|
381
395
|
/* crash recovery is best-effort at startup */
|
|
382
396
|
}
|
|
383
397
|
}
|
|
384
|
-
this.
|
|
385
|
-
this.loadLearnedRouteFeedback();
|
|
398
|
+
this.routeController = new autoRouter_1.RouteExecutionController({ policyVersion: autoRouter_1.ROUTE_POLICY_VERSION });
|
|
386
399
|
if (this.isSubagentRuntime || this.agentOnly) {
|
|
387
400
|
this.config.set('workspace', 'auto_create_timestamp_workspace', false);
|
|
388
401
|
}
|
|
@@ -857,7 +870,6 @@ class Agent {
|
|
|
857
870
|
}
|
|
858
871
|
return;
|
|
859
872
|
}
|
|
860
|
-
const previousAuto = this.model === 'auto' ? this.resolvedDeployment : null;
|
|
861
873
|
const qualified = parseDeploymentSelectionValue(requested);
|
|
862
874
|
const legacyQualified = requested.includes('/')
|
|
863
875
|
? this.config.allModels().filter(model => `${model.provider_id}/${model.name}` === requested || `${model.provider}/${model.name}` === requested)
|
|
@@ -871,11 +883,8 @@ class Agent {
|
|
|
871
883
|
this.pendingAutoAttempts = [];
|
|
872
884
|
if (current?.provider_id)
|
|
873
885
|
this.config.set('models', 'auto_switch_anchor_provider', current.provider_id);
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
this.recordRouteFeedbackFor(previousAuto, taskClass, -1, 'manual_switch');
|
|
877
|
-
this.recordRouteFeedbackFor(this.deploymentRef(current), taskClass, 1, 'manual_switch');
|
|
878
|
-
}
|
|
886
|
+
// A manual model switch is explicit user control. Auto Router v2 records
|
|
887
|
+
// no feedback for it: switching models must never train future Auto routes.
|
|
879
888
|
this.lastRouteDecision = null;
|
|
880
889
|
this.routeAttemptStartedAt = 0;
|
|
881
890
|
this.routeStreamCommitted = false;
|
|
@@ -1052,7 +1061,7 @@ class Agent {
|
|
|
1052
1061
|
}
|
|
1053
1062
|
resetAutoRoute() {
|
|
1054
1063
|
if (this.routeTransactionId)
|
|
1055
|
-
this.
|
|
1064
|
+
this.routeController.endTransaction(this.routeTransactionId);
|
|
1056
1065
|
this.resolvedDeployment = null;
|
|
1057
1066
|
this.lastRouteDecision = null;
|
|
1058
1067
|
this.pendingAutoAttempts = [];
|
|
@@ -1095,7 +1104,7 @@ class Agent {
|
|
|
1095
1104
|
const deployment = this.activeDeployment();
|
|
1096
1105
|
if (!deployment)
|
|
1097
1106
|
return;
|
|
1098
|
-
this.
|
|
1107
|
+
this.routeController.recordEndpointSuccess(deployment, latencyMs, throughput);
|
|
1099
1108
|
if (this.lastRouteDecision) {
|
|
1100
1109
|
const attempt = [...this.lastRouteDecision.attempts].reverse().find(item => deploymentIdentity(item.deployment) === deploymentIdentity(deployment));
|
|
1101
1110
|
if (attempt) {
|
|
@@ -1114,7 +1123,7 @@ class Agent {
|
|
|
1114
1123
|
const deployment = this.activeDeployment();
|
|
1115
1124
|
if (!deployment)
|
|
1116
1125
|
return;
|
|
1117
|
-
this.
|
|
1126
|
+
this.routeController.recordToolOutcome(deployment, valid);
|
|
1118
1127
|
}
|
|
1119
1128
|
updateProviders(value) {
|
|
1120
1129
|
const before = this.config.providers();
|
|
@@ -1135,7 +1144,7 @@ class Agent {
|
|
|
1135
1144
|
if (!provider)
|
|
1136
1145
|
continue;
|
|
1137
1146
|
for (const model of provider.models || []) {
|
|
1138
|
-
this.
|
|
1147
|
+
this.routeController.resetEndpointAfterConfigChange({
|
|
1139
1148
|
providerId: provider.id,
|
|
1140
1149
|
modelId: model.name,
|
|
1141
1150
|
logicalModelGroupId: model.logical_model_group_id || undefined,
|
|
@@ -1146,76 +1155,59 @@ class Agent {
|
|
|
1146
1155
|
if (changed)
|
|
1147
1156
|
this.resetAutoRoute();
|
|
1148
1157
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
}
|
|
1157
|
-
rateActiveAutoRoute(score, expectedRouteId = '') {
|
|
1158
|
-
const normalizedScore = Number(score);
|
|
1159
|
-
if (normalizedScore !== -1 && normalizedScore !== 1) {
|
|
1160
|
-
return { ok: false, reason: 'invalid_score' };
|
|
1161
|
-
}
|
|
1162
|
-
const decision = this.lastRouteDecision;
|
|
1163
|
-
const deployment = this.activeDeployment();
|
|
1164
|
-
if (this.model !== 'auto'
|
|
1165
|
-
|| decision?.requestedSelection.kind !== 'auto'
|
|
1166
|
-
|| !decision.routeId
|
|
1167
|
-
|| !decision.resolvedDeployment
|
|
1168
|
-
|| !deployment) {
|
|
1169
|
-
return { ok: false, reason: 'no_active_auto_route' };
|
|
1170
|
-
}
|
|
1171
|
-
const requestedRouteId = String(expectedRouteId || '').trim();
|
|
1172
|
-
if (requestedRouteId && requestedRouteId !== decision.routeId) {
|
|
1173
|
-
return { ok: false, routeId: decision.routeId, reason: 'stale_route' };
|
|
1174
|
-
}
|
|
1175
|
-
const previousScore = this.explicitlyRatedRoutes.get(decision.routeId);
|
|
1176
|
-
if (previousScore !== undefined) {
|
|
1177
|
-
return { ok: false, score: previousScore, routeId: decision.routeId, reason: 'already_rated' };
|
|
1178
|
-
}
|
|
1179
|
-
const taskClasses = [...new Set(decision.taskClasses)];
|
|
1180
|
-
for (const taskClass of taskClasses) {
|
|
1181
|
-
this.recordRouteFeedbackFor(deployment, taskClass, normalizedScore, 'explicit_rating');
|
|
1182
|
-
}
|
|
1183
|
-
this.recordRouteQualityObservation(deployment, taskClasses, normalizedScore > 0);
|
|
1184
|
-
this.explicitlyRatedRoutes.set(decision.routeId, normalizedScore);
|
|
1185
|
-
while (this.explicitlyRatedRoutes.size > 1_000) {
|
|
1186
|
-
const oldest = this.explicitlyRatedRoutes.keys().next().value;
|
|
1187
|
-
if (!oldest)
|
|
1188
|
-
break;
|
|
1189
|
-
this.explicitlyRatedRoutes.delete(oldest);
|
|
1190
|
-
}
|
|
1191
|
-
return { ok: true, score: normalizedScore, routeId: decision.routeId };
|
|
1158
|
+
/**
|
|
1159
|
+
* Auto master switch. Every automatic model decision in this runtime is
|
|
1160
|
+
* gated on it: when it is off the Agent neither routes nor edits the current
|
|
1161
|
+
* model decision settings, and no code path may move the active model on its own.
|
|
1162
|
+
*/
|
|
1163
|
+
autoDecisionEnabled() {
|
|
1164
|
+
return this.config.autoSwitchEnabled();
|
|
1192
1165
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1166
|
+
/** Current-model JEV settings exposed to GUI / TUI / CLI editors. */
|
|
1167
|
+
jevDecisionModelState() {
|
|
1168
|
+
const autoEnabled = this.autoDecisionEnabled();
|
|
1169
|
+
const timeout = Number(this.config.get('models', 'jev_timeout_ms'));
|
|
1170
|
+
return {
|
|
1171
|
+
autoEnabled,
|
|
1172
|
+
locked: !autoEnabled,
|
|
1173
|
+
...(autoEnabled ? {} : { lockReason: 'auto_disabled' }),
|
|
1174
|
+
enabled: this.config.get('models', 'jev_enabled') !== false,
|
|
1175
|
+
decisionModel: 'current_eligible_model',
|
|
1176
|
+
thinkingMode: 'off',
|
|
1177
|
+
outputFormat: 'strict_json_schema',
|
|
1178
|
+
timeoutMs: Number.isFinite(timeout) && timeout > 0 ? Math.round(timeout) : jevDecision_1.DEFAULT_JEV_TIMEOUT_MS,
|
|
1179
|
+
};
|
|
1203
1180
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
};
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1181
|
+
/**
|
|
1182
|
+
* Edit current-model Auto decision settings.
|
|
1183
|
+
*
|
|
1184
|
+
* Refuses every write while Auto is off (`auto_disabled`): with automatic
|
|
1185
|
+
* decision-making disabled, not even the decision-model configuration may
|
|
1186
|
+
* change — that is what "Auto off never moves a model" means at the editor
|
|
1187
|
+
* boundary. Invalid values are rejected without touching the stored config.
|
|
1188
|
+
*/
|
|
1189
|
+
updateJevDecisionModel(patch = {}) {
|
|
1190
|
+
if (!this.autoDecisionEnabled()) {
|
|
1191
|
+
return { ok: false, reason: 'auto_disabled', state: this.jevDecisionModelState() };
|
|
1192
|
+
}
|
|
1193
|
+
const current = this.jevDecisionModelState();
|
|
1194
|
+
const next = { ...current };
|
|
1195
|
+
const reject = () => ({ ok: false, reason: 'invalid_value', state: this.jevDecisionModelState() });
|
|
1196
|
+
const editableKeys = new Set(['enabled', 'timeoutMs']);
|
|
1197
|
+
if (Object.keys(patch).some(key => !editableKeys.has(key)))
|
|
1198
|
+
return reject();
|
|
1199
|
+
if (patch.enabled !== undefined)
|
|
1200
|
+
next.enabled = patch.enabled === true;
|
|
1201
|
+
if (patch.timeoutMs !== undefined) {
|
|
1202
|
+
const timeout = Number(patch.timeoutMs);
|
|
1203
|
+
if (!Number.isFinite(timeout) || timeout < 100 || timeout > 30_000)
|
|
1204
|
+
return reject();
|
|
1205
|
+
next.timeoutMs = Math.round(timeout);
|
|
1206
|
+
}
|
|
1207
|
+
this.config.set('models', 'jev_enabled', next.enabled);
|
|
1208
|
+
this.config.set('models', 'jev_timeout_ms', next.timeoutMs);
|
|
1218
1209
|
this.config.save();
|
|
1210
|
+
return { ok: true, state: this.jevDecisionModelState() };
|
|
1219
1211
|
}
|
|
1220
1212
|
workspaceConversationKey() {
|
|
1221
1213
|
const ws = this.workspace.current;
|
|
@@ -1228,6 +1220,158 @@ class Agent {
|
|
|
1228
1220
|
return null;
|
|
1229
1221
|
return path.join(ws.path, 'conversations', 'state.json');
|
|
1230
1222
|
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Heavy conversation payload that lives in a per-conversation shard file
|
|
1225
|
+
* instead of the workspace index document. Everything else (title, pinning,
|
|
1226
|
+
* order, draft, mode, goal, runtime owner, counters) stays in the index so
|
|
1227
|
+
* listing, sorting and cross-process merge never need to parse a transcript.
|
|
1228
|
+
*/
|
|
1229
|
+
static CONVERSATION_SHARD_FIELDS = [
|
|
1230
|
+
'chatMessages', 'history', 'workRuns', 'continuations',
|
|
1231
|
+
'compressionCache', 'branches', 'tree', 'branchMailbox',
|
|
1232
|
+
];
|
|
1233
|
+
workspaceConversationShardDir(ws = this.workspace.current) {
|
|
1234
|
+
const store = this.workspaceConversationStorePath(ws);
|
|
1235
|
+
if (!store)
|
|
1236
|
+
return null;
|
|
1237
|
+
return path.join(path.dirname(store), 'shards');
|
|
1238
|
+
}
|
|
1239
|
+
conversationShardFileName(key) {
|
|
1240
|
+
const safe = String(key).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 180);
|
|
1241
|
+
const digest = crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 12);
|
|
1242
|
+
return `${safe}.${digest}.json`;
|
|
1243
|
+
}
|
|
1244
|
+
conversationShardPath(key, ws = this.workspace.current) {
|
|
1245
|
+
const dir = this.workspaceConversationShardDir(ws);
|
|
1246
|
+
if (!dir)
|
|
1247
|
+
return null;
|
|
1248
|
+
return path.join(dir, this.conversationShardFileName(key));
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Hydrate one conversation entry from its shard on first access. The heavy
|
|
1252
|
+
* fields are non-enumerable so `{ ...entry }` (and therefore the index
|
|
1253
|
+
* document written back to disk) never pulls a transcript into memory, while
|
|
1254
|
+
* every existing reader that asks for `entry.chatMessages` / `entry.history`
|
|
1255
|
+
* keeps working unchanged.
|
|
1256
|
+
*/
|
|
1257
|
+
attachConversationShard(entry, shardFile, key) {
|
|
1258
|
+
const cache = { loaded: false, value: null };
|
|
1259
|
+
const load = () => {
|
|
1260
|
+
if (cache.loaded)
|
|
1261
|
+
return cache.value;
|
|
1262
|
+
cache.loaded = true;
|
|
1263
|
+
this.hydratedShardEntries.add(entry);
|
|
1264
|
+
try {
|
|
1265
|
+
const raw = fs.readFileSync(shardFile, 'utf-8').replace(/^\uFEFF/, '');
|
|
1266
|
+
const parsed = JSON.parse(raw);
|
|
1267
|
+
cache.value = parsed && typeof parsed === 'object' ? parsed : null;
|
|
1268
|
+
}
|
|
1269
|
+
catch {
|
|
1270
|
+
cache.value = null;
|
|
1271
|
+
}
|
|
1272
|
+
return cache.value;
|
|
1273
|
+
};
|
|
1274
|
+
for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
|
|
1275
|
+
Object.defineProperty(entry, field, {
|
|
1276
|
+
enumerable: false,
|
|
1277
|
+
configurable: true,
|
|
1278
|
+
get: () => {
|
|
1279
|
+
const shard = load();
|
|
1280
|
+
return shard ? shard[field] : undefined;
|
|
1281
|
+
},
|
|
1282
|
+
set: (value) => {
|
|
1283
|
+
this.hydratedShardEntries.add(entry);
|
|
1284
|
+
const shard = { ...(cache.value || {}) };
|
|
1285
|
+
shard[field] = value;
|
|
1286
|
+
cache.value = shard;
|
|
1287
|
+
cache.loaded = true;
|
|
1288
|
+
},
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
void key;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Heavy payload carried in memory by this entry, or `null` when the entry is
|
|
1295
|
+
* a shard-backed index record whose heavy fields were never touched. That
|
|
1296
|
+
* distinction is what keeps unrelated conversations off the write path.
|
|
1297
|
+
*/
|
|
1298
|
+
conversationShardSnapshot(entry) {
|
|
1299
|
+
// Ownership rule: a *live* entry carries the heavy fields as own enumerable
|
|
1300
|
+
// properties; an index record read back from disk exposes them as
|
|
1301
|
+
// non-enumerable accessors that hydrate from the shard on demand. Only a
|
|
1302
|
+
// record that was actually hydrated (its accessors fired) counts as owning
|
|
1303
|
+
// its payload — keying on a `shard` marker instead would misclassify the
|
|
1304
|
+
// live entry, because it is built by spreading the previous index entry.
|
|
1305
|
+
const hydrated = this.hydratedShardEntries.has(entry);
|
|
1306
|
+
const snapshot = {};
|
|
1307
|
+
let any = false;
|
|
1308
|
+
for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
|
|
1309
|
+
const descriptor = Object.getOwnPropertyDescriptor(entry, field);
|
|
1310
|
+
if (!descriptor)
|
|
1311
|
+
continue;
|
|
1312
|
+
if (!descriptor.enumerable && !hydrated)
|
|
1313
|
+
continue;
|
|
1314
|
+
const value = descriptor.get ? descriptor.get.call(entry) : descriptor.value;
|
|
1315
|
+
if (value === undefined)
|
|
1316
|
+
continue;
|
|
1317
|
+
snapshot[field] = value;
|
|
1318
|
+
any = true;
|
|
1319
|
+
}
|
|
1320
|
+
return any ? snapshot : null;
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Force one shard-backed entry into memory. Used only on the rare recovery
|
|
1324
|
+
* paths that must compare against a payload written by another runtime.
|
|
1325
|
+
*/
|
|
1326
|
+
hydrateConversationEntry(entry) {
|
|
1327
|
+
if (typeof entry.shard !== 'string' || !entry.shard)
|
|
1328
|
+
return;
|
|
1329
|
+
this.hydratedShardEntries.add(entry);
|
|
1330
|
+
for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
|
|
1331
|
+
const value = entry[field];
|
|
1332
|
+
if (value !== undefined)
|
|
1333
|
+
entry[field] = value;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Durable queue merge.
|
|
1338
|
+
*
|
|
1339
|
+
* Queue entries are the user's pending work, so a runtime that never saw an
|
|
1340
|
+
* entry must not delete it, while an entry this runtime actually consumed
|
|
1341
|
+
* must not be resurrected. Identity-based union minus exactly the consumed
|
|
1342
|
+
* ids gives both guarantees without needing a global revision clock.
|
|
1343
|
+
*/
|
|
1344
|
+
mergeConversationContinuations(incoming, existingCount, existingShardBacked, existingEntry, incomingRevision, existingRevision) {
|
|
1345
|
+
// A writer that has seen the persisted revision owns the queue (including
|
|
1346
|
+
// deliberate clearing/consumption). Anything older keeps what is stored.
|
|
1347
|
+
if (incomingRevision >= existingRevision)
|
|
1348
|
+
return this.normalizeContinuations(incoming);
|
|
1349
|
+
if (existingShardBacked)
|
|
1350
|
+
this.hydrateConversationEntry(existingEntry);
|
|
1351
|
+
const existing = existingEntry.continuations || [];
|
|
1352
|
+
void existingCount;
|
|
1353
|
+
return this.normalizeContinuations(existing.length ? existing : incoming);
|
|
1354
|
+
}
|
|
1355
|
+
/** Index-document form of a conversation entry: metadata plus a shard pointer. */
|
|
1356
|
+
conversationIndexEntryForDisk(entry, shardFileName, shardBytes, counts) {
|
|
1357
|
+
const index = {};
|
|
1358
|
+
for (const [field, value] of Object.entries(entry))
|
|
1359
|
+
index[field] = value;
|
|
1360
|
+
for (const field of Agent.CONVERSATION_SHARD_FIELDS)
|
|
1361
|
+
delete index[field];
|
|
1362
|
+
index.messageCount = counts.messageCount;
|
|
1363
|
+
index.historyCount = counts.historyCount;
|
|
1364
|
+
index.workRunCount = counts.workRunCount;
|
|
1365
|
+
index.hasTree = counts.hasTree;
|
|
1366
|
+
index.hasBranches = counts.hasBranches;
|
|
1367
|
+
index.continuationCount = counts.continuationCount;
|
|
1368
|
+
index.continuationRevision = counts.continuationRevision;
|
|
1369
|
+
if (shardFileName) {
|
|
1370
|
+
index.shard = shardFileName;
|
|
1371
|
+
index.shardBytes = shardBytes;
|
|
1372
|
+
}
|
|
1373
|
+
return index;
|
|
1374
|
+
}
|
|
1231
1375
|
workspaceConversationStateKey(conversationId = this.activeConversationId) {
|
|
1232
1376
|
return this.workspaceConversationStateKeyFor(conversationId, this.workspace.current);
|
|
1233
1377
|
}
|
|
@@ -1537,6 +1681,26 @@ class Agent {
|
|
|
1537
1681
|
tree,
|
|
1538
1682
|
};
|
|
1539
1683
|
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Bound the live Build ledger.
|
|
1686
|
+
*
|
|
1687
|
+
* `normalizeWorkRuns()` already windows the persisted/served ledger to 120
|
|
1688
|
+
* runs, but the live `this.workRuns` array kept every Build block of the
|
|
1689
|
+
* conversation (measured: 307 runs / 4.6 MiB after 300 turns). The renderer
|
|
1690
|
+
* only ever shows the newest window, so older terminal runs can be dropped
|
|
1691
|
+
* from memory while the conversation continues.
|
|
1692
|
+
*/
|
|
1693
|
+
pruneLiveWorkRuns() {
|
|
1694
|
+
const window = 120;
|
|
1695
|
+
if (this.workRuns.length <= window)
|
|
1696
|
+
return;
|
|
1697
|
+
const running = this.workRuns.filter(run => run.status === 'running');
|
|
1698
|
+
const keep = new Set(running.map(run => run.runId));
|
|
1699
|
+
const byRecency = [...this.workRuns].sort((a, b) => String(a.startedAt || '').localeCompare(String(b.startedAt || '')));
|
|
1700
|
+
for (const run of byRecency.slice(-window))
|
|
1701
|
+
keep.add(run.runId);
|
|
1702
|
+
this.workRuns = this.workRuns.filter(run => keep.has(run.runId));
|
|
1703
|
+
}
|
|
1540
1704
|
normalizeWorkRuns(runs) {
|
|
1541
1705
|
if (!Array.isArray(runs))
|
|
1542
1706
|
return [];
|
|
@@ -1815,6 +1979,7 @@ class Agent {
|
|
|
1815
1979
|
return true;
|
|
1816
1980
|
}
|
|
1817
1981
|
beginConversationWorkRun(runId, target = this.currentConversationTarget(), startedAt = this.nowIso(), managed = false, runtimeKey) {
|
|
1982
|
+
this.pruneLiveWorkRuns();
|
|
1818
1983
|
const cleanRunId = String(runId || crypto.randomUUID()).trim().slice(0, 200);
|
|
1819
1984
|
const existing = this.workRuns.find(run => run.runId === cleanRunId);
|
|
1820
1985
|
if (existing) {
|
|
@@ -2499,8 +2664,11 @@ class Agent {
|
|
|
2499
2664
|
}
|
|
2500
2665
|
if (event.type === 'start')
|
|
2501
2666
|
this.saveWorkspaceConversationState(false);
|
|
2667
|
+
// Terminal events are coalesced on purpose: `Agent.process()` performs the
|
|
2668
|
+
// synchronous save right after this sequence, so persisting here as well
|
|
2669
|
+
// only rewrote the whole conversation again for the same turn.
|
|
2502
2670
|
if (event.type === 'done' || event.type === 'error')
|
|
2503
|
-
this.saveWorkspaceConversationState(
|
|
2671
|
+
this.saveWorkspaceConversationState(false);
|
|
2504
2672
|
if (activeRun && this.finalizingWorkRunId !== activeRun.runId
|
|
2505
2673
|
&& (event.type === 'done' || event.type === 'error') && activeRun.status !== 'running') {
|
|
2506
2674
|
const terminalStatus = activeRun.status;
|
|
@@ -2617,8 +2785,10 @@ class Agent {
|
|
|
2617
2785
|
const before = this.continuations.length;
|
|
2618
2786
|
this.continuations = this.continuations.filter(item => item.queueMode !== 'followUp'
|
|
2619
2787
|
|| !matches(item.content, item.hiddenUserInput === true, item.clientMessageId));
|
|
2620
|
-
if (this.continuations.length !== before)
|
|
2788
|
+
if (this.continuations.length !== before) {
|
|
2789
|
+
this.continuationRevision += 1;
|
|
2621
2790
|
this.saveWorkspaceConversationState(true);
|
|
2791
|
+
}
|
|
2622
2792
|
for (const subscriber of this.rootInboxRetiredSubscribers)
|
|
2623
2793
|
subscriber(ids);
|
|
2624
2794
|
return ids;
|
|
@@ -2705,6 +2875,7 @@ class Agent {
|
|
|
2705
2875
|
createdAt: item.createdAt || new Date().toISOString(),
|
|
2706
2876
|
}))]);
|
|
2707
2877
|
this.continuations = combined;
|
|
2878
|
+
this.continuationRevision += 1;
|
|
2708
2879
|
this.saveWorkspaceConversationState(true);
|
|
2709
2880
|
return combined.map(item => ({
|
|
2710
2881
|
...item,
|
|
@@ -2719,6 +2890,7 @@ class Agent {
|
|
|
2719
2890
|
if (index < 0)
|
|
2720
2891
|
return false;
|
|
2721
2892
|
this.continuations.splice(index, 1);
|
|
2893
|
+
this.continuationRevision += 1;
|
|
2722
2894
|
this.saveWorkspaceConversationState(true);
|
|
2723
2895
|
return true;
|
|
2724
2896
|
}
|
|
@@ -2808,7 +2980,15 @@ class Agent {
|
|
|
2808
2980
|
state.version = Math.max(1, Number(state.version || 1));
|
|
2809
2981
|
state.conversations = state.conversations || {};
|
|
2810
2982
|
let migrated = false;
|
|
2811
|
-
|
|
2983
|
+
const shardDir = this.workspaceConversationShardDir(ws);
|
|
2984
|
+
for (const [key, entry] of Object.entries(state.conversations)) {
|
|
2985
|
+
if (entry.shard) {
|
|
2986
|
+
// Index record: attach lazy accessors instead of reading the shard.
|
|
2987
|
+
const shardFile = shardDir ? path.join(shardDir, String(entry.shard)) : '';
|
|
2988
|
+
if (shardFile)
|
|
2989
|
+
this.attachConversationShard(entry, shardFile, key);
|
|
2990
|
+
continue;
|
|
2991
|
+
}
|
|
2812
2992
|
if (!entry.tree && entry.branches?.length) {
|
|
2813
2993
|
this.normalizeConversationTree(entry);
|
|
2814
2994
|
entry.branches = undefined;
|
|
@@ -2828,25 +3008,87 @@ class Agent {
|
|
|
2828
3008
|
}
|
|
2829
3009
|
return {};
|
|
2830
3010
|
}
|
|
3011
|
+
/** Durable replacement-style text write (temp file + fsync + rename). */
|
|
3012
|
+
writeAtomicTextFile(file, content) {
|
|
3013
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
3014
|
+
const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
3015
|
+
try {
|
|
3016
|
+
const handle = fs.openSync(temp, 'w');
|
|
3017
|
+
try {
|
|
3018
|
+
fs.writeFileSync(handle, content, 'utf-8');
|
|
3019
|
+
fs.fsyncSync(handle);
|
|
3020
|
+
}
|
|
3021
|
+
finally {
|
|
3022
|
+
fs.closeSync(handle);
|
|
3023
|
+
}
|
|
3024
|
+
let lastError = null;
|
|
3025
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
3026
|
+
try {
|
|
3027
|
+
fs.renameSync(temp, file);
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
catch (error) {
|
|
3031
|
+
lastError = error;
|
|
3032
|
+
const code = error && typeof error === 'object' && 'code' in error ? String(error.code || '') : '';
|
|
3033
|
+
if (!['EPERM', 'EACCES', 'EBUSY'].includes(code))
|
|
3034
|
+
break;
|
|
3035
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10 * (attempt + 1));
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
throw lastError;
|
|
3039
|
+
}
|
|
3040
|
+
finally {
|
|
3041
|
+
try {
|
|
3042
|
+
fs.unlinkSync(temp);
|
|
3043
|
+
}
|
|
3044
|
+
catch { }
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
2831
3047
|
writeStoredConversationState(state, ws = this.workspace.current, deletedKeys = []) {
|
|
2832
3048
|
this.writeStoredConversationStateNow(state, ws, deletedKeys);
|
|
2833
3049
|
}
|
|
2834
|
-
|
|
3050
|
+
/**
|
|
3051
|
+
* Coalesced conversation-state write.
|
|
3052
|
+
*
|
|
3053
|
+
* Mid-turn persistence used to rewrite the whole workspace document
|
|
3054
|
+
* synchronously on every kernel event (measured: 5.33 full rewrites per turn
|
|
3055
|
+
* with no tools). The document grows with the conversation, so that cost grew
|
|
3056
|
+
* with the turn count. This path keeps durability at turn boundaries (the
|
|
3057
|
+
* terminal save stays synchronous and cancels any pending timer) while
|
|
3058
|
+
* throttling intermediate saves: the latest snapshot wins, and bursts inside
|
|
3059
|
+
* one throttle window collapse into a single write.
|
|
3060
|
+
*/
|
|
3061
|
+
scheduleStoredConversationState(state, ws = this.workspace.current, minIntervalMs = 80) {
|
|
2835
3062
|
const file = this.workspaceConversationStorePath(ws);
|
|
2836
3063
|
if (!file)
|
|
2837
3064
|
return;
|
|
2838
|
-
this.conversationStateDirty.set(file, { state, ws });
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
3065
|
+
this.conversationStateDirty.set(file, { state, ws, at: Date.now() });
|
|
3066
|
+
// A write is already queued: keep it and let it carry the newest snapshot
|
|
3067
|
+
// instead of pushing the deadline out (debounce) on every event.
|
|
3068
|
+
if (this.conversationStateFlushTimers.has(file))
|
|
3069
|
+
return;
|
|
3070
|
+
const elapsed = Date.now() - (this.conversationStateLastWriteAt.get(file) || 0);
|
|
3071
|
+
const wait = Math.max(0, minIntervalMs - elapsed);
|
|
3072
|
+
const timer = setTimeout(() => {
|
|
2843
3073
|
this.conversationStateFlushTimers.delete(file);
|
|
2844
3074
|
const pending = this.conversationStateDirty.get(file);
|
|
2845
3075
|
if (!pending)
|
|
2846
3076
|
return;
|
|
2847
3077
|
this.conversationStateDirty.delete(file);
|
|
2848
|
-
|
|
2849
|
-
|
|
3078
|
+
try {
|
|
3079
|
+
this.writeStoredConversationStateNow(pending.state, pending.ws);
|
|
3080
|
+
}
|
|
3081
|
+
catch (error) {
|
|
3082
|
+
// A deferred write must never take the process down: keep the snapshot
|
|
3083
|
+
// pending so the next save or the exit flush retries it.
|
|
3084
|
+
this.conversationStateDirty.set(file, pending);
|
|
3085
|
+
if (process.env.NEWMARK_PROVIDER_DIAGNOSTICS === '1')
|
|
3086
|
+
console.error(`[NewmarkState] deferred conversation-state write failed: ${String(error)}`);
|
|
3087
|
+
}
|
|
3088
|
+
}, wait);
|
|
3089
|
+
if (typeof timer.unref === 'function')
|
|
3090
|
+
timer.unref();
|
|
3091
|
+
this.conversationStateFlushTimers.set(file, timer);
|
|
2850
3092
|
}
|
|
2851
3093
|
flushWorkspaceConversationState() {
|
|
2852
3094
|
const file = this.workspaceConversationStorePath();
|
|
@@ -2929,6 +3171,16 @@ class Agent {
|
|
|
2929
3171
|
writeStoredConversationStateNow(state, ws = this.workspace.current, deletedKeys = []) {
|
|
2930
3172
|
const file = this.workspaceConversationStorePath(ws);
|
|
2931
3173
|
if (file) {
|
|
3174
|
+
// A queued coalesced snapshot may hold changes for *other* conversations
|
|
3175
|
+
// (for example provider usage recorded just before switching away). Fold
|
|
3176
|
+
// those in before cancelling it so a synchronous save can never drop them.
|
|
3177
|
+
const pending = this.conversationStateDirty.get(file);
|
|
3178
|
+
if (pending) {
|
|
3179
|
+
state = {
|
|
3180
|
+
...state,
|
|
3181
|
+
conversations: { ...(pending.state.conversations || {}), ...(state.conversations || {}) },
|
|
3182
|
+
};
|
|
3183
|
+
}
|
|
2932
3184
|
const pendingTimer = this.conversationStateFlushTimers.get(file);
|
|
2933
3185
|
if (pendingTimer)
|
|
2934
3186
|
clearTimeout(pendingTimer);
|
|
@@ -2950,29 +3202,139 @@ class Agent {
|
|
|
2950
3202
|
const existingUpdatedAt = Date.parse(existing.updatedAt || '') || 0;
|
|
2951
3203
|
const preferred = incomingUpdatedAt >= existingUpdatedAt ? { ...existing, ...incoming } : { ...incoming, ...existing };
|
|
2952
3204
|
const branchPageReplacement = !!incoming.branchReset || !!incoming.tree || !!incoming.branches?.length;
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
3205
|
+
// Only the side that actually carries a payload may decide the heavy
|
|
3206
|
+
// fields. A shard-backed index entry that this process never touched
|
|
3207
|
+
// must not be hydrated just to merge metadata.
|
|
3208
|
+
const incomingEntry = incoming;
|
|
3209
|
+
const existingEntry = existing;
|
|
3210
|
+
const incomingShard = this.conversationShardSnapshot(incomingEntry);
|
|
3211
|
+
const count = (value) => (Array.isArray(value) ? value.length : 0);
|
|
3212
|
+
const existingMessages = Number(existing.messageCount) || count(existingEntry.chatMessages);
|
|
3213
|
+
const existingHistory = Number(existing.historyCount) || count(existingEntry.history);
|
|
3214
|
+
const incomingMessages = incomingShard ? count(incomingShard.chatMessages) : Number(incoming.messageCount) || 0;
|
|
3215
|
+
const incomingHistory = incomingShard ? count(incomingShard.history) : Number(incoming.historyCount) || 0;
|
|
3216
|
+
const incomingTranscriptEmpty = incomingMessages === 0 && incomingHistory === 0;
|
|
3217
|
+
// Legacy guard: an empty in-memory snapshot must never clobber a stored
|
|
3218
|
+
// transcript (mirrors, cold reloads and legacy callers can carry an
|
|
3219
|
+
// entry without its messages).
|
|
3220
|
+
const keepsExistingPayload = incomingTranscriptEmpty
|
|
3221
|
+
&& (existingMessages > 0 || existingHistory > 0)
|
|
3222
|
+
&& !branchPageReplacement;
|
|
3223
|
+
if (!incomingShard || keepsExistingPayload) {
|
|
3224
|
+
// Metadata-only update: keep the existing heavy payload and pointer.
|
|
3225
|
+
preferred.messageCount = existingMessages || Number(preferred.messageCount) || 0;
|
|
3226
|
+
preferred.historyCount = existingHistory || Number(preferred.historyCount) || 0;
|
|
3227
|
+
preferred.workRunCount = Number(existing.workRunCount) || Number(preferred.workRunCount) || 0;
|
|
3228
|
+
if (typeof existing.shard === 'string' && existing.shard) {
|
|
3229
|
+
preferred.shard = existing.shard;
|
|
3230
|
+
preferred.shardBytes = Number(existing.shardBytes) || Number(preferred.shardBytes) || 0;
|
|
3231
|
+
}
|
|
3232
|
+
// `preferred` is a shallow metadata copy: the lazy shard accessors are
|
|
3233
|
+
// non-enumerable and therefore absent from it. Re-attach them so the
|
|
3234
|
+
// cached state keeps exposing this conversation's transcript instead
|
|
3235
|
+
// of looking like an empty conversation to later readers.
|
|
3236
|
+
if (typeof preferred.shard === 'string' && preferred.shard) {
|
|
3237
|
+
const shardDir = this.workspaceConversationShardDir(ws);
|
|
3238
|
+
if (shardDir) {
|
|
3239
|
+
this.attachConversationShard(preferred, path.join(shardDir, String(preferred.shard)), key);
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
// Even a metadata-only writer can carry a stale WorkRun ledger. Load
|
|
3243
|
+
// the existing shard, then reconcile by run id and event sequence;
|
|
3244
|
+
// the other heavy fields still come from that same persisted shard.
|
|
3245
|
+
// Explicit branch-page replacement is the only path that replaces
|
|
3246
|
+
// runs without merging. A completed snapshot wins over an older
|
|
3247
|
+
// running copy, while explicit resume emits a newer sequence.
|
|
3248
|
+
const incomingRuns = Array.isArray(incoming.workRuns) ? incoming.workRuns : null;
|
|
3249
|
+
if (branchPageReplacement) {
|
|
3250
|
+
preferred.workRuns = this.normalizeWorkRuns(incomingRuns || []);
|
|
3251
|
+
}
|
|
3252
|
+
else if (incomingRuns) {
|
|
3253
|
+
const existingShardBacked = typeof preferred.shard === 'string' && !!preferred.shard;
|
|
3254
|
+
let previousRuns = [];
|
|
3255
|
+
if (existingShardBacked) {
|
|
3256
|
+
const preferredEntry = preferred;
|
|
3257
|
+
this.hydrateConversationEntry(preferredEntry);
|
|
3258
|
+
previousRuns = preferred.workRuns || [];
|
|
3259
|
+
}
|
|
3260
|
+
else {
|
|
3261
|
+
previousRuns = existing.workRuns || [];
|
|
3262
|
+
}
|
|
3263
|
+
preferred.workRuns = this.normalizeWorkRuns([...previousRuns, ...incomingRuns]);
|
|
3264
|
+
}
|
|
3265
|
+
merged[key] = preferred;
|
|
3266
|
+
continue;
|
|
2958
3267
|
}
|
|
3268
|
+
// This process owns a payload for this conversation. Which side wins is
|
|
3269
|
+
// decided per field exactly like the pre-shard merge did:
|
|
3270
|
+
// - `updatedAt` orders whole entries (a stale in-memory snapshot must
|
|
3271
|
+
// never revert a newer persisted transcript/queue);
|
|
3272
|
+
// - fields the caller does not produce at all (`tree`/`branches` come
|
|
3273
|
+
// from branch transactions, never from saveWorkspaceConversationState)
|
|
3274
|
+
// always survive from the stored payload;
|
|
3275
|
+
// - a branch page replacement still takes the incoming side verbatim.
|
|
3276
|
+
const preferIncoming = incomingUpdatedAt >= existingUpdatedAt;
|
|
3277
|
+
const existingShardBacked = Object.prototype.hasOwnProperty.call(existing, 'shard');
|
|
3278
|
+
const needsExistingPayload = existingShardBacked && (!preferIncoming
|
|
3279
|
+
|| (!!existing.hasTree && incomingShard.tree === undefined)
|
|
3280
|
+
|| (!!existing.hasBranches && incomingShard.branches === undefined));
|
|
3281
|
+
const priorPayload = needsExistingPayload
|
|
3282
|
+
? (this.hydrateConversationEntry(existingEntry), existingEntry)
|
|
3283
|
+
: null;
|
|
3284
|
+
const winningHeavy = (field) => {
|
|
3285
|
+
const existingValue = priorPayload ? priorPayload[field] : undefined;
|
|
3286
|
+
const incomingValue = incomingShard[field];
|
|
3287
|
+
if (preferIncoming)
|
|
3288
|
+
return incomingValue !== undefined ? incomingValue : existingValue;
|
|
3289
|
+
return existingValue !== undefined ? existingValue : incomingValue;
|
|
3290
|
+
};
|
|
2959
3291
|
const incomingPlanRevision = Math.max(0, Number(incoming.linkedPlan?.revision || 0));
|
|
2960
3292
|
const existingPlanRevision = Math.max(0, Number(existing.linkedPlan?.revision || 0));
|
|
2961
3293
|
preferred.linkedPlan = incomingPlanRevision >= existingPlanRevision ? incoming.linkedPlan : existing.linkedPlan;
|
|
2962
3294
|
const incomingSequence = Math.max(0, Number(incoming.subagentState?.nextSequence || 0));
|
|
2963
3295
|
const existingSequence = Math.max(0, Number(existing.subagentState?.nextSequence || 0));
|
|
2964
3296
|
preferred.subagentState = incomingSequence >= existingSequence ? incoming.subagentState : existing.subagentState;
|
|
2965
|
-
preferred.chatMessages = branchPageReplacement
|
|
2966
|
-
|
|
3297
|
+
preferred.chatMessages = branchPageReplacement
|
|
3298
|
+
? [...(incomingShard.chatMessages || [])]
|
|
3299
|
+
: winningHeavy('chatMessages') || [];
|
|
3300
|
+
preferred.history = branchPageReplacement
|
|
3301
|
+
? [...(incomingShard.history || [])]
|
|
3302
|
+
: winningHeavy('history') || [];
|
|
3303
|
+
// WorkRun ledgers can diverge across isolated runtimes while keeping
|
|
3304
|
+
// the same number of runs (for example, a stale `running` snapshot
|
|
3305
|
+
// after another runtime durably completed that run). Always reconcile
|
|
3306
|
+
// the persisted ledger with the incoming one; normalizeWorkRuns merges
|
|
3307
|
+
// duplicate run ids by event sequence and terminal rank. Explicit
|
|
3308
|
+
// resume emits a newer event sequence, so it still reopens the run.
|
|
3309
|
+
const incomingRuns = incomingShard.workRuns || [];
|
|
3310
|
+
const previousRuns = existingShardBacked && !branchPageReplacement
|
|
3311
|
+
? (this.hydrateConversationEntry(existingEntry), existingEntry.workRuns || [])
|
|
3312
|
+
: [];
|
|
2967
3313
|
preferred.workRuns = branchPageReplacement
|
|
2968
|
-
? this.normalizeWorkRuns(
|
|
2969
|
-
: this.normalizeWorkRuns([...
|
|
2970
|
-
preferred.continuations = branchPageReplacement
|
|
2971
|
-
|
|
3314
|
+
? this.normalizeWorkRuns(incomingRuns)
|
|
3315
|
+
: this.normalizeWorkRuns([...previousRuns, ...incomingRuns]);
|
|
3316
|
+
preferred.continuations = branchPageReplacement
|
|
3317
|
+
? this.normalizeContinuations(incomingShard.continuations || [])
|
|
3318
|
+
: this.mergeConversationContinuations(incomingShard.continuations || [], Number(existing.continuationCount) || 0, existingShardBacked, existingEntry, Number(incoming.continuationRevision) || 0, Number(existing.continuationRevision) || 0);
|
|
3319
|
+
if (branchPageReplacement)
|
|
3320
|
+
preferred.branches = [...(incomingShard.branches || [])];
|
|
3321
|
+
else if (winningHeavy('branches') !== undefined)
|
|
3322
|
+
preferred.branches = winningHeavy('branches');
|
|
2972
3323
|
preferred.activeBranchId = branchPageReplacement ? String(incoming.activeBranchId || '') : preferred.activeBranchId;
|
|
2973
3324
|
preferred.activeBranchGroupId = branchPageReplacement ? String(incoming.activeBranchGroupId || '') : preferred.activeBranchGroupId;
|
|
2974
|
-
preferred.tree =
|
|
3325
|
+
preferred.tree = winningHeavy('tree') || preferred.tree;
|
|
2975
3326
|
preferred.branchReset = branchPageReplacement;
|
|
3327
|
+
if (priorPayload) {
|
|
3328
|
+
for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
|
|
3329
|
+
if (field === 'chatMessages' || field === 'history')
|
|
3330
|
+
continue;
|
|
3331
|
+
if (preferred[field] === undefined && priorPayload[field] !== undefined) {
|
|
3332
|
+
preferred[field] = priorPayload[field];
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
delete preferred.shard;
|
|
3337
|
+
delete preferred.shardBytes;
|
|
2976
3338
|
merged[key] = preferred;
|
|
2977
3339
|
}
|
|
2978
3340
|
return {
|
|
@@ -2983,6 +3345,17 @@ class Agent {
|
|
|
2983
3345
|
conversations: merged,
|
|
2984
3346
|
};
|
|
2985
3347
|
});
|
|
3348
|
+
// A deleted conversation must not leave its transcript shard behind.
|
|
3349
|
+
for (const key of deletedKeys) {
|
|
3350
|
+
const shardFile = this.conversationShardPath(String(key), ws);
|
|
3351
|
+
if (!shardFile)
|
|
3352
|
+
continue;
|
|
3353
|
+
try {
|
|
3354
|
+
fs.unlinkSync(shardFile);
|
|
3355
|
+
}
|
|
3356
|
+
catch { }
|
|
3357
|
+
this.conversationShardFingerprint.delete(shardFile);
|
|
3358
|
+
}
|
|
2986
3359
|
stopTimer();
|
|
2987
3360
|
}
|
|
2988
3361
|
mutateStoredConversationState(ws, mutate, select) {
|
|
@@ -2992,9 +3365,15 @@ class Agent {
|
|
|
2992
3365
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2993
3366
|
const lock = `${file}.lock`;
|
|
2994
3367
|
let lockHandle = null;
|
|
2995
|
-
for (let attempt = 0; attempt <
|
|
3368
|
+
for (let attempt = 0; attempt < 400; attempt += 1) {
|
|
2996
3369
|
try {
|
|
2997
3370
|
lockHandle = fs.openSync(lock, 'wx');
|
|
3371
|
+
// Record the owner so a lock left behind by a killed runtime can be
|
|
3372
|
+
// reclaimed immediately instead of blocking saves for the age window.
|
|
3373
|
+
try {
|
|
3374
|
+
fs.writeFileSync(lockHandle, String(process.pid), 'utf-8');
|
|
3375
|
+
}
|
|
3376
|
+
catch { }
|
|
2998
3377
|
break;
|
|
2999
3378
|
}
|
|
3000
3379
|
catch (error) {
|
|
@@ -3003,7 +3382,22 @@ class Agent {
|
|
|
3003
3382
|
throw error;
|
|
3004
3383
|
if (code === 'EEXIST') {
|
|
3005
3384
|
try {
|
|
3006
|
-
|
|
3385
|
+
const stat = fs.statSync(lock);
|
|
3386
|
+
const rawOwner = fs.readFileSync(lock, 'utf-8').trim();
|
|
3387
|
+
const ownerPid = Number(rawOwner);
|
|
3388
|
+
const knownOwner = rawOwner.length > 0 && Number.isFinite(ownerPid) && ownerPid > 0;
|
|
3389
|
+
const ownerAlive = knownOwner && (ownerPid === process.pid || (() => {
|
|
3390
|
+
try {
|
|
3391
|
+
process.kill(ownerPid, 0);
|
|
3392
|
+
return true;
|
|
3393
|
+
}
|
|
3394
|
+
catch {
|
|
3395
|
+
return false;
|
|
3396
|
+
}
|
|
3397
|
+
})());
|
|
3398
|
+
if (knownOwner && !ownerAlive)
|
|
3399
|
+
fs.unlinkSync(lock);
|
|
3400
|
+
else if (Date.now() - stat.mtimeMs > 30_000)
|
|
3007
3401
|
fs.unlinkSync(lock);
|
|
3008
3402
|
}
|
|
3009
3403
|
catch { }
|
|
@@ -3028,8 +3422,49 @@ class Agent {
|
|
|
3028
3422
|
contentState.version = 3;
|
|
3029
3423
|
contentState.conversations = contentState.conversations || {};
|
|
3030
3424
|
const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
3031
|
-
const
|
|
3032
|
-
|
|
3425
|
+
const shardDir = this.workspaceConversationShardDir(ws);
|
|
3426
|
+
const diskConversations = {};
|
|
3427
|
+
const pendingShards = [];
|
|
3428
|
+
for (const [key, rawEntry] of Object.entries(contentState.conversations || {})) {
|
|
3429
|
+
const entry = rawEntry;
|
|
3430
|
+
const snapshot = this.conversationShardSnapshot(entry);
|
|
3431
|
+
let shardFileName = typeof entry.shard === 'string' ? entry.shard : '';
|
|
3432
|
+
let shardBytes = Number(entry.shardBytes) || 0;
|
|
3433
|
+
let messageCount = Number(entry.messageCount) || 0;
|
|
3434
|
+
let historyCount = Number(entry.historyCount) || 0;
|
|
3435
|
+
let workRunCount = Number(entry.workRunCount) || 0;
|
|
3436
|
+
let hasTree = !!entry.hasTree;
|
|
3437
|
+
let hasBranches = !!entry.hasBranches;
|
|
3438
|
+
let continuationCount = Number(entry.continuationCount) || 0;
|
|
3439
|
+
let continuationRevision = Number(entry.continuationRevision) || 0;
|
|
3440
|
+
if (snapshot && shardDir) {
|
|
3441
|
+
const payload = this.conversationEntryForDisk(snapshot);
|
|
3442
|
+
messageCount = Array.isArray(payload.chatMessages) ? payload.chatMessages.length : 0;
|
|
3443
|
+
historyCount = Array.isArray(payload.history) ? payload.history.length : 0;
|
|
3444
|
+
workRunCount = Array.isArray(payload.workRuns) ? payload.workRuns.length : 0;
|
|
3445
|
+
hasTree = !!payload.tree;
|
|
3446
|
+
hasBranches = Array.isArray(payload.branches) && payload.branches.length > 0;
|
|
3447
|
+
continuationCount = Array.isArray(payload.continuations) ? payload.continuations.length : 0;
|
|
3448
|
+
continuationRevision = Number(entry.continuationRevision) || this.continuationRevision;
|
|
3449
|
+
// Shard content is the heavy payload only: no `updatedAt`, no metadata.
|
|
3450
|
+
// A save that only bumps metadata therefore produces a byte-identical
|
|
3451
|
+
// shard and is skipped by the fingerprint check below.
|
|
3452
|
+
const shardContent = JSON.stringify({ version: 1, key, ...payload });
|
|
3453
|
+
shardFileName = shardFileName || this.conversationShardFileName(key);
|
|
3454
|
+
const shardFile = path.join(shardDir, shardFileName);
|
|
3455
|
+
const shardFingerprint = crypto.createHash('sha1').update(shardContent).digest('hex');
|
|
3456
|
+
if (this.conversationShardFingerprint.get(shardFile) !== shardFingerprint) {
|
|
3457
|
+
pendingShards.push({ file: shardFile, content: shardContent });
|
|
3458
|
+
this.conversationShardFingerprint.set(shardFile, shardFingerprint);
|
|
3459
|
+
}
|
|
3460
|
+
shardBytes = Buffer.byteLength(shardContent, 'utf8');
|
|
3461
|
+
}
|
|
3462
|
+
diskConversations[key] = this.conversationIndexEntryForDisk(entry, shardFileName || null, shardBytes, {
|
|
3463
|
+
messageCount, historyCount, workRunCount, hasTree, hasBranches, continuationCount, continuationRevision,
|
|
3464
|
+
});
|
|
3465
|
+
}
|
|
3466
|
+
for (const shard of pendingShards)
|
|
3467
|
+
this.writeAtomicTextFile(shard.file, shard.content);
|
|
3033
3468
|
const content = JSON.stringify({
|
|
3034
3469
|
version: 3,
|
|
3035
3470
|
activeConversationId: contentState.activeConversationId || this.activeConversationId || 'default',
|
|
@@ -3037,6 +3472,27 @@ class Agent {
|
|
|
3037
3472
|
flowSuspensions: contentState.flowSuspensions || undefined,
|
|
3038
3473
|
conversations: diskConversations,
|
|
3039
3474
|
}, null, 2);
|
|
3475
|
+
// Content-fingerprint short circuit: a save whose serialized document is
|
|
3476
|
+
// byte-identical to what this process last wrote needs no temp file, no
|
|
3477
|
+
// fsync and no rename. Repeated saves inside one turn (Build start, kernel
|
|
3478
|
+
// events, work-run bookkeeping) usually collapse into this case.
|
|
3479
|
+
const contentFingerprint = crypto.createHash('sha1').update(content).digest('hex');
|
|
3480
|
+
const knownDiskFingerprint = this.conversationStateCacheFingerprint.get(file);
|
|
3481
|
+
let diskUntouched = false;
|
|
3482
|
+
if (knownDiskFingerprint) {
|
|
3483
|
+
try {
|
|
3484
|
+
const current = fs.statSync(file, { bigint: true });
|
|
3485
|
+
diskUntouched = knownDiskFingerprint === `${current.mtimeNs}:${current.ctimeNs}:${current.size}`;
|
|
3486
|
+
}
|
|
3487
|
+
catch {
|
|
3488
|
+
diskUntouched = false;
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
if (diskUntouched && this.conversationStateWrittenFingerprint.get(file) === contentFingerprint) {
|
|
3492
|
+
this.conversationStateCache.set(file, contentState);
|
|
3493
|
+
this.conversationStateLastWriteAt.set(file, Date.now());
|
|
3494
|
+
return select ? select(contentState) : undefined;
|
|
3495
|
+
}
|
|
3040
3496
|
try {
|
|
3041
3497
|
const handle = fs.openSync(temp, 'w');
|
|
3042
3498
|
try {
|
|
@@ -3051,6 +3507,8 @@ class Agent {
|
|
|
3051
3507
|
try {
|
|
3052
3508
|
fs.renameSync(temp, file);
|
|
3053
3509
|
this.conversationStateCache.set(file, contentState);
|
|
3510
|
+
this.conversationStateWrittenFingerprint.set(file, contentFingerprint);
|
|
3511
|
+
this.conversationStateLastWriteAt.set(file, Date.now());
|
|
3054
3512
|
const writtenStat = fs.statSync(file, { bigint: true });
|
|
3055
3513
|
this.conversationStateCacheFingerprint.set(file, `${writtenStat.mtimeNs}:${writtenStat.ctimeNs}:${writtenStat.size}`);
|
|
3056
3514
|
return select ? select(contentState) : undefined;
|
|
@@ -3154,11 +3612,17 @@ class Agent {
|
|
|
3154
3612
|
if (prefix && !key.startsWith(prefix))
|
|
3155
3613
|
continue;
|
|
3156
3614
|
const id = key.slice(prefix.length + 1) || key;
|
|
3615
|
+
// Index-resident counts: listing conversations must never hydrate a
|
|
3616
|
+
// shard, otherwise every UI refresh would parse every transcript.
|
|
3617
|
+
const entry = value;
|
|
3618
|
+
const shardBacked = typeof entry.shard === 'string' && !!entry.shard;
|
|
3157
3619
|
rows.push({
|
|
3158
3620
|
id,
|
|
3159
3621
|
key,
|
|
3160
|
-
title: value.title ||
|
|
3161
|
-
|
|
3622
|
+
title: value.title || (shardBacked
|
|
3623
|
+
? String(entry.title || id)
|
|
3624
|
+
: this.titleFromMessages(value.chatMessages || [], id)),
|
|
3625
|
+
messageCount: Number(value.messageCount) || value.chatMessages?.length || 0,
|
|
3162
3626
|
historyCount: value.history?.length || 0,
|
|
3163
3627
|
updatedAt: value.updatedAt || '',
|
|
3164
3628
|
pinned: !!value.pinned,
|
|
@@ -3756,6 +4220,10 @@ class Agent {
|
|
|
3756
4220
|
return false;
|
|
3757
4221
|
existing.pinned = !!pinned;
|
|
3758
4222
|
existing.pinnedAt = existing.pinned ? new Date().toISOString() : '';
|
|
4223
|
+
// The pin is a real change: advance `updatedAt` so a concurrent older
|
|
4224
|
+
// snapshot (which ties on the previous timestamp) cannot win the merge and
|
|
4225
|
+
// silently unpin the conversation.
|
|
4226
|
+
existing.updatedAt = new Date().toISOString();
|
|
3759
4227
|
const siblingOrders = Object.entries(stored.conversations)
|
|
3760
4228
|
.filter(([key, value]) => key !== stateKey && !!value.pinned === existing.pinned && Number.isFinite(value.order))
|
|
3761
4229
|
.map(([, value]) => Number(value.order));
|
|
@@ -3782,6 +4250,7 @@ class Agent {
|
|
|
3782
4250
|
return item;
|
|
3783
4251
|
return byId.get(orderedIds[nextIndex++]);
|
|
3784
4252
|
});
|
|
4253
|
+
this.continuationRevision += 1;
|
|
3785
4254
|
this.saveWorkspaceConversationState(true);
|
|
3786
4255
|
return this.conversationContinuations();
|
|
3787
4256
|
}
|
|
@@ -4133,6 +4602,12 @@ class Agent {
|
|
|
4133
4602
|
this.firstAgentResponseStarted = true;
|
|
4134
4603
|
return true;
|
|
4135
4604
|
}
|
|
4605
|
+
// This is a hard durability gate: it verifies the flag by reading the store
|
|
4606
|
+
// back, so the snapshot produced by the current turn must be on disk first.
|
|
4607
|
+
// Intermediate saves are coalesced, which can leave the entry unwritten for
|
|
4608
|
+
// up to one throttle window; flush it synchronously here instead of relying
|
|
4609
|
+
// on timing.
|
|
4610
|
+
this.saveWorkspaceConversationState(true);
|
|
4136
4611
|
const marked = this.mutateStoredConversationState(workspace, latest => {
|
|
4137
4612
|
const current = latest.conversations?.[stateKey];
|
|
4138
4613
|
const firstUser = (current?.chatMessages || []).find(message => message.role === 'user');
|
|
@@ -4194,7 +4669,14 @@ class Agent {
|
|
|
4194
4669
|
if (orderSlots.some(order => !Number.isFinite(order)))
|
|
4195
4670
|
return latest;
|
|
4196
4671
|
orderSlots.sort((a, b) => a - b);
|
|
4197
|
-
|
|
4672
|
+
const reorderedAt = new Date().toISOString();
|
|
4673
|
+
normalized.forEach((id, index) => {
|
|
4674
|
+
const entry = entryById.get(id);
|
|
4675
|
+
entry.order = orderSlots[index];
|
|
4676
|
+
// Same reason as pinning: keep a real change strictly newer than any
|
|
4677
|
+
// snapshot a coalesced save may still be holding.
|
|
4678
|
+
entry.updatedAt = reorderedAt;
|
|
4679
|
+
});
|
|
4198
4680
|
accepted = true;
|
|
4199
4681
|
return latest;
|
|
4200
4682
|
});
|
|
@@ -4310,6 +4792,7 @@ class Agent {
|
|
|
4310
4792
|
subagentState: this.subagents.serialize(),
|
|
4311
4793
|
workRuns: this.normalizeWorkRuns(this.workRuns),
|
|
4312
4794
|
continuations: this.normalizeContinuations(this.continuations),
|
|
4795
|
+
continuationRevision: this.continuationRevision,
|
|
4313
4796
|
modelSelection: this.currentConversationModelSelection(),
|
|
4314
4797
|
flowSelection: this.currentConversationFlowSelection(),
|
|
4315
4798
|
inputMode: this.inputMode,
|
|
@@ -4356,6 +4839,7 @@ class Agent {
|
|
|
4356
4839
|
runtimeOwnerId: runtimeOwner.runtimeOwnerId,
|
|
4357
4840
|
runtimeOwnerPid: runtimeOwner.runtimeOwnerPid,
|
|
4358
4841
|
runtimeLifecycleRole: runtimeOwner.runtimeLifecycleRole,
|
|
4842
|
+
continuationRevision: this.continuationRevision,
|
|
4359
4843
|
updatedAt,
|
|
4360
4844
|
};
|
|
4361
4845
|
if (nextEntry.tree || nextEntry.branches?.length) {
|
|
@@ -4383,6 +4867,7 @@ class Agent {
|
|
|
4383
4867
|
this.linkedPlan = { markdown: '', revision: 0 };
|
|
4384
4868
|
this.workRuns = [];
|
|
4385
4869
|
this.continuations = [];
|
|
4870
|
+
this.continuationRevision = 0;
|
|
4386
4871
|
this.mode = 'build';
|
|
4387
4872
|
this.goal = null;
|
|
4388
4873
|
this.titleRequestMessageId = '';
|
|
@@ -4409,6 +4894,7 @@ class Agent {
|
|
|
4409
4894
|
this.bindConversationSubagents(this.activeConversationId, saved.subagentState);
|
|
4410
4895
|
this.workRuns = this.normalizeWorkRuns(saved.workRuns);
|
|
4411
4896
|
this.continuations = this.normalizeContinuations(saved.continuations);
|
|
4897
|
+
this.continuationRevision = Number(saved.continuationRevision || 0);
|
|
4412
4898
|
this.restoreConversationModelSelection(saved.modelSelection);
|
|
4413
4899
|
this.restoreConversationFlowSelection(saved.flowSelection);
|
|
4414
4900
|
this.inputMode = this.defaultInputMode();
|
|
@@ -4438,6 +4924,7 @@ class Agent {
|
|
|
4438
4924
|
const recoveredWorkRuns = this.recoverPersistedWorkRuns(persisted?.workRuns, persisted?.updatedAt, this.runtimeLifecycle.unexpectedExit);
|
|
4439
4925
|
this.workRuns = recoveredWorkRuns.runs;
|
|
4440
4926
|
this.continuations = this.normalizeContinuations(persisted?.continuations);
|
|
4927
|
+
this.continuationRevision = Number(persisted?.continuationRevision || 0);
|
|
4441
4928
|
this.restoreConversationModelSelection(persisted?.modelSelection);
|
|
4442
4929
|
this.restoreConversationFlowSelection(persisted?.flowSelection);
|
|
4443
4930
|
this.inputMode = this.defaultInputMode();
|
|
@@ -4942,6 +5429,8 @@ class Agent {
|
|
|
4942
5429
|
conversationId: this.activeConversationId || 'default',
|
|
4943
5430
|
actorId: this.runtimeActorId,
|
|
4944
5431
|
workspaceId: this.workspace.current?.id || '',
|
|
5432
|
+
runtimeKey: process.env.NEWMARK_RUNTIME_KEY || undefined,
|
|
5433
|
+
runId: this.currentWorkRunId() || undefined,
|
|
4945
5434
|
backend: process.env.NEWMARK_WSL_DISTRO ? 'wsl' : (process.platform === 'win32' ? 'windows' : process.platform),
|
|
4946
5435
|
signal,
|
|
4947
5436
|
}).then((content) => {
|
|
@@ -5795,6 +6284,7 @@ class Agent {
|
|
|
5795
6284
|
this.subagents = source.subagents;
|
|
5796
6285
|
this.workRuns = workRuns;
|
|
5797
6286
|
this.continuations = continuations;
|
|
6287
|
+
this.continuationRevision += 1;
|
|
5798
6288
|
this.mode = source.mode || this.mode;
|
|
5799
6289
|
if (source.goal !== undefined)
|
|
5800
6290
|
this.goal = this.restoreGoal(source.goal);
|
|
@@ -6637,7 +7127,9 @@ class Agent {
|
|
|
6637
7127
|
: (memory?.history ?? persisted?.history ?? []);
|
|
6638
7128
|
const messages = this.normalizeConversationChatMessages(sourceMessages, sourceHistory);
|
|
6639
7129
|
const filename = this.writeSessionArchive(messages, this.modeName(), this.model);
|
|
6640
|
-
|
|
7130
|
+
// Materialize the shard-backed entry before cloning: a plain JSON round-trip
|
|
7131
|
+
// of the index record would archive an empty transcript and no branch tree.
|
|
7132
|
+
const archiveEntry = persisted ? JSON.parse(JSON.stringify(this.conversationEntryForDisk(persisted))) : {
|
|
6641
7133
|
title: this.titleFromMessages(messages, clean),
|
|
6642
7134
|
chatMessages: messages,
|
|
6643
7135
|
history: sourceHistory,
|
|
@@ -6649,6 +7141,9 @@ class Agent {
|
|
|
6649
7141
|
continuations: memory?.continuations,
|
|
6650
7142
|
updatedAt: new Date().toISOString(),
|
|
6651
7143
|
};
|
|
7144
|
+
for (const key of ['shard', 'shardBytes', 'messageCount', 'historyCount', 'workRunCount', 'hasTree', 'hasBranches']) {
|
|
7145
|
+
delete archiveEntry[key];
|
|
7146
|
+
}
|
|
6652
7147
|
const manifest = {
|
|
6653
7148
|
version: 2,
|
|
6654
7149
|
kind: 'newmark-conversation-archive',
|
|
@@ -6715,6 +7210,24 @@ class Agent {
|
|
|
6715
7210
|
const cachedStored = this.readStoredConversationState(ws);
|
|
6716
7211
|
const stored = JSON.parse(JSON.stringify(cachedStored || {}));
|
|
6717
7212
|
const persisted = stored.conversations?.[stateKey];
|
|
7213
|
+
// A shard-backed index record keeps `chatMessages` / `history` / `workRuns`
|
|
7214
|
+
// / `tree` behind non-enumerable accessors, and the JSON round-trip above
|
|
7215
|
+
// drops exactly those. Without this the archive of a conversation this
|
|
7216
|
+
// process does not hold in memory (the GUI archives while a utility runtime
|
|
7217
|
+
// owns the transcript) writes an empty payload -- and then deletes the real
|
|
7218
|
+
// transcript from the store. Read the live record's payload into the
|
|
7219
|
+
// detached copy instead.
|
|
7220
|
+
const cachedEntry = (cachedStored.conversations || {})[stateKey];
|
|
7221
|
+
if (persisted && cachedEntry) {
|
|
7222
|
+
for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
|
|
7223
|
+
if (persisted[field] !== undefined)
|
|
7224
|
+
continue;
|
|
7225
|
+
const value = cachedEntry[field];
|
|
7226
|
+
if (value === undefined)
|
|
7227
|
+
continue;
|
|
7228
|
+
persisted[field] = JSON.parse(JSON.stringify(value));
|
|
7229
|
+
}
|
|
7230
|
+
}
|
|
6718
7231
|
if (persisted)
|
|
6719
7232
|
this.normalizeConversationTree(persisted);
|
|
6720
7233
|
const memory = this.workspaceConversations.get(memoryKey);
|
|
@@ -6735,7 +7248,8 @@ class Agent {
|
|
|
6735
7248
|
model: archiveModel,
|
|
6736
7249
|
goal: archiveGoal,
|
|
6737
7250
|
}, archiveDir);
|
|
6738
|
-
|
|
7251
|
+
// Same materialization rule as the synchronous archive path.
|
|
7252
|
+
const archiveEntry = persisted ? JSON.parse(JSON.stringify(this.conversationEntryForDisk(persisted))) : {
|
|
6739
7253
|
title: this.titleFromMessages(messages, clean),
|
|
6740
7254
|
chatMessages: messages,
|
|
6741
7255
|
history: sourceHistory,
|
|
@@ -6747,6 +7261,9 @@ class Agent {
|
|
|
6747
7261
|
continuations: memory?.continuations,
|
|
6748
7262
|
updatedAt: new Date().toISOString(),
|
|
6749
7263
|
};
|
|
7264
|
+
for (const key of ['shard', 'shardBytes', 'messageCount', 'historyCount', 'workRunCount', 'hasTree', 'hasBranches']) {
|
|
7265
|
+
delete archiveEntry[key];
|
|
7266
|
+
}
|
|
6750
7267
|
const manifest = {
|
|
6751
7268
|
version: 2,
|
|
6752
7269
|
kind: 'newmark-conversation-archive',
|
|
@@ -6792,6 +7309,19 @@ class Agent {
|
|
|
6792
7309
|
nextActiveId = latest.activeConversationId || remaining[0] || 'default';
|
|
6793
7310
|
return latest;
|
|
6794
7311
|
});
|
|
7312
|
+
// The index record is gone, so its transcript shard must go with it: an
|
|
7313
|
+
// orphaned shard is dead weight now and a stale transcript source if the
|
|
7314
|
+
// same conversation id is ever written again. The synchronous archive path
|
|
7315
|
+
// deletes shards through `writeStoredConversationState(...deletedKeys)`; the
|
|
7316
|
+
// async path mutates the locked state directly, so it unlinks here.
|
|
7317
|
+
const archivedShardFile = this.conversationShardPath(stateKey, ws);
|
|
7318
|
+
if (archivedShardFile) {
|
|
7319
|
+
try {
|
|
7320
|
+
fs.unlinkSync(archivedShardFile);
|
|
7321
|
+
}
|
|
7322
|
+
catch { /* already gone */ }
|
|
7323
|
+
this.conversationShardFingerprint.delete(archivedShardFile);
|
|
7324
|
+
}
|
|
6795
7325
|
this.workspaceConversations.delete(memoryKey);
|
|
6796
7326
|
const duplicateMemoryKey = `${ws.isInternal ? 'internal' : 'external'}:${path.resolve(ws.path)}::conversation:${clean}`;
|
|
6797
7327
|
this.workspaceConversations.delete(duplicateMemoryKey);
|
|
@@ -7113,7 +7643,7 @@ class Agent {
|
|
|
7113
7643
|
capabilities.add(capability);
|
|
7114
7644
|
}
|
|
7115
7645
|
}
|
|
7116
|
-
const health = this.
|
|
7646
|
+
const health = this.routeController.endpointMetrics(this.deploymentRef(model));
|
|
7117
7647
|
const latencySeconds = Number(model.evaluation?.latency);
|
|
7118
7648
|
const configuredPrivacy = (model.privacy || ['default']).filter(value => value === 'default' || value === 'no_training' || value === 'zdr');
|
|
7119
7649
|
const supportedParameters = new Set((routeMetadata.supported_parameters || []).map(value => String(value).toLowerCase()));
|
|
@@ -7133,6 +7663,7 @@ class Agent {
|
|
|
7133
7663
|
return {
|
|
7134
7664
|
deployment: this.deploymentRef(model),
|
|
7135
7665
|
enabled: model.enabled !== false,
|
|
7666
|
+
unavailableReason: this.isBalanceBlockedDeployment(this.deploymentRef(model)) ? 'balance_blocked' : undefined,
|
|
7136
7667
|
validation: {
|
|
7137
7668
|
level: validation.level,
|
|
7138
7669
|
status: effectiveValidationStatus,
|
|
@@ -7154,7 +7685,7 @@ class Agent {
|
|
|
7154
7685
|
reliability: health.attempts ? health.reliability : 0.5,
|
|
7155
7686
|
toolValidity: health.toolAttempts ? health.toolValidity : undefined,
|
|
7156
7687
|
throughput: health.throughput ?? (model.speed_rating === 'fast' ? 80 : model.speed_rating === 'medium' ? 40 : model.speed_rating === 'slow' ? 15 : undefined),
|
|
7157
|
-
|
|
7688
|
+
circuit: health.circuit,
|
|
7158
7689
|
preference: Number.isFinite(Number(routeMetadata.route_preference)) ? Number(routeMetadata.route_preference) : undefined,
|
|
7159
7690
|
fallbackOnly: !!model.fallback_only,
|
|
7160
7691
|
};
|
|
@@ -7170,40 +7701,6 @@ class Agent {
|
|
|
7170
7701
|
// Routing remains available when metrics-only audit storage is read-only.
|
|
7171
7702
|
}
|
|
7172
7703
|
}
|
|
7173
|
-
loadLearnedRouteFeedback() {
|
|
7174
|
-
try {
|
|
7175
|
-
const file = path.join(this.rootPath, 'routing', 'feedback.jsonl');
|
|
7176
|
-
if (!fs.existsSync(file))
|
|
7177
|
-
return;
|
|
7178
|
-
const lines = fs.readFileSync(file, 'utf-8').split(/\r?\n/).filter(Boolean).slice(-2000);
|
|
7179
|
-
for (const line of lines) {
|
|
7180
|
-
const value = JSON.parse(line);
|
|
7181
|
-
const deployment = value.deployment;
|
|
7182
|
-
if (!deployment?.providerId || !deployment.modelId)
|
|
7183
|
-
continue;
|
|
7184
|
-
if (!['chat', 'coding', 'reasoning', 'long_context', 'vision', 'image_generation', 'tool_use', 'computer_use'].includes(String(value.taskClass)))
|
|
7185
|
-
continue;
|
|
7186
|
-
if (!['manual_switch', 'explicit_rating', 'objective_success'].includes(String(value.source)))
|
|
7187
|
-
continue;
|
|
7188
|
-
if (!Number.isFinite(Number(value.score)) || !Number.isFinite(Number(value.at)))
|
|
7189
|
-
continue;
|
|
7190
|
-
this.autoRouter.recordFeedback(value);
|
|
7191
|
-
}
|
|
7192
|
-
}
|
|
7193
|
-
catch {
|
|
7194
|
-
// A malformed preference log is ignored instead of influencing routing.
|
|
7195
|
-
}
|
|
7196
|
-
}
|
|
7197
|
-
recordRouteFeedbackFor(deployment, taskClass, score, source) {
|
|
7198
|
-
const event = { deployment: { ...deployment }, taskClass, score, source, at: Date.now() };
|
|
7199
|
-
this.autoRouter.recordFeedback(event);
|
|
7200
|
-
try {
|
|
7201
|
-
const directory = path.join(this.rootPath, 'routing');
|
|
7202
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
7203
|
-
fs.appendFileSync(path.join(directory, 'feedback.jsonl'), `${JSON.stringify(event)}\n`, 'utf-8');
|
|
7204
|
-
}
|
|
7205
|
-
catch { }
|
|
7206
|
-
}
|
|
7207
7704
|
allModelNames() {
|
|
7208
7705
|
const names = this.config.allModels().filter(m => m.enabled !== false).map(m => {
|
|
7209
7706
|
const label = m.display || m.name;
|
|
@@ -7218,13 +7715,8 @@ class Agent {
|
|
|
7218
7715
|
const before = this.resolvedDeployment ? deploymentIdentity(this.resolvedDeployment) : '';
|
|
7219
7716
|
const mode = override?.mode || (0, autoRouter_1.normalizeAutoPreference)(this.config.autoSwitchPreference());
|
|
7220
7717
|
const policy = (0, autoRouter_1.defaultRoutePolicy)(mode);
|
|
7221
|
-
|
|
7222
|
-
|
|
7223
|
-
policy.maxQualityLoss = Math.min(1, configuredQualityLoss);
|
|
7224
|
-
}
|
|
7225
|
-
if (typeof override?.maxQualityLoss === 'number' && Number.isFinite(override.maxQualityLoss) && override.maxQualityLoss >= 0) {
|
|
7226
|
-
policy.maxQualityLoss = Math.min(1, override.maxQualityLoss);
|
|
7227
|
-
}
|
|
7718
|
+
// The tendency is guidance for the current-model JEV decision. Eligibility
|
|
7719
|
+
// and explicit cost ceilings remain hard constraints.
|
|
7228
7720
|
const maxExpectedCost = this.config.get('models', 'auto_max_expected_cost_usd');
|
|
7229
7721
|
if (typeof maxExpectedCost === 'number' && Number.isFinite(maxExpectedCost) && maxExpectedCost > 0)
|
|
7230
7722
|
policy.maxExpectedCostUsd = maxExpectedCost;
|
|
@@ -7277,27 +7769,141 @@ class Agent {
|
|
|
7277
7769
|
};
|
|
7278
7770
|
const ownsTransaction = !this.routeTransactionId;
|
|
7279
7771
|
const transactionId = this.routeTransactionId || `route-${crypto.randomUUID()}`;
|
|
7280
|
-
const
|
|
7772
|
+
const estimatedInputTokens = this.estimateContextTokens();
|
|
7773
|
+
const expectedOutputTokens = this.intelligence === 'ultra' || this.intelligence === 'max' ? 32768
|
|
7774
|
+
: this.intelligence === 'xhigh' ? 16384
|
|
7775
|
+
: this.intelligence === 'high' ? 8192
|
|
7776
|
+
: this.intelligence === 'low' ? 2048 : 4096;
|
|
7777
|
+
const routeRequest = {
|
|
7281
7778
|
transactionId,
|
|
7282
|
-
affinityKey: `${this.workspace.current?.path || this.rootPath}\u0000${this.activeConversationId || 'default'}`,
|
|
7283
7779
|
taskText: task,
|
|
7284
|
-
estimatedInputTokens
|
|
7285
|
-
expectedOutputTokens
|
|
7286
|
-
: this.intelligence === 'xhigh' ? 16384
|
|
7287
|
-
: this.intelligence === 'high' ? 8192
|
|
7288
|
-
: this.intelligence === 'low' ? 2048 : 4096,
|
|
7780
|
+
estimatedInputTokens,
|
|
7781
|
+
expectedOutputTokens,
|
|
7289
7782
|
requiredCapabilities: [...requiredCapabilities],
|
|
7290
7783
|
batch: override?.batch === true,
|
|
7784
|
+
};
|
|
7785
|
+
const decision = await this.decideAutoRoute({
|
|
7786
|
+
selection,
|
|
7787
|
+
policy,
|
|
7788
|
+
task,
|
|
7789
|
+
request: routeRequest,
|
|
7790
|
+
candidates: this.autoRouteCandidates(),
|
|
7291
7791
|
});
|
|
7292
7792
|
if (ownsTransaction)
|
|
7293
|
-
this.
|
|
7793
|
+
this.routeController.endTransaction(transactionId);
|
|
7794
|
+
else if (decision.resolvedDeployment)
|
|
7795
|
+
this.routeController.pinTransaction(transactionId, decision.resolvedDeployment);
|
|
7796
|
+
if (decision.resolvedDeployment)
|
|
7797
|
+
this.routeController.claimEndpointAttempt(decision.resolvedDeployment);
|
|
7294
7798
|
this.lastRouteDecision = decision;
|
|
7295
|
-
|
|
7799
|
+
// Auto Router v2 freeze rule: a decision that produced no deployment must
|
|
7800
|
+
// not move the conversation to another model. Keeping the previous resolved
|
|
7801
|
+
// deployment is what makes "Auto off / decision model off never changes my
|
|
7802
|
+
// model" observable behaviour instead of a silent re-selection.
|
|
7803
|
+
if (decision.resolvedDeployment) {
|
|
7804
|
+
this.resolvedDeployment = { ...decision.resolvedDeployment };
|
|
7805
|
+
}
|
|
7806
|
+
else if (decision.finalStatus !== 'no_candidate') {
|
|
7807
|
+
this.resolvedDeployment = null;
|
|
7808
|
+
}
|
|
7296
7809
|
this.pendingAutoAttempts = [];
|
|
7297
7810
|
this.routeAttemptStartedAt = this.resolvedDeployment ? Date.now() : 0;
|
|
7298
7811
|
this.persistRouteDecision(decision);
|
|
7299
7812
|
return !!this.resolvedDeployment && before !== deploymentIdentity(this.resolvedDeployment);
|
|
7300
7813
|
}
|
|
7814
|
+
/**
|
|
7815
|
+
* Auto Router v2 decision pipeline:
|
|
7816
|
+
*
|
|
7817
|
+
* RouteEligibilityGuard → JevDecisionEngine → RouteDecisionValidator → pin
|
|
7818
|
+
*
|
|
7819
|
+
* The current conversation model is the only Auto decision source. If it is
|
|
7820
|
+
* disabled, times out, crashes or returns something the guard cannot accept,
|
|
7821
|
+
* Auto stops without selecting another deployment.
|
|
7822
|
+
*/
|
|
7823
|
+
async decideAutoRoute(input) {
|
|
7824
|
+
const { selection, policy, task, request, candidates } = input;
|
|
7825
|
+
const taskClasses = (0, autoRouter_1.classifyTaskClasses)(task, [...policy.requiredCapabilities, ...request.requiredCapabilities], request.estimatedInputTokens);
|
|
7826
|
+
const stopTimer = (0, performanceDiagnostics_1.performanceTimer)('jev_route_decision', { conversationId: this.activeConversationId || 'default' });
|
|
7827
|
+
try {
|
|
7828
|
+
const configuredTimeout = Number(this.config.get('models', 'jev_timeout_ms'));
|
|
7829
|
+
const jevUsageRequest = this.beginProviderUsageRequest();
|
|
7830
|
+
const jev = this.resolveCurrentModelJevEngine(jevUsageRequest);
|
|
7831
|
+
return await (0, routeDecisionValidator_1.resolveAutoRouteDecision)({
|
|
7832
|
+
selection,
|
|
7833
|
+
policy,
|
|
7834
|
+
taskText: task,
|
|
7835
|
+
request,
|
|
7836
|
+
candidates,
|
|
7837
|
+
taskClasses,
|
|
7838
|
+
intelligence: this.intelligence,
|
|
7839
|
+
engine: jev.engine || undefined,
|
|
7840
|
+
unavailableReason: jev.unavailableReason,
|
|
7841
|
+
timeoutMs: Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : jevDecision_1.DEFAULT_JEV_TIMEOUT_MS,
|
|
7842
|
+
previousDeployment: this.resolvedDeployment,
|
|
7843
|
+
pinnedDeployment: this.routeTransactionId ? this.routeController.transactionPin(request.transactionId) : undefined,
|
|
7844
|
+
policyVersion: this.routeController.version(),
|
|
7845
|
+
});
|
|
7846
|
+
}
|
|
7847
|
+
finally {
|
|
7848
|
+
stopTimer();
|
|
7849
|
+
}
|
|
7850
|
+
}
|
|
7851
|
+
/** Resolve JEV to the current eligible conversation model, never a second model slot. */
|
|
7852
|
+
resolveCurrentModelJevEngine(usageRequest) {
|
|
7853
|
+
const jevEnabled = this.config.get('models', 'jev_enabled') !== false;
|
|
7854
|
+
const decisionEnabled = this.config.autoSwitchEnabled() && jevEnabled;
|
|
7855
|
+
const engine = !decisionEnabled
|
|
7856
|
+
? null
|
|
7857
|
+
: new jevDecision_1.CurrentModelJevDecisionEngine(request => {
|
|
7858
|
+
const eligible = request.candidates;
|
|
7859
|
+
if (!eligible.length)
|
|
7860
|
+
return null;
|
|
7861
|
+
const eligibleByIdentity = new Map(eligible.map(candidate => [
|
|
7862
|
+
`${candidate.deployment.providerId}\u0000${candidate.deployment.modelId}`,
|
|
7863
|
+
candidate,
|
|
7864
|
+
]));
|
|
7865
|
+
const availableModels = eligible.flatMap(candidate => {
|
|
7866
|
+
const model = this.config.findDeployment(candidate.deployment);
|
|
7867
|
+
return model?.provider_url && model.api_key ? [{ candidate, model }] : [];
|
|
7868
|
+
});
|
|
7869
|
+
const preferredDeployment = request.previous?.deployment || this.activeDeployment();
|
|
7870
|
+
const defaultSelection = this.config.getStr('models', 'default_model');
|
|
7871
|
+
const defaultRef = parseDeploymentSelectionValue(defaultSelection);
|
|
7872
|
+
const defaultModel = defaultRef
|
|
7873
|
+
? this.config.findDeployment(defaultRef)
|
|
7874
|
+
: this.config.findModel(defaultSelection);
|
|
7875
|
+
const defaultDeployment = defaultModel ? this.deploymentRef(defaultModel) : null;
|
|
7876
|
+
const preferred = preferredDeployment
|
|
7877
|
+
? eligibleByIdentity.get(`${preferredDeployment.providerId}\u0000${preferredDeployment.modelId}`)
|
|
7878
|
+
: undefined;
|
|
7879
|
+
const configuredDefault = defaultDeployment
|
|
7880
|
+
? eligibleByIdentity.get(`${defaultDeployment.providerId}\u0000${defaultDeployment.modelId}`)
|
|
7881
|
+
: undefined;
|
|
7882
|
+
const selected = availableModels.find(entry => preferred
|
|
7883
|
+
&& entry.candidate.deployment.providerId === preferred.deployment.providerId
|
|
7884
|
+
&& entry.candidate.deployment.modelId === preferred.deployment.modelId)
|
|
7885
|
+
|| availableModels.find(entry => configuredDefault
|
|
7886
|
+
&& entry.candidate.deployment.providerId === configuredDefault.deployment.providerId
|
|
7887
|
+
&& entry.candidate.deployment.modelId === configuredDefault.deployment.modelId)
|
|
7888
|
+
|| availableModels[0];
|
|
7889
|
+
if (!selected)
|
|
7890
|
+
return null;
|
|
7891
|
+
const modelIdentity = `${selected.model.provider_id}/${selected.model.name}`;
|
|
7892
|
+
return {
|
|
7893
|
+
provider: this.modelProvider(selected.model, this.jevProviderCache),
|
|
7894
|
+
model: selected.model.name,
|
|
7895
|
+
modelVersion: `${jevDecision_1.CURRENT_MODEL_JEV_VERSION}:${modelIdentity}`,
|
|
7896
|
+
thinkingCapable: selected.model.thinking === true,
|
|
7897
|
+
onUsage: usage => this.recordProviderUsage(usage, usageRequest),
|
|
7898
|
+
};
|
|
7899
|
+
});
|
|
7900
|
+
return {
|
|
7901
|
+
engine,
|
|
7902
|
+
unavailableReason: !this.config.autoSwitchEnabled()
|
|
7903
|
+
? 'auto_disabled'
|
|
7904
|
+
: 'disabled',
|
|
7905
|
+
};
|
|
7906
|
+
}
|
|
7301
7907
|
shouldExposeToolInterface() {
|
|
7302
7908
|
return true;
|
|
7303
7909
|
}
|
|
@@ -7333,7 +7939,7 @@ class Agent {
|
|
|
7333
7939
|
return null;
|
|
7334
7940
|
}
|
|
7335
7941
|
if (observedDeployment)
|
|
7336
|
-
this.
|
|
7942
|
+
this.routeController.recordEndpointFailure(observedDeployment, observedFailure);
|
|
7337
7943
|
if (this.model === 'auto') {
|
|
7338
7944
|
const current = this.activeDeployment();
|
|
7339
7945
|
if (!current || !this.lastRouteDecision)
|
|
@@ -7351,7 +7957,7 @@ class Agent {
|
|
|
7351
7957
|
currentAttempt.durationMs = Math.max(0, Date.now() - this.routeAttemptStartedAt);
|
|
7352
7958
|
}
|
|
7353
7959
|
if (!this.pendingAutoAttempts.length) {
|
|
7354
|
-
this.pendingAutoAttempts = this.
|
|
7960
|
+
this.pendingAutoAttempts = this.routeController.planAttempts(this.lastRouteDecision, this.autoRouteCandidates(), {
|
|
7355
7961
|
error: failure,
|
|
7356
7962
|
streamCommitted: this.routeStreamCommitted,
|
|
7357
7963
|
sideEffectCommitted: this.routeSideEffectCommitted,
|
|
@@ -7371,7 +7977,7 @@ class Agent {
|
|
|
7371
7977
|
this.lastRouteDecision.attempts.push({ ...next });
|
|
7372
7978
|
this.lastRouteDecision.resolvedDeployment = { ...next.deployment };
|
|
7373
7979
|
this.lastRouteDecision.finalStatus = 'retrying';
|
|
7374
|
-
this.
|
|
7980
|
+
this.routeController.claimEndpointAttempt(next.deployment);
|
|
7375
7981
|
this.routeAttemptStartedAt = Date.now();
|
|
7376
7982
|
this.persistRouteDecision(this.lastRouteDecision);
|
|
7377
7983
|
return current.modelId;
|
|
@@ -7389,8 +7995,9 @@ class Agent {
|
|
|
7389
7995
|
const usable = all.filter(m => !modelConfigIsUnavailable(m) && !this.failedDeploymentsThisRun.has(deploymentIdentity(this.deploymentRef(m))));
|
|
7390
7996
|
if (!usable.length)
|
|
7391
7997
|
return null;
|
|
7392
|
-
|
|
7393
|
-
|
|
7998
|
+
// Operational recovery is independent of the user-facing Auto tendency;
|
|
7999
|
+
// that tendency is supplied only to the current-model JEV decision.
|
|
8000
|
+
const ranked = [...usable].sort((a, b) => this.modelScore(b, 'balanced', false, false) - this.modelScore(a, 'balanced', false, false));
|
|
7394
8001
|
const next = ranked[0];
|
|
7395
8002
|
if (!next?.name)
|
|
7396
8003
|
return null;
|
|
@@ -8322,8 +8929,14 @@ class Agent {
|
|
|
8322
8929
|
stopTotalTimer();
|
|
8323
8930
|
this.processDepth = Math.max(0, this.processDepth - 1);
|
|
8324
8931
|
if (this.processDepth === 0) {
|
|
8932
|
+
// Kernel events coalesce intermediate writes to avoid rewriting the
|
|
8933
|
+
// workspace document on every token. A completed Agent turn is a
|
|
8934
|
+
// durability boundary across GUI, TUI, and one-shot CLI runtimes; do
|
|
8935
|
+
// the terminal write here so callers can safely read or exit as soon
|
|
8936
|
+
// as process() resolves.
|
|
8937
|
+
this.flushConversationState();
|
|
8325
8938
|
if (this.routeTransactionId)
|
|
8326
|
-
this.
|
|
8939
|
+
this.routeController.endTransaction(this.routeTransactionId);
|
|
8327
8940
|
this.routeTransactionId = '';
|
|
8328
8941
|
this.processingConversationId = null;
|
|
8329
8942
|
this.activeProcessAbortController = null;
|