newmark-agent 0.6.3 → 0.6.7
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 +53 -2
- package/dist/cli-discovery.js +3 -0
- package/dist/conversation-utility-host.bundle.cjs +3046 -1038
- package/dist/conversation-utility-host.js +94 -7
- package/dist/core/agent.d.ts +149 -12
- package/dist/core/agent.js +843 -176
- package/dist/core/agentKernelRunner.d.ts +7 -1
- package/dist/core/agentKernelRunner.js +171 -22
- package/dist/core/autoRouter.d.ts +49 -44
- package/dist/core/autoRouter.js +117 -302
- package/dist/core/branchIdentity.d.ts +29 -0
- package/dist/core/branchIdentity.js +54 -0
- package/dist/core/config.js +27 -11
- package/dist/core/continuation/contracts.d.ts +1 -1
- package/dist/core/continuation/store.d.ts +119 -1
- package/dist/core/continuation/store.js +337 -19
- package/dist/core/conversationKernel.d.ts +62 -8
- package/dist/core/conversationKernel.js +361 -47
- 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/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/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 +108 -31
- package/dist/preload.js +3 -1
- package/dist/server.js +30 -18
- package/dist/tools/computerUse.d.ts +4 -0
- package/dist/tools/computerUse.js +202 -4
- package/dist/tools/computerUsePowerShellHost.d.ts +6 -1
- package/dist/tools/computerUsePowerShellHost.js +105 -31
- package/dist/tools/index.js +4 -1
- 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 +994 -277
- package/dist/ui/lucide-sprite.svg +0 -8
- package/dist/wsl-agent-host.bundle.cjs +2851 -1013
- package/dist/wsl-agent-host.js +0 -3
- package/package.json +38 -9
package/dist/core/config.js
CHANGED
|
@@ -90,9 +90,10 @@ class ConfigManager {
|
|
|
90
90
|
}
|
|
91
91
|
const providerIdsMigrated = migrateProviderIdsInConfig(normalized);
|
|
92
92
|
const marqueeConfigRemoved = removeDeprecatedMarqueeConfig(normalized);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
93
|
+
const legacyJevRuntimeRemoved = removeLegacyJevDecisionRuntimeConfig(normalized);
|
|
94
|
+
if (providerIdsMigrated || marqueeConfigRemoved || legacyJevRuntimeRemoved) {
|
|
95
|
+
// Persist migrations immediately so obsolete JEV slot settings are not
|
|
96
|
+
// left in the active user config until an unrelated settings save.
|
|
96
97
|
try {
|
|
97
98
|
if (!this.readOnly)
|
|
98
99
|
fs.writeFileSync(cp, JSON.stringify(normalized, null, 2), 'utf-8');
|
|
@@ -356,13 +357,7 @@ class ConfigManager {
|
|
|
356
357
|
autoSwitchEnabled() { return this.getBool('models', 'auto_switch'); }
|
|
357
358
|
autoSwitchPreference() {
|
|
358
359
|
const value = this.getStr('models', 'auto_switch_preference');
|
|
359
|
-
|
|
360
|
-
return 'quality';
|
|
361
|
-
if (value === 'cheap_save')
|
|
362
|
-
return 'cost';
|
|
363
|
-
if (value === 'speed')
|
|
364
|
-
return 'speed';
|
|
365
|
-
return value === 'quality' || value === 'cost' || value === 'balanced' ? value : 'balanced';
|
|
360
|
+
return value === 'conservative' || value === 'balanced' || value === 'aggressive' ? value : 'balanced';
|
|
366
361
|
}
|
|
367
362
|
autoSwitchScope() { return this.getStr('models', 'auto_switch_scope') || 'all'; }
|
|
368
363
|
autoSwitchAnchorProvider() { return this.getStr('models', 'auto_switch_anchor_provider'); }
|
|
@@ -478,6 +473,25 @@ function removeDeprecatedMarqueeConfig(config) {
|
|
|
478
473
|
}
|
|
479
474
|
return changed;
|
|
480
475
|
}
|
|
476
|
+
function removeLegacyJevDecisionRuntimeConfig(config) {
|
|
477
|
+
const models = config.models;
|
|
478
|
+
if (!models)
|
|
479
|
+
return false;
|
|
480
|
+
const retiredKeys = [
|
|
481
|
+
'jev_runtime', 'jev_model_id', 'jev_model_path',
|
|
482
|
+
'jev_fallback_strategy', 'jev_fallback_provider_id', 'jev_fallback_model_id',
|
|
483
|
+
];
|
|
484
|
+
const hadRetiredInterface = retiredKeys.some(key => Object.prototype.hasOwnProperty.call(models, key));
|
|
485
|
+
if (!hadRetiredInterface)
|
|
486
|
+
return false;
|
|
487
|
+
const legacyTimeout = models.jev_timeout_ms?.value;
|
|
488
|
+
if (legacyTimeout === 100 && models.jev_timeout_ms) {
|
|
489
|
+
models.jev_timeout_ms.value = 3000;
|
|
490
|
+
}
|
|
491
|
+
for (const key of retiredKeys)
|
|
492
|
+
delete models[key];
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
481
495
|
function isConfigEntry(value) {
|
|
482
496
|
return !!value && typeof value === 'object' && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, 'value');
|
|
483
497
|
}
|
|
@@ -879,7 +893,7 @@ function defaultConfig() {
|
|
|
879
893
|
default_intelligence: { _description: "Default reasoning effort", _type: "choice", _values: ["low", "medium", "high", "xhigh", "max", "ultra"], value: "medium" },
|
|
880
894
|
agent_engine: { _description: "Agent engine", _type: "choice", _values: ["builtin", "codex", "opencode"], value: "builtin" },
|
|
881
895
|
auto_switch: { _description: "Auto-switch models", _type: "boolean", value: false },
|
|
882
|
-
auto_switch_preference: { _description: "Auto
|
|
896
|
+
auto_switch_preference: { _description: "Qualitative tendency for current-model Auto decisions", _type: "choice", _values: ["conservative", "balanced", "aggressive"], value: "balanced" },
|
|
883
897
|
auto_switch_scope: { _description: "Auto-switch scope", _type: "choice", _values: ["all", "provider"], value: "all" },
|
|
884
898
|
auto_switch_anchor_provider: { _description: "Stable provider id anchor for provider-scoped Auto routing", _type: "string", value: "" },
|
|
885
899
|
auto_switch_subset: { _description: "Explicit deployment allowlist for Auto; new catalog models are never added automatically", _type: "array", value: [] },
|
|
@@ -888,6 +902,8 @@ function defaultConfig() {
|
|
|
888
902
|
auto_data_region: { _description: "Required Auto data region; empty disables the region filter", _type: "string", value: "" },
|
|
889
903
|
auto_required_protocol_parameters: { _description: "Protocol parameters every Auto candidate must support", _type: "array", value: [] },
|
|
890
904
|
auto_max_expected_cost_usd: { _description: "Hard expected request cost ceiling; 0 disables the ceiling", _type: "number", value: 0 },
|
|
905
|
+
jev_enabled: { _description: "Use the current eligible conversation model for strict-JSON Auto decisions", _type: "boolean", value: true },
|
|
906
|
+
jev_timeout_ms: { _description: "Hard timeout for one current-model JEV decision in milliseconds", _type: "number", value: 3000 },
|
|
891
907
|
fallback_on_unavailable: { _description: "Fallback when model unavailable", _type: "boolean", value: true },
|
|
892
908
|
openai_api_mode: { _description: "OpenAI-compatible API mode", _type: "choice", _values: ["chat_stream", "chat", "responses"], value: "chat_stream" },
|
|
893
909
|
openai_streaming: { _description: "Legacy streaming flag for OpenAI-compatible chat completions", _type: "boolean", value: true },
|
|
@@ -146,7 +146,7 @@ export interface FinalRecord {
|
|
|
146
146
|
export interface ContinuationEvent {
|
|
147
147
|
eventId: string;
|
|
148
148
|
cursor: number;
|
|
149
|
-
type: 'BuildAccepted' | 'AttemptStarted' | 'AttemptHeartbeat' | 'BuildCommitted' | 'BuildFailed' | 'BuildCancelled' | 'BranchForked' | 'BranchPaused' | 'BranchResumed' | 'StaleWriteRejected';
|
|
149
|
+
type: 'BuildAccepted' | 'AttemptStarted' | 'AttemptHeartbeat' | 'BuildCommitted' | 'BuildFailed' | 'BuildCancelled' | 'BranchForked' | 'BranchPaused' | 'BranchResumed' | 'BuildQueueRepaired' | 'AttemptOrphanRecovered' | 'StaleWriteRejected';
|
|
150
150
|
workspaceId: WorkspaceId;
|
|
151
151
|
rootId: RootId;
|
|
152
152
|
branchId: BranchId;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BranchId, BranchRecord, BuildId, BuildInput, BuildRecord, CommandReceipt, ContinuationEvent, EnqueueBuildCommand, ExecutionScope, FinalRecord, QueueSnapshot, RootId, SnapshotId } from './contracts';
|
|
1
|
+
import { AttemptId, BranchId, BranchRecord, BuildId, BuildInput, BuildRecord, CommandReceipt, ContinuationEvent, EnqueueBuildCommand, ExecutionScope, FinalRecord, QueueSnapshot, RootId, SnapshotId } from './contracts';
|
|
2
2
|
export interface GuardedContinuationStoreOptions {
|
|
3
3
|
/** Lease duration for one attempt. */
|
|
4
4
|
leaseMs?: number;
|
|
@@ -25,6 +25,8 @@ export declare class GuardedContinuationStore {
|
|
|
25
25
|
private cached;
|
|
26
26
|
constructor(workspacePath: string, workspaceId: string, options?: GuardedContinuationStoreOptions);
|
|
27
27
|
get filePath(): string;
|
|
28
|
+
/** How often a live owner must renew its lease (one third of the lease). */
|
|
29
|
+
get leaseRenewalMs(): number;
|
|
28
30
|
ensureRoot(rootId: RootId, defaultBranchId: BranchId): void;
|
|
29
31
|
ensureBranch(input: {
|
|
30
32
|
rootId: RootId;
|
|
@@ -47,6 +49,62 @@ export declare class GuardedContinuationStore {
|
|
|
47
49
|
buildId: BuildId;
|
|
48
50
|
input: BuildInput;
|
|
49
51
|
}): BuildRecord;
|
|
52
|
+
/**
|
|
53
|
+
* dev-0.6.4 hotfix: 当某个被受理的 Build 失败/取消后,它的后继会永远无法被
|
|
54
|
+
* claim(`claimBuild` 只认「父 = 已提交 frontier」),整条队列就此卡死,新提交
|
|
55
|
+
* 也只是接在失败链后面。
|
|
56
|
+
*
|
|
57
|
+
* 这里提供一条**显式**修复通道:把被阻断的排队 Build 重新挂到当前已提交的
|
|
58
|
+
* frontier 上,保持原有队列顺序,并为每一次改挂写入 `BuildQueueRepaired`
|
|
59
|
+
* 审计事件(记录旧 parent / 新 parent / 原因)。它不是静默 rebase:调用方是
|
|
60
|
+
* 用户的显式动作(恢复队列)或一次新的入队命令。
|
|
61
|
+
*/
|
|
62
|
+
repairBlockedQueue(input: {
|
|
63
|
+
workspaceId: string;
|
|
64
|
+
rootId: RootId;
|
|
65
|
+
branchId: BranchId;
|
|
66
|
+
reason?: string;
|
|
67
|
+
}): {
|
|
68
|
+
repaired: BuildId[];
|
|
69
|
+
frontier: BuildId | null;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Re-anchor the QUEUED chain onto the committed frontier, in queue order.
|
|
73
|
+
*
|
|
74
|
+
* Used by the explicit "repair blocked queue" action *and* by orphan recovery:
|
|
75
|
+
* a crashed attempt is returned to QUEUED, so the rows behind it must chain
|
|
76
|
+
* back onto it, otherwise the first row runs and the rest stay derived-blocked
|
|
77
|
+
* (the real workspace showed exactly that: seq3 kept a stale
|
|
78
|
+
* `DEPENDENCY_FAILED` because its parent was re-anchored to the old frontier).
|
|
79
|
+
*/
|
|
80
|
+
private reanchorQueuedChainUnlocked;
|
|
81
|
+
/**
|
|
82
|
+
* dev-0.6.4 hotfix: 读取视角的受阻推导。
|
|
83
|
+
*
|
|
84
|
+
* 一个 QUEUED Build 只有在它的父正好是「已提交 frontier」或队列里位于它之前
|
|
85
|
+
* 的可执行 Build 时才可能被 claim(见 `claimBuild`)。这里按队列顺序推导哪些行
|
|
86
|
+
* 目前不可执行:父不是链上前驱、或父已 FAILED/CANCELLED 的行即为受阻,且受阻点
|
|
87
|
+
* 之后的所有行同样受阻。
|
|
88
|
+
*
|
|
89
|
+
* dev-0.6.6 hotfix: 链上前驱必须包含 **正在执行(RUNNING)** 的 Build。已受理但
|
|
90
|
+
* 尚未提交的执行只把 `branch.headBuildId` 留在上一个已提交 Build 上,所以只按
|
|
91
|
+
* QUEUED 行走链会把「正在跑的这一轮」当成断链:紧随其后的正常排队行会被误判为
|
|
92
|
+
* 「被失败前置阻断」,而且后面每一行都会跟着被标记。RUNNING 行执行完会提交为
|
|
93
|
+
* frontier,因此它才是下一行的真实前驱。
|
|
94
|
+
*/
|
|
95
|
+
private blockedQueuedBuildIds;
|
|
96
|
+
/**
|
|
97
|
+
* dev-0.6.6: drop persisted `waitingReason` markers that the current chain no
|
|
98
|
+
* longer justifies.
|
|
99
|
+
*
|
|
100
|
+
* `markDependentsBlocked()` stamps the direct successor of a failed build, and
|
|
101
|
+
* a later repair/recovery can re-anchor that row so the same marker is simply
|
|
102
|
+
* stale. Leaving it behind keeps a healthy queue row painted as "blocked by a
|
|
103
|
+
* failed predecessor" forever (real workspace: seq3 kept the marker after two
|
|
104
|
+
* explicit repairs). The derived blocked set is authoritative, so any marker
|
|
105
|
+
* on a row it does not list is by definition stale.
|
|
106
|
+
*/
|
|
107
|
+
private clearStaleBlockedMarkers;
|
|
50
108
|
deleteQueuedBuild(input: {
|
|
51
109
|
workspaceId: string;
|
|
52
110
|
rootId: RootId;
|
|
@@ -149,9 +207,67 @@ export declare class GuardedContinuationStore {
|
|
|
149
207
|
rootId: RootId;
|
|
150
208
|
branchId: BranchId;
|
|
151
209
|
}): QueueSnapshot;
|
|
210
|
+
/**
|
|
211
|
+
* dev-0.6.6: queue projections only need "which queued rows cannot run yet,
|
|
212
|
+
* and why". Building the full QueueSnapshot for that (every build of the
|
|
213
|
+
* conversation, each with a deep-cloned input) made every state read cost
|
|
214
|
+
* more as the conversation grew. This read-only view touches the queued rows
|
|
215
|
+
* only and never clones anything.
|
|
216
|
+
*/
|
|
217
|
+
queuedBlockedReasons(input: {
|
|
218
|
+
workspaceId: string;
|
|
219
|
+
rootId: RootId;
|
|
220
|
+
branchId: BranchId;
|
|
221
|
+
}): Record<BuildId, string>;
|
|
222
|
+
/**
|
|
223
|
+
* dev-0.6.6: read-only view of the branch guard so callers can decide whether
|
|
224
|
+
* an in-flight attempt is an orphan (dead owner / long-expired lease) without
|
|
225
|
+
* paying for a ledger write on every projection.
|
|
226
|
+
*/
|
|
227
|
+
activeAttempt(input: {
|
|
228
|
+
workspaceId: string;
|
|
229
|
+
rootId: RootId;
|
|
230
|
+
branchId: BranchId;
|
|
231
|
+
}): {
|
|
232
|
+
buildId: BuildId;
|
|
233
|
+
attemptId: AttemptId | '';
|
|
234
|
+
fence: number;
|
|
235
|
+
status: string;
|
|
236
|
+
workerCapability: string;
|
|
237
|
+
leaseUntil: number;
|
|
238
|
+
} | null;
|
|
152
239
|
private snapshotUnlocked;
|
|
153
240
|
events(afterCursor?: number, limit?: number): ContinuationEvent[];
|
|
154
241
|
recoverExpiredLeases(now?: number): number;
|
|
242
|
+
/**
|
|
243
|
+
* dev-0.6.6: 回收「孤儿 attempt」。
|
|
244
|
+
*
|
|
245
|
+
* 真实现场:持有账本 attempt 的 utility host 进程被 V8 OOM 直接杀死
|
|
246
|
+
* (`utility_runtime_unexpected_exit`,exit 4294930435,3GB 堆耗尽)。崩溃者
|
|
247
|
+
* 不可能再写终态,于是 build 永远停在 RUNNING、guard 永远指向它:后续排队行既
|
|
248
|
+
* 不会执行,界面上也只是一直“计时”。崩溃不可怕,把崩溃后的账本留在不可恢复的
|
|
249
|
+
* 状态才可怕——这里允许任何存活读方在确认「所有者进程已死 / 租约早已过期 /
|
|
250
|
+
* guard 挂着的 attempt 已经终态」后回收它:attempt → EXPIRED、RUNNING build →
|
|
251
|
+
* QUEUED(保留原始输入,用户消息按 userMessageId 幂等)、guard 释放,并写入
|
|
252
|
+
* `AttemptOrphanRecovered` 审计事件。真实存活的所有者(PID 活着且心跳正常)不会
|
|
253
|
+
* 被抢占。
|
|
254
|
+
*/
|
|
255
|
+
recoverOrphanedAttempt(input: {
|
|
256
|
+
workspaceId: string;
|
|
257
|
+
rootId: RootId;
|
|
258
|
+
branchId: BranchId;
|
|
259
|
+
now?: number;
|
|
260
|
+
graceMs?: number;
|
|
261
|
+
reason?: string;
|
|
262
|
+
ownerAlive?: (workerCapability: string) => boolean;
|
|
263
|
+
}): {
|
|
264
|
+
recovered: boolean;
|
|
265
|
+
buildId?: BuildId;
|
|
266
|
+
attemptId?: AttemptId;
|
|
267
|
+
previousBuildStatus?: string;
|
|
268
|
+
ownerAlive?: boolean;
|
|
269
|
+
reason?: string;
|
|
270
|
+
};
|
|
155
271
|
private terminateAttempt;
|
|
156
272
|
private releaseExpiredGuard;
|
|
157
273
|
private markDependentsBlocked;
|
|
@@ -171,6 +287,8 @@ export declare class GuardedContinuationStore {
|
|
|
171
287
|
private acquireLock;
|
|
172
288
|
private readLedger;
|
|
173
289
|
private readLedgerUnlocked;
|
|
290
|
+
private normalizeLedgerShape;
|
|
291
|
+
private recoverFromBackup;
|
|
174
292
|
private writeLedgerUnlocked;
|
|
175
293
|
private ensureLedgerExists;
|
|
176
294
|
private invalidateCache;
|
|
@@ -67,7 +67,18 @@ class GuardedContinuationStore {
|
|
|
67
67
|
get filePath() {
|
|
68
68
|
return this.ledgerPath;
|
|
69
69
|
}
|
|
70
|
+
/** How often a live owner must renew its lease (one third of the lease). */
|
|
71
|
+
get leaseRenewalMs() {
|
|
72
|
+
return Math.max(1_000, Math.floor(this.leaseMs / 3));
|
|
73
|
+
}
|
|
70
74
|
ensureRoot(rootId, defaultBranchId) {
|
|
75
|
+
// Hot path: most ensureRoot() calls come from read-only projections
|
|
76
|
+
// (`queueItems`, snapshots, mobile state reads) for a root/branch that
|
|
77
|
+
// already exists. Rewriting the whole ledger for those turns every state
|
|
78
|
+
// read into a write and makes a long conversation progressively slower.
|
|
79
|
+
const current = this.readLedger();
|
|
80
|
+
if (current.roots[rootId] && current.branches[defaultBranchId])
|
|
81
|
+
return;
|
|
71
82
|
this.mutate(ledger => {
|
|
72
83
|
if (!ledger.roots[rootId]) {
|
|
73
84
|
ledger.roots[rootId] = {
|
|
@@ -81,6 +92,10 @@ class GuardedContinuationStore {
|
|
|
81
92
|
});
|
|
82
93
|
}
|
|
83
94
|
ensureBranch(input) {
|
|
95
|
+
const current = this.readLedger();
|
|
96
|
+
const existing = current.branches[input.branchId];
|
|
97
|
+
if (existing)
|
|
98
|
+
return { ...existing };
|
|
84
99
|
return this.mutate(ledger => this.ensureBranchRecord(ledger, input));
|
|
85
100
|
}
|
|
86
101
|
getReceipt(actorId, commandId) {
|
|
@@ -271,6 +286,137 @@ class GuardedContinuationStore {
|
|
|
271
286
|
return { ...build, input: cloneInput(build.input) };
|
|
272
287
|
});
|
|
273
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* dev-0.6.4 hotfix: 当某个被受理的 Build 失败/取消后,它的后继会永远无法被
|
|
291
|
+
* claim(`claimBuild` 只认「父 = 已提交 frontier」),整条队列就此卡死,新提交
|
|
292
|
+
* 也只是接在失败链后面。
|
|
293
|
+
*
|
|
294
|
+
* 这里提供一条**显式**修复通道:把被阻断的排队 Build 重新挂到当前已提交的
|
|
295
|
+
* frontier 上,保持原有队列顺序,并为每一次改挂写入 `BuildQueueRepaired`
|
|
296
|
+
* 审计事件(记录旧 parent / 新 parent / 原因)。它不是静默 rebase:调用方是
|
|
297
|
+
* 用户的显式动作(恢复队列)或一次新的入队命令。
|
|
298
|
+
*/
|
|
299
|
+
repairBlockedQueue(input) {
|
|
300
|
+
return this.mutate(ledger => {
|
|
301
|
+
const branch = this.requireBranch(ledger, input);
|
|
302
|
+
const frontier = branch.headBuildId || null;
|
|
303
|
+
const outcome = this.reanchorQueuedChainUnlocked(ledger, branch, String(input.reason || 'committed-frontier-repair'));
|
|
304
|
+
return { repaired: outcome.rebased, frontier };
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Re-anchor the QUEUED chain onto the committed frontier, in queue order.
|
|
309
|
+
*
|
|
310
|
+
* Used by the explicit "repair blocked queue" action *and* by orphan recovery:
|
|
311
|
+
* a crashed attempt is returned to QUEUED, so the rows behind it must chain
|
|
312
|
+
* back onto it, otherwise the first row runs and the rest stay derived-blocked
|
|
313
|
+
* (the real workspace showed exactly that: seq3 kept a stale
|
|
314
|
+
* `DEPENDENCY_FAILED` because its parent was re-anchored to the old frontier).
|
|
315
|
+
*/
|
|
316
|
+
reanchorQueuedChainUnlocked(ledger, branch, reason) {
|
|
317
|
+
const queued = Object.values(ledger.builds)
|
|
318
|
+
.filter(build => build.branchId === branch.branchId && build.status === 'QUEUED')
|
|
319
|
+
.sort((a, b) => a.queueSequence - b.queueSequence);
|
|
320
|
+
const rebased = [];
|
|
321
|
+
let chainParent = branch.headBuildId || null;
|
|
322
|
+
for (const build of queued) {
|
|
323
|
+
if ((build.parentBuildId || null) !== (chainParent || null)) {
|
|
324
|
+
const previousParentBuildId = build.parentBuildId;
|
|
325
|
+
build.parentBuildId = chainParent;
|
|
326
|
+
build.waitingReason = undefined;
|
|
327
|
+
build.updatedAt = new Date().toISOString();
|
|
328
|
+
rebased.push(build.buildId);
|
|
329
|
+
this.appendEvent(ledger, {
|
|
330
|
+
type: 'BuildQueueRepaired',
|
|
331
|
+
workspaceId: this.workspaceId,
|
|
332
|
+
rootId: branch.rootId,
|
|
333
|
+
branchId: branch.branchId,
|
|
334
|
+
buildId: build.buildId,
|
|
335
|
+
attemptId: null,
|
|
336
|
+
fence: null,
|
|
337
|
+
payload: { previousParentBuildId, newParentBuildId: chainParent, reason },
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
chainParent = build.buildId;
|
|
341
|
+
}
|
|
342
|
+
// A row whose parent already matches the chain can still carry a fail-time
|
|
343
|
+
// marker from an earlier shape; the derived blocked set is authoritative.
|
|
344
|
+
const clearedMarkers = this.clearStaleBlockedMarkers(ledger, branch);
|
|
345
|
+
if (rebased.length)
|
|
346
|
+
branch.queueRevision += 1;
|
|
347
|
+
if (rebased.length || clearedMarkers) {
|
|
348
|
+
branch.lifecycleRevision += 1;
|
|
349
|
+
branch.updatedAt = new Date().toISOString();
|
|
350
|
+
}
|
|
351
|
+
return { rebased, clearedMarkers };
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* dev-0.6.4 hotfix: 读取视角的受阻推导。
|
|
355
|
+
*
|
|
356
|
+
* 一个 QUEUED Build 只有在它的父正好是「已提交 frontier」或队列里位于它之前
|
|
357
|
+
* 的可执行 Build 时才可能被 claim(见 `claimBuild`)。这里按队列顺序推导哪些行
|
|
358
|
+
* 目前不可执行:父不是链上前驱、或父已 FAILED/CANCELLED 的行即为受阻,且受阻点
|
|
359
|
+
* 之后的所有行同样受阻。
|
|
360
|
+
*
|
|
361
|
+
* dev-0.6.6 hotfix: 链上前驱必须包含 **正在执行(RUNNING)** 的 Build。已受理但
|
|
362
|
+
* 尚未提交的执行只把 `branch.headBuildId` 留在上一个已提交 Build 上,所以只按
|
|
363
|
+
* QUEUED 行走链会把「正在跑的这一轮」当成断链:紧随其后的正常排队行会被误判为
|
|
364
|
+
* 「被失败前置阻断」,而且后面每一行都会跟着被标记。RUNNING 行执行完会提交为
|
|
365
|
+
* frontier,因此它才是下一行的真实前驱。
|
|
366
|
+
*/
|
|
367
|
+
blockedQueuedBuildIds(ledger, branch) {
|
|
368
|
+
const blocked = new Set();
|
|
369
|
+
const chain = Object.values(ledger.builds)
|
|
370
|
+
.filter(build => build.branchId === branch.branchId
|
|
371
|
+
&& (build.status === 'QUEUED' || build.status === 'RUNNING'))
|
|
372
|
+
.sort((a, b) => a.queueSequence - b.queueSequence);
|
|
373
|
+
let chainParent = branch.headBuildId || null;
|
|
374
|
+
for (const build of chain) {
|
|
375
|
+
const parent = build.parentBuildId ? ledger.builds[build.parentBuildId] : null;
|
|
376
|
+
// 链上的前驱(frontier、正在执行的行、或队列里位于它之前的行)在执行时都会
|
|
377
|
+
// 提交,因此只有「父不是链上前驱」或「父已 FAILED/CANCELLED」的排队行才是
|
|
378
|
+
// 真正受阻的。
|
|
379
|
+
const parentIsChainPredecessor = (build.parentBuildId || null) === (chainParent || null);
|
|
380
|
+
const parentFailed = !!parent && (parent.status === 'FAILED' || parent.status === 'CANCELLED');
|
|
381
|
+
if (!parentIsChainPredecessor || parentFailed) {
|
|
382
|
+
// 正在执行的行已经拿到执行权,它的「受阻」只可能是账本损坏,不应该反过来
|
|
383
|
+
// 让它在界面和队列投影里变成一个可修复的等待行。
|
|
384
|
+
if (build.status === 'QUEUED')
|
|
385
|
+
blocked.add(build.buildId);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
chainParent = build.buildId;
|
|
389
|
+
}
|
|
390
|
+
return blocked;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* dev-0.6.6: drop persisted `waitingReason` markers that the current chain no
|
|
394
|
+
* longer justifies.
|
|
395
|
+
*
|
|
396
|
+
* `markDependentsBlocked()` stamps the direct successor of a failed build, and
|
|
397
|
+
* a later repair/recovery can re-anchor that row so the same marker is simply
|
|
398
|
+
* stale. Leaving it behind keeps a healthy queue row painted as "blocked by a
|
|
399
|
+
* failed predecessor" forever (real workspace: seq3 kept the marker after two
|
|
400
|
+
* explicit repairs). The derived blocked set is authoritative, so any marker
|
|
401
|
+
* on a row it does not list is by definition stale.
|
|
402
|
+
*/
|
|
403
|
+
clearStaleBlockedMarkers(ledger, branch) {
|
|
404
|
+
const derivedBlocked = this.blockedQueuedBuildIds(ledger, branch);
|
|
405
|
+
let cleared = 0;
|
|
406
|
+
const now = new Date().toISOString();
|
|
407
|
+
for (const build of Object.values(ledger.builds)) {
|
|
408
|
+
if (build.branchId !== branch.branchId || build.status !== 'QUEUED')
|
|
409
|
+
continue;
|
|
410
|
+
if (!build.waitingReason)
|
|
411
|
+
continue;
|
|
412
|
+
if (derivedBlocked.has(build.buildId))
|
|
413
|
+
continue;
|
|
414
|
+
build.waitingReason = undefined;
|
|
415
|
+
build.updatedAt = now;
|
|
416
|
+
cleared += 1;
|
|
417
|
+
}
|
|
418
|
+
return cleared;
|
|
419
|
+
}
|
|
274
420
|
deleteQueuedBuild(input) {
|
|
275
421
|
this.mutate(ledger => {
|
|
276
422
|
const build = ledger.builds[input.buildId];
|
|
@@ -316,8 +462,7 @@ class GuardedContinuationStore {
|
|
|
316
462
|
|| guard.activeAttemptId !== scope.attemptId
|
|
317
463
|
|| guard.fence !== scope.fence
|
|
318
464
|
|| attempt.fence !== scope.fence
|
|
319
|
-
|| attempt.status !== 'RUNNING'
|
|
320
|
-
|| guard.leaseUntil <= now) {
|
|
465
|
+
|| attempt.status !== 'RUNNING') {
|
|
321
466
|
this.appendEvent(ledger, {
|
|
322
467
|
type: 'StaleWriteRejected',
|
|
323
468
|
workspaceId: this.workspaceId,
|
|
@@ -365,8 +510,7 @@ class GuardedContinuationStore {
|
|
|
365
510
|
|| guard.activeAttemptId !== scope.attemptId
|
|
366
511
|
|| guard.fence !== scope.fence
|
|
367
512
|
|| attempt.fence !== scope.fence
|
|
368
|
-
|| attempt.status !== 'RUNNING'
|
|
369
|
-
|| guard.leaseUntil <= now) {
|
|
513
|
+
|| attempt.status !== 'RUNNING') {
|
|
370
514
|
this.appendEvent(ledger, {
|
|
371
515
|
type: 'StaleWriteRejected',
|
|
372
516
|
workspaceId: this.workspaceId,
|
|
@@ -375,7 +519,7 @@ class GuardedContinuationStore {
|
|
|
375
519
|
buildId: build.buildId,
|
|
376
520
|
attemptId: scope.attemptId,
|
|
377
521
|
fence: scope.fence,
|
|
378
|
-
payload: { operation: 'commit_final' },
|
|
522
|
+
payload: { operation: 'commit_final', leaseUntil: guard.leaseUntil },
|
|
379
523
|
});
|
|
380
524
|
throw new contracts_1.ContinuationError('STALE_EXECUTION', `Attempt ${scope.attemptId} no longer owns branch ${branch.branchId}`);
|
|
381
525
|
}
|
|
@@ -700,12 +844,62 @@ class GuardedContinuationStore {
|
|
|
700
844
|
const branch = this.requireBranch(ledger, input);
|
|
701
845
|
return this.snapshotUnlocked(ledger, branch);
|
|
702
846
|
}
|
|
847
|
+
/**
|
|
848
|
+
* dev-0.6.6: queue projections only need "which queued rows cannot run yet,
|
|
849
|
+
* and why". Building the full QueueSnapshot for that (every build of the
|
|
850
|
+
* conversation, each with a deep-cloned input) made every state read cost
|
|
851
|
+
* more as the conversation grew. This read-only view touches the queued rows
|
|
852
|
+
* only and never clones anything.
|
|
853
|
+
*/
|
|
854
|
+
queuedBlockedReasons(input) {
|
|
855
|
+
const ledger = this.readLedger();
|
|
856
|
+
const branch = this.requireBranch(ledger, input);
|
|
857
|
+
const reasons = {};
|
|
858
|
+
const blocked = this.blockedQueuedBuildIds(ledger, branch);
|
|
859
|
+
for (const build of Object.values(ledger.builds)) {
|
|
860
|
+
if (build.branchId !== branch.branchId || build.status !== 'QUEUED')
|
|
861
|
+
continue;
|
|
862
|
+
if (build.waitingReason)
|
|
863
|
+
reasons[build.buildId] = String(build.waitingReason);
|
|
864
|
+
else if (blocked.has(build.buildId))
|
|
865
|
+
reasons[build.buildId] = 'DEPENDENCY_FAILED';
|
|
866
|
+
}
|
|
867
|
+
return reasons;
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* dev-0.6.6: read-only view of the branch guard so callers can decide whether
|
|
871
|
+
* an in-flight attempt is an orphan (dead owner / long-expired lease) without
|
|
872
|
+
* paying for a ledger write on every projection.
|
|
873
|
+
*/
|
|
874
|
+
activeAttempt(input) {
|
|
875
|
+
const ledger = this.readLedger();
|
|
876
|
+
const branch = this.requireBranch(ledger, input);
|
|
877
|
+
const guard = ledger.guards[branch.branchId];
|
|
878
|
+
if (!guard || !guard.activeBuildId)
|
|
879
|
+
return null;
|
|
880
|
+
const attempt = guard.activeAttemptId ? ledger.attempts[guard.activeAttemptId] : undefined;
|
|
881
|
+
return {
|
|
882
|
+
buildId: guard.activeBuildId,
|
|
883
|
+
attemptId: guard.activeAttemptId || '',
|
|
884
|
+
fence: Number(guard.fence || 0),
|
|
885
|
+
status: String(attempt?.status || 'UNKNOWN'),
|
|
886
|
+
workerCapability: String(attempt?.workerCapability || guard.workerCapability || ''),
|
|
887
|
+
leaseUntil: Number(guard.leaseUntil || 0),
|
|
888
|
+
};
|
|
889
|
+
}
|
|
703
890
|
snapshotUnlocked(ledger, branch) {
|
|
704
891
|
const guard = ledger.guards[branch.branchId] || (0, contracts_1.defaultGuard)(branch.branchId);
|
|
892
|
+
// dev-0.6.4 hotfix: 受阻状态由父链推导,而不是只在失败瞬间给直系后继打标记。
|
|
893
|
+
// 这样“失败之后才入队”的行同样会显示为受阻,界面才能给出显式修复入口。
|
|
894
|
+
const blockedBuildIds = this.blockedQueuedBuildIds(ledger, branch);
|
|
705
895
|
const builds = Object.values(ledger.builds)
|
|
706
896
|
.filter(build => build.branchId === branch.branchId)
|
|
707
897
|
.sort((a, b) => a.queueSequence - b.queueSequence)
|
|
708
|
-
.map(build => ({
|
|
898
|
+
.map(build => ({
|
|
899
|
+
...build,
|
|
900
|
+
input: cloneInput(build.input),
|
|
901
|
+
waitingReason: build.waitingReason || (blockedBuildIds.has(build.buildId) ? 'DEPENDENCY_FAILED' : undefined),
|
|
902
|
+
}));
|
|
709
903
|
return {
|
|
710
904
|
workspaceId: this.workspaceId,
|
|
711
905
|
rootId: branch.rootId,
|
|
@@ -748,6 +942,89 @@ class GuardedContinuationStore {
|
|
|
748
942
|
return recovered;
|
|
749
943
|
});
|
|
750
944
|
}
|
|
945
|
+
/**
|
|
946
|
+
* dev-0.6.6: 回收「孤儿 attempt」。
|
|
947
|
+
*
|
|
948
|
+
* 真实现场:持有账本 attempt 的 utility host 进程被 V8 OOM 直接杀死
|
|
949
|
+
* (`utility_runtime_unexpected_exit`,exit 4294930435,3GB 堆耗尽)。崩溃者
|
|
950
|
+
* 不可能再写终态,于是 build 永远停在 RUNNING、guard 永远指向它:后续排队行既
|
|
951
|
+
* 不会执行,界面上也只是一直“计时”。崩溃不可怕,把崩溃后的账本留在不可恢复的
|
|
952
|
+
* 状态才可怕——这里允许任何存活读方在确认「所有者进程已死 / 租约早已过期 /
|
|
953
|
+
* guard 挂着的 attempt 已经终态」后回收它:attempt → EXPIRED、RUNNING build →
|
|
954
|
+
* QUEUED(保留原始输入,用户消息按 userMessageId 幂等)、guard 释放,并写入
|
|
955
|
+
* `AttemptOrphanRecovered` 审计事件。真实存活的所有者(PID 活着且心跳正常)不会
|
|
956
|
+
* 被抢占。
|
|
957
|
+
*/
|
|
958
|
+
recoverOrphanedAttempt(input) {
|
|
959
|
+
return this.mutate(ledger => {
|
|
960
|
+
const branch = this.requireBranch(ledger, input);
|
|
961
|
+
const guard = ledger.guards[branch.branchId];
|
|
962
|
+
if (!guard || !guard.activeBuildId)
|
|
963
|
+
return { recovered: false };
|
|
964
|
+
const build = ledger.builds[guard.activeBuildId];
|
|
965
|
+
const attempt = guard.activeAttemptId ? ledger.attempts[guard.activeAttemptId] : undefined;
|
|
966
|
+
const now = input.now ?? Date.now();
|
|
967
|
+
const capability = String(attempt?.workerCapability || guard.workerCapability || '');
|
|
968
|
+
const ownerAlive = input.ownerAlive ? input.ownerAlive(capability) === true : true;
|
|
969
|
+
const leaseUntil = Number(guard.leaseUntil || 0);
|
|
970
|
+
const graceMs = Math.max(0, Number(input.graceMs ?? 0));
|
|
971
|
+
const leaseLongExpired = leaseUntil > 0 && leaseUntil + graceMs <= now;
|
|
972
|
+
const attemptTerminal = !attempt || attempt.status !== 'RUNNING';
|
|
973
|
+
const buildTerminal = !build || ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(build.status);
|
|
974
|
+
// A dangling guard (attempt already terminal) and a dead owner are always
|
|
975
|
+
// recoverable; a live owner is only recovered once its lease is long gone.
|
|
976
|
+
if (!attemptTerminal && !buildTerminal && ownerAlive && !leaseLongExpired) {
|
|
977
|
+
return { recovered: false, buildId: build?.buildId, ownerAlive };
|
|
978
|
+
}
|
|
979
|
+
const previousBuildStatus = build ? build.status : undefined;
|
|
980
|
+
const reason = String(input.reason || (!ownerAlive ? 'owner-process-exited'
|
|
981
|
+
: leaseLongExpired ? 'lease-expired' : 'dangling-guard'));
|
|
982
|
+
if (attempt && attempt.status === 'RUNNING') {
|
|
983
|
+
attempt.status = 'EXPIRED';
|
|
984
|
+
attempt.endedAt = new Date(now).toISOString();
|
|
985
|
+
}
|
|
986
|
+
if (build && build.status === 'RUNNING') {
|
|
987
|
+
build.status = 'QUEUED';
|
|
988
|
+
build.waitingReason = undefined;
|
|
989
|
+
build.updatedAt = new Date(now).toISOString();
|
|
990
|
+
}
|
|
991
|
+
const recoveredBuildId = guard.activeBuildId;
|
|
992
|
+
const recoveredAttemptId = guard.activeAttemptId;
|
|
993
|
+
this.releaseGuard(ledger, branch.branchId, guard.fence);
|
|
994
|
+
// The reclaimed build returns to QUEUED, so the rows behind it have to be
|
|
995
|
+
// chained back onto it; otherwise the first row runs and the rest stay
|
|
996
|
+
// derived-blocked behind a predecessor that already moved.
|
|
997
|
+
const chain = this.reanchorQueuedChainUnlocked(ledger, branch, 'orphan-recovery-chain');
|
|
998
|
+
branch.lifecycleRevision += 1;
|
|
999
|
+
branch.updatedAt = new Date(now).toISOString();
|
|
1000
|
+
this.appendEvent(ledger, {
|
|
1001
|
+
type: 'AttemptOrphanRecovered',
|
|
1002
|
+
workspaceId: this.workspaceId,
|
|
1003
|
+
rootId: branch.rootId,
|
|
1004
|
+
branchId: branch.branchId,
|
|
1005
|
+
buildId: recoveredBuildId,
|
|
1006
|
+
attemptId: recoveredAttemptId,
|
|
1007
|
+
fence: guard.fence,
|
|
1008
|
+
payload: {
|
|
1009
|
+
reason,
|
|
1010
|
+
workerCapability: capability,
|
|
1011
|
+
ownerAlive,
|
|
1012
|
+
leaseUntil,
|
|
1013
|
+
previousBuildStatus,
|
|
1014
|
+
rebasedBuildIds: chain.rebased,
|
|
1015
|
+
clearedMarkers: chain.clearedMarkers,
|
|
1016
|
+
},
|
|
1017
|
+
});
|
|
1018
|
+
return {
|
|
1019
|
+
recovered: true,
|
|
1020
|
+
buildId: recoveredBuildId || undefined,
|
|
1021
|
+
attemptId: recoveredAttemptId || undefined,
|
|
1022
|
+
previousBuildStatus,
|
|
1023
|
+
ownerAlive,
|
|
1024
|
+
reason,
|
|
1025
|
+
};
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
751
1028
|
terminateAttempt(scope, status, reason, now = Date.now()) {
|
|
752
1029
|
this.mutate(ledger => {
|
|
753
1030
|
const branch = this.requireBranch(ledger, scope);
|
|
@@ -938,29 +1215,70 @@ class GuardedContinuationStore {
|
|
|
938
1215
|
this.ensureLedgerExists();
|
|
939
1216
|
try {
|
|
940
1217
|
const raw = fs.readFileSync(this.ledgerPath, 'utf-8').replace(/^\uFEFF/, '');
|
|
941
|
-
|
|
942
|
-
if (!parsed || parsed.version !== 1 || !parsed.branches || !parsed.builds) {
|
|
943
|
-
throw new contracts_1.ContinuationError('EFFECT_UNCERTAIN', `Continuation ledger ${this.ledgerPath} has an unsupported shape`);
|
|
944
|
-
}
|
|
945
|
-
parsed.attempts = parsed.attempts || {};
|
|
946
|
-
parsed.guards = parsed.guards || {};
|
|
947
|
-
parsed.commands = parsed.commands || {};
|
|
948
|
-
parsed.finals = parsed.finals || {};
|
|
949
|
-
parsed.events = Array.isArray(parsed.events) ? parsed.events : [];
|
|
950
|
-
parsed.nextEventCursor = Math.max(1, Number(parsed.nextEventCursor) || (parsed.events.at(-1)?.cursor || 0) + 1);
|
|
951
|
-
parsed.nextQueueSequence = Math.max(1, Number(parsed.nextQueueSequence) || (Object.values(parsed.builds).reduce((max, build) => Math.max(max, build.queueSequence || 0), 0) + 1));
|
|
952
|
-
return parsed;
|
|
1218
|
+
return this.normalizeLedgerShape(JSON.parse(raw));
|
|
953
1219
|
}
|
|
954
1220
|
catch (error) {
|
|
955
1221
|
if (error instanceof contracts_1.ContinuationError)
|
|
956
1222
|
throw error;
|
|
1223
|
+
// dev-0.6.6: a truncated/corrupt ledger used to make the whole workspace
|
|
1224
|
+
// queue unusable (every command threw EFFECT_UNCERTAIN). The previous
|
|
1225
|
+
// good ledger is kept next to it, so recover from it instead of failing
|
|
1226
|
+
// closed: durable state must survive a half-written file.
|
|
1227
|
+
const recovered = this.recoverFromBackup();
|
|
1228
|
+
if (recovered)
|
|
1229
|
+
return recovered;
|
|
957
1230
|
throw new contracts_1.ContinuationError('EFFECT_UNCERTAIN', `Continuation ledger ${this.ledgerPath} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
958
1231
|
}
|
|
959
1232
|
}
|
|
1233
|
+
normalizeLedgerShape(parsed) {
|
|
1234
|
+
if (!parsed || parsed.version !== 1 || !parsed.branches || !parsed.builds) {
|
|
1235
|
+
throw new contracts_1.ContinuationError('EFFECT_UNCERTAIN', `Continuation ledger ${this.ledgerPath} has an unsupported shape`);
|
|
1236
|
+
}
|
|
1237
|
+
parsed.attempts = parsed.attempts || {};
|
|
1238
|
+
parsed.guards = parsed.guards || {};
|
|
1239
|
+
parsed.commands = parsed.commands || {};
|
|
1240
|
+
parsed.finals = parsed.finals || {};
|
|
1241
|
+
parsed.events = Array.isArray(parsed.events) ? parsed.events : [];
|
|
1242
|
+
parsed.nextEventCursor = Math.max(1, Number(parsed.nextEventCursor) || (parsed.events.at(-1)?.cursor || 0) + 1);
|
|
1243
|
+
parsed.nextQueueSequence = Math.max(1, Number(parsed.nextQueueSequence) || (Object.values(parsed.builds).reduce((max, build) => Math.max(max, build.queueSequence || 0), 0) + 1));
|
|
1244
|
+
return parsed;
|
|
1245
|
+
}
|
|
1246
|
+
recoverFromBackup() {
|
|
1247
|
+
const backupPath = `${this.ledgerPath}.bak`;
|
|
1248
|
+
if (!fs.existsSync(backupPath))
|
|
1249
|
+
return null;
|
|
1250
|
+
try {
|
|
1251
|
+
const raw = fs.readFileSync(backupPath, 'utf-8').replace(/^\uFEFF/, '');
|
|
1252
|
+
const parsed = this.normalizeLedgerShape(JSON.parse(raw));
|
|
1253
|
+
// Restore the recovered ledger as the authoritative file, keeping the
|
|
1254
|
+
// unreadable source for post-mortem instead of silently deleting it.
|
|
1255
|
+
try {
|
|
1256
|
+
fs.copyFileSync(this.ledgerPath, `${this.ledgerPath}.corrupt`);
|
|
1257
|
+
}
|
|
1258
|
+
catch { /* best effort */ }
|
|
1259
|
+
const temp = `${this.ledgerPath}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`;
|
|
1260
|
+
fs.writeFileSync(temp, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8');
|
|
1261
|
+
fs.renameSync(temp, this.ledgerPath);
|
|
1262
|
+
this.invalidateCache();
|
|
1263
|
+
console.warn(`[continuation-ledger-recovered] ${this.ledgerPath} restored from ${backupPath}`);
|
|
1264
|
+
return parsed;
|
|
1265
|
+
}
|
|
1266
|
+
catch {
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
960
1270
|
writeLedgerUnlocked(ledger) {
|
|
961
1271
|
const temp = `${this.ledgerPath}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`;
|
|
962
|
-
|
|
1272
|
+
const content = `${JSON.stringify(ledger, null, 2)}\n`;
|
|
1273
|
+
fs.writeFileSync(temp, content, 'utf-8');
|
|
963
1274
|
fs.renameSync(temp, this.ledgerPath);
|
|
1275
|
+
// Keep the last good ledger next to the live one so a truncated/corrupt file
|
|
1276
|
+
// (crash between write and rename, full disk, killed process) is recoverable.
|
|
1277
|
+
// Copied after the rename so the backup always equals a fully written state.
|
|
1278
|
+
try {
|
|
1279
|
+
fs.copyFileSync(this.ledgerPath, `${this.ledgerPath}.bak`);
|
|
1280
|
+
}
|
|
1281
|
+
catch { /* the backup is best effort; the atomic rename is still safe */ }
|
|
964
1282
|
this.invalidateCache();
|
|
965
1283
|
}
|
|
966
1284
|
ensureLedgerExists() {
|