@stravica-ai/digital-operator-pa 0.1.21 → 0.1.22
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.
- package/dist/engineering-pa-cli/docs/estate-sync.md +90 -9
- package/dist/engineering-pa-cli/src/sync/git.mjs +88 -0
- package/dist/engineering-pa-cli/src/sync/pending-store.mjs +318 -0
- package/dist/engineering-pa-cli/src/sync/run.mjs +272 -5
- package/dist/engineering-pa-cli/src/sync/self-heal.mjs +577 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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,
|
|
362
|
-
//
|
|
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.
|
|
458
|
-
//
|
|
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 {
|