@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.
@@ -0,0 +1,577 @@
1
+ // estate-sync — self-heal on rebase-abort (R2, w-2026-08-12-004).
2
+ //
3
+ // Purpose. Kill the failure mode observed on ops-01 2026-08-11: estate-sync
4
+ // committed locally, `git pull --rebase` conflicted on one path outside the
5
+ // regenerated-artifacts allowlist, estate-sync aborted the rebase, and the
6
+ // workspace stayed parked at ahead=1/behind=N with no self-heal. Every
7
+ // downstream consumer (librarian sweep shares .git via symlink) inherited
8
+ // the wedge until a human intervened. R1 (writer-scoped work-item IDs,
9
+ // shipped 0.1.21) removed the dual-mint cause of THAT specific conflict;
10
+ // R2 is the belt-and-braces for every other conflicting path.
11
+ //
12
+ // Shape (ratified 2026-08-12 via the library architecture review §7 R2 +
13
+ // amendment from the coordinator on the drain design; corrected 2026-08-
14
+ // 12 per the fresh-context reviewer's blocker on PR #120 — the original
15
+ // implementation used `git reset --keep HEAD^` which destroys the non-
16
+ // conflicting portion of the dropped commit; see the DROP note below).
17
+ // On a non-allowlist conflict:
18
+ // (a) QUEUE — write estate-sync's version of every conflicting path
19
+ // to `<repoRoot>/.cache/estate-sync/pending/<id>` with a
20
+ // JSON sidecar (originalPath, upstream commit sha, upstream
21
+ // blob sha AT QUEUE TIME, attempt count, first-seen).
22
+ // Content is fsynced BEFORE any git mutation runs — if
23
+ // queueing fails, we do NOT reset; sync falls back to the
24
+ // existing fail-rebase behaviour and stays loud rather
25
+ // than lose bytes.
26
+ // (b) DROP — abort the rebase, then GUARDED-drop only the local
27
+ // commit estate-sync itself just created: HEAD must equal
28
+ // the sha recorded at commit time; HEAD^..HEAD must
29
+ // contain exactly one commit; HEAD^ must exist; the
30
+ // workspace must be exactly one commit ahead of upstream.
31
+ // Guard failure means the workspace has moved under us
32
+ // (human commit landed, hook injected a merge, etc.) —
33
+ // do NOT reset; alert loudly and fall back to fail-rebase.
34
+ // The drop is `git reset --soft HEAD^` (NOT --keep):
35
+ // --soft leaves the index and working tree entirely
36
+ // untouched, so every non-conflicting path in the
37
+ // dropped commit remains STAGED and its bytes remain
38
+ // on disk. Then surgically for each conflicting path P:
39
+ // if P was newly-added by our commit (existedInParent=
40
+ // false) → `git rm --cached -- P` then `fs.unlink(P)`
41
+ // (P is now un-staged and off disk; its bytes live in
42
+ // pending); if P was modified (existedInParent=true) →
43
+ // `git checkout HEAD -- P` (HEAD is now the parent;
44
+ // restores parent's version in both index and working
45
+ // tree). Result: HEAD=parent, index+working tree hold
46
+ // exactly our commit's content MINUS the conflicting
47
+ // paths; the next tick's step 6 re-stages via idempotent
48
+ // `git add -A` / `git add -- <paths>` and recommits.
49
+ // The reviewer's attack1 (multi-file commit, one
50
+ // conflicting path) and attack2 (modified README
51
+ // alongside a conflict) are pinned as regression tests.
52
+ // (c) ALERT — audit-record for the tick uses a `fail-*` status so
53
+ // librarian-alerts picks it up when wired against
54
+ // estate-sync's runs.jsonl (see librarian-alerts config
55
+ // field companionCadenceMinutes / executorRunsPath — the
56
+ // shape is compatible without config changes here). Exit
57
+ // code stays 0 because the workspace ITSELF is healthy
58
+ // (fast-forwardable again) — the process is not the wedge.
59
+ // (d) DRAIN — at the START of every subsequent tick, retry pending
60
+ // files oldest-first. Placement: AFTER fetch, so
61
+ // origin/<branch> reflects reality. For each pending file:
62
+ // compare the upstream blob NOW against the sidecar's
63
+ // recorded upstream blob at queue time. Three cases:
64
+ // - Upstream unchanged → safe to re-apply: copy content
65
+ // back into the workspace at originalPath; step 6
66
+ // stages it normally; if the tick commits + pushes
67
+ // successfully, remove the pending file.
68
+ // - Upstream now MATCHES our content → already delivered
69
+ // (another writer pushed it for us); remove the
70
+ // pending file, no re-apply needed.
71
+ // - Upstream DIVERGED (different blob than at queue) →
72
+ // stamp the sidecar alertLevel='needs-human' (sticky —
73
+ // never de-escalates), do NOT overwrite the workspace,
74
+ // leave the pending file for a human. Skips the
75
+ // 5-attempt ladder entirely — attempts can't fix
76
+ // divergence. Names both blob shas in the alert.
77
+ // A crash mid-drain (after workspace copy, before commit)
78
+ // leaves the pending file intact — next tick re-evaluates.
79
+ // Removal happens ONLY after commit AND push are on HEAD.
80
+ //
81
+ // Content-safety invariant. Every path in the dropped commit ends up in
82
+ // one of three places after self-heal completes:
83
+ // 1. Still staged in the index (non-conflicting paths — --soft leaves
84
+ // them alone; next tick re-commits them naturally).
85
+ // 2. On disk in `<pendingDir>/<id>` with a sidecar (conflicting paths,
86
+ // queued BEFORE any git mutation runs).
87
+ // 3. Reverted to their parent-commit form (conflicting paths that
88
+ // existed in HEAD^ — we surgically `git checkout HEAD -- <path>` to
89
+ // restore the parent's version).
90
+ // A conflicting path newly-added by our commit takes option 2 only
91
+ // (queue then delete from working tree). If any queue write throws OR
92
+ // any guard fires, `run.mjs` sees `{ ok: false }` and takes the classic
93
+ // fail-rebase path (rebaseAbort only, no reset) — workspace stays
94
+ // committed, staying wedged loudly. If the reset itself fails (extremely
95
+ // unlikely; --soft has no working-tree dependency), the pending files
96
+ // still hold the queued bytes and the audit surfaces the failure.
97
+ //
98
+ // Concurrency (audited in the safety argument section of the PR): the
99
+ // existing PID lock (10-minute stale window; see lock.mjs) protects the
100
+ // whole tick, so drain-at-tick-start and self-heal-at-conflict never race
101
+ // with another sync tick on the same host. Cross-host is the git-level
102
+ // contract — each host's pending queue is under its own gitignored
103
+ // `.cache/estate-sync/pending/` and never leaves the host.
104
+
105
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
106
+ import { dirname, join } from 'node:path';
107
+
108
+ import {
109
+ checkoutFromRef,
110
+ hashObject,
111
+ rebaseAbort,
112
+ refPathExists,
113
+ resetSoft,
114
+ revListCount,
115
+ revParseSha,
116
+ rmCached,
117
+ } from './git.mjs';
118
+ import {
119
+ ensurePendingDir,
120
+ loadAll,
121
+ queueConflict,
122
+ readContent,
123
+ removePending,
124
+ writeSidecar,
125
+ } from './pending-store.mjs';
126
+
127
+ const ESCALATE_AT = 5;
128
+
129
+ function log(line, stderr) {
130
+ stderr.write(`[estate-sync] ${line}\n`);
131
+ }
132
+
133
+ // Resolve `origin/<branch>` to its blob sha for `path`, or null if the
134
+ // blob doesn't exist there. Non-fatal: any git failure returns null so
135
+ // the caller falls through to a defensive "unknown upstream state" branch.
136
+ async function upstreamBlobSha(runner, { cwd, branch, path }) {
137
+ const ref = `origin/${branch}:${path}`;
138
+ const r = await runner.spawn('git', ['rev-parse', '--verify', `${ref}`], { cwd });
139
+ if (r.code !== 0) return null;
140
+ return r.stdout.trim();
141
+ }
142
+
143
+ // Resolve `origin/<branch>` to its commit sha, or null.
144
+ async function upstreamCommitSha(runner, { cwd, branch }) {
145
+ const r = await runner.spawn('git', ['rev-parse', '--verify', `origin/${branch}^{commit}`], { cwd });
146
+ if (r.code !== 0) return null;
147
+ return r.stdout.trim();
148
+ }
149
+
150
+ /**
151
+ * Attempt the self-heal path in response to a non-allowlist rebase
152
+ * conflict. Contract:
153
+ * - Runs AFTER classifyConflicts has already surfaced `disallowed`
154
+ * paths (the caller is `tryAutoResolveRebase` in run.mjs, unchanged
155
+ * for the allowlist branch).
156
+ * - Returns { ok: true, queued: [{ id, originalPath, attemptCount,
157
+ * alertLevel, upstreamBlobShaAtQueue, conflictingUpstreamCommitSha }]
158
+ * } on success, with the workspace at HEAD = <our commit's parent>,
159
+ * working tree carrying only the non-conflicting portion of the
160
+ * dropped commit's diff (conflicting paths restored to their
161
+ * pre-commit state — either HEAD^ version, or removed if newly added
162
+ * by our commit).
163
+ * - Returns { ok: false, reason: '<short>', stage: '<stage>' } on any
164
+ * failure. Caller must fall back to the existing fail-rebase behaviour
165
+ * (rebaseAbort has been called, but reset was NOT).
166
+ */
167
+ export async function attemptSelfHeal({
168
+ runner,
169
+ repoRoot,
170
+ pendingDir,
171
+ branch,
172
+ disallowedPaths,
173
+ ourCommitSha,
174
+ firstSeenTs,
175
+ stderr,
176
+ }) {
177
+ if (!ourCommitSha) {
178
+ return { ok: false, reason: 'self-heal called without ourCommitSha; refusing to reset', stage: 'precondition' };
179
+ }
180
+ if (!Array.isArray(disallowedPaths) || disallowedPaths.length === 0) {
181
+ return { ok: false, reason: 'self-heal called without conflicting paths', stage: 'precondition' };
182
+ }
183
+
184
+ // 1. Abort the rebase first. This is safe/reversible: it restores the
185
+ // tree to HEAD = ourCommitSha, working tree matches our commit
186
+ // (git reflog carries the aborted rebase). Doing this before content
187
+ // capture means the file bytes we read from disk in step 2 are OUR
188
+ // committed version, not conflict markers.
189
+ const abort = await rebaseAbort(runner, { cwd: repoRoot });
190
+ if (abort.code !== 0) {
191
+ return {
192
+ ok: false,
193
+ reason: `git rebase --abort failed: ${abort.stderr.trim()}`,
194
+ stage: 'rebase-abort',
195
+ };
196
+ }
197
+
198
+ // 2. GUARDS — before we touch anything else, prove HEAD is still our
199
+ // commit and there's exactly one commit to drop.
200
+ const head = await revParseSha(runner, { cwd: repoRoot, ref: 'HEAD' });
201
+ if (head.code !== 0) {
202
+ return { ok: false, reason: `git rev-parse HEAD failed: ${head.stderr.trim()}`, stage: 'guard-head' };
203
+ }
204
+ if (head.sha !== ourCommitSha) {
205
+ return {
206
+ ok: false,
207
+ reason: `guard failed: HEAD=${head.sha.slice(0, 8)} but expected ourCommitSha=${ourCommitSha.slice(0, 8)} — refusing to reset a commit we did not just make`,
208
+ stage: 'guard-head-sha',
209
+ };
210
+ }
211
+ const parent = await revParseSha(runner, { cwd: repoRoot, ref: 'HEAD^' });
212
+ if (parent.code !== 0) {
213
+ return {
214
+ ok: false,
215
+ reason: `guard failed: HEAD^ does not exist (root commit?) — refusing to reset: ${parent.stderr.trim()}`,
216
+ stage: 'guard-parent-exists',
217
+ };
218
+ }
219
+ const count = await revListCount(runner, { cwd: repoRoot, range: 'HEAD^..HEAD' });
220
+ if (count.code !== 0 || count.count !== 1) {
221
+ return {
222
+ ok: false,
223
+ reason: `guard failed: expected exactly one commit in HEAD^..HEAD, got ${count.count} — refusing to reset a stack`,
224
+ stage: 'guard-single-commit',
225
+ };
226
+ }
227
+ // Belt-and-braces: also verify the workspace is exactly one commit
228
+ // ahead of origin/<branch>. If it's more (a human commit landed
229
+ // before this tick started, or a hook interposed one), --keep HEAD^
230
+ // would drop OUR commit but leave the other unresolved, so we'd
231
+ // still be ahead=1/behind=N — wedged. Refuse the reset instead; the
232
+ // classic fail-rebase behaviour surfaces the situation for a human.
233
+ const aheadOfUpstream = await revListCount(runner, {
234
+ cwd: repoRoot, range: `origin/${branch}..HEAD`,
235
+ });
236
+ if (aheadOfUpstream.code !== 0 || aheadOfUpstream.count !== 1) {
237
+ return {
238
+ ok: false,
239
+ reason: `guard failed: expected exactly one commit ahead of origin/${branch}, got ${aheadOfUpstream.count} — refusing to reset while other local commits exist`,
240
+ stage: 'guard-ahead-of-upstream',
241
+ };
242
+ }
243
+
244
+ // 3. Capture each conflicting path's content from the working tree
245
+ // (matches our commit after rebase --abort). Also capture whether
246
+ // the path existed at HEAD^ (drives the tree-cleanup shape after
247
+ // reset), and origin's current blob sha for the path (upstream
248
+ // state at queue time, drives the drain's divergence check).
249
+ const upstreamCommit = await upstreamCommitSha(runner, { cwd: repoRoot, branch });
250
+ const captures = [];
251
+ for (const p of disallowedPaths) {
252
+ let content;
253
+ try {
254
+ content = await readFile(join(repoRoot, p));
255
+ } catch (err) {
256
+ return {
257
+ ok: false,
258
+ reason: `capture failed: reading ${p} from working tree: ${err.message}`,
259
+ stage: 'capture-read',
260
+ };
261
+ }
262
+ const existedInParent = await refPathExists(runner, { cwd: repoRoot, ref: 'HEAD^', path: p });
263
+ const upstreamBlob = await upstreamBlobSha(runner, { cwd: repoRoot, branch, path: p });
264
+ captures.push({
265
+ path: p,
266
+ content,
267
+ existedInParent,
268
+ upstreamBlobShaAtQueue: upstreamBlob,
269
+ });
270
+ }
271
+
272
+ // 4. QUEUE — write pending files (raw + sidecar). Fsync-then-rename
273
+ // inside pending-store gives us the durability guarantee. If any
274
+ // queue write throws, we bail BEFORE the reset — data preserved
275
+ // only in the working tree at that point, but the tree still
276
+ // matches our commit and the caller's fail-rebase audit will be
277
+ // accurate.
278
+ await ensurePendingDir(pendingDir);
279
+ const queued = [];
280
+ for (const c of captures) {
281
+ let result;
282
+ try {
283
+ result = await queueConflict(pendingDir, {
284
+ originalPath: c.path,
285
+ content: c.content,
286
+ firstSeen: firstSeenTs,
287
+ conflictingUpstreamCommitSha: upstreamCommit,
288
+ upstreamBlobShaAtQueue: c.upstreamBlobShaAtQueue,
289
+ existedInParent: c.existedInParent,
290
+ escalateAt: ESCALATE_AT,
291
+ });
292
+ } catch (err) {
293
+ return {
294
+ ok: false,
295
+ reason: `queue write failed for ${c.path}: ${err.message}`,
296
+ stage: 'queue-write',
297
+ };
298
+ }
299
+ queued.push({
300
+ id: result.sidecar.id,
301
+ originalPath: result.sidecar.originalPath,
302
+ attemptCount: result.sidecar.attemptCount,
303
+ alertLevel: result.sidecar.alertLevel,
304
+ upstreamBlobShaAtQueue: result.sidecar.upstreamBlobShaAtQueue,
305
+ conflictingUpstreamCommitSha: result.sidecar.conflictingUpstreamCommitSha,
306
+ pendingPath: result.contentPath,
307
+ existedInParent: result.sidecar.existedInParent,
308
+ });
309
+ }
310
+
311
+ // 5. DROP — reset --soft HEAD^. This is the corrected primitive
312
+ // (2026-08-12, per the fresh-context reviewer's blocker on PR
313
+ // #120). --soft moves HEAD to the parent while leaving BOTH the
314
+ // index and the working tree entirely untouched, so every
315
+ // non-conflicting path in the dropped commit remains staged and
316
+ // on disk. The earlier `--keep HEAD^` implementation was wrong:
317
+ // --keep updates working-tree files that differ between HEAD^
318
+ // and HEAD, which silently destroys every non-conflicting path
319
+ // we committed alongside the conflicting one. Attacks 1 and 2
320
+ // from the reviewer's POC pin this correction as regressions.
321
+ const reset = await resetSoft(runner, { cwd: repoRoot, ref: 'HEAD^' });
322
+ if (reset.code !== 0) {
323
+ // Extremely unlikely path — --soft has no working-tree or index
324
+ // dependency. If it did fail, workspace is still at ourCommitSha
325
+ // (reset is atomic; failure means HEAD did not move), so pending
326
+ // files are still-honest bytes we can retry from later.
327
+ return {
328
+ ok: false,
329
+ reason: `guarded reset --soft HEAD^ failed: ${reset.stderr.trim()}`,
330
+ stage: 'reset',
331
+ queuedButNotDropped: queued,
332
+ };
333
+ }
334
+
335
+ // 6. Surgically clean up ONLY the conflicting paths. The index and
336
+ // working tree are still at our dropped-commit's state; we need
337
+ // the conflicting paths gone (their bytes are safe in pending)
338
+ // while every other path stays exactly as we staged it. Two
339
+ // branches per path:
340
+ // - existedInParent === true → `git checkout HEAD -- <path>`.
341
+ // HEAD is now the parent, so this restores the parent's
342
+ // version in both index and working tree, cleanly reverting
343
+ // just our modification.
344
+ // - existedInParent === false → the file was newly added by
345
+ // our commit. `git rm --cached -- <path>` unstages it (index
346
+ // no longer tracks it), then `fs.unlink` removes the byte
347
+ // content from the working tree. Neither op touches other
348
+ // paths, so non-conflicting content is preserved.
349
+ const restorePaths = queued.filter((q) => q.existedInParent).map((q) => q.originalPath);
350
+ if (restorePaths.length > 0) {
351
+ const co = await checkoutFromRef(runner, { cwd: repoRoot, ref: 'HEAD', paths: restorePaths });
352
+ if (co.code !== 0) {
353
+ log(
354
+ `warning: post-reset checkout HEAD -- for restore failed: ${co.stderr.trim()} — pending queue holds content`,
355
+ stderr,
356
+ );
357
+ }
358
+ }
359
+ const newlyAddedPaths = queued.filter((q) => !q.existedInParent).map((q) => q.originalPath);
360
+ if (newlyAddedPaths.length > 0) {
361
+ const rmc = await rmCached(runner, { cwd: repoRoot, paths: newlyAddedPaths });
362
+ if (rmc.code !== 0) {
363
+ log(
364
+ `warning: post-reset git rm --cached for newly-added paths failed: ${rmc.stderr.trim()} — pending queue holds content`,
365
+ stderr,
366
+ );
367
+ }
368
+ for (const p of newlyAddedPaths) {
369
+ try {
370
+ await unlink(join(repoRoot, p));
371
+ } catch (err) {
372
+ if (err.code !== 'ENOENT') {
373
+ log(
374
+ `warning: post-reset unlink ${p} failed: ${err.message} — pending queue holds content`,
375
+ stderr,
376
+ );
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ return { ok: true, queued };
383
+ }
384
+
385
+ /**
386
+ * Drain the pending queue at the top of a tick (AFTER fetch, BEFORE
387
+ * commit-scope computation). Contract:
388
+ * - Skipped entirely if the pending dir is empty.
389
+ * - Runs in oldest-firstSeen order so the earliest conflict retries
390
+ * first (deterministic ordering, easier operator debugging).
391
+ * - Per pending entry:
392
+ * a. If upstream blob for originalPath NOW matches our queued
393
+ * content (someone else pushed it for us) → remove the pending
394
+ * file, no workspace write. Return action: 'already-delivered'.
395
+ * b. If upstream blob is UNCHANGED since queue time (or still
396
+ * absent when it was absent at queue) → copy content into the
397
+ * workspace at originalPath. Return action: 'staged-for-retry'.
398
+ * Step 6 will stage it; the pending file is NOT removed here —
399
+ * removal happens only after commit + push are confirmed on
400
+ * HEAD (see finaliseDrainOnPushSuccess in run.mjs).
401
+ * c. If upstream blob has diverged → stamp sidecar
402
+ * alertLevel='needs-human' (sticky), name both blob shas in the
403
+ * reason, do NOT overwrite the workspace. Return action:
404
+ * 'divergence-held'.
405
+ * - Returns { drained: [{ id, originalPath, action, ... }], warnings }
406
+ * — warnings surface corrupt sidecars from loadAll.
407
+ *
408
+ * Crash-safety: if the process is killed after step (b) writes the
409
+ * workspace but before step 6 commits, the pending file is still on
410
+ * disk — the next tick sees the same conflict blob-sha comparison and
411
+ * re-runs the drain step for this entry (idempotent).
412
+ */
413
+ export async function drainPending({
414
+ runner,
415
+ repoRoot,
416
+ pendingDir,
417
+ branch,
418
+ stderr,
419
+ }) {
420
+ const { entries, warnings } = await loadAll(pendingDir);
421
+ const drained = [];
422
+ if (entries.length === 0) return { drained, warnings };
423
+
424
+ for (const entry of entries) {
425
+ const { sidecar } = entry;
426
+ const originalPath = sidecar.originalPath;
427
+ const upstreamBlobNow = await upstreamBlobSha(runner, {
428
+ cwd: repoRoot, branch, path: originalPath,
429
+ });
430
+
431
+ // Case (a): upstream already carries our content. Compared via git
432
+ // blob shas both sides (binary-safe): upstream sha is what
433
+ // `origin/<branch>:<path>` resolves to; ours is what
434
+ // `git hash-object` produces from the pending file bytes. Equal
435
+ // shas === identical bytes, without any utf8 round-trip that would
436
+ // corrupt binary content.
437
+ if (upstreamBlobNow) {
438
+ const oursHash = await hashObject(runner, {
439
+ cwd: repoRoot,
440
+ path: join(pendingDir, sidecar.id),
441
+ });
442
+ if (oursHash.code === 0 && oursHash.sha === upstreamBlobNow) {
443
+ await removePending(pendingDir, sidecar.id);
444
+ drained.push({
445
+ id: sidecar.id,
446
+ originalPath,
447
+ action: 'already-delivered',
448
+ attemptCount: sidecar.attemptCount,
449
+ });
450
+ log(
451
+ `drain: ${originalPath} already delivered upstream (blob ${upstreamBlobNow.slice(0, 8)}) — pending removed`,
452
+ stderr,
453
+ );
454
+ continue;
455
+ }
456
+ }
457
+
458
+ // Case (c): upstream has DIFFERENT bytes than at queue time, AND
459
+ // those bytes are not ours (case a caught that). Divergence.
460
+ const upstreamMatchesQueue = (
461
+ (upstreamBlobNow == null && sidecar.upstreamBlobShaAtQueue == null)
462
+ || (upstreamBlobNow != null && upstreamBlobNow === sidecar.upstreamBlobShaAtQueue)
463
+ );
464
+ if (!upstreamMatchesQueue) {
465
+ // Stamp needs-human on the sidecar (sticky).
466
+ const next = {
467
+ ...sidecar,
468
+ alertLevel: 'needs-human',
469
+ };
470
+ await writeSidecar(pendingDir, next);
471
+ drained.push({
472
+ id: sidecar.id,
473
+ originalPath,
474
+ action: 'divergence-held',
475
+ attemptCount: sidecar.attemptCount,
476
+ alertLevel: 'needs-human',
477
+ upstreamBlobShaAtQueue: sidecar.upstreamBlobShaAtQueue,
478
+ upstreamBlobShaNow: upstreamBlobNow,
479
+ });
480
+ log(
481
+ `drain: ${originalPath} upstream DIVERGED (queue-time blob=${sidecar.upstreamBlobShaAtQueue?.slice(0, 8) || 'absent'}, now=${upstreamBlobNow?.slice(0, 8) || 'absent'}) — needs-human; pending held, workspace untouched`,
482
+ stderr,
483
+ );
484
+ continue;
485
+ }
486
+
487
+ // Case (b): safe to re-apply. Copy content into workspace so step 6
488
+ // stages it as part of the normal flow.
489
+ let content;
490
+ try { content = await readContent(pendingDir, sidecar.id); } catch (err) {
491
+ log(
492
+ `drain: WARNING reading pending content for ${originalPath} failed: ${err.message} — skipping`,
493
+ stderr,
494
+ );
495
+ continue;
496
+ }
497
+ try {
498
+ const targetPath = join(repoRoot, originalPath);
499
+ await mkdir(dirname(targetPath), { recursive: true });
500
+ await writeFile(targetPath, content);
501
+ } catch (err) {
502
+ log(
503
+ `drain: WARNING writing ${originalPath} back to workspace failed: ${err.message} — pending held, skipping`,
504
+ stderr,
505
+ );
506
+ continue;
507
+ }
508
+ drained.push({
509
+ id: sidecar.id,
510
+ originalPath,
511
+ action: 'staged-for-retry',
512
+ attemptCount: sidecar.attemptCount,
513
+ });
514
+ log(
515
+ `drain: ${originalPath} restored to workspace (attempt ${sidecar.attemptCount}) — step 6 will commit; pending held until push confirmed`,
516
+ stderr,
517
+ );
518
+ }
519
+ return { drained, warnings };
520
+ }
521
+
522
+ /**
523
+ * Called after the tick's push succeeded. Removes pending files whose
524
+ * originalPath is now delivered upstream (i.e. matches the pushed HEAD).
525
+ * Contract:
526
+ * - Only touches entries with action='staged-for-retry' (divergence-
527
+ * held ones stay for a human; already-delivered ones were removed
528
+ * during drain).
529
+ * - Verifies delivery by comparing the file at HEAD to the pending
530
+ * content bytes. If HEAD's bytes don't match ours, keep the pending
531
+ * file (the push may have carried the file but with intervening
532
+ * changes on the same tick — bail loudly, next tick re-evaluates).
533
+ */
534
+ export async function finaliseDrainOnPushSuccess({
535
+ runner,
536
+ repoRoot,
537
+ pendingDir,
538
+ drained,
539
+ stderr,
540
+ }) {
541
+ const finalised = [];
542
+ for (const d of drained) {
543
+ if (d.action !== 'staged-for-retry') continue;
544
+ // Compare HEAD's blob for the path vs `git hash-object` of the
545
+ // pending file. Both sides yield git blob shas — binary-safe,
546
+ // no utf8 round-trip.
547
+ const headBlob = await runner.spawn(
548
+ 'git', ['rev-parse', '--verify', `HEAD:${d.originalPath}`], { cwd: repoRoot },
549
+ );
550
+ if (headBlob.code !== 0) {
551
+ log(
552
+ `drain-finalise: HEAD:${d.originalPath} not readable (${headBlob.stderr.trim()}) — pending held`,
553
+ stderr,
554
+ );
555
+ continue;
556
+ }
557
+ const headSha = headBlob.stdout.trim();
558
+ const oursHash = await hashObject(runner, {
559
+ cwd: repoRoot,
560
+ path: join(pendingDir, d.id),
561
+ });
562
+ if (oursHash.code !== 0 || oursHash.sha !== headSha) {
563
+ log(
564
+ `drain-finalise: HEAD:${d.originalPath} blob=${headSha.slice(0, 8)} does not match pending content blob=${oursHash.sha?.slice(0, 8) || 'n/a'} — pending held for re-evaluation next tick`,
565
+ stderr,
566
+ );
567
+ continue;
568
+ }
569
+ await removePending(pendingDir, d.id);
570
+ finalised.push(d.id);
571
+ log(
572
+ `drain-finalise: ${d.originalPath} delivered on HEAD — pending removed`,
573
+ stderr,
574
+ );
575
+ }
576
+ return { finalised };
577
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Digital Operator PA \u2014 single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",