@webpieces/repo-workflow-core 0.4.973 → 0.4.974
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/repo-workflow-core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.974",
|
|
4
4
|
"description": "Shared repository branch, worktree, sync, and identity-stamp mechanics.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"inversify": "7.10.4",
|
|
23
23
|
"reflect-metadata": "0.2.2",
|
|
24
|
-
"@webpieces/tooling-common": "0.4.
|
|
24
|
+
"@webpieces/tooling-common": "0.4.974",
|
|
25
25
|
"tslib": "2.8.1"
|
|
26
26
|
},
|
|
27
27
|
"publishConfig": {
|
package/src/main-sync-file.d.ts
CHANGED
|
@@ -56,7 +56,8 @@ export declare class MainSyncStatusFile {
|
|
|
56
56
|
/**
|
|
57
57
|
* Epoch ms the last detached refresh STARTED: the throttle's stamp (#1342). The spawner reads it
|
|
58
58
|
* before launching anything and launches nothing while it is under MIN_REFRESH_INTERVAL_MINUTES
|
|
59
|
-
* old
|
|
59
|
+
* old, unless the judged branch has no entry in `branches` yet (#1377; see
|
|
60
|
+
* MainSyncLockService.claimRefresh). It is the START, not the finish, so a failing run is retried after the interval rather than
|
|
60
61
|
* on every tool call. Null (an older file, or one pr-gate wrote before any refresh) means "due".
|
|
61
62
|
*
|
|
62
63
|
* The refresher writes it BEFORE it deletes main-sync.lock.json, so no process can win the next
|
package/src/main-sync-file.js
CHANGED
|
@@ -87,7 +87,8 @@ class MainSyncStatusFile {
|
|
|
87
87
|
/**
|
|
88
88
|
* Epoch ms the last detached refresh STARTED: the throttle's stamp (#1342). The spawner reads it
|
|
89
89
|
* before launching anything and launches nothing while it is under MIN_REFRESH_INTERVAL_MINUTES
|
|
90
|
-
* old
|
|
90
|
+
* old, unless the judged branch has no entry in `branches` yet (#1377; see
|
|
91
|
+
* MainSyncLockService.claimRefresh). It is the START, not the finish, so a failing run is retried after the interval rather than
|
|
91
92
|
* on every tool call. Null (an older file, or one pr-gate wrote before any refresh) means "due".
|
|
92
93
|
*
|
|
93
94
|
* The refresher writes it BEFORE it deletes main-sync.lock.json, so no process can win the next
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main-sync-file.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/main-sync-file.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { AtomicFile } from '@webpieces/tooling-common';\nimport { toError } from '@webpieces/tooling-common/to-error';\n\n/**\n * The ON-DISK SHAPE of `<primary>/.webpieces/main-sync-status.json`, and nothing else.\n *\n * WHY this is its own module rather than more of main-sync-status.ts: that file already owns the lock\n * state machine plus the whole slow git/gh computation, and adding the map shape to it pushed it past\n * the 700-line cap. The split is along a real seam — everything here is pure parse/serialise with no\n * process spawning, so it is also the cheap half to test.\n *\n * WHY the file is a MAP of branch -> status (v2) rather than one status (v1): the cache is SHARED by\n * every worktree of the repo (one `.git`, one `origin/main`, one single-flight refresher), but a v1\n * file described exactly ONE branch — whichever tree won the lock. Every guard fails open when the\n * cached branch is not the branch it is judging, so with N worktrees at most one worktree's guards\n * were armed and the rest abstained, thrashing as the lock changed hands. Keeping the payload per\n * branch identical and only changing the LOOKUP means no guard's field access changed.\n */\n\n// Bumped when the shape changes. v1 = one bare status; v2 = branch map; v3 = cached commit distance.\nexport const MAIN_SYNC_STATUS_VERSION = 3;\n\n// Data-only (per CLAUDE.md, classes for data). The per-branch payload — unchanged from v1.\nexport class MainSyncStatus {\n branch: string;\n branchAlreadyMerged: boolean;\n mergedPr: string;\n hasForkPoint: boolean;\n forkPoint: string | null;\n originMain: string;\n featureHead: string;\n conflict: boolean;\n conflictFiles: string[];\n timestamp: string;\n // An OPEN (not merged) PR tracking this branch, if any — '' = none or not-yet-known. Advisory.\n // Kept OUT of the positional constructor (a defaulted field) so existing call sites don't churn.\n openPr: string = '';\n // The LOCAL refs/heads/main hash — '' = main does not exist locally (fresh clone / worktree) or\n // could not be read. Paired with `originMain`, this is what tells the read-stale-guard whether a\n // checked-out `main` is behind its remote. Defaulted field for the same reason as `openPr`.\n localMain: string = '';\n // Computed by the detached refresher as local main..origin/main. Null means an older cache or git\n // could not establish the distance; stale-main threshold checks fail open in that state.\n commitsBehind: number | null = null;\n /**\n * Did the forge answer when this entry was computed? See PullRequestIndex.forgeReachable.\n *\n * `branchAlreadyMerged: false` is produced BOTH by \"this branch has no merged PR\" and by \"we could\n * not ask\", and the three guards that act on the merged state took the ordinary ALLOW path in both.\n * Carrying the distinction into the cache is what lets them say ALLOW_FAIL_OPEN / `no-forge`\n * instead — the typed verdict exists precisely so abstentions are countable.\n *\n * Defaults TRUE, matching `openPr`/`localMain` as a non-positional field: an entry written before\n * this field existed (or hand-edited) is read as \"the forge answered\", which preserves today's\n * verdicts exactly rather than retro-labelling old caches as abstentions.\n */\n forgeReachable: boolean = true;\n\n constructor(\n branch: string,\n branchAlreadyMerged: boolean,\n mergedPr: string,\n hasForkPoint: boolean,\n forkPoint: string | null,\n originMain: string,\n featureHead: string,\n conflict: boolean,\n conflictFiles: string[],\n timestamp: string,\n ) {\n this.branch = branch;\n this.branchAlreadyMerged = branchAlreadyMerged;\n this.mergedPr = mergedPr;\n this.hasForkPoint = hasForkPoint;\n this.forkPoint = forkPoint;\n this.originMain = originMain;\n this.featureHead = featureHead;\n this.conflict = conflict;\n this.conflictFiles = conflictFiles;\n this.timestamp = timestamp;\n }\n}\n\n/** How the last detached refresh ended. Recorded in the status file, never in the lock. */\nexport enum RefreshOutcome {\n OK = 'ok',\n ERROR = 'error',\n}\n\n// Data-only. The whole document: every branch the last refresh could see, written as one unit.\nexport class MainSyncStatusFile {\n version: number;\n // When the map as a whole was written. Each entry also carries its own timestamp.\n timestamp: string;\n branches: Record<string, MainSyncStatus>;\n /**\n * Epoch ms the last detached refresh STARTED: the throttle's stamp (#1342). The spawner reads it\n * before launching anything and launches nothing while it is under MIN_REFRESH_INTERVAL_MINUTES\n * old. It is the START, not the finish, so a failing run is retried after the interval rather than\n * on every tool call. Null (an older file, or one pr-gate wrote before any refresh) means \"due\".\n *\n * The refresher writes it BEFORE it deletes main-sync.lock.json, so no process can win the next\n * lock race and still see the previous stamp.\n */\n lastRefreshStartedMs: number | null = null;\n /** How that run ended; null until a refresh has recorded one. */\n lastRefreshOutcome: RefreshOutcome | null = null;\n\n constructor(version: number, timestamp: string, branches: Record<string, MainSyncStatus>) {\n this.version = version;\n this.timestamp = timestamp;\n this.branches = branches;\n }\n}\n\n/**\n * The repo's pull requests indexed by head branch — ONE `gh pr list` for the whole refresh.\n *\n * WHY it exists: the per-branch status needs a merged-PR and an open-PR answer, and the old code asked\n * `gh` twice PER BRANCH. Recording N branches per refresh would have meant 2N network round trips and\n * made the refresher scale with worktree count. One repo-wide query indexed locally is 1 call for any N.\n */\nexport class PullRequestIndex {\n // branch -> PR number, as a string ('' means \"no such PR\"), for MERGED and OPEN respectively.\n merged: Record<string, string>;\n open: Record<string, string>;\n /**\n * Did the forge actually ANSWER?\n *\n * `mergedFor()` returning '' has always meant two completely different things — \"this branch has no\n * merged PR\" and \"we could not ask\" (`gh` missing, unauthenticated, rate-limited, offline, or the\n * response unparseable). Both produced the same empty index, so from the decision log you could not\n * tell whether the merged-branch policy was PROTECTING anything or quietly abstaining. This flag is\n * the difference, and it is what lets the guards emit ALLOW_FAIL_OPEN instead of a plain ALLOW.\n *\n * REQUIRED, not defaulted: an index built on a failure path must say so out loud, and a default of\n * `true` would make \"we asked and got an answer\" the thing you get by forgetting.\n */\n forgeReachable: boolean;\n\n constructor(merged: Record<string, string>, open: Record<string, string>, forgeReachable: boolean) {\n this.merged = merged;\n this.open = open;\n this.forgeReachable = forgeReachable;\n }\n\n // A merged PR for this branch, or '' — the same value the old per-branch `gh` call produced.\n mergedFor(branch: string): string {\n if (branch === '' || branch === 'main') return '';\n return hasOwn(this.merged, branch) ? (this.merged[branch] ?? '') : '';\n }\n\n openFor(branch: string): string {\n if (branch === '' || branch === 'main') return '';\n return hasOwn(this.open, branch) ? (this.open[branch] ?? '') : '';\n }\n}\n\n// Raw JSON shapes for the cast at the parse boundary.\ninterface RawStatus {\n branch?: string;\n branchAlreadyMerged?: boolean;\n mergedPr?: string;\n hasForkPoint?: boolean;\n forkPoint?: string | null;\n originMain?: string;\n featureHead?: string;\n conflict?: boolean;\n conflictFiles?: string[];\n timestamp?: string;\n openPr?: string;\n localMain?: string;\n commitsBehind?: number | null;\n forgeReachable?: boolean;\n}\n\n// v2 has `branches`; a v1 document instead has the RawStatus fields at the top level.\ninterface RawFile extends RawStatus {\n version?: number;\n lastRefreshStartedMs?: number | null;\n lastRefreshOutcome?: string | null;\n branches?: Record<string, RawStatus>;\n}\n\n// One row of `gh pr list --json number,headRefName,state`.\ninterface RawPullRequest {\n number?: number;\n headRefName?: string;\n state?: string;\n}\n\n// Own-property lookup only: a branch literally named `constructor` or `toString` must not resolve to\n// something off Object.prototype and be mistaken for a cached status.\n// webpieces-disable no-function-outside-class -- one-line own-property predicate used by the classes in this module\nfunction hasOwn(record: object, key: string): boolean {\n return Object.prototype.hasOwnProperty.call(record, key);\n}\n\n/**\n * Reads and writes the branch-keyed status document. Every read fails SOFT to null (or to an empty\n * map) — the guards above this treat null as `no-sync-cache (fail-open)`, so a missing, truncated or\n * malformed file must never throw and must never block an edit.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class MainSyncFileStore {\n constructor(private readonly atomicFile: AtomicFile = new AtomicFile()) {}\n\n /**\n * Parse the document at `statusPath`, adapting a v1 file on the way. Returns null when there is\n * nothing readable there — missing file, unreadable file, or JSON that does not parse.\n */\n readFile(statusPath: string): MainSyncStatusFile | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(statusPath)) return null;\n // The cast is the trust boundary: every field of RawFile is optional and every read of it\n // below is defaulted, so a document of any other shape degrades to an empty map.\n const parsed = JSON.parse(fs.readFileSync(statusPath, 'utf8')) as RawFile | null;\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;\n return this.adapt(parsed);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * ATOMIC write (temp file + `rename()` in the same directory). The refresher writes this while\n * guards on the blocking path read it, from other worktrees; a plain `writeFileSync` truncates\n * first, so a reader landing in that window gets a torn document. Writing the map WHOLE also means\n * a reader never sees half of one refresh's branches and half of the previous refresh's.\n */\n writeFile(statusPath: string, file: MainSyncStatusFile): void {\n this.atomicFile.writeJsonAtomic(statusPath, file);\n }\n\n /**\n * READ-MODIFY-WRITE a single branch's entry, leaving every sibling entry untouched.\n *\n * This is what any writer that is NOT the single-flight refresher must use. `stampCleanMainSyncStatus`\n * (pr-gate's post-merge stamp) runs without the lock and knows about exactly one branch; if it wrote\n * a whole document it would silently erase every other worktree's entry and disarm their guards\n * until the next refresh.\n */\n mergeBranch(statusPath: string, status: MainSyncStatus): void {\n const existing = this.readFile(statusPath);\n const branches: Record<string, MainSyncStatus> = existing === null ? {} : existing.branches;\n branches[status.branch] = status;\n const next = new MainSyncStatusFile(MAIN_SYNC_STATUS_VERSION, new Date().toISOString(), branches);\n this.carryRefreshStamp(existing, next);\n this.writeFile(statusPath, next);\n }\n\n /**\n * READ-MODIFY-WRITE the refresh stamp alone, keeping every branch entry. The refresher's failure\n * path: no new branch data, but the run still counts, so the throttle waits out the interval.\n */\n recordRefresh(statusPath: string, startedMs: number, outcome: RefreshOutcome): void {\n const existing = this.readFile(statusPath);\n const next = new MainSyncStatusFile(MAIN_SYNC_STATUS_VERSION, new Date().toISOString(),\n existing === null ? {} : existing.branches);\n next.lastRefreshStartedMs = startedMs;\n next.lastRefreshOutcome = outcome;\n this.writeFile(statusPath, next);\n }\n\n // A writer that rebuilds the document (mergeBranch) must not erase the throttle's stamp: losing it\n // would make the very next tool call launch a refresh.\n private carryRefreshStamp(from: MainSyncStatusFile | null, to: MainSyncStatusFile): void {\n if (from === null) return;\n to.lastRefreshStartedMs = from.lastRefreshStartedMs;\n to.lastRefreshOutcome = from.lastRefreshOutcome;\n }\n\n // The entry for one branch, or null when this refresh never saw that branch.\n branchStatus(file: MainSyncStatusFile | null, branch: string): MainSyncStatus | null {\n if (file === null) return null;\n // Defensive: readFile always produces a map, but this method is public and sits on the\n // blocking path — a caller handing it a raw document must get null, not a throw.\n if (file.branches === null || file.branches === undefined) return null;\n if (!hasOwn(file.branches, branch)) return null;\n return file.branches[branch] ?? null;\n }\n\n /**\n * Index `gh pr list --state all --json number,headRefName,state` output by head branch.\n *\n * Newest-first is gh's order, so the FIRST row for a branch wins — matching the old per-branch\n * `--jq .[0].number`. Anything unparseable yields an empty index, i.e. \"no PR\", which is the same\n * answer the old code produced when `gh` was missing or offline: fail-open.\n */\n indexPullRequests(json: string): PullRequestIndex {\n const merged: Record<string, string> = {};\n const open: Record<string, string> = {};\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const parsed = JSON.parse(json) as RawPullRequest[] | null;\n // Parsed but not an array: `gh` answered with something we do not understand, which is\n // not the same as it answering \"no PRs\". Unreachable, so the guards abstain rather than allow.\n if (!Array.isArray(parsed)) return new PullRequestIndex(merged, open, false);\n for (const row of parsed) {\n if (row !== null && typeof row === 'object') this.indexOne(row, merged, open);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return new PullRequestIndex(merged, open, false);\n }\n return new PullRequestIndex(merged, open, true);\n }\n\n private indexOne(row: RawPullRequest, merged: Record<string, string>, open: Record<string, string>): void {\n const branch = row.headRefName ?? '';\n const number = typeof row.number === 'number' ? String(row.number) : '';\n if (branch === '' || number === '') return;\n const state = (row.state ?? '').toUpperCase();\n // First row wins per branch+state — gh lists newest first, matching the old `.[0].number`.\n if (state === 'MERGED' && !hasOwn(merged, branch)) merged[branch] = number;\n if (state === 'OPEN' && !hasOwn(open, branch)) open[branch] = number;\n }\n\n private toOutcome(raw: string | null | undefined): RefreshOutcome | null {\n if (raw === RefreshOutcome.OK) return RefreshOutcome.OK;\n if (raw === RefreshOutcome.ERROR) return RefreshOutcome.ERROR;\n return null;\n }\n\n // v2 documents pass through; a v1 document becomes a one-entry map keyed by its own `branch`.\n private adapt(raw: RawFile): MainSyncStatusFile {\n const branches: Record<string, MainSyncStatus> = {};\n const rawBranches = raw.branches;\n if (rawBranches !== undefined && rawBranches !== null && typeof rawBranches === 'object') {\n for (const [name, entry] of Object.entries(rawBranches)) {\n if (entry !== null && typeof entry === 'object') branches[name] = this.toStatus(entry);\n }\n const file = new MainSyncStatusFile(raw.version ?? MAIN_SYNC_STATUS_VERSION, raw.timestamp ?? '', branches);\n file.lastRefreshStartedMs = typeof raw.lastRefreshStartedMs === 'number' ? raw.lastRefreshStartedMs : null;\n file.lastRefreshOutcome = this.toOutcome(raw.lastRefreshOutcome);\n return file;\n }\n // v1: one bare status at the top level. Key it by the branch it was computed for, so the\n // guard for THAT branch stays armed across the upgrade and every other branch simply misses\n // (null → `no-sync-cache (fail-open)`), which is what it did under v1 anyway.\n if (typeof raw.branch === 'string' && raw.branch !== '') branches[raw.branch] = this.toStatus(raw);\n return new MainSyncStatusFile(1, raw.timestamp ?? '', branches);\n }\n\n // Every field defaulted: a truncated or hand-edited entry degrades to a benign status rather\n // than to a throw. `hasForkPoint` defaults TRUE because false is the BLOCKING value.\n private toStatus(raw: RawStatus): MainSyncStatus {\n const status = new MainSyncStatus(\n raw.branch ?? '',\n raw.branchAlreadyMerged ?? false,\n raw.mergedPr ?? '',\n raw.hasForkPoint ?? true,\n raw.forkPoint ?? null,\n raw.originMain ?? '',\n raw.featureHead ?? '',\n raw.conflict ?? false,\n raw.conflictFiles ?? [],\n raw.timestamp ?? '',\n );\n status.openPr = raw.openPr ?? '';\n status.localMain = raw.localMain ?? '';\n status.commitsBehind = typeof raw.commitsBehind === 'number'\n && Number.isInteger(raw.commitsBehind) && raw.commitsBehind >= 0\n ? raw.commitsBehind\n : null;\n status.forgeReachable = raw.forgeReachable ?? true;\n return status;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"main-sync-file.js","sourceRoot":"","sources":["../../../../../packages/tooling/repo-workflow-core/src/main-sync-file.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { AtomicFile } from '@webpieces/tooling-common';\nimport { toError } from '@webpieces/tooling-common/to-error';\n\n/**\n * The ON-DISK SHAPE of `<primary>/.webpieces/main-sync-status.json`, and nothing else.\n *\n * WHY this is its own module rather than more of main-sync-status.ts: that file already owns the lock\n * state machine plus the whole slow git/gh computation, and adding the map shape to it pushed it past\n * the 700-line cap. The split is along a real seam — everything here is pure parse/serialise with no\n * process spawning, so it is also the cheap half to test.\n *\n * WHY the file is a MAP of branch -> status (v2) rather than one status (v1): the cache is SHARED by\n * every worktree of the repo (one `.git`, one `origin/main`, one single-flight refresher), but a v1\n * file described exactly ONE branch — whichever tree won the lock. Every guard fails open when the\n * cached branch is not the branch it is judging, so with N worktrees at most one worktree's guards\n * were armed and the rest abstained, thrashing as the lock changed hands. Keeping the payload per\n * branch identical and only changing the LOOKUP means no guard's field access changed.\n */\n\n// Bumped when the shape changes. v1 = one bare status; v2 = branch map; v3 = cached commit distance.\nexport const MAIN_SYNC_STATUS_VERSION = 3;\n\n// Data-only (per CLAUDE.md, classes for data). The per-branch payload — unchanged from v1.\nexport class MainSyncStatus {\n branch: string;\n branchAlreadyMerged: boolean;\n mergedPr: string;\n hasForkPoint: boolean;\n forkPoint: string | null;\n originMain: string;\n featureHead: string;\n conflict: boolean;\n conflictFiles: string[];\n timestamp: string;\n // An OPEN (not merged) PR tracking this branch, if any — '' = none or not-yet-known. Advisory.\n // Kept OUT of the positional constructor (a defaulted field) so existing call sites don't churn.\n openPr: string = '';\n // The LOCAL refs/heads/main hash — '' = main does not exist locally (fresh clone / worktree) or\n // could not be read. Paired with `originMain`, this is what tells the read-stale-guard whether a\n // checked-out `main` is behind its remote. Defaulted field for the same reason as `openPr`.\n localMain: string = '';\n // Computed by the detached refresher as local main..origin/main. Null means an older cache or git\n // could not establish the distance; stale-main threshold checks fail open in that state.\n commitsBehind: number | null = null;\n /**\n * Did the forge answer when this entry was computed? See PullRequestIndex.forgeReachable.\n *\n * `branchAlreadyMerged: false` is produced BOTH by \"this branch has no merged PR\" and by \"we could\n * not ask\", and the three guards that act on the merged state took the ordinary ALLOW path in both.\n * Carrying the distinction into the cache is what lets them say ALLOW_FAIL_OPEN / `no-forge`\n * instead — the typed verdict exists precisely so abstentions are countable.\n *\n * Defaults TRUE, matching `openPr`/`localMain` as a non-positional field: an entry written before\n * this field existed (or hand-edited) is read as \"the forge answered\", which preserves today's\n * verdicts exactly rather than retro-labelling old caches as abstentions.\n */\n forgeReachable: boolean = true;\n\n constructor(\n branch: string,\n branchAlreadyMerged: boolean,\n mergedPr: string,\n hasForkPoint: boolean,\n forkPoint: string | null,\n originMain: string,\n featureHead: string,\n conflict: boolean,\n conflictFiles: string[],\n timestamp: string,\n ) {\n this.branch = branch;\n this.branchAlreadyMerged = branchAlreadyMerged;\n this.mergedPr = mergedPr;\n this.hasForkPoint = hasForkPoint;\n this.forkPoint = forkPoint;\n this.originMain = originMain;\n this.featureHead = featureHead;\n this.conflict = conflict;\n this.conflictFiles = conflictFiles;\n this.timestamp = timestamp;\n }\n}\n\n/** How the last detached refresh ended. Recorded in the status file, never in the lock. */\nexport enum RefreshOutcome {\n OK = 'ok',\n ERROR = 'error',\n}\n\n// Data-only. The whole document: every branch the last refresh could see, written as one unit.\nexport class MainSyncStatusFile {\n version: number;\n // When the map as a whole was written. Each entry also carries its own timestamp.\n timestamp: string;\n branches: Record<string, MainSyncStatus>;\n /**\n * Epoch ms the last detached refresh STARTED: the throttle's stamp (#1342). The spawner reads it\n * before launching anything and launches nothing while it is under MIN_REFRESH_INTERVAL_MINUTES\n * old, unless the judged branch has no entry in `branches` yet (#1377; see\n * MainSyncLockService.claimRefresh). It is the START, not the finish, so a failing run is retried after the interval rather than\n * on every tool call. Null (an older file, or one pr-gate wrote before any refresh) means \"due\".\n *\n * The refresher writes it BEFORE it deletes main-sync.lock.json, so no process can win the next\n * lock race and still see the previous stamp.\n */\n lastRefreshStartedMs: number | null = null;\n /** How that run ended; null until a refresh has recorded one. */\n lastRefreshOutcome: RefreshOutcome | null = null;\n\n constructor(version: number, timestamp: string, branches: Record<string, MainSyncStatus>) {\n this.version = version;\n this.timestamp = timestamp;\n this.branches = branches;\n }\n}\n\n/**\n * The repo's pull requests indexed by head branch — ONE `gh pr list` for the whole refresh.\n *\n * WHY it exists: the per-branch status needs a merged-PR and an open-PR answer, and the old code asked\n * `gh` twice PER BRANCH. Recording N branches per refresh would have meant 2N network round trips and\n * made the refresher scale with worktree count. One repo-wide query indexed locally is 1 call for any N.\n */\nexport class PullRequestIndex {\n // branch -> PR number, as a string ('' means \"no such PR\"), for MERGED and OPEN respectively.\n merged: Record<string, string>;\n open: Record<string, string>;\n /**\n * Did the forge actually ANSWER?\n *\n * `mergedFor()` returning '' has always meant two completely different things — \"this branch has no\n * merged PR\" and \"we could not ask\" (`gh` missing, unauthenticated, rate-limited, offline, or the\n * response unparseable). Both produced the same empty index, so from the decision log you could not\n * tell whether the merged-branch policy was PROTECTING anything or quietly abstaining. This flag is\n * the difference, and it is what lets the guards emit ALLOW_FAIL_OPEN instead of a plain ALLOW.\n *\n * REQUIRED, not defaulted: an index built on a failure path must say so out loud, and a default of\n * `true` would make \"we asked and got an answer\" the thing you get by forgetting.\n */\n forgeReachable: boolean;\n\n constructor(merged: Record<string, string>, open: Record<string, string>, forgeReachable: boolean) {\n this.merged = merged;\n this.open = open;\n this.forgeReachable = forgeReachable;\n }\n\n // A merged PR for this branch, or '' — the same value the old per-branch `gh` call produced.\n mergedFor(branch: string): string {\n if (branch === '' || branch === 'main') return '';\n return hasOwn(this.merged, branch) ? (this.merged[branch] ?? '') : '';\n }\n\n openFor(branch: string): string {\n if (branch === '' || branch === 'main') return '';\n return hasOwn(this.open, branch) ? (this.open[branch] ?? '') : '';\n }\n}\n\n// Raw JSON shapes for the cast at the parse boundary.\ninterface RawStatus {\n branch?: string;\n branchAlreadyMerged?: boolean;\n mergedPr?: string;\n hasForkPoint?: boolean;\n forkPoint?: string | null;\n originMain?: string;\n featureHead?: string;\n conflict?: boolean;\n conflictFiles?: string[];\n timestamp?: string;\n openPr?: string;\n localMain?: string;\n commitsBehind?: number | null;\n forgeReachable?: boolean;\n}\n\n// v2 has `branches`; a v1 document instead has the RawStatus fields at the top level.\ninterface RawFile extends RawStatus {\n version?: number;\n lastRefreshStartedMs?: number | null;\n lastRefreshOutcome?: string | null;\n branches?: Record<string, RawStatus>;\n}\n\n// One row of `gh pr list --json number,headRefName,state`.\ninterface RawPullRequest {\n number?: number;\n headRefName?: string;\n state?: string;\n}\n\n// Own-property lookup only: a branch literally named `constructor` or `toString` must not resolve to\n// something off Object.prototype and be mistaken for a cached status.\n// webpieces-disable no-function-outside-class -- one-line own-property predicate used by the classes in this module\nfunction hasOwn(record: object, key: string): boolean {\n return Object.prototype.hasOwnProperty.call(record, key);\n}\n\n/**\n * Reads and writes the branch-keyed status document. Every read fails SOFT to null (or to an empty\n * map) — the guards above this treat null as `no-sync-cache (fail-open)`, so a missing, truncated or\n * malformed file must never throw and must never block an edit.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class MainSyncFileStore {\n constructor(private readonly atomicFile: AtomicFile = new AtomicFile()) {}\n\n /**\n * Parse the document at `statusPath`, adapting a v1 file on the way. Returns null when there is\n * nothing readable there — missing file, unreadable file, or JSON that does not parse.\n */\n readFile(statusPath: string): MainSyncStatusFile | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(statusPath)) return null;\n // The cast is the trust boundary: every field of RawFile is optional and every read of it\n // below is defaulted, so a document of any other shape degrades to an empty map.\n const parsed = JSON.parse(fs.readFileSync(statusPath, 'utf8')) as RawFile | null;\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;\n return this.adapt(parsed);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * ATOMIC write (temp file + `rename()` in the same directory). The refresher writes this while\n * guards on the blocking path read it, from other worktrees; a plain `writeFileSync` truncates\n * first, so a reader landing in that window gets a torn document. Writing the map WHOLE also means\n * a reader never sees half of one refresh's branches and half of the previous refresh's.\n */\n writeFile(statusPath: string, file: MainSyncStatusFile): void {\n this.atomicFile.writeJsonAtomic(statusPath, file);\n }\n\n /**\n * READ-MODIFY-WRITE a single branch's entry, leaving every sibling entry untouched.\n *\n * This is what any writer that is NOT the single-flight refresher must use. `stampCleanMainSyncStatus`\n * (pr-gate's post-merge stamp) runs without the lock and knows about exactly one branch; if it wrote\n * a whole document it would silently erase every other worktree's entry and disarm their guards\n * until the next refresh.\n */\n mergeBranch(statusPath: string, status: MainSyncStatus): void {\n const existing = this.readFile(statusPath);\n const branches: Record<string, MainSyncStatus> = existing === null ? {} : existing.branches;\n branches[status.branch] = status;\n const next = new MainSyncStatusFile(MAIN_SYNC_STATUS_VERSION, new Date().toISOString(), branches);\n this.carryRefreshStamp(existing, next);\n this.writeFile(statusPath, next);\n }\n\n /**\n * READ-MODIFY-WRITE the refresh stamp alone, keeping every branch entry. The refresher's failure\n * path: no new branch data, but the run still counts, so the throttle waits out the interval.\n */\n recordRefresh(statusPath: string, startedMs: number, outcome: RefreshOutcome): void {\n const existing = this.readFile(statusPath);\n const next = new MainSyncStatusFile(MAIN_SYNC_STATUS_VERSION, new Date().toISOString(),\n existing === null ? {} : existing.branches);\n next.lastRefreshStartedMs = startedMs;\n next.lastRefreshOutcome = outcome;\n this.writeFile(statusPath, next);\n }\n\n // A writer that rebuilds the document (mergeBranch) must not erase the throttle's stamp: losing it\n // would make the very next tool call launch a refresh.\n private carryRefreshStamp(from: MainSyncStatusFile | null, to: MainSyncStatusFile): void {\n if (from === null) return;\n to.lastRefreshStartedMs = from.lastRefreshStartedMs;\n to.lastRefreshOutcome = from.lastRefreshOutcome;\n }\n\n // The entry for one branch, or null when this refresh never saw that branch.\n branchStatus(file: MainSyncStatusFile | null, branch: string): MainSyncStatus | null {\n if (file === null) return null;\n // Defensive: readFile always produces a map, but this method is public and sits on the\n // blocking path — a caller handing it a raw document must get null, not a throw.\n if (file.branches === null || file.branches === undefined) return null;\n if (!hasOwn(file.branches, branch)) return null;\n return file.branches[branch] ?? null;\n }\n\n /**\n * Index `gh pr list --state all --json number,headRefName,state` output by head branch.\n *\n * Newest-first is gh's order, so the FIRST row for a branch wins — matching the old per-branch\n * `--jq .[0].number`. Anything unparseable yields an empty index, i.e. \"no PR\", which is the same\n * answer the old code produced when `gh` was missing or offline: fail-open.\n */\n indexPullRequests(json: string): PullRequestIndex {\n const merged: Record<string, string> = {};\n const open: Record<string, string> = {};\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const parsed = JSON.parse(json) as RawPullRequest[] | null;\n // Parsed but not an array: `gh` answered with something we do not understand, which is\n // not the same as it answering \"no PRs\". Unreachable, so the guards abstain rather than allow.\n if (!Array.isArray(parsed)) return new PullRequestIndex(merged, open, false);\n for (const row of parsed) {\n if (row !== null && typeof row === 'object') this.indexOne(row, merged, open);\n }\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return new PullRequestIndex(merged, open, false);\n }\n return new PullRequestIndex(merged, open, true);\n }\n\n private indexOne(row: RawPullRequest, merged: Record<string, string>, open: Record<string, string>): void {\n const branch = row.headRefName ?? '';\n const number = typeof row.number === 'number' ? String(row.number) : '';\n if (branch === '' || number === '') return;\n const state = (row.state ?? '').toUpperCase();\n // First row wins per branch+state — gh lists newest first, matching the old `.[0].number`.\n if (state === 'MERGED' && !hasOwn(merged, branch)) merged[branch] = number;\n if (state === 'OPEN' && !hasOwn(open, branch)) open[branch] = number;\n }\n\n private toOutcome(raw: string | null | undefined): RefreshOutcome | null {\n if (raw === RefreshOutcome.OK) return RefreshOutcome.OK;\n if (raw === RefreshOutcome.ERROR) return RefreshOutcome.ERROR;\n return null;\n }\n\n // v2 documents pass through; a v1 document becomes a one-entry map keyed by its own `branch`.\n private adapt(raw: RawFile): MainSyncStatusFile {\n const branches: Record<string, MainSyncStatus> = {};\n const rawBranches = raw.branches;\n if (rawBranches !== undefined && rawBranches !== null && typeof rawBranches === 'object') {\n for (const [name, entry] of Object.entries(rawBranches)) {\n if (entry !== null && typeof entry === 'object') branches[name] = this.toStatus(entry);\n }\n const file = new MainSyncStatusFile(raw.version ?? MAIN_SYNC_STATUS_VERSION, raw.timestamp ?? '', branches);\n file.lastRefreshStartedMs = typeof raw.lastRefreshStartedMs === 'number' ? raw.lastRefreshStartedMs : null;\n file.lastRefreshOutcome = this.toOutcome(raw.lastRefreshOutcome);\n return file;\n }\n // v1: one bare status at the top level. Key it by the branch it was computed for, so the\n // guard for THAT branch stays armed across the upgrade and every other branch simply misses\n // (null → `no-sync-cache (fail-open)`), which is what it did under v1 anyway.\n if (typeof raw.branch === 'string' && raw.branch !== '') branches[raw.branch] = this.toStatus(raw);\n return new MainSyncStatusFile(1, raw.timestamp ?? '', branches);\n }\n\n // Every field defaulted: a truncated or hand-edited entry degrades to a benign status rather\n // than to a throw. `hasForkPoint` defaults TRUE because false is the BLOCKING value.\n private toStatus(raw: RawStatus): MainSyncStatus {\n const status = new MainSyncStatus(\n raw.branch ?? '',\n raw.branchAlreadyMerged ?? false,\n raw.mergedPr ?? '',\n raw.hasForkPoint ?? true,\n raw.forkPoint ?? null,\n raw.originMain ?? '',\n raw.featureHead ?? '',\n raw.conflict ?? false,\n raw.conflictFiles ?? [],\n raw.timestamp ?? '',\n );\n status.openPr = raw.openPr ?? '';\n status.localMain = raw.localMain ?? '';\n status.commitsBehind = typeof raw.commitsBehind === 'number'\n && Number.isInteger(raw.commitsBehind) && raw.commitsBehind >= 0\n ? raw.commitsBehind\n : null;\n status.forgeReachable = raw.forgeReachable ?? true;\n return status;\n }\n}\n"]}
|
package/src/main-sync-lock.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export declare class MainSyncLock {
|
|
|
36
36
|
export declare enum RefreshClaimOutcome {
|
|
37
37
|
/** This caller created the lock and must launch the refresher (which adopts it). */
|
|
38
38
|
CLAIMED = "claimed",
|
|
39
|
-
/** The last refresh STARTED less than MIN_REFRESH_INTERVAL_MINUTES ago. Nothing to do. */
|
|
39
|
+
/** The last refresh STARTED less than MIN_REFRESH_INTERVAL_MINUTES ago and already covers the judged branch. Nothing to do. */
|
|
40
40
|
RECENT = "recent",
|
|
41
41
|
/** The lock exists and its holder is running, or another caller won the create first. */
|
|
42
42
|
HELD = "held"
|
|
@@ -73,12 +73,32 @@ export declare class MainSyncLockService {
|
|
|
73
73
|
* THE SPAWNER'S WHOLE DECISION: may this hook process launch a refresher? Called BEFORE anything
|
|
74
74
|
* is spawned, so a throttled tool call costs one read of the status file: no node child, no git.
|
|
75
75
|
*
|
|
76
|
+
* `judgedBranch` is REQUIRED: the branch whose cache entry the caller is about to read (#1377). The
|
|
77
|
+
* MIN_REFRESH_INTERVAL_MINUTES throttle applies only while that branch already HAS an entry in
|
|
78
|
+
* `main-sync-status.json`. A branch the file has never seen (one created after the last refresh)
|
|
79
|
+
* skips the interval and goes straight to the single-flight lock, so the next refresh covers it
|
|
80
|
+
* instead of its guards failing open for up to the whole interval. It is still single-flight (at
|
|
81
|
+
* most one refresher at a time, per clone), and it costs one extra refresh per NEW branch, not per
|
|
82
|
+
* call: a refresh records every worktree's branch, so from then on the branch is throttled like any
|
|
83
|
+
* other.
|
|
84
|
+
*
|
|
85
|
+
* Two kinds of unknown branch keep the throttle, because bypassing would launch a refresh on every
|
|
86
|
+
* call: a detached HEAD (DETACHED_HEAD; the refresher keys entries by branch name, so it never has
|
|
87
|
+
* one), and any branch while the last refresh FAILED (that run recorded no new branch, and a failing
|
|
88
|
+
* refresh is retried after the interval, never on every call).
|
|
89
|
+
*
|
|
76
90
|
* The stamp is the last run's START, and a failed run records one too, so a failing refresh
|
|
77
91
|
* (offline, gh auth expired) is retried after the interval: never hammered on every call, never
|
|
78
92
|
* blocked forever. A run that crashes before recording anything leaves its lock behind, and that
|
|
79
93
|
* lock is reclaimed once older than `hangTimeoutMinutes`.
|
|
80
94
|
*/
|
|
81
|
-
claimRefresh(repoRoot: string, hangTimeoutMinutes: number, now?: number): RefreshClaim;
|
|
95
|
+
claimRefresh(repoRoot: string, hangTimeoutMinutes: number, judgedBranch: string, now?: number): RefreshClaim;
|
|
96
|
+
/**
|
|
97
|
+
* True when `branch` has no entry yet and a refresh would add one: the one case that skips the
|
|
98
|
+
* interval (see claimRefresh). Never for a detached HEAD, and never after a failed refresh, so
|
|
99
|
+
* neither can turn the throttle into a refresh per call.
|
|
100
|
+
*/
|
|
101
|
+
private isUncoveredBranch;
|
|
82
102
|
/**
|
|
83
103
|
* Take the mutex with an atomic exclusive create. Returns the lock now held, or null.
|
|
84
104
|
*
|
package/src/main-sync-lock.js
CHANGED
|
@@ -7,6 +7,7 @@ const path = tslib_1.__importStar(require("path"));
|
|
|
7
7
|
const inversify_1 = require("inversify");
|
|
8
8
|
const tooling_common_1 = require("@webpieces/tooling-common");
|
|
9
9
|
const to_error_1 = require("@webpieces/tooling-common/to-error");
|
|
10
|
+
const main_sync_file_1 = require("./main-sync-file");
|
|
10
11
|
const main_sync_status_1 = require("./main-sync-status");
|
|
11
12
|
/**
|
|
12
13
|
* The fewest minutes between two refresher STARTS, repo-wide (#1342).
|
|
@@ -53,7 +54,7 @@ var RefreshClaimOutcome;
|
|
|
53
54
|
(function (RefreshClaimOutcome) {
|
|
54
55
|
/** This caller created the lock and must launch the refresher (which adopts it). */
|
|
55
56
|
RefreshClaimOutcome["CLAIMED"] = "claimed";
|
|
56
|
-
/** The last refresh STARTED less than MIN_REFRESH_INTERVAL_MINUTES ago. Nothing to do. */
|
|
57
|
+
/** The last refresh STARTED less than MIN_REFRESH_INTERVAL_MINUTES ago and already covers the judged branch. Nothing to do. */
|
|
57
58
|
RefreshClaimOutcome["RECENT"] = "recent";
|
|
58
59
|
/** The lock exists and its holder is running, or another caller won the create first. */
|
|
59
60
|
RefreshClaimOutcome["HELD"] = "held";
|
|
@@ -102,20 +103,51 @@ let MainSyncLockService = class MainSyncLockService {
|
|
|
102
103
|
* THE SPAWNER'S WHOLE DECISION: may this hook process launch a refresher? Called BEFORE anything
|
|
103
104
|
* is spawned, so a throttled tool call costs one read of the status file: no node child, no git.
|
|
104
105
|
*
|
|
106
|
+
* `judgedBranch` is REQUIRED: the branch whose cache entry the caller is about to read (#1377). The
|
|
107
|
+
* MIN_REFRESH_INTERVAL_MINUTES throttle applies only while that branch already HAS an entry in
|
|
108
|
+
* `main-sync-status.json`. A branch the file has never seen (one created after the last refresh)
|
|
109
|
+
* skips the interval and goes straight to the single-flight lock, so the next refresh covers it
|
|
110
|
+
* instead of its guards failing open for up to the whole interval. It is still single-flight (at
|
|
111
|
+
* most one refresher at a time, per clone), and it costs one extra refresh per NEW branch, not per
|
|
112
|
+
* call: a refresh records every worktree's branch, so from then on the branch is throttled like any
|
|
113
|
+
* other.
|
|
114
|
+
*
|
|
115
|
+
* Two kinds of unknown branch keep the throttle, because bypassing would launch a refresh on every
|
|
116
|
+
* call: a detached HEAD (DETACHED_HEAD; the refresher keys entries by branch name, so it never has
|
|
117
|
+
* one), and any branch while the last refresh FAILED (that run recorded no new branch, and a failing
|
|
118
|
+
* refresh is retried after the interval, never on every call).
|
|
119
|
+
*
|
|
105
120
|
* The stamp is the last run's START, and a failed run records one too, so a failing refresh
|
|
106
121
|
* (offline, gh auth expired) is retried after the interval: never hammered on every call, never
|
|
107
122
|
* blocked forever. A run that crashes before recording anything leaves its lock behind, and that
|
|
108
123
|
* lock is reclaimed once older than `hangTimeoutMinutes`.
|
|
109
124
|
*/
|
|
110
|
-
claimRefresh(repoRoot, hangTimeoutMinutes, now = Date.now()) {
|
|
125
|
+
claimRefresh(repoRoot, hangTimeoutMinutes, judgedBranch, now = Date.now()) {
|
|
111
126
|
const file = this.status.readMainSyncStatusFile(repoRoot);
|
|
112
127
|
const stamp = file === null ? null : file.lastRefreshStartedMs;
|
|
113
|
-
if (stamp !== null && this.withinInterval(stamp, now)) {
|
|
128
|
+
if (stamp !== null && this.withinInterval(stamp, now) && !this.isUncoveredBranch(file, judgedBranch)) {
|
|
114
129
|
return new RefreshClaim(RefreshClaimOutcome.RECENT, null, stamp);
|
|
115
130
|
}
|
|
116
131
|
const lock = this.tryAcquireMainSyncLock(repoRoot, hangTimeoutMinutes, now, exports.SPAWNER_CLAIM_PID);
|
|
117
132
|
return new RefreshClaim(lock === null ? RefreshClaimOutcome.HELD : RefreshClaimOutcome.CLAIMED, lock, stamp);
|
|
118
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* True when `branch` has no entry yet and a refresh would add one: the one case that skips the
|
|
136
|
+
* interval (see claimRefresh). Never for a detached HEAD, and never after a failed refresh, so
|
|
137
|
+
* neither can turn the throttle into a refresh per call.
|
|
138
|
+
*/
|
|
139
|
+
isUncoveredBranch(file, branch) {
|
|
140
|
+
if (branch === tooling_common_1.DETACHED_HEAD)
|
|
141
|
+
return false;
|
|
142
|
+
if (file === null)
|
|
143
|
+
return true;
|
|
144
|
+
if (file.lastRefreshOutcome === main_sync_file_1.RefreshOutcome.ERROR)
|
|
145
|
+
return false;
|
|
146
|
+
const branches = file.branches;
|
|
147
|
+
if (branches === null || branches === undefined)
|
|
148
|
+
return true;
|
|
149
|
+
return !Object.prototype.hasOwnProperty.call(branches, branch);
|
|
150
|
+
}
|
|
119
151
|
/**
|
|
120
152
|
* Take the mutex with an atomic exclusive create. Returns the lock now held, or null.
|
|
121
153
|
*
|
|
@@ -1 +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
|
+
{"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, DETACHED_HEAD, DotWebpieces, dotWebpieces } from '@webpieces/tooling-common';\nimport { toError } from '@webpieces/tooling-common/to-error';\nimport { MainSyncStatusFile, RefreshOutcome } from './main-sync-file';\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 and already covers the judged branch. 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 * `judgedBranch` is REQUIRED: the branch whose cache entry the caller is about to read (#1377). The\n * MIN_REFRESH_INTERVAL_MINUTES throttle applies only while that branch already HAS an entry in\n * `main-sync-status.json`. A branch the file has never seen (one created after the last refresh)\n * skips the interval and goes straight to the single-flight lock, so the next refresh covers it\n * instead of its guards failing open for up to the whole interval. It is still single-flight (at\n * most one refresher at a time, per clone), and it costs one extra refresh per NEW branch, not per\n * call: a refresh records every worktree's branch, so from then on the branch is throttled like any\n * other.\n *\n * Two kinds of unknown branch keep the throttle, because bypassing would launch a refresh on every\n * call: a detached HEAD (DETACHED_HEAD; the refresher keys entries by branch name, so it never has\n * one), and any branch while the last refresh FAILED (that run recorded no new branch, and a failing\n * refresh is retried after the interval, never on every call).\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, judgedBranch: string, 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) && !this.isUncoveredBranch(file, judgedBranch)) {\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 * True when `branch` has no entry yet and a refresh would add one: the one case that skips the\n * interval (see claimRefresh). Never for a detached HEAD, and never after a failed refresh, so\n * neither can turn the throttle into a refresh per call.\n */\n private isUncoveredBranch(file: MainSyncStatusFile | null, branch: string): boolean {\n if (branch === DETACHED_HEAD) return false;\n if (file === null) return true;\n if (file.lastRefreshOutcome === RefreshOutcome.ERROR) return false;\n const branches = file.branches;\n if (branches === null || branches === undefined) return true;\n return !Object.prototype.hasOwnProperty.call(branches, branch);\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"]}
|