newmark-agent 0.6.4 → 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 +2880 -1069
- package/dist/conversation-utility-host.js +94 -7
- package/dist/core/agent.d.ts +126 -12
- package/dist/core/agent.js +783 -172
- package/dist/core/agentKernelRunner.d.ts +7 -1
- package/dist/core/agentKernelRunner.js +156 -20
- package/dist/core/autoRouter.d.ts +49 -44
- package/dist/core/autoRouter.js +117 -302
- 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/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 +107 -30
- 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 +651 -233
- package/dist/ui/lucide-sprite.svg +0 -8
- package/dist/wsl-agent-host.bundle.cjs +2685 -1044
- package/dist/wsl-agent-host.js +0 -3
- package/package.json +36 -9
|
@@ -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;
|
|
@@ -66,6 +68,16 @@ export declare class GuardedContinuationStore {
|
|
|
66
68
|
repaired: BuildId[];
|
|
67
69
|
frontier: BuildId | null;
|
|
68
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;
|
|
69
81
|
/**
|
|
70
82
|
* dev-0.6.4 hotfix: 读取视角的受阻推导。
|
|
71
83
|
*
|
|
@@ -73,8 +85,26 @@ export declare class GuardedContinuationStore {
|
|
|
73
85
|
* 的可执行 Build 时才可能被 claim(见 `claimBuild`)。这里按队列顺序推导哪些行
|
|
74
86
|
* 目前不可执行:父不是链上前驱、或父已 FAILED/CANCELLED 的行即为受阻,且受阻点
|
|
75
87
|
* 之后的所有行同样受阻。
|
|
88
|
+
*
|
|
89
|
+
* dev-0.6.6 hotfix: 链上前驱必须包含 **正在执行(RUNNING)** 的 Build。已受理但
|
|
90
|
+
* 尚未提交的执行只把 `branch.headBuildId` 留在上一个已提交 Build 上,所以只按
|
|
91
|
+
* QUEUED 行走链会把「正在跑的这一轮」当成断链:紧随其后的正常排队行会被误判为
|
|
92
|
+
* 「被失败前置阻断」,而且后面每一行都会跟着被标记。RUNNING 行执行完会提交为
|
|
93
|
+
* frontier,因此它才是下一行的真实前驱。
|
|
76
94
|
*/
|
|
77
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;
|
|
78
108
|
deleteQueuedBuild(input: {
|
|
79
109
|
workspaceId: string;
|
|
80
110
|
rootId: RootId;
|
|
@@ -177,9 +207,67 @@ export declare class GuardedContinuationStore {
|
|
|
177
207
|
rootId: RootId;
|
|
178
208
|
branchId: BranchId;
|
|
179
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;
|
|
180
239
|
private snapshotUnlocked;
|
|
181
240
|
events(afterCursor?: number, limit?: number): ContinuationEvent[];
|
|
182
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
|
+
};
|
|
183
271
|
private terminateAttempt;
|
|
184
272
|
private releaseExpiredGuard;
|
|
185
273
|
private markDependentsBlocked;
|
|
@@ -199,6 +287,8 @@ export declare class GuardedContinuationStore {
|
|
|
199
287
|
private acquireLock;
|
|
200
288
|
private readLedger;
|
|
201
289
|
private readLedgerUnlocked;
|
|
290
|
+
private normalizeLedgerShape;
|
|
291
|
+
private recoverFromBackup;
|
|
202
292
|
private writeLedgerUnlocked;
|
|
203
293
|
private ensureLedgerExists;
|
|
204
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) {
|
|
@@ -285,45 +300,56 @@ class GuardedContinuationStore {
|
|
|
285
300
|
return this.mutate(ledger => {
|
|
286
301
|
const branch = this.requireBranch(ledger, input);
|
|
287
302
|
const frontier = branch.headBuildId || null;
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
.sort((a, b) => a.queueSequence - b.queueSequence);
|
|
291
|
-
const repaired = [];
|
|
292
|
-
let chainParent = frontier;
|
|
293
|
-
let mutated = false;
|
|
294
|
-
for (const build of queued) {
|
|
295
|
-
if ((build.parentBuildId || null) !== (chainParent || null)) {
|
|
296
|
-
const previousParentBuildId = build.parentBuildId;
|
|
297
|
-
build.parentBuildId = chainParent;
|
|
298
|
-
build.waitingReason = undefined;
|
|
299
|
-
build.updatedAt = new Date().toISOString();
|
|
300
|
-
repaired.push(build.buildId);
|
|
301
|
-
mutated = true;
|
|
302
|
-
this.appendEvent(ledger, {
|
|
303
|
-
type: 'BuildQueueRepaired',
|
|
304
|
-
workspaceId: this.workspaceId,
|
|
305
|
-
rootId: branch.rootId,
|
|
306
|
-
branchId: branch.branchId,
|
|
307
|
-
buildId: build.buildId,
|
|
308
|
-
attemptId: null,
|
|
309
|
-
fence: null,
|
|
310
|
-
payload: {
|
|
311
|
-
previousParentBuildId,
|
|
312
|
-
newParentBuildId: chainParent,
|
|
313
|
-
reason: String(input.reason || 'committed-frontier-repair'),
|
|
314
|
-
},
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
chainParent = build.buildId;
|
|
318
|
-
}
|
|
319
|
-
if (mutated) {
|
|
320
|
-
branch.queueRevision += 1;
|
|
321
|
-
branch.lifecycleRevision += 1;
|
|
322
|
-
branch.updatedAt = new Date().toISOString();
|
|
323
|
-
}
|
|
324
|
-
return { repaired, frontier };
|
|
303
|
+
const outcome = this.reanchorQueuedChainUnlocked(ledger, branch, String(input.reason || 'committed-frontier-repair'));
|
|
304
|
+
return { repaired: outcome.rebased, frontier };
|
|
325
305
|
});
|
|
326
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
|
+
}
|
|
327
353
|
/**
|
|
328
354
|
* dev-0.6.4 hotfix: 读取视角的受阻推导。
|
|
329
355
|
*
|
|
@@ -331,27 +357,66 @@ class GuardedContinuationStore {
|
|
|
331
357
|
* 的可执行 Build 时才可能被 claim(见 `claimBuild`)。这里按队列顺序推导哪些行
|
|
332
358
|
* 目前不可执行:父不是链上前驱、或父已 FAILED/CANCELLED 的行即为受阻,且受阻点
|
|
333
359
|
* 之后的所有行同样受阻。
|
|
360
|
+
*
|
|
361
|
+
* dev-0.6.6 hotfix: 链上前驱必须包含 **正在执行(RUNNING)** 的 Build。已受理但
|
|
362
|
+
* 尚未提交的执行只把 `branch.headBuildId` 留在上一个已提交 Build 上,所以只按
|
|
363
|
+
* QUEUED 行走链会把「正在跑的这一轮」当成断链:紧随其后的正常排队行会被误判为
|
|
364
|
+
* 「被失败前置阻断」,而且后面每一行都会跟着被标记。RUNNING 行执行完会提交为
|
|
365
|
+
* frontier,因此它才是下一行的真实前驱。
|
|
334
366
|
*/
|
|
335
367
|
blockedQueuedBuildIds(ledger, branch) {
|
|
336
368
|
const blocked = new Set();
|
|
337
|
-
const
|
|
338
|
-
.filter(build => build.branchId === branch.branchId
|
|
369
|
+
const chain = Object.values(ledger.builds)
|
|
370
|
+
.filter(build => build.branchId === branch.branchId
|
|
371
|
+
&& (build.status === 'QUEUED' || build.status === 'RUNNING'))
|
|
339
372
|
.sort((a, b) => a.queueSequence - b.queueSequence);
|
|
340
373
|
let chainParent = branch.headBuildId || null;
|
|
341
|
-
for (const build of
|
|
374
|
+
for (const build of chain) {
|
|
342
375
|
const parent = build.parentBuildId ? ledger.builds[build.parentBuildId] : null;
|
|
343
|
-
// 链上的前驱(frontier
|
|
344
|
-
//
|
|
376
|
+
// 链上的前驱(frontier、正在执行的行、或队列里位于它之前的行)在执行时都会
|
|
377
|
+
// 提交,因此只有「父不是链上前驱」或「父已 FAILED/CANCELLED」的排队行才是
|
|
378
|
+
// 真正受阻的。
|
|
345
379
|
const parentIsChainPredecessor = (build.parentBuildId || null) === (chainParent || null);
|
|
346
380
|
const parentFailed = !!parent && (parent.status === 'FAILED' || parent.status === 'CANCELLED');
|
|
347
381
|
if (!parentIsChainPredecessor || parentFailed) {
|
|
348
|
-
|
|
382
|
+
// 正在执行的行已经拿到执行权,它的「受阻」只可能是账本损坏,不应该反过来
|
|
383
|
+
// 让它在界面和队列投影里变成一个可修复的等待行。
|
|
384
|
+
if (build.status === 'QUEUED')
|
|
385
|
+
blocked.add(build.buildId);
|
|
349
386
|
continue;
|
|
350
387
|
}
|
|
351
388
|
chainParent = build.buildId;
|
|
352
389
|
}
|
|
353
390
|
return blocked;
|
|
354
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
|
+
}
|
|
355
420
|
deleteQueuedBuild(input) {
|
|
356
421
|
this.mutate(ledger => {
|
|
357
422
|
const build = ledger.builds[input.buildId];
|
|
@@ -397,8 +462,7 @@ class GuardedContinuationStore {
|
|
|
397
462
|
|| guard.activeAttemptId !== scope.attemptId
|
|
398
463
|
|| guard.fence !== scope.fence
|
|
399
464
|
|| attempt.fence !== scope.fence
|
|
400
|
-
|| attempt.status !== 'RUNNING'
|
|
401
|
-
|| guard.leaseUntil <= now) {
|
|
465
|
+
|| attempt.status !== 'RUNNING') {
|
|
402
466
|
this.appendEvent(ledger, {
|
|
403
467
|
type: 'StaleWriteRejected',
|
|
404
468
|
workspaceId: this.workspaceId,
|
|
@@ -446,8 +510,7 @@ class GuardedContinuationStore {
|
|
|
446
510
|
|| guard.activeAttemptId !== scope.attemptId
|
|
447
511
|
|| guard.fence !== scope.fence
|
|
448
512
|
|| attempt.fence !== scope.fence
|
|
449
|
-
|| attempt.status !== 'RUNNING'
|
|
450
|
-
|| guard.leaseUntil <= now) {
|
|
513
|
+
|| attempt.status !== 'RUNNING') {
|
|
451
514
|
this.appendEvent(ledger, {
|
|
452
515
|
type: 'StaleWriteRejected',
|
|
453
516
|
workspaceId: this.workspaceId,
|
|
@@ -456,7 +519,7 @@ class GuardedContinuationStore {
|
|
|
456
519
|
buildId: build.buildId,
|
|
457
520
|
attemptId: scope.attemptId,
|
|
458
521
|
fence: scope.fence,
|
|
459
|
-
payload: { operation: 'commit_final' },
|
|
522
|
+
payload: { operation: 'commit_final', leaseUntil: guard.leaseUntil },
|
|
460
523
|
});
|
|
461
524
|
throw new contracts_1.ContinuationError('STALE_EXECUTION', `Attempt ${scope.attemptId} no longer owns branch ${branch.branchId}`);
|
|
462
525
|
}
|
|
@@ -781,6 +844,49 @@ class GuardedContinuationStore {
|
|
|
781
844
|
const branch = this.requireBranch(ledger, input);
|
|
782
845
|
return this.snapshotUnlocked(ledger, branch);
|
|
783
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
|
+
}
|
|
784
890
|
snapshotUnlocked(ledger, branch) {
|
|
785
891
|
const guard = ledger.guards[branch.branchId] || (0, contracts_1.defaultGuard)(branch.branchId);
|
|
786
892
|
// dev-0.6.4 hotfix: 受阻状态由父链推导,而不是只在失败瞬间给直系后继打标记。
|
|
@@ -836,6 +942,89 @@ class GuardedContinuationStore {
|
|
|
836
942
|
return recovered;
|
|
837
943
|
});
|
|
838
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
|
+
}
|
|
839
1028
|
terminateAttempt(scope, status, reason, now = Date.now()) {
|
|
840
1029
|
this.mutate(ledger => {
|
|
841
1030
|
const branch = this.requireBranch(ledger, scope);
|
|
@@ -1026,29 +1215,70 @@ class GuardedContinuationStore {
|
|
|
1026
1215
|
this.ensureLedgerExists();
|
|
1027
1216
|
try {
|
|
1028
1217
|
const raw = fs.readFileSync(this.ledgerPath, 'utf-8').replace(/^\uFEFF/, '');
|
|
1029
|
-
|
|
1030
|
-
if (!parsed || parsed.version !== 1 || !parsed.branches || !parsed.builds) {
|
|
1031
|
-
throw new contracts_1.ContinuationError('EFFECT_UNCERTAIN', `Continuation ledger ${this.ledgerPath} has an unsupported shape`);
|
|
1032
|
-
}
|
|
1033
|
-
parsed.attempts = parsed.attempts || {};
|
|
1034
|
-
parsed.guards = parsed.guards || {};
|
|
1035
|
-
parsed.commands = parsed.commands || {};
|
|
1036
|
-
parsed.finals = parsed.finals || {};
|
|
1037
|
-
parsed.events = Array.isArray(parsed.events) ? parsed.events : [];
|
|
1038
|
-
parsed.nextEventCursor = Math.max(1, Number(parsed.nextEventCursor) || (parsed.events.at(-1)?.cursor || 0) + 1);
|
|
1039
|
-
parsed.nextQueueSequence = Math.max(1, Number(parsed.nextQueueSequence) || (Object.values(parsed.builds).reduce((max, build) => Math.max(max, build.queueSequence || 0), 0) + 1));
|
|
1040
|
-
return parsed;
|
|
1218
|
+
return this.normalizeLedgerShape(JSON.parse(raw));
|
|
1041
1219
|
}
|
|
1042
1220
|
catch (error) {
|
|
1043
1221
|
if (error instanceof contracts_1.ContinuationError)
|
|
1044
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;
|
|
1045
1230
|
throw new contracts_1.ContinuationError('EFFECT_UNCERTAIN', `Continuation ledger ${this.ledgerPath} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
1046
1231
|
}
|
|
1047
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
|
+
}
|
|
1048
1270
|
writeLedgerUnlocked(ledger) {
|
|
1049
1271
|
const temp = `${this.ledgerPath}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`;
|
|
1050
|
-
|
|
1272
|
+
const content = `${JSON.stringify(ledger, null, 2)}\n`;
|
|
1273
|
+
fs.writeFileSync(temp, content, 'utf-8');
|
|
1051
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 */ }
|
|
1052
1282
|
this.invalidateCache();
|
|
1053
1283
|
}
|
|
1054
1284
|
ensureLedgerExists() {
|