@webpieces/repo-workflow-core 0.4.958 → 0.4.959
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/package.json +2 -2
- package/src/command-timeouts.d.ts +23 -0
- package/src/command-timeouts.js +37 -0
- package/src/command-timeouts.js.map +1 -0
- package/src/index.d.ts +2 -0
- package/src/index.js +2 -0
- package/src/index.js.map +1 -1
- package/src/main-sync-file.d.ts +24 -0
- package/src/main-sync-file.js +52 -3
- package/src/main-sync-file.js.map +1 -1
- package/src/main-sync-lock.d.ts +142 -0
- package/src/main-sync-lock.js +324 -0
- package/src/main-sync-lock.js.map +1 -0
- package/src/main-sync-status.d.ts +11 -53
- package/src/main-sync-status.js +20 -175
- package/src/main-sync-status.js.map +1 -1
- package/src/merged-branches.d.ts +3 -1
- package/src/merged-branches.js +7 -3
- package/src/merged-branches.js.map +1 -1
- package/src/worktrees.js +3 -2
- package/src/worktrees.js.map +1 -1
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MainSyncLockService = exports.RefreshClaim = exports.RefreshClaimOutcome = exports.MainSyncLock = exports.MAIN_SYNC_LOCK_PURPOSE = exports.SPAWNER_CLAIM_PID = exports.MIN_REFRESH_INTERVAL_MINUTES = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const inversify_1 = require("inversify");
|
|
8
|
+
const tooling_common_1 = require("@webpieces/tooling-common");
|
|
9
|
+
const to_error_1 = require("@webpieces/tooling-common/to-error");
|
|
10
|
+
const main_sync_status_1 = require("./main-sync-status");
|
|
11
|
+
/**
|
|
12
|
+
* The fewest minutes between two refresher STARTS, repo-wide (#1342).
|
|
13
|
+
*
|
|
14
|
+
* The refresh feeds only main-staleness (commits behind) and the merged-PR/reap data, and neither
|
|
15
|
+
* needs minute-level freshness: every `wp-*` command that acts on main does its own fetch. Without a
|
|
16
|
+
* floor the guards started a full refresh (`git fetch` over ssh, `gh pr list --limit 200`, per-branch
|
|
17
|
+
* merge-base) roughly once a minute per repo, and launched a node child on every single tool call.
|
|
18
|
+
*
|
|
19
|
+
* A CONSTANT, not a config knob: it only tunes frequency, and a key would have to wait a release
|
|
20
|
+
* before this repo's own config could state it (the published validator is one release behind).
|
|
21
|
+
*/
|
|
22
|
+
exports.MIN_REFRESH_INTERVAL_MINUTES = 10;
|
|
23
|
+
/**
|
|
24
|
+
* The pid a SPAWNER writes into the lock it takes for a refresher it has not launched yet. The hook
|
|
25
|
+
* process that wins exits within milliseconds, so its own pid would read as "holder dead" and invite a
|
|
26
|
+
* reclaim; 0 reads as "holder unknown", which is judged by age alone and is never signalled. The child
|
|
27
|
+
* replaces it with its own pid the moment it starts (adoptMainSyncLock).
|
|
28
|
+
*/
|
|
29
|
+
exports.SPAWNER_CLAIM_PID = 0;
|
|
30
|
+
const MAIN_SYNC_LOCK_FILE = 'main-sync.lock.json';
|
|
31
|
+
exports.MAIN_SYNC_LOCK_PURPOSE = 'Created atomically (O_EXCL) to award ONE process the right to run the main-sync refresh. The winner '
|
|
32
|
+
+ 'deletes it when done; a lock older than hangTimeoutMinutes, or whose pid is dead, is reclaimed '
|
|
33
|
+
+ '(its holder killed). Results live in main-sync-status.json, never here.';
|
|
34
|
+
/**
|
|
35
|
+
* The WHOLE content of `main-sync.lock.json` (data only). The file is a mutex and nothing else: its
|
|
36
|
+
* EXISTENCE is the lock. `pid` serves the liveness check, the kill of a hung holder and the "is it
|
|
37
|
+
* still mine" check before delete; `startedMs` serves the hangTimeoutMinutes staleness check. No state,
|
|
38
|
+
* no results: those are in main-sync-status.json, written before this file is deleted.
|
|
39
|
+
*/
|
|
40
|
+
class MainSyncLock {
|
|
41
|
+
purpose;
|
|
42
|
+
pid;
|
|
43
|
+
startedMs;
|
|
44
|
+
constructor(pid, startedMs) {
|
|
45
|
+
this.purpose = exports.MAIN_SYNC_LOCK_PURPOSE;
|
|
46
|
+
this.pid = pid;
|
|
47
|
+
this.startedMs = startedMs;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
exports.MainSyncLock = MainSyncLock;
|
|
51
|
+
/** Why a spawner did, or did not, win the right to launch a refresher. */
|
|
52
|
+
var RefreshClaimOutcome;
|
|
53
|
+
(function (RefreshClaimOutcome) {
|
|
54
|
+
/** This caller created the lock and must launch the refresher (which adopts it). */
|
|
55
|
+
RefreshClaimOutcome["CLAIMED"] = "claimed";
|
|
56
|
+
/** The last refresh STARTED less than MIN_REFRESH_INTERVAL_MINUTES ago. Nothing to do. */
|
|
57
|
+
RefreshClaimOutcome["RECENT"] = "recent";
|
|
58
|
+
/** The lock exists and its holder is running, or another caller won the create first. */
|
|
59
|
+
RefreshClaimOutcome["HELD"] = "held";
|
|
60
|
+
})(RefreshClaimOutcome || (exports.RefreshClaimOutcome = RefreshClaimOutcome = {}));
|
|
61
|
+
// Data-only result of MainSyncLockService.claimRefresh (per CLAUDE.md: classes for data).
|
|
62
|
+
class RefreshClaim {
|
|
63
|
+
outcome;
|
|
64
|
+
/** The lock now held; set only for CLAIMED. */
|
|
65
|
+
lock;
|
|
66
|
+
/** The stamp this decision read from main-sync-status.json, or null when none was recorded. */
|
|
67
|
+
lastRefreshStartedMs;
|
|
68
|
+
constructor(outcome, lock, lastRefreshStartedMs) {
|
|
69
|
+
this.outcome = outcome;
|
|
70
|
+
this.lock = lock;
|
|
71
|
+
this.lastRefreshStartedMs = lastRefreshStartedMs;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.RefreshClaim = RefreshClaim;
|
|
75
|
+
/**
|
|
76
|
+
* The single-flight mutex for the detached main-sync refresher, and the spawner's throttle in front of
|
|
77
|
+
* it (#1342). Two files, two jobs:
|
|
78
|
+
*
|
|
79
|
+
* - `main-sync.lock.json` — the MUTEX. Created with an atomic exclusive create; the creator runs the
|
|
80
|
+
* refresh and deletes it when done, after confirming it is still its own. Everybody else gets
|
|
81
|
+
* EEXIST and skips.
|
|
82
|
+
* - `main-sync-status.json` — the RESULTS, and `lastRefreshStartedMs`, the throttle's stamp. The
|
|
83
|
+
* refresher writes it FIRST and deletes the lock LAST, so the stamp is on disk before any process
|
|
84
|
+
* can win the next race.
|
|
85
|
+
*
|
|
86
|
+
* SHARED scope: one lock for the whole repo, in the primary clone. There is one `.git`, and N
|
|
87
|
+
* concurrent `git fetch`es against it are what corrupt `.git/FETCH_HEAD`.
|
|
88
|
+
*/
|
|
89
|
+
let MainSyncLockService = class MainSyncLockService {
|
|
90
|
+
dotDir;
|
|
91
|
+
atomicFile;
|
|
92
|
+
status;
|
|
93
|
+
constructor(dotDir = tooling_common_1.dotWebpieces, atomicFile = new tooling_common_1.AtomicFile(), status = new main_sync_status_1.MainSyncStatusService()) {
|
|
94
|
+
this.dotDir = dotDir;
|
|
95
|
+
this.atomicFile = atomicFile;
|
|
96
|
+
this.status = status;
|
|
97
|
+
}
|
|
98
|
+
mainSyncLockPath(repoRoot) {
|
|
99
|
+
return this.dotDir.sharedFile(repoRoot, MAIN_SYNC_LOCK_FILE);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* THE SPAWNER'S WHOLE DECISION: may this hook process launch a refresher? Called BEFORE anything
|
|
103
|
+
* is spawned, so a throttled tool call costs one read of the status file: no node child, no git.
|
|
104
|
+
*
|
|
105
|
+
* The stamp is the last run's START, and a failed run records one too, so a failing refresh
|
|
106
|
+
* (offline, gh auth expired) is retried after the interval: never hammered on every call, never
|
|
107
|
+
* blocked forever. A run that crashes before recording anything leaves its lock behind, and that
|
|
108
|
+
* lock is reclaimed once older than `hangTimeoutMinutes`.
|
|
109
|
+
*/
|
|
110
|
+
claimRefresh(repoRoot, hangTimeoutMinutes, now = Date.now()) {
|
|
111
|
+
const file = this.status.readMainSyncStatusFile(repoRoot);
|
|
112
|
+
const stamp = file === null ? null : file.lastRefreshStartedMs;
|
|
113
|
+
if (stamp !== null && this.withinInterval(stamp, now)) {
|
|
114
|
+
return new RefreshClaim(RefreshClaimOutcome.RECENT, null, stamp);
|
|
115
|
+
}
|
|
116
|
+
const lock = this.tryAcquireMainSyncLock(repoRoot, hangTimeoutMinutes, now, exports.SPAWNER_CLAIM_PID);
|
|
117
|
+
return new RefreshClaim(lock === null ? RefreshClaimOutcome.HELD : RefreshClaimOutcome.CLAIMED, lock, stamp);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Take the mutex with an atomic exclusive create. Returns the lock now held, or null.
|
|
121
|
+
*
|
|
122
|
+
* Normal case: the file does not exist (the last winner deleted it), the create wins, done. On
|
|
123
|
+
* EEXIST the holder is judged: alive and inside `hangTimeoutMinutes` means it is working, and we
|
|
124
|
+
* skip. Otherwise (hung, dead, or a file that is not a lock in this shape at all, such as the
|
|
125
|
+
* retired `state: finished` format) it is the ONE exception to "the winner deletes": its holder is
|
|
126
|
+
* killed if still alive, the file unlinked, and the create retried once; only that create's winner
|
|
127
|
+
* proceeds.
|
|
128
|
+
*
|
|
129
|
+
* The create writes the content to a private temp file and LINKS it into place: `link()` fails with
|
|
130
|
+
* EEXIST exactly like O_EXCL, but the lock appears with its content already in it, so no reader can
|
|
131
|
+
* ever see an empty lock and mistake it for garbage.
|
|
132
|
+
*/
|
|
133
|
+
tryAcquireMainSyncLock(repoRoot, hangTimeoutMinutes, now = Date.now(), pid = process.pid) {
|
|
134
|
+
const mine = new MainSyncLock(pid, now);
|
|
135
|
+
if (this.createExclusive(repoRoot, mine))
|
|
136
|
+
return mine;
|
|
137
|
+
const held = this.readMainSyncLock(repoRoot);
|
|
138
|
+
if (held !== null && this.isHolderRunning(held, hangTimeoutMinutes, now))
|
|
139
|
+
return null;
|
|
140
|
+
if (held !== null) {
|
|
141
|
+
this.killStaleHolder(held, pid);
|
|
142
|
+
// Unlink only the lock we judged. A contender that reclaimed first has already created a
|
|
143
|
+
// FRESH lock here, and that one is not ours to remove.
|
|
144
|
+
const again = this.readMainSyncLock(repoRoot);
|
|
145
|
+
if (again !== null && (again.pid !== held.pid || again.startedMs !== held.startedMs))
|
|
146
|
+
return null;
|
|
147
|
+
fs.rmSync(this.mainSyncLockPath(repoRoot), { force: true });
|
|
148
|
+
}
|
|
149
|
+
return this.createExclusive(repoRoot, mine) ? mine : null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The detached refresher takes over the lock its spawner created: same `startedMs`, its own pid, so
|
|
153
|
+
* liveness and the reclaimer's kill now target the process doing the work. Null when that lock is no
|
|
154
|
+
* longer on disk (it was reclaimed while the child booted); the child then does nothing. The child
|
|
155
|
+
* never creates the lock itself, so it can never race its own spawner.
|
|
156
|
+
*/
|
|
157
|
+
adoptMainSyncLock(repoRoot, claimStartedMs, pid = process.pid) {
|
|
158
|
+
const held = this.readMainSyncLock(repoRoot);
|
|
159
|
+
if (held === null || held.startedMs !== claimStartedMs || held.pid !== exports.SPAWNER_CLAIM_PID)
|
|
160
|
+
return null;
|
|
161
|
+
const adopted = new MainSyncLock(pid, claimStartedMs);
|
|
162
|
+
this.writeMainSyncLock(repoRoot, adopted);
|
|
163
|
+
return adopted;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Delete OUR lock. Returns false, and deletes nothing, when the lock on disk is not ours: a run that
|
|
167
|
+
* hung past its window was reclaimed, and deleting the NEWER holder's lock would let a third
|
|
168
|
+
* refresher start beside the second. Call it only after the status file (and its stamp) is written.
|
|
169
|
+
*/
|
|
170
|
+
releaseMainSyncLock(repoRoot, held) {
|
|
171
|
+
const onDisk = this.readMainSyncLock(repoRoot);
|
|
172
|
+
if (onDisk === null || onDisk.pid !== held.pid || onDisk.startedMs !== held.startedMs)
|
|
173
|
+
return false;
|
|
174
|
+
fs.rmSync(this.mainSyncLockPath(repoRoot), { force: true });
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The lock on disk, or null when there is none. A file that is not a lock in this shape (the retired
|
|
179
|
+
* `state`/`started` format, or anything unparseable) reads as a lock with pid 0 started at the epoch:
|
|
180
|
+
* stale at once, so it is reclaimed rather than honoured as a second spelling of a lock.
|
|
181
|
+
*/
|
|
182
|
+
readMainSyncLock(repoRoot) {
|
|
183
|
+
const text = this.readText(this.mainSyncLockPath(repoRoot));
|
|
184
|
+
if (text === null)
|
|
185
|
+
return null;
|
|
186
|
+
const raw = this.parse(text);
|
|
187
|
+
if (raw === null || raw.purpose !== exports.MAIN_SYNC_LOCK_PURPOSE)
|
|
188
|
+
return new MainSyncLock(0, 0);
|
|
189
|
+
if (typeof raw.pid !== 'number' || typeof raw.startedMs !== 'number')
|
|
190
|
+
return new MainSyncLock(0, 0);
|
|
191
|
+
return new MainSyncLock(raw.pid, raw.startedMs);
|
|
192
|
+
}
|
|
193
|
+
// Replace the lock's content atomically (temp + rename). Only for a lock this process already holds.
|
|
194
|
+
writeMainSyncLock(repoRoot, lock) {
|
|
195
|
+
this.atomicFile.writeJsonAtomic(this.mainSyncLockPath(repoRoot), lock);
|
|
196
|
+
}
|
|
197
|
+
// True when a refresher holds the lock and is working: alive, and inside its hang window.
|
|
198
|
+
isRefreshInProgress(repoRoot, hangTimeoutMinutes, now = Date.now()) {
|
|
199
|
+
const lock = this.readMainSyncLock(repoRoot);
|
|
200
|
+
return lock !== null && this.isHolderRunning(lock, hangTimeoutMinutes, now);
|
|
201
|
+
}
|
|
202
|
+
// A lock is stale (its holder assumed hung) once older than hangTimeoutMinutes.
|
|
203
|
+
isLockStale(lock, hangTimeoutMinutes, now = Date.now()) {
|
|
204
|
+
return now - lock.startedMs > hangTimeoutMinutes * 60 * 1000;
|
|
205
|
+
}
|
|
206
|
+
// pid 0 = unknown (a spawner's unadopted lock) → alive, judged by age alone.
|
|
207
|
+
isHolderRunning(lock, hangTimeoutMinutes, now) {
|
|
208
|
+
return !this.isLockStale(lock, hangTimeoutMinutes, now) && this.isProcessAlive(lock.pid);
|
|
209
|
+
}
|
|
210
|
+
// A negative age (the clock stepped backwards) never throttles: it would otherwise pin the stamp.
|
|
211
|
+
withinInterval(startedMs, now) {
|
|
212
|
+
const age = now - startedMs;
|
|
213
|
+
return age >= 0 && age < exports.MIN_REFRESH_INTERVAL_MINUTES * 60 * 1000;
|
|
214
|
+
}
|
|
215
|
+
// The file's text, or null when there is no lock (including one deleted between check and read).
|
|
216
|
+
readText(lockPath) {
|
|
217
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
218
|
+
try {
|
|
219
|
+
return fs.readFileSync(lockPath, 'utf8');
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
const error = (0, to_error_1.toError)(err);
|
|
223
|
+
if (error.message.includes('ENOENT'))
|
|
224
|
+
return null;
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
parse(text) {
|
|
229
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(text);
|
|
232
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
233
|
+
}
|
|
234
|
+
catch (err) { // webpieces-disable catch-must-propagate -- an unparseable lock IS the answer here: it is garbage, and the caller reclaims it
|
|
235
|
+
const error = (0, to_error_1.toError)(err);
|
|
236
|
+
void error;
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Atomic create-with-content: write a private temp file, then `link()` it to the lock path. The link
|
|
242
|
+
* fails with EEXIST when the lock exists, which is the O_EXCL contract, and a reader never sees the
|
|
243
|
+
* lock before its content.
|
|
244
|
+
*/
|
|
245
|
+
createExclusive(repoRoot, lock) {
|
|
246
|
+
const lockPath = this.mainSyncLockPath(repoRoot);
|
|
247
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
248
|
+
const tmp = `${lockPath}.${String(process.pid)}.${String(Date.now())}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
249
|
+
fs.writeFileSync(tmp, JSON.stringify(lock, null, 2) + '\n', { flag: 'wx' });
|
|
250
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
251
|
+
try {
|
|
252
|
+
fs.linkSync(tmp, lockPath);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
const error = (0, to_error_1.toError)(err);
|
|
257
|
+
if (error.message.includes('EEXIST'))
|
|
258
|
+
return false;
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
fs.rmSync(tmp, { force: true });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Best-effort SIGKILL of a reclaimed holder that is still alive: a refresher hung past
|
|
267
|
+
* `hangTimeoutMinutes`. Never for pid <= 0 (a spawner's unadopted lock, or garbage), our own pid,
|
|
268
|
+
* or the claimant's. The refresher is spawned `detached`, so it leads its own process group: the
|
|
269
|
+
* group is signalled first, which takes its in-flight `git fetch` / `gh` children with it; a plain
|
|
270
|
+
* pid signal is the fallback for a holder that is not a group leader.
|
|
271
|
+
*
|
|
272
|
+
* Residual risk, accepted: after a long hang the OS may have reused the pid. The window is
|
|
273
|
+
* hangTimeoutMinutes, and pids cycle far slower than that on a developer machine.
|
|
274
|
+
*/
|
|
275
|
+
killStaleHolder(stale, claimantPid) {
|
|
276
|
+
if (stale.pid <= 0 || stale.pid === process.pid || stale.pid === claimantPid)
|
|
277
|
+
return;
|
|
278
|
+
if (!this.isProcessAlive(stale.pid))
|
|
279
|
+
return;
|
|
280
|
+
if (this.sendKill(-stale.pid))
|
|
281
|
+
return;
|
|
282
|
+
this.sendKill(stale.pid);
|
|
283
|
+
}
|
|
284
|
+
// true when delivered; false for no such process/group (ESRCH) or not ours to signal (EPERM).
|
|
285
|
+
sendKill(target) {
|
|
286
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
287
|
+
try {
|
|
288
|
+
process.kill(target, 'SIGKILL');
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
const error = (0, to_error_1.toError)(err);
|
|
293
|
+
if (error.message.includes('ESRCH') || error.message.includes('EPERM'))
|
|
294
|
+
return false;
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Liveness probe: is `pid` still a running process? pid <= 0 (unknown) → assume alive.
|
|
299
|
+
isProcessAlive(pid) {
|
|
300
|
+
if (pid <= 0)
|
|
301
|
+
return true;
|
|
302
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
303
|
+
try {
|
|
304
|
+
process.kill(pid, 0);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
const error = (0, to_error_1.toError)(err);
|
|
309
|
+
if (error.message.includes('ESRCH'))
|
|
310
|
+
return false;
|
|
311
|
+
if (error.message.includes('EPERM'))
|
|
312
|
+
return true;
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
exports.MainSyncLockService = MainSyncLockService;
|
|
318
|
+
exports.MainSyncLockService = MainSyncLockService = tslib_1.__decorate([
|
|
319
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
320
|
+
tslib_1.__metadata("design:paramtypes", [tooling_common_1.DotWebpieces,
|
|
321
|
+
tooling_common_1.AtomicFile,
|
|
322
|
+
main_sync_status_1.MainSyncStatusService])
|
|
323
|
+
], MainSyncLockService);
|
|
324
|
+
//# sourceMappingURL=main-sync-lock.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main-sync-lock.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/main-sync-lock.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { AtomicFile, DotWebpieces, dotWebpieces } from '@webpieces/tooling-common';\nimport { toError } from '@webpieces/tooling-common/to-error';\nimport { MainSyncStatusService } from './main-sync-status';\n\n/**\n * The fewest minutes between two refresher STARTS, repo-wide (#1342).\n *\n * The refresh feeds only main-staleness (commits behind) and the merged-PR/reap data, and neither\n * needs minute-level freshness: every `wp-*` command that acts on main does its own fetch. Without a\n * floor the guards started a full refresh (`git fetch` over ssh, `gh pr list --limit 200`, per-branch\n * merge-base) roughly once a minute per repo, and launched a node child on every single tool call.\n *\n * A CONSTANT, not a config knob: it only tunes frequency, and a key would have to wait a release\n * before this repo's own config could state it (the published validator is one release behind).\n */\nexport const MIN_REFRESH_INTERVAL_MINUTES = 10;\n\n/**\n * The pid a SPAWNER writes into the lock it takes for a refresher it has not launched yet. The hook\n * process that wins exits within milliseconds, so its own pid would read as \"holder dead\" and invite a\n * reclaim; 0 reads as \"holder unknown\", which is judged by age alone and is never signalled. The child\n * replaces it with its own pid the moment it starts (adoptMainSyncLock).\n */\nexport const SPAWNER_CLAIM_PID = 0;\n\nconst MAIN_SYNC_LOCK_FILE = 'main-sync.lock.json';\n\nexport const MAIN_SYNC_LOCK_PURPOSE =\n 'Created atomically (O_EXCL) to award ONE process the right to run the main-sync refresh. The winner '\n + 'deletes it when done; a lock older than hangTimeoutMinutes, or whose pid is dead, is reclaimed '\n + '(its holder killed). Results live in main-sync-status.json, never here.';\n\n/**\n * The WHOLE content of `main-sync.lock.json` (data only). The file is a mutex and nothing else: its\n * EXISTENCE is the lock. `pid` serves the liveness check, the kill of a hung holder and the \"is it\n * still mine\" check before delete; `startedMs` serves the hangTimeoutMinutes staleness check. No state,\n * no results: those are in main-sync-status.json, written before this file is deleted.\n */\nexport class MainSyncLock {\n purpose: string;\n pid: number;\n startedMs: number;\n\n constructor(pid: number, startedMs: number) {\n this.purpose = MAIN_SYNC_LOCK_PURPOSE;\n this.pid = pid;\n this.startedMs = startedMs;\n }\n}\n\n/** Why a spawner did, or did not, win the right to launch a refresher. */\nexport enum RefreshClaimOutcome {\n /** This caller created the lock and must launch the refresher (which adopts it). */\n CLAIMED = 'claimed',\n /** The last refresh STARTED less than MIN_REFRESH_INTERVAL_MINUTES ago. Nothing to do. */\n RECENT = 'recent',\n /** The lock exists and its holder is running, or another caller won the create first. */\n HELD = 'held',\n}\n\n// Data-only result of MainSyncLockService.claimRefresh (per CLAUDE.md: classes for data).\nexport class RefreshClaim {\n outcome: RefreshClaimOutcome;\n /** The lock now held; set only for CLAIMED. */\n lock: MainSyncLock | null;\n /** The stamp this decision read from main-sync-status.json, or null when none was recorded. */\n lastRefreshStartedMs: number | null;\n\n constructor(outcome: RefreshClaimOutcome, lock: MainSyncLock | null, lastRefreshStartedMs: number | null) {\n this.outcome = outcome;\n this.lock = lock;\n this.lastRefreshStartedMs = lastRefreshStartedMs;\n }\n}\n\n// Raw JSON shape for the cast at the parse boundary.\n// Every field optional: the runtime `typeof` checks in readMainSyncLock are what decide it is a lock.\ninterface RawLock {\n purpose?: string;\n pid?: number;\n startedMs?: number;\n}\n\n/**\n * The single-flight mutex for the detached main-sync refresher, and the spawner's throttle in front of\n * it (#1342). Two files, two jobs:\n *\n * - `main-sync.lock.json` — the MUTEX. Created with an atomic exclusive create; the creator runs the\n * refresh and deletes it when done, after confirming it is still its own. Everybody else gets\n * EEXIST and skips.\n * - `main-sync-status.json` — the RESULTS, and `lastRefreshStartedMs`, the throttle's stamp. The\n * refresher writes it FIRST and deletes the lock LAST, so the stamp is on disk before any process\n * can win the next race.\n *\n * SHARED scope: one lock for the whole repo, in the primary clone. There is one `.git`, and N\n * concurrent `git fetch`es against it are what corrupt `.git/FETCH_HEAD`.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class MainSyncLockService {\n constructor(\n private readonly dotDir: DotWebpieces = dotWebpieces,\n private readonly atomicFile: AtomicFile = new AtomicFile(),\n private readonly status: MainSyncStatusService = new MainSyncStatusService(),\n ) {}\n\n mainSyncLockPath(repoRoot: string): string {\n return this.dotDir.sharedFile(repoRoot, MAIN_SYNC_LOCK_FILE);\n }\n\n /**\n * THE SPAWNER'S WHOLE DECISION: may this hook process launch a refresher? Called BEFORE anything\n * is spawned, so a throttled tool call costs one read of the status file: no node child, no git.\n *\n * The stamp is the last run's START, and a failed run records one too, so a failing refresh\n * (offline, gh auth expired) is retried after the interval: never hammered on every call, never\n * blocked forever. A run that crashes before recording anything leaves its lock behind, and that\n * lock is reclaimed once older than `hangTimeoutMinutes`.\n */\n claimRefresh(repoRoot: string, hangTimeoutMinutes: number, now: number = Date.now()): RefreshClaim {\n const file = this.status.readMainSyncStatusFile(repoRoot);\n const stamp = file === null ? null : file.lastRefreshStartedMs;\n if (stamp !== null && this.withinInterval(stamp, now)) {\n return new RefreshClaim(RefreshClaimOutcome.RECENT, null, stamp);\n }\n const lock = this.tryAcquireMainSyncLock(repoRoot, hangTimeoutMinutes, now, SPAWNER_CLAIM_PID);\n return new RefreshClaim(lock === null ? RefreshClaimOutcome.HELD : RefreshClaimOutcome.CLAIMED, lock, stamp);\n }\n\n /**\n * Take the mutex with an atomic exclusive create. Returns the lock now held, or null.\n *\n * Normal case: the file does not exist (the last winner deleted it), the create wins, done. On\n * EEXIST the holder is judged: alive and inside `hangTimeoutMinutes` means it is working, and we\n * skip. Otherwise (hung, dead, or a file that is not a lock in this shape at all, such as the\n * retired `state: finished` format) it is the ONE exception to \"the winner deletes\": its holder is\n * killed if still alive, the file unlinked, and the create retried once; only that create's winner\n * proceeds.\n *\n * The create writes the content to a private temp file and LINKS it into place: `link()` fails with\n * EEXIST exactly like O_EXCL, but the lock appears with its content already in it, so no reader can\n * ever see an empty lock and mistake it for garbage.\n */\n tryAcquireMainSyncLock(repoRoot: string, hangTimeoutMinutes: number, now: number = Date.now(), pid: number = process.pid): MainSyncLock | null {\n const mine = new MainSyncLock(pid, now);\n if (this.createExclusive(repoRoot, mine)) return mine;\n\n const held = this.readMainSyncLock(repoRoot);\n if (held !== null && this.isHolderRunning(held, hangTimeoutMinutes, now)) return null;\n if (held !== null) {\n this.killStaleHolder(held, pid);\n // Unlink only the lock we judged. A contender that reclaimed first has already created a\n // FRESH lock here, and that one is not ours to remove.\n const again = this.readMainSyncLock(repoRoot);\n if (again !== null && (again.pid !== held.pid || again.startedMs !== held.startedMs)) return null;\n fs.rmSync(this.mainSyncLockPath(repoRoot), { force: true });\n }\n return this.createExclusive(repoRoot, mine) ? mine : null;\n }\n\n /**\n * The detached refresher takes over the lock its spawner created: same `startedMs`, its own pid, so\n * liveness and the reclaimer's kill now target the process doing the work. Null when that lock is no\n * longer on disk (it was reclaimed while the child booted); the child then does nothing. The child\n * never creates the lock itself, so it can never race its own spawner.\n */\n adoptMainSyncLock(repoRoot: string, claimStartedMs: number, pid: number = process.pid): MainSyncLock | null {\n const held = this.readMainSyncLock(repoRoot);\n if (held === null || held.startedMs !== claimStartedMs || held.pid !== SPAWNER_CLAIM_PID) return null;\n const adopted = new MainSyncLock(pid, claimStartedMs);\n this.writeMainSyncLock(repoRoot, adopted);\n return adopted;\n }\n\n /**\n * Delete OUR lock. Returns false, and deletes nothing, when the lock on disk is not ours: a run that\n * hung past its window was reclaimed, and deleting the NEWER holder's lock would let a third\n * refresher start beside the second. Call it only after the status file (and its stamp) is written.\n */\n releaseMainSyncLock(repoRoot: string, held: MainSyncLock): boolean {\n const onDisk = this.readMainSyncLock(repoRoot);\n if (onDisk === null || onDisk.pid !== held.pid || onDisk.startedMs !== held.startedMs) return false;\n fs.rmSync(this.mainSyncLockPath(repoRoot), { force: true });\n return true;\n }\n\n /**\n * The lock on disk, or null when there is none. A file that is not a lock in this shape (the retired\n * `state`/`started` format, or anything unparseable) reads as a lock with pid 0 started at the epoch:\n * stale at once, so it is reclaimed rather than honoured as a second spelling of a lock.\n */\n readMainSyncLock(repoRoot: string): MainSyncLock | null {\n const text = this.readText(this.mainSyncLockPath(repoRoot));\n if (text === null) return null;\n const raw = this.parse(text);\n if (raw === null || raw.purpose !== MAIN_SYNC_LOCK_PURPOSE) return new MainSyncLock(0, 0);\n if (typeof raw.pid !== 'number' || typeof raw.startedMs !== 'number') return new MainSyncLock(0, 0);\n return new MainSyncLock(raw.pid, raw.startedMs);\n }\n\n // Replace the lock's content atomically (temp + rename). Only for a lock this process already holds.\n writeMainSyncLock(repoRoot: string, lock: MainSyncLock): void {\n this.atomicFile.writeJsonAtomic(this.mainSyncLockPath(repoRoot), lock);\n }\n\n // True when a refresher holds the lock and is working: alive, and inside its hang window.\n isRefreshInProgress(repoRoot: string, hangTimeoutMinutes: number, now: number = Date.now()): boolean {\n const lock = this.readMainSyncLock(repoRoot);\n return lock !== null && this.isHolderRunning(lock, hangTimeoutMinutes, now);\n }\n\n // A lock is stale (its holder assumed hung) once older than hangTimeoutMinutes.\n isLockStale(lock: MainSyncLock, hangTimeoutMinutes: number, now: number = Date.now()): boolean {\n return now - lock.startedMs > hangTimeoutMinutes * 60 * 1000;\n }\n\n // pid 0 = unknown (a spawner's unadopted lock) → alive, judged by age alone.\n private isHolderRunning(lock: MainSyncLock, hangTimeoutMinutes: number, now: number): boolean {\n return !this.isLockStale(lock, hangTimeoutMinutes, now) && this.isProcessAlive(lock.pid);\n }\n\n // A negative age (the clock stepped backwards) never throttles: it would otherwise pin the stamp.\n private withinInterval(startedMs: number, now: number): boolean {\n const age = now - startedMs;\n return age >= 0 && age < MIN_REFRESH_INTERVAL_MINUTES * 60 * 1000;\n }\n\n // The file's text, or null when there is no lock (including one deleted between check and read).\n private readText(lockPath: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.readFileSync(lockPath, 'utf8');\n } catch (err: unknown) {\n const error = toError(err);\n if (error.message.includes('ENOENT')) return null;\n throw error;\n }\n }\n\n private parse(text: string): RawLock | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const parsed = JSON.parse(text) as RawLock | null;\n return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;\n } catch (err: unknown) { // webpieces-disable catch-must-propagate -- an unparseable lock IS the answer here: it is garbage, and the caller reclaims it\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * Atomic create-with-content: write a private temp file, then `link()` it to the lock path. The link\n * fails with EEXIST when the lock exists, which is the O_EXCL contract, and a reader never sees the\n * lock before its content.\n */\n private createExclusive(repoRoot: string, lock: MainSyncLock): boolean {\n const lockPath = this.mainSyncLockPath(repoRoot);\n fs.mkdirSync(path.dirname(lockPath), { recursive: true });\n const tmp = `${lockPath}.${String(process.pid)}.${String(Date.now())}.${Math.random().toString(36).slice(2)}.tmp`;\n fs.writeFileSync(tmp, JSON.stringify(lock, null, 2) + '\\n', { flag: 'wx' });\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.linkSync(tmp, lockPath);\n return true;\n } catch (err: unknown) {\n const error = toError(err);\n if (error.message.includes('EEXIST')) return false;\n throw error;\n } finally {\n fs.rmSync(tmp, { force: true });\n }\n }\n\n /**\n * Best-effort SIGKILL of a reclaimed holder that is still alive: a refresher hung past\n * `hangTimeoutMinutes`. Never for pid <= 0 (a spawner's unadopted lock, or garbage), our own pid,\n * or the claimant's. The refresher is spawned `detached`, so it leads its own process group: the\n * group is signalled first, which takes its in-flight `git fetch` / `gh` children with it; a plain\n * pid signal is the fallback for a holder that is not a group leader.\n *\n * Residual risk, accepted: after a long hang the OS may have reused the pid. The window is\n * hangTimeoutMinutes, and pids cycle far slower than that on a developer machine.\n */\n private killStaleHolder(stale: MainSyncLock, claimantPid: number): void {\n if (stale.pid <= 0 || stale.pid === process.pid || stale.pid === claimantPid) return;\n if (!this.isProcessAlive(stale.pid)) return;\n if (this.sendKill(-stale.pid)) return;\n this.sendKill(stale.pid);\n }\n\n // true when delivered; false for no such process/group (ESRCH) or not ours to signal (EPERM).\n private sendKill(target: number): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n process.kill(target, 'SIGKILL');\n return true;\n } catch (err: unknown) {\n const error = toError(err);\n if (error.message.includes('ESRCH') || error.message.includes('EPERM')) return false;\n throw error;\n }\n }\n\n // Liveness probe: is `pid` still a running process? pid <= 0 (unknown) → assume alive.\n private isProcessAlive(pid: number): boolean {\n if (pid <= 0) return true;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n process.kill(pid, 0);\n return true;\n } catch (err: unknown) {\n const error = toError(err);\n if (error.message.includes('ESRCH')) return false;\n if (error.message.includes('EPERM')) return true;\n throw error;\n }\n }\n}\n"]}
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
import { AtomicFile } from '@webpieces/tooling-common';
|
|
2
|
-
import { MAIN_SYNC_STATUS_VERSION, MainSyncFileStore, MainSyncStatus, MainSyncStatusFile, PullRequestIndex } from './main-sync-file';
|
|
2
|
+
import { MAIN_SYNC_STATUS_VERSION, MainSyncFileStore, MainSyncStatus, MainSyncStatusFile, PullRequestIndex, RefreshOutcome } from './main-sync-file';
|
|
3
3
|
import { DotWebpieces } from '@webpieces/tooling-common';
|
|
4
4
|
import { WorktreeService } from './worktrees';
|
|
5
|
-
|
|
5
|
+
import { CommandTimeouts } from './command-timeouts';
|
|
6
|
+
export { MainSyncStatus, MainSyncStatusFile, PullRequestIndex, MAIN_SYNC_STATUS_VERSION, RefreshOutcome };
|
|
6
7
|
export declare const DEFAULT_HANG_TIMEOUT_MINUTES = 5;
|
|
7
|
-
export declare class MainSyncLock {
|
|
8
|
-
state: string;
|
|
9
|
-
started: number;
|
|
10
|
-
pid: number;
|
|
11
|
-
constructor(state: string, started: number, pid?: number);
|
|
12
|
-
}
|
|
13
8
|
/**
|
|
14
|
-
* Reads/writes the main-sync cache
|
|
9
|
+
* Reads/writes the main-sync cache and computes the slow "is my branch healthy vs origin/main?"
|
|
15
10
|
* status. `@injectable(bindingScopeValues.Singleton)` so it's injectable and drawn in consumers’ DI designs.
|
|
16
11
|
*/
|
|
17
12
|
export declare class MainSyncStatusService {
|
|
@@ -19,7 +14,8 @@ export declare class MainSyncStatusService {
|
|
|
19
14
|
private readonly atomicFile;
|
|
20
15
|
private readonly store;
|
|
21
16
|
private readonly worktrees;
|
|
22
|
-
|
|
17
|
+
private readonly timeouts;
|
|
18
|
+
constructor(dotDir?: DotWebpieces, atomicFile?: AtomicFile, store?: MainSyncFileStore, worktrees?: WorktreeService, timeouts?: CommandTimeouts);
|
|
23
19
|
/**
|
|
24
20
|
* SHARED scope: `<primary>/.webpieces/main-sync-status.json`, one per repo.
|
|
25
21
|
*
|
|
@@ -36,21 +32,6 @@ export declare class MainSyncStatusService {
|
|
|
36
32
|
* so one fetch arms everyone. Single-flight is unchanged; only what the winner writes changed.
|
|
37
33
|
*/
|
|
38
34
|
mainSyncStatusPath(repoRoot: string): string;
|
|
39
|
-
/**
|
|
40
|
-
* SHARED scope — the single-flight lock for the whole repo.
|
|
41
|
-
*
|
|
42
|
-
* This is the fix for the filed bug "the detached main-sync refresher's `git fetch` races the
|
|
43
|
-
* agent's foreground `git fetch`/`git pull` and corrupts `.git/FETCH_HEAD`". The lock was already
|
|
44
|
-
* atomic (O_CREAT|O_EXCL) and already serialised refreshers against each other — but only WITHIN
|
|
45
|
-
* one worktree, so N worktrees meant N locks and up to N concurrent `git fetch`es against the ONE
|
|
46
|
-
* shared `.git`, which is exactly how a duplicate `for-merge` line lands in FETCH_HEAD. There is
|
|
47
|
-
* one `.git`, so there is now one lock: at most one refresher in flight across all worktrees.
|
|
48
|
-
*
|
|
49
|
-
* Stale-lock handling is unchanged and still required (a lock nobody can clear is worse than the
|
|
50
|
-
* race): the holder records pid + start epoch, and `tryAcquireMainSyncLock` reclaims it once
|
|
51
|
-
* `isRefreshInProgress` proves the holder is finished, hung past hangTimeoutMinutes, or dead.
|
|
52
|
-
*/
|
|
53
|
-
mainSyncLockPath(repoRoot: string): string;
|
|
54
35
|
/**
|
|
55
36
|
* The cached status FOR ONE BRANCH — the only read the four main-sync guards perform.
|
|
56
37
|
*
|
|
@@ -78,26 +59,13 @@ export declare class MainSyncStatusService {
|
|
|
78
59
|
* truncates first, so a reader landing in that window gets a torn document. See AtomicFile.
|
|
79
60
|
*/
|
|
80
61
|
writeMainSyncStatusFile(repoRoot: string, file: MainSyncStatusFile): void;
|
|
81
|
-
readMainSyncLock(repoRoot: string): MainSyncLock | null;
|
|
82
|
-
writeMainSyncLock(repoRoot: string, lock: MainSyncLock): void;
|
|
83
|
-
isLockStale(lock: MainSyncLock, hangTimeoutMinutes: number, now?: number): boolean;
|
|
84
|
-
isRefreshInProgress(repoRoot: string, hangTimeoutMinutes: number, now?: number): boolean;
|
|
85
62
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* concurrency this lock exists to prevent. The create uses the `wx` flag (O_CREAT|O_EXCL), so of
|
|
91
|
-
* N racing refreshers exactly one creates the file.
|
|
92
|
-
*
|
|
93
|
-
* A lock file left behind by a FINISHED, hung, or killed refresher is reclaimed: we only unlink
|
|
94
|
-
* and re-take it once `isRefreshInProgress` says the holder is provably not running, and we
|
|
95
|
-
* re-read afterwards to confirm the entry we see is ours before claiming the lock.
|
|
63
|
+
* Record that a refresh STARTED at `startedMs` and ended with `outcome`, keeping every branch entry
|
|
64
|
+
* already on disk. The refresher's failure path: a run that produced no new branch data still
|
|
65
|
+
* counts as a run, so the throttle retries it after MIN_REFRESH_INTERVAL_MINUTES rather than on
|
|
66
|
+
* every tool call. A successful run stamps the whole document instead (writeMainSyncStatusFile).
|
|
96
67
|
*/
|
|
97
|
-
|
|
98
|
-
private createExclusive;
|
|
99
|
-
inProcessLock(now?: number, pid?: number): MainSyncLock;
|
|
100
|
-
finishedLock(started: number): MainSyncLock;
|
|
68
|
+
recordRefreshOutcome(repoRoot: string, startedMs: number, outcome: RefreshOutcome): void;
|
|
101
69
|
/**
|
|
102
70
|
* The SLOW path for ONE branch: the branch checked out in `repoRoot`. Runs its own fetch and its
|
|
103
71
|
* own `gh` query, so it is the right entry point for a single-worktree caller and for tests.
|
|
@@ -155,8 +123,6 @@ export declare class MainSyncStatusService {
|
|
|
155
123
|
*/
|
|
156
124
|
private fetchOriginMain;
|
|
157
125
|
private isUnknownGitOption;
|
|
158
|
-
private ensureDir;
|
|
159
|
-
private isProcessAlive;
|
|
160
126
|
private capture;
|
|
161
127
|
private gitBranch;
|
|
162
128
|
private localMainHash;
|
|
@@ -179,18 +145,10 @@ export declare class MainSyncStatusService {
|
|
|
179
145
|
private benignStatus;
|
|
180
146
|
}
|
|
181
147
|
export declare function mainSyncStatusPath(repoRoot: string): string;
|
|
182
|
-
export declare function mainSyncLockPath(repoRoot: string): string;
|
|
183
148
|
export declare function readMainSyncStatus(repoRoot: string, branch: string): MainSyncStatus | null;
|
|
184
149
|
export declare function readMainSyncStatusFile(repoRoot: string): MainSyncStatusFile | null;
|
|
185
150
|
export declare function writeMainSyncStatus(repoRoot: string, status: MainSyncStatus): void;
|
|
186
151
|
export declare function writeMainSyncStatusFile(repoRoot: string, file: MainSyncStatusFile): void;
|
|
187
|
-
export declare function readMainSyncLock(repoRoot: string): MainSyncLock | null;
|
|
188
|
-
export declare function writeMainSyncLock(repoRoot: string, lock: MainSyncLock): void;
|
|
189
|
-
export declare function isLockStale(lock: MainSyncLock, hangTimeoutMinutes: number, now?: number): boolean;
|
|
190
|
-
export declare function isRefreshInProgress(repoRoot: string, hangTimeoutMinutes: number, now?: number): boolean;
|
|
191
|
-
export declare function tryAcquireMainSyncLock(repoRoot: string, hangTimeoutMinutes: number, now?: number, pid?: number): MainSyncLock | null;
|
|
192
|
-
export declare function inProcessLock(now?: number, pid?: number): MainSyncLock;
|
|
193
|
-
export declare function finishedLock(started: number): MainSyncLock;
|
|
194
152
|
export declare function computeMainSyncStatus(repoRoot: string): MainSyncStatus;
|
|
195
153
|
export declare function computeAllMainSyncStatuses(repoRoot: string): MainSyncStatusFile;
|
|
196
154
|
export declare function squashRecoverySteps(currentBranch: string): string[];
|