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
@@ -1 +1 @@
1
- {"version":3,"file":"workflow.d.ts","sourceRoot":"","sources":["../../src/commands/workflow.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAmFnD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAqyBjF"}
1
+ {"version":3,"file":"workflow.d.ts","sourceRoot":"","sources":["../../src/commands/workflow.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AA2EnD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CA2vBjF"}
@@ -475,9 +475,7 @@ export function registerWorkflowCommand(program, emit) {
475
475
  .description("Preview reclaim of an inactive owned workspace, or confirm salvage or discard and release.")
476
476
  .option("--yes", "Confirm that this commits or discards uncommitted work, then releases")
477
477
  .option("--discard", "Throw the uncommitted work away instead of salvaging it")
478
- .option("--unknown-liveness <sha256>", "Recover unknown evidence pinned by the preview digest; active evidence still refuses")
479
- .option("--actor <id>", "Actor responsible for an audited recovery")
480
- .option("--reason <text>", "Reason for an audited recovery")
478
+ .option("--force", "With --yes, admit missing or corrupt liveness evidence (active evidence still refuses) and record it")
481
479
  .option("--json", "Emit the reclaim preview or confirmed result as JSON")
482
480
  .action(async (runId, opts) => {
483
481
  const coordRoot = resolveCoordRoot();
@@ -502,13 +500,7 @@ export function registerWorkflowCommand(program, emit) {
502
500
  const result = await provider.reclaim(binding, {
503
501
  mode: opts.discard ? "discard" : "salvage",
504
502
  yes: opts.yes,
505
- unknownLiveness: opts.unknownLiveness
506
- ? {
507
- evidenceSha256: opts.unknownLiveness,
508
- actor: opts.actor,
509
- reason: opts.reason,
510
- }
511
- : undefined,
503
+ force: opts.force,
512
504
  });
513
505
  if (opts.json) {
514
506
  emit.config({ format: "json" });
@@ -519,11 +511,13 @@ export function registerWorkflowCommand(program, emit) {
519
511
  const holders = result.lease_holder.map((holder) => `${holder.scope}: ${holder.owner_id ?? "unknown"}${holder.pid ? ` (pid ${holder.pid})` : ""}${holder.detail ? `; ${holder.detail}` : ""}`);
520
512
  emit.text(`reclaim preview: ${result.run_id}\n` +
521
513
  `mode: ${result.mode}\nliveness: ${result.liveness}\nownership: ${result.ownership}\n` +
522
- `evidence sha256: ${result.evidence_sha256}\n` +
523
514
  `lease holder: ${holders.length > 0 ? holders.join(", ") : "none observed"}\n` +
524
515
  `dirty paths: ${result.dirty_paths === null ? "unknown" : result.dirty_paths.length > 0 ? result.dirty_paths.join(", ") : "none"}\n` +
525
516
  (result.ownership_detail ? `ownership detail: ${result.ownership_detail}\n` : "") +
526
- `Pass --yes to confirm ${result.mode} and release after all checks pass.\n`);
517
+ `Pass --yes to confirm ${result.mode} and release after all checks pass.\n` +
518
+ (result.liveness === "unknown"
519
+ ? "Liveness is unknown; add --force with --yes only if the run is known to be stopped.\n"
520
+ : ""));
527
521
  return;
528
522
  }
529
523
  emit.text(`reclaim ${result.preparation.action}: ${result.preparation.detail}\n` +
@@ -539,39 +533,6 @@ export function registerWorkflowCommand(program, emit) {
539
533
  process.exit(1);
540
534
  }
541
535
  });
542
- workflow
543
- .command("unlock <run-id>")
544
- .description("Preview or explicitly retire one exact resume lock whose local owner is proven dead.")
545
- .option("--lock-sha256 <sha256>", "Exact lock identity digest returned by the preview")
546
- .option("--actor <id>", "Actor responsible for the recovery")
547
- .option("--reason <text>", "Reason for the recovery")
548
- .option("--yes", "Confirm removal after dead-owner and exact-lock checks")
549
- .option("--json", "Emit the recovery preview or result as JSON")
550
- .action(async (runId, opts) => {
551
- try {
552
- const coordRoot = resolveCoordRoot();
553
- if (!coordRoot)
554
- throw new Error("no .harnery/ coordination root found");
555
- const { recoverWorkflowResumeLease } = await import("../core/workflow/index.js");
556
- const result = recoverWorkflowResumeLease(coordRoot, runId, opts);
557
- if (opts.json) {
558
- emit.config({ format: "json" });
559
- emit.data(result);
560
- return;
561
- }
562
- emit.text(`resume lock ${result.action}: ${runId}\nowner: ${result.owner_state}\nlock sha256: ${result.lock_sha256 ?? "absent"}\n` +
563
- (result.action === "preview"
564
- ? "Pass --lock-sha256, --actor, --reason and --yes to confirm exact dead-owner recovery.\n"
565
- : `recovery: ${result.recovery_id}\n`));
566
- }
567
- catch (error) {
568
- emit.error({
569
- code: "workflow_resume_unlock_failed",
570
- message: error instanceof Error ? error.message : String(error),
571
- });
572
- process.exit(1);
573
- }
574
- });
575
536
  const approvals = program
576
537
  .command("approval")
577
538
  .description("Inspect and resolve durable run-policy approvals.");
@@ -1 +1 @@
1
- {"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAeH;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAWhE;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,MAAM,CAI5E;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAOhF;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CA+B7E;AAED,iEAAiE;AACjE,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE7D;AAeD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGtD;AAiED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED,mFAAmF;AACnF,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA+DxD;AAgCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,iBAAiB,GAAG,aAAa,GAAG,kBAAkB,GAAG,MAAM,CAAC;CAC5F,CAsEA;AAED;;;;;;;;;GASG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,MAAM,GACX;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAsB5C;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG;IAClD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,QAAQ,GAAG,iBAAiB,GAAG,MAAM,CAAC;CAC/C,CAyBA;AAyBD,sFAAsF;AACtF;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,GAAG,IAAI,CAEtD;AAuCD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAEpE;AAED;;;;;;GAMG;AACH,wBAAgB,gCAAgC,CAC9C,IAAI,EAAE,MAAM,GACX;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAmB9C;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAOpE"}
1
+ {"version":3,"file":"coord-client.d.ts","sourceRoot":"","sources":["../../../src/core/agents/coord-client.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAeH;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAWhE;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,KAAK,IAAI,MAAM,CAI5E;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAOhF;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,GAAE,MAAsB,GAAG,MAAM,GAAG,IAAI,CA+B7E;AAED,iEAAiE;AACjE,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE7D;AAeD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGtD;AAiED,qEAAqE;AACrE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,+EAA+E;AAC/E,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAG1D;AAED,mFAAmF;AACnF,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA2CxD;AAgCD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,IAAI,MAAM,GAAG,IAAI,CAE5C;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,IAAI;IACxC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,KAAK,GAAG,QAAQ,GAAG,iBAAiB,GAAG,aAAa,GAAG,kBAAkB,GAAG,MAAM,CAAC;CAC5F,CAsEA;AAED;;;;;;;;;GASG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,MAAM,GACX;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAsB5C;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG;IAClD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,QAAQ,GAAG,iBAAiB,GAAG,MAAM,CAAC;CAC/C,CAyBA;AAyBD,sFAAsF;AACtF;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,GAAG,IAAI,CAEtD;AAuCD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAEpE;AAED;;;;;;GAMG;AACH,wBAAgB,gCAAgC,CAC9C,IAAI,EAAE,MAAM,GACX;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAmB9C;AAED;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAOpE"}
@@ -251,25 +251,7 @@ export function parsePidmapRowStartToken(row) {
251
251
  * a test can hold this one against `processStartToken` and fail on drift.
252
252
  */
253
253
  export function pidStartToken(pid) {
254
- if (!Number.isInteger(pid) || pid <= 0)
255
- return null;
256
254
  const forced = process.env.HARNERY_PID_PROBE;
257
- if (process.platform === "win32" && forced !== "procfs" && forced !== "ps") {
258
- try {
259
- const out = spawnSync("powershell.exe", [
260
- "-NoLogo",
261
- "-NoProfile",
262
- "-NonInteractive",
263
- "-Command",
264
- `$process = [Diagnostics.Process]::GetProcessById(${pid}); try { 'w' + $process.StartTime.ToFileTimeUtc().ToString([Globalization.CultureInfo]::InvariantCulture) } finally { $process.Dispose() }`,
265
- ], { encoding: "utf8", timeout: 10_000, windowsHide: true });
266
- const token = String(out.stdout ?? "").trim();
267
- return out.status === 0 && /^w\d+$/.test(token) ? token : null;
268
- }
269
- catch {
270
- return null;
271
- }
272
- }
273
255
  const useProcfs = forced === "procfs" || (forced !== "ps" && existsSync("/proc/self/stat"));
274
256
  // One machine, one probe. Falling back to the other on a read failure would
275
257
  // answer in the wrong dialect and read as a recycled pid.
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
17
17
  import { dirname, join } from "node:path";
18
- import { checkPidToken, processStartToken } from "./proc-start.js";
18
+ import { processStartToken, verifyProcessGeneration } from "./proc-start.js";
19
19
  /**
20
20
  * Row count that triggers a dead-row sweep on the next write.
21
21
  *
@@ -44,7 +44,7 @@ export function parsePidmapRow(row) {
44
44
  function rowStillNamesItsProcess(pid, startToken) {
45
45
  if (!pidIsAlive(pid))
46
46
  return false;
47
- return checkPidToken(pid, startToken) !== "mismatch";
47
+ return verifyProcessGeneration(pid, startToken) !== "different";
48
48
  }
49
49
  function atomicWrite(path, content) {
50
50
  mkdirSync(dirname(path), { recursive: true });
@@ -30,9 +30,8 @@
30
30
  * through the caller's locale and timezone, so both are pinned; without that,
31
31
  * two callers reading the same live process disagree.
32
32
  */
33
- import { signalProcessWithHandle } from "./process-handle.js";
34
33
  /** Which probe answers "when did this process start?" on this machine. */
35
- export type StartTokenProbe = "procfs" | "ps" | "windows";
34
+ export type StartTokenProbe = "procfs" | "ps";
36
35
  /**
37
36
  * Which probe this machine uses, decided once.
38
37
  *
@@ -50,7 +49,7 @@ export declare function resetStartTokenCaches(): void;
50
49
  * than by splitting the whole line, because comm is the executable name in
51
50
  * parentheses and may itself contain spaces and parens. The boot segment is
52
51
  * dropped rather than faked when the kernel will not name its boot, which
53
- * yields the pre-boot-id shape that `tokensAgree` still accepts.
52
+ * yields the pre-boot-id shape that `verifyProcessGeneration` compares by ticks.
54
53
  */
55
54
  export declare function procfsStartToken(pid: number): string | null;
56
55
  /**
@@ -71,43 +70,33 @@ export declare function psStartToken(pid: number, run?: (pid: number) => {
71
70
  export declare function processStartToken(pid: number): string | null;
72
71
  export type ProcessGenerationVerdict = "same" | "different" | "unknown";
73
72
  type ReadProcessStartToken = (pid: number) => string | null;
74
- /** A missing or unreadable token never establishes process identity. */
73
+ /**
74
+ * Does `pid` still name the run that wrote `recordedToken`?
75
+ *
76
+ * - `"same"`: both tokens present and equal.
77
+ * - `"different"`: the pid was recycled. A liveness check cannot see this,
78
+ * because a recycled pid is alive.
79
+ * - `"unknown"`: a token is missing, malformed, or unreadable. Never treat
80
+ * this as proof of identity.
81
+ *
82
+ * A Linux token written without a boot segment cannot identify a run across
83
+ * reboots, so it is compared on its tick count: a different count proves a
84
+ * different run, and an equal count stays unknown.
85
+ */
75
86
  export declare function verifyProcessGeneration(pid: number, recordedToken: string | null | undefined, readToken?: ReadProcessStartToken): ProcessGenerationVerdict;
76
87
  export interface ProcessGenerationSignalDependencies {
77
88
  readToken?: ReadProcessStartToken;
78
- signalProcess?: typeof signalProcessWithHandle;
89
+ kill?: (pid: number, signal: NodeJS.Signals | number) => unknown;
79
90
  }
80
91
  /**
81
- * Observations may refuse a signal, but only the retained native process handle
82
- * can authorize it. No numeric-PID effect follows the preliminary token read.
92
+ * Recheck the process generation immediately before sending the signal.
93
+ *
94
+ * The process can still exit and its PID can be reused between the token read
95
+ * and the signal. Eliminating that race requires a generation-bound OS handle.
83
96
  */
84
97
  export declare function signalIfSameGeneration(pid: number, recordedToken: string | null | undefined, signal: NodeJS.Signals | number, dependencies?: ProcessGenerationSignalDependencies): {
85
98
  sent: boolean;
86
99
  verdict: ProcessGenerationVerdict;
87
100
  };
88
- /**
89
- * Do two tokens describe the same run?
90
- *
91
- * Equality, plus one concession: a row written before tokens carried a boot
92
- * segment recorded the tick count alone, so it is compared on the part it
93
- * actually recorded. Refusing that would call every pre-upgrade row a recycled
94
- * pid and prune a working machine's live rows the first time the new code ran.
95
- * Such a row loses its reboot protection until the next write rewrites it,
96
- * which is a strictly better position than the one it came from.
97
- */
98
- export declare function tokensAgree(recorded: string, current: string): boolean;
99
- /**
100
- * Does `pid` still name the process a row recorded?
101
- *
102
- * Three answers, and the difference between the last two matters:
103
- *
104
- * - `"match"` — recorded token, current token, same run. The row is trustworthy.
105
- * - `"mismatch"` — both present and different. The pid was recycled. This is
106
- * the case a liveness check cannot see, because a recycled pid is alive.
107
- * - `"unverified"` — no recorded token (a row written before tokens existed) or
108
- * no current one (an unsupported platform). Callers treat this the way they
109
- * treated every row previously: trust it if the pid is alive.
110
- */
111
- export declare function checkPidToken(pid: number, recorded: string | undefined): "match" | "mismatch" | "unverified";
112
101
  export {};
113
102
  //# sourceMappingURL=proc-start.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"proc-start.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/proc-start.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAKH,OAAO,EAAE,uBAAuB,EAA4B,MAAM,qBAAqB,CAAC;AAExF,0EAA0E;AAC1E,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,IAAI,GAAG,SAAS,CAAC;AAc1D;;;;;;GAMG;AACH,wBAAgB,eAAe,IAAI,eAAe,CAQjD;AAyBD,uDAAuD;AACvD,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc3D;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,MAAM,EACX,GAAG,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK;IAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAiB,GAC7E,MAAM,GAAG,IAAI,CAUf;AAWD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ5D;AAED,MAAM,MAAM,wBAAwB,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,CAAC;AAExE,KAAK,qBAAqB,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;AAE5D,wEAAwE;AACxE,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,SAAS,GAAE,qBAAyC,GACnD,wBAAwB,CAkB1B;AAED,MAAM,WAAW,mCAAmC;IAClD,SAAS,CAAC,EAAE,qBAAqB,CAAC;IAClC,aAAa,CAAC,EAAE,OAAO,uBAAuB,CAAC;CAChD;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,EAC/B,YAAY,GAAE,mCAAwC,GACrD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,wBAAwB,CAAA;CAAE,CAQtD;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAQtE;AAOD;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAC3B,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC3B,OAAO,GAAG,UAAU,GAAG,YAAY,CAKrC"}
1
+ {"version":3,"file":"proc-start.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/proc-start.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAMH,0EAA0E;AAC1E,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,IAAI,CAAC;AAc9C;;;;;;GAMG;AACH,wBAAgB,eAAe,IAAI,eAAe,CAOjD;AAyBD,uDAAuD;AACvD,wBAAgB,qBAAqB,IAAI,IAAI,CAG5C;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAc3D;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,MAAM,EACX,GAAG,GAAE,CAAC,GAAG,EAAE,MAAM,KAAK;IAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAiB,GAC7E,MAAM,GAAG,IAAI,CAUf;AAWD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG5D;AAED,MAAM,MAAM,wBAAwB,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,CAAC;AAExE,KAAK,qBAAqB,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;AAE5D;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,SAAS,GAAE,qBAAyC,GACnD,wBAAwB,CAuB1B;AAOD,MAAM,WAAW,mCAAmC;IAClD,SAAS,CAAC,EAAE,qBAAqB,CAAC;IAClC,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,KAAK,OAAO,CAAC;CAClE;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,MAAM,EAAE,MAAM,CAAC,OAAO,GAAG,MAAM,EAC/B,YAAY,GAAE,mCAAwC,GACrD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,wBAAwB,CAAA;CAAE,CAUtD"}
@@ -33,7 +33,6 @@
33
33
  import { spawnSync } from "node:child_process";
34
34
  import { existsSync, readFileSync } from "node:fs";
35
35
  import { coordEnv } from "../../../lib/env.js";
36
- import { signalProcessWithHandle, windowsProcessStartToken } from "./process-handle.js";
37
36
  /**
38
37
  * The `ps` probe is text formatted by `strftime("%c", localtime(...))`, so its
39
38
  * output moves with `TZ` and `LC_TIME`. A hook running under `LC_ALL=C` (git
@@ -55,8 +54,6 @@ export function startTokenProbe() {
55
54
  const override = coordEnv("PID_PROBE");
56
55
  if (override === "procfs" || override === "ps")
57
56
  return override;
58
- if (process.platform === "win32")
59
- return "windows";
60
57
  if (cachedProbe === undefined) {
61
58
  cachedProbe = existsSync("/proc/self/stat") ? "procfs" : "ps";
62
59
  }
@@ -98,7 +95,7 @@ export function resetStartTokenCaches() {
98
95
  * than by splitting the whole line, because comm is the executable name in
99
96
  * parentheses and may itself contain spaces and parens. The boot segment is
100
97
  * dropped rather than faked when the kernel will not name its boot, which
101
- * yields the pre-boot-id shape that `tokensAgree` still accepts.
98
+ * yields the pre-boot-id shape that `verifyProcessGeneration` compares by ticks.
102
99
  */
103
100
  export function procfsStartToken(pid) {
104
101
  let stat;
@@ -152,93 +149,63 @@ function defaultPsRun(pid) {
152
149
  export function processStartToken(pid) {
153
150
  if (!Number.isFinite(pid) || pid <= 0)
154
151
  return null;
155
- const probe = startTokenProbe();
156
- return probe === "windows"
157
- ? windowsProcessStartToken(pid)
158
- : probe === "procfs"
159
- ? procfsStartToken(pid)
160
- : psStartToken(pid);
152
+ return startTokenProbe() === "procfs" ? procfsStartToken(pid) : psStartToken(pid);
161
153
  }
162
- /** A missing or unreadable token never establishes process identity. */
154
+ /**
155
+ * Does `pid` still name the run that wrote `recordedToken`?
156
+ *
157
+ * - `"same"`: both tokens present and equal.
158
+ * - `"different"`: the pid was recycled. A liveness check cannot see this,
159
+ * because a recycled pid is alive.
160
+ * - `"unknown"`: a token is missing, malformed, or unreadable. Never treat
161
+ * this as proof of identity.
162
+ *
163
+ * A Linux token written without a boot segment cannot identify a run across
164
+ * reboots, so it is compared on its tick count: a different count proves a
165
+ * different run, and an equal count stays unknown.
166
+ */
163
167
  export function verifyProcessGeneration(pid, recordedToken, readToken = processStartToken) {
164
168
  if (!Number.isInteger(pid) ||
165
169
  pid <= 0 ||
166
170
  typeof recordedToken !== "string" ||
167
171
  !recordedToken.trim())
168
172
  return "unknown";
169
- // A bootless Linux token cannot identify a process across machine boots.
170
- if (/^l\d+$/.test(recordedToken))
171
- return "unknown";
173
+ let currentToken;
172
174
  try {
173
- const currentToken = readToken(pid);
174
- if (typeof currentToken !== "string" || !currentToken.trim())
175
- return "unknown";
176
- if (/^l\d+$/.test(currentToken))
177
- return "unknown";
178
- return currentToken === recordedToken ? "same" : "different";
175
+ currentToken = readToken(pid);
179
176
  }
180
177
  catch {
181
178
  return "unknown";
182
179
  }
180
+ if (typeof currentToken !== "string" || !currentToken.trim())
181
+ return "unknown";
182
+ const bootless = (token) => /^l\d+$/.test(token);
183
+ if (recordedToken[0] === "l" &&
184
+ currentToken[0] === "l" &&
185
+ (bootless(recordedToken) || bootless(currentToken)))
186
+ return ticksOf(recordedToken) === ticksOf(currentToken) ? "unknown" : "different";
187
+ return currentToken === recordedToken ? "same" : "different";
188
+ }
189
+ function ticksOf(token) {
190
+ const dot = token.indexOf(".");
191
+ return dot === -1 ? token.slice(1) : token.slice(dot + 1);
183
192
  }
184
193
  /**
185
- * Observations may refuse a signal, but only the retained native process handle
186
- * can authorize it. No numeric-PID effect follows the preliminary token read.
194
+ * Recheck the process generation immediately before sending the signal.
195
+ *
196
+ * The process can still exit and its PID can be reused between the token read
197
+ * and the signal. Eliminating that race requires a generation-bound OS handle.
187
198
  */
188
199
  export function signalIfSameGeneration(pid, recordedToken, signal, dependencies = {}) {
200
+ const kill = dependencies.kill ?? process.kill;
189
201
  const verdict = verifyProcessGeneration(pid, recordedToken, dependencies.readToken);
190
202
  if (verdict !== "same")
191
203
  return { sent: false, verdict };
192
204
  try {
193
- return (dependencies.signalProcess ?? signalProcessWithHandle)(pid, recordedToken, signal);
205
+ kill(pid, signal);
206
+ return { sent: true, verdict };
194
207
  }
195
208
  catch {
196
- return { sent: false, verdict: "unknown" };
209
+ return { sent: false, verdict };
197
210
  }
198
211
  }
199
- /**
200
- * Do two tokens describe the same run?
201
- *
202
- * Equality, plus one concession: a row written before tokens carried a boot
203
- * segment recorded the tick count alone, so it is compared on the part it
204
- * actually recorded. Refusing that would call every pre-upgrade row a recycled
205
- * pid and prune a working machine's live rows the first time the new code ran.
206
- * Such a row loses its reboot protection until the next write rewrites it,
207
- * which is a strictly better position than the one it came from.
208
- */
209
- export function tokensAgree(recorded, current) {
210
- if (recorded === current)
211
- return true;
212
- // Only the Linux form has a boot segment to disagree about, and only when
213
- // exactly one side carries one is this a shape difference rather than two
214
- // genuinely different start instants.
215
- if (recorded[0] !== "l" || current[0] !== "l")
216
- return false;
217
- if (recorded.includes(".") === current.includes("."))
218
- return false;
219
- return ticksOf(recorded) === ticksOf(current);
220
- }
221
- function ticksOf(token) {
222
- const dot = token.indexOf(".");
223
- return dot === -1 ? token.slice(1) : token.slice(dot + 1);
224
- }
225
- /**
226
- * Does `pid` still name the process a row recorded?
227
- *
228
- * Three answers, and the difference between the last two matters:
229
- *
230
- * - `"match"` — recorded token, current token, same run. The row is trustworthy.
231
- * - `"mismatch"` — both present and different. The pid was recycled. This is
232
- * the case a liveness check cannot see, because a recycled pid is alive.
233
- * - `"unverified"` — no recorded token (a row written before tokens existed) or
234
- * no current one (an unsupported platform). Callers treat this the way they
235
- * treated every row previously: trust it if the pid is alive.
236
- */
237
- export function checkPidToken(pid, recorded) {
238
- if (!recorded)
239
- return "unverified";
240
- const current = processStartToken(pid);
241
- if (!current)
242
- return "unverified";
243
- return tokensAgree(recorded, current) ? "match" : "mismatch";
244
- }
@@ -0,0 +1,61 @@
1
+ import { type ProcessGenerationSignalDependencies, type ProcessGenerationVerdict } from "./proc-start.js";
2
+ export interface ServiceProcessDependencies extends ProcessGenerationSignalDependencies {
3
+ /** Throws ESRCH, or returns false, when the process is gone. */
4
+ probePid?: (pid: number) => unknown;
5
+ }
6
+ export type ServiceLiveness = "running" | "stopped" | "unknown";
7
+ export interface ServiceProcessOwner {
8
+ pid: number;
9
+ host: string;
10
+ start_token?: string;
11
+ state?: string;
12
+ }
13
+ /**
14
+ * A stale heartbeat does not prove that a process has exited. Another host, a
15
+ * probe error other than ESRCH, or an unverifiable token answers unknown. A
16
+ * recorded `stopped` state counts only while the generation still matches.
17
+ */
18
+ export declare function observeServiceProcess(owner: ServiceProcessOwner, dependencies?: ServiceProcessDependencies): {
19
+ generation: ProcessGenerationVerdict;
20
+ liveness: ServiceLiveness;
21
+ };
22
+ /** Refuse a start while another owner runs or cannot be identified. */
23
+ export declare function serviceStartRefusal(label: string, stopCommand: string, pid: number | undefined, liveness: ServiceLiveness): Error;
24
+ export interface ServiceLease {
25
+ pid: number;
26
+ start_token?: string;
27
+ host: string;
28
+ nonce: string;
29
+ created_at?: string;
30
+ }
31
+ export declare function readServiceLease(path: string): ServiceLease | undefined;
32
+ /** An absent lease is stopped; an unreadable one cannot be identified. */
33
+ export declare function serviceLeaseOwner(path: string): {
34
+ lease?: ServiceLease;
35
+ liveness: ServiceLiveness;
36
+ };
37
+ /**
38
+ * Take the singleton lease with an exclusive create. A lease whose owner is
39
+ * proven stopped is replaced; any other owner refuses the start.
40
+ */
41
+ export declare function acquireServiceLease(path: string, refuse: (pid: number | undefined, liveness: ServiceLiveness) => Error, fields?: Record<string, unknown>): {
42
+ nonce: string;
43
+ release: () => void;
44
+ };
45
+ export interface ServiceStopFields {
46
+ signal_sent: boolean;
47
+ signal_verdict: ProcessGenerationVerdict;
48
+ }
49
+ /**
50
+ * Write the durable stop request, then SIGTERM the owner `status` observed as
51
+ * running on this host, after a fresh generation check. The request stays for
52
+ * the daemon either way; `read` reports the state afterward.
53
+ */
54
+ export declare function requestServiceStop<S extends {
55
+ liveness: ServiceLiveness;
56
+ generation: ProcessGenerationVerdict;
57
+ record?: ServiceProcessOwner;
58
+ }>(status: S, read: () => S, stopPath: string, dependencies?: ServiceProcessDependencies, options?: {
59
+ signal?: boolean;
60
+ }): S & ServiceStopFields;
61
+ //# sourceMappingURL=service-process.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-process.d.ts","sourceRoot":"","sources":["../../../../src/core/agents/state/service-process.ts"],"names":[],"mappings":"AAmBA,OAAO,EACL,KAAK,mCAAmC,EACxC,KAAK,wBAAwB,EAI9B,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,0BAA2B,SAAQ,mCAAmC;IACrF,gEAAgE;IAChE,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;CACrC;AAED,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,CAAC;AAEhE,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,mBAAmB,EAC1B,YAAY,GAAE,0BAA+B,GAC5C;IAAE,UAAU,EAAE,wBAAwB,CAAC;IAAC,QAAQ,EAAE,eAAe,CAAA;CAAE,CAuBrE;AAED,uEAAuE;AACvE,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,GAAG,SAAS,EACvB,QAAQ,EAAE,eAAe,GACxB,KAAK,CAMP;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAmBvE;AAED,0EAA0E;AAC1E,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG;IAC/C,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,QAAQ,EAAE,eAAe,CAAC;CAC3B,CAIA;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,eAAe,KAAK,KAAK,EACrE,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACnC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CA0CxC;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,OAAO,CAAC;IACrB,cAAc,EAAE,wBAAwB,CAAC;CAC1C;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,CAAC,SAAS;IACR,QAAQ,EAAE,eAAe,CAAC;IAC1B,UAAU,EAAE,wBAAwB,CAAC;IACrC,MAAM,CAAC,EAAE,mBAAmB,CAAC;CAC9B,EAED,MAAM,EAAE,CAAC,EACT,IAAI,EAAE,MAAM,CAAC,EACb,QAAQ,EAAE,MAAM,EAChB,YAAY,GAAE,0BAA+B,EAC7C,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAO,GACjC,CAAC,GAAG,iBAAiB,CAevB"}
@@ -0,0 +1,143 @@
1
+ /**
2
+ * One owner check for every singleton background service (governor, local
3
+ * supervisor, legacy resource observer): is the recorded process still the
4
+ * run that wrote the record, and how is a stop requested?
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
8
+ import { hostname } from "node:os";
9
+ import { dirname } from "node:path";
10
+ import { writePrivateJsonAtomic } from "../../storage/atomic-json.js";
11
+ import { stateDirMode, stateFileMode } from "../../storage/modes.js";
12
+ import { processStartToken, signalIfSameGeneration, verifyProcessGeneration, } from "./proc-start.js";
13
+ /**
14
+ * A stale heartbeat does not prove that a process has exited. Another host, a
15
+ * probe error other than ESRCH, or an unverifiable token answers unknown. A
16
+ * recorded `stopped` state counts only while the generation still matches.
17
+ */
18
+ export function observeServiceProcess(owner, dependencies = {}) {
19
+ if (owner.host !== hostname())
20
+ return { generation: "unknown", liveness: "unknown" };
21
+ try {
22
+ if ((dependencies.probePid ?? ((pid) => process.kill(pid, 0)))(owner.pid) === false)
23
+ return { generation: "unknown", liveness: "stopped" };
24
+ }
25
+ catch (error) {
26
+ return {
27
+ generation: "unknown",
28
+ liveness: error.code === "ESRCH" ? "stopped" : "unknown",
29
+ };
30
+ }
31
+ const generation = verifyProcessGeneration(owner.pid, owner.start_token, dependencies.readToken);
32
+ return {
33
+ generation,
34
+ liveness: generation === "same"
35
+ ? owner.state === "stopped"
36
+ ? "stopped"
37
+ : "running"
38
+ : generation === "different"
39
+ ? "stopped"
40
+ : "unknown",
41
+ };
42
+ }
43
+ /** Refuse a start while another owner runs or cannot be identified. */
44
+ export function serviceStartRefusal(label, stopCommand, pid, liveness) {
45
+ return new Error(liveness === "running"
46
+ ? `${label} is already running under pid ${pid}`
47
+ : `${label} process generation is unknown${pid ? ` for pid ${pid}` : ""}; run \`${stopCommand}\` to write a durable stop request before starting it again`);
48
+ }
49
+ export function readServiceLease(path) {
50
+ try {
51
+ const value = JSON.parse(readFileSync(path, "utf8"));
52
+ if (!Number.isSafeInteger(value.pid) ||
53
+ (value.pid ?? 0) <= 0 ||
54
+ typeof value.host !== "string" ||
55
+ !value.host ||
56
+ typeof value.nonce !== "string" ||
57
+ !value.nonce)
58
+ return undefined;
59
+ return {
60
+ ...value,
61
+ start_token: typeof value.start_token === "string" ? value.start_token : undefined,
62
+ };
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ /** An absent lease is stopped; an unreadable one cannot be identified. */
69
+ export function serviceLeaseOwner(path) {
70
+ if (!existsSync(path))
71
+ return { liveness: "stopped" };
72
+ const lease = readServiceLease(path);
73
+ return { lease, liveness: lease ? observeServiceProcess(lease).liveness : "unknown" };
74
+ }
75
+ /**
76
+ * Take the singleton lease with an exclusive create. A lease whose owner is
77
+ * proven stopped is replaced; any other owner refuses the start.
78
+ */
79
+ export function acquireServiceLease(path, refuse, fields = {}) {
80
+ mkdirSync(dirname(path), { recursive: true, mode: stateDirMode() });
81
+ for (let attempt = 0; attempt < 2; attempt++) {
82
+ const startToken = processStartToken(process.pid);
83
+ const lease = {
84
+ ...fields,
85
+ pid: process.pid,
86
+ ...(startToken ? { start_token: startToken } : {}),
87
+ host: hostname(),
88
+ nonce: randomUUID(),
89
+ created_at: new Date().toISOString(),
90
+ };
91
+ try {
92
+ const fd = openSync(path, "wx", stateFileMode());
93
+ try {
94
+ writeFileSync(fd, `${JSON.stringify(lease)}\n`, "utf8");
95
+ }
96
+ finally {
97
+ closeSync(fd);
98
+ }
99
+ return {
100
+ nonce: lease.nonce,
101
+ release: () => {
102
+ if (readServiceLease(path)?.nonce !== lease.nonce)
103
+ return;
104
+ try {
105
+ unlinkSync(path);
106
+ }
107
+ catch {
108
+ // A missing lease is already released.
109
+ }
110
+ },
111
+ };
112
+ }
113
+ catch (error) {
114
+ if (error.code !== "EEXIST")
115
+ throw error;
116
+ const owner = serviceLeaseOwner(path);
117
+ if (owner.liveness !== "stopped")
118
+ throw refuse(owner.lease?.pid, owner.liveness);
119
+ try {
120
+ unlinkSync(path);
121
+ }
122
+ catch {
123
+ // A concurrent starter may have replaced or removed the stale lease.
124
+ }
125
+ }
126
+ }
127
+ throw new Error(`could not acquire the singleton lease at ${path}`);
128
+ }
129
+ /**
130
+ * Write the durable stop request, then SIGTERM the owner `status` observed as
131
+ * running on this host, after a fresh generation check. The request stays for
132
+ * the daemon either way; `read` reports the state afterward.
133
+ */
134
+ export function requestServiceStop(status, read, stopPath, dependencies = {}, options = {}) {
135
+ writePrivateJsonAtomic(stopPath, {
136
+ requested_at: new Date().toISOString(),
137
+ requested_by_pid: process.pid,
138
+ });
139
+ const signal = options.signal !== false && status.liveness === "running" && status.record?.host === hostname()
140
+ ? signalIfSameGeneration(status.record.pid, status.record.start_token, "SIGTERM", dependencies)
141
+ : { sent: false, verdict: status.generation };
142
+ return { ...read(), signal_sent: signal.sent, signal_verdict: signal.verdict };
143
+ }
@@ -7,7 +7,7 @@
7
7
  import type { Adapter } from "../adapter.js";
8
8
  import type { RepoSnapshot } from "./observation.js";
9
9
  export type { RepoObservation, RepoSnapshot } from "./observation.js";
10
- export { observationsComparable } from "./observation.js";
10
+ export { repoComparisonProblems } from "./observation.js";
11
11
  export declare const CONTEXT_SCHEMA_VERSION: 1;
12
12
  export declare const MAX_CAPSULE_BYTES: number;
13
13
  export type ContextSampleSource = "hook" | "native_event" | "result" | "transcript" | "estimate";
@@ -97,7 +97,6 @@ export interface PreparedContextCheckpoint {
97
97
  instance_id: string;
98
98
  generation: number;
99
99
  capsule_id: string;
100
- capsule_sha256: string;
101
100
  }
102
101
  export interface PreparedContextRecovery {
103
102
  checkpoint: Readonly<PreparedContextCheckpoint>;
@@ -126,8 +125,11 @@ export declare function prepareContextRecovery(coordRoot: string, input: {
126
125
  }): PreparedContextRecovery | null;
127
126
  /**
128
127
  * Advance continuity only after the adapter accepted the prepared briefing.
129
- * Missing, altered, foreign or superseded checkpoints refuse without writing.
130
- * Repeating the same accepted checkpoint is an exact no-op.
128
+ * A foreign or superseded checkpoint refuses without writing, and repeating
129
+ * the same accepted checkpoint is an exact no-op. The write is a
130
+ * compare-and-write: if the state changed after it was read, for example
131
+ * because a newer checkpoint landed, completion refuses instead of
132
+ * overwriting it.
131
133
  */
132
134
  export declare function completeContextRecovery(coordRoot: string, input: {
133
135
  sessionId: string;