@webpieces/rules-config 0.4.709 → 0.4.710
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 +1 -1
- package/src/agent-worktree-lock.d.ts +4 -16
- package/src/agent-worktree-lock.js +16 -38
- package/src/agent-worktree-lock.js.map +1 -1
- package/src/branch-reaper.js +3 -1
- package/src/branch-reaper.js.map +1 -1
- package/src/builds-log.d.ts +8 -3
- package/src/builds-log.js +8 -3
- package/src/builds-log.js.map +1 -1
- package/src/harness-agent-activity.d.ts +152 -0
- package/src/harness-agent-activity.js +345 -0
- package/src/harness-agent-activity.js.map +1 -0
- package/src/index.d.ts +2 -1
- package/src/index.js +12 -1
- package/src/index.js.map +1 -1
- package/src/merged-branches.d.ts +13 -7
- package/src/merged-branches.js +29 -19
- package/src/merged-branches.js.map +1 -1
- package/src/worktree-lock-verdicts.d.ts +128 -25
- package/src/worktree-lock-verdicts.js +184 -39
- package/src/worktree-lock-verdicts.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/rules-config",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.710",
|
|
4
4
|
"description": "Shared webpieces.config.json loader. Single source of truth for validation rule configuration consumed by @webpieces/ai-hook-rules, @webpieces/code-rules, and @webpieces/nx-webpieces-rules.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
export declare class AgentWorktreeLock {
|
|
2
2
|
/** The harness's own name for the agent, e.g. `agent-a017f6be7c518c68c`. */
|
|
3
3
|
agent: string;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* The pid the harness recorded. This is the SESSION process, shared by every subagent in that
|
|
6
|
+
* session — printed as a fact about the lock reason, never interrogated. See the file header.
|
|
7
|
+
*/
|
|
5
8
|
pid: number;
|
|
6
9
|
/** The `start <date>` text verbatim, or '' when the reason carried none. Printed, never parsed. */
|
|
7
10
|
startedAt: string;
|
|
@@ -15,19 +18,4 @@ export declare class AgentWorktreeLockReader {
|
|
|
15
18
|
* not identify who.
|
|
16
19
|
*/
|
|
17
20
|
parse(lockReason: string): AgentWorktreeLock | null;
|
|
18
|
-
/**
|
|
19
|
-
* Is the agent that took this lock still running?
|
|
20
|
-
*
|
|
21
|
-
* `process.kill(pid, 0)` sends no signal — it only asks the kernel whether the pid is addressable.
|
|
22
|
-
* ESRCH is the ONE answer that proves death. EPERM proves the opposite (the process exists, it just
|
|
23
|
-
* belongs to somebody else), and any other outcome is a question we could not ask, which under the
|
|
24
|
-
* asymmetry above is answered ALIVE.
|
|
25
|
-
*
|
|
26
|
-
* PID REUSE is a known and ACCEPTED imprecision: an unrelated process may inherit a long-dead
|
|
27
|
-
* agent's pid, and this will then report the worktree as live and spare it forever. That is the
|
|
28
|
-
* safe direction — one directory survives to a cleanup run after the recycled process exits — and
|
|
29
|
-
* the alternative (dating the lock, sniffing the process table for a claude binary) is a pile of
|
|
30
|
-
* platform-specific guessing on the side where being wrong destroys work.
|
|
31
|
-
*/
|
|
32
|
-
isRunning(lock: AgentWorktreeLock): boolean;
|
|
33
21
|
}
|
|
@@ -3,9 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.AgentWorktreeLockReader = exports.AgentWorktreeLock = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const inversify_1 = require("inversify");
|
|
6
|
-
const to_error_1 = require("./to-error");
|
|
7
6
|
/**
|
|
8
|
-
* Reading the LOCK REASON off a worktree
|
|
7
|
+
* Reading the LOCK REASON off a worktree — and NOTHING else.
|
|
9
8
|
*
|
|
10
9
|
* WHY this exists: `git worktree lock` used to mean one thing — a human said "do not touch" — and
|
|
11
10
|
* wp-cleanup spared every locked worktree on that basis. In the agent era that assumption is simply
|
|
@@ -15,16 +14,19 @@ const to_error_1 = require("./to-error");
|
|
|
15
14
|
* locked claude agent agent-a017f6be7c518c68c (pid 64914 start Thu Aug 20 05:12:29 2026)
|
|
16
15
|
*
|
|
17
16
|
* So every `/full-cycle` run left its worktree behind forever, reported as "locked by a human — do not
|
|
18
|
-
* touch" about a human who has never locked a worktree in his life.
|
|
19
|
-
* wp-cleanup exists to prevent, and the message was a lie on top of it.
|
|
17
|
+
* touch" about a human who has never locked a worktree in his life.
|
|
20
18
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* THE PID IN THAT REASON PROVES NOTHING, AND THIS FILE NO LONGER ASKS THE KERNEL ABOUT IT.
|
|
20
|
+
* Subagents are not separate OS processes — every agent in a session records the SAME pid, the pid of
|
|
21
|
+
* the Claude Code session itself — so `process.kill(pid, 0)` reduced to "is the editor still open?",
|
|
22
|
+
* which is true by construction for the whole life of a session. That check is why nine worktrees
|
|
23
|
+
* piled up against a cap of five with ONE agent running, each reported as "that agent is working in
|
|
24
|
+
* here" while several of their PRs had already merged. It is deleted, not softened: a heuristic that
|
|
25
|
+
* is right by accident and wrong by construction has no safe residual use.
|
|
23
26
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* resolves to ALIVE, and alive means spared. Nothing here ever reaps on a guess.
|
|
27
|
+
* The pid is still PARSED and still PRINTED, because it is what the reason says and a reader deserves
|
|
28
|
+
* to see it — but it is presented as the shared session pid it is, never as evidence of an agent.
|
|
29
|
+
* Liveness now comes from HarnessAgentActivityReader, which asks the harness about the AGENT.
|
|
28
30
|
*/
|
|
29
31
|
/**
|
|
30
32
|
* The Claude Code harness's lock reason. Anchored at the start so a reason that merely MENTIONS an
|
|
@@ -33,13 +35,14 @@ const to_error_1 = require("./to-error");
|
|
|
33
35
|
* silently reverting to "human lock". `start` is optional for the same reason.
|
|
34
36
|
*/
|
|
35
37
|
const AGENT_LOCK_REASON = /^claude agent (\S+) \(pid (\d+)(?:\s+start\s+([^)]*))?\)/;
|
|
36
|
-
// The one errno that PROVES a pid is gone. Everything else means alive, or means we could not ask.
|
|
37
|
-
const NO_SUCH_PROCESS = 'ESRCH';
|
|
38
38
|
// Data-only (per CLAUDE.md, classes for data). One parsed agent lock.
|
|
39
39
|
class AgentWorktreeLock {
|
|
40
40
|
/** The harness's own name for the agent, e.g. `agent-a017f6be7c518c68c`. */
|
|
41
41
|
agent;
|
|
42
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* The pid the harness recorded. This is the SESSION process, shared by every subagent in that
|
|
44
|
+
* session — printed as a fact about the lock reason, never interrogated. See the file header.
|
|
45
|
+
*/
|
|
43
46
|
pid;
|
|
44
47
|
/** The `start <date>` text verbatim, or '' when the reason carried none. Printed, never parsed. */
|
|
45
48
|
startedAt;
|
|
@@ -66,31 +69,6 @@ let AgentWorktreeLockReader = class AgentWorktreeLockReader {
|
|
|
66
69
|
return null;
|
|
67
70
|
return new AgentWorktreeLock(match[1], pid, (match[3] ?? '').trim());
|
|
68
71
|
}
|
|
69
|
-
/**
|
|
70
|
-
* Is the agent that took this lock still running?
|
|
71
|
-
*
|
|
72
|
-
* `process.kill(pid, 0)` sends no signal — it only asks the kernel whether the pid is addressable.
|
|
73
|
-
* ESRCH is the ONE answer that proves death. EPERM proves the opposite (the process exists, it just
|
|
74
|
-
* belongs to somebody else), and any other outcome is a question we could not ask, which under the
|
|
75
|
-
* asymmetry above is answered ALIVE.
|
|
76
|
-
*
|
|
77
|
-
* PID REUSE is a known and ACCEPTED imprecision: an unrelated process may inherit a long-dead
|
|
78
|
-
* agent's pid, and this will then report the worktree as live and spare it forever. That is the
|
|
79
|
-
* safe direction — one directory survives to a cleanup run after the recycled process exits — and
|
|
80
|
-
* the alternative (dating the lock, sniffing the process table for a claude binary) is a pile of
|
|
81
|
-
* platform-specific guessing on the side where being wrong destroys work.
|
|
82
|
-
*/
|
|
83
|
-
isRunning(lock) {
|
|
84
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
85
|
-
try {
|
|
86
|
-
process.kill(lock.pid, 0);
|
|
87
|
-
return true;
|
|
88
|
-
}
|
|
89
|
-
catch (err) {
|
|
90
|
-
const error = (0, to_error_1.toError)(err);
|
|
91
|
-
return error.code !== NO_SUCH_PROCESS;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
72
|
};
|
|
95
73
|
exports.AgentWorktreeLockReader = AgentWorktreeLockReader;
|
|
96
74
|
exports.AgentWorktreeLockReader = AgentWorktreeLockReader = tslib_1.__decorate([
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-worktree-lock.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/agent-worktree-lock.ts"],"names":[],"mappings":";;;;AAAA,yCAA2D;AAE3D
|
|
1
|
+
{"version":3,"file":"agent-worktree-lock.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/agent-worktree-lock.ts"],"names":[],"mappings":";;;;AAAA,yCAA2D;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH;;;;;GAKG;AACH,MAAM,iBAAiB,GAAG,0DAA0D,CAAC;AAErF,sEAAsE;AACtE,MAAa,iBAAiB;IAC1B,4EAA4E;IAC5E,KAAK,CAAS;IACd;;;OAGG;IACH,GAAG,CAAS;IACZ,mGAAmG;IACnG,SAAS,CAAS;IAElB,YAAY,KAAa,EAAE,GAAW,EAAE,SAAiB;QACrD,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC;QACf,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC/B,CAAC;CACJ;AAhBD,8CAgBC;AAGM,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IAChC;;;;;OAKG;IACH,KAAK,CAAC,UAAkB;QACpB,MAAM,KAAK,GAAG,iBAAiB,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC,CAAC;QACxD,IAAI,KAAK,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QAChC,MAAM,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QAC1C,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;QACpD,OAAO,IAAI,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IACzE,CAAC;CACJ,CAAA;AAdY,0DAAuB;kCAAvB,uBAAuB;IADnC,IAAA,sBAAU,EAAC,8BAAkB,CAAC,SAAS,CAAC;GAC5B,uBAAuB,CAcnC","sourcesContent":["import { injectable, bindingScopeValues } from 'inversify';\n\n/**\n * Reading the LOCK REASON off a worktree — and NOTHING else.\n *\n * WHY this exists: `git worktree lock` used to mean one thing — a human said \"do not touch\" — and\n * wp-cleanup spared every locked worktree on that basis. In the agent era that assumption is simply\n * wrong. The Claude Code harness locks EVERY worktree it opens for a subagent, and it writes a\n * machine-readable reason while doing so:\n *\n * locked claude agent agent-a017f6be7c518c68c (pid 64914 start Thu Aug 20 05:12:29 2026)\n *\n * So every `/full-cycle` run left its worktree behind forever, reported as \"locked by a human — do not\n * touch\" about a human who has never locked a worktree in his life.\n *\n * THE PID IN THAT REASON PROVES NOTHING, AND THIS FILE NO LONGER ASKS THE KERNEL ABOUT IT.\n * Subagents are not separate OS processes — every agent in a session records the SAME pid, the pid of\n * the Claude Code session itself — so `process.kill(pid, 0)` reduced to \"is the editor still open?\",\n * which is true by construction for the whole life of a session. That check is why nine worktrees\n * piled up against a cap of five with ONE agent running, each reported as \"that agent is working in\n * here\" while several of their PRs had already merged. It is deleted, not softened: a heuristic that\n * is right by accident and wrong by construction has no safe residual use.\n *\n * The pid is still PARSED and still PRINTED, because it is what the reason says and a reader deserves\n * to see it — but it is presented as the shared session pid it is, never as evidence of an agent.\n * Liveness now comes from HarnessAgentActivityReader, which asks the harness about the AGENT.\n */\n\n/**\n * The Claude Code harness's lock reason. Anchored at the start so a reason that merely MENTIONS an\n * agent (a human writing \"leave this, the claude agent is mid-run\") is not mistaken for the harness's\n * own machine-written string; unanchored at the end so a future harness may append to it without this\n * silently reverting to \"human lock\". `start` is optional for the same reason.\n */\nconst AGENT_LOCK_REASON = /^claude agent (\\S+) \\(pid (\\d+)(?:\\s+start\\s+([^)]*))?\\)/;\n\n// Data-only (per CLAUDE.md, classes for data). One parsed agent lock.\nexport class AgentWorktreeLock {\n /** The harness's own name for the agent, e.g. `agent-a017f6be7c518c68c`. */\n agent: string;\n /**\n * The pid the harness recorded. This is the SESSION process, shared by every subagent in that\n * session — printed as a fact about the lock reason, never interrogated. See the file header.\n */\n pid: number;\n /** The `start <date>` text verbatim, or '' when the reason carried none. Printed, never parsed. */\n startedAt: string;\n\n constructor(agent: string, pid: number, startedAt: string) {\n this.agent = agent;\n this.pid = pid;\n this.startedAt = startedAt;\n }\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class AgentWorktreeLockReader {\n /**\n * The parsed agent lock, or null when this reason was written by anything other than the Claude\n * Code harness — including the empty reason `git worktree lock` writes with no `--reason`. Null\n * means \"spare it, and say only what the reason says\" — somebody locked this and the evidence does\n * not identify who.\n */\n parse(lockReason: string): AgentWorktreeLock | null {\n const match = AGENT_LOCK_REASON.exec(lockReason.trim());\n if (match === null) return null;\n const pid = Number.parseInt(match[2], 10);\n if (!Number.isInteger(pid) || pid <= 0) return null;\n return new AgentWorktreeLock(match[1], pid, (match[3] ?? '').trim());\n }\n}\n"]}
|
package/src/branch-reaper.js
CHANGED
|
@@ -114,7 +114,9 @@ let BranchReaper = class BranchReaper {
|
|
|
114
114
|
* branch may have gained commits since it was written. Deleting never reads the stale file.
|
|
115
115
|
*/
|
|
116
116
|
reap(repoRoot, verb, cache = null, retention = branch_archiver_1.BRANCH_RETENTION_ARCHIVE_TAG) {
|
|
117
|
-
|
|
117
|
+
// `false`: this is the BRANCH reap, and worktree locks are none of its business. Ignoring one
|
|
118
|
+
// would only widen the WORKTREE verdicts it never reads.
|
|
119
|
+
const verdicts = cache ?? this.mergedBranches.computeMergedBranches(repoRoot, false);
|
|
118
120
|
// 'keep' means "never delete anything" — the reap becomes a pure report. Everything still lands
|
|
119
121
|
// in `spared` so the human sees exactly what WOULD have been reaped under the other policies.
|
|
120
122
|
if (retention === branch_archiver_1.BRANCH_RETENTION_KEEP) {
|
package/src/branch-reaper.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branch-reaper.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/branch-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport {\n ArchiveResult,\n BranchArchiver,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n} from './branch-archiver';\nimport { BranchMutationEvent, BranchMutationLog, MutationVerb } from './branch-mutation-log';\nimport { DeletableBranch, MergedBranchesCache, MergedBranchesService } from './merged-branches';\n\n/**\n * The EXECUTOR for the dead-branch verdicts that merged-branches.ts computes.\n *\n * WHY this exists: for a long time nothing in the tooling ever deleted a branch. The reap was only\n * ever a `git branch -D a b c` STRING embedded in a guard's fix hint, handed to an AI agent — which\n * reads a raw `-D` as destructive, asks \"may I clean this up?\", and stops. So the verdicts were\n * computed correctly on every hook call and then acted on by nobody, and local branches grew without\n * bound. The fix is not better wording; it is a thing that does the deleting.\n *\n * WHY deleting here is safe: every entry in `deletable` earned its place by one of exactly three\n * proofs — a MERGED PR (the work is in main), a squash-merge BACKUP of a merged branch, or ZERO\n * commits of its own (there is no work). merged-branches.ts also guarantees `main` is never in the\n * list and that a branch checked out in ANY worktree lands in `keep` instead, so we can never delete\n * the branch someone is standing on. On top of that, every delete is logged with the branch's\n * pre-delete SHA and a literal recover command, so no reap is unrecoverable.\n */\n\n// Data-only (per CLAUDE.md, classes for data). One branch and what happened to it.\nexport class ReapedBranch {\n branch: string;\n // The commit the branch pointed at BEFORE deletion — captured first, precisely so a delete is\n // always undoable via `git branch <branch> <sha>`. Empty only if rev-parse itself failed.\n sha: string;\n reason: string;\n pr: number;\n ok: boolean;\n // git's own stderr when ok=false. Kept verbatim: a failed delete is a thing a human must read.\n error: string;\n /**\n * The `archive/<date>/<branch>` tag written immediately BEFORE the delete, or '' when the retention\n * policy is 'delete'. Non-empty means the branch is restorable by NAME rather than by remembering a\n * sha — `git checkout -b <branch> <tag>` restores the exact objects, and the tag survives `gc` and\n * reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n archiveTag: string = '';\n /**\n * The branch was ALREADY GONE when we reached it — someone else reaped it first.\n *\n * Not a failure, and the distinction is not cosmetic. `wp-cleanup` and the detached refresher's\n * `auto-reap` both act on their own fresh verdicts, and the hooks kick off the refresher on the\n * same commands a human runs wp-cleanup from, so the two race by design. Observed 2026-08-04:\n * auto-reap deleted both dead branches at 14:30:58.207 and 14:30:59.035, wp-cleanup reached them\n * 300ms later and reported \"⚠️ 2 branch(es) could not be deleted — could not archive it first:\n * unknown revision\", i.e. it announced a failure for the exact outcome it wanted. Whoever wins,\n * the branch is gone, archived and logged by the winner.\n *\n * Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n alreadyGone: boolean = false;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string) {\n this.branch = branch;\n this.sha = sha;\n this.reason = reason;\n this.pr = pr;\n this.ok = ok;\n this.error = error;\n }\n}\n\n/**\n * The outcome of one reap. `spared` is carried alongside deliberately: a cleanup that silently says\n * nothing about the branches it did NOT touch reads as \"there was nothing else\", when in fact those\n * are exactly the branches only a human can rule on.\n */\nexport class ReapResult {\n reaped: ReapedBranch[];\n failed: ReapedBranch[];\n spared: DeletableBranch[];\n /**\n * Branches a CONCURRENT reaper got to first (see ReapedBranch.alreadyGone). A separate bucket\n * because they belong in neither of the other two: counting them as `reaped` would credit this\n * run with a delete and an archive tag it did not write, and counting them as `failed` is the bug\n * this fixes — a red warning about branches that are, in fact, gone.\n *\n * Optional so every existing `new ReapResult(...)` call site still builds.\n */\n alreadyGone: ReapedBranch[];\n\n constructor(\n reaped: ReapedBranch[],\n failed: ReapedBranch[],\n spared: DeletableBranch[],\n alreadyGone: ReapedBranch[] = [],\n ) {\n this.reaped = reaped;\n this.failed = failed;\n this.spared = spared;\n this.alreadyGone = alreadyGone;\n }\n}\n\n// Result of a captured git invocation. `err` carries stderr so a failed delete can be reported.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n err: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class BranchReaper {\n // Defaulted so the non-DI call sites (the detached refresher in sync-main.ts) can just\n // `new BranchReaper()`, while inversify still injects the singletons when resolved from a\n // container. Mirrors how MergedBranchesService defaults its WorktreeService.\n constructor(\n private readonly mergedBranches: MergedBranchesService = new MergedBranchesService(),\n private readonly mutationLog: BranchMutationLog = new BranchMutationLog(),\n private readonly archiver: BranchArchiver = new BranchArchiver(),\n ) {}\n\n /**\n * Delete every branch the verdicts call dead, one command at a time.\n *\n * `cache` is an ALREADY-FRESH set of verdicts (the refresher just computed them, so re-running\n * the `gh` lookup would be pure waste). Pass nothing — as `wp-cleanup` does — and we recompute\n * from scratch. That distinction is load-bearing: the cache file on disk is DELIBERATELY allowed\n * to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a\n * branch may have gained commits since it was written. Deleting never reads the stale file.\n */\n reap(\n repoRoot: string,\n verb: MutationVerb,\n cache: MergedBranchesCache | null = null,\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): ReapResult {\n const verdicts = cache ?? this.mergedBranches.computeMergedBranches(repoRoot);\n // 'keep' means \"never delete anything\" — the reap becomes a pure report. Everything still lands\n // in `spared` so the human sees exactly what WOULD have been reaped under the other policies.\n if (retention === BRANCH_RETENTION_KEEP) {\n return new ReapResult([], [], [...verdicts.deletable, ...verdicts.keep]);\n }\n return this.reapBranches(repoRoot, verb, verdicts.deletable, retention, verdicts);\n }\n\n /**\n * Delete a CALLER-SUPPLIED list of branches — the branches a human just said yes to at wp-cleanup's\n * classification prompt. Separate from `reap` because these are NOT provably dead: they earned their\n * deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.\n * Archiving still happens first, which is precisely what makes that yes low-stakes.\n */\n reapApproved(\n repoRoot: string,\n verb: MutationVerb,\n approved: DeletableBranch[],\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): ReapResult {\n if (retention === BRANCH_RETENTION_KEEP) return new ReapResult([], [], approved);\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n const alreadyGone: ReapedBranch[] = [];\n for (const entry of approved) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else if (outcome.alreadyGone) alreadyGone.push(outcome);\n else failed.push(outcome);\n }\n return new ReapResult(reaped, failed, [], alreadyGone);\n }\n\n private reapBranches(\n repoRoot: string,\n verb: MutationVerb,\n targets: DeletableBranch[],\n retention: string,\n verdicts: MergedBranchesCache,\n ): ReapResult {\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n const alreadyGone: ReapedBranch[] = [];\n for (const entry of targets) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else if (outcome.alreadyGone) alreadyGone.push(outcome);\n else failed.push(outcome);\n }\n\n // Only genuine failures stay in the cache's `deletable`. An already-gone branch is not there\n // to delete on the next pass, and leaving it in would make the cap keep counting a ghost.\n this.rewriteCache(repoRoot, verdicts, failed);\n return new ReapResult(reaped, failed, verdicts.keep, alreadyGone);\n }\n\n /**\n * One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old\n * fix hint used: git aborts the whole command on the first branch it refuses, which would strand\n * every branch after it in the list. One invocation each means one failure costs exactly one branch.\n *\n * The archive comes FIRST and, when it fails, the delete does NOT happen. A branch we could not\n * archive is a branch whose only remaining copy is the reflog, and the entire point of this change is\n * to stop relying on the reflog. Refusing to delete is the fail-safe direction: the worst case is a\n * branch that survives one more cleanup cycle.\n */\n private deleteOne(\n repoRoot: string,\n verb: MutationVerb,\n entry: DeletableBranch,\n retention: string,\n ): ReapedBranch {\n // SHA first — after the delete there is no branch left to resolve, and the whole point of the\n // audit line is that it records what was destroyed while it still exists.\n const resolved = this.capture(repoRoot, ['rev-parse', entry.branch]);\n const sha = resolved.ok ? resolved.out : '';\n\n // It does not resolve ⇒ it is already gone, reaped by whoever won the race (see\n // ReapedBranch.alreadyGone). Report that outcome honestly instead of letting it fall through\n // to the archive, which fails on the same missing ref and calls a successful cleanup a\n // failure. Nothing to archive and nothing to delete: the winner did both, and logged them.\n if (!resolved.ok) return this.alreadyGone(repoRoot, verb, entry);\n\n const archive = retention === BRANCH_RETENTION_ARCHIVE_TAG\n ? this.archiver.archive(repoRoot, entry.branch)\n : new ArchiveResult('', sha, true, '');\n if (!archive.ok) return this.archiveFailed(repoRoot, verb, entry, sha, archive);\n\n const deleted = this.capture(repoRoot, ['branch', '-D', entry.branch]);\n const result = new ReapedBranch(\n entry.branch, sha, entry.reason, entry.pr, deleted.ok, deleted.ok ? '' : deleted.err);\n result.archiveTag = archive.tag;\n\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.archiveTag = archive.tag;\n event.outcome = deleted.ok ? `deleted (${entry.reason})` : `FAILED (${deleted.err})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n return result;\n }\n\n /**\n * The branch vanished between the verdict and the reap — a concurrent reaper got it.\n *\n * Logged like every other outcome (a mutation nobody logs is a mutation nobody can audit), but\n * as ALREADY_GONE rather than SKIPPED/FAILED, and carried in its own bucket so the report can\n * say \"already gone\" instead of warning about a branch that is exactly as dead as intended.\n */\n private alreadyGone(repoRoot: string, verb: MutationVerb, entry: DeletableBranch): ReapedBranch {\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.outcome = 'ALREADY GONE (a concurrent reap deleted and archived it first)';\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n const result = new ReapedBranch(\n entry.branch, '', entry.reason, entry.pr, false,\n 'already gone — a concurrent reap deleted and archived it first');\n result.alreadyGone = true;\n return result;\n }\n\n // Archiving failed ⇒ the branch is NOT deleted. Reported as a failure with git's own words, so the\n // human sees a branch that survived and why, rather than a silent skip.\n private archiveFailed(\n repoRoot: string, verb: MutationVerb, entry: DeletableBranch, sha: string, archive: ArchiveResult,\n ): ReapedBranch {\n const error = `not deleted — could not archive it first: ${archive.error}`;\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.outcome = `SKIPPED (${error})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n return new ReapedBranch(entry.branch, sha, entry.reason, entry.pr, false, error);\n }\n\n /**\n * Write the verdicts back with the reaped branches removed, so the branch-creation-guard's cap\n * sees the post-reap truth on its very next call instead of continuing to block against branches\n * that no longer exist. Anything that FAILED to delete stays in `deletable` — it is still there,\n * and still dead.\n */\n private rewriteCache(repoRoot: string, verdicts: MergedBranchesCache, failed: ReapedBranch[]): void {\n const stillDead = new Set(failed.map((entry: ReapedBranch): string => entry.branch));\n const remaining = verdicts.deletable.filter(\n (entry: DeletableBranch): boolean => stillDead.has(entry.branch));\n this.mergedBranches.writeMergedBranches(\n repoRoot,\n new MergedBranchesCache(verdicts.timestamp, remaining, verdicts.keep, verdicts.worktrees),\n );\n }\n\n // Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';\n if (result.status !== 0 || typeof result.stdout !== 'string') {\n return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };\n }\n return { ok: true, out: result.stdout.trim(), err };\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"branch-reaper.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/branch-reaper.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport {\n ArchiveResult,\n BranchArchiver,\n BRANCH_RETENTION_ARCHIVE_TAG,\n BRANCH_RETENTION_KEEP,\n} from './branch-archiver';\nimport { BranchMutationEvent, BranchMutationLog, MutationVerb } from './branch-mutation-log';\nimport { DeletableBranch, MergedBranchesCache, MergedBranchesService } from './merged-branches';\n\n/**\n * The EXECUTOR for the dead-branch verdicts that merged-branches.ts computes.\n *\n * WHY this exists: for a long time nothing in the tooling ever deleted a branch. The reap was only\n * ever a `git branch -D a b c` STRING embedded in a guard's fix hint, handed to an AI agent — which\n * reads a raw `-D` as destructive, asks \"may I clean this up?\", and stops. So the verdicts were\n * computed correctly on every hook call and then acted on by nobody, and local branches grew without\n * bound. The fix is not better wording; it is a thing that does the deleting.\n *\n * WHY deleting here is safe: every entry in `deletable` earned its place by one of exactly three\n * proofs — a MERGED PR (the work is in main), a squash-merge BACKUP of a merged branch, or ZERO\n * commits of its own (there is no work). merged-branches.ts also guarantees `main` is never in the\n * list and that a branch checked out in ANY worktree lands in `keep` instead, so we can never delete\n * the branch someone is standing on. On top of that, every delete is logged with the branch's\n * pre-delete SHA and a literal recover command, so no reap is unrecoverable.\n */\n\n// Data-only (per CLAUDE.md, classes for data). One branch and what happened to it.\nexport class ReapedBranch {\n branch: string;\n // The commit the branch pointed at BEFORE deletion — captured first, precisely so a delete is\n // always undoable via `git branch <branch> <sha>`. Empty only if rev-parse itself failed.\n sha: string;\n reason: string;\n pr: number;\n ok: boolean;\n // git's own stderr when ok=false. Kept verbatim: a failed delete is a thing a human must read.\n error: string;\n /**\n * The `archive/<date>/<branch>` tag written immediately BEFORE the delete, or '' when the retention\n * policy is 'delete'. Non-empty means the branch is restorable by NAME rather than by remembering a\n * sha — `git checkout -b <branch> <tag>` restores the exact objects, and the tag survives `gc` and\n * reflog expiry. Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n archiveTag: string = '';\n /**\n * The branch was ALREADY GONE when we reached it — someone else reaped it first.\n *\n * Not a failure, and the distinction is not cosmetic. `wp-cleanup` and the detached refresher's\n * `auto-reap` both act on their own fresh verdicts, and the hooks kick off the refresher on the\n * same commands a human runs wp-cleanup from, so the two race by design. Observed 2026-08-04:\n * auto-reap deleted both dead branches at 14:30:58.207 and 14:30:59.035, wp-cleanup reached them\n * 300ms later and reported \"⚠️ 2 branch(es) could not be deleted — could not archive it first:\n * unknown revision\", i.e. it announced a failure for the exact outcome it wanted. Whoever wins,\n * the branch is gone, archived and logged by the winner.\n *\n * Field-with-default so every existing `new ReapedBranch(...)` call site still builds.\n */\n alreadyGone: boolean = false;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(branch: string, sha: string, reason: string, pr: number, ok: boolean, error: string) {\n this.branch = branch;\n this.sha = sha;\n this.reason = reason;\n this.pr = pr;\n this.ok = ok;\n this.error = error;\n }\n}\n\n/**\n * The outcome of one reap. `spared` is carried alongside deliberately: a cleanup that silently says\n * nothing about the branches it did NOT touch reads as \"there was nothing else\", when in fact those\n * are exactly the branches only a human can rule on.\n */\nexport class ReapResult {\n reaped: ReapedBranch[];\n failed: ReapedBranch[];\n spared: DeletableBranch[];\n /**\n * Branches a CONCURRENT reaper got to first (see ReapedBranch.alreadyGone). A separate bucket\n * because they belong in neither of the other two: counting them as `reaped` would credit this\n * run with a delete and an archive tag it did not write, and counting them as `failed` is the bug\n * this fixes — a red warning about branches that are, in fact, gone.\n *\n * Optional so every existing `new ReapResult(...)` call site still builds.\n */\n alreadyGone: ReapedBranch[];\n\n constructor(\n reaped: ReapedBranch[],\n failed: ReapedBranch[],\n spared: DeletableBranch[],\n alreadyGone: ReapedBranch[] = [],\n ) {\n this.reaped = reaped;\n this.failed = failed;\n this.spared = spared;\n this.alreadyGone = alreadyGone;\n }\n}\n\n// Result of a captured git invocation. `err` carries stderr so a failed delete can be reported.\ninterface CmdCapture {\n ok: boolean;\n out: string;\n err: string;\n}\n\n@injectable(bindingScopeValues.Singleton)\nexport class BranchReaper {\n // Defaulted so the non-DI call sites (the detached refresher in sync-main.ts) can just\n // `new BranchReaper()`, while inversify still injects the singletons when resolved from a\n // container. Mirrors how MergedBranchesService defaults its WorktreeService.\n constructor(\n private readonly mergedBranches: MergedBranchesService = new MergedBranchesService(),\n private readonly mutationLog: BranchMutationLog = new BranchMutationLog(),\n private readonly archiver: BranchArchiver = new BranchArchiver(),\n ) {}\n\n /**\n * Delete every branch the verdicts call dead, one command at a time.\n *\n * `cache` is an ALREADY-FRESH set of verdicts (the refresher just computed them, so re-running\n * the `gh` lookup would be pure waste). Pass nothing — as `wp-cleanup` does — and we recompute\n * from scratch. That distinction is load-bearing: the cache file on disk is DELIBERATELY allowed\n * to go stale, which is fine for blocking a branch creation but is not fine for deleting, since a\n * branch may have gained commits since it was written. Deleting never reads the stale file.\n */\n reap(\n repoRoot: string,\n verb: MutationVerb,\n cache: MergedBranchesCache | null = null,\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): ReapResult {\n // `false`: this is the BRANCH reap, and worktree locks are none of its business. Ignoring one\n // would only widen the WORKTREE verdicts it never reads.\n const verdicts = cache ?? this.mergedBranches.computeMergedBranches(repoRoot, false);\n // 'keep' means \"never delete anything\" — the reap becomes a pure report. Everything still lands\n // in `spared` so the human sees exactly what WOULD have been reaped under the other policies.\n if (retention === BRANCH_RETENTION_KEEP) {\n return new ReapResult([], [], [...verdicts.deletable, ...verdicts.keep]);\n }\n return this.reapBranches(repoRoot, verb, verdicts.deletable, retention, verdicts);\n }\n\n /**\n * Delete a CALLER-SUPPLIED list of branches — the branches a human just said yes to at wp-cleanup's\n * classification prompt. Separate from `reap` because these are NOT provably dead: they earned their\n * deletion from an explicit human answer, not from a verdict, so nothing here may ever run unattended.\n * Archiving still happens first, which is precisely what makes that yes low-stakes.\n */\n reapApproved(\n repoRoot: string,\n verb: MutationVerb,\n approved: DeletableBranch[],\n retention: string = BRANCH_RETENTION_ARCHIVE_TAG,\n ): ReapResult {\n if (retention === BRANCH_RETENTION_KEEP) return new ReapResult([], [], approved);\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n const alreadyGone: ReapedBranch[] = [];\n for (const entry of approved) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else if (outcome.alreadyGone) alreadyGone.push(outcome);\n else failed.push(outcome);\n }\n return new ReapResult(reaped, failed, [], alreadyGone);\n }\n\n private reapBranches(\n repoRoot: string,\n verb: MutationVerb,\n targets: DeletableBranch[],\n retention: string,\n verdicts: MergedBranchesCache,\n ): ReapResult {\n const reaped: ReapedBranch[] = [];\n const failed: ReapedBranch[] = [];\n const alreadyGone: ReapedBranch[] = [];\n for (const entry of targets) {\n const outcome = this.deleteOne(repoRoot, verb, entry, retention);\n if (outcome.ok) reaped.push(outcome);\n else if (outcome.alreadyGone) alreadyGone.push(outcome);\n else failed.push(outcome);\n }\n\n // Only genuine failures stay in the cache's `deletable`. An already-gone branch is not there\n // to delete on the next pass, and leaving it in would make the cap keep counting a ghost.\n this.rewriteCache(repoRoot, verdicts, failed);\n return new ReapResult(reaped, failed, verdicts.keep, alreadyGone);\n }\n\n /**\n * One branch: ARCHIVE the tip as a tag, then one `git branch -D`. Never the multi-name form the old\n * fix hint used: git aborts the whole command on the first branch it refuses, which would strand\n * every branch after it in the list. One invocation each means one failure costs exactly one branch.\n *\n * The archive comes FIRST and, when it fails, the delete does NOT happen. A branch we could not\n * archive is a branch whose only remaining copy is the reflog, and the entire point of this change is\n * to stop relying on the reflog. Refusing to delete is the fail-safe direction: the worst case is a\n * branch that survives one more cleanup cycle.\n */\n private deleteOne(\n repoRoot: string,\n verb: MutationVerb,\n entry: DeletableBranch,\n retention: string,\n ): ReapedBranch {\n // SHA first — after the delete there is no branch left to resolve, and the whole point of the\n // audit line is that it records what was destroyed while it still exists.\n const resolved = this.capture(repoRoot, ['rev-parse', entry.branch]);\n const sha = resolved.ok ? resolved.out : '';\n\n // It does not resolve ⇒ it is already gone, reaped by whoever won the race (see\n // ReapedBranch.alreadyGone). Report that outcome honestly instead of letting it fall through\n // to the archive, which fails on the same missing ref and calls a successful cleanup a\n // failure. Nothing to archive and nothing to delete: the winner did both, and logged them.\n if (!resolved.ok) return this.alreadyGone(repoRoot, verb, entry);\n\n const archive = retention === BRANCH_RETENTION_ARCHIVE_TAG\n ? this.archiver.archive(repoRoot, entry.branch)\n : new ArchiveResult('', sha, true, '');\n if (!archive.ok) return this.archiveFailed(repoRoot, verb, entry, sha, archive);\n\n const deleted = this.capture(repoRoot, ['branch', '-D', entry.branch]);\n const result = new ReapedBranch(\n entry.branch, sha, entry.reason, entry.pr, deleted.ok, deleted.ok ? '' : deleted.err);\n result.archiveTag = archive.tag;\n\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.archiveTag = archive.tag;\n event.outcome = deleted.ok ? `deleted (${entry.reason})` : `FAILED (${deleted.err})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n return result;\n }\n\n /**\n * The branch vanished between the verdict and the reap — a concurrent reaper got it.\n *\n * Logged like every other outcome (a mutation nobody logs is a mutation nobody can audit), but\n * as ALREADY_GONE rather than SKIPPED/FAILED, and carried in its own bucket so the report can\n * say \"already gone\" instead of warning about a branch that is exactly as dead as intended.\n */\n private alreadyGone(repoRoot: string, verb: MutationVerb, entry: DeletableBranch): ReapedBranch {\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.outcome = 'ALREADY GONE (a concurrent reap deleted and archived it first)';\n this.mutationLog.logBranchMutation(repoRoot, event);\n\n const result = new ReapedBranch(\n entry.branch, '', entry.reason, entry.pr, false,\n 'already gone — a concurrent reap deleted and archived it first');\n result.alreadyGone = true;\n return result;\n }\n\n // Archiving failed ⇒ the branch is NOT deleted. Reported as a failure with git's own words, so the\n // human sees a branch that survived and why, rather than a silent skip.\n private archiveFailed(\n repoRoot: string, verb: MutationVerb, entry: DeletableBranch, sha: string, archive: ArchiveResult,\n ): ReapedBranch {\n const error = `not deleted — could not archive it first: ${archive.error}`;\n const event = new BranchMutationEvent(verb, 'REAP');\n event.fromBranch = entry.branch;\n event.sha = sha;\n event.outcome = `SKIPPED (${error})`;\n this.mutationLog.logBranchMutation(repoRoot, event);\n return new ReapedBranch(entry.branch, sha, entry.reason, entry.pr, false, error);\n }\n\n /**\n * Write the verdicts back with the reaped branches removed, so the branch-creation-guard's cap\n * sees the post-reap truth on its very next call instead of continuing to block against branches\n * that no longer exist. Anything that FAILED to delete stays in `deletable` — it is still there,\n * and still dead.\n */\n private rewriteCache(repoRoot: string, verdicts: MergedBranchesCache, failed: ReapedBranch[]): void {\n const stillDead = new Set(failed.map((entry: ReapedBranch): string => entry.branch));\n const remaining = verdicts.deletable.filter(\n (entry: DeletableBranch): boolean => stillDead.has(entry.branch));\n this.mergedBranches.writeMergedBranches(\n repoRoot,\n new MergedBranchesCache(verdicts.timestamp, remaining, verdicts.keep, verdicts.worktrees),\n );\n }\n\n // Run a git command capturing trimmed stdout/stderr; ok=false on spawn failure or non-zero exit.\n private capture(repoRoot: string, args: string[]): CmdCapture {\n const result = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8' });\n const err = typeof result.stderr === 'string' ? result.stderr.trim() : '';\n if (result.status !== 0 || typeof result.stdout !== 'string') {\n return { ok: false, out: '', err: err !== '' ? err : 'git command failed' };\n }\n return { ok: true, out: result.stdout.trim(), err };\n }\n}\n"]}
|
package/src/builds-log.d.ts
CHANGED
|
@@ -149,9 +149,14 @@ export declare class BuildsLog {
|
|
|
149
149
|
/**
|
|
150
150
|
* Is `pid` still addressable? `process.kill(pid, 0)` sends no signal — it only asks the kernel. ESRCH
|
|
151
151
|
* is the ONE answer that proves death; EPERM proves the opposite (it exists, it is somebody else's).
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
152
|
+
* Pid reuse is an accepted imprecision here: being wrong in the "still running" direction costs
|
|
153
|
+
* one extra refusal, being wrong the other way lets a fourth build start.
|
|
154
|
+
*
|
|
155
|
+
* WHY A PID IS MEANINGFUL HERE AND WAS NOT FOR AGENT WORKTREE LOCKS. Every row in this ledger is
|
|
156
|
+
* written BY the process it names — one real OS process per build — so its pid identifies it.
|
|
157
|
+
* A Claude Code subagent is not a process at all: every agent in a session records the SAME pid,
|
|
158
|
+
* the session's, which is why that check was deleted rather than shared (see
|
|
159
|
+
* harness-agent-activity.ts). Do not generalise this one back out to anything but a real process.
|
|
155
160
|
*/
|
|
156
161
|
private isAlive;
|
|
157
162
|
/**
|
package/src/builds-log.js
CHANGED
|
@@ -284,9 +284,14 @@ let BuildsLog = class BuildsLog {
|
|
|
284
284
|
/**
|
|
285
285
|
* Is `pid` still addressable? `process.kill(pid, 0)` sends no signal — it only asks the kernel. ESRCH
|
|
286
286
|
* is the ONE answer that proves death; EPERM proves the opposite (it exists, it is somebody else's).
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
287
|
+
* Pid reuse is an accepted imprecision here: being wrong in the "still running" direction costs
|
|
288
|
+
* one extra refusal, being wrong the other way lets a fourth build start.
|
|
289
|
+
*
|
|
290
|
+
* WHY A PID IS MEANINGFUL HERE AND WAS NOT FOR AGENT WORKTREE LOCKS. Every row in this ledger is
|
|
291
|
+
* written BY the process it names — one real OS process per build — so its pid identifies it.
|
|
292
|
+
* A Claude Code subagent is not a process at all: every agent in a session records the SAME pid,
|
|
293
|
+
* the session's, which is why that check was deleted rather than shared (see
|
|
294
|
+
* harness-agent-activity.ts). Do not generalise this one back out to anything but a real process.
|
|
290
295
|
*/
|
|
291
296
|
isAlive(pid) {
|
|
292
297
|
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
package/src/builds-log.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builds-log.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/builds-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as crypto from 'crypto';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { DotWebpieces } from './state-dir';\nimport { HOME_CONFIG_DIR } from './home-config';\nimport { toError } from './to-error';\n\n/**\n * `~/.webpieces/builds.log` — the MACHINE-WIDE, append-only ledger of every build this box has started.\n *\n * ─── WHY THIS ONE FILE LIVES OUTSIDE THE REPO ─────────────────────────────────────────────────────────\n * `no-machine-global-state.spec.ts` records the standing rule: webpieces writes state under\n * `{repo}/.webpieces` and nowhere else. This is the ONE carve-out, and the argument is written out in\n * `decisions/0006-the-build-ledger-is-machine-global.md`. In short:\n *\n * • The FACT is machine-scoped. \"How many builds are burning this box's CPU right now\" is not a\n * property of any repo; it is a property of the machine. A per-repo ledger cannot answer it — every\n * linked worktree has its OWN `.webpieces/`, so it would be blind to the sibling worktree it is\n * actually contending with, never mind the four other repos on the disk.\n * • It is NOT A CACHE. The retired `PrBodyStore` that the no-machine-global rule was written for was a\n * local copy of a fact GitHub owned, so it could be stale, missing, or on the wrong computer. There\n * is no remote copy of this. The file IS the fact.\n * • Its key is an ABSOLUTE LOCAL PATH, which is stable precisely because it never leaves the machine —\n * the instability that killed `PrBodyStore`'s `<host>/<owner>/<repo>` key cannot arise here.\n *\n * ─── WHY IT IS SAFE TO WRITE CONCURRENTLY ─────────────────────────────────────────────────────────────\n * Rows are deliberately kept under `MAX_ROW_BYTES` (512, macOS `PIPE_BUF`). A single `O_APPEND`\n * `write(2)` at or below that size is indivisible, so two builds appending at the same instant cannot\n * interleave halves of a line. The lock is therefore belt-and-braces for the APPEND and genuinely\n * load-bearing for ROTATION, where a rename-and-reopen really does race.\n *\n * ─── IT MAY NEVER FAIL A BUILD ────────────────────────────────────────────────────────────────────────\n * Every method here is best-effort and swallows its own errors. A build must never die because a log\n * file was busy, unwritable, or on a full disk. Lock acquisition retries and then gives up and appends\n * anyway — which the row-size invariant above makes safe.\n */\nexport const BUILDS_LOG_FILE = 'builds.log';\nexport const BUILDS_LOCK_FILE = 'builds.log.lock';\n\n/** START, and the two terminal kinds. `DONE-` is the greppable prefix that pairs with a START. */\nexport const BUILD_START = 'START';\nexport const BUILD_DONE_SUCCESS = 'DONE-SUCCESS';\nexport const BUILD_DONE_FAIL = 'DONE-FAIL';\n\n/** Rotate at 1 MB, keeping five generations (`.1` … `.5`); the old `.5` is dropped. */\nexport const MAX_BUILDS_LOG_BYTES = 1024 * 1024;\nexport const BUILDS_LOG_GENERATIONS = 5;\n\n/**\n * macOS `PIPE_BUF`. A row at or under this size is written by ONE indivisible `write(2)`, which is what\n * makes a lost lock a non-event rather than a corrupted file. Long paths are clipped to hold the line\n * under it — see `clip`.\n */\nexport const MAX_ROW_BYTES = 512;\n\n/** How long a build may hold the lock before another writer stops waiting and appends anyway. */\nconst LOCK_RETRY_MS = 50;\nconst LOCK_TIMEOUT_MS = 2000;\n\n/**\n * The handle a START row hands back, and the ONLY thing `finish()` accepts. Data-only (a class, per\n * CLAUDE.md), carrying exactly the fields the DONE row needs to pair itself with its START: the uuid,\n * the caller, the repo, and when it began (so `took=` is computed from one clock, not two).\n */\nexport class BuildTicket {\n id: string;\n by: string;\n repo: string;\n startedMs: number;\n\n constructor(id: string, by: string, repo: string, startedMs: number) {\n this.id = id;\n this.by = by;\n this.repo = repo;\n this.startedMs = startedMs;\n }\n}\n\n/**\n * One build that is STILL RUNNING — a START row with no matching `DONE-`, whose pid is still alive.\n * Data-only. This is what the refusal message renders, so it carries the three things a reader needs to\n * recognise the build in question: where it is, which tree, and how old it is.\n */\nexport class RunningBuild {\n id: string;\n by: string;\n repo: string;\n tree: string;\n cwd: string;\n branch: string;\n pid: number;\n startedMs: number;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n id: string, by: string, repo: string, tree: string,\n cwd: string, branch: string, pid: number, startedMs: number,\n ) {\n this.id = id;\n this.by = by;\n this.repo = repo;\n this.tree = tree;\n this.cwd = cwd;\n this.branch = branch;\n this.pid = pid;\n this.startedMs = startedMs;\n }\n}\n\n/**\n * The ledger. See the file docblock for why it is machine-global and why every operation swallows its\n * own errors.\n *\n * `homeDir` is a parameter on every public method, defaulted to `os.homedir()`, for exactly the reason\n * `HomeConfigService.configPath` takes one: a spec must be able to exercise the real code against a temp\n * directory and must never touch the developer's actual `~/.webpieces`.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildsLog {\n constructor(private readonly dotDir: DotWebpieces) {}\n\n /** `~/.webpieces/builds.log`. */\n logPath(homeDir: string = os.homedir()): string {\n return path.join(homeDir, HOME_CONFIG_DIR, BUILDS_LOG_FILE);\n }\n\n /** `~/.webpieces/builds.log.lock` — `{\"pid\":N,\"started\":<epochMs>}`. */\n lockPath(homeDir: string = os.homedir()): string {\n return path.join(homeDir, HOME_CONFIG_DIR, BUILDS_LOCK_FILE);\n }\n\n /** `~/.webpieces/builds.log.<n>` — generation `n`, 1 being the most recent. */\n rotatedPath(generation: number, homeDir: string = os.homedir()): string {\n return `${this.logPath(homeDir)}.${String(generation)}`;\n }\n\n /**\n * Record that a build is starting, and hand back the ticket its DONE row will need.\n *\n * `by` is the CALLER — `BuildGateOptions.stage`, i.e. `build` | `review` | `finish`. There is no\n * second \"caller\" concept anywhere: the stage id already is one, and a second spelling of it would\n * be the shim CLAUDE.md rejects.\n *\n * Returns a ticket even when the append failed. A build whose START row never landed still has to be\n * able to call `finish()`; the alternative is a nullable return that every call site must branch on\n * for a logging failure that is, by policy, not an error.\n */\n start(by: string, startDir: string, homeDir: string = os.homedir()): BuildTicket {\n const ticket = new BuildTicket(\n crypto.randomUUID(), by, this.dotDir.primaryRoot(startDir), Date.now());\n this.append(this.startRow(ticket, startDir), homeDir);\n return ticket;\n }\n\n /**\n * Record that the build behind `ticket` has ended. `exitCode` 0 writes `DONE-SUCCESS`; anything else\n * writes `DONE-FAIL` carrying the code, so `grep DONE-FAIL` lists every red build on the machine.\n */\n finish(ticket: BuildTicket, exitCode: number, homeDir: string = os.homedir()): void {\n this.append(this.doneRow(ticket, exitCode), homeDir);\n }\n\n /**\n * Every build that is still live: a `START` with no matching `DONE-` row, whose pid is still alive.\n *\n * The pid filter is not an optimisation, it is what keeps the ledger from wedging the machine. A\n * build killed with SIGKILL — an agent cancelled mid-run, a terminal closed — writes no DONE row, so\n * without the liveness test its START would count forever and the fourth build would be refused for\n * the rest of the machine's life. The uuid answers \"which build\"; the pid answers \"is it still real\".\n *\n * Only the CURRENT generation is read. A rotated-away START is by definition at least 1 MB of rows\n * old and is not a build anyone is contending with.\n */\n running(homeDir: string = os.homedir()): RunningBuild[] {\n const lines = this.readLines(this.logPath(homeDir));\n const done = new Set<string>();\n for (const line of lines) {\n if (line.startsWith(`${BUILD_DONE_SUCCESS}\\t`) || line.startsWith(`${BUILD_DONE_FAIL}\\t`)) {\n done.add(this.field(line, 'id'));\n }\n }\n const live: RunningBuild[] = [];\n for (const line of lines) {\n if (!line.startsWith(`${BUILD_START}\\t`)) continue;\n const build = this.toRunningBuild(line);\n if (build === null || done.has(build.id)) continue;\n if (!this.isAlive(build.pid)) continue;\n live.push(build);\n }\n return live;\n }\n\n /**\n * The `@webpieces` release ACTUALLY EXECUTING — found by walking UP from this module's own directory\n * to the nearest enclosing `node_modules/@webpieces/<pkg>/package.json`. `''` when this code is\n * running from source rather than from an installed package (which is the state in this repo's own\n * specs, and a perfectly ordinary answer).\n *\n * ─── WHY THIS IS NOT `WebpiecesVersions.readInstalled(root)` ──────────────────────────────────────\n * They answer DIFFERENT QUESTIONS and merging them would break the older one. `readInstalled` joins\n * `<root>/node_modules/@webpieces/...` at a FIXED tree root ON PURPOSE: its whole job is to detect\n * DRIFT between what a tree PINS and what some other tree pins, and a walk-up would silently resolve\n * a worktree with no install of its own to the primary clone's copy — hiding exactly the skew that\n * guard exists to catch. This question is the opposite one: \"whichever copy is running, name it\", and\n * for that the walk-up is the only correct answer. Do not fold them together.\n */\n executingVersion(): string {\n let dir = __dirname;\n for (let hops = 0; hops < 40; hops += 1) {\n const version = this.versionOfEnclosingPackage(dir);\n if (version !== '') return version;\n const parent = path.dirname(dir);\n if (parent === dir) return '';\n dir = parent;\n }\n return '';\n }\n\n // `<dir>` is `node_modules/@webpieces/<pkg>/...`? Then that package's version, else ''.\n private versionOfEnclosingPackage(dir: string): string {\n const parent = path.dirname(dir);\n const grandparent = path.dirname(parent);\n if (path.basename(parent) !== '@webpieces' || path.basename(grandparent) !== 'node_modules') return '';\n const text = this.readTextOrEmpty(path.join(dir, 'package.json'));\n const match = /\"version\"\\s*:\\s*\"([^\"]+)\"/.exec(text);\n return match === null ? '' : match[1];\n }\n\n // ─── ROW RENDERING ────────────────────────────────────────────────────────────────────────────────\n\n private startRow(ticket: BuildTicket, startDir: string): string {\n return [\n BUILD_START,\n `id=${ticket.id}`,\n `t=${new Date(ticket.startedMs).toISOString()}`,\n `ms=${String(ticket.startedMs)}`,\n `by=${ticket.by}`,\n `repo=${this.clip(ticket.repo)}`,\n `tree=${this.dotDir.worktreeName(startDir) || 'primary'}`,\n `cwd=${this.clip(startDir)}`,\n `branch=${this.gitBranch(startDir)}`,\n `pid=${String(process.pid)}`,\n `wp=${this.executingVersion()}`,\n ].join('\\t');\n }\n\n private doneRow(ticket: BuildTicket, exitCode: number): string {\n const now = Date.now();\n const fields = [\n exitCode === 0 ? BUILD_DONE_SUCCESS : BUILD_DONE_FAIL,\n `id=${ticket.id}`,\n `t=${new Date(now).toISOString()}`,\n `ms=${String(now)}`,\n `by=${ticket.by}`,\n `repo=${this.clip(ticket.repo)}`,\n `took=${String(now - ticket.startedMs)}`,\n ];\n if (exitCode !== 0) fields.push(`exit=${String(exitCode)}`);\n fields.push(`pid=${String(process.pid)}`);\n return fields.join('\\t');\n }\n\n /**\n * Hold a long path down to `max` characters by keeping its TAIL, which is the half that identifies\n * the tree; a clipped value is marked with a leading `…` so nobody mistakes it for a real path.\n *\n * This is what keeps a row under `MAX_ROW_BYTES` — see the file docblock. `append` re-checks the\n * assembled line as a backstop, because three clipped fields plus a long branch name can still add up.\n */\n private clip(value: string, max = 120): string {\n const oneLine = value.replace(/[\\t\\n\\r]/g, ' ');\n return oneLine.length <= max ? oneLine : `…${oneLine.slice(oneLine.length - max)}`;\n }\n\n // ─── ROW PARSING ──────────────────────────────────────────────────────────────────────────────────\n\n /** The value of `<name>=` on a TSV row, or '' when the row does not carry it. */\n private field(line: string, name: string): string {\n for (const part of line.split('\\t')) {\n if (part.startsWith(`${name}=`)) return part.slice(name.length + 1);\n }\n return '';\n }\n\n // A START row as a RunningBuild, or null when it is missing the two fields that make it usable.\n private toRunningBuild(line: string): RunningBuild | null {\n const id = this.field(line, 'id');\n const pid = Number.parseInt(this.field(line, 'pid'), 10);\n if (id === '' || !Number.isInteger(pid) || pid <= 0) return null;\n const startedMs = Number.parseInt(this.field(line, 'ms'), 10);\n return new RunningBuild(\n id, this.field(line, 'by'), this.field(line, 'repo'), this.field(line, 'tree'),\n this.field(line, 'cwd'), this.field(line, 'branch'), pid,\n Number.isInteger(startedMs) ? startedMs : 0,\n );\n }\n\n /**\n * Is `pid` still addressable? `process.kill(pid, 0)` sends no signal — it only asks the kernel. ESRCH\n * is the ONE answer that proves death; EPERM proves the opposite (it exists, it is somebody else's).\n * Same test, same reasoning, as `AgentWorktreeLockReader.isRunning`, including the accepted\n * imprecision of pid reuse: being wrong in the \"still running\" direction costs one extra refusal,\n * being wrong the other way lets a fourth build start.\n */\n private isAlive(pid: number): boolean {\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 return (error as NodeJS.ErrnoException).code !== 'ESRCH';\n }\n }\n\n // ─── APPEND, LOCK, ROTATE ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Append one row, best-effort. Takes the lock so rotation cannot race, and appends ANYWAY when the\n * lock cannot be had within `LOCK_TIMEOUT_MS` — the row is under `PIPE_BUF`, so an unlocked\n * `O_APPEND` write is still indivisible, and a build must never die because a log file was busy.\n */\n private append(row: string, homeDir: string): void {\n const line = `${this.truncateToRowLimit(row)}\\n`;\n const held = this.tryAcquireLock(homeDir);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n this.ensureDir(this.logPath(homeDir));\n if (held) this.rotateIfLarge(homeDir);\n fs.appendFileSync(this.logPath(homeDir), line);\n } catch (err: unknown) {\n const error = toError(err);\n void error; // logging may never fail a build — see the file docblock\n } finally {\n if (held) this.releaseLock(homeDir);\n }\n }\n\n // The backstop for the PIPE_BUF invariant, measured in BYTES rather than characters because a path\n // may hold multi-byte characters. Truncating a row loses fields off the end, which is strictly better\n // than a torn line that breaks every row after it.\n private truncateToRowLimit(row: string): string {\n const bytes = Buffer.from(row, 'utf8');\n // -1 for the newline `append` adds.\n if (bytes.length <= MAX_ROW_BYTES - 1) return row;\n return bytes.subarray(0, MAX_ROW_BYTES - 1).toString('utf8');\n }\n\n /**\n * Take the ledger lock, retrying every `LOCK_RETRY_MS` until `LOCK_TIMEOUT_MS`. False means \"carry on\n * without it\" — never an error, and never a reason to skip the append.\n *\n * The mechanism is `MainSyncStatusService.tryAcquireMainSyncLock`'s, proven and deliberately copied\n * rather than re-invented: an `wx` (O_CREAT|O_EXCL) create so exactly one of N racers wins, a payload\n * carrying pid + started so a dead holder is identifiable, stale reclaim gated on pid liveness, and a\n * re-read afterwards to confirm the entry on disk is OURS (a simultaneous reclaimer could have\n * unlinked ours and written its own between the two calls).\n */\n private tryAcquireLock(homeDir: string): boolean {\n const deadline = Date.now() + LOCK_TIMEOUT_MS;\n this.ensureDir(this.lockPath(homeDir));\n // Rendered rather than JSON.stringify'd off an anonymous object — two fields, both numbers, and\n // the file's whole contract is `{\"pid\":N,\"started\":M}`.\n const payload = `{\"pid\":${String(process.pid)},\"started\":${String(Date.now())}}\\n`;\n for (;;) {\n if (this.createExclusive(this.lockPath(homeDir), payload)) return true;\n if (!this.isHolderAlive(homeDir)) {\n this.unlinkQuietly(this.lockPath(homeDir));\n if (this.createExclusive(this.lockPath(homeDir), payload) && this.holderIsUs(homeDir)) return true;\n }\n if (Date.now() >= deadline) return false;\n this.sleep(LOCK_RETRY_MS);\n }\n }\n\n private releaseLock(homeDir: string): void {\n if (!this.holderIsUs(homeDir)) return;\n this.unlinkQuietly(this.lockPath(homeDir));\n }\n\n // O_CREAT|O_EXCL write: true when THIS call created the file, false when it already existed.\n private createExclusive(file: string, payload: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.writeFileSync(file, payload, { flag: 'wx' });\n return true;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n // The pid recorded in the lock file, or 0 when there is no readable lock.\n private lockHolderPid(homeDir: string): number {\n const text = this.readTextOrEmpty(this.lockPath(homeDir));\n if (text === '') return 0;\n const match = /\"pid\"\\s*:\\s*(\\d+)/.exec(text);\n return match === null ? 0 : Number.parseInt(match[1], 10);\n }\n\n // An unreadable or pid-less lock file counts as DEAD: it is a corpse from a crashed writer, and\n // leaving it forever would mean every future append silently skips rotation.\n private isHolderAlive(homeDir: string): boolean {\n const pid = this.lockHolderPid(homeDir);\n return pid > 0 && this.isAlive(pid);\n }\n\n private holderIsUs(homeDir: string): boolean {\n return this.lockHolderPid(homeDir) === process.pid;\n }\n\n /**\n * `.4→.5, .3→.4, … .log→.1`, dropping the old `.5`. Runs INSIDE the lock, which is the one place the\n * lock is genuinely load-bearing: a rename-and-reopen really does race, and a writer that opened the\n * old inode mid-shift would append into a file nobody reads again.\n */\n private rotateIfLarge(homeDir: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(this.logPath(homeDir))) return;\n if (fs.statSync(this.logPath(homeDir)).size < MAX_BUILDS_LOG_BYTES) return;\n this.unlinkQuietly(this.rotatedPath(BUILDS_LOG_GENERATIONS, homeDir));\n for (let gen = BUILDS_LOG_GENERATIONS - 1; gen >= 1; gen -= 1) {\n this.renameQuietly(this.rotatedPath(gen, homeDir), this.rotatedPath(gen + 1, homeDir));\n }\n this.renameQuietly(this.logPath(homeDir), this.rotatedPath(1, homeDir));\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n // ─── FILESYSTEM PRIMITIVES, ALL SWALLOWING ────────────────────────────────────────────────────────\n\n private ensureDir(file: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.mkdirSync(path.dirname(file), { recursive: true });\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n private unlinkQuietly(file: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (fs.existsSync(file)) fs.unlinkSync(file);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n private renameQuietly(from: string, to: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (fs.existsSync(from)) fs.renameSync(from, to);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n private readTextOrEmpty(file: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '';\n }\n }\n\n private readLines(file: string): string[] {\n const text = this.readTextOrEmpty(file);\n if (text === '') return [];\n return text.split('\\n').filter((line: string): boolean => line.trim() !== '');\n }\n\n // A blocking sleep, because the lock retry sits on a synchronous append path that must not become\n // async — `finish()` is called from a `finally` and an async logger there could outlive the process.\n private sleep(ms: number): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n // The checked-out branch in `startDir`, or '' when git cannot say. spawnSync does not throw on a\n // non-zero exit, so \"not a repo\" arrives as a status, not an exception.\n private gitBranch(startDir: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'],\n { cwd: startDir, encoding: 'utf8' });\n if (result.status !== 0 || typeof result.stdout !== 'string') return '';\n return result.stdout.trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '';\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"builds-log.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/builds-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawnSync } from 'child_process';\nimport * as crypto from 'crypto';\nimport * as fs from 'fs';\nimport * as os from 'os';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { DotWebpieces } from './state-dir';\nimport { HOME_CONFIG_DIR } from './home-config';\nimport { toError } from './to-error';\n\n/**\n * `~/.webpieces/builds.log` — the MACHINE-WIDE, append-only ledger of every build this box has started.\n *\n * ─── WHY THIS ONE FILE LIVES OUTSIDE THE REPO ─────────────────────────────────────────────────────────\n * `no-machine-global-state.spec.ts` records the standing rule: webpieces writes state under\n * `{repo}/.webpieces` and nowhere else. This is the ONE carve-out, and the argument is written out in\n * `decisions/0006-the-build-ledger-is-machine-global.md`. In short:\n *\n * • The FACT is machine-scoped. \"How many builds are burning this box's CPU right now\" is not a\n * property of any repo; it is a property of the machine. A per-repo ledger cannot answer it — every\n * linked worktree has its OWN `.webpieces/`, so it would be blind to the sibling worktree it is\n * actually contending with, never mind the four other repos on the disk.\n * • It is NOT A CACHE. The retired `PrBodyStore` that the no-machine-global rule was written for was a\n * local copy of a fact GitHub owned, so it could be stale, missing, or on the wrong computer. There\n * is no remote copy of this. The file IS the fact.\n * • Its key is an ABSOLUTE LOCAL PATH, which is stable precisely because it never leaves the machine —\n * the instability that killed `PrBodyStore`'s `<host>/<owner>/<repo>` key cannot arise here.\n *\n * ─── WHY IT IS SAFE TO WRITE CONCURRENTLY ─────────────────────────────────────────────────────────────\n * Rows are deliberately kept under `MAX_ROW_BYTES` (512, macOS `PIPE_BUF`). A single `O_APPEND`\n * `write(2)` at or below that size is indivisible, so two builds appending at the same instant cannot\n * interleave halves of a line. The lock is therefore belt-and-braces for the APPEND and genuinely\n * load-bearing for ROTATION, where a rename-and-reopen really does race.\n *\n * ─── IT MAY NEVER FAIL A BUILD ────────────────────────────────────────────────────────────────────────\n * Every method here is best-effort and swallows its own errors. A build must never die because a log\n * file was busy, unwritable, or on a full disk. Lock acquisition retries and then gives up and appends\n * anyway — which the row-size invariant above makes safe.\n */\nexport const BUILDS_LOG_FILE = 'builds.log';\nexport const BUILDS_LOCK_FILE = 'builds.log.lock';\n\n/** START, and the two terminal kinds. `DONE-` is the greppable prefix that pairs with a START. */\nexport const BUILD_START = 'START';\nexport const BUILD_DONE_SUCCESS = 'DONE-SUCCESS';\nexport const BUILD_DONE_FAIL = 'DONE-FAIL';\n\n/** Rotate at 1 MB, keeping five generations (`.1` … `.5`); the old `.5` is dropped. */\nexport const MAX_BUILDS_LOG_BYTES = 1024 * 1024;\nexport const BUILDS_LOG_GENERATIONS = 5;\n\n/**\n * macOS `PIPE_BUF`. A row at or under this size is written by ONE indivisible `write(2)`, which is what\n * makes a lost lock a non-event rather than a corrupted file. Long paths are clipped to hold the line\n * under it — see `clip`.\n */\nexport const MAX_ROW_BYTES = 512;\n\n/** How long a build may hold the lock before another writer stops waiting and appends anyway. */\nconst LOCK_RETRY_MS = 50;\nconst LOCK_TIMEOUT_MS = 2000;\n\n/**\n * The handle a START row hands back, and the ONLY thing `finish()` accepts. Data-only (a class, per\n * CLAUDE.md), carrying exactly the fields the DONE row needs to pair itself with its START: the uuid,\n * the caller, the repo, and when it began (so `took=` is computed from one clock, not two).\n */\nexport class BuildTicket {\n id: string;\n by: string;\n repo: string;\n startedMs: number;\n\n constructor(id: string, by: string, repo: string, startedMs: number) {\n this.id = id;\n this.by = by;\n this.repo = repo;\n this.startedMs = startedMs;\n }\n}\n\n/**\n * One build that is STILL RUNNING — a START row with no matching `DONE-`, whose pid is still alive.\n * Data-only. This is what the refusal message renders, so it carries the three things a reader needs to\n * recognise the build in question: where it is, which tree, and how old it is.\n */\nexport class RunningBuild {\n id: string;\n by: string;\n repo: string;\n tree: string;\n cwd: string;\n branch: string;\n pid: number;\n startedMs: number;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(\n id: string, by: string, repo: string, tree: string,\n cwd: string, branch: string, pid: number, startedMs: number,\n ) {\n this.id = id;\n this.by = by;\n this.repo = repo;\n this.tree = tree;\n this.cwd = cwd;\n this.branch = branch;\n this.pid = pid;\n this.startedMs = startedMs;\n }\n}\n\n/**\n * The ledger. See the file docblock for why it is machine-global and why every operation swallows its\n * own errors.\n *\n * `homeDir` is a parameter on every public method, defaulted to `os.homedir()`, for exactly the reason\n * `HomeConfigService.configPath` takes one: a spec must be able to exercise the real code against a temp\n * directory and must never touch the developer's actual `~/.webpieces`.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildsLog {\n constructor(private readonly dotDir: DotWebpieces) {}\n\n /** `~/.webpieces/builds.log`. */\n logPath(homeDir: string = os.homedir()): string {\n return path.join(homeDir, HOME_CONFIG_DIR, BUILDS_LOG_FILE);\n }\n\n /** `~/.webpieces/builds.log.lock` — `{\"pid\":N,\"started\":<epochMs>}`. */\n lockPath(homeDir: string = os.homedir()): string {\n return path.join(homeDir, HOME_CONFIG_DIR, BUILDS_LOCK_FILE);\n }\n\n /** `~/.webpieces/builds.log.<n>` — generation `n`, 1 being the most recent. */\n rotatedPath(generation: number, homeDir: string = os.homedir()): string {\n return `${this.logPath(homeDir)}.${String(generation)}`;\n }\n\n /**\n * Record that a build is starting, and hand back the ticket its DONE row will need.\n *\n * `by` is the CALLER — `BuildGateOptions.stage`, i.e. `build` | `review` | `finish`. There is no\n * second \"caller\" concept anywhere: the stage id already is one, and a second spelling of it would\n * be the shim CLAUDE.md rejects.\n *\n * Returns a ticket even when the append failed. A build whose START row never landed still has to be\n * able to call `finish()`; the alternative is a nullable return that every call site must branch on\n * for a logging failure that is, by policy, not an error.\n */\n start(by: string, startDir: string, homeDir: string = os.homedir()): BuildTicket {\n const ticket = new BuildTicket(\n crypto.randomUUID(), by, this.dotDir.primaryRoot(startDir), Date.now());\n this.append(this.startRow(ticket, startDir), homeDir);\n return ticket;\n }\n\n /**\n * Record that the build behind `ticket` has ended. `exitCode` 0 writes `DONE-SUCCESS`; anything else\n * writes `DONE-FAIL` carrying the code, so `grep DONE-FAIL` lists every red build on the machine.\n */\n finish(ticket: BuildTicket, exitCode: number, homeDir: string = os.homedir()): void {\n this.append(this.doneRow(ticket, exitCode), homeDir);\n }\n\n /**\n * Every build that is still live: a `START` with no matching `DONE-` row, whose pid is still alive.\n *\n * The pid filter is not an optimisation, it is what keeps the ledger from wedging the machine. A\n * build killed with SIGKILL — an agent cancelled mid-run, a terminal closed — writes no DONE row, so\n * without the liveness test its START would count forever and the fourth build would be refused for\n * the rest of the machine's life. The uuid answers \"which build\"; the pid answers \"is it still real\".\n *\n * Only the CURRENT generation is read. A rotated-away START is by definition at least 1 MB of rows\n * old and is not a build anyone is contending with.\n */\n running(homeDir: string = os.homedir()): RunningBuild[] {\n const lines = this.readLines(this.logPath(homeDir));\n const done = new Set<string>();\n for (const line of lines) {\n if (line.startsWith(`${BUILD_DONE_SUCCESS}\\t`) || line.startsWith(`${BUILD_DONE_FAIL}\\t`)) {\n done.add(this.field(line, 'id'));\n }\n }\n const live: RunningBuild[] = [];\n for (const line of lines) {\n if (!line.startsWith(`${BUILD_START}\\t`)) continue;\n const build = this.toRunningBuild(line);\n if (build === null || done.has(build.id)) continue;\n if (!this.isAlive(build.pid)) continue;\n live.push(build);\n }\n return live;\n }\n\n /**\n * The `@webpieces` release ACTUALLY EXECUTING — found by walking UP from this module's own directory\n * to the nearest enclosing `node_modules/@webpieces/<pkg>/package.json`. `''` when this code is\n * running from source rather than from an installed package (which is the state in this repo's own\n * specs, and a perfectly ordinary answer).\n *\n * ─── WHY THIS IS NOT `WebpiecesVersions.readInstalled(root)` ──────────────────────────────────────\n * They answer DIFFERENT QUESTIONS and merging them would break the older one. `readInstalled` joins\n * `<root>/node_modules/@webpieces/...` at a FIXED tree root ON PURPOSE: its whole job is to detect\n * DRIFT between what a tree PINS and what some other tree pins, and a walk-up would silently resolve\n * a worktree with no install of its own to the primary clone's copy — hiding exactly the skew that\n * guard exists to catch. This question is the opposite one: \"whichever copy is running, name it\", and\n * for that the walk-up is the only correct answer. Do not fold them together.\n */\n executingVersion(): string {\n let dir = __dirname;\n for (let hops = 0; hops < 40; hops += 1) {\n const version = this.versionOfEnclosingPackage(dir);\n if (version !== '') return version;\n const parent = path.dirname(dir);\n if (parent === dir) return '';\n dir = parent;\n }\n return '';\n }\n\n // `<dir>` is `node_modules/@webpieces/<pkg>/...`? Then that package's version, else ''.\n private versionOfEnclosingPackage(dir: string): string {\n const parent = path.dirname(dir);\n const grandparent = path.dirname(parent);\n if (path.basename(parent) !== '@webpieces' || path.basename(grandparent) !== 'node_modules') return '';\n const text = this.readTextOrEmpty(path.join(dir, 'package.json'));\n const match = /\"version\"\\s*:\\s*\"([^\"]+)\"/.exec(text);\n return match === null ? '' : match[1];\n }\n\n // ─── ROW RENDERING ────────────────────────────────────────────────────────────────────────────────\n\n private startRow(ticket: BuildTicket, startDir: string): string {\n return [\n BUILD_START,\n `id=${ticket.id}`,\n `t=${new Date(ticket.startedMs).toISOString()}`,\n `ms=${String(ticket.startedMs)}`,\n `by=${ticket.by}`,\n `repo=${this.clip(ticket.repo)}`,\n `tree=${this.dotDir.worktreeName(startDir) || 'primary'}`,\n `cwd=${this.clip(startDir)}`,\n `branch=${this.gitBranch(startDir)}`,\n `pid=${String(process.pid)}`,\n `wp=${this.executingVersion()}`,\n ].join('\\t');\n }\n\n private doneRow(ticket: BuildTicket, exitCode: number): string {\n const now = Date.now();\n const fields = [\n exitCode === 0 ? BUILD_DONE_SUCCESS : BUILD_DONE_FAIL,\n `id=${ticket.id}`,\n `t=${new Date(now).toISOString()}`,\n `ms=${String(now)}`,\n `by=${ticket.by}`,\n `repo=${this.clip(ticket.repo)}`,\n `took=${String(now - ticket.startedMs)}`,\n ];\n if (exitCode !== 0) fields.push(`exit=${String(exitCode)}`);\n fields.push(`pid=${String(process.pid)}`);\n return fields.join('\\t');\n }\n\n /**\n * Hold a long path down to `max` characters by keeping its TAIL, which is the half that identifies\n * the tree; a clipped value is marked with a leading `…` so nobody mistakes it for a real path.\n *\n * This is what keeps a row under `MAX_ROW_BYTES` — see the file docblock. `append` re-checks the\n * assembled line as a backstop, because three clipped fields plus a long branch name can still add up.\n */\n private clip(value: string, max = 120): string {\n const oneLine = value.replace(/[\\t\\n\\r]/g, ' ');\n return oneLine.length <= max ? oneLine : `…${oneLine.slice(oneLine.length - max)}`;\n }\n\n // ─── ROW PARSING ──────────────────────────────────────────────────────────────────────────────────\n\n /** The value of `<name>=` on a TSV row, or '' when the row does not carry it. */\n private field(line: string, name: string): string {\n for (const part of line.split('\\t')) {\n if (part.startsWith(`${name}=`)) return part.slice(name.length + 1);\n }\n return '';\n }\n\n // A START row as a RunningBuild, or null when it is missing the two fields that make it usable.\n private toRunningBuild(line: string): RunningBuild | null {\n const id = this.field(line, 'id');\n const pid = Number.parseInt(this.field(line, 'pid'), 10);\n if (id === '' || !Number.isInteger(pid) || pid <= 0) return null;\n const startedMs = Number.parseInt(this.field(line, 'ms'), 10);\n return new RunningBuild(\n id, this.field(line, 'by'), this.field(line, 'repo'), this.field(line, 'tree'),\n this.field(line, 'cwd'), this.field(line, 'branch'), pid,\n Number.isInteger(startedMs) ? startedMs : 0,\n );\n }\n\n /**\n * Is `pid` still addressable? `process.kill(pid, 0)` sends no signal — it only asks the kernel. ESRCH\n * is the ONE answer that proves death; EPERM proves the opposite (it exists, it is somebody else's).\n * Pid reuse is an accepted imprecision here: being wrong in the \"still running\" direction costs\n * one extra refusal, being wrong the other way lets a fourth build start.\n *\n * WHY A PID IS MEANINGFUL HERE AND WAS NOT FOR AGENT WORKTREE LOCKS. Every row in this ledger is\n * written BY the process it names — one real OS process per build — so its pid identifies it.\n * A Claude Code subagent is not a process at all: every agent in a session records the SAME pid,\n * the session's, which is why that check was deleted rather than shared (see\n * harness-agent-activity.ts). Do not generalise this one back out to anything but a real process.\n */\n private isAlive(pid: number): boolean {\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 return (error as NodeJS.ErrnoException).code !== 'ESRCH';\n }\n }\n\n // ─── APPEND, LOCK, ROTATE ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Append one row, best-effort. Takes the lock so rotation cannot race, and appends ANYWAY when the\n * lock cannot be had within `LOCK_TIMEOUT_MS` — the row is under `PIPE_BUF`, so an unlocked\n * `O_APPEND` write is still indivisible, and a build must never die because a log file was busy.\n */\n private append(row: string, homeDir: string): void {\n const line = `${this.truncateToRowLimit(row)}\\n`;\n const held = this.tryAcquireLock(homeDir);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n this.ensureDir(this.logPath(homeDir));\n if (held) this.rotateIfLarge(homeDir);\n fs.appendFileSync(this.logPath(homeDir), line);\n } catch (err: unknown) {\n const error = toError(err);\n void error; // logging may never fail a build — see the file docblock\n } finally {\n if (held) this.releaseLock(homeDir);\n }\n }\n\n // The backstop for the PIPE_BUF invariant, measured in BYTES rather than characters because a path\n // may hold multi-byte characters. Truncating a row loses fields off the end, which is strictly better\n // than a torn line that breaks every row after it.\n private truncateToRowLimit(row: string): string {\n const bytes = Buffer.from(row, 'utf8');\n // -1 for the newline `append` adds.\n if (bytes.length <= MAX_ROW_BYTES - 1) return row;\n return bytes.subarray(0, MAX_ROW_BYTES - 1).toString('utf8');\n }\n\n /**\n * Take the ledger lock, retrying every `LOCK_RETRY_MS` until `LOCK_TIMEOUT_MS`. False means \"carry on\n * without it\" — never an error, and never a reason to skip the append.\n *\n * The mechanism is `MainSyncStatusService.tryAcquireMainSyncLock`'s, proven and deliberately copied\n * rather than re-invented: an `wx` (O_CREAT|O_EXCL) create so exactly one of N racers wins, a payload\n * carrying pid + started so a dead holder is identifiable, stale reclaim gated on pid liveness, and a\n * re-read afterwards to confirm the entry on disk is OURS (a simultaneous reclaimer could have\n * unlinked ours and written its own between the two calls).\n */\n private tryAcquireLock(homeDir: string): boolean {\n const deadline = Date.now() + LOCK_TIMEOUT_MS;\n this.ensureDir(this.lockPath(homeDir));\n // Rendered rather than JSON.stringify'd off an anonymous object — two fields, both numbers, and\n // the file's whole contract is `{\"pid\":N,\"started\":M}`.\n const payload = `{\"pid\":${String(process.pid)},\"started\":${String(Date.now())}}\\n`;\n for (;;) {\n if (this.createExclusive(this.lockPath(homeDir), payload)) return true;\n if (!this.isHolderAlive(homeDir)) {\n this.unlinkQuietly(this.lockPath(homeDir));\n if (this.createExclusive(this.lockPath(homeDir), payload) && this.holderIsUs(homeDir)) return true;\n }\n if (Date.now() >= deadline) return false;\n this.sleep(LOCK_RETRY_MS);\n }\n }\n\n private releaseLock(homeDir: string): void {\n if (!this.holderIsUs(homeDir)) return;\n this.unlinkQuietly(this.lockPath(homeDir));\n }\n\n // O_CREAT|O_EXCL write: true when THIS call created the file, false when it already existed.\n private createExclusive(file: string, payload: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.writeFileSync(file, payload, { flag: 'wx' });\n return true;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n // The pid recorded in the lock file, or 0 when there is no readable lock.\n private lockHolderPid(homeDir: string): number {\n const text = this.readTextOrEmpty(this.lockPath(homeDir));\n if (text === '') return 0;\n const match = /\"pid\"\\s*:\\s*(\\d+)/.exec(text);\n return match === null ? 0 : Number.parseInt(match[1], 10);\n }\n\n // An unreadable or pid-less lock file counts as DEAD: it is a corpse from a crashed writer, and\n // leaving it forever would mean every future append silently skips rotation.\n private isHolderAlive(homeDir: string): boolean {\n const pid = this.lockHolderPid(homeDir);\n return pid > 0 && this.isAlive(pid);\n }\n\n private holderIsUs(homeDir: string): boolean {\n return this.lockHolderPid(homeDir) === process.pid;\n }\n\n /**\n * `.4→.5, .3→.4, … .log→.1`, dropping the old `.5`. Runs INSIDE the lock, which is the one place the\n * lock is genuinely load-bearing: a rename-and-reopen really does race, and a writer that opened the\n * old inode mid-shift would append into a file nobody reads again.\n */\n private rotateIfLarge(homeDir: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!fs.existsSync(this.logPath(homeDir))) return;\n if (fs.statSync(this.logPath(homeDir)).size < MAX_BUILDS_LOG_BYTES) return;\n this.unlinkQuietly(this.rotatedPath(BUILDS_LOG_GENERATIONS, homeDir));\n for (let gen = BUILDS_LOG_GENERATIONS - 1; gen >= 1; gen -= 1) {\n this.renameQuietly(this.rotatedPath(gen, homeDir), this.rotatedPath(gen + 1, homeDir));\n }\n this.renameQuietly(this.logPath(homeDir), this.rotatedPath(1, homeDir));\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n // ─── FILESYSTEM PRIMITIVES, ALL SWALLOWING ────────────────────────────────────────────────────────\n\n private ensureDir(file: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.mkdirSync(path.dirname(file), { recursive: true });\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n private unlinkQuietly(file: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (fs.existsSync(file)) fs.unlinkSync(file);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n private renameQuietly(from: string, to: string): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (fs.existsSync(from)) fs.renameSync(from, to);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n private readTextOrEmpty(file: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '';\n }\n }\n\n private readLines(file: string): string[] {\n const text = this.readTextOrEmpty(file);\n if (text === '') return [];\n return text.split('\\n').filter((line: string): boolean => line.trim() !== '');\n }\n\n // A blocking sleep, because the lock retry sits on a synchronous append path that must not become\n // async — `finish()` is called from a `finally` and an async logger there could outlive the process.\n private sleep(ms: number): void {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n }\n }\n\n // The checked-out branch in `startDir`, or '' when git cannot say. spawnSync does not throw on a\n // non-zero exit, so \"not a repo\" arrives as a status, not an exception.\n private gitBranch(startDir: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'],\n { cwd: startDir, encoding: 'utf8' });\n if (result.status !== 0 || typeof result.stdout !== 'string') return '';\n return result.stdout.trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '';\n }\n }\n}\n"]}
|