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.
Files changed (72) 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 +3046 -1038
  6. package/dist/conversation-utility-host.js +94 -7
  7. package/dist/core/agent.d.ts +149 -12
  8. package/dist/core/agent.js +843 -176
  9. package/dist/core/agentKernelRunner.d.ts +7 -1
  10. package/dist/core/agentKernelRunner.js +171 -22
  11. package/dist/core/autoRouter.d.ts +49 -44
  12. package/dist/core/autoRouter.js +117 -302
  13. package/dist/core/branchIdentity.d.ts +29 -0
  14. package/dist/core/branchIdentity.js +54 -0
  15. package/dist/core/config.js +27 -11
  16. package/dist/core/continuation/contracts.d.ts +1 -1
  17. package/dist/core/continuation/store.d.ts +119 -1
  18. package/dist/core/continuation/store.js +337 -19
  19. package/dist/core/conversationKernel.d.ts +62 -8
  20. package/dist/core/conversationKernel.js +361 -47
  21. package/dist/core/conversationStateDocument.d.ts +18 -0
  22. package/dist/core/conversationStateDocument.js +88 -0
  23. package/dist/core/conversationVisualBudget.d.ts +74 -0
  24. package/dist/core/conversationVisualBudget.js +153 -0
  25. package/dist/core/electronUtilityAgentClient.d.ts +3 -2
  26. package/dist/core/electronUtilityAgentClient.js +32 -8
  27. package/dist/core/electronUtilityRuntimePool.d.ts +28 -3
  28. package/dist/core/electronUtilityRuntimePool.js +93 -24
  29. package/dist/core/hostRuntimeHooks.d.ts +23 -0
  30. package/dist/core/hostRuntimeHooks.js +17 -0
  31. package/dist/core/jevDecision.d.ts +144 -0
  32. package/dist/core/jevDecision.js +226 -0
  33. package/dist/core/memoryProbe.d.ts +24 -0
  34. package/dist/core/memoryProbe.js +104 -0
  35. package/dist/core/mobilePairing.d.ts +7 -1
  36. package/dist/core/mobilePairing.js +9 -1
  37. package/dist/core/performanceDiagnostics.d.ts +1 -1
  38. package/dist/core/routeDecisionValidator.d.ts +86 -0
  39. package/dist/core/routeDecisionValidator.js +249 -0
  40. package/dist/core/routeEligibility.d.ts +52 -0
  41. package/dist/core/routeEligibility.js +108 -0
  42. package/dist/core/runtimeMemoryBudget.d.ts +6 -0
  43. package/dist/core/runtimeMemoryBudget.js +12 -0
  44. package/dist/core/utilityAgentProtocol.d.ts +8 -10
  45. package/dist/core/utilityHostToolRouter.js +2 -0
  46. package/dist/core/visualDownscale.d.ts +6 -0
  47. package/dist/core/visualDownscale.js +137 -0
  48. package/dist/core/wslAgentClient.d.ts +1 -2
  49. package/dist/core/wslAgentClient.js +0 -8
  50. package/dist/core/wslAgentProtocol.d.ts +1 -10
  51. package/dist/core/wslAgentRuntimePool.d.ts +1 -3
  52. package/dist/core/wslAgentRuntimePool.js +17 -22
  53. package/dist/llm/provider.d.ts +3 -1
  54. package/dist/llm/provider.js +53 -9
  55. package/dist/main.js +108 -31
  56. package/dist/preload.js +3 -1
  57. package/dist/server.js +30 -18
  58. package/dist/tools/computerUse.d.ts +4 -0
  59. package/dist/tools/computerUse.js +202 -4
  60. package/dist/tools/computerUsePowerShellHost.d.ts +6 -1
  61. package/dist/tools/computerUsePowerShellHost.js +105 -31
  62. package/dist/tools/index.js +4 -1
  63. package/dist/tui/src/adapters/core-runtime-adapter.js +13 -1
  64. package/dist/tui/src/i18n.js +12 -1
  65. package/dist/tui/src/render.js +9 -2
  66. package/dist/tui/src/settings-schema.js +19 -0
  67. package/dist/tui/src/state.js +69 -1
  68. package/dist/ui/index.html +994 -277
  69. package/dist/ui/lucide-sprite.svg +0 -8
  70. package/dist/wsl-agent-host.bundle.cjs +2851 -1013
  71. package/dist/wsl-agent-host.js +0 -3
  72. package/package.json +38 -9
@@ -90,9 +90,10 @@ class ConfigManager {
90
90
  }
91
91
  const providerIdsMigrated = migrateProviderIdsInConfig(normalized);
92
92
  const marqueeConfigRemoved = removeDeprecatedMarqueeConfig(normalized);
93
- if (providerIdsMigrated || marqueeConfigRemoved) {
94
- // Provider ids are routing identities, so legacy/malformed catalogs must
95
- // not wait for an unrelated settings save before becoming collision-safe.
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
- if (value === 'performance')
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 route policy", _type: "choice", _values: ["quality", "balanced", "cost", "speed"], value: "balanced" },
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 => ({ ...build, input: cloneInput(build.input) }));
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
- const parsed = JSON.parse(raw);
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
- 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');
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() {