@stravica-ai/digital-operator-pa 0.1.21 → 0.1.23

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.
@@ -85,6 +85,7 @@ import {
85
85
  push,
86
86
  rebaseAbort,
87
87
  rebaseContinue,
88
+ revParseSha,
88
89
  statusPorcelain,
89
90
  } from './git.mjs';
90
91
  import { acquire } from './lock.mjs';
@@ -94,6 +95,11 @@ import {
94
95
  } from './regenerated-artifacts.mjs';
95
96
  import { computeCommitScope, filterInScopePaths, renderScopeSection } from './registry-scope.mjs';
96
97
  import { composeBody } from './runs-digest.mjs';
98
+ import {
99
+ attemptSelfHeal,
100
+ drainPending,
101
+ finaliseDrainOnPushSuccess,
102
+ } from './self-heal.mjs';
97
103
 
98
104
  function log(line, { stderr = process.stderr } = {}) {
99
105
  stderr.write(`[estate-sync] ${line}\n`);
@@ -126,6 +132,57 @@ async function writeAudit(runsLog, record, stderr) {
126
132
  }
127
133
  }
128
134
 
135
+ // Alert-level ordering: needs-human > error > warn. `needs-human` outranks
136
+ // `error` because a divergence detected at drain time can never be fixed
137
+ // by more attempts — it needs an operator to reconcile the two upstream
138
+ // resolutions, so its status must NOT get de-escalated behind a
139
+ // mechanical attempt-count.
140
+ const ALERT_RANK = { warn: 0, error: 1, 'needs-human': 2 };
141
+ function worstAlertLevel(queued) {
142
+ let best = 'warn';
143
+ for (const q of queued) {
144
+ if ((ALERT_RANK[q.alertLevel] ?? -1) > (ALERT_RANK[best] ?? -1)) best = q.alertLevel;
145
+ }
146
+ return best;
147
+ }
148
+
149
+ // Compact per-queued summary safe to serialise to the audit record.
150
+ function summariseQueued(q) {
151
+ return {
152
+ id: q.id,
153
+ originalPath: q.originalPath,
154
+ attemptCount: q.attemptCount,
155
+ alertLevel: q.alertLevel,
156
+ upstreamBlobShaAtQueue: q.upstreamBlobShaAtQueue,
157
+ conflictingUpstreamCommitSha: q.conflictingUpstreamCommitSha,
158
+ pendingPath: q.pendingPath,
159
+ };
160
+ }
161
+
162
+ function summariseDrained(drained) {
163
+ return drained.map((d) => ({
164
+ id: d.id,
165
+ originalPath: d.originalPath,
166
+ action: d.action,
167
+ attemptCount: d.attemptCount,
168
+ ...(d.alertLevel ? { alertLevel: d.alertLevel } : {}),
169
+ ...(d.upstreamBlobShaAtQueue !== undefined ? { upstreamBlobShaAtQueue: d.upstreamBlobShaAtQueue } : {}),
170
+ ...(d.upstreamBlobShaNow !== undefined ? { upstreamBlobShaNow: d.upstreamBlobShaNow } : {}),
171
+ }));
172
+ }
173
+
174
+ // Human-readable alert reason for the audit `reason` field. Names every
175
+ // queued path with attempt count + alert level + upstream blob at queue
176
+ // time, so a librarian-alerts email or grep of runs.jsonl shows the full
177
+ // alert payload without needing to open the sidecar.
178
+ function renderSelfHealReason(queued) {
179
+ const parts = queued.map((q) => {
180
+ const blob = q.upstreamBlobShaAtQueue ? q.upstreamBlobShaAtQueue.slice(0, 8) : 'absent';
181
+ return `${q.originalPath} attempt=${q.attemptCount} alert=${q.alertLevel} upstreamBlob=${blob}`;
182
+ });
183
+ return `estate-sync self-heal on rebase conflict; queued: ${parts.join('; ')}`;
184
+ }
185
+
129
186
  export async function runSync({
130
187
  repoRoot,
131
188
  logDir,
@@ -190,6 +247,46 @@ export async function runSync({
190
247
  }
191
248
  }
192
249
 
250
+ // Step 2.5 — DRAIN the self-heal pending queue (R2, w-2026-08-12-004).
251
+ // Runs AFTER fetch so the upstream-divergence check inside drainPending
252
+ // compares against fresh origin refs (a stale check would silently
253
+ // let us clobber a resolution another writer pushed since we queued).
254
+ // Skipped in dry-run for the same reason git commits are.
255
+ //
256
+ // Pending entries whose upstream state is unchanged since queue time
257
+ // are copied back into the working tree, so step 3's status sees them
258
+ // as dirty and step 6 stages them normally. Divergence cases stay on
259
+ // disk with alertLevel='needs-human' and DO NOT touch the workspace
260
+ // (the write would clobber the other writer's resolution — this is
261
+ // the same down-path clobber class the whole train exists to kill).
262
+ // Already-delivered cases are cleaned up in place.
263
+ //
264
+ // Branch resolution: currentBranch is called ahead of drain so
265
+ // upstream refs (`origin/<branch>`) are addressable inside the drain.
266
+ // On a detached HEAD this returns 'HEAD' — drain still runs but the
267
+ // upstream check will fail (origin/HEAD is not a real ref), which
268
+ // routes every pending entry to divergence-held. That's the safe
269
+ // outcome — detached HEAD is not the state estate-sync should be
270
+ // draining into.
271
+ const branchForDrain = await currentBranch(runner, { cwd: repoRoot });
272
+ const pendingDir = join(repoRoot, '.cache/estate-sync/pending');
273
+ let drainedEntries = [];
274
+ let drainWarnings = [];
275
+ if (!dryRun && upfrontFetch.code === 0) {
276
+ const drainResult = await drainPending({
277
+ runner,
278
+ repoRoot,
279
+ pendingDir,
280
+ branch: branchForDrain,
281
+ stderr,
282
+ });
283
+ drainedEntries = drainResult.drained;
284
+ drainWarnings = drainResult.warnings || [];
285
+ for (const w of drainWarnings) {
286
+ log(`drain warning: ${w.sidecar}: ${w.reason}`, { stderr });
287
+ }
288
+ }
289
+
193
290
  // Step 3 — examine working tree and upstream relation.
194
291
  const status = await statusPorcelain(runner, { cwd: repoRoot });
195
292
  if (status.code !== 0) {
@@ -211,7 +308,8 @@ export async function runSync({
211
308
  const behindCount = Number.isFinite(behind) ? behind : 0;
212
309
  log(`tree dirty=${dirty}, ahead=${aheadCount}, behind=${behindCount}`, { stderr });
213
310
 
214
- const branch = await currentBranch(runner, { cwd: repoRoot });
311
+ // Branch was resolved earlier for the drain step — reuse it here.
312
+ const branch = branchForDrain;
215
313
 
216
314
  // Step 4 — noop short-circuit.
217
315
  if (!dirty && aheadCount === 0 && behindCount === 0) {
@@ -226,6 +324,11 @@ export async function runSync({
226
324
  ahead: aheadCount,
227
325
  behind: behindCount,
228
326
  branch,
327
+ // Drain may have run and reported already-delivered / divergence-
328
+ // held entries even on an otherwise-noop tick; surface them
329
+ // even though staged-for-retry cases wouldn't reach here (they
330
+ // would have made the tree dirty).
331
+ ...(drainedEntries.length > 0 ? { drained: summariseDrained(drainedEntries) } : {}),
229
332
  }, stderr);
230
333
  }
231
334
  return 0;
@@ -269,7 +372,15 @@ export async function runSync({
269
372
  // replays a clean local commit on top of origin's history. This is
270
373
  // the same shape a human follows when integrating: stage, commit,
271
374
  // then rebase onto upstream.
375
+ //
376
+ // ourCommitSha (R2, w-2026-08-12-004): the sha of estate-sync's
377
+ // commit is recorded RIGHT AFTER the commit lands. It's the load-
378
+ // bearing guard for the self-heal reset — the reset only ever runs
379
+ // when HEAD is still exactly this sha at reset time. If anything
380
+ // else committed between (a human commit, a hook) HEAD^..HEAD is no
381
+ // longer just our commit and the guard refuses to reset.
272
382
  let didCommit = false;
383
+ let ourCommitSha = null;
273
384
  if (dirty) {
274
385
  let addRes;
275
386
  let stagedCount = 0;
@@ -329,6 +440,20 @@ export async function runSync({
329
440
  return 3;
330
441
  }
331
442
  didCommit = true;
443
+ // Record the sha immediately — this is the guard the self-heal
444
+ // reset checks against before dropping. Any subsequent operation
445
+ // that moves HEAD (a hook, a concurrent write) will fail the
446
+ // guard and self-heal will refuse to reset, preserving the
447
+ // moved-on state for a human to inspect.
448
+ const shaRes = await revParseSha(runner, { cwd: repoRoot, ref: 'HEAD' });
449
+ if (shaRes.code === 0 && shaRes.sha) {
450
+ ourCommitSha = shaRes.sha;
451
+ } else {
452
+ log(
453
+ `warning: could not resolve HEAD sha after commit (${shaRes.stderr.trim()}) — self-heal guard cannot be checked; a subsequent rebase conflict will be handled as fail-rebase (no reset)`,
454
+ { stderr },
455
+ );
456
+ }
332
457
  log('committed', { stderr });
333
458
  } else {
334
459
  log('nothing staged after add — skipping commit', { stderr });
@@ -347,6 +472,7 @@ export async function runSync({
347
472
  // either no-ops (if local refs already match) or fails spuriously.
348
473
  let autoResolvedPaths = [];
349
474
  let autostashConflict = false;
475
+ let selfHealedQueued = [];
350
476
  if (upfrontFetch.code === 0) {
351
477
  const prRes = await pullRebase(runner, { cwd: repoRoot, branch });
352
478
  autostashConflict = detectAutostashConflict(prRes, stderr) || autostashConflict;
@@ -358,8 +484,12 @@ export async function runSync({
358
484
  // conflict just means two hosts pushed regenerations in the same
359
485
  // window. Auto-resolve by preferring origin's version (see the
360
486
  // rebase-inversion note on `checkoutOursDuringRebase`) and
361
- // continue. If ANY conflict is outside the allowlist, we still
362
- // abort and surface fail-rebase — real conflicts need a human.
487
+ // continue. If ANY conflict is outside the allowlist, hand off
488
+ // to self-heal (R2, w-2026-08-12-004): queue our version, drop
489
+ // our commit under guard, restore fast-forwardability, alert.
490
+ // If self-heal itself declines (queue-write failure, guard
491
+ // failure) we fall through to the classic fail-rebase branch
492
+ // and stay wedged loudly.
363
493
  const resolved = await tryAutoResolveRebase({
364
494
  runner,
365
495
  repoRoot,
@@ -371,6 +501,67 @@ export async function runSync({
371
501
  `auto-resolved rebase conflict on ${resolved.paths.length} regenerated artifact(s): ${resolved.paths.join(', ')}`,
372
502
  { stderr },
373
503
  );
504
+ } else if (resolved.disallowed && didCommit && ourCommitSha) {
505
+ const heal = await attemptSelfHeal({
506
+ runner,
507
+ repoRoot,
508
+ pendingDir,
509
+ branch,
510
+ disallowedPaths: resolved.disallowed,
511
+ ourCommitSha,
512
+ firstSeenTs: tsIso,
513
+ stderr,
514
+ });
515
+ if (heal.ok) {
516
+ selfHealedQueued = heal.queued;
517
+ log(
518
+ `self-heal: queued ${heal.queued.length} path(s) for retry, dropped local commit ${ourCommitSha.slice(0, 8)}, workspace fast-forwardable again`,
519
+ { stderr },
520
+ );
521
+ // Emit a fail-* audit record so librarian-alerts (if wired
522
+ // to estate-sync's runs.jsonl) picks this up as a failure
523
+ // requiring human attention — but exit code stays 0 because
524
+ // the workspace itself IS healthy (fast-forwardable, not
525
+ // wedged). This is the whole point: cron process healthy,
526
+ // human-visible alert loud, next tick's drain will retry.
527
+ const maxAlert = worstAlertLevel(heal.queued);
528
+ const status =
529
+ maxAlert === 'error' ? 'fail-rebase-stuck'
530
+ : maxAlert === 'needs-human' ? 'fail-rebase-needs-human'
531
+ : 'fail-rebase-queued';
532
+ await writeAudit(runsLog, {
533
+ ts: tsIso,
534
+ status,
535
+ exitCode: 0,
536
+ durationSec: Math.round((Date.now() - startedAtMs) / 1000),
537
+ reason: renderSelfHealReason(heal.queued),
538
+ committed: false,
539
+ selfHealed: true,
540
+ droppedCommit: ourCommitSha,
541
+ queued: heal.queued.map(summariseQueued),
542
+ conflictedPaths: resolved.disallowed,
543
+ branch,
544
+ ...(drainedEntries.length > 0 ? { drained: summariseDrained(drainedEntries) } : {}),
545
+ }, stderr);
546
+ return 0;
547
+ }
548
+ log(
549
+ `self-heal DECLINED (stage=${heal.stage}): ${heal.reason} — falling back to fail-rebase, workspace remains wedged (loud)`,
550
+ { stderr },
551
+ );
552
+ // Fall through to fail-rebase.
553
+ await rebaseAbort(runner, { cwd: repoRoot });
554
+ await writeAudit(runsLog, {
555
+ ts: tsIso,
556
+ status: 'fail-rebase',
557
+ exitCode: 2,
558
+ durationSec: Math.round((Date.now() - startedAtMs) / 1000),
559
+ reason: `git pull --rebase failed — self-heal declined (${heal.stage}): ${heal.reason}; ${resolved.reason}`,
560
+ committed: didCommit,
561
+ selfHealDeclined: { stage: heal.stage, reason: heal.reason },
562
+ ...(resolved.disallowed ? { conflictedPaths: resolved.disallowed } : {}),
563
+ }, stderr);
564
+ return 2;
374
565
  } else {
375
566
  log(`aborting rebase: ${resolved.reason}`, { stderr });
376
567
  // Defensive abort. If no rebase is in progress (auto-resolve
@@ -407,6 +598,14 @@ export async function runSync({
407
598
  const needsPush = Number.isFinite(aheadAfter) && aheadAfter > 0;
408
599
  if (!needsPush) {
409
600
  log('nothing to push (up-to-date with origin)', { stderr });
601
+ // If drain staged pending content into the workspace but nothing
602
+ // needed pushing (e.g. drain wrote content matching HEAD after a
603
+ // race), still finalise so we don't hold pending files that are
604
+ // already delivered. finaliseDrainOnPushSuccess compares HEAD's
605
+ // bytes to the pending bytes before removing.
606
+ const finaliseIdle = await finaliseDrainOnPushSuccess({
607
+ runner, repoRoot, pendingDir, drained: drainedEntries, stderr,
608
+ });
410
609
  await writeAudit(runsLog, {
411
610
  ts: tsIso,
412
611
  status: 'ok',
@@ -416,6 +615,8 @@ export async function runSync({
416
615
  pushed: false,
417
616
  branch,
418
617
  ...autoResolveFields,
618
+ ...(drainedEntries.length > 0 ? { drained: summariseDrained(drainedEntries) } : {}),
619
+ ...(finaliseIdle.finalised.length > 0 ? { drainFinalised: finaliseIdle.finalised } : {}),
419
620
  }, stderr);
420
621
  return 0;
421
622
  }
@@ -423,6 +624,9 @@ export async function runSync({
423
624
  const p1 = await push(runner, { cwd: repoRoot });
424
625
  if (p1.code === 0) {
425
626
  log('pushed', { stderr });
627
+ const finalise1 = await finaliseDrainOnPushSuccess({
628
+ runner, repoRoot, pendingDir, drained: drainedEntries, stderr,
629
+ });
426
630
  await writeAudit(runsLog, {
427
631
  ts: tsIso,
428
632
  status: 'ok',
@@ -432,6 +636,8 @@ export async function runSync({
432
636
  pushed: true,
433
637
  branch,
434
638
  ...autoResolveFields,
639
+ ...(drainedEntries.length > 0 ? { drained: summariseDrained(drainedEntries) } : {}),
640
+ ...(finalise1.finalised.length > 0 ? { drainFinalised: finalise1.finalised } : {}),
435
641
  }, stderr);
436
642
  return 0;
437
643
  }
@@ -454,8 +660,10 @@ export async function runSync({
454
660
  if (pr2.code !== 0) {
455
661
  log(`git pull --rebase (for push-retry) failed: ${pr2.stderr.trim()}`, { stderr });
456
662
  // Symmetric with step 7: if the push-retry rebase conflicts only
457
- // on regenerated artifacts, auto-resolve and continue. Otherwise
458
- // abort as before.
663
+ // on regenerated artifacts, auto-resolve and continue. If any path
664
+ // is outside the allowlist, hand off to self-heal (R2, w-2026-08-
665
+ // 12-004) exactly as the primary path does. Otherwise fall through
666
+ // to the classic fail-rebase branch.
459
667
  const resolved2 = await tryAutoResolveRebase({
460
668
  runner,
461
669
  repoRoot,
@@ -467,6 +675,60 @@ export async function runSync({
467
675
  `auto-resolved push-retry rebase on ${resolved2.paths.length} regenerated artifact(s): ${resolved2.paths.join(', ')}`,
468
676
  { stderr },
469
677
  );
678
+ } else if (resolved2.disallowed && didCommit && ourCommitSha) {
679
+ const heal2 = await attemptSelfHeal({
680
+ runner,
681
+ repoRoot,
682
+ pendingDir,
683
+ branch,
684
+ disallowedPaths: resolved2.disallowed,
685
+ ourCommitSha,
686
+ firstSeenTs: tsIso,
687
+ stderr,
688
+ });
689
+ if (heal2.ok) {
690
+ selfHealedQueued = [...selfHealedQueued, ...heal2.queued];
691
+ log(
692
+ `self-heal (push-retry): queued ${heal2.queued.length} path(s) for retry, dropped local commit ${ourCommitSha.slice(0, 8)}, workspace fast-forwardable again`,
693
+ { stderr },
694
+ );
695
+ const maxAlert = worstAlertLevel(heal2.queued);
696
+ const status =
697
+ maxAlert === 'error' ? 'fail-rebase-stuck'
698
+ : maxAlert === 'needs-human' ? 'fail-rebase-needs-human'
699
+ : 'fail-rebase-queued';
700
+ await writeAudit(runsLog, {
701
+ ts: tsIso,
702
+ status,
703
+ exitCode: 0,
704
+ durationSec: Math.round((Date.now() - startedAtMs) / 1000),
705
+ reason: renderSelfHealReason(heal2.queued),
706
+ committed: false,
707
+ selfHealed: true,
708
+ pushRetryPath: true,
709
+ droppedCommit: ourCommitSha,
710
+ queued: heal2.queued.map(summariseQueued),
711
+ conflictedPaths: resolved2.disallowed,
712
+ branch,
713
+ ...(drainedEntries.length > 0 ? { drained: summariseDrained(drainedEntries) } : {}),
714
+ }, stderr);
715
+ return 0;
716
+ }
717
+ log(
718
+ `self-heal (push-retry) DECLINED (stage=${heal2.stage}): ${heal2.reason} — falling back to fail-rebase`,
719
+ { stderr },
720
+ );
721
+ await rebaseAbort(runner, { cwd: repoRoot });
722
+ await writeAudit(runsLog, {
723
+ ts: tsIso,
724
+ status: 'fail-rebase',
725
+ exitCode: 2,
726
+ durationSec: Math.round((Date.now() - startedAtMs) / 1000),
727
+ reason: `push rejected; follow-up rebase self-heal declined (${heal2.stage}): ${heal2.reason}; ${resolved2.reason}`,
728
+ selfHealDeclined: { stage: heal2.stage, reason: heal2.reason },
729
+ ...(resolved2.disallowed ? { conflictedPaths: resolved2.disallowed } : {}),
730
+ }, stderr);
731
+ return 2;
470
732
  } else {
471
733
  log(`aborting push-retry rebase: ${resolved2.reason}`, { stderr });
472
734
  await rebaseAbort(runner, { cwd: repoRoot });
@@ -501,6 +763,9 @@ export async function runSync({
501
763
  : {}),
502
764
  ...(autostashConflict ? { autostashConflict: true } : {}),
503
765
  };
766
+ const finalise2 = await finaliseDrainOnPushSuccess({
767
+ runner, repoRoot, pendingDir, drained: drainedEntries, stderr,
768
+ });
504
769
  await writeAudit(runsLog, {
505
770
  ts: tsIso,
506
771
  status: 'ok',
@@ -511,6 +776,8 @@ export async function runSync({
511
776
  retriedAfterRebase: true,
512
777
  branch,
513
778
  ...autoResolveFieldsFinal,
779
+ ...(drainedEntries.length > 0 ? { drained: summariseDrained(drainedEntries) } : {}),
780
+ ...(finalise2.finalised.length > 0 ? { drainFinalised: finalise2.finalised } : {}),
514
781
  }, stderr);
515
782
  return 0;
516
783
  } finally {