harnery 0.44.0 → 0.45.1

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 (138) hide show
  1. package/dist/commands/workflow.d.ts.map +1 -1
  2. package/dist/commands/workflow.js +44 -0
  3. package/dist/core/agents/coord-client.d.ts.map +1 -1
  4. package/dist/core/agents/coord-client.js +18 -0
  5. package/dist/core/agents/live-authority-v3.d.ts +0 -1
  6. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  7. package/dist/core/agents/live-authority-v3.js +0 -11
  8. package/dist/core/agents/live-lifecycle-v3.d.ts +2 -4
  9. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  10. package/dist/core/agents/live-lifecycle-v3.js +7 -28
  11. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  12. package/dist/core/agents/state/heartbeat-writer.js +14 -25
  13. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  14. package/dist/core/agents/state/live-coordination-writer.js +0 -5
  15. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  16. package/dist/core/agents/state/pidmap.js +0 -5
  17. package/dist/core/agents/state/proc-start.d.ts +5 -6
  18. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  19. package/dist/core/agents/state/proc-start.js +13 -9
  20. package/dist/core/agents/state/process-handle.d.ts +26 -0
  21. package/dist/core/agents/state/process-handle.d.ts.map +1 -0
  22. package/dist/core/agents/state/process-handle.js +121 -0
  23. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  24. package/dist/core/agents/state/stale-sweep.js +70 -180
  25. package/dist/core/artifacts/activity.d.ts +4 -1
  26. package/dist/core/artifacts/activity.d.ts.map +1 -1
  27. package/dist/core/artifacts/activity.js +8 -2
  28. package/dist/core/artifacts/index.d.ts.map +1 -1
  29. package/dist/core/artifacts/index.js +38 -12
  30. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  31. package/dist/core/events/v3/authority-outbox.js +0 -29
  32. package/dist/core/events/v3/producers/coordination-recorder.d.ts +0 -1
  33. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  34. package/dist/core/events/v3/producers/coordination-recorder.js +0 -7
  35. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  36. package/dist/core/events/v3/producers/recorder.js +64 -77
  37. package/dist/core/hooks/cli.js +78 -48
  38. package/dist/core/resources/service.d.ts +2 -16
  39. package/dist/core/resources/service.d.ts.map +1 -1
  40. package/dist/core/resources/service.js +1 -78
  41. package/dist/core/semantic/service.d.ts.map +1 -1
  42. package/dist/core/semantic/service.js +2 -9
  43. package/dist/core/servers/index.d.ts +4 -0
  44. package/dist/core/servers/index.d.ts.map +1 -1
  45. package/dist/core/servers/index.js +21 -3
  46. package/dist/core/storage/durable-history.js +56 -22
  47. package/dist/core/storage/segments.d.ts.map +1 -1
  48. package/dist/core/storage/segments.js +73 -17
  49. package/dist/core/supervisor/service.d.ts +2 -4
  50. package/dist/core/supervisor/service.d.ts.map +1 -1
  51. package/dist/core/work/state.js +7 -7
  52. package/dist/core/workflow/engine.d.ts.map +1 -1
  53. package/dist/core/workflow/engine.js +21 -65
  54. package/dist/core/workflow/index.d.ts +4 -2
  55. package/dist/core/workflow/index.d.ts.map +1 -1
  56. package/dist/core/workflow/index.js +2 -1
  57. package/dist/core/workflow/live-session-v3.d.ts +0 -6
  58. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  59. package/dist/core/workflow/live-session-v3.js +5 -126
  60. package/dist/core/workflow/proof.d.ts +4 -4
  61. package/dist/core/workflow/proof.d.ts.map +1 -1
  62. package/dist/core/workflow/proof.js +4 -12
  63. package/dist/core/workflow/recovery-audit.d.ts +30 -0
  64. package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
  65. package/dist/core/workflow/recovery-audit.js +116 -0
  66. package/dist/core/workflow/run-state.d.ts +19 -2
  67. package/dist/core/workflow/run-state.d.ts.map +1 -1
  68. package/dist/core/workflow/run-state.js +113 -8
  69. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  70. package/dist/core/workflow/spawn-claude.js +0 -19
  71. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  72. package/dist/core/workflow/spawn-codex.js +0 -13
  73. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  74. package/dist/core/workflow/spawn-cursor.js +0 -19
  75. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  76. package/dist/core/workflow/spawn-opencode.js +0 -14
  77. package/dist/core/workflow/types.d.ts +11 -54
  78. package/dist/core/workflow/types.d.ts.map +1 -1
  79. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  80. package/dist/core/workflow/workspaces/local-git.js +70 -14
  81. package/dist/core/workflow/workspaces/reclaim.d.ts +6 -1
  82. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  83. package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
  84. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  85. package/dist/core/workflow/workspaces/recovery.js +24 -0
  86. package/dist/lib/identities/assume.d.ts.map +1 -1
  87. package/dist/lib/identities/assume.js +0 -4
  88. package/package.json +1 -1
  89. package/src/commands/workflow.ts +59 -0
  90. package/src/core/agents/coord-client.ts +20 -0
  91. package/src/core/agents/live-authority-v3.ts +0 -17
  92. package/src/core/agents/live-lifecycle-v3.ts +14 -52
  93. package/src/core/agents/state/heartbeat-writer.ts +12 -29
  94. package/src/core/agents/state/live-coordination-writer.ts +0 -29
  95. package/src/core/agents/state/pidmap.ts +0 -13
  96. package/src/core/agents/state/proc-start.ts +14 -11
  97. package/src/core/agents/state/process-handle.ts +165 -0
  98. package/src/core/agents/state/stale-sweep.ts +82 -247
  99. package/src/core/artifacts/activity.ts +9 -2
  100. package/src/core/artifacts/index.ts +45 -12
  101. package/src/core/events/v3/authority-outbox.ts +0 -56
  102. package/src/core/events/v3/producers/coordination-recorder.ts +0 -15
  103. package/src/core/events/v3/producers/recorder.ts +68 -94
  104. package/src/core/hooks/cli.ts +85 -47
  105. package/src/core/resources/service.ts +3 -88
  106. package/src/core/semantic/service.ts +2 -8
  107. package/src/core/servers/index.ts +30 -3
  108. package/src/core/storage/durable-history.ts +52 -20
  109. package/src/core/storage/segments.ts +79 -17
  110. package/src/core/supervisor/service.ts +2 -3
  111. package/src/core/work/state.ts +7 -7
  112. package/src/core/workflow/engine.ts +21 -73
  113. package/src/core/workflow/index.ts +12 -1
  114. package/src/core/workflow/live-session-v3.ts +5 -146
  115. package/src/core/workflow/proof.ts +5 -21
  116. package/src/core/workflow/recovery-audit.ts +181 -0
  117. package/src/core/workflow/run-state.ts +137 -8
  118. package/src/core/workflow/spawn-claude.ts +0 -18
  119. package/src/core/workflow/spawn-codex.ts +0 -13
  120. package/src/core/workflow/spawn-cursor.ts +0 -18
  121. package/src/core/workflow/spawn-opencode.ts +0 -14
  122. package/src/core/workflow/types.ts +11 -41
  123. package/src/core/workflow/workspaces/local-git.ts +86 -15
  124. package/src/core/workflow/workspaces/reclaim.ts +4 -0
  125. package/src/core/workflow/workspaces/recovery.ts +23 -0
  126. package/src/lib/identities/assume.ts +0 -4
  127. package/dist/core/agents/state/unchanged-file.d.ts +0 -44
  128. package/dist/core/agents/state/unchanged-file.d.ts.map +0 -1
  129. package/dist/core/agents/state/unchanged-file.js +0 -390
  130. package/dist/core/events/v3/generation-custody.d.ts +0 -5
  131. package/dist/core/events/v3/generation-custody.d.ts.map +0 -1
  132. package/dist/core/events/v3/generation-custody.js +0 -50
  133. package/dist/core/workflow/failure-evidence.d.ts +0 -16
  134. package/dist/core/workflow/failure-evidence.d.ts.map +0 -1
  135. package/dist/core/workflow/failure-evidence.js +0 -114
  136. package/src/core/agents/state/unchanged-file.ts +0 -456
  137. package/src/core/events/v3/generation-custody.ts +0 -59
  138. package/src/core/workflow/failure-evidence.ts +0 -141
@@ -33,7 +33,6 @@ import { markObservedClockRegressionV3 } from "../clock-order.ts";
33
33
  import type { EventV3 } from "../contract.ts";
34
34
  import { type EventV3WriteMode, readEventV3ControlState } from "../control.ts";
35
35
  import { fingerprintContextV3 } from "../fingerprint-keys.ts";
36
- import { assertCurrentGenerationV3, withGenerationCustodyV3 } from "../generation-custody.ts";
37
36
  import { clockIdV3, spanIdV3 } from "../ids.ts";
38
37
  import { closeSpanStateV3, type OpenSpanStateV3, openSpanStateV3 } from "../span-state.ts";
39
38
  import { EVENT_V3_LEDGER_RELATIVE_ROOT } from "../writer.ts";
@@ -104,7 +103,6 @@ export interface RecordCoordinationAuthorityV3Input<
104
103
  bridge?: "codex-wsl";
105
104
  expected_prior_state_digest: `sha256:${string}`;
106
105
  desired_state_digest: `sha256:${string}`;
107
- expected_generation_id?: `gen_${string}`;
108
106
  monotonic_ns?: string;
109
107
  observed_at?: string;
110
108
  reconciler: AuthorityReconcilerV3;
@@ -131,16 +129,6 @@ export type RecordCoordinationAuthorityV3Result =
131
129
  */
132
130
  export function recordCoordinationAuthorityV3<S extends CoordinationAuthoritySignalV3>(
133
131
  input: RecordCoordinationAuthorityV3Input<S>,
134
- ): RecordCoordinationAuthorityV3Result {
135
- return withGenerationCustodyV3(
136
- input.coordRoot,
137
- [input.actor_instance_id, input.subject_instance_id],
138
- () => recordCoordinationAuthorityWithCustodyV3(input),
139
- );
140
- }
141
-
142
- function recordCoordinationAuthorityWithCustodyV3<S extends CoordinationAuthoritySignalV3>(
143
- input: RecordCoordinationAuthorityV3Input<S>,
144
132
  ): RecordCoordinationAuthorityV3Result {
145
133
  const control = readEventV3ControlState(input.coordRoot);
146
134
  if (control.state !== input.mode) return { state: "gate_closed", reason: control.state };
@@ -153,8 +141,6 @@ function recordCoordinationAuthorityWithCustodyV3<S extends CoordinationAuthorit
153
141
  if (
154
142
  !hook ||
155
143
  hook.terminal ||
156
- (input.expected_generation_id !== undefined &&
157
- hook.generation_id !== input.expected_generation_id) ||
158
144
  hook.instance_id !== input.actor_instance_id ||
159
145
  !hook.last_event_id
160
146
  ) {
@@ -342,7 +328,6 @@ function recordCoordinationAuthorityWithCustodyV3<S extends CoordinationAuthorit
342
328
  mutation: normalized.mutation,
343
329
  event: normalized.event,
344
330
  });
345
- assertCurrentGenerationV3(input.coordRoot, state.actor_instance_id, state.generation_id);
346
331
  if (input.signal === "wait-ended" && waitId) delete state.open_waits[waitId];
347
332
  state.pending = { source_id: sourceId, transaction };
348
333
  publishCoordinationState(path, state);
@@ -43,7 +43,6 @@ import { markObservedClockRegressionV3 } from "../clock-order.ts";
43
43
  import type { EventV3 } from "../contract.ts";
44
44
  import { type EventV3WriteMode, readEventV3ControlState } from "../control.ts";
45
45
  import { fingerprintContextV3 } from "../fingerprint-keys.ts";
46
- import { withGenerationCustodyV3 } from "../generation-custody.ts";
47
46
  import {
48
47
  attestationIdV3,
49
48
  clockIdV3,
@@ -659,17 +658,6 @@ function processHookSignalLocked(
659
658
  input: RecordHookSignalV3Input,
660
659
  sessionHash: `hid_${string}`,
661
660
  path: string,
662
- ): RecordHookSignalV3Result {
663
- return withGenerationCustodyV3(input.coordRoot, [input.instance_id], () =>
664
- processHookSignalWithCustodyLocked(control, input, sessionHash, path),
665
- );
666
- }
667
-
668
- function processHookSignalWithCustodyLocked(
669
- control: OpenHookControlStateV3,
670
- input: RecordHookSignalV3Input,
671
- sessionHash: `hid_${string}`,
672
- path: string,
673
661
  ): RecordHookSignalV3Result {
674
662
  const behavior = adapterBehavior(input.adapter);
675
663
  const rootId = control.genesis.event.scope.root_id as `root_${string}`;
@@ -3221,14 +3209,6 @@ function suppressClosedSpanSignal(
3221
3209
  */
3222
3210
  export function recordApprovedSessionEndV3(
3223
3211
  input: RecordApprovedSessionEndV3Input,
3224
- ): RecordApprovedSessionEndV3Result {
3225
- return withGenerationCustodyV3(input.coordRoot, [input.instance_id], () =>
3226
- recordApprovedSessionEndWithCustodyV3(input),
3227
- );
3228
- }
3229
-
3230
- function recordApprovedSessionEndWithCustodyV3(
3231
- input: RecordApprovedSessionEndV3Input,
3232
3212
  ): RecordApprovedSessionEndV3Result {
3233
3213
  const control = readEventV3ControlState(input.coordRoot);
3234
3214
  if (control.state !== input.mode) {
@@ -3576,81 +3556,77 @@ export function reanchorArchivedHookProducersV3(
3576
3556
  for (const archived of listArchivedHookProducerStatesV3(input.archivedEpoch)) {
3577
3557
  const previous = archived.state;
3578
3558
  if (previous.terminal || !previous.started_event_id) continue;
3579
- withGenerationCustodyV3(input.coordRoot, [previous.instance_id], () => {
3580
- const path = producerStatePath(input.coordRoot, previous.adapter, archived.sessionHash);
3581
- const lease = acquireStateLeaseWithRetry(input.coordRoot, path, 40);
3582
- if (!lease) {
3583
- throw new Error(`event_v3_rotation_reanchor_busy:${previous.instance_id}`);
3559
+ const path = producerStatePath(input.coordRoot, previous.adapter, archived.sessionHash);
3560
+ const lease = acquireStateLeaseWithRetry(input.coordRoot, path, 40);
3561
+ if (!lease) {
3562
+ throw new Error(`event_v3_rotation_reanchor_busy:${previous.instance_id}`);
3563
+ }
3564
+ try {
3565
+ if (existsSync(path)) {
3566
+ const current = readProducerState(path);
3567
+ if (current.epoch_genesis_id === genesisId) continue;
3584
3568
  }
3585
- try {
3586
- if (existsSync(path)) {
3587
- const current = readProducerState(path);
3588
- if (current.epoch_genesis_id === genesisId) return;
3589
- }
3590
- const sourceEvent = `${previous.adapter}.epoch-rotation-reanchor`;
3591
- const rotationInput: RecordHookSignalV3Input = {
3592
- coordRoot: input.coordRoot,
3593
- mode: input.mode,
3594
- signal: "session-start",
3595
- payload: {
3596
- raw: {},
3597
- ...(previous.current_native_turn_id
3598
- ? { turn_id: previous.current_native_turn_id }
3599
- : {}),
3600
- ...(adapterBehavior(previous.adapter).tracksExecutionMode && previous.cursor_mode
3601
- ? { cursor_mode: previous.cursor_mode }
3602
- : {}),
3603
- },
3604
- adapter: previous.adapter,
3605
- instance_id: previous.instance_id,
3606
- producer_id: "prd_agent-hook",
3607
- build_id: input.build_id,
3608
- platform: input.platform,
3609
- observed_at: observedAt,
3610
- writerOptions: { expectedGenesisId: genesisId },
3611
- };
3612
- const state = newProducerState(
3613
- rotationInput,
3614
- previous.session_id,
3615
- epochId,
3616
- boundaryEventId as `evt_${string}`,
3617
- genesisId,
3618
- );
3619
- const sessionStart = buildMidFlightSessionStart(rotationInput, state, rootId);
3620
- if (sessionStart.event_type !== "session.started") {
3621
- throw new Error("rotation re-anchor session start could not be normalized");
3622
- }
3623
- sessionStart.provenance = {
3624
- ...sessionStart.provenance,
3569
+ const sourceEvent = `${previous.adapter}.epoch-rotation-reanchor`;
3570
+ const rotationInput: RecordHookSignalV3Input = {
3571
+ coordRoot: input.coordRoot,
3572
+ mode: input.mode,
3573
+ signal: "session-start",
3574
+ payload: {
3575
+ raw: {},
3576
+ ...(previous.current_native_turn_id ? { turn_id: previous.current_native_turn_id } : {}),
3577
+ ...(adapterBehavior(previous.adapter).tracksExecutionMode && previous.cursor_mode
3578
+ ? { cursor_mode: previous.cursor_mode }
3579
+ : {}),
3580
+ },
3581
+ adapter: previous.adapter,
3582
+ instance_id: previous.instance_id,
3583
+ producer_id: "prd_agent-hook",
3584
+ build_id: input.build_id,
3585
+ platform: input.platform,
3586
+ observed_at: observedAt,
3587
+ writerOptions: { expectedGenesisId: genesisId },
3588
+ };
3589
+ const state = newProducerState(
3590
+ rotationInput,
3591
+ previous.session_id,
3592
+ epochId,
3593
+ boundaryEventId as `evt_${string}`,
3594
+ genesisId,
3595
+ );
3596
+ const sessionStart = buildMidFlightSessionStart(rotationInput, state, rootId);
3597
+ if (sessionStart.event_type !== "session.started") {
3598
+ throw new Error("rotation re-anchor session start could not be normalized");
3599
+ }
3600
+ sessionStart.provenance = {
3601
+ ...sessionStart.provenance,
3602
+ source_event: sourceEvent,
3603
+ attestation: "derived",
3604
+ confidence: "high",
3605
+ attribution: {
3606
+ method: "session_env",
3607
+ state: "verified",
3608
+ subject_instance_id: state.instance_id,
3609
+ },
3610
+ };
3611
+ sessionStart.payload.resume = {
3612
+ state: "unknown",
3613
+ reason: "epoch_rotation_reanchor",
3614
+ };
3615
+ assertEventV3(sessionStart);
3616
+ commitEventLocked(rotationInput, state, path, sessionStart);
3617
+ sessions += 1;
3618
+
3619
+ if (previous.current_turn_id && previous.current_turn_span) {
3620
+ commitMidFlightTurnStart(rotationInput, state, path, rootId, {
3625
3621
  source_event: sourceEvent,
3626
- attestation: "derived",
3627
3622
  confidence: "high",
3628
- attribution: {
3629
- method: "session_env",
3630
- state: "verified",
3631
- subject_instance_id: state.instance_id,
3632
- },
3633
- };
3634
- sessionStart.payload.resume = {
3635
- state: "unknown",
3636
- reason: "epoch_rotation_reanchor",
3637
- };
3638
- assertEventV3(sessionStart);
3639
- commitEventLocked(rotationInput, state, path, sessionStart);
3640
- sessions += 1;
3641
-
3642
- if (previous.current_turn_id && previous.current_turn_span) {
3643
- commitMidFlightTurnStart(rotationInput, state, path, rootId, {
3644
- source_event: sourceEvent,
3645
- confidence: "high",
3646
- attribution_method: "session_env",
3647
- });
3648
- turns += 1;
3649
- }
3650
- } finally {
3651
- lease.release();
3623
+ attribution_method: "session_env",
3624
+ });
3625
+ turns += 1;
3652
3626
  }
3653
- });
3627
+ } finally {
3628
+ lease.release();
3629
+ }
3654
3630
  }
3655
3631
  return { sessions, turns };
3656
3632
  }
@@ -3888,9 +3864,7 @@ export function readJoinableHookProducerStateV3(
3888
3864
  instanceId: `inst_${string}`,
3889
3865
  ): HookProducerStateV3 | undefined {
3890
3866
  const direct = readHookProducerStateV3(coordRoot, adapter, nativeSessionId);
3891
- // A terminal native session belonging to this actor is stale authority,
3892
- // not permission to adopt a replacement published under the same instance.
3893
- if (direct?.instance_id === instanceId) return direct.terminal ? undefined : direct;
3867
+ if (direct?.instance_id === instanceId && !direct.terminal) return direct;
3894
3868
  const byInstance = readHookProducerStateByInstanceV3(coordRoot, instanceId);
3895
3869
  return byInstance?.adapter === adapter && !byInstance.terminal ? byInstance : undefined;
3896
3870
  }
@@ -844,6 +844,9 @@ async function main(): Promise<number> {
844
844
  if (eventName === "runtime-context-retry") {
845
845
  return runRuntimeContextRetryWorker(coordRoot, adapter, extra);
846
846
  }
847
+ if (eventName === SESSION_MAINTENANCE_EVENT) {
848
+ return runSessionMaintenanceWorker(coordRoot, adapter);
849
+ }
847
850
  const ledgerRoute = resolveLiveEventLedgerRouteV3(coordRoot);
848
851
 
849
852
  // Always log a breadcrumb, useful when an event_type maps to null or owner
@@ -1274,7 +1277,7 @@ function recordContextTelemetry(run: HookRun): void {
1274
1277
  }
1275
1278
 
1276
1279
  async function handleSessionStarted(run: HookRun): Promise<void> {
1277
- const { coordRoot, adapter, behavior, payload, owner, sessionId, data } = run;
1280
+ const { coordRoot, adapter, payload, owner, sessionId, data } = run;
1278
1281
  // A configured backup runs out of process and is freshness-gated per host.
1279
1282
  // Startup never waits for restic or a remote provider; the child owns no
1280
1283
  // hook stdio, so the harness is not held open until it finishes. A failed
@@ -1285,52 +1288,10 @@ async function handleSessionStarted(run: HookRun): Promise<void> {
1285
1288
  } catch (err) {
1286
1289
  logError(coordRoot, err, { phase: "backup-schedule" });
1287
1290
  }
1288
- // Effect (journal-maintaining adapters): prune stale journal archives +
1289
- // sweep orphans. The recovery-cue is merged into the session-start
1290
- // additionalContext inside emitSessionStartSystemMessage.
1291
- if (behavior.journalMaintenance) journalJanitor(coordRoot);
1292
- imageJanitor(coordRoot);
1293
- // Session start is the only caller of the artifact janitor and the storage
1294
- // maintenance pass, so both load here instead of on every tool call.
1295
- const { autoCleanArtifacts } = await import("../artifacts/index.ts");
1296
- const { autoCleanEventV3Archives } = await import("../events/v3/archive-retention.ts");
1297
- const { createAutomaticMaintenanceComposition, runAutomaticMaintenancePass } = await import(
1298
- "../storage/maintenance-providers.ts"
1299
- );
1300
- // Effect: throttled hourly artifact sweep (see autoCleanArtifacts). It
1301
- // deletes expired workspaces, plus unheld workspaces over the per-bundle
1302
- // ceiling or repository budget once they have been idle past
1303
- // artifacts.idle_grace_hours. Each deletion is rechecked first and recorded
1304
- // in .harnery/artifact-deletions.jsonl. Best-effort like every janitor
1305
- // here: a failure logs and never blocks session start.
1306
- try {
1307
- autoCleanArtifacts(coordRoot);
1308
- } catch (err) {
1309
- logError(coordRoot, err, { phase: "artifact-auto-clean" });
1310
- }
1311
- // Effect: apply the same daily throttle to closed V3 epochs. The newest
1312
- // recovery epochs remain protected by the archive retention policy.
1313
- try {
1314
- autoCleanEventV3Archives(coordRoot);
1315
- } catch (err) {
1316
- logError(coordRoot, err, { phase: "event-v3-archive-auto-clean" });
1317
- }
1318
- // Plan one bounded, claim-first global slice from cached pressure only.
1319
- // Existing owner janitors above remain the only active executors until
1320
- // each provider receives a separate production activation.
1321
- try {
1322
- await runAutomaticMaintenancePass(
1323
- createAutomaticMaintenanceComposition(coordRoot, {
1324
- journal: () => {
1325
- if (behavior.journalMaintenance) journalJanitor(coordRoot);
1326
- },
1327
- images: () => imageJanitor(coordRoot),
1328
- artifacts: () => autoCleanArtifacts(coordRoot),
1329
- }),
1330
- );
1331
- } catch (err) {
1332
- logError(coordRoot, err, { phase: "storage-maintenance-auto" });
1333
- }
1291
+ // Storage maintenance can walk large stores. It runs in a detached worker so
1292
+ // session start emits its context without waiting for it; an adapter that
1293
+ // stops a slow hook would otherwise drop that context entirely.
1294
+ scheduleSessionMaintenance(coordRoot, adapter);
1334
1295
  let recovery: PreparedContextRecovery | null = null;
1335
1296
  if (payload?.source === "compact") {
1336
1297
  try {
@@ -1814,6 +1775,83 @@ async function handleCompletedResponse(run: CompletedResponseRun): Promise<numbe
1814
1775
  return 0;
1815
1776
  }
1816
1777
 
1778
+ const SESSION_MAINTENANCE_EVENT = "session-maintenance";
1779
+
1780
+ function scheduleSessionMaintenance(coordRoot: string, adapter: Adapter): void {
1781
+ const modulePath = process.argv[1];
1782
+ if (!modulePath) return;
1783
+ try {
1784
+ const child = spawn(
1785
+ process.execPath,
1786
+ [modulePath, SESSION_MAINTENANCE_EVENT, "--adapter", adapter],
1787
+ {
1788
+ cwd: coordRoot,
1789
+ detached: true,
1790
+ stdio: "ignore",
1791
+ env: childEnv(coordRoot),
1792
+ },
1793
+ );
1794
+ child.unref();
1795
+ } catch (err) {
1796
+ logError(coordRoot, err, { phase: "session-maintenance-spawn" });
1797
+ }
1798
+ }
1799
+
1800
+ /**
1801
+ * Session-start storage maintenance, run out of the hook's process. Every step
1802
+ * is best-effort and throttled by its own stamp: a failure logs and never
1803
+ * reaches the session.
1804
+ */
1805
+ async function runSessionMaintenanceWorker(
1806
+ coordRoot: string,
1807
+ adapter: Adapter | null,
1808
+ ): Promise<number> {
1809
+ const journalMaintenance = adapter ? adapterBehavior(adapter).journalMaintenance : false;
1810
+ // Effect (journal-maintaining adapters): prune stale journal archives +
1811
+ // sweep orphans. The recovery cue is read separately at session start.
1812
+ if (journalMaintenance) journalJanitor(coordRoot);
1813
+ imageJanitor(coordRoot);
1814
+ const { autoCleanArtifacts } = await import("../artifacts/index.ts");
1815
+ const { autoCleanEventV3Archives } = await import("../events/v3/archive-retention.ts");
1816
+ const { createAutomaticMaintenanceComposition, runAutomaticMaintenancePass } = await import(
1817
+ "../storage/maintenance-providers.ts"
1818
+ );
1819
+ // Effect: throttled hourly artifact sweep (see autoCleanArtifacts). It
1820
+ // deletes expired workspaces, plus unheld workspaces over the per-bundle
1821
+ // ceiling or repository budget once they have been idle past
1822
+ // artifacts.idle_grace_hours. Each deletion is rechecked first and recorded
1823
+ // in .harnery/artifact-deletions.jsonl.
1824
+ try {
1825
+ autoCleanArtifacts(coordRoot);
1826
+ } catch (err) {
1827
+ logError(coordRoot, err, { phase: "artifact-auto-clean" });
1828
+ }
1829
+ // Effect: apply the same daily throttle to closed V3 epochs. The newest
1830
+ // recovery epochs remain protected by the archive retention policy.
1831
+ try {
1832
+ autoCleanEventV3Archives(coordRoot);
1833
+ } catch (err) {
1834
+ logError(coordRoot, err, { phase: "event-v3-archive-auto-clean" });
1835
+ }
1836
+ // Plan one bounded, claim-first global slice from cached pressure only.
1837
+ // Existing owner janitors above remain the only active executors until
1838
+ // each provider receives a separate production activation.
1839
+ try {
1840
+ await runAutomaticMaintenancePass(
1841
+ createAutomaticMaintenanceComposition(coordRoot, {
1842
+ journal: () => {
1843
+ if (journalMaintenance) journalJanitor(coordRoot);
1844
+ },
1845
+ images: () => imageJanitor(coordRoot),
1846
+ artifacts: () => autoCleanArtifacts(coordRoot),
1847
+ }),
1848
+ );
1849
+ } catch (err) {
1850
+ logError(coordRoot, err, { phase: "storage-maintenance-auto" });
1851
+ }
1852
+ return 0;
1853
+ }
1854
+
1817
1855
  function scheduleRuntimeContextRetry(
1818
1856
  coordRoot: string,
1819
1857
  adapter: Adapter,
@@ -1,4 +1,3 @@
1
- import { spawn } from "node:child_process";
2
1
  import { randomUUID } from "node:crypto";
3
2
  import {
4
3
  closeSync,
@@ -10,9 +9,10 @@ import {
10
9
  unlinkSync,
11
10
  writeFileSync,
12
11
  } from "node:fs";
13
- import { hostname, platform } from "node:os";
12
+ import { hostname } from "node:os";
14
13
  import { resolve } from "node:path";
15
14
  import {
15
+ type ProcessGenerationSignalDependencies,
16
16
  type ProcessGenerationVerdict,
17
17
  processStartToken,
18
18
  signalIfSameGeneration,
@@ -55,80 +55,6 @@ export interface RunResourceServiceInput {
55
55
  wait?: (milliseconds: number) => Promise<void>;
56
56
  }
57
57
 
58
- export type EnsureResourceServiceResult =
59
- | { state: "running" | "started"; status: ResourceServiceStatus }
60
- | { state: "unsupported" | "unavailable"; status: ResourceServiceStatus; error: string };
61
-
62
- export async function spawnResourceService(
63
- coordRootRaw: string,
64
- options: { intervalMs?: number } = {},
65
- ): Promise<ResourceServiceStatus> {
66
- const coordRoot = resolve(coordRootRaw);
67
- const current = readResourceServiceStatus(coordRoot);
68
- if (current.liveness === "unknown") throw unknownResourceOwnerError();
69
- if (current.liveness === "running") {
70
- throw new Error(`resource observer is already running under pid ${current.record?.pid}`);
71
- }
72
- const intervalMs = normalizeInterval(options.intervalMs);
73
- const paths = resourcePaths(coordRoot);
74
- assertResourceLeaseAvailable(paths.lease);
75
- mkdirSync(paths.root, { recursive: true, mode: stateDirMode() });
76
- const harnBin = new URL("../../../bin/harn", import.meta.url).pathname;
77
- if (!existsSync(harnBin)) throw new Error(`cannot find harn executable at ${harnBin}`);
78
- let spawnError: Error | undefined;
79
- const child = spawn(
80
- harnBin,
81
- ["resources", "service", "daemon", "--root", coordRoot, "--interval-ms", String(intervalMs)],
82
- {
83
- cwd: coordRoot,
84
- detached: true,
85
- stdio: ["ignore", "ignore", "ignore"],
86
- env: {
87
- ...process.env,
88
- HARNERY_COORD_ROOT_OVERRIDE: coordRoot,
89
- HARNERY_OUTPUT_SESSION_TEE: "0",
90
- },
91
- },
92
- );
93
- child.once("error", (error) => {
94
- spawnError = error;
95
- });
96
- child.unref();
97
- const deadline = Date.now() + 5_000;
98
- while (Date.now() < deadline) {
99
- await delay(50);
100
- if (spawnError) throw spawnError;
101
- const status = readResourceServiceStatus(coordRoot);
102
- if (status.running && status.record?.pid === child.pid) return status;
103
- if (child.exitCode !== null) break;
104
- }
105
- throw new Error(`resource observer failed to start; inspect ${paths.service}`);
106
- }
107
-
108
- export async function ensureResourceServiceRunning(
109
- coordRoot: string,
110
- ): Promise<EnsureResourceServiceResult> {
111
- const status = readResourceServiceStatus(coordRoot);
112
- if (status.running) return { state: "running", status };
113
- if (platform() !== "linux") {
114
- return {
115
- state: "unsupported",
116
- status,
117
- error: `resource observer does not yet support ${platform()}`,
118
- };
119
- }
120
- try {
121
- const started = await spawnResourceService(coordRoot);
122
- return { state: "started", status: started };
123
- } catch (error) {
124
- return {
125
- state: "unavailable",
126
- status: readResourceServiceStatus(coordRoot),
127
- error: error instanceof Error ? error.message : String(error),
128
- };
129
- }
130
- }
131
-
132
58
  export interface ResourceServiceStopResult extends ResourceServiceStatus {
133
59
  signal_sent: boolean;
134
60
  signal_verdict: ProcessGenerationVerdict;
@@ -136,9 +62,7 @@ export interface ResourceServiceStopResult extends ResourceServiceStatus {
136
62
 
137
63
  export function requestResourceServiceStop(
138
64
  coordRootRaw: string,
139
- dependencies: ResourceProcessDependencies & {
140
- kill?: (pid: number, signal: NodeJS.Signals | number) => unknown;
141
- } = {},
65
+ dependencies: ResourceProcessDependencies & ProcessGenerationSignalDependencies = {},
142
66
  ): ResourceServiceStopResult {
143
67
  const coordRoot = resolve(coordRootRaw);
144
68
  const paths = resourcePaths(coordRoot);
@@ -329,15 +253,6 @@ function leaseIsLive(lease: ResourceServiceLease): ResourceServiceStatus["livene
329
253
  return resourceProcessLiveness(lease).liveness;
330
254
  }
331
255
 
332
- function assertResourceLeaseAvailable(path: string): void {
333
- if (!existsSync(path)) return;
334
- const lease = readLease(path);
335
- const liveness = lease ? leaseIsLive(lease) : "unknown";
336
- if (liveness === "unknown") throw unknownResourceOwnerError();
337
- if (liveness === "running")
338
- throw new Error(`resource observer lease is held by pid ${lease?.pid}`);
339
- }
340
-
341
256
  function unknownResourceOwnerError(): Error {
342
257
  return new Error(
343
258
  `resource observer process identity is unknown; run ${resolveBinName()} supervisor stop to write the durable stop request before starting again`,
@@ -225,19 +225,13 @@ export async function ensureSemanticServiceRunning(
225
225
 
226
226
  export function requestSemanticServiceStop(coordRootRaw: string): SemanticServiceStatus {
227
227
  const coordRoot = resolve(coordRootRaw);
228
- const status = readSemanticServiceStatus(coordRoot);
229
228
  mkdirSync(semanticPaths(coordRoot).root, { recursive: true, mode: stateDirMode() });
230
229
  writePrivateJsonAtomic(semanticPaths(coordRoot).stop, {
231
230
  requested_at: new Date().toISOString(),
232
231
  requested_by_pid: process.pid,
233
232
  });
234
- if (status.running && status.record?.host === hostname()) {
235
- try {
236
- process.kill(status.record.pid, "SIGTERM");
237
- } catch {
238
- // The durable stop request remains for a racing or restarted daemon.
239
- }
240
- }
233
+ // This record has no generation witness. The durable request is the stop
234
+ // authority; a numeric PID cannot authorize an additional signal.
241
235
  return readSemanticServiceStatus(coordRoot);
242
236
  }
243
237
 
@@ -52,6 +52,13 @@ import {
52
52
  scanSupport,
53
53
  } from "./net.ts";
54
54
 
55
+ // Host supervisors verify and signal their own children with the same checks.
56
+ export {
57
+ processStartToken,
58
+ signalIfSameGeneration,
59
+ verifyProcessGeneration,
60
+ } from "../agents/state/proc-start.ts";
61
+ export { acquireNoClobberLease } from "../workflow/workspaces/leases.ts";
55
62
  export {
56
63
  ancestry,
57
64
  establishedConnectionCounts,
@@ -591,6 +598,26 @@ export interface StopResult {
591
598
 
592
599
  export interface ServerProcessDependencies extends ProcessGenerationSignalDependencies {
593
600
  probePid?: (pid: number) => boolean;
601
+ /** Numeric signal, used for the process group and in tests. */
602
+ kill?: (pid: number, signal: NodeJS.Signals) => void;
603
+ }
604
+
605
+ /** Recheck the generation, then signal. A supplied `kill` replaces the handle path. */
606
+ function signalVerified(
607
+ pid: number,
608
+ token: string | undefined,
609
+ name: NodeJS.Signals,
610
+ dependencies: ServerProcessDependencies,
611
+ ): { sent: boolean; verdict: ProcessGenerationVerdict } {
612
+ if (!dependencies.kill) return signalIfSameGeneration(pid, token, name, dependencies);
613
+ const verdict = verifyProcessGeneration(pid, token, dependencies.readToken);
614
+ if (verdict !== "same") return { sent: false, verdict };
615
+ try {
616
+ dependencies.kill(pid, name);
617
+ return { sent: true, verdict };
618
+ } catch {
619
+ return { sent: false, verdict };
620
+ }
594
621
  }
595
622
 
596
623
  /**
@@ -653,7 +680,7 @@ export async function stopServer(
653
680
  const result =
654
681
  target.pid === record.pid
655
682
  ? signalServerLeader(target.pid, target.token, name, dependencies)
656
- : signalIfSameGeneration(target.pid, target.token, name, dependencies);
683
+ : signalVerified(target.pid, target.token, name, dependencies);
657
684
  if (result.verdict !== "same") return refuse(target.pid, result.verdict);
658
685
  }
659
686
  const observed = await waitForExit(targets, name === "SIGTERM" ? graceMs : 2_000, dependencies);
@@ -679,13 +706,13 @@ function signalServerLeader(
679
706
  // The leader's generation authorizes its group. A failed group signal must
680
707
  // not reuse that observation for the direct-PID fallback.
681
708
  const kill = dependencies.kill ?? process.kill;
682
- const group = signalIfSameGeneration(pid, token, name, {
709
+ const group = signalVerified(pid, token, name, {
683
710
  readToken: dependencies.readToken,
684
711
  kill: (leader, signal) => kill(-leader, signal),
685
712
  });
686
713
  return group.sent || group.verdict !== "same"
687
714
  ? group
688
- : signalIfSameGeneration(pid, token, name, dependencies);
715
+ : signalVerified(pid, token, name, dependencies);
689
716
  }
690
717
 
691
718
  async function waitForExit(