forge-workflow 0.1.0-beta.3 → 0.1.0-beta.5
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/AGENTS.md +14 -7
- package/CHANGELOG.md +43 -1
- package/README.md +6 -2
- package/bin/forge-cmd.js +21 -1
- package/bin/forge.js +16 -369
- package/docs/INDEX.md +1 -1
- package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
- package/docs/guides/MIGRATION.md +4 -4
- package/docs/guides/SETUP.md +16 -16
- package/docs/reference/COMMANDS.md +9 -4
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +117 -17
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/greptile-review-adapter.js +1 -1
- package/lib/adapters/pr-state-adapter.js +397 -100
- package/lib/agents-config.js +5 -0
- package/lib/audit-evidence.js +71 -110
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/commands/_issue.js +31 -46
- package/lib/commands/_manifest.js +1 -1
- package/lib/commands/_registry.js +2 -2
- package/lib/commands/_resolve-command-opts.js +36 -29
- package/lib/commands/claim.js +2 -4
- package/lib/commands/clean.js +196 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/hooks.js +358 -13
- package/lib/commands/insights.js +8 -3
- package/lib/commands/merge.js +600 -40
- package/lib/commands/plan.js +23 -115
- package/lib/commands/pr.js +1 -1
- package/lib/commands/preflight.js +11 -2
- package/lib/commands/prime.js +23 -3
- package/lib/commands/push.js +41 -51
- package/lib/commands/recall.js +60 -16
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +18 -4
- package/lib/commands/serve.js +5 -2
- package/lib/commands/setup.js +191 -95
- package/lib/commands/shepherd.js +49 -4
- package/lib/commands/ship.js +22 -23
- package/lib/commands/skill.js +383 -0
- package/lib/commands/status.js +54 -33
- package/lib/commands/test.js +56 -34
- package/lib/commands/worktree.js +247 -43
- package/lib/core/runtime-graph.js +89 -15
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +64 -491
- package/lib/git-defaults.js +56 -0
- package/lib/harness-capability-matrix.js +5 -5
- package/lib/hook-renderer.js +147 -16
- package/lib/insights.js +96 -80
- package/lib/issue-backend.js +42 -3
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +44 -0
- package/lib/kernel/cli-broker-factory.js +12 -1
- package/lib/kernel/close-on-merge.js +154 -0
- package/lib/kernel/fs-class.js +42 -25
- package/lib/kernel/migrations.js +30 -2
- package/lib/kernel/schema.js +35 -0
- package/lib/kernel/sqlite-driver.js +292 -18
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/router.js +16 -1
- package/lib/memory-digest.js +47 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +212 -0
- package/lib/merge-rules.js +8 -4
- package/lib/npm-publish-workflow.js +272 -0
- package/lib/orientation.js +371 -49
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +9 -6
- package/lib/pr-monitor/journal.js +18 -2
- package/lib/pr-monitor/reconcile-executor.js +842 -0
- package/lib/pr-monitor/reconcile-tick.js +138 -0
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +196 -0
- package/lib/pr-monitor/shepherd-lease.js +252 -0
- package/lib/pr-monitor/watch-lifecycle.js +14 -2
- package/lib/pr-pull.js +98 -24
- package/lib/pr-shepherd.js +34 -8
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +40 -0
- package/lib/protected-state-authority.js +305 -0
- package/lib/protected-state-surfaces.js +64 -44
- package/lib/release-readiness.js +51 -4
- package/lib/rules-sync.js +4 -0
- package/lib/runtime-health.js +15 -46
- package/lib/shell-utils.js +1 -1
- package/lib/skill-eval.js +750 -0
- package/lib/skills-sync.js +6 -3
- package/lib/smart-merge.js +28 -4
- package/lib/status/identity.js +46 -0
- package/lib/status/presenter.js +0 -35
- package/lib/status/snapshot.js +11 -16
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +47 -9
- package/lib/using-forge.js +328 -0
- package/lib/workflow/enforce-stage.js +5 -5
- package/lib/workflow/state-manager.js +23 -23
- package/package.json +6 -7
- package/rules/using-forge.md +24 -0
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/forge-team/index.sh +0 -5
- package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
- package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +456 -0
- package/scripts/lib/eval-evidence.js +328 -0
- package/scripts/lib/eval-runner.js +81 -41
- package/scripts/lib/immutable-eval-corpus.js +309 -0
- package/scripts/lib/promotion-evidence-loader.js +94 -0
- package/scripts/lib/promotion-scorecard.js +314 -0
- package/scripts/npm-release-receipt.js +134 -0
- package/scripts/process-tree.js +761 -0
- package/scripts/protected-state-check.js +47 -22
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +249 -37
- package/scripts/test.js +184 -44
- package/skills/claim-safety/SKILL.md +4 -0
- package/skills/claim-safety/evals/scorecard.json +41 -0
- package/skills/coverage.json +83 -0
- package/skills/dev/SKILL.md +4 -0
- package/skills/dev/evals/scorecard.json +41 -0
- package/skills/gates/SKILL.md +80 -0
- package/skills/gates/evals/evals.json +38 -0
- package/skills/gates/evals/scorecard.json +41 -0
- package/skills/hermes-forge/SKILL.md +1 -0
- package/skills/hermes-forge/evals/scorecard.json +41 -0
- package/skills/issue-basics/SKILL.md +1 -0
- package/skills/issue-basics/evals/scorecard.json +41 -0
- package/skills/kernel/SKILL.md +38 -0
- package/skills/kernel/evals/scorecard.json +41 -0
- package/skills/memory/SKILL.md +16 -1
- package/skills/memory/evals/scorecard.json +41 -0
- package/skills/parallel-deep-research/SKILL.md +1 -0
- package/skills/parallel-deep-research/evals/scorecard.json +41 -0
- package/skills/plan/SKILL.md +6 -0
- package/skills/plan/evals/scorecard.json +41 -0
- package/skills/portability/SKILL.md +47 -0
- package/skills/portability/evals/evals.json +34 -0
- package/skills/portability/evals/scorecard.json +41 -0
- package/skills/research/SKILL.md +1 -0
- package/skills/research/evals/scorecard.json +41 -0
- package/skills/review/SKILL.md +10 -11
- package/skills/review/evals/scorecard.json +41 -0
- package/skills/rollback/SKILL.md +5 -11
- package/skills/rollback/evals/scorecard.json +41 -0
- package/skills/setup/SKILL.md +91 -0
- package/skills/setup/evals/evals.json +42 -0
- package/skills/setup/evals/scorecard.json +41 -0
- package/skills/shepherd/SKILL.md +84 -38
- package/skills/shepherd/evals/evals.json +21 -9
- package/skills/shepherd/evals/scorecard.json +41 -0
- package/skills/ship/SKILL.md +10 -12
- package/skills/ship/evals/scorecard.json +41 -0
- package/skills/smith/SKILL.md +8 -0
- package/skills/smith/evals/scorecard.json +41 -0
- package/skills/sonarcloud/SKILL.md +1 -0
- package/skills/sonarcloud/evals/scorecard.json +41 -0
- package/skills/sonarcloud-analysis/SKILL.md +1 -0
- package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
- package/skills/status/SKILL.md +3 -0
- package/skills/status/evals/scorecard.json +41 -0
- package/skills/triage-ready/SKILL.md +2 -0
- package/skills/triage-ready/evals/scorecard.json +41 -0
- package/skills/using-forge/SKILL.md +104 -0
- package/skills/using-forge/evals/scorecard.json +41 -0
- package/skills/validate/SKILL.md +4 -0
- package/skills/validate/evals/scorecard.json +41 -0
- package/skills/verify/SKILL.md +4 -0
- package/skills/verify/evals/scorecard.json +41 -0
- package/skills/worktree/SKILL.md +92 -0
- package/skills/worktree/evals/evals.json +38 -0
- package/skills/worktree/evals/scorecard.json +41 -0
- package/lib/adapters/beads-issue-adapter.js +0 -127
- package/lib/beads-nudge.js +0 -91
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/commands/board.js +0 -64
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -192
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- package/lib/status/beads-snapshot.js +0 -145
- package/scripts/beads-context.sh +0 -577
- package/scripts/beads-migrate-to-dolt.sh +0 -7
- package/scripts/beads-upgrade-smoke.sh +0 -284
- package/scripts/forge-team/lib/dashboard.sh +0 -316
- package/scripts/forge-team/tests/dashboard.test.sh +0 -155
- package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The IMPURE half of the autonomous shepherd reconciler (W-S4b, design §3/§4).
|
|
5
|
+
*
|
|
6
|
+
* W-S4a shipped the pure `reconcile(desired, observed, now)` and the `tick()`
|
|
7
|
+
* debounce guard. This module is the thin, side-effecting dispatcher over them:
|
|
8
|
+
* it gathers the two state sets (GitHub via `gh`, kernel via the broker), runs the
|
|
9
|
+
* actions `reconcile()` emits (spawn/stop/reap watchers, upsert/retire kernel_pr
|
|
10
|
+
* rows), owns the SINGLETON DAEMON lease lifecycle, and provides the approved-seam
|
|
11
|
+
* `fireAndForget()` trigger used by session-start, push, and ship.
|
|
12
|
+
*
|
|
13
|
+
* The NON-BLOCKING / ERROR-SWALLOWING contract is paramount: `fireAndForget()`
|
|
14
|
+
* MUST never throw and never affect the command that triggered it. Every spawn is modeled on
|
|
15
|
+
* `watch-lifecycle.startPrWatcherDetached` (detached, `stdio:'ignore'`,
|
|
16
|
+
* `windowsHide:true`, `.unref()`, no-op `'error'` listener) so a failed launch
|
|
17
|
+
* degrades to "not started" rather than crashing.
|
|
18
|
+
*
|
|
19
|
+
* SAFETY INVARIANTS (guarded by tests):
|
|
20
|
+
* - Orphan reaping NEVER `process.kill`s on a PID match alone. It re-verifies at
|
|
21
|
+
* kill time: the pid must be alive AND the journal start-time marker for that
|
|
22
|
+
* PR must still exist AND equal the watcher entry's `startedAt`. A null/legacy
|
|
23
|
+
* startedAt, or an absent/mismatched marker, means "do not kill" (PID reuse
|
|
24
|
+
* fail-safe) — the stale entry is dropped silently.
|
|
25
|
+
* - The singleton is arbitrated by the O_EXCL shepherd lease: a daemon that loses
|
|
26
|
+
* `acquire` exits immediately and spawns nothing.
|
|
27
|
+
* - Watcher launch classification branches on CAPABILITY presence
|
|
28
|
+
* (`ctx.harness.hasBgShell`), NEVER on harness name; uncertain → detached.
|
|
29
|
+
*
|
|
30
|
+
* @module pr-monitor/reconcile-executor
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('node:fs');
|
|
34
|
+
const path = require('node:path');
|
|
35
|
+
const { spawn } = require('node:child_process');
|
|
36
|
+
|
|
37
|
+
const shepherdLease = require('./shepherd-lease');
|
|
38
|
+
const journal = require('./journal');
|
|
39
|
+
const { reconcile: defaultReconcile } = require('./reconcile');
|
|
40
|
+
const { tick: defaultTick } = require('./reconcile-tick');
|
|
41
|
+
const { startPrWatcherDetached, forgeBin } = require('./watch-lifecycle');
|
|
42
|
+
const brokerMod = require('../kernel/broker');
|
|
43
|
+
|
|
44
|
+
const { STALE_MS } = shepherdLease;
|
|
45
|
+
const CANONICAL_REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
46
|
+
const GH_COMMAND_TIMEOUT_MS = 30_000;
|
|
47
|
+
const LINKAGE_FIELDS = ['issue_id', 'worktree_id', 'journal_ptr'];
|
|
48
|
+
|
|
49
|
+
function normalizeRepository(value) {
|
|
50
|
+
return typeof value === 'string' && CANONICAL_REPOSITORY.test(value.trim())
|
|
51
|
+
? value.trim().toLowerCase()
|
|
52
|
+
: null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve the GitHub identity used by merge binding, never a bare repo basename. */
|
|
56
|
+
function resolveCanonicalRepository(runGh) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = runGh(['repo', 'view', '--json', 'owner,name']);
|
|
59
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
60
|
+
const owner = parsed && parsed.owner && parsed.owner.login;
|
|
61
|
+
const name = parsed && parsed.name;
|
|
62
|
+
if (typeof owner !== 'string' || owner.trim() === '' || typeof name !== 'string' || name.trim() === '') return null;
|
|
63
|
+
return normalizeRepository(`${owner}/${name}`);
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Persist the daemon's latest lifecycle outcome beside the lease. This status
|
|
71
|
+
* file is deliberately separate from `shepherd.reconcile`: that sentinel's
|
|
72
|
+
* mtime is the cold-trigger debounce clock and diagnostics must never move it.
|
|
73
|
+
*/
|
|
74
|
+
function writeDaemonDiagnostic(gitCommonDir, entry, opts = {}) {
|
|
75
|
+
try {
|
|
76
|
+
const file = path.join(gitCommonDir, 'forge', 'shepherd.status.json');
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
const now = opts.now || (() => Date.now());
|
|
79
|
+
const payload = {
|
|
80
|
+
...entry,
|
|
81
|
+
pid: opts.pid ?? process.pid,
|
|
82
|
+
at: new Date(now()).toISOString(),
|
|
83
|
+
};
|
|
84
|
+
fs.writeFileSync(file, `${JSON.stringify(payload)}\n`);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Record through an injected test seam or the separate durable status file. */
|
|
92
|
+
function recordDaemonDiagnostic(opts, gitCommonDir, kind, detail) {
|
|
93
|
+
const entry = {
|
|
94
|
+
kind,
|
|
95
|
+
...(detail ? { detail: (detail && detail.message) || String(detail) } : {}),
|
|
96
|
+
};
|
|
97
|
+
try {
|
|
98
|
+
if (typeof opts.recordDiagnostic === 'function') opts.recordDiagnostic(entry);
|
|
99
|
+
else writeDaemonDiagnostic(gitCommonDir, entry, { now: opts.now });
|
|
100
|
+
} catch {
|
|
101
|
+
/* diagnostics must never affect a command or daemon lifecycle */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Normalize a lease watcher entry to the W-S4b `{pr, repo, pid, startedAt}` shape. */
|
|
106
|
+
function normalizeWatcher(entry) {
|
|
107
|
+
if (typeof entry === 'number') return { pr: entry, repo: null, pid: null, startedAt: null };
|
|
108
|
+
return {
|
|
109
|
+
pr: entry.pr,
|
|
110
|
+
repo: entry.repo ?? null,
|
|
111
|
+
pid: entry.pid ?? null,
|
|
112
|
+
startedAt: entry.startedAt ?? null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** File that records a watcher's spawn-time ISO stamp for kill-time re-verification. */
|
|
117
|
+
function claimPath(dir) {
|
|
118
|
+
return path.join(dir, 'watch.startedat');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Write the start-time marker for `(repo, pr)` into its journal dir. This is the
|
|
123
|
+
* kill-time re-verification token — orphan reaping refuses to kill a pid unless
|
|
124
|
+
* this marker still equals the watcher entry's `startedAt`.
|
|
125
|
+
*/
|
|
126
|
+
function writeClaimMarker(projectRoot, repo, pr, startedAt, gitCommonDir) {
|
|
127
|
+
if (repo == null || pr == null || startedAt == null) return;
|
|
128
|
+
try {
|
|
129
|
+
const dir = journal.journalDir({ root: projectRoot, gitCommonDir, repo, pr });
|
|
130
|
+
fs.writeFileSync(claimPath(dir), String(startedAt));
|
|
131
|
+
} catch {
|
|
132
|
+
/* best-effort — a missing marker just means the pid is treated as unverifiable (never reaped) */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Read the start-time marker for `(repo, pr)`, or null when absent/unreadable. */
|
|
137
|
+
function readClaimMarker(projectRoot, repo, pr, gitCommonDir) {
|
|
138
|
+
if (repo == null || pr == null) return null;
|
|
139
|
+
try {
|
|
140
|
+
const dir = journal.journalDir({ root: projectRoot, gitCommonDir, repo, pr });
|
|
141
|
+
return fs.readFileSync(claimPath(dir), 'utf8').trim();
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Remove the start-time marker for `(repo, pr)` when its watcher is stopped/reaped, so
|
|
149
|
+
* a future PID reuse can't match a STALE marker and get treated as the live watcher.
|
|
150
|
+
* Best-effort; a missing marker is fine (an unverifiable pid is never reaped anyway).
|
|
151
|
+
*/
|
|
152
|
+
function removeClaimMarker(projectRoot, repo, pr, gitCommonDir) {
|
|
153
|
+
if (repo == null || pr == null) return;
|
|
154
|
+
try {
|
|
155
|
+
const dir = journal.journalDir({ root: projectRoot, gitCommonDir, repo, pr });
|
|
156
|
+
fs.rmSync(claimPath(dir), { force: true });
|
|
157
|
+
} catch {
|
|
158
|
+
/* best-effort marker cleanup */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Gather the DESIRED open-PR set: GitHub's open PRs (`gh pr list`) enriched with
|
|
164
|
+
* kernel linkage (issue/worktree/journal) where a `kernel_pr` row already exists.
|
|
165
|
+
* A hand-opened PR with no kernel row is still included (issue_id/worktree_id
|
|
166
|
+
* null) so the reconciler self-registers it — zero user invocation. External
|
|
167
|
+
* fields (branch names) are stored raw and NEVER evaluated.
|
|
168
|
+
*/
|
|
169
|
+
async function gatherDesired(gitCommonDir, opts = {}) {
|
|
170
|
+
const runGh = opts.runGh || ((args) => require('node:child_process').execFileSync('gh', args, {
|
|
171
|
+
cwd: opts.projectRoot || process.cwd(), encoding: 'utf8', timeout: GH_COMMAND_TIMEOUT_MS, windowsHide: true,
|
|
172
|
+
}));
|
|
173
|
+
// The broker is a live kernel handle from createLocalBroker (listOpenPrs/upsertPr/
|
|
174
|
+
// retirePr are INSTANCE methods) — the daemon threads one in. Never fall back to the
|
|
175
|
+
// broker MODULE namespace: those methods don't exist there and every call would
|
|
176
|
+
// silently no-op behind the catch (this exact bug shipped once — keep it gone).
|
|
177
|
+
const broker = opts.broker || null;
|
|
178
|
+
// Explicit repo injection remains a test/programmatic seam. Production resolves the
|
|
179
|
+
// owner/name pair from GitHub; a bare basename is never inferred from a failed lookup.
|
|
180
|
+
const suppliedRepo = typeof opts.repo === 'string' && opts.repo.trim() ? opts.repo.trim() : null;
|
|
181
|
+
const repo = suppliedRepo || resolveCanonicalRepository(runGh);
|
|
182
|
+
|
|
183
|
+
let ghPrs = [];
|
|
184
|
+
// `listingOk` distinguishes "GitHub says zero open PRs" from "the gh call failed"
|
|
185
|
+
// (network/auth/rate-limit). A FAILED listing must be a no-op upstream, never a
|
|
186
|
+
// teardown of every watcher+row — the caller skips the reconcile pass when false.
|
|
187
|
+
let listingOk = true;
|
|
188
|
+
try {
|
|
189
|
+
// `--limit 1000` overrides gh's default 30-result cap so a repo with >30 open
|
|
190
|
+
// PRs is fully enumerated (otherwise the tail would go unwatched or get retired).
|
|
191
|
+
const raw = runGh(['pr', 'list', '--state', 'open', '--limit', '1000', '--json', 'number,headRefName,headRefOid']);
|
|
192
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
193
|
+
if (Array.isArray(parsed)) ghPrs = parsed;
|
|
194
|
+
} catch {
|
|
195
|
+
ghPrs = [];
|
|
196
|
+
listingOk = false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!repo) return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
|
|
200
|
+
|
|
201
|
+
let prRows = [];
|
|
202
|
+
try {
|
|
203
|
+
if (broker) prRows = await broker.listOpenPrs(gitCommonDir);
|
|
204
|
+
} catch {
|
|
205
|
+
/* kernel unavailable → treat as no linkage; the GitHub-driven desired set still stands */
|
|
206
|
+
}
|
|
207
|
+
// Key by canonical (repo, number). A single bare-name row is accepted
|
|
208
|
+
// only as a one-way compatibility bridge; it is rewritten under the canonical key
|
|
209
|
+
// and retired by the same reconcile pass. The common-dir natural key plus an exact
|
|
210
|
+
// basename/number match preserves links across force-pushes; cross-repository,
|
|
211
|
+
// ambiguous, and other malformed rows never bind.
|
|
212
|
+
const canonicalRepo = normalizeRepository(repo);
|
|
213
|
+
const legacyRepo = canonicalRepo ? canonicalRepo.slice(canonicalRepo.lastIndexOf('/') + 1) : null;
|
|
214
|
+
const validPrNumber = (value) => (Number.isSafeInteger(value) && value > 0)
|
|
215
|
+
|| (typeof value === 'string' && /^[1-9][0-9]*$/.test(value));
|
|
216
|
+
const rows = Array.isArray(prRows) ? prRows : [];
|
|
217
|
+
if (canonicalRepo) {
|
|
218
|
+
const counts = new Map();
|
|
219
|
+
const conflicting = rows.some((row) => {
|
|
220
|
+
if (!row || !validPrNumber(row.number)) return true;
|
|
221
|
+
const rowRepo = normalizeRepository(row.repo);
|
|
222
|
+
const canonical = rowRepo === canonicalRepo;
|
|
223
|
+
const legacy = typeof row.repo === 'string' && row.repo.trim().toLowerCase() === legacyRepo;
|
|
224
|
+
if (!canonical && !legacy) return true;
|
|
225
|
+
const number = String(row.number);
|
|
226
|
+
const count = counts.get(number) || { canonical: 0, legacy: 0 };
|
|
227
|
+
if (canonical) count.canonical += 1;
|
|
228
|
+
else count.legacy += 1;
|
|
229
|
+
counts.set(number, count);
|
|
230
|
+
return count.canonical > 1 || count.legacy > 1;
|
|
231
|
+
});
|
|
232
|
+
if (conflicting) return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
|
|
233
|
+
}
|
|
234
|
+
const exactRows = new Map();
|
|
235
|
+
const legacyRows = new Map();
|
|
236
|
+
const add = (map, number, row) => {
|
|
237
|
+
if (!validPrNumber(number)) return;
|
|
238
|
+
const list = map.get(String(number)) || [];
|
|
239
|
+
list.push(row);
|
|
240
|
+
map.set(String(number), list);
|
|
241
|
+
};
|
|
242
|
+
for (const row of rows) {
|
|
243
|
+
if (canonicalRepo && normalizeRepository(row && row.repo) === canonicalRepo) add(exactRows, row.number, row);
|
|
244
|
+
else if (legacyRepo && row && typeof row.repo === 'string' && row.repo.trim().toLowerCase() === legacyRepo) add(legacyRows, row.number, row);
|
|
245
|
+
else if (!canonicalRepo && row && row.repo === repo) add(exactRows, row.number, row);
|
|
246
|
+
}
|
|
247
|
+
const coalesceLinkage = (canonical, legacy) => {
|
|
248
|
+
if (!canonical || !legacy) return canonical || legacy || null;
|
|
249
|
+
const merged = { ...canonical };
|
|
250
|
+
for (const field of LINKAGE_FIELDS) {
|
|
251
|
+
const canonicalValue = canonical[field] ?? null;
|
|
252
|
+
const legacyValue = legacy[field] ?? null;
|
|
253
|
+
if (canonicalValue != null && legacyValue != null && canonicalValue !== legacyValue) return null;
|
|
254
|
+
if (canonicalValue == null && legacyValue != null) merged[field] = legacyValue;
|
|
255
|
+
}
|
|
256
|
+
return merged;
|
|
257
|
+
};
|
|
258
|
+
const mergedRows = new Map();
|
|
259
|
+
for (const number of new Set([...exactRows.keys(), ...legacyRows.keys()])) {
|
|
260
|
+
const exact = exactRows.get(number) || [];
|
|
261
|
+
const legacy = legacyRows.get(number) || [];
|
|
262
|
+
const merged = coalesceLinkage(exact[0], legacy[0]);
|
|
263
|
+
if (exact.length > 0 && legacy.length > 0 && !merged) {
|
|
264
|
+
return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
|
|
265
|
+
}
|
|
266
|
+
if (merged) mergedRows.set(number, merged);
|
|
267
|
+
}
|
|
268
|
+
const rowForPr = (p) => {
|
|
269
|
+
if (canonicalRepo) return mergedRows.get(String(p.number)) || null;
|
|
270
|
+
const exact = exactRows.get(String(p.number)) || [];
|
|
271
|
+
return exact.length === 1 ? exact[0] : null;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const openPrs = ghPrs.map((p) => {
|
|
275
|
+
const row = rowForPr(p);
|
|
276
|
+
return {
|
|
277
|
+
repo: canonicalRepo || repo,
|
|
278
|
+
number: p.number,
|
|
279
|
+
branch: p.headRefName ?? null,
|
|
280
|
+
headSha: p.headRefOid ?? null,
|
|
281
|
+
issueId: row?.issue_id ?? null,
|
|
282
|
+
worktreeId: row?.worktree_id ?? null,
|
|
283
|
+
journalPtr: row?.journal_ptr ?? null,
|
|
284
|
+
};
|
|
285
|
+
});
|
|
286
|
+
return { openPrs, gitCommonDir, listingOk };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Gather the OBSERVED state: kernel `kernel_pr` rows, the lease watcher set, and
|
|
291
|
+
* which of those watcher pids are actually alive (probed via `pidAlive`).
|
|
292
|
+
*/
|
|
293
|
+
async function gatherObserved(gitCommonDir, lock, opts = {}) {
|
|
294
|
+
const broker = opts.broker || null; // live kernel handle threaded by the daemon; never the module namespace
|
|
295
|
+
const isAlive = opts.isAlive || shepherdLease.pidAlive;
|
|
296
|
+
const readClaim = opts.readClaim
|
|
297
|
+
|| ((repo, pr) => readClaimMarker(opts.projectRoot, repo, pr, gitCommonDir));
|
|
298
|
+
const now = (opts.now || (() => Date.now()))();
|
|
299
|
+
|
|
300
|
+
let prRows = [];
|
|
301
|
+
try {
|
|
302
|
+
if (broker) prRows = await broker.listOpenPrs(gitCommonDir);
|
|
303
|
+
} catch {
|
|
304
|
+
/* kernel unavailable → observe an empty kernel set; reconcile still converges watchers */
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const watchers = (lock && Array.isArray(lock.watchers) ? lock.watchers : []).map(normalizeWatcher);
|
|
308
|
+
// A pid is "live" ONLY when it is alive AND its journal start-time marker still
|
|
309
|
+
// equals the entry's startedAt — the SAME check verifiedKill makes at kill time.
|
|
310
|
+
// A reused PID (alive, but a mismatched/absent marker) must NOT be reported live,
|
|
311
|
+
// or reconcile would suppress the startWatcher and leave the PR unmonitored.
|
|
312
|
+
const liveWatcherPids = watchers
|
|
313
|
+
.filter((w) => {
|
|
314
|
+
if (w.pid == null || !isAlive(w.pid)) return false;
|
|
315
|
+
const marker = readClaim(w.repo, w.pr);
|
|
316
|
+
return marker != null && String(marker) === String(w.startedAt);
|
|
317
|
+
})
|
|
318
|
+
.map((w) => ({ pid: w.pid, startedAt: w.startedAt ?? null }));
|
|
319
|
+
|
|
320
|
+
const beat = lock ? Date.parse(lock.heartbeatAt) : NaN;
|
|
321
|
+
const leaseFresh = Number.isFinite(beat) && (now - beat) < STALE_MS;
|
|
322
|
+
|
|
323
|
+
return { lease: lock || null, leaseFresh, prRows: Array.isArray(prRows) ? prRows : [], liveWatcherPids };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Kill a watcher pid ONLY after re-verifying start-time (design risk #4). Returns
|
|
328
|
+
* true iff the kill actually happened. Never kills on a PID match alone.
|
|
329
|
+
*/
|
|
330
|
+
function verifiedKill(entry, ctx) {
|
|
331
|
+
if (!entry || entry.pid == null || entry.startedAt == null) return false;
|
|
332
|
+
const isAlive = ctx.isAlive || shepherdLease.pidAlive;
|
|
333
|
+
if (!isAlive(entry.pid)) return false;
|
|
334
|
+
const readClaim = ctx.readClaim
|
|
335
|
+
|| ((e) => readClaimMarker(ctx.projectRoot, e.repo, e.pr, ctx.gitCommonDir));
|
|
336
|
+
const claim = readClaim(entry);
|
|
337
|
+
if (claim == null || String(claim) !== String(entry.startedAt)) return false;
|
|
338
|
+
try {
|
|
339
|
+
(ctx.kill || process.kill)(entry.pid);
|
|
340
|
+
} catch {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Per-action-type handlers, keyed by `action.type`. Extracted from `execute` so
|
|
348
|
+
* each is small and independently testable and the dispatcher stays a flat loop
|
|
349
|
+
* (keeps `execute`'s cognitive complexity under the SonarCloud gate). Each handler
|
|
350
|
+
* mutates the shared `s` state (`s.watchers` is reassigned by stop/reap) and the
|
|
351
|
+
* behavior is identical to the former if/else-if chain.
|
|
352
|
+
*/
|
|
353
|
+
const ACTION_HANDLERS = {
|
|
354
|
+
startWatcher(action, s) {
|
|
355
|
+
const startedAt = new Date(s.now()).toISOString();
|
|
356
|
+
const repo = action.pr.repo ?? s.repo ?? null;
|
|
357
|
+
const res = s.spawnWatcher({
|
|
358
|
+
prNumber: action.pr.number, cwd: s.projectRoot, gitCommonDir: s.gitCommonDir,
|
|
359
|
+
});
|
|
360
|
+
const pid = res && res.pid != null ? res.pid : null;
|
|
361
|
+
// A pid-less result means the watcher is already running (ship/push/adopt started it,
|
|
362
|
+
// startPrWatcherDetached → {started:false, reason:'already-running'}) or the spawn
|
|
363
|
+
// failed. Do NOT record a {pid:null} entry: gatherObserved never counts it live, so
|
|
364
|
+
// each interval would re-emit startWatcher and append another null entry forever.
|
|
365
|
+
if (pid == null) return;
|
|
366
|
+
const entry = { pr: action.pr.number, repo, pid, startedAt };
|
|
367
|
+
s.watchers.push(entry);
|
|
368
|
+
s.writeClaim(entry);
|
|
369
|
+
},
|
|
370
|
+
stopWatcher(action, s) {
|
|
371
|
+
for (const entry of s.watchers.filter((w) => w.pr === action.pr.number)) {
|
|
372
|
+
verifiedKill(entry, s.ctx);
|
|
373
|
+
s.removeClaim(entry); // clear the start-time marker so a reused PID can't match it later
|
|
374
|
+
}
|
|
375
|
+
s.watchers = s.watchers.filter((w) => w.pr !== action.pr.number);
|
|
376
|
+
},
|
|
377
|
+
reapOrphan(action, s) {
|
|
378
|
+
const entry = s.watchers.find((w) => w.pid === action.pid && w.startedAt === action.startedAt);
|
|
379
|
+
verifiedKill(entry, s.ctx);
|
|
380
|
+
if (entry) s.removeClaim(entry);
|
|
381
|
+
s.watchers = s.watchers.filter((w) => !(w.pid === action.pid && w.startedAt === action.startedAt));
|
|
382
|
+
},
|
|
383
|
+
async upsertPrRow(action, s) {
|
|
384
|
+
try {
|
|
385
|
+
if (!s.broker) return true;
|
|
386
|
+
const result = await s.broker.upsertPr(action.row);
|
|
387
|
+
return result !== false && !(result && result.ok === false);
|
|
388
|
+
} catch {
|
|
389
|
+
/* derived reconcile state — a failed upsert retries on the next converge */
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
async retire(action, s) {
|
|
394
|
+
try {
|
|
395
|
+
if (s.broker) await s.broker.retirePr(
|
|
396
|
+
{ git_common_dir: s.gitCommonDir, repo: action.pr.repo, number: action.pr.number },
|
|
397
|
+
{ state: 'closed', retired_at: new Date(s.now()).toISOString() },
|
|
398
|
+
);
|
|
399
|
+
} catch {
|
|
400
|
+
/* retried on the next converge */
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Dispatch a reconcile action set in order. A failed kernel upsert stops the pass so
|
|
407
|
+
* dependent retire actions retry on the next converge. Returns the updated
|
|
408
|
+
* watcher entry list (`{pr,repo,pid,startedAt}[]`) for the caller to publish via
|
|
409
|
+
* `updateWatchers`. `ctx.watchers` seeds the current set (from observed state).
|
|
410
|
+
*/
|
|
411
|
+
async function execute(actions, ctx = {}) {
|
|
412
|
+
const s = {
|
|
413
|
+
broker: ctx.broker || null, // live kernel handle threaded by the daemon; never the module namespace
|
|
414
|
+
spawnWatcher: ctx.spawnWatcher || startPrWatcherDetached,
|
|
415
|
+
writeClaim: ctx.writeClaim
|
|
416
|
+
|| ((e) => writeClaimMarker(ctx.projectRoot, e.repo, e.pr, e.startedAt, ctx.gitCommonDir)),
|
|
417
|
+
removeClaim: ctx.removeClaim
|
|
418
|
+
|| ((e) => removeClaimMarker(ctx.projectRoot, e.repo, e.pr, ctx.gitCommonDir)),
|
|
419
|
+
now: ctx.now || (() => Date.now()),
|
|
420
|
+
repo: ctx.repo,
|
|
421
|
+
projectRoot: ctx.projectRoot,
|
|
422
|
+
gitCommonDir: ctx.gitCommonDir,
|
|
423
|
+
watchers: Array.isArray(ctx.watchers) ? ctx.watchers.map(normalizeWatcher) : [],
|
|
424
|
+
ctx, // verifiedKill reads isAlive/readClaim/kill/projectRoot off the original ctx
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
for (const action of (Array.isArray(actions) ? actions : [])) {
|
|
428
|
+
const handler = ACTION_HANDLERS[action && action.type];
|
|
429
|
+
if (handler && (await handler(action, s)) === false) break;
|
|
430
|
+
}
|
|
431
|
+
return s.watchers;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* One converge pass: gather → reconcile → execute → publish watchers. Used by the
|
|
436
|
+
* daemon loop and directly unit-testable with injected gather/reconcile/execute.
|
|
437
|
+
* Returns `{ actions, watchers, desiredCount }`.
|
|
438
|
+
*/
|
|
439
|
+
async function convergeOnce(projectRoot, opts = {}) {
|
|
440
|
+
const gitCommonDir = opts.gitCommonDir;
|
|
441
|
+
const reconcile = opts.reconcile || defaultReconcile;
|
|
442
|
+
const now = opts.now || (() => Date.now());
|
|
443
|
+
const lock = opts.lock !== undefined ? opts.lock : null; // daemon threads the live lock in; default null
|
|
444
|
+
|
|
445
|
+
const desired = opts.gatherDesired
|
|
446
|
+
? await opts.gatherDesired()
|
|
447
|
+
: await gatherDesired(gitCommonDir, { ...opts, projectRoot });
|
|
448
|
+
|
|
449
|
+
// A FAILED gh listing (listingOk === false) is a transient outage, not "zero open
|
|
450
|
+
// PRs". Skipping the reconcile+execute pass entirely makes it a true no-op — no
|
|
451
|
+
// observe, no retire/stopWatcher teardown of every row+watcher. desiredCount is
|
|
452
|
+
// left non-zero (null) so the daemon does NOT read it as "no PRs → self-retire".
|
|
453
|
+
if (desired && desired.listingOk === false) {
|
|
454
|
+
const keep = (lock && Array.isArray(lock.watchers)) ? lock.watchers : (opts.watchers || []);
|
|
455
|
+
return { actions: [], watchers: keep, desiredCount: null, listingOk: false };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const observed = opts.gatherObserved
|
|
459
|
+
? await opts.gatherObserved()
|
|
460
|
+
: await gatherObserved(gitCommonDir, lock, { ...opts, now, projectRoot });
|
|
461
|
+
|
|
462
|
+
const { actions } = reconcile(desired, observed, now());
|
|
463
|
+
const seedWatchers = observed.lease && Array.isArray(observed.lease.watchers)
|
|
464
|
+
? observed.lease.watchers
|
|
465
|
+
: (opts.watchers || []);
|
|
466
|
+
const watchers = await execute(actions, {
|
|
467
|
+
...opts,
|
|
468
|
+
projectRoot,
|
|
469
|
+
gitCommonDir,
|
|
470
|
+
watchers: seedWatchers,
|
|
471
|
+
now,
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
let leaseLost = false;
|
|
475
|
+
const updateWatchers = opts.updateWatchers || shepherdLease.updateWatchers;
|
|
476
|
+
if (opts.token) {
|
|
477
|
+
try {
|
|
478
|
+
// updateWatchers returns false when the lock is gone or owned by a DIFFERENT
|
|
479
|
+
// token — i.e. THIS daemon was superseded (its stale lease reclaimed by a newer
|
|
480
|
+
// one). Signal the caller to stop: a superseded daemon must not keep spawning/
|
|
481
|
+
// reaping watchers behind the live owner's back.
|
|
482
|
+
const published = updateWatchers(projectRoot, watchers, { gitCommonDir, token: opts.token });
|
|
483
|
+
if (published === false) leaseLost = true;
|
|
484
|
+
} catch {
|
|
485
|
+
/* publishing the watcher set is best-effort — the next pass re-derives it */
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return { actions, watchers, desiredCount: desired.openPrs.length, leaseLost };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The singleton daemon: acquire the lease (exit if a live foreign owner holds it),
|
|
493
|
+
* heartbeat, converge on a cadence, self-retire when no PRs remain. `opts.once`
|
|
494
|
+
* runs a single converge (for tests); otherwise an interval loop + signal handlers.
|
|
495
|
+
*/
|
|
496
|
+
async function runDaemon(projectRoot, opts = {}) {
|
|
497
|
+
const gitCommonDir = opts.gitCommonDir || brokerMod.resolveGitCommonDir(projectRoot);
|
|
498
|
+
const acquire = opts.acquire || shepherdLease.acquire;
|
|
499
|
+
const startHeartbeat = opts.startHeartbeat || shepherdLease.startHeartbeat;
|
|
500
|
+
const stopHeartbeat = opts.stopHeartbeat || shepherdLease.stopHeartbeat;
|
|
501
|
+
const release = opts.release || shepherdLease.release;
|
|
502
|
+
const converge = opts.convergeOnce || convergeOnce;
|
|
503
|
+
const now = opts.now || (() => Date.now());
|
|
504
|
+
// Tests that replace acquisition own the matching ownership seam as well.
|
|
505
|
+
// Production always verifies the real shared lock by exact pid+token.
|
|
506
|
+
const ownsLease = opts.ownsLease
|
|
507
|
+
|| (opts.acquire ? (() => true) : shepherdLease.owns);
|
|
508
|
+
// Injectable exit so a lifecycle test can assert the daemon actually exits
|
|
509
|
+
// (finding 4). `opts.exit === false` keeps the process alive (legacy test mode).
|
|
510
|
+
const exit = typeof opts.exit === 'function'
|
|
511
|
+
? opts.exit
|
|
512
|
+
: (opts.exit === false ? () => {} : (code) => process.exit(code));
|
|
513
|
+
|
|
514
|
+
const res = acquire(projectRoot, { gitCommonDir });
|
|
515
|
+
if (!res.ok) {
|
|
516
|
+
exit(0);
|
|
517
|
+
// A live, fresh foreign daemon owns this repo — exit immediately, spawn nothing.
|
|
518
|
+
return { ok: false, reason: 'foreign-lease' };
|
|
519
|
+
}
|
|
520
|
+
const token = res.token;
|
|
521
|
+
const heartbeat = startHeartbeat(projectRoot, { gitCommonDir, token });
|
|
522
|
+
|
|
523
|
+
// Build ONE real kernel broker for the daemon's lifetime — createLocalBroker-backed,
|
|
524
|
+
// because listOpenPrs/upsertPr/retirePr are INSTANCE methods (the module namespace has
|
|
525
|
+
// none). Injectable for tests via opts.broker/opts.buildBroker. `ownedDriver` is closed
|
|
526
|
+
// on retire ONLY when we created it (Windows EBUSY guard — a leaked handle wedges the
|
|
527
|
+
// sqlite file). A genuine build failure degrades to the watcher half (broker stays null).
|
|
528
|
+
let broker = opts.broker || null;
|
|
529
|
+
let ownedDriver = null;
|
|
530
|
+
if (!broker) {
|
|
531
|
+
try {
|
|
532
|
+
const built = await (opts.buildBroker || defaultBuildBroker)({ projectRoot, gitCommonDir });
|
|
533
|
+
broker = built.broker;
|
|
534
|
+
ownedDriver = built.driver;
|
|
535
|
+
} catch {
|
|
536
|
+
/* kernel genuinely unavailable → run degraded (watcher convergence only) */
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const convergeArgs = { ...opts, gitCommonDir, token, broker };
|
|
541
|
+
|
|
542
|
+
// retire() must NEVER throw: a release / stopHeartbeat / driver.close error must
|
|
543
|
+
// not leave the daemon un-exited (finding 4). Each teardown step swallows its own
|
|
544
|
+
// error so the caller's exit(0) always runs — no un-retired zombie.
|
|
545
|
+
const retire = async () => {
|
|
546
|
+
try { release(projectRoot, { gitCommonDir, token }); } catch { /* best effort */ }
|
|
547
|
+
try { stopHeartbeat(heartbeat); } catch { /* best effort */ }
|
|
548
|
+
if (ownedDriver && typeof ownedDriver.close === 'function') {
|
|
549
|
+
try { ownedDriver.close(); } catch { /* best effort */ }
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
if (opts.once) {
|
|
554
|
+
const conv = await converge(projectRoot, convergeArgs);
|
|
555
|
+
if (conv.desiredCount === 0) {
|
|
556
|
+
recordDaemonDiagnostic(opts, gitCommonDir, 'retired-no-open-prs');
|
|
557
|
+
await retire();
|
|
558
|
+
}
|
|
559
|
+
return { ok: true, token, ...conv };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const intervalMs = opts.intervalMs || 60000;
|
|
563
|
+
let stopped = false;
|
|
564
|
+
let inFlight = false; // finding 5: re-entrancy guard — never run two passes at once
|
|
565
|
+
let lastWatchers = []; // finding 2: thread the live watcher set across passes
|
|
566
|
+
let timer = null;
|
|
567
|
+
|
|
568
|
+
const retireForLeaseLoss = async () => {
|
|
569
|
+
stopped = true;
|
|
570
|
+
if (timer) clearInterval(timer);
|
|
571
|
+
recordDaemonDiagnostic(opts, gitCommonDir, 'lease-lost');
|
|
572
|
+
await retire();
|
|
573
|
+
exit(0);
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const stillOwnsLease = () => {
|
|
577
|
+
try {
|
|
578
|
+
return ownsLease(projectRoot, { gitCommonDir, token });
|
|
579
|
+
} catch {
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const runPass = async () => {
|
|
585
|
+
// A tick that fires while the previous pass is still in flight (converge slower
|
|
586
|
+
// than intervalMs) returns immediately, so passes never race on start/stop/reap.
|
|
587
|
+
if (stopped || inFlight) return;
|
|
588
|
+
if (!stillOwnsLease()) {
|
|
589
|
+
await retireForLeaseLoss();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
inFlight = true;
|
|
593
|
+
try {
|
|
594
|
+
// Thread the live watcher set + a fresh heartbeat stamp so gatherObserved
|
|
595
|
+
// observes the REAL live set each pass (finding 2) — without this the daemon
|
|
596
|
+
// saw lease:null every tick and re-started a watcher for every PR forever.
|
|
597
|
+
const passLock = { watchers: lastWatchers, heartbeatAt: new Date(now()).toISOString() };
|
|
598
|
+
const conv = await converge(projectRoot, { ...convergeArgs, lock: passLock });
|
|
599
|
+
if (!stillOwnsLease()) {
|
|
600
|
+
await retireForLeaseLoss();
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (conv && Array.isArray(conv.watchers)) lastWatchers = conv.watchers;
|
|
604
|
+
// Superseded: a newer daemon reclaimed our stale lease. Stop and exit — retire()
|
|
605
|
+
// won't touch the foreign lock (release is token-guarded), so the new owner is
|
|
606
|
+
// left intact; we just stop spawning/reaping behind it.
|
|
607
|
+
if (conv && conv.leaseLost) {
|
|
608
|
+
await retireForLeaseLoss();
|
|
609
|
+
} else if (conv && conv.desiredCount === 0) {
|
|
610
|
+
stopped = true;
|
|
611
|
+
if (timer) clearInterval(timer);
|
|
612
|
+
recordDaemonDiagnostic(opts, gitCommonDir, 'retired-no-open-prs');
|
|
613
|
+
await retire();
|
|
614
|
+
exit(0);
|
|
615
|
+
}
|
|
616
|
+
} catch (error) {
|
|
617
|
+
recordDaemonDiagnostic(opts, gitCommonDir, 'converge-failed', error);
|
|
618
|
+
if (!stillOwnsLease()) await retireForLeaseLoss();
|
|
619
|
+
/* a bad converge pass never crashes the daemon — the next tick retries */
|
|
620
|
+
} finally {
|
|
621
|
+
inFlight = false;
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
// finding 3: converge IMMEDIATELY on cold start — don't idle for up to intervalMs.
|
|
626
|
+
await runPass();
|
|
627
|
+
if (stopped) return { ok: true, token, retired: true };
|
|
628
|
+
|
|
629
|
+
timer = setInterval(runPass, intervalMs);
|
|
630
|
+
// The converge timer is intentionally left REF'd so it keeps the daemon process
|
|
631
|
+
// alive between passes (the heartbeat timer is unref'd inside startHeartbeat).
|
|
632
|
+
|
|
633
|
+
const onSignal = async () => { await retire(); exit(0); };
|
|
634
|
+
process.on('SIGINT', onSignal);
|
|
635
|
+
process.on('SIGTERM', onSignal);
|
|
636
|
+
|
|
637
|
+
return { ok: true, token, heartbeat, timer };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Launch the singleton daemon. Classify the execution home by CAPABILITY presence
|
|
642
|
+
* (`ctx.harness.hasBgShell`), NEVER by harness name; uncertain → detached spawn
|
|
643
|
+
* modeled on `startPrWatcherDetached`. Never throws.
|
|
644
|
+
*/
|
|
645
|
+
function launchDaemon(ctx = {}) {
|
|
646
|
+
const harness = ctx.harness || {};
|
|
647
|
+
const commonRoot = path.basename(ctx.gitCommonDir || '').toLowerCase() === '.git'
|
|
648
|
+
? path.dirname(ctx.gitCommonDir)
|
|
649
|
+
: ctx.projectRoot;
|
|
650
|
+
if (harness.hasBgShell && typeof harness.runBgShell === 'function') {
|
|
651
|
+
try {
|
|
652
|
+
harness.runBgShell([process.execPath, forgeBin(), 'shepherd', 'daemon'], { cwd: commonRoot });
|
|
653
|
+
return { launched: true, via: 'bg-shell' };
|
|
654
|
+
} catch {
|
|
655
|
+
/* fall through to the detached fail-safe */
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const spawnFn = ctx.spawnProcess || spawn;
|
|
659
|
+
try {
|
|
660
|
+
const child = spawnFn(
|
|
661
|
+
process.execPath,
|
|
662
|
+
[forgeBin(), 'shepherd', 'daemon'],
|
|
663
|
+
{ cwd: commonRoot, detached: true, stdio: 'ignore', windowsHide: true },
|
|
664
|
+
);
|
|
665
|
+
if (child && typeof child.on === 'function') {
|
|
666
|
+
child.on('error', (error) => {
|
|
667
|
+
recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed', error);
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
671
|
+
return { launched: true, via: 'detached', pid: child && child.pid != null ? child.pid : null };
|
|
672
|
+
} catch (error) {
|
|
673
|
+
recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed', error);
|
|
674
|
+
return { launched: false };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Build a live, migrated kernel broker (+ its owned driver) for the daemon. Uses the
|
|
680
|
+
* same createLocalBroker-backed factory the CLI uses, so listOpenPrs/upsertPr/retirePr
|
|
681
|
+
* are the real instance methods. The caller closes `driver` on retire.
|
|
682
|
+
*/
|
|
683
|
+
async function defaultBuildBroker({ projectRoot, gitCommonDir }) {
|
|
684
|
+
const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
|
|
685
|
+
const deps = await buildMigratedKernelIssueDeps({ projectRoot, gitCommonDir });
|
|
686
|
+
return { broker: deps.kernelBroker, driver: deps.kernelDriver };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Whether the default-ON `rail.auto_shepherd` gate permits the autonomous trigger.
|
|
691
|
+
* Reuses ship.js's `autoShepherdRailEnabled` — the SAME resolver `forge push`,
|
|
692
|
+
* `forge ship`, and `forge shepherd adopt` honor — so one `forge gate disable
|
|
693
|
+
* rail.auto_shepherd` turns the whole autonomous surface off. Lazy-required to keep
|
|
694
|
+
* the per-command trigger cheap and avoid an eager/circular load; FAIL-OPEN (returns
|
|
695
|
+
* enabled) if the resolver can't be read, and never throws.
|
|
696
|
+
*/
|
|
697
|
+
function railAutoShepherdEnabled(projectRoot) {
|
|
698
|
+
try {
|
|
699
|
+
return require('../commands/ship').autoShepherdRailEnabled(projectRoot);
|
|
700
|
+
} catch {
|
|
701
|
+
return true; // config unreadable → fail open (default-ON), never block the trigger's own path
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* True iff a kernel DB already exists for `projectRoot` — the SAME no-lazy-create
|
|
707
|
+
* invariant `forge prime` honors (orientation.hasExistingKernelDb). The trigger must
|
|
708
|
+
* CREATE NOTHING in an uninitialized or setup/init TARGET repo (else `setup --dry-run`
|
|
709
|
+
* and `init` would sprout a shepherd.lock and pollute output). SILENT: a no-op `warn`
|
|
710
|
+
* suppresses resolveGitCommonDir's fallback message on a non-git dir. Never throws.
|
|
711
|
+
*/
|
|
712
|
+
function kernelInitialized(projectRoot) {
|
|
713
|
+
try {
|
|
714
|
+
// Resolve the git-common-dir with a FAST, SUBPROCESS-FREE read — NEVER `git
|
|
715
|
+
// rev-parse` (its 30s timeout would block every registry command on the dispatch
|
|
716
|
+
// finally, and a git subprocess pollutes bare-repo command tests). Common checkout:
|
|
717
|
+
// <root>/.git is a dir. Linked worktree: <root>/.git is a file `gitdir: …/worktrees/x`
|
|
718
|
+
// whose common dir is the part before `/worktrees/`.
|
|
719
|
+
const gitPath = path.join(projectRoot, '.git');
|
|
720
|
+
const st = fs.statSync(gitPath); // throws if absent → not a repo → false
|
|
721
|
+
let commonDir;
|
|
722
|
+
if (st.isDirectory()) {
|
|
723
|
+
commonDir = gitPath;
|
|
724
|
+
} else {
|
|
725
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitPath, 'utf8'));
|
|
726
|
+
if (!m) return false;
|
|
727
|
+
const wtGitDir = path.resolve(projectRoot, m[1].trim());
|
|
728
|
+
const marker = `${path.sep}worktrees${path.sep}`;
|
|
729
|
+
const idx = wtGitDir.lastIndexOf(marker);
|
|
730
|
+
commonDir = idx >= 0 ? wtGitDir.slice(0, idx) : wtGitDir;
|
|
731
|
+
}
|
|
732
|
+
return fs.existsSync(path.join(commonDir, 'forge', 'kernel.sqlite'));
|
|
733
|
+
} catch {
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Empty enumeration for a cold-tick loser that lost the lease race (backs off). */
|
|
739
|
+
function emptyEnum(gitCommonDir) {
|
|
740
|
+
return {
|
|
741
|
+
desired: { openPrs: [], gitCommonDir },
|
|
742
|
+
observed: { lease: null, leaseFresh: false, prRows: [], liveWatcherPids: [] },
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* The session-start / successful-push / successful-ship trigger. Runs the `tick()` debounce; the hot
|
|
748
|
+
* path (a fresh daemon lease) short-circuits in-process with a single lock read
|
|
749
|
+
* and no spawn. Only on the cold (G3) path does it ARBITRATE via the O_EXCL lease:
|
|
750
|
+
* the acquire-winner launches the singleton daemon (which does the real
|
|
751
|
+
* `gh pr list` enumeration + converge), and a loser backs off — no spawn. The
|
|
752
|
+
* arbitration lease is released immediately after launch so the spawned daemon can
|
|
753
|
+
* take sole ownership; the daemon's own `acquire` is the final singleton authority,
|
|
754
|
+
* so even a race that double-launches still yields exactly one live daemon.
|
|
755
|
+
*
|
|
756
|
+
* The gh enumeration deliberately lives in the DAEMON, not here, so this trigger
|
|
757
|
+
* NEVER runs a blocking subprocess on the command's critical path.
|
|
758
|
+
*
|
|
759
|
+
* CONTRACT: never throws, never blocks (no await), never affects the command.
|
|
760
|
+
*/
|
|
761
|
+
function fireAndForget(ctx = {}) {
|
|
762
|
+
try {
|
|
763
|
+
const env = ctx.env || process.env;
|
|
764
|
+
// Operator kill-switch (agent-agnostic): a set FORGE_SHEPHERD_DISABLE turns the
|
|
765
|
+
// autonomous trigger fully inert — no lease, no enumeration, no daemon spawn.
|
|
766
|
+
if (
|
|
767
|
+
env.FORGE_SHEPHERD_DISABLE
|
|
768
|
+
|| env.NODE_ENV === 'test'
|
|
769
|
+
|| env.BUN_ENV === 'test'
|
|
770
|
+
|| env.CI
|
|
771
|
+
|| env.GITHUB_ACTIONS
|
|
772
|
+
|| env.GITLAB_CI
|
|
773
|
+
) return;
|
|
774
|
+
const projectRoot = ctx.projectRoot;
|
|
775
|
+
if (!projectRoot) return;
|
|
776
|
+
// A dry-run must have ZERO side effects, and the trigger must CREATE NOTHING in an
|
|
777
|
+
// uninitialized / setup-or-init TARGET repo (no-lazy-create invariant, same as prime).
|
|
778
|
+
// Both guards run BEFORE any git/lock touch so `setup --dry-run` / `init` stay
|
|
779
|
+
// side-effect- AND output-clean (kernelInitialized is silent). Checked here, not the
|
|
780
|
+
// caller, so every approved trigger site is covered uniformly.
|
|
781
|
+
if (ctx.dryRun) return;
|
|
782
|
+
if (!(ctx.kernelInitialized || kernelInitialized)(projectRoot)) return;
|
|
783
|
+
// Config kill-switch (same gate ship/push/adopt honor): a maintainer who ran
|
|
784
|
+
// `forge gate disable rail.auto_shepherd` gets a fully inert trigger — no lease,
|
|
785
|
+
// no enumeration, no daemon spawn. Cheap + fail-open, inside the dispatch try.
|
|
786
|
+
const railEnabled = ctx.railEnabled || railAutoShepherdEnabled;
|
|
787
|
+
if (!railEnabled(projectRoot)) return;
|
|
788
|
+
let gitCommonDir = ctx.gitCommonDir;
|
|
789
|
+
if (!gitCommonDir) {
|
|
790
|
+
try {
|
|
791
|
+
gitCommonDir = brokerMod.resolveGitCommonDir(projectRoot, { warn: () => {} });
|
|
792
|
+
} catch {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const acquire = ctx.acquire || shepherdLease.acquire;
|
|
797
|
+
const release = ctx.release || shepherdLease.release;
|
|
798
|
+
const launch = ctx.launch || launchDaemon;
|
|
799
|
+
const tickFn = ctx.tick || defaultTick;
|
|
800
|
+
|
|
801
|
+
let token = null;
|
|
802
|
+
const enumerate = () => {
|
|
803
|
+
// COLD path only: arbitrate the singleton. The O_EXCL acquire is the atomic
|
|
804
|
+
// arbiter — exactly one concurrent trigger wins; the rest get {ok:false}.
|
|
805
|
+
const res = acquire(projectRoot, { gitCommonDir });
|
|
806
|
+
if (res.ok) token = res.token;
|
|
807
|
+
return emptyEnum(gitCommonDir);
|
|
808
|
+
};
|
|
809
|
+
const execute = () => {
|
|
810
|
+
if (token == null) return; // loser: no daemon launch
|
|
811
|
+
// RELEASE the arbitration lease BEFORE launching so the spawned daemon can
|
|
812
|
+
// acquire it. Holding it during launch races the child's runDaemon().acquire():
|
|
813
|
+
// the child would see a fresh foreign owner and exit, and after we then release,
|
|
814
|
+
// the bumped cold-tick sentinel suppresses re-launch until the next throttle
|
|
815
|
+
// window — leaving NO daemon running.
|
|
816
|
+
const held = token;
|
|
817
|
+
token = null;
|
|
818
|
+
try { release(projectRoot, { gitCommonDir, token: held }); } catch { /* best effort */ }
|
|
819
|
+
try { launch({ ...ctx, projectRoot, gitCommonDir }); } catch { /* best effort */ }
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
tickFn({ gitCommonDir, now: ctx.now, enumerate, execute, minInterval: ctx.minInterval });
|
|
823
|
+
} catch {
|
|
824
|
+
/* NEVER affect the command result or exit code */
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
module.exports = {
|
|
829
|
+
normalizeWatcher,
|
|
830
|
+
writeClaimMarker,
|
|
831
|
+
readClaimMarker,
|
|
832
|
+
gatherDesired,
|
|
833
|
+
gatherObserved,
|
|
834
|
+
verifiedKill,
|
|
835
|
+
execute,
|
|
836
|
+
convergeOnce,
|
|
837
|
+
runDaemon,
|
|
838
|
+
launchDaemon,
|
|
839
|
+
writeDaemonDiagnostic,
|
|
840
|
+
defaultBuildBroker,
|
|
841
|
+
fireAndForget,
|
|
842
|
+
};
|