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.
Files changed (70) hide show
  1. package/config.example.json +13 -3
  2. package/dist/cli-commands.d.ts +1 -1
  3. package/dist/cli-commands.js +53 -2
  4. package/dist/cli-discovery.js +3 -0
  5. package/dist/conversation-utility-host.bundle.cjs +2880 -1069
  6. package/dist/conversation-utility-host.js +94 -7
  7. package/dist/core/agent.d.ts +126 -12
  8. package/dist/core/agent.js +783 -172
  9. package/dist/core/agentKernelRunner.d.ts +7 -1
  10. package/dist/core/agentKernelRunner.js +156 -20
  11. package/dist/core/autoRouter.d.ts +49 -44
  12. package/dist/core/autoRouter.js +117 -302
  13. package/dist/core/config.js +27 -11
  14. package/dist/core/continuation/contracts.d.ts +1 -1
  15. package/dist/core/continuation/store.d.ts +91 -1
  16. package/dist/core/continuation/store.js +291 -61
  17. package/dist/core/conversationKernel.d.ts +49 -7
  18. package/dist/core/conversationKernel.js +303 -51
  19. package/dist/core/conversationStateDocument.d.ts +18 -0
  20. package/dist/core/conversationStateDocument.js +88 -0
  21. package/dist/core/conversationVisualBudget.d.ts +74 -0
  22. package/dist/core/conversationVisualBudget.js +153 -0
  23. package/dist/core/electronUtilityAgentClient.d.ts +3 -2
  24. package/dist/core/electronUtilityAgentClient.js +32 -8
  25. package/dist/core/electronUtilityRuntimePool.d.ts +28 -3
  26. package/dist/core/electronUtilityRuntimePool.js +93 -24
  27. package/dist/core/hostRuntimeHooks.d.ts +23 -0
  28. package/dist/core/hostRuntimeHooks.js +17 -0
  29. package/dist/core/jevDecision.d.ts +144 -0
  30. package/dist/core/jevDecision.js +226 -0
  31. package/dist/core/memoryProbe.d.ts +24 -0
  32. package/dist/core/memoryProbe.js +104 -0
  33. package/dist/core/mobilePairing.d.ts +7 -1
  34. package/dist/core/mobilePairing.js +9 -1
  35. package/dist/core/performanceDiagnostics.d.ts +1 -1
  36. package/dist/core/routeDecisionValidator.d.ts +86 -0
  37. package/dist/core/routeDecisionValidator.js +249 -0
  38. package/dist/core/routeEligibility.d.ts +52 -0
  39. package/dist/core/routeEligibility.js +108 -0
  40. package/dist/core/runtimeMemoryBudget.d.ts +6 -0
  41. package/dist/core/runtimeMemoryBudget.js +12 -0
  42. package/dist/core/utilityAgentProtocol.d.ts +8 -10
  43. package/dist/core/utilityHostToolRouter.js +2 -0
  44. package/dist/core/visualDownscale.d.ts +6 -0
  45. package/dist/core/visualDownscale.js +137 -0
  46. package/dist/core/wslAgentClient.d.ts +1 -2
  47. package/dist/core/wslAgentClient.js +0 -8
  48. package/dist/core/wslAgentProtocol.d.ts +1 -10
  49. package/dist/core/wslAgentRuntimePool.d.ts +1 -3
  50. package/dist/core/wslAgentRuntimePool.js +17 -22
  51. package/dist/llm/provider.d.ts +3 -1
  52. package/dist/llm/provider.js +53 -9
  53. package/dist/main.js +107 -30
  54. package/dist/preload.js +3 -1
  55. package/dist/server.js +30 -18
  56. package/dist/tools/computerUse.d.ts +4 -0
  57. package/dist/tools/computerUse.js +202 -4
  58. package/dist/tools/computerUsePowerShellHost.d.ts +6 -1
  59. package/dist/tools/computerUsePowerShellHost.js +105 -31
  60. package/dist/tools/index.js +4 -1
  61. package/dist/tui/src/adapters/core-runtime-adapter.js +13 -1
  62. package/dist/tui/src/i18n.js +12 -1
  63. package/dist/tui/src/render.js +9 -2
  64. package/dist/tui/src/settings-schema.js +19 -0
  65. package/dist/tui/src/state.js +69 -1
  66. package/dist/ui/index.html +651 -233
  67. package/dist/ui/lucide-sprite.svg +0 -8
  68. package/dist/wsl-agent-host.bundle.cjs +2685 -1044
  69. package/dist/wsl-agent-host.js +0 -3
  70. 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 queued = Object.values(ledger.builds)
289
- .filter(build => build.branchId === branch.branchId && build.status === 'QUEUED')
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 queued = Object.values(ledger.builds)
338
- .filter(build => build.branchId === branch.branchId && build.status === 'QUEUED')
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 queued) {
374
+ for (const build of chain) {
342
375
  const parent = build.parentBuildId ? ledger.builds[build.parentBuildId] : null;
343
- // 链上的前驱(frontier 或队列里位于它之前的行)在执行时已经提交,因此只有
344
- // 「父不是链上前驱」或「父已 FAILED/CANCELLED」的行才是真正受阻的。
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
- blocked.add(build.buildId);
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
- const parsed = JSON.parse(raw);
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
- fs.writeFileSync(temp, `${JSON.stringify(ledger, null, 2)}\n`, 'utf-8');
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() {