@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.
- package/dist/engineering-pa-cli/docs/estate-sync.md +90 -9
- package/dist/engineering-pa-cli/src/pa/io.mjs +18 -0
- package/dist/engineering-pa-cli/src/pa/work.mjs +81 -44
- 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
|
@@ -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