harnery 0.45.1 → 0.45.2

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 (119) hide show
  1. package/dist/commands/workflow.d.ts.map +1 -1
  2. package/dist/commands/workflow.js +6 -45
  3. package/dist/core/agents/coord-client.d.ts.map +1 -1
  4. package/dist/core/agents/coord-client.js +0 -18
  5. package/dist/core/agents/state/pidmap.js +2 -2
  6. package/dist/core/agents/state/proc-start.d.ts +20 -31
  7. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  8. package/dist/core/agents/state/proc-start.js +37 -70
  9. package/dist/core/agents/state/service-process.d.ts +61 -0
  10. package/dist/core/agents/state/service-process.d.ts.map +1 -0
  11. package/dist/core/agents/state/service-process.js +143 -0
  12. package/dist/core/context/index.d.ts +6 -4
  13. package/dist/core/context/index.d.ts.map +1 -1
  14. package/dist/core/context/index.js +194 -256
  15. package/dist/core/context/observation.d.ts +9 -9
  16. package/dist/core/context/observation.d.ts.map +1 -1
  17. package/dist/core/context/observation.js +37 -6
  18. package/dist/core/events/v3/runtime-build.d.ts +14 -0
  19. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  20. package/dist/core/events/v3/runtime-build.js +44 -0
  21. package/dist/core/governor/index.d.ts +2 -1
  22. package/dist/core/governor/index.d.ts.map +1 -1
  23. package/dist/core/governor/index.js +2 -1
  24. package/dist/core/governor/service-read.d.ts +3 -10
  25. package/dist/core/governor/service-read.d.ts.map +1 -1
  26. package/dist/core/governor/service-read.js +10 -94
  27. package/dist/core/governor/service.d.ts +3 -7
  28. package/dist/core/governor/service.d.ts.map +1 -1
  29. package/dist/core/governor/service.js +17 -182
  30. package/dist/core/hooks/resolve/owner.js +2 -2
  31. package/dist/core/resources/sampler.js +2 -2
  32. package/dist/core/resources/service-status.d.ts +2 -14
  33. package/dist/core/resources/service-status.d.ts.map +1 -1
  34. package/dist/core/resources/service-status.js +3 -23
  35. package/dist/core/resources/service.d.ts +3 -7
  36. package/dist/core/resources/service.d.ts.map +1 -1
  37. package/dist/core/resources/service.js +12 -101
  38. package/dist/core/resources/status.d.ts.map +1 -1
  39. package/dist/core/resources/status.js +3 -2
  40. package/dist/core/semantic/service.d.ts.map +1 -1
  41. package/dist/core/semantic/service.js +9 -2
  42. package/dist/core/servers/index.d.ts +0 -2
  43. package/dist/core/servers/index.d.ts.map +1 -1
  44. package/dist/core/servers/index.js +3 -18
  45. package/dist/core/supervisor/service.d.ts +4 -7
  46. package/dist/core/supervisor/service.d.ts.map +1 -1
  47. package/dist/core/supervisor/service.js +23 -122
  48. package/dist/core/supervisor/services.js +2 -2
  49. package/dist/core/supervisor/status.d.ts +3 -17
  50. package/dist/core/supervisor/status.d.ts.map +1 -1
  51. package/dist/core/supervisor/status.js +7 -70
  52. package/dist/core/workflow/engine.js +2 -1
  53. package/dist/core/workflow/index.d.ts +3 -4
  54. package/dist/core/workflow/index.d.ts.map +1 -1
  55. package/dist/core/workflow/index.js +2 -2
  56. package/dist/core/workflow/proof.d.ts.map +1 -1
  57. package/dist/core/workflow/proof.js +30 -74
  58. package/dist/core/workflow/resume-lock.d.ts +17 -0
  59. package/dist/core/workflow/resume-lock.d.ts.map +1 -0
  60. package/dist/core/workflow/resume-lock.js +158 -0
  61. package/dist/core/workflow/run-state.d.ts +0 -21
  62. package/dist/core/workflow/run-state.d.ts.map +1 -1
  63. package/dist/core/workflow/run-state.js +3 -174
  64. package/dist/core/workflow/workspaces/leases.d.ts +4 -0
  65. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  66. package/dist/core/workflow/workspaces/leases.js +1 -1
  67. package/dist/core/workflow/workspaces/local-git.js +91 -210
  68. package/dist/core/workflow/workspaces/ownership.d.ts +0 -7
  69. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -1
  70. package/dist/core/workflow/workspaces/ownership.js +3 -130
  71. package/dist/core/workflow/workspaces/reclaim.d.ts +4 -7
  72. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  73. package/dist/core/workflow/workspaces/reclaim.js +0 -4
  74. package/dist/core/workflow/workspaces/recovery.d.ts +1 -1
  75. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  76. package/dist/core/workflow/workspaces/recovery.js +1 -1
  77. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  78. package/dist/core/workflow/workspaces/state.js +1 -4
  79. package/package.json +1 -1
  80. package/src/commands/workflow.ts +8 -58
  81. package/src/core/agents/coord-client.ts +0 -20
  82. package/src/core/agents/state/pidmap.ts +2 -2
  83. package/src/core/agents/state/proc-start.ts +41 -69
  84. package/src/core/agents/state/service-process.ts +217 -0
  85. package/src/core/context/index.ts +207 -272
  86. package/src/core/context/observation.ts +40 -9
  87. package/src/core/events/v3/runtime-build.ts +62 -0
  88. package/src/core/governor/index.ts +1 -2
  89. package/src/core/governor/service-read.ts +15 -114
  90. package/src/core/governor/service.ts +36 -220
  91. package/src/core/hooks/resolve/owner.ts +2 -2
  92. package/src/core/resources/sampler.ts +3 -2
  93. package/src/core/resources/service-status.ts +6 -35
  94. package/src/core/resources/service.ts +23 -133
  95. package/src/core/resources/status.ts +3 -2
  96. package/src/core/semantic/service.ts +8 -2
  97. package/src/core/servers/index.ts +3 -23
  98. package/src/core/supervisor/service.ts +33 -155
  99. package/src/core/supervisor/services.ts +2 -2
  100. package/src/core/supervisor/status.ts +10 -85
  101. package/src/core/workflow/engine.ts +1 -1
  102. package/src/core/workflow/index.ts +2 -13
  103. package/src/core/workflow/proof.ts +35 -83
  104. package/src/core/workflow/resume-lock.ts +179 -0
  105. package/src/core/workflow/run-state.ts +3 -207
  106. package/src/core/workflow/workspaces/leases.ts +1 -1
  107. package/src/core/workflow/workspaces/local-git.ts +120 -239
  108. package/src/core/workflow/workspaces/ownership.ts +3 -158
  109. package/src/core/workflow/workspaces/reclaim.ts +3 -9
  110. package/src/core/workflow/workspaces/recovery.ts +1 -1
  111. package/src/core/workflow/workspaces/state.ts +1 -5
  112. package/dist/core/agents/state/process-handle.d.ts +0 -26
  113. package/dist/core/agents/state/process-handle.d.ts.map +0 -1
  114. package/dist/core/agents/state/process-handle.js +0 -121
  115. package/dist/core/workflow/recovery-audit.d.ts +0 -30
  116. package/dist/core/workflow/recovery-audit.d.ts.map +0 -1
  117. package/dist/core/workflow/recovery-audit.js +0 -116
  118. package/src/core/agents/state/process-handle.ts +0 -165
  119. package/src/core/workflow/recovery-audit.ts +0 -181
@@ -7,8 +7,8 @@
7
7
 
8
8
  import { spawnSync } from "node:child_process";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11
- import { dirname, isAbsolute, join } from "node:path";
10
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
12
  import type { Adapter } from "../adapter.ts";
13
13
  import { readLiveCoordinationRow } from "../agents/state/live-coordination-view.ts";
14
14
  import { resolveBinName } from "../config.ts";
@@ -16,12 +16,11 @@ import type { HarneryInboxMessageRecordV1 } from "../inbox/contract.ts";
16
16
  import { peerInbox } from "../inbox/peer-messages.ts";
17
17
  import { project } from "../inbox/service.ts";
18
18
  import { stateFileMode } from "../storage/modes.ts";
19
- import { acquireNoClobberLease } from "../workflow/workspaces/leases.ts";
20
- import type { RepoObservation, RepoSnapshot } from "./observation.ts";
21
- import { observationsComparable } from "./observation.ts";
19
+ import type { RepoSnapshot } from "./observation.ts";
20
+ import { repoComparisonProblems } from "./observation.ts";
22
21
 
23
22
  export type { RepoObservation, RepoSnapshot } from "./observation.ts";
24
- export { observationsComparable } from "./observation.ts";
23
+ export { repoComparisonProblems } from "./observation.ts";
25
24
 
26
25
  export const CONTEXT_SCHEMA_VERSION = 1 as const;
27
26
  export const MAX_CAPSULE_BYTES = 32 * 1024;
@@ -125,7 +124,6 @@ export interface PreparedContextCheckpoint {
125
124
  instance_id: string;
126
125
  generation: number;
127
126
  capsule_id: string;
128
- capsule_sha256: string;
129
127
  }
130
128
 
131
129
  export interface PreparedContextRecovery {
@@ -206,26 +204,24 @@ export function recordContextSample(
206
204
  instanceId: string,
207
205
  sample: ContextSample,
208
206
  ): { state: ContextContinuityState; changed: boolean } {
209
- return withContextMutation(coordRoot, sample.session_id, () => {
210
- const current = readContextState(coordRoot, sample.session_id);
211
- const state =
212
- current?.instance_id === instanceId
213
- ? current
214
- : {
215
- ...newState({ sessionId: sample.session_id, instanceId, now: sample.observed_at }),
216
- generation: current?.generation ?? 0,
217
- };
218
- const changed = !sameMeasurement(state.latest_context, sample);
219
- if (!changed) return { state, changed: false };
220
- const next: ContextContinuityState = {
221
- ...state,
222
- instance_id: instanceId,
223
- latest_context: sample,
224
- updated_at: sample.observed_at,
225
- };
226
- writeState(coordRoot, next);
227
- return { state: next, changed: true };
228
- });
207
+ const current = readContextState(coordRoot, sample.session_id);
208
+ const state =
209
+ current?.instance_id === instanceId
210
+ ? current
211
+ : {
212
+ ...newState({ sessionId: sample.session_id, instanceId, now: sample.observed_at }),
213
+ generation: current?.generation ?? 0,
214
+ };
215
+ const changed = !sameMeasurement(state.latest_context, sample);
216
+ if (!changed) return { state, changed: false };
217
+ const next: ContextContinuityState = {
218
+ ...state,
219
+ instance_id: instanceId,
220
+ latest_context: sample,
221
+ updated_at: sample.observed_at,
222
+ };
223
+ writeState(coordRoot, next);
224
+ return { state: next, changed: true };
229
225
  }
230
226
 
231
227
  /**
@@ -237,64 +233,62 @@ export function checkpointContext(
237
233
  coordRoot: string,
238
234
  input: CheckpointContextInput,
239
235
  ): CheckpointContextResult {
240
- return withContextMutation(coordRoot, input.sessionId, () => {
241
- const now = new Date().toISOString();
242
- const previous = readContextState(coordRoot, input.sessionId);
243
- const current =
244
- previous?.instance_id === input.instanceId
245
- ? previous
246
- : {
247
- ...newState({ sessionId: input.sessionId, instanceId: input.instanceId, now }),
248
- generation: previous?.generation ?? 0,
249
- };
250
- if (input.reason === "pre_compact" && current.phase === "checkpointed") {
251
- const existing = readLatestCapsule(coordRoot, input.sessionId);
252
- if (existing && capsuleMatchesState(current, existing, input) && current.latest_capsule) {
253
- return {
254
- capsule: existing,
255
- path: join(coordRoot, ".harnery", current.latest_capsule),
256
- state: current,
257
- reused: true,
236
+ const now = new Date().toISOString();
237
+ const previous = readContextState(coordRoot, input.sessionId);
238
+ const current =
239
+ previous?.instance_id === input.instanceId
240
+ ? previous
241
+ : {
242
+ ...newState({ sessionId: input.sessionId, instanceId: input.instanceId, now }),
243
+ generation: previous?.generation ?? 0,
258
244
  };
259
- }
245
+ if (input.reason === "pre_compact" && current.phase === "checkpointed") {
246
+ const existing = readLatestCapsule(coordRoot, input.sessionId);
247
+ if (existing && capsuleMatchesState(current, existing, input) && current.latest_capsule) {
248
+ return {
249
+ capsule: existing,
250
+ path: join(coordRoot, ".harnery", current.latest_capsule),
251
+ state: current,
252
+ reused: true,
253
+ };
260
254
  }
255
+ }
261
256
 
262
- const heartbeat = readHeartbeat(coordRoot, input.instanceId);
263
- const generation = current.generation + 1;
264
- const capsule: ContinuityCapsule = {
265
- schema_version: CONTEXT_SCHEMA_VERSION,
266
- capsule_id: randomUUID(),
267
- generation,
268
- created_at: now,
269
- reason: input.reason,
270
- session: {
271
- session_id: input.sessionId,
272
- instance_id: input.instanceId,
273
- adapter: input.adapter,
274
- model: input.model ?? current.latest_context?.model ?? heartbeat?.model,
275
- },
276
- context: current.latest_context,
277
- work: buildWorkSnapshot(heartbeat, input.continuationNote),
278
- repo: snapshotRepo(input.cwd),
279
- };
280
-
281
- const relativePath = capsuleRelativePath(input.sessionId, generation);
282
- const absolutePath = join(coordRoot, ".harnery", relativePath);
283
- writeBoundedJson(absolutePath, capsule, MAX_CAPSULE_BYTES);
284
- const next: ContextContinuityState = {
285
- ...current,
257
+ const heartbeat = readHeartbeat(coordRoot, input.instanceId);
258
+ const generation = current.generation + 1;
259
+ const capsule: ContinuityCapsule = {
260
+ schema_version: CONTEXT_SCHEMA_VERSION,
261
+ capsule_id: randomUUID(),
262
+ generation,
263
+ created_at: now,
264
+ reason: input.reason,
265
+ session: {
266
+ session_id: input.sessionId,
286
267
  instance_id: input.instanceId,
287
- phase: "checkpointed",
288
- generation,
289
- latest_capsule: relativePath,
290
- updated_at: now,
291
- recovered_at: undefined,
292
- compaction_completed_at: undefined,
293
- degraded_reason: undefined,
294
- };
295
- writeState(coordRoot, next);
296
- return { capsule, path: absolutePath, state: next, reused: false };
297
- });
268
+ adapter: input.adapter,
269
+ model: input.model ?? current.latest_context?.model ?? heartbeat?.model,
270
+ },
271
+ context: current.latest_context,
272
+ work: buildWorkSnapshot(heartbeat, input.continuationNote),
273
+ repo: snapshotRepo(input.cwd),
274
+ };
275
+
276
+ const relativePath = capsuleRelativePath(input.sessionId, generation);
277
+ const absolutePath = join(coordRoot, ".harnery", relativePath);
278
+ writeBoundedJson(absolutePath, capsule, MAX_CAPSULE_BYTES);
279
+ const next: ContextContinuityState = {
280
+ ...current,
281
+ instance_id: input.instanceId,
282
+ phase: "checkpointed",
283
+ generation,
284
+ latest_capsule: relativePath,
285
+ updated_at: now,
286
+ recovered_at: undefined,
287
+ compaction_completed_at: undefined,
288
+ degraded_reason: undefined,
289
+ };
290
+ writeState(coordRoot, next);
291
+ return { capsule, path: absolutePath, state: next, reused: false };
298
292
  }
299
293
 
300
294
  /** Prepare a briefing without claiming that a adapter has accepted it yet. */
@@ -302,48 +296,45 @@ export function prepareContextRecovery(
302
296
  coordRoot: string,
303
297
  input: { sessionId: string; instanceId: string; cwd: string },
304
298
  ): PreparedContextRecovery | null {
305
- return withContextMutation(coordRoot, input.sessionId, () => {
306
- const now = new Date().toISOString();
307
- const current =
308
- readContextState(coordRoot, input.sessionId) ??
309
- newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
310
- // A reused provider ID does not transfer the prior task's context.
311
- if (current.instance_id !== input.instanceId) return null;
312
- const capsule = readLatestCapsule(coordRoot, input.sessionId);
313
- if (!capsule || !capsuleMatchesState(current, capsule, input)) {
314
- const degraded: ContextContinuityState = {
315
- ...current,
316
- instance_id: input.instanceId,
317
- phase: "degraded",
318
- updated_at: now,
319
- degraded_reason: capsule
320
- ? "continuity checkpoint does not match the requesting instance and state"
321
- : "post-compaction signal arrived without a continuity checkpoint",
322
- };
323
- writeState(coordRoot, degraded);
324
- return null;
325
- }
326
-
327
- const liveRepo = snapshotRepo(input.cwd);
328
- const briefing = [
329
- renderRecoveryBriefing(capsule, liveRepo),
330
- renderRecoveryInput(coordRoot, input.instanceId),
331
- ]
332
- .filter(Boolean)
333
- .join("\n\n");
334
- return {
335
- state: current,
336
- capsule,
337
- briefing,
338
- checkpoint: Object.freeze({
339
- session_id: input.sessionId,
340
- instance_id: input.instanceId,
341
- generation: capsule.generation,
342
- capsule_id: capsule.capsule_id,
343
- capsule_sha256: capsuleDigest(capsule),
344
- }),
299
+ const now = new Date().toISOString();
300
+ const current =
301
+ readContextState(coordRoot, input.sessionId) ??
302
+ newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
303
+ // A reused provider ID does not transfer the prior task's context.
304
+ if (current.instance_id !== input.instanceId) return null;
305
+ const capsule = readLatestCapsule(coordRoot, input.sessionId);
306
+ if (!capsule || !capsuleMatchesState(current, capsule, input)) {
307
+ const degraded: ContextContinuityState = {
308
+ ...current,
309
+ instance_id: input.instanceId,
310
+ phase: "degraded",
311
+ updated_at: now,
312
+ degraded_reason: capsule
313
+ ? "continuity checkpoint does not match the requesting instance and state"
314
+ : "post-compaction signal arrived without a continuity checkpoint",
345
315
  };
346
- });
316
+ writeState(coordRoot, degraded);
317
+ return null;
318
+ }
319
+
320
+ const liveRepo = snapshotRepo(input.cwd);
321
+ const briefing = [
322
+ renderRecoveryBriefing(capsule, liveRepo),
323
+ renderRecoveryInput(coordRoot, input.instanceId),
324
+ ]
325
+ .filter(Boolean)
326
+ .join("\n\n");
327
+ return {
328
+ state: current,
329
+ capsule,
330
+ briefing,
331
+ checkpoint: Object.freeze({
332
+ session_id: input.sessionId,
333
+ instance_id: input.instanceId,
334
+ generation: capsule.generation,
335
+ capsule_id: capsule.capsule_id,
336
+ }),
337
+ };
347
338
  }
348
339
 
349
340
  /** Recovery lists input without surfacing it again or changing its receipts. */
@@ -404,8 +395,11 @@ function inputLine(messageId: string, status: string, body: string): string {
404
395
 
405
396
  /**
406
397
  * Advance continuity only after the adapter accepted the prepared briefing.
407
- * Missing, altered, foreign or superseded checkpoints refuse without writing.
408
- * Repeating the same accepted checkpoint is an exact no-op.
398
+ * A foreign or superseded checkpoint refuses without writing, and repeating
399
+ * the same accepted checkpoint is an exact no-op. The write is a
400
+ * compare-and-write: if the state changed after it was read, for example
401
+ * because a newer checkpoint landed, completion refuses instead of
402
+ * overwriting it.
409
403
  */
410
404
  export function completeContextRecovery(
411
405
  coordRoot: string,
@@ -415,43 +409,36 @@ export function completeContextRecovery(
415
409
  checkpoint: Readonly<PreparedContextCheckpoint>;
416
410
  },
417
411
  ): ContextContinuityState | null {
418
- return withContextMutation(coordRoot, input.sessionId, () => {
419
- const current = readContextState(coordRoot, input.sessionId);
420
- const checkpoint = input.checkpoint;
421
- if (
422
- !current ||
423
- !checkpoint ||
424
- checkpoint.session_id !== input.sessionId ||
425
- checkpoint.instance_id !== input.instanceId ||
426
- !Number.isSafeInteger(checkpoint.generation) ||
427
- checkpoint.generation < 1 ||
428
- typeof checkpoint.capsule_id !== "string" ||
429
- !checkpoint.capsule_id ||
430
- !/^[a-f0-9]{64}$/.test(checkpoint.capsule_sha256) ||
431
- (current.phase !== "checkpointed" && current.phase !== "recovered")
432
- )
433
- return null;
434
- const capsule = readLatestCapsule(coordRoot, input.sessionId);
435
- if (
436
- !capsule ||
437
- !capsuleMatchesState(current, capsule, input) ||
438
- checkpoint.generation !== capsule.generation ||
439
- checkpoint.capsule_id !== capsule.capsule_id ||
440
- checkpoint.capsule_sha256 !== capsuleDigest(capsule)
441
- )
442
- return null;
443
- if (current.phase === "recovered") return current;
444
- const now = new Date().toISOString();
445
- const recovered: ContextContinuityState = {
446
- ...current,
447
- phase: "recovered",
448
- updated_at: now,
449
- recovered_at: now,
450
- degraded_reason: undefined,
451
- };
452
- writeState(coordRoot, recovered);
453
- return recovered;
454
- });
412
+ const path = statePath(coordRoot, input.sessionId);
413
+ const observed = readText(path);
414
+ if (observed === null) return null;
415
+ const current = parseJson<ContextContinuityState>(observed);
416
+ const { checkpoint } = input;
417
+ if (
418
+ !current ||
419
+ checkpoint.session_id !== input.sessionId ||
420
+ checkpoint.instance_id !== input.instanceId ||
421
+ checkpoint.generation !== current.generation ||
422
+ (current.phase !== "checkpointed" && current.phase !== "recovered")
423
+ )
424
+ return null;
425
+ const capsule = readLatestCapsule(coordRoot, input.sessionId);
426
+ if (
427
+ !capsule ||
428
+ !capsuleMatchesState(current, capsule, input) ||
429
+ capsule.capsule_id !== checkpoint.capsule_id
430
+ )
431
+ return null;
432
+ if (current.phase === "recovered") return current;
433
+ const now = new Date().toISOString();
434
+ const recovered: ContextContinuityState = {
435
+ ...current,
436
+ phase: "recovered",
437
+ updated_at: now,
438
+ recovered_at: now,
439
+ degraded_reason: undefined,
440
+ };
441
+ return writeBoundedJson(path, recovered, MAX_CAPSULE_BYTES, observed) ? recovered : null;
455
442
  }
456
443
 
457
444
  /** Record that the adapter, rather than Harnery, reported compaction complete. */
@@ -459,25 +446,23 @@ export function markContextCompactionCompleted(
459
446
  coordRoot: string,
460
447
  input: { sessionId: string; instanceId: string; observedAt?: string },
461
448
  ): ContextContinuityState {
462
- return withContextMutation(coordRoot, input.sessionId, () => {
463
- const now = input.observedAt ?? new Date().toISOString();
464
- const current =
465
- readContextState(coordRoot, input.sessionId) ??
466
- newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
467
- if (current.instance_id !== input.instanceId) return current;
468
- const next: ContextContinuityState = {
469
- ...current,
470
- instance_id: input.instanceId,
471
- phase: current.latest_capsule ? current.phase : "degraded",
472
- compaction_completed_at: now,
473
- updated_at: now,
474
- degraded_reason: current.latest_capsule
475
- ? current.degraded_reason
476
- : "post-compaction signal arrived without a continuity checkpoint",
477
- };
478
- writeState(coordRoot, next);
479
- return next;
480
- });
449
+ const now = input.observedAt ?? new Date().toISOString();
450
+ const current =
451
+ readContextState(coordRoot, input.sessionId) ??
452
+ newState({ sessionId: input.sessionId, instanceId: input.instanceId, now });
453
+ if (current.instance_id !== input.instanceId) return current;
454
+ const next: ContextContinuityState = {
455
+ ...current,
456
+ instance_id: input.instanceId,
457
+ phase: current.latest_capsule ? current.phase : "degraded",
458
+ compaction_completed_at: now,
459
+ updated_at: now,
460
+ degraded_reason: current.latest_capsule
461
+ ? current.degraded_reason
462
+ : "post-compaction signal arrived without a continuity checkpoint",
463
+ };
464
+ writeState(coordRoot, next);
465
+ return next;
481
466
  }
482
467
 
483
468
  /** Convenience API for callers whose injection is synchronous and guaranteed. */
@@ -520,29 +505,8 @@ export function readContextState(
520
505
 
521
506
  export function readLatestCapsule(coordRoot: string, sessionId: string): ContinuityCapsule | null {
522
507
  const state = readContextState(coordRoot, sessionId);
523
- if (typeof state?.latest_capsule !== "string" || !state.latest_capsule) return null;
524
- const capsule = readJson<ContinuityCapsule>(join(coordRoot, ".harnery", state.latest_capsule));
525
- if (
526
- !capsule ||
527
- !record(capsule.repo) ||
528
- !record(capsule.work) ||
529
- typeof capsule.repo.cwd !== "string" ||
530
- !Array.isArray(capsule.repo.dirty_paths) ||
531
- !capsule.repo.dirty_paths.every((path) => typeof path === "string") ||
532
- !Array.isArray(capsule.work.files_held) ||
533
- !capsule.work.files_held.every((path) => typeof path === "string")
534
- ) {
535
- return null;
536
- }
537
- capsule.repo.observation = repoObservation(capsule.repo.observation);
538
- capsule.repo.observation_detail = clampString(
539
- string(capsule.repo.observation_detail),
540
- MAX_OBSERVATION_DETAIL_CHARS,
541
- );
542
- capsule.repo.branch = string(capsule.repo.branch)?.trim() || undefined;
543
- capsule.repo.head = string(capsule.repo.head)?.trim() || undefined;
544
- capsule.repo.root = string(capsule.repo.root)?.trim() || undefined;
545
- return capsule;
508
+ if (!state?.latest_capsule) return null;
509
+ return readJson<ContinuityCapsule>(join(coordRoot, ".harnery", state.latest_capsule));
546
510
  }
547
511
 
548
512
  export function renderRecoveryBriefing(capsule: ContinuityCapsule, liveRepo: RepoSnapshot): string {
@@ -557,35 +521,10 @@ export function renderRecoveryBriefing(capsule: ContinuityCapsule, liveRepo: Rep
557
521
  lines.push(`Files held: ${capsule.work.files_held.join(", ")}`);
558
522
  }
559
523
 
560
- const unknown: string[] = [];
561
- if (!observationsComparable(capsule.repo, liveRepo)) {
562
- for (const [label, repo] of [
563
- ["checkpoint", capsule.repo],
564
- ["current repository", liveRepo],
565
- ] as const) {
566
- if (repo.observation !== "ok") {
567
- const detail = clampString(string(repo.observation_detail), MAX_OBSERVATION_DETAIL_CHARS);
568
- unknown.push(
569
- `${label}: ${repoObservation(repo.observation)}${detail ? ` (${detail})` : ""}`,
570
- );
571
- }
572
- }
573
- }
574
- if (capsule.repo.dirty_paths_truncated || liveRepo.dirty_paths_truncated) {
575
- unknown.push("dirty path list is truncated");
576
- }
577
- if (
578
- !capsule.repo.root ||
579
- !liveRepo.root ||
580
- !isAbsolute(capsule.repo.root) ||
581
- !isAbsolute(liveRepo.root)
582
- ) {
583
- unknown.push("repository root is unknown");
584
- } else if (capsule.repo.root !== liveRepo.root) {
585
- unknown.push("repository root differs from the checkpoint");
586
- }
587
- if (!capsule.repo.branch?.trim() || !liveRepo.branch?.trim()) unknown.push("branch is unknown");
588
- if (!capsule.repo.head?.trim() || !liveRepo.head?.trim()) unknown.push("HEAD is unknown");
524
+ const unknown = repoComparisonProblems(capsule.repo, liveRepo, [
525
+ "checkpoint",
526
+ "current repository",
527
+ ]);
589
528
  if (unknown.length > 0) {
590
529
  lines.push(
591
530
  `Repository could not be verified against the checkpoint: ${unknown.join("; ")}. Re-check before editing.`,
@@ -594,11 +533,11 @@ export function renderRecoveryBriefing(capsule: ContinuityCapsule, liveRepo: Rep
594
533
  }
595
534
 
596
535
  const drift: string[] = [];
597
- if (capsule.repo.branch && liveRepo.branch && capsule.repo.branch !== liveRepo.branch) {
536
+ if (capsule.repo.branch !== liveRepo.branch) {
598
537
  drift.push(`branch changed ${capsule.repo.branch} -> ${liveRepo.branch}`);
599
538
  }
600
- if (capsule.repo.head && liveRepo.head && capsule.repo.head !== liveRepo.head) {
601
- drift.push(`HEAD changed ${capsule.repo.head.slice(0, 8)} -> ${liveRepo.head.slice(0, 8)}`);
539
+ if (capsule.repo.head !== liveRepo.head) {
540
+ drift.push(`HEAD changed ${capsule.repo.head?.slice(0, 8)} -> ${liveRepo.head?.slice(0, 8)}`);
602
541
  }
603
542
  const beforeDirty = new Set(capsule.repo.dirty_paths);
604
543
  const afterDirty = new Set(liveRepo.dirty_paths);
@@ -715,47 +654,20 @@ function newState(input: {
715
654
  };
716
655
  }
717
656
 
657
+ /** The capsule is the one the state names, written for the requesting task. */
718
658
  function capsuleMatchesState(
719
659
  state: ContextContinuityState,
720
660
  capsule: ContinuityCapsule,
721
661
  input: { sessionId: string; instanceId: string },
722
662
  ): boolean {
723
663
  return (
724
- state.session_id === input.sessionId &&
725
664
  state.instance_id === input.instanceId &&
726
- capsule.session?.session_id === input.sessionId &&
727
- capsule.session?.instance_id === input.instanceId &&
728
- capsule.schema_version === CONTEXT_SCHEMA_VERSION &&
729
- Number.isSafeInteger(capsule.generation) &&
730
- capsule.generation > 0 &&
665
+ capsule.session.instance_id === input.instanceId &&
731
666
  capsule.generation === state.generation &&
732
- typeof capsule.capsule_id === "string" &&
733
- capsule.capsule_id.length > 0 &&
734
667
  state.latest_capsule === capsuleRelativePath(input.sessionId, capsule.generation)
735
668
  );
736
669
  }
737
670
 
738
- function capsuleDigest(capsule: ContinuityCapsule): string {
739
- return createHash("sha256").update(JSON.stringify(capsule)).digest("hex");
740
- }
741
-
742
- /** Every state writer shares the same crash-recoverable session reservation. */
743
- function withContextMutation<T>(coordRoot: string, sessionId: string, action: () => T): T {
744
- const authority = createHash("sha256").update(sessionId).digest("hex");
745
- const lease = acquireNoClobberLease({
746
- path: join(contextDir(coordRoot, sessionId), "mutation.lease"),
747
- scope: "context-continuity",
748
- authoritySha256: authority,
749
- staleAfterMs: 30_000,
750
- validateStaleOwner: (owner) => owner.authority_sha256 === authority,
751
- });
752
- try {
753
- return action();
754
- } finally {
755
- lease.release();
756
- }
757
- }
758
-
759
671
  function writeState(coordRoot: string, state: ContextContinuityState): void {
760
672
  writeBoundedJson(statePath(coordRoot, state.session_id), state, MAX_CAPSULE_BYTES);
761
673
  }
@@ -781,7 +693,17 @@ function sessionKey(sessionId: string): string {
781
693
  return createHash("sha256").update(sessionId).digest("hex").slice(0, 24);
782
694
  }
783
695
 
784
- function writeBoundedJson(path: string, value: unknown, maxBytes: number): void {
696
+ /**
697
+ * Atomically replace a record. With `expected`, the replacement happens only
698
+ * while the file still holds exactly those bytes, so a concurrent writer's
699
+ * newer record is never overwritten by a decision made on older state.
700
+ */
701
+ function writeBoundedJson(
702
+ path: string,
703
+ value: unknown,
704
+ maxBytes: number,
705
+ expected?: string,
706
+ ): boolean {
785
707
  const text = `${JSON.stringify(value, null, 2)}\n`;
786
708
  const bytes = Buffer.byteLength(text, "utf8");
787
709
  if (bytes > maxBytes) {
@@ -790,18 +712,35 @@ function writeBoundedJson(path: string, value: unknown, maxBytes: number): void
790
712
  mkdirSync(dirname(path), { recursive: true });
791
713
  const temp = `${path}.tmp.${process.pid}.${randomUUID()}`;
792
714
  writeFileSync(temp, text, { encoding: "utf8", mode: stateFileMode() });
715
+ if (expected !== undefined && readText(path) !== expected) {
716
+ unlinkSync(temp);
717
+ return false;
718
+ }
793
719
  renameSync(temp, path);
720
+ return true;
794
721
  }
795
722
 
796
- function readJson<T>(path: string): T | null {
797
- if (!existsSync(path)) return null;
723
+ function readText(path: string): string | null {
798
724
  try {
799
- return JSON.parse(readFileSync(path, "utf8")) as T;
725
+ return readFileSync(path, "utf8");
800
726
  } catch {
801
727
  return null;
802
728
  }
803
729
  }
804
730
 
731
+ function parseJson<T>(text: string): T | null {
732
+ try {
733
+ return JSON.parse(text) as T;
734
+ } catch {
735
+ return null;
736
+ }
737
+ }
738
+
739
+ function readJson<T>(path: string): T | null {
740
+ const text = readText(path);
741
+ return text === null ? null : parseJson<T>(text);
742
+ }
743
+
805
744
  type GitReadResult =
806
745
  | { ok: true; out: string }
807
746
  | { ok: false; reason: "not_repository" | "failed" | "timed_out" | "spawn_error" };
@@ -828,10 +767,6 @@ function git(cwd: string, args: string[]): GitReadResult {
828
767
  }
829
768
  }
830
769
 
831
- function repoObservation(value: unknown): RepoObservation {
832
- return value === "ok" || value === "partial" ? value : "unavailable";
833
- }
834
-
835
770
  function sameMeasurement(a: ContextSample | undefined, b: ContextSample): boolean {
836
771
  if (!a) return false;
837
772
  return (