forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6
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 +18 -7
- package/CHANGELOG.md +79 -1
- package/CLAUDE.md +0 -12
- package/CODING_STANDARDS.md +72 -0
- package/README.md +6 -2
- package/bin/forge-cmd.js +20 -0
- package/bin/forge.js +28 -375
- 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 +8 -5
- package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
- package/docs/reference/INSIGHTS_RECAP.md +9 -20
- package/docs/reference/INSTALL.md +4 -0
- package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
- package/docs/reference/RELEASE.md +5 -3
- package/docs/reference/TOOLCHAIN.md +8 -0
- package/docs/reference/github-accounts.md +134 -0
- package/docs/reference/protected-state-surfaces.md +4 -4
- package/docs/reference/shepherd.md +114 -35
- package/lefthook.yml +12 -0
- package/lib/activation/ensure-forge-home.js +33 -15
- package/lib/adapters/pr-state-adapter.js +359 -144
- package/lib/audit-evidence.js +71 -110
- package/lib/base-remote.js +138 -0
- package/lib/beta5-compatibility-evidence.js +1093 -0
- package/lib/bun-lockfile-proof.js +413 -0
- package/lib/bun-workflow-pins.js +461 -0
- package/lib/capabilities/index.js +9 -0
- package/lib/capabilities/model.js +141 -0
- package/lib/capabilities/probes.js +347 -0
- package/lib/capped-jsonl-log.js +236 -0
- package/lib/codex-skills.js +2 -2
- package/lib/commands/_manifest.js +1 -0
- package/lib/commands/_registry.js +50 -20
- package/lib/commands/clean.js +252 -32
- package/lib/commands/dev.js +4 -33
- package/lib/commands/doctor.js +37 -6
- package/lib/commands/gate.js +197 -27
- package/lib/commands/github.js +215 -0
- package/lib/commands/hooks.js +276 -30
- package/lib/commands/insights.js +8 -3
- package/lib/commands/memory.js +66 -2
- package/lib/commands/merge.js +1265 -58
- package/lib/commands/plan.js +33 -2
- package/lib/commands/pr.js +3 -1
- package/lib/commands/preflight.js +21 -4
- package/lib/commands/prime.js +21 -8
- package/lib/commands/push.js +146 -54
- package/lib/commands/recall.js +127 -49
- package/lib/commands/recap.js +6 -1
- package/lib/commands/release.js +39 -3
- package/lib/commands/remember.js +28 -4
- package/lib/commands/serve.js +26 -9
- package/lib/commands/setup.js +323 -98
- package/lib/commands/shepherd.js +591 -73
- package/lib/commands/ship.js +36 -91
- package/lib/commands/skill.js +127 -11
- package/lib/commands/status.js +17 -1
- package/lib/commands/team.js +47 -8
- package/lib/commands/test.js +187 -38
- package/lib/commands/validate.js +65 -21
- package/lib/commands/worktree.js +359 -45
- package/lib/core/runtime-graph.js +1 -1
- package/lib/doc-assertions.js +297 -0
- package/lib/existing-tdd-gate.js +253 -0
- package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
- package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
- package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
- package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
- package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
- package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
- package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
- package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
- package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
- package/lib/forge-context.js +1 -4
- package/lib/forge-issues.js +134 -32
- package/lib/gate-events.js +98 -10
- package/lib/git-defaults.js +56 -0
- package/lib/github-context.js +308 -0
- package/lib/global-flags.js +1 -0
- package/lib/harness-capability-matrix.js +3 -3
- package/lib/hook-renderer.js +122 -5
- package/lib/insights.js +96 -80
- package/lib/issue-render.js +19 -0
- package/lib/kernel/backing-issue.js +14 -2
- package/lib/kernel/broker.js +739 -31
- package/lib/kernel/claim-reconciler.js +238 -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/lease-enforcer.js +9 -4
- package/lib/kernel/legacy-claim-repair.js +442 -0
- package/lib/kernel/live-claim-projection.js +26 -0
- package/lib/kernel/migrations.js +118 -3
- package/lib/kernel/readiness-model.js +184 -12
- package/lib/kernel/schema.js +49 -1
- package/lib/kernel/sqlite-driver.js +3435 -172
- package/lib/kernel/taxonomy-validator.js +4 -1
- package/lib/kernel/windows-private-acl.js +239 -0
- package/lib/lefthook-wiring.js +21 -1
- package/lib/memory/hygiene.js +191 -0
- package/lib/memory/router.js +110 -28
- package/lib/memory/usage-evidence.js +4 -0
- package/lib/memory-digest.js +106 -15
- package/lib/memory-recall-events.js +145 -0
- package/lib/memory-recall.js +71 -10
- package/lib/merge-rules.js +143 -21
- package/lib/npm-publish-workflow.js +465 -0
- package/lib/orientation.js +68 -43
- package/lib/package-root.js +2 -0
- package/lib/plugin-catalog.js +14 -4
- package/lib/pr-bundle.js +5 -6
- package/lib/pr-monitor/auto-actions.js +169 -28
- package/lib/pr-monitor/differ.js +110 -4
- package/lib/pr-monitor/events.js +0 -0
- package/lib/pr-monitor/flow-monitor.js +1424 -0
- package/lib/pr-monitor/gather.js +251 -44
- package/lib/pr-monitor/journal.js +18 -39
- package/lib/pr-monitor/monitor.js +117 -10
- package/lib/pr-monitor/process-identity.js +117 -0
- package/lib/pr-monitor/reconcile-executor.js +1129 -470
- package/lib/pr-monitor/reconcile.js +0 -0
- package/lib/pr-monitor/render-summary.js +293 -0
- package/lib/pr-monitor/review-preflight.js +269 -0
- package/lib/pr-monitor/shepherd-lease.js +38 -20
- package/lib/pr-monitor/verdict.js +438 -0
- package/lib/pr-monitor/watch-lifecycle.js +145 -27
- package/lib/pr-monitor/watch-owner.js +1414 -0
- package/lib/pr-monitor/watch.js +129 -58
- package/lib/pr-pull.js +33 -14
- package/lib/pr-shepherd.js +51 -11
- package/lib/preflight/gates.js +65 -18
- package/lib/preflight/runner.js +5 -0
- package/lib/project-memory.js +178 -4
- package/lib/protected-state-authority.js +1100 -0
- package/lib/protected-state-surfaces.js +243 -45
- package/lib/release-readiness.js +53 -7
- package/lib/review-adapter.js +65 -0
- package/lib/shell-utils.js +1 -1
- package/lib/skills-sync.js +71 -35
- package/lib/smart-merge.js +28 -4
- package/lib/symlink-utils.js +74 -26
- package/lib/upgrade-safety.js +39 -0
- package/lib/using-forge.js +19 -6
- package/lib/validation/risk-manifest.js +339 -0
- package/lib/workflow/enforce-stage.js +44 -0
- package/lib/workflow/plan-authority.js +225 -0
- package/package.json +12 -9
- package/scripts/commitlint.js +13 -15
- package/scripts/doc-asserting-tests.js +158 -0
- package/scripts/generate-risk-manifest.js +91 -0
- package/scripts/github-context-bridge.sh +10 -0
- package/scripts/legacy-claim-repair.js +145 -0
- package/scripts/lib/behavioral-eval-runner.js +310 -0
- package/scripts/lib/behavioral-eval-runtime.js +457 -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 +773 -0
- package/scripts/protected-state-check.js +479 -31
- package/scripts/run-command-eval.js +29 -1
- package/scripts/sync-agent-skills.js +333 -34
- package/scripts/sync-d20-audit.js +172 -0
- package/scripts/test-full-suite.js +935 -37
- package/scripts/test-profile.js +13 -3
- package/scripts/test.js +271 -57
- package/skills/coverage.json +1 -0
- package/skills/review/SKILL.md +6 -11
- package/skills/review/evals/scorecard.json +4 -4
- package/skills/rollback/SKILL.md +4 -11
- package/skills/rollback/evals/scorecard.json +3 -3
- package/skills/setup/SKILL.md +18 -0
- package/skills/setup/evals/scorecard.json +3 -3
- package/skills/shepherd/SKILL.md +39 -16
- package/skills/shepherd/evals/scorecard.json +4 -4
- package/skills/ship/SKILL.md +4 -12
- package/skills/ship/evals/scorecard.json +3 -3
- package/skills/validate/SKILL.md +3 -0
- package/skills/validate/evals/scorecard.json +1 -1
- package/skills/worktree/SKILL.md +6 -1
- package/skills/worktree/evals/scorecard.json +2 -2
- package/lib/beads-setup.js +0 -538
- package/lib/beads-sync-scaffold.js +0 -189
- package/lib/pat-setup.js +0 -207
- package/lib/pr-monitor/render-sticky.js +0 -206
- package/lib/pr-monitor/upsert-sticky.js +0 -169
- 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/lib/beads-migrate-to-dolt.mjs +0 -503
|
@@ -1,659 +1,1318 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 per-command
|
|
11
|
-
* `fireAndForget()` trigger wired into `bin/forge.js`.
|
|
12
|
-
*
|
|
13
|
-
* The NON-BLOCKING / ERROR-SWALLOWING contract is paramount: `fireAndForget()`
|
|
14
|
-
* MUST never throw and never affect the command that triggered it (it is called
|
|
15
|
-
* from a `finally` in the dispatch chokepoint). Every spawn is modeled on
|
|
16
|
-
* `watch-lifecycle.startPrWatcherDetached` (detached, `stdio:'ignore'`,
|
|
17
|
-
* `windowsHide:true`, `.unref()`, no-op `'error'` listener) so a failed launch
|
|
18
|
-
* degrades to "not started" rather than crashing.
|
|
19
|
-
*
|
|
20
|
-
* SAFETY INVARIANTS (guarded by tests):
|
|
21
|
-
* - Orphan reaping NEVER `process.kill`s on a PID match alone. It re-verifies at
|
|
22
|
-
* kill time: the pid must be alive AND the journal start-time marker for that
|
|
23
|
-
* PR must still exist AND equal the watcher entry's `startedAt`. A null/legacy
|
|
24
|
-
* startedAt, or an absent/mismatched marker, means "do not kill" (PID reuse
|
|
25
|
-
* fail-safe) — the stale entry is dropped silently.
|
|
26
|
-
* - The singleton is arbitrated by the O_EXCL shepherd lease: a daemon that loses
|
|
27
|
-
* `acquire` exits immediately and spawns nothing.
|
|
28
|
-
* - Watcher launch classification branches on CAPABILITY presence
|
|
29
|
-
* (`ctx.harness.hasBgShell`), NEVER on harness name; uncertain → detached.
|
|
4
|
+
* Side-effecting half of owner-row shepherd reconciliation. The filesystem
|
|
5
|
+
* lease elects one repository daemon; all per-PR lifecycle changes go through
|
|
6
|
+
* the narrow watch-owner APIs.
|
|
30
7
|
*
|
|
31
8
|
* @module pr-monitor/reconcile-executor
|
|
32
9
|
*/
|
|
33
10
|
|
|
34
11
|
const fs = require('node:fs');
|
|
35
12
|
const path = require('node:path');
|
|
13
|
+
const crypto = require('node:crypto');
|
|
36
14
|
const { spawn } = require('node:child_process');
|
|
37
15
|
|
|
38
16
|
const shepherdLease = require('./shepherd-lease');
|
|
39
|
-
const
|
|
17
|
+
const watchOwner = require('./watch-owner');
|
|
40
18
|
const { reconcile: defaultReconcile } = require('./reconcile');
|
|
41
19
|
const { tick: defaultTick } = require('./reconcile-tick');
|
|
42
|
-
const { startPrWatcherDetached,
|
|
20
|
+
const { startPrWatcherDetached, forgeArgs, githubWorkerEnvironment } = require('./watch-lifecycle');
|
|
21
|
+
const { privacySafeIdentity } = require('./flow-monitor');
|
|
22
|
+
const { processIdentityAlive, defaultPidStartedAt } = require('./process-identity');
|
|
43
23
|
const brokerMod = require('../kernel/broker');
|
|
24
|
+
const { secureExecFileSync } = require('../shell-utils');
|
|
44
25
|
|
|
45
|
-
const
|
|
26
|
+
const CANONICAL_REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
27
|
+
const GH_COMMAND_TIMEOUT_MS = 30_000;
|
|
28
|
+
const MAX_OPEN_PRS = 1000;
|
|
29
|
+
const MAX_ACTIONS_PER_PASS = 128;
|
|
30
|
+
const MAX_LEGACY_SNAPSHOT_BYTES = 4 * 1024 * 1024;
|
|
31
|
+
const MAX_MIGRATION_ATTEMPTS = 3;
|
|
32
|
+
const MAX_MONITOR_ID_LENGTH = 128;
|
|
33
|
+
const OWNER_HEARTBEAT_STALE_MS = 4 * 60_000;
|
|
46
34
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return
|
|
51
|
-
pr: entry.pr,
|
|
52
|
-
repo: entry.repo ?? null,
|
|
53
|
-
pid: entry.pid ?? null,
|
|
54
|
-
startedAt: entry.startedAt ?? null,
|
|
55
|
-
};
|
|
35
|
+
function normalizeRepository(value) {
|
|
36
|
+
if (typeof value !== 'string') return null;
|
|
37
|
+
const normalized = value.trim().toLowerCase();
|
|
38
|
+
return CANONICAL_REPOSITORY.test(normalized) ? normalized : null;
|
|
56
39
|
}
|
|
57
40
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return
|
|
41
|
+
function legacyMonitorId(repo, pr) {
|
|
42
|
+
const raw = privacySafeIdentity(`pr:${privacySafeIdentity(repo)}:${pr}`);
|
|
43
|
+
return raw.length <= MAX_MONITOR_ID_LENGTH
|
|
44
|
+
? raw
|
|
45
|
+
: `pr:${crypto.createHash('sha256').update(raw).digest('hex')}`;
|
|
61
46
|
}
|
|
62
47
|
|
|
63
|
-
|
|
64
|
-
* Write the start-time marker for `(repo, pr)` into its journal dir. This is the
|
|
65
|
-
* kill-time re-verification token — orphan reaping refuses to kill a pid unless
|
|
66
|
-
* this marker still equals the watcher entry's `startedAt`.
|
|
67
|
-
*/
|
|
68
|
-
function writeClaimMarker(projectRoot, repo, pr, startedAt) {
|
|
69
|
-
if (repo == null || pr == null || startedAt == null) return;
|
|
48
|
+
function resolveCanonicalRepository(runGh) {
|
|
70
49
|
try {
|
|
71
|
-
const
|
|
72
|
-
|
|
50
|
+
const raw = runGh(['repo', 'view', '--json', 'nameWithOwner,parent']);
|
|
51
|
+
const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
52
|
+
return normalizeRepository(value?.parent?.nameWithOwner || value?.nameWithOwner);
|
|
73
53
|
} catch {
|
|
74
|
-
|
|
54
|
+
return null;
|
|
75
55
|
}
|
|
76
56
|
}
|
|
77
57
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
58
|
+
function githubRunner(opts = {}) {
|
|
59
|
+
return opts.runGh || ((args) => secureExecFileSync('gh', args, {
|
|
60
|
+
cwd: opts.projectRoot || process.cwd(), encoding: 'utf8', timeout: GH_COMMAND_TIMEOUT_MS, windowsHide: true,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeDaemonDiagnostic(gitCommonDir, entry, opts = {}) {
|
|
65
|
+
if (!gitCommonDir) return false;
|
|
81
66
|
try {
|
|
82
|
-
const dir =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
67
|
+
const dir = path.join(gitCommonDir, 'forge');
|
|
68
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
69
|
+
fs.appendFileSync(path.join(dir, 'shepherd-daemon.ndjson'), `${JSON.stringify(entry)}\n`, {
|
|
70
|
+
encoding: 'utf8', mode: 0o600,
|
|
71
|
+
});
|
|
72
|
+
return true;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
try { opts.onDiagnosticError?.(error); } catch { /* diagnostics remain best effort */ }
|
|
75
|
+
return false;
|
|
86
76
|
}
|
|
87
77
|
}
|
|
88
78
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (repo == null || pr == null) return;
|
|
79
|
+
function recordDaemonDiagnostic(opts, gitCommonDir, kind, detail) {
|
|
80
|
+
const entry = {
|
|
81
|
+
kind,
|
|
82
|
+
at: new Date((opts.now || (() => Date.now()))()).toISOString(),
|
|
83
|
+
...(detail ? { detail: String(detail?.message || detail).slice(0, 500) } : {}),
|
|
84
|
+
};
|
|
96
85
|
try {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
} catch {
|
|
100
|
-
/* best-effort marker cleanup */
|
|
101
|
-
}
|
|
86
|
+
(opts.writeDaemonDiagnostic || writeDaemonDiagnostic)(gitCommonDir, entry, opts);
|
|
87
|
+
} catch { /* diagnostics never affect daemon lifecycle */ }
|
|
102
88
|
}
|
|
103
89
|
|
|
104
|
-
/**
|
|
105
|
-
* Gather the DESIRED open-PR set: GitHub's open PRs (`gh pr list`) enriched with
|
|
106
|
-
* kernel linkage (issue/worktree/journal) where a `kernel_pr` row already exists.
|
|
107
|
-
* A hand-opened PR with no kernel row is still included (issue_id/worktree_id
|
|
108
|
-
* null) so the reconciler self-registers it — zero user invocation. External
|
|
109
|
-
* fields (branch names) are stored raw and NEVER evaluated.
|
|
110
|
-
*/
|
|
111
90
|
async function gatherDesired(gitCommonDir, opts = {}) {
|
|
112
|
-
const runGh = opts
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// silently no-op behind the catch (this exact bug shipped once — keep it gone).
|
|
119
|
-
const broker = opts.broker || null;
|
|
120
|
-
const repo = opts.repo || defaultResolveSlug({ cwd: opts.projectRoot || process.cwd() });
|
|
121
|
-
|
|
122
|
-
let ghPrs = [];
|
|
123
|
-
// `listingOk` distinguishes "GitHub says zero open PRs" from "the gh call failed"
|
|
124
|
-
// (network/auth/rate-limit). A FAILED listing must be a no-op upstream, never a
|
|
125
|
-
// teardown of every watcher+row — the caller skips the reconcile pass when false.
|
|
126
|
-
let listingOk = true;
|
|
91
|
+
const runGh = githubRunner(opts);
|
|
92
|
+
const suppliedRepo = normalizeRepository(opts.repo);
|
|
93
|
+
const repo = suppliedRepo || resolveCanonicalRepository(runGh);
|
|
94
|
+
if (!repo) return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false };
|
|
95
|
+
|
|
96
|
+
let ghPrs;
|
|
127
97
|
try {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (Array.isArray(parsed)) ghPrs = parsed;
|
|
98
|
+
const raw = runGh(['pr', 'list', '--repo', repo, '--state', 'open', '--limit', String(MAX_OPEN_PRS + 1), '--json', 'number,headRefName,headRefOid']);
|
|
99
|
+
ghPrs = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
100
|
+
if (!Array.isArray(ghPrs)) throw new TypeError('PR listing is not an array');
|
|
101
|
+
if (ghPrs.length > MAX_OPEN_PRS) throw new RangeError('PR listing exceeds safe reconciliation bound');
|
|
133
102
|
} catch {
|
|
134
|
-
|
|
135
|
-
listingOk = false;
|
|
103
|
+
return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: true, repo };
|
|
136
104
|
}
|
|
137
105
|
|
|
138
106
|
let prRows = [];
|
|
139
107
|
try {
|
|
140
|
-
if (broker) prRows = await broker.listOpenPrs(gitCommonDir);
|
|
108
|
+
if (opts.broker) prRows = await opts.broker.listOpenPrs(gitCommonDir);
|
|
141
109
|
} catch {
|
|
142
|
-
|
|
110
|
+
return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: true, repo };
|
|
143
111
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
112
|
+
const exactRows = new Map();
|
|
113
|
+
for (const row of Array.isArray(prRows) ? prRows : []) {
|
|
114
|
+
const rowRepo = normalizeRepository(row?.repo);
|
|
115
|
+
const number = Number(row?.number);
|
|
116
|
+
if (rowRepo !== repo || !Number.isSafeInteger(number) || number <= 0) continue;
|
|
117
|
+
const current = exactRows.get(number);
|
|
118
|
+
if (current) return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false, repo };
|
|
119
|
+
exactRows.set(number, row);
|
|
120
|
+
}
|
|
121
|
+
const openPrs = ghPrs.map((item) => {
|
|
122
|
+
const number = Number(item?.number);
|
|
123
|
+
const row = exactRows.get(number);
|
|
152
124
|
return {
|
|
153
125
|
repo,
|
|
154
|
-
number
|
|
155
|
-
branch:
|
|
156
|
-
headSha:
|
|
126
|
+
number,
|
|
127
|
+
branch: item?.headRefName ?? null,
|
|
128
|
+
headSha: item?.headRefOid ?? null,
|
|
157
129
|
issueId: row?.issue_id ?? null,
|
|
158
130
|
worktreeId: row?.worktree_id ?? null,
|
|
159
131
|
journalPtr: row?.journal_ptr ?? null,
|
|
160
132
|
};
|
|
161
133
|
});
|
|
162
|
-
|
|
134
|
+
if (openPrs.some(item => !Number.isSafeInteger(item.number) || item.number <= 0)) {
|
|
135
|
+
return { openPrs: [], gitCommonDir, listingOk: false, repositoryOk: false, repo };
|
|
136
|
+
}
|
|
137
|
+
return { openPrs, gitCommonDir, listingOk: true, repositoryOk: true, repo };
|
|
163
138
|
}
|
|
164
139
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
140
|
+
function ownerOptions(ctx) {
|
|
141
|
+
const isPidAlive = ctx.ownerOptions?.isPidAlive || ctx.isAlive || shepherdLease.pidAlive;
|
|
142
|
+
// Both halves of an identity proof must describe the same process. A caller that
|
|
143
|
+
// supplies its own liveness answer (tests, cached legacy evidence) is answering for
|
|
144
|
+
// PIDs the built-in /proc probe knows nothing about, so pairing that answer with the
|
|
145
|
+
// built-in probe would compare a fabricated PID's liveness against a real process's
|
|
146
|
+
// start time and "prove" reuse. The built-in probe travels with the built-in
|
|
147
|
+
// liveness check only; otherwise identity stays unprovable.
|
|
148
|
+
const pidStartedAt = ctx.ownerOptions?.pidStartedAt || ctx.pidStartedAt
|
|
149
|
+
|| (isPidAlive === shepherdLease.pidAlive ? defaultPidStartedAt : null);
|
|
150
|
+
return {
|
|
151
|
+
...(ctx.ownerOptions || {}),
|
|
152
|
+
...(ctx.driver ? { driver: ctx.driver } : {}),
|
|
153
|
+
...(ctx.databaseConfig ? { databaseConfig: ctx.databaseConfig } : {}),
|
|
154
|
+
isPidAlive,
|
|
155
|
+
pidStartedAt,
|
|
156
|
+
verifyProviderEvidence: ctx.ownerOptions?.verifyProviderEvidence
|
|
157
|
+
|| (async (evidence, expected) => expected.states.includes(String(evidence?.state || '').toLowerCase())),
|
|
158
|
+
verifyTerminalReceipt: ctx.ownerOptions?.verifyTerminalReceipt
|
|
159
|
+
|| ctx.verifyTerminalReceipt
|
|
160
|
+
|| (async () => false),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
174
163
|
|
|
164
|
+
async function gatherObserved(gitCommonDir, _lock, opts = {}) {
|
|
175
165
|
let prRows = [];
|
|
176
166
|
try {
|
|
177
|
-
if (broker) prRows = await broker.listOpenPrs(gitCommonDir);
|
|
167
|
+
if (opts.broker) prRows = await opts.broker.listOpenPrs(gitCommonDir);
|
|
178
168
|
} catch {
|
|
179
|
-
|
|
169
|
+
return { prRows: [], ownerRows: [], ownerRowsOk: false, migrationGate: null };
|
|
180
170
|
}
|
|
171
|
+
const authority = opts.authority || watchOwner;
|
|
172
|
+
const options = ownerOptions(opts);
|
|
173
|
+
const listed = await authority.enumerateOwners({}, options);
|
|
174
|
+
const gate = typeof authority.readMigrationGate === 'function'
|
|
175
|
+
? await authority.readMigrationGate({}, options)
|
|
176
|
+
: { ok: false, reason: 'authority_unavailable' };
|
|
177
|
+
if (!listed?.ok || !Array.isArray(listed.records) || !gate?.ok || !gate.gate) {
|
|
178
|
+
return {
|
|
179
|
+
prRows: Array.isArray(prRows) ? prRows : [], ownerRows: [], ownerRowsOk: false,
|
|
180
|
+
migrationGate: gate?.gate || null,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const isAlive = options.isPidAlive;
|
|
184
|
+
const pidStartedAt = options.pidStartedAt;
|
|
185
|
+
const observedAt = Number((opts.now || (() => Date.now()))());
|
|
186
|
+
const heartbeatStaleMs = opts.ownerHeartbeatStaleMs ?? OWNER_HEARTBEAT_STALE_MS;
|
|
187
|
+
const ownerRows = [];
|
|
188
|
+
for (const record of listed.records) {
|
|
189
|
+
const next = { ...record };
|
|
190
|
+
if (record.controllerPid != null) {
|
|
191
|
+
// The controller itself wrote this row at `updatedAt`, so it was running then.
|
|
192
|
+
// A process holding the same number that booted afterwards is a different
|
|
193
|
+
// process: the controller is gone and the PID was reused. Recovery must not
|
|
194
|
+
// defer to it, and the proof travels with the row so the authority
|
|
195
|
+
// transaction can accept recovery instead of rejecting on a bare live PID.
|
|
196
|
+
const controllerState = await processIdentityAlive({
|
|
197
|
+
pid: record.controllerPid, startedAt: record.updatedAt, isPidAlive: isAlive, pidStartedAt,
|
|
198
|
+
});
|
|
199
|
+
next.controllerAlive = controllerState === 'alive';
|
|
200
|
+
if (controllerState === 'reused') next.controllerPidReuseProven = true;
|
|
201
|
+
}
|
|
202
|
+
if (record.watcherPid != null) {
|
|
203
|
+
// The watcher stamps `heartbeatAt` itself, making it the tightest honest
|
|
204
|
+
// marker for watcher identity. Blocked legacy rows never beat — the schema
|
|
205
|
+
// forbids it — so their marker is the legacy `startedAt` the import retained.
|
|
206
|
+
// Without it a legacy watcher that exited and had its PID inherited reads as
|
|
207
|
+
// alive forever, recheckLegacyBlocked never fires, and the PR stays unwatched.
|
|
208
|
+
const watcherMarker = record.phase === 'blocked' ? record.startedAt : record.heartbeatAt;
|
|
209
|
+
const watcherState = await processIdentityAlive({
|
|
210
|
+
pid: record.watcherPid, startedAt: watcherMarker, isPidAlive: isAlive, pidStartedAt,
|
|
211
|
+
});
|
|
212
|
+
if (watcherState === 'reused') next.watcherPidReuseProven = true;
|
|
213
|
+
const pidAlive = watcherState === 'alive';
|
|
214
|
+
const heartbeatRequired = record.phase === 'running'
|
|
215
|
+
|| record.phase === 'stop_requested'
|
|
216
|
+
|| record.phase === 'terminal_pending';
|
|
217
|
+
const heartbeatAt = Date.parse(record.heartbeatAt);
|
|
218
|
+
const heartbeatFresh = Number.isFinite(heartbeatAt)
|
|
219
|
+
&& Number.isFinite(observedAt)
|
|
220
|
+
&& observedAt >= heartbeatAt
|
|
221
|
+
&& observedAt - heartbeatAt <= heartbeatStaleMs;
|
|
222
|
+
next.watcherAlive = pidAlive && (!heartbeatRequired || heartbeatFresh);
|
|
223
|
+
}
|
|
224
|
+
ownerRows.push(next);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
prRows: Array.isArray(prRows) ? prRows : [], ownerRows, ownerRowsOk: true,
|
|
228
|
+
migrationGate: gate.gate,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
181
231
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// A reused PID (alive, but a mismatched/absent marker) must NOT be reported live,
|
|
186
|
-
// or reconcile would suppress the startWatcher and leave the PR unmonitored.
|
|
187
|
-
const liveWatcherPids = watchers
|
|
188
|
-
.filter((w) => {
|
|
189
|
-
if (w.pid == null || !isAlive(w.pid)) return false;
|
|
190
|
-
const marker = readClaim(w.repo, w.pr);
|
|
191
|
-
return marker != null && String(marker) === String(w.startedAt);
|
|
192
|
-
})
|
|
193
|
-
.map((w) => ({ pid: w.pid, startedAt: w.startedAt ?? null }));
|
|
194
|
-
|
|
195
|
-
const beat = lock ? Date.parse(lock.heartbeatAt) : NaN;
|
|
196
|
-
const leaseFresh = Number.isFinite(beat) && (now - beat) < STALE_MS;
|
|
232
|
+
function identity(recordOrPr) {
|
|
233
|
+
return { repo: recordOrPr.repo, pr: Number(recordOrPr.pr ?? recordOrPr.number) };
|
|
234
|
+
}
|
|
197
235
|
|
|
198
|
-
|
|
236
|
+
function operationInput(record) {
|
|
237
|
+
return { generation: record.generation, pid: record.watcherPid };
|
|
199
238
|
}
|
|
200
239
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
function verifiedKill(entry, ctx) {
|
|
206
|
-
if (!entry || entry.pid == null || entry.startedAt == null) return false;
|
|
207
|
-
const isAlive = ctx.isAlive || shepherdLease.pidAlive;
|
|
208
|
-
if (!isAlive(entry.pid)) return false;
|
|
209
|
-
const readClaim = ctx.readClaim || ((e) => readClaimMarker(ctx.projectRoot, e.repo, e.pr));
|
|
210
|
-
const claim = readClaim(entry);
|
|
211
|
-
if (claim == null || String(claim) !== String(entry.startedAt)) return false;
|
|
240
|
+
async function bindSpawned(reservation, pr, s) {
|
|
241
|
+
if (!reservation?.ok || !reservation.record) return reservation || { ok: false, reason: 'reservation_failed' };
|
|
242
|
+
const record = reservation.record;
|
|
243
|
+
let spawned;
|
|
212
244
|
try {
|
|
213
|
-
|
|
245
|
+
spawned = await s.spawnWatcher({
|
|
246
|
+
prNumber: pr.number,
|
|
247
|
+
repository: pr.repo,
|
|
248
|
+
reservation,
|
|
249
|
+
controllerPid: s.controllerPid,
|
|
250
|
+
cwd: s.projectRoot,
|
|
251
|
+
gitCommonDir: s.gitCommonDir,
|
|
252
|
+
owner: s.authority,
|
|
253
|
+
ownerOptions: s.options,
|
|
254
|
+
});
|
|
214
255
|
} catch {
|
|
215
|
-
|
|
256
|
+
spawned = null;
|
|
216
257
|
}
|
|
217
|
-
|
|
258
|
+
const pid = Number(spawned?.pid);
|
|
259
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) {
|
|
260
|
+
return s.authority.abortStarting(identity(pr), {
|
|
261
|
+
generation: record.generation, controllerPid: s.controllerPid,
|
|
262
|
+
}, s.options);
|
|
263
|
+
}
|
|
264
|
+
return s.authority.bindRunning(identity(pr), {
|
|
265
|
+
generation: record.generation, controllerPid: s.controllerPid, pid,
|
|
266
|
+
}, s.options);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Re-reads the on-disk legacy evidence for one blocked legacy row and returns the
|
|
270
|
+
// terminal receipt the legacy watcher wrote after the migration snapshot was taken.
|
|
271
|
+
// Ambiguous or unreadable evidence returns null so the caller fails closed.
|
|
272
|
+
async function recoverLegacyTerminalReceipt(owner, s) {
|
|
273
|
+
let snapshot;
|
|
274
|
+
try { snapshot = await s.readLegacySnapshot(); } catch { return null; }
|
|
275
|
+
if (!snapshot || snapshot.corrupt === true) return null;
|
|
276
|
+
const repo = normalizeRepository(owner?.repo);
|
|
277
|
+
const pr = Number(owner?.pr);
|
|
278
|
+
if (!repo || !Number.isSafeInteger(pr) || pr <= 0) return null;
|
|
279
|
+
const receipts = new Set();
|
|
280
|
+
for (const entry of Array.isArray(snapshot.entries) ? snapshot.entries : []) {
|
|
281
|
+
if (normalizeRepository(entry?.repo) !== repo || Number(entry?.pr) !== pr) continue;
|
|
282
|
+
if (entry?.terminalReceiptId) receipts.add(String(entry.terminalReceiptId));
|
|
283
|
+
}
|
|
284
|
+
return receipts.size === 1 ? [...receipts][0] : null;
|
|
218
285
|
}
|
|
219
286
|
|
|
220
|
-
/**
|
|
221
|
-
* Per-action-type handlers, keyed by `action.type`. Extracted from `execute` so
|
|
222
|
-
* each is small and independently testable and the dispatcher stays a flat loop
|
|
223
|
-
* (keeps `execute`'s cognitive complexity under the SonarCloud gate). Each handler
|
|
224
|
-
* mutates the shared `s` state (`s.watchers` is reassigned by stop/reap) and the
|
|
225
|
-
* behavior is identical to the former if/else-if chain.
|
|
226
|
-
*/
|
|
227
287
|
const ACTION_HANDLERS = {
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
// A pid-less result means the watcher is already running (ship/push/adopt started it,
|
|
234
|
-
// startPrWatcherDetached → {started:false, reason:'already-running'}) or the spawn
|
|
235
|
-
// failed. Do NOT record a {pid:null} entry: gatherObserved never counts it live, so
|
|
236
|
-
// each interval would re-emit startWatcher and append another null entry forever.
|
|
237
|
-
if (pid == null) return;
|
|
238
|
-
const entry = { pr: action.pr.number, repo, pid, startedAt };
|
|
239
|
-
s.watchers.push(entry);
|
|
240
|
-
s.writeClaim(entry);
|
|
288
|
+
async reserveWatcher(action, s) {
|
|
289
|
+
const result = await s.authority.reserveStarting(identity(action.pr), {
|
|
290
|
+
controllerPid: s.controllerPid,
|
|
291
|
+
}, s.options);
|
|
292
|
+
return bindSpawned(result, action.pr, s);
|
|
241
293
|
},
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
294
|
+
async recoverStarting(action, s) {
|
|
295
|
+
const result = await s.authority.recoverDeadStarting(identity(action.owner), {
|
|
296
|
+
generation: action.owner.generation,
|
|
297
|
+
controllerPid: action.owner.controllerPid,
|
|
298
|
+
recoveryControllerPid: s.controllerPid,
|
|
299
|
+
pidReuseProven: action.owner.controllerPidReuseProven === true,
|
|
300
|
+
}, s.options);
|
|
301
|
+
return bindSpawned(result, action.pr || { repo: action.owner.repo, number: action.owner.pr }, s);
|
|
302
|
+
},
|
|
303
|
+
async retryStarting(action, s) {
|
|
304
|
+
const released = await s.authority.abortStarting(identity(action.owner), {
|
|
305
|
+
generation: action.owner.generation, controllerPid: s.controllerPid,
|
|
306
|
+
}, s.options);
|
|
307
|
+
if (!released?.ok || released.changed !== true) {
|
|
308
|
+
return released || { ok: false, reason: 'starting_release_failed' };
|
|
246
309
|
}
|
|
247
|
-
|
|
310
|
+
const pr = action.pr || { repo: action.owner.repo, number: action.owner.pr };
|
|
311
|
+
const reservation = await s.authority.reserveStarting(identity(pr), {
|
|
312
|
+
controllerPid: s.controllerPid,
|
|
313
|
+
}, s.options);
|
|
314
|
+
return bindSpawned(reservation, pr, s);
|
|
315
|
+
},
|
|
316
|
+
async recoverWatcher(action, s) {
|
|
317
|
+
const result = await s.authority.recoverDeadWatcher(identity(action.owner), {
|
|
318
|
+
...operationInput(action.owner),
|
|
319
|
+
recoveryControllerPid: s.controllerPid,
|
|
320
|
+
pidReuseProven: action.owner.watcherPidReuseProven === true,
|
|
321
|
+
providerEvidence: { state: action.providerState },
|
|
322
|
+
}, s.options);
|
|
323
|
+
return bindSpawned(result, action.pr || { repo: action.owner.repo, number: action.owner.pr }, s);
|
|
248
324
|
},
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
325
|
+
async reopenWatcher(action, s) {
|
|
326
|
+
const result = await s.authority.reserveReopened(identity(action.owner), {
|
|
327
|
+
generation: action.owner.generation,
|
|
328
|
+
expectedReceiptId: action.owner.terminalReceiptId,
|
|
329
|
+
controllerPid: s.controllerPid,
|
|
330
|
+
providerEvidence: { state: 'open' },
|
|
331
|
+
}, s.options);
|
|
332
|
+
return bindSpawned(result, action.pr, s);
|
|
333
|
+
},
|
|
334
|
+
async requestStop(action, s) {
|
|
335
|
+
return s.authority.requestStop(identity(action.owner), operationInput(action.owner), s.options);
|
|
336
|
+
},
|
|
337
|
+
async completeTerminal(action, s) {
|
|
338
|
+
return s.authority.completeTerminal(identity(action.owner), {
|
|
339
|
+
...operationInput(action.owner),
|
|
340
|
+
pidReuseProven: action.owner.watcherPidReuseProven === true,
|
|
341
|
+
terminalReceiptId: action.owner.terminalReceiptId,
|
|
342
|
+
}, s.options);
|
|
343
|
+
},
|
|
344
|
+
async recheckLegacyBlocked(action, s) {
|
|
345
|
+
const terminal = action.providerState === 'terminal';
|
|
346
|
+
// A legacy watcher imported while its PR was still open carries no terminal
|
|
347
|
+
// receipt. If the PR later reaches a terminal state and the legacy process
|
|
348
|
+
// exits, releasing the row plainly drops the terminal receipt on the floor:
|
|
349
|
+
// the PR is already absent from the open-PR listing, so no replacement watcher
|
|
350
|
+
// will ever record it. Recover the receipt the exiting legacy watcher wrote and
|
|
351
|
+
// complete instead; fail closed (leave the row blocked for the next pass) when
|
|
352
|
+
// no single unambiguous receipt can be recovered.
|
|
353
|
+
let terminalReceiptId = terminal ? action.owner.terminalReceiptId || null : null;
|
|
354
|
+
if (terminal && !terminalReceiptId) {
|
|
355
|
+
terminalReceiptId = await recoverLegacyTerminalReceipt(action.owner, s);
|
|
356
|
+
if (!terminalReceiptId) return { ok: false, reason: 'terminal_receipt_unrecovered' };
|
|
357
|
+
}
|
|
358
|
+
const complete = terminal && Boolean(terminalReceiptId);
|
|
359
|
+
return s.authority.recheckLegacyBlocked(identity(action.owner), {
|
|
360
|
+
generation: action.owner.generation,
|
|
361
|
+
legacyEvidenceHash: action.owner.legacyEvidenceHash,
|
|
362
|
+
pid: action.owner.watcherPid,
|
|
363
|
+
pidReuseProven: action.owner.watcherPidReuseProven === true,
|
|
364
|
+
action: complete ? 'complete' : 'release',
|
|
365
|
+
...(complete ? { terminalReceiptId } : {}),
|
|
366
|
+
}, s.options);
|
|
254
367
|
},
|
|
255
368
|
async upsertPrRow(action, s) {
|
|
369
|
+
if (!s.broker) return { ok: false, reason: 'kernel_unavailable' };
|
|
256
370
|
try {
|
|
257
|
-
|
|
371
|
+
const result = await s.broker.upsertPr(action.row);
|
|
372
|
+
return result === false || result?.ok === false
|
|
373
|
+
? { ok: false, reason: 'kernel_write_failed' }
|
|
374
|
+
: { ok: true, changed: true };
|
|
258
375
|
} catch {
|
|
259
|
-
|
|
376
|
+
return { ok: false, reason: 'kernel_write_failed' };
|
|
260
377
|
}
|
|
261
378
|
},
|
|
262
379
|
async retire(action, s) {
|
|
380
|
+
if (!s.broker) return { ok: false, reason: 'kernel_unavailable' };
|
|
263
381
|
try {
|
|
264
|
-
|
|
382
|
+
await s.broker.retirePr(
|
|
265
383
|
{ git_common_dir: s.gitCommonDir, repo: action.pr.repo, number: action.pr.number },
|
|
266
384
|
{ state: 'closed', retired_at: new Date(s.now()).toISOString() },
|
|
267
385
|
);
|
|
386
|
+
return { ok: true, changed: true };
|
|
268
387
|
} catch {
|
|
269
|
-
|
|
388
|
+
return { ok: false, reason: 'kernel_write_failed' };
|
|
270
389
|
}
|
|
271
390
|
},
|
|
272
391
|
};
|
|
273
392
|
|
|
274
|
-
/**
|
|
275
|
-
* Dispatch a reconcile action set. Idempotent and order-free. Returns the updated
|
|
276
|
-
* watcher entry list (`{pr,repo,pid,startedAt}[]`) for the caller to publish via
|
|
277
|
-
* `updateWatchers`. `ctx.watchers` seeds the current set (from observed state).
|
|
278
|
-
*/
|
|
279
393
|
async function execute(actions, ctx = {}) {
|
|
394
|
+
if (!Array.isArray(actions)) {
|
|
395
|
+
const error = new TypeError('Reconcile actions must be an array');
|
|
396
|
+
error.code = 'INVALID_ACTIONS';
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
const authority = ctx.authority || watchOwner;
|
|
280
400
|
const s = {
|
|
281
|
-
|
|
401
|
+
authority,
|
|
402
|
+
options: ownerOptions(ctx),
|
|
403
|
+
controllerPid: Number.isSafeInteger(ctx.controllerPid) && ctx.controllerPid > 0 ? ctx.controllerPid : process.pid,
|
|
282
404
|
spawnWatcher: ctx.spawnWatcher || startPrWatcherDetached,
|
|
283
|
-
|
|
284
|
-
removeClaim: ctx.removeClaim || ((e) => removeClaimMarker(ctx.projectRoot, e.repo, e.pr)),
|
|
285
|
-
now: ctx.now || (() => Date.now()),
|
|
286
|
-
repo: ctx.repo,
|
|
405
|
+
broker: ctx.broker || null,
|
|
287
406
|
projectRoot: ctx.projectRoot,
|
|
288
407
|
gitCommonDir: ctx.gitCommonDir,
|
|
289
|
-
|
|
290
|
-
ctx
|
|
408
|
+
readLegacySnapshot: ctx.readLegacySnapshot || (() => defaultReadLegacySnapshot(ctx.projectRoot, ctx)),
|
|
409
|
+
now: ctx.now || (() => Date.now()),
|
|
291
410
|
};
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
411
|
+
let changed = false;
|
|
412
|
+
const results = [];
|
|
413
|
+
for (const action of actions.slice(0, MAX_ACTIONS_PER_PASS)) {
|
|
414
|
+
const handler = ACTION_HANDLERS[action?.type];
|
|
415
|
+
if (!handler) continue;
|
|
416
|
+
const result = await handler(action, s);
|
|
417
|
+
results.push({ type: action.type, result });
|
|
418
|
+
changed ||= result?.changed === true;
|
|
419
|
+
if (result?.ok === false && (action.type === 'upsertPrRow' || result.reason === 'authority_unavailable')) break;
|
|
296
420
|
}
|
|
297
|
-
return
|
|
421
|
+
return { ok: results.every(item => item.result?.ok !== false), changed, results };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function activeOwnerCount(records) {
|
|
425
|
+
return records.filter(record => record.phase !== 'complete').length;
|
|
298
426
|
}
|
|
299
427
|
|
|
300
|
-
/**
|
|
301
|
-
* One converge pass: gather → reconcile → execute → publish watchers. Used by the
|
|
302
|
-
* daemon loop and directly unit-testable with injected gather/reconcile/execute.
|
|
303
|
-
* Returns `{ actions, watchers, desiredCount }`.
|
|
304
|
-
*/
|
|
305
428
|
async function convergeOnce(projectRoot, opts = {}) {
|
|
306
429
|
const gitCommonDir = opts.gitCommonDir;
|
|
307
|
-
const reconcile = opts.reconcile || defaultReconcile;
|
|
308
|
-
const now = opts.now || (() => Date.now());
|
|
309
|
-
const lock = opts.lock !== undefined ? opts.lock : null; // daemon threads the live lock in; default null
|
|
310
|
-
|
|
311
430
|
const desired = opts.gatherDesired
|
|
312
431
|
? await opts.gatherDesired()
|
|
313
432
|
: await gatherDesired(gitCommonDir, { ...opts, projectRoot });
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
// PRs". Skipping the reconcile+execute pass entirely makes it a true no-op — no
|
|
317
|
-
// observe, no retire/stopWatcher teardown of every row+watcher. desiredCount is
|
|
318
|
-
// left non-zero (null) so the daemon does NOT read it as "no PRs → self-retire".
|
|
319
|
-
if (desired && desired.listingOk === false) {
|
|
320
|
-
const keep = (lock && Array.isArray(lock.watchers)) ? lock.watchers : (opts.watchers || []);
|
|
321
|
-
return { actions: [], watchers: keep, desiredCount: null, listingOk: false };
|
|
433
|
+
if (!desired || desired.listingOk === false || desired.repositoryOk === false) {
|
|
434
|
+
return { actions: [], desiredCount: null, authorityOk: false, activeOwnerCount: null, listingOk: false };
|
|
322
435
|
}
|
|
323
|
-
|
|
324
436
|
const observed = opts.gatherObserved
|
|
325
437
|
? await opts.gatherObserved()
|
|
326
|
-
: await gatherObserved(gitCommonDir,
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
438
|
+
: await gatherObserved(gitCommonDir, null, { ...opts, projectRoot });
|
|
439
|
+
const controllerPid = Number.isSafeInteger(opts.controllerPid) && opts.controllerPid > 0
|
|
440
|
+
? opts.controllerPid
|
|
441
|
+
: process.pid;
|
|
442
|
+
const decision = (opts.reconcile || defaultReconcile)(
|
|
443
|
+
{ ...desired, controllerPid }, observed, (opts.now || (() => Date.now()))(),
|
|
444
|
+
);
|
|
445
|
+
const actions = Array.isArray(decision?.actions) ? decision.actions : [];
|
|
446
|
+
const execution = await (opts.execute || execute)(actions, { ...opts, projectRoot, gitCommonDir });
|
|
447
|
+
|
|
448
|
+
const authority = opts.authority || watchOwner;
|
|
449
|
+
const listed = await authority.enumerateOwners({}, ownerOptions(opts));
|
|
450
|
+
const authorityOk = listed?.ok === true && Array.isArray(listed.records);
|
|
451
|
+
return {
|
|
452
|
+
actions,
|
|
453
|
+
desiredCount: desired.openPrs.length,
|
|
454
|
+
authorityOk,
|
|
455
|
+
activeOwnerCount: authorityOk ? activeOwnerCount(listed.records) : null,
|
|
456
|
+
executionOk: execution?.ok === true,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function daemonCanRetire(convergence) {
|
|
461
|
+
return convergence?.desiredCount === 0
|
|
462
|
+
&& convergence.authorityOk === true
|
|
463
|
+
&& convergence.activeOwnerCount === 0
|
|
464
|
+
&& convergence.executionOk !== false;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function stableJson(value) {
|
|
468
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
469
|
+
if (value && typeof value === 'object') {
|
|
470
|
+
return `{${Object.keys(value).sort((left, right) => left.localeCompare(right)).map(key => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
|
471
|
+
}
|
|
472
|
+
return JSON.stringify(value);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const LEGACY_HASH_ENTRY_FIELDS = [
|
|
476
|
+
'repo', 'pr', 'pid', 'startedAt', 'terminalReceiptId', 'providerState', 'legacyPhase',
|
|
477
|
+
'generation', 'controllerPid', 'blockReason',
|
|
478
|
+
'lifecycleConflict', 'legacyEvidence',
|
|
479
|
+
];
|
|
480
|
+
|
|
481
|
+
function projectLegacyHashEntry(entry) {
|
|
482
|
+
const projected = {};
|
|
483
|
+
for (const field of LEGACY_HASH_ENTRY_FIELDS) {
|
|
484
|
+
if (Object.hasOwn(entry || {}, field)) projected[field] = entry[field];
|
|
485
|
+
}
|
|
486
|
+
return projected;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function hashLegacyEntry(entry) {
|
|
490
|
+
return crypto.createHash('sha256').update(stableJson(projectLegacyHashEntry(entry))).digest('hex');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Legacy discovery labels each root by how the *invoking* checkout reached it, so the same
|
|
494
|
+
// shared marker is recorded as `.forge/pr-monitor/...` from the primary checkout and
|
|
495
|
+
// `git-common-root/.forge/pr-monitor/...` from a linked worktree. Hash a checkout-independent
|
|
496
|
+
// identity instead, or an interrupted cutover resumed from another checkout self-conflicts.
|
|
497
|
+
const LEGACY_ROOT_LABELS = [
|
|
498
|
+
'git-common-root/.forge/pr-monitor',
|
|
499
|
+
'git-common/forge/pr-monitor',
|
|
500
|
+
'.forge/pr-monitor',
|
|
501
|
+
];
|
|
502
|
+
|
|
503
|
+
function canonicalMarkerPath(rawPath) {
|
|
504
|
+
let value = String(rawPath ?? '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
505
|
+
for (const label of LEGACY_ROOT_LABELS) {
|
|
506
|
+
if (value === label || value.startsWith(`${label}/`)) {
|
|
507
|
+
value = value.slice(label.length).replace(/^\/+/, '');
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return process.platform === 'win32' ? value.toLowerCase() : value;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function canonicalMarkers(sources) {
|
|
515
|
+
const unique = new Map();
|
|
516
|
+
for (const source of sources) {
|
|
517
|
+
const marker = { path: canonicalMarkerPath(source?.path), content: source?.content };
|
|
518
|
+
unique.set(stableJson(marker), marker);
|
|
519
|
+
}
|
|
520
|
+
return [...unique.values()].sort((left, right) => stableJson(left).localeCompare(stableJson(right)));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function hashLegacySnapshot(snapshot) {
|
|
524
|
+
const canonical = snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)
|
|
525
|
+
? {
|
|
526
|
+
corrupt: snapshot.corrupt === true,
|
|
527
|
+
unmappable: snapshot.unmappable === true,
|
|
528
|
+
entries: (Array.isArray(snapshot.entries) ? snapshot.entries : [])
|
|
529
|
+
.map(projectLegacyHashEntry)
|
|
530
|
+
.sort((left, right) => stableJson(left).localeCompare(stableJson(right))),
|
|
531
|
+
markers: canonicalMarkers((Array.isArray(snapshot.sources) ? snapshot.sources : [])
|
|
532
|
+
.filter(source => /generation|cleanup/i.test(path.basename(String(source?.path || ''))))),
|
|
533
|
+
}
|
|
534
|
+
: snapshot;
|
|
535
|
+
const encoded = stableJson(canonical);
|
|
536
|
+
if (Buffer.byteLength(encoded, 'utf8') > MAX_LEGACY_SNAPSHOT_BYTES) {
|
|
537
|
+
const error = new Error('Legacy watcher snapshot exceeds the migration bound');
|
|
538
|
+
error.code = 'LEGACY_SNAPSHOT_TOO_LARGE';
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
return crypto.createHash('sha256').update(encoded).digest('hex');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const LEGACY_LIFECYCLE_FIELDS = [
|
|
545
|
+
'pid', 'startedAt', 'terminalReceiptId', 'providerState', 'legacyPhase',
|
|
546
|
+
'generation', 'controllerPid', 'blockReason',
|
|
547
|
+
];
|
|
548
|
+
|
|
549
|
+
function consolidateLegacyEntries(rawEntries) {
|
|
550
|
+
const grouped = new Map();
|
|
551
|
+
let unmappable = false;
|
|
552
|
+
for (const raw of Array.isArray(rawEntries) ? rawEntries : []) {
|
|
553
|
+
const repo = normalizeRepository(raw?.repo);
|
|
554
|
+
const pr = Number(raw?.pr);
|
|
555
|
+
if (!repo || !Number.isSafeInteger(pr) || pr <= 0) {
|
|
556
|
+
unmappable = true;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const key = `${repo}#${pr}`;
|
|
560
|
+
let group = grouped.get(key);
|
|
561
|
+
if (!group) {
|
|
562
|
+
group = { repo, pr, values: {}, evidence: new Map(), lifecycleConflict: false };
|
|
563
|
+
grouped.set(key, group);
|
|
564
|
+
}
|
|
565
|
+
const normalized = { repo, pr };
|
|
566
|
+
for (const field of LEGACY_LIFECYCLE_FIELDS) {
|
|
567
|
+
const value = raw?.[field] == null ? null : raw[field];
|
|
568
|
+
normalized[field] = value;
|
|
569
|
+
if (value == null) continue;
|
|
570
|
+
if (group.values[field] != null && stableJson(group.values[field]) !== stableJson(value)) {
|
|
571
|
+
group.lifecycleConflict = true;
|
|
572
|
+
} else {
|
|
573
|
+
group.values[field] = value;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
group.evidence.set(stableJson(normalized), normalized);
|
|
577
|
+
}
|
|
578
|
+
const entries = [...grouped.values()].map(group => ({
|
|
579
|
+
repo: group.repo,
|
|
580
|
+
pr: group.pr,
|
|
581
|
+
...group.values,
|
|
582
|
+
lifecycleConflict: group.lifecycleConflict,
|
|
583
|
+
legacyEvidence: [...group.evidence.values()].sort((left, right) => stableJson(left).localeCompare(stableJson(right))),
|
|
584
|
+
})).sort((left, right) => left.repo.localeCompare(right.repo) || left.pr - right.pr);
|
|
585
|
+
return { entries, unmappable };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const OWNER_REREAD_FIELDS = [
|
|
589
|
+
'version', 'repo', 'pr', 'generation', 'phase', 'controllerPid', 'watcherPid',
|
|
590
|
+
'startedAt', 'updatedAt', 'heartbeatAt', 'terminalReceiptId', 'blockReason', 'legacyEvidenceHash',
|
|
591
|
+
];
|
|
592
|
+
|
|
593
|
+
function ownerRereadProjection(record, fields = OWNER_REREAD_FIELDS) {
|
|
594
|
+
const projected = {};
|
|
595
|
+
for (const field of fields) {
|
|
596
|
+
if (Object.hasOwn(record || {}, field)) projected[field] = record[field];
|
|
597
|
+
}
|
|
598
|
+
return projected;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function ownerRowsMatch(expectedRows, actualRows) {
|
|
602
|
+
if (expectedRows.length !== actualRows.length) return false;
|
|
603
|
+
const actualByKey = new Map();
|
|
604
|
+
for (const row of actualRows) {
|
|
605
|
+
const key = `${row?.repo}#${row?.pr}`;
|
|
606
|
+
if (actualByKey.has(key)) return false;
|
|
607
|
+
actualByKey.set(key, row);
|
|
608
|
+
}
|
|
609
|
+
return expectedRows.every((expected) => {
|
|
610
|
+
const actual = actualByKey.get(`${expected.repo}#${expected.pr}`);
|
|
611
|
+
if (!actual) return false;
|
|
612
|
+
const fields = OWNER_REREAD_FIELDS.filter(field => Object.hasOwn(expected, field));
|
|
613
|
+
return stableJson(ownerRereadProjection(actual, fields)) === stableJson(ownerRereadProjection(expected, fields));
|
|
338
614
|
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function readOptionalFile(file) {
|
|
618
|
+
try { return fs.readFileSync(file, 'utf8'); } catch (error) {
|
|
619
|
+
if (error?.code === 'ENOENT') return null;
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
339
623
|
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
624
|
+
function defaultReadLegacySnapshot(projectRoot, opts = {}) {
|
|
625
|
+
const gitCommonDir = opts.gitCommonDir || brokerMod.resolveGitCommonDir(projectRoot);
|
|
626
|
+
const repo = normalizeRepository(opts.repo);
|
|
627
|
+
const readDirectory = opts.readDirectory || fs.readdirSync;
|
|
628
|
+
const sources = [];
|
|
629
|
+
const entries = [];
|
|
630
|
+
let unmappable = false;
|
|
631
|
+
const commonRoot = path.basename(gitCommonDir).toLowerCase() === '.git'
|
|
632
|
+
? path.dirname(gitCommonDir) : projectRoot;
|
|
633
|
+
const leaseFile = path.join(gitCommonDir, 'forge', 'shepherd.lock');
|
|
634
|
+
const leaseRaw = readOptionalFile(leaseFile);
|
|
635
|
+
if (leaseRaw != null) {
|
|
636
|
+
let lease;
|
|
637
|
+
try { lease = JSON.parse(leaseRaw); } catch { return { entries, sources, corrupt: true, unmappable: false }; }
|
|
638
|
+
const legacyWatchers = Array.isArray(lease.watchers) ? lease.watchers : [];
|
|
639
|
+
sources.push({ path: 'shepherd.lock#watchers', content: stableJson(legacyWatchers) });
|
|
640
|
+
for (const watcher of legacyWatchers) {
|
|
641
|
+
const value = typeof watcher === 'number' ? { pr: watcher } : watcher;
|
|
642
|
+
const pr = Number(value?.pr);
|
|
643
|
+
const identityRepo = normalizeRepository(value?.repo) || repo;
|
|
644
|
+
if (!identityRepo || !Number.isSafeInteger(pr) || pr <= 0) { unmappable = true; continue; }
|
|
645
|
+
entries.push({ repo: identityRepo, pr, pid: Number(value?.pid) || null, startedAt: value?.startedAt || null });
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const roots = [
|
|
649
|
+
{ dir: path.join(projectRoot, '.forge', 'pr-monitor'), label: '.forge/pr-monitor' },
|
|
650
|
+
{ dir: path.join(commonRoot, '.forge', 'pr-monitor'), label: 'git-common-root/.forge/pr-monitor' },
|
|
651
|
+
{ dir: path.join(gitCommonDir, 'forge', 'pr-monitor'), label: 'git-common/forge/pr-monitor' },
|
|
652
|
+
];
|
|
653
|
+
const seenRoots = new Set();
|
|
654
|
+
for (const candidate of roots) {
|
|
655
|
+
const resolved = path.resolve(candidate.dir).toLowerCase();
|
|
656
|
+
if (seenRoots.has(resolved)) continue;
|
|
657
|
+
seenRoots.add(resolved);
|
|
658
|
+
let directories = [];
|
|
343
659
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
660
|
+
directories = readDirectory(candidate.dir, { withFileTypes: true })
|
|
661
|
+
.filter(item => item.isDirectory()).sort((left, right) => left.name.localeCompare(right.name));
|
|
662
|
+
}
|
|
663
|
+
catch (error) { if (error?.code !== 'ENOENT') return { entries, sources, corrupt: true, unmappable }; }
|
|
664
|
+
for (const directory of directories) {
|
|
665
|
+
if (directory.name === 'owners') continue;
|
|
666
|
+
const dir = path.join(candidate.dir, directory.name);
|
|
667
|
+
const snapshotRaw = readOptionalFile(path.join(dir, 'snapshot.json'));
|
|
668
|
+
const pidRaw = readOptionalFile(path.join(dir, 'watch.pid'));
|
|
669
|
+
const startedAtRaw = readOptionalFile(path.join(dir, 'watch.startedat'));
|
|
670
|
+
// Discover generation/cleanup markers BEFORE the emptiness check. An
|
|
671
|
+
// interrupted legacy cleanup can leave a directory holding nothing but a
|
|
672
|
+
// marker; skipping it would keep that marker out of the snapshot and its
|
|
673
|
+
// hash, letting the gate complete as if no legacy lifecycle authority
|
|
674
|
+
// existed and a new owner generation start beside an unresolved legacy one.
|
|
675
|
+
let markerFiles = [];
|
|
676
|
+
try {
|
|
677
|
+
markerFiles = readDirectory(dir, { withFileTypes: true })
|
|
678
|
+
.filter(item => item.isFile() && /(?:generation|cleanup)/i.test(item.name))
|
|
679
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
680
|
+
} catch (error) {
|
|
681
|
+
if (error?.code !== 'ENOENT') return { entries, sources, corrupt: true, unmappable };
|
|
682
|
+
}
|
|
683
|
+
if (snapshotRaw == null && pidRaw == null && startedAtRaw == null && markerFiles.length === 0) continue;
|
|
684
|
+
const sourcePrefix = `${candidate.label}/${directory.name}`;
|
|
685
|
+
sources.push({ path: `${sourcePrefix}/snapshot.json`, content: snapshotRaw });
|
|
686
|
+
sources.push({ path: `${sourcePrefix}/watch.pid`, content: pidRaw });
|
|
687
|
+
sources.push({ path: `${sourcePrefix}/watch.startedat`, content: startedAtRaw });
|
|
688
|
+
for (const marker of markerFiles) {
|
|
689
|
+
sources.push({ path: `${sourcePrefix}/${marker.name}`, content: readOptionalFile(path.join(dir, marker.name)) });
|
|
690
|
+
}
|
|
691
|
+
let record;
|
|
692
|
+
try { record = snapshotRaw == null ? null : JSON.parse(snapshotRaw); }
|
|
693
|
+
catch { return { entries, sources, corrupt: true, unmappable }; }
|
|
694
|
+
const snapshot = record?.snapshot || record;
|
|
695
|
+
const terminalReceiptId = record?.terminalReceiptId || snapshot?.terminalReceiptId || null;
|
|
696
|
+
const hasLifecycleAuthority = pidRaw != null || startedAtRaw != null || markerFiles.length > 0
|
|
697
|
+
|| terminalReceiptId != null || record?.startedAt != null || snapshot?.startedAt != null;
|
|
698
|
+
const identityRepo = normalizeRepository(snapshot?.repo) || repo;
|
|
699
|
+
const pr = Number(snapshot?.pr);
|
|
700
|
+
if (!identityRepo || !Number.isSafeInteger(pr) || pr <= 0) { unmappable = true; continue; }
|
|
701
|
+
if (!hasLifecycleAuthority) continue;
|
|
702
|
+
entries.push({
|
|
703
|
+
repo: identityRepo,
|
|
704
|
+
pr,
|
|
705
|
+
pid: pidRaw == null ? null : Number(pidRaw.trim()),
|
|
706
|
+
startedAt: snapshot?.startedAt || startedAtRaw?.trim() || null,
|
|
707
|
+
terminalReceiptId,
|
|
708
|
+
providerState: snapshot?.state || null,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
const ownersRoot = path.join(candidate.dir, 'owners');
|
|
712
|
+
let ownerDirectories = [];
|
|
713
|
+
try {
|
|
714
|
+
ownerDirectories = readDirectory(ownersRoot, { withFileTypes: true })
|
|
715
|
+
.filter(item => item.isDirectory()).sort((left, right) => left.name.localeCompare(right.name));
|
|
716
|
+
}
|
|
717
|
+
catch (error) { if (error?.code !== 'ENOENT') return { entries, sources, corrupt: true, unmappable }; }
|
|
718
|
+
for (const directory of ownerDirectories) {
|
|
719
|
+
const relative = `${candidate.label}/owners/${directory.name}/watch.owner.json`;
|
|
720
|
+
const raw = readOptionalFile(path.join(ownersRoot, directory.name, 'watch.owner.json'));
|
|
721
|
+
if (raw == null) continue;
|
|
722
|
+
sources.push({ path: relative, content: raw });
|
|
723
|
+
let record;
|
|
724
|
+
try { record = JSON.parse(raw); } catch { return { entries, sources, corrupt: true, unmappable }; }
|
|
725
|
+
const identityRepo = normalizeRepository(record?.repo);
|
|
726
|
+
const pr = Number(record?.pr);
|
|
727
|
+
if (!identityRepo || !Number.isSafeInteger(pr) || pr <= 0) { unmappable = true; continue; }
|
|
728
|
+
entries.push({
|
|
729
|
+
repo: identityRepo,
|
|
730
|
+
pr,
|
|
731
|
+
pid: record.pid == null ? null : Number(record.pid),
|
|
732
|
+
startedAt: record.startedAt || null,
|
|
733
|
+
terminalReceiptId: record.terminalReceiptId || null,
|
|
734
|
+
legacyPhase: record.phase || null,
|
|
735
|
+
generation: record.generation || null,
|
|
736
|
+
controllerPid: record.controllerPid == null ? null : Number(record.controllerPid),
|
|
737
|
+
blockReason: record.blockReason || null,
|
|
738
|
+
});
|
|
352
739
|
}
|
|
353
740
|
}
|
|
354
|
-
|
|
741
|
+
entries.sort((left, right) => stableJson(left).localeCompare(stableJson(right)));
|
|
742
|
+
sources.sort((left, right) => stableJson(left).localeCompare(stableJson(right)));
|
|
743
|
+
return { entries, sources, corrupt: false, unmappable };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function defaultReadProviderState(entry, opts = {}) {
|
|
747
|
+
const runGh = githubRunner(opts);
|
|
748
|
+
try {
|
|
749
|
+
const raw = runGh(['pr', 'view', String(entry.pr), '--repo', entry.repo, '--json', 'state']);
|
|
750
|
+
const value = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
751
|
+
return typeof value?.state === 'string' ? value.state.toLowerCase() : null;
|
|
752
|
+
} catch {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
async function collectLegacyEntryEvidence(entry, options, opts, projectRoot) {
|
|
758
|
+
const ctx = identity(entry);
|
|
759
|
+
const pid = Number(entry.pid);
|
|
760
|
+
let hasPid = Number.isSafeInteger(pid) && pid > 0;
|
|
761
|
+
let pidState = false;
|
|
762
|
+
let pidReused = false;
|
|
763
|
+
// A bare `isPidAlive` hit is not proof the LEGACY watcher is alive: an unrelated
|
|
764
|
+
// long-lived process may have inherited the number. Importing that as
|
|
765
|
+
// blocked/legacy_live_pid is unrecoverable, because every later recheck sees the
|
|
766
|
+
// same live PID and the PR stays unwatched forever. Compare the process start time
|
|
767
|
+
// against the legacy start marker; a process that booted materially AFTER the
|
|
768
|
+
// marker cannot be the watcher that wrote it, so its PID evidence is discarded.
|
|
769
|
+
// An unknown start time changes nothing (fail closed on the live PID).
|
|
770
|
+
if (hasPid) {
|
|
771
|
+
let identityState;
|
|
772
|
+
try {
|
|
773
|
+
identityState = await processIdentityAlive({
|
|
774
|
+
pid,
|
|
775
|
+
startedAt: entry.startedAt,
|
|
776
|
+
isPidAlive: options.isPidAlive,
|
|
777
|
+
pidStartedAt: options.pidStartedAt,
|
|
778
|
+
});
|
|
779
|
+
} catch { identityState = 'unknown'; }
|
|
780
|
+
if (identityState === 'alive') pidState = true;
|
|
781
|
+
else if (identityState === 'unknown') pidState = null;
|
|
782
|
+
else pidState = false;
|
|
783
|
+
if (identityState === 'reused') {
|
|
784
|
+
hasPid = false;
|
|
785
|
+
pidReused = true;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
let conflictingPidUnsafe = false;
|
|
789
|
+
if (entry.lifecycleConflict) {
|
|
790
|
+
const conflictPids = [...new Set((entry.legacyEvidence || [])
|
|
791
|
+
.map(value => Number(value?.pid)).filter(value => Number.isSafeInteger(value) && value > 0))];
|
|
792
|
+
for (const conflictPid of conflictPids) {
|
|
793
|
+
try {
|
|
794
|
+
if (await options.isPidAlive(conflictPid) !== false) conflictingPidUnsafe = true;
|
|
795
|
+
} catch { conflictingPidUnsafe = true; }
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
let providerState = '';
|
|
799
|
+
let providerReadable;
|
|
800
|
+
try {
|
|
801
|
+
providerState = String(
|
|
802
|
+
await (opts.readProviderState || defaultReadProviderState)(entry, { ...opts, projectRoot }) || '',
|
|
803
|
+
).toLowerCase();
|
|
804
|
+
providerReadable = providerState.length > 0;
|
|
805
|
+
} catch { providerReadable = false; }
|
|
806
|
+
const providerTerminal = ['closed', 'merged', 'terminal'].includes(providerState);
|
|
807
|
+
let providerVerified = false;
|
|
808
|
+
if ((hasPid || pidReused) && providerState === 'open' && typeof options.verifyProviderEvidence === 'function') {
|
|
809
|
+
try {
|
|
810
|
+
providerVerified = await options.verifyProviderEvidence(
|
|
811
|
+
{ state: providerState }, { ...ctx, states: ['open'] },
|
|
812
|
+
) === true;
|
|
813
|
+
} catch { providerVerified = false; }
|
|
814
|
+
}
|
|
815
|
+
let receiptVerified = false;
|
|
816
|
+
if (entry.terminalReceiptId && providerTerminal && typeof options.verifyTerminalReceipt === 'function') {
|
|
817
|
+
try { receiptVerified = await options.verifyTerminalReceipt(entry.terminalReceiptId, ctx) === true; }
|
|
818
|
+
catch { receiptVerified = false; }
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
entry,
|
|
822
|
+
entryHash: hashLegacyEntry(entry),
|
|
823
|
+
ctx,
|
|
824
|
+
pid,
|
|
825
|
+
hasPid,
|
|
826
|
+
pidState,
|
|
827
|
+
providerState,
|
|
828
|
+
providerReadable,
|
|
829
|
+
providerTerminal,
|
|
830
|
+
providerVerified,
|
|
831
|
+
receiptVerified,
|
|
832
|
+
conflictingPidUnsafe,
|
|
833
|
+
pidReused,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function cachedLegacyEvidenceOptions(options, evidence) {
|
|
838
|
+
const sameIdentity = value => value?.repo === evidence.ctx.repo && value?.pr === evidence.ctx.pr;
|
|
839
|
+
return {
|
|
840
|
+
...options,
|
|
841
|
+
isPidAlive: async pid => (pid === evidence.pid ? evidence.pidState : null),
|
|
842
|
+
// Cached liveness has no start-time counterpart; identity was already settled
|
|
843
|
+
// while the evidence was collected.
|
|
844
|
+
pidStartedAt: null,
|
|
845
|
+
verifyProviderEvidence: async (providerEvidence, expected) => evidence.providerVerified
|
|
846
|
+
&& sameIdentity(expected)
|
|
847
|
+
&& String(providerEvidence?.state || '').toLowerCase() === evidence.providerState
|
|
848
|
+
&& Array.isArray(expected?.states)
|
|
849
|
+
&& expected.states.includes(evidence.providerState),
|
|
850
|
+
verifyTerminalReceipt: async (receipt, ownerIdentity) => evidence.receiptVerified
|
|
851
|
+
&& sameIdentity(ownerIdentity)
|
|
852
|
+
&& receipt === evidence.entry.terminalReceiptId,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function migrateLegacyAuthority(projectRoot, opts = {}) {
|
|
857
|
+
const authority = opts.authority || watchOwner;
|
|
858
|
+
const options = ownerOptions(opts);
|
|
859
|
+
const now = new Date((opts.now || (() => Date.now()))()).toISOString();
|
|
860
|
+
const readSnapshot = opts.readLegacySnapshot || (() => defaultReadLegacySnapshot(projectRoot, opts));
|
|
861
|
+
// Legacy verification calls the SYNCHRONOUS provider runner once per entry, so the
|
|
862
|
+
// heartbeat timer started by the daemon cannot fire while a read is in flight. A
|
|
863
|
+
// handful of reads near the 30s command timeout would otherwise age the lease past
|
|
864
|
+
// its 90s TTL, let a concurrent trigger reclaim it, and leave two controllers
|
|
865
|
+
// mutating the same cutover gate. Stamp the lease around every entry, and re-verify
|
|
866
|
+
// ownership by token before any mutation that commits migration results.
|
|
867
|
+
const leaseGuarded = opts.token !== undefined && opts.token !== null;
|
|
868
|
+
const stampLease = opts.stampLease || (opts.acquire ? () => true : shepherdLease.stamp);
|
|
869
|
+
const ownsLease = opts.ownsLease || (opts.acquire ? () => true : shepherdLease.owns);
|
|
870
|
+
const leaseArgs = { gitCommonDir: opts.gitCommonDir, token: opts.token };
|
|
871
|
+
const heartbeatLease = () => {
|
|
872
|
+
if (!leaseGuarded) return;
|
|
873
|
+
try { stampLease(projectRoot, leaseArgs); } catch { /* best effort — ownership is rechecked below */ }
|
|
874
|
+
};
|
|
875
|
+
const holdsLease = () => {
|
|
876
|
+
if (!leaseGuarded) return true;
|
|
877
|
+
try { return ownsLease(projectRoot, leaseArgs) === true; } catch { return false; }
|
|
878
|
+
};
|
|
879
|
+
const initialGate = await authority.readMigrationGate({}, options);
|
|
880
|
+
if (initialGate?.ok && initialGate.gate?.state === 'complete') {
|
|
881
|
+
const completedSnapshot = await readSnapshot();
|
|
882
|
+
const completedHash = hashLegacySnapshot(completedSnapshot);
|
|
883
|
+
if (completedSnapshot?.corrupt || completedHash !== initialGate.gate.snapshot_hash) {
|
|
884
|
+
return {
|
|
885
|
+
ok: true, state: 'complete', snapshotHash: initialGate.gate.snapshot_hash,
|
|
886
|
+
cleanupPending: true, reason: 'legacy_source_changed',
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
try { await opts.cleanupLegacyEvidence?.(completedSnapshot); } catch {
|
|
890
|
+
return { ok: true, state: 'complete', snapshotHash: completedHash, cleanupPending: true };
|
|
891
|
+
}
|
|
892
|
+
return { ok: true, state: 'complete', snapshotHash: completedHash };
|
|
893
|
+
}
|
|
894
|
+
if (initialGate?.ok && initialGate.gate?.state === 'conflict') {
|
|
895
|
+
return { ok: false, state: 'conflict', reason: initialGate.gate.conflict_code || 'legacy_owner_conflict' };
|
|
896
|
+
}
|
|
897
|
+
if (!initialGate?.ok && initialGate?.reason !== 'absent') {
|
|
898
|
+
return { ok: false, state: 'quarantined', reason: initialGate?.reason || 'authority_unavailable' };
|
|
899
|
+
}
|
|
900
|
+
const quarantined = initialGate?.ok
|
|
901
|
+
? initialGate
|
|
902
|
+
: await authority.publishMigrationQuarantine({ updatedAt: now }, options);
|
|
903
|
+
if (!quarantined?.ok || quarantined.gate?.state !== 'quarantined') {
|
|
904
|
+
return { ok: false, state: 'quarantined', reason: quarantined?.reason || 'authority_unavailable' };
|
|
905
|
+
}
|
|
906
|
+
const migrationStartedAt = quarantined.gate?.updated_at || now;
|
|
907
|
+
// This process is the migrating controller; it owns rows whose legacy controller
|
|
908
|
+
// PID proved reused and therefore cannot be trusted.
|
|
909
|
+
const migrationControllerPid = Number.isSafeInteger(Number(opts.controllerPid)) && Number(opts.controllerPid) > 0
|
|
910
|
+
? Number(opts.controllerPid)
|
|
911
|
+
: process.pid;
|
|
912
|
+
let snapshot;
|
|
913
|
+
let snapshotHash;
|
|
914
|
+
let verifiedEntries;
|
|
915
|
+
for (let attempt = 0; attempt < MAX_MIGRATION_ATTEMPTS; attempt += 1) {
|
|
916
|
+
const first = await readSnapshot();
|
|
917
|
+
const firstHash = hashLegacySnapshot(first);
|
|
918
|
+
const consolidated = first && !first.corrupt
|
|
919
|
+
? consolidateLegacyEntries(first.entries)
|
|
920
|
+
: null;
|
|
921
|
+
const evidence = [];
|
|
922
|
+
if (consolidated && !first.unmappable && !consolidated.unmappable) {
|
|
923
|
+
for (const entry of consolidated.entries) {
|
|
924
|
+
heartbeatLease();
|
|
925
|
+
evidence.push(await collectLegacyEntryEvidence(entry, options, opts, projectRoot));
|
|
926
|
+
heartbeatLease();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (!holdsLease()) return { ok: false, state: 'quarantined', reason: 'lease_lost' };
|
|
930
|
+
const second = await readSnapshot();
|
|
931
|
+
const secondHash = hashLegacySnapshot(second);
|
|
932
|
+
if (firstHash === secondHash) {
|
|
933
|
+
snapshot = second;
|
|
934
|
+
snapshotHash = secondHash;
|
|
935
|
+
if (consolidated && (first.unmappable || consolidated.unmappable)) {
|
|
936
|
+
await authority.publishMigrationConflict({
|
|
937
|
+
snapshotHash, conflictCode: 'legacy_identity_unmappable', updatedAt: now,
|
|
938
|
+
}, options);
|
|
939
|
+
return { ok: false, state: 'conflict', reason: 'legacy_identity_unmappable' };
|
|
940
|
+
}
|
|
941
|
+
verifiedEntries = consolidated ? evidence : null;
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
if (attempt === MAX_MIGRATION_ATTEMPTS - 1) {
|
|
945
|
+
await authority.publishMigrationConflict({
|
|
946
|
+
snapshotHash: secondHash, conflictCode: 'legacy_snapshot_changed', updatedAt: now,
|
|
947
|
+
}, options);
|
|
948
|
+
return { ok: false, state: 'conflict', reason: 'legacy_snapshot_changed' };
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (!snapshot || snapshot.corrupt) {
|
|
952
|
+
await authority.publishMigrationConflict({
|
|
953
|
+
snapshotHash, conflictCode: 'legacy_owner_conflict', updatedAt: now,
|
|
954
|
+
}, options);
|
|
955
|
+
return { ok: false, state: 'conflict', reason: 'legacy_owner_conflict' };
|
|
956
|
+
}
|
|
957
|
+
if (verifiedEntries?.some(entry => entry.conflictingPidUnsafe)) {
|
|
958
|
+
await authority.publishMigrationConflict({
|
|
959
|
+
snapshotHash, conflictCode: 'legacy_owner_conflict', updatedAt: now,
|
|
960
|
+
}, options);
|
|
961
|
+
return { ok: false, state: 'conflict', reason: 'legacy_owner_conflict' };
|
|
962
|
+
}
|
|
963
|
+
// Ownership is rechecked BEFORE every mutation, not only after: a lease reclaimed
|
|
964
|
+
// during the preceding await must not be able to bind a snapshot or write a single
|
|
965
|
+
// owner row on the way to reporting lease_lost.
|
|
966
|
+
if (!holdsLease()) return { ok: false, state: 'quarantined', reason: 'lease_lost' };
|
|
967
|
+
const bound = await authority.bindMigrationSnapshot({ snapshotHash, updatedAt: now }, options);
|
|
968
|
+
if (!bound?.ok) return { ok: false, state: 'conflict', reason: bound?.reason || 'snapshot_mismatch' };
|
|
969
|
+
if (verifiedEntries?.some(entry => !entry.providerReadable)) {
|
|
970
|
+
return { ok: false, state: 'quarantined', reason: 'legacy_provider_unreadable' };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const expectedRows = [];
|
|
974
|
+
for (const evidence of verifiedEntries || []) {
|
|
975
|
+
const {
|
|
976
|
+
entry, entryHash, ctx, pid, hasPid, pidState, providerState, providerReadable,
|
|
977
|
+
providerTerminal, providerVerified, receiptVerified, pidReused,
|
|
978
|
+
} = evidence;
|
|
979
|
+
if (!holdsLease()) return { ok: false, state: 'quarantined', reason: 'lease_lost' };
|
|
980
|
+
const operationOptions = cachedLegacyEvidenceOptions(options, evidence);
|
|
981
|
+
let result;
|
|
982
|
+
if (entry.lifecycleConflict) {
|
|
983
|
+
result = await authority.markLegacyBlocked(ctx, {
|
|
984
|
+
blockReason: 'legacy_conflict', snapshotHash, legacyEvidenceHash: entryHash,
|
|
985
|
+
startedAt: entry.startedAt || migrationStartedAt,
|
|
986
|
+
}, operationOptions);
|
|
987
|
+
} else if (pidState == null) {
|
|
988
|
+
result = await authority.markLegacyBlocked(ctx, {
|
|
989
|
+
blockReason: 'legacy_unreadable', snapshotHash, legacyEvidenceHash: entryHash,
|
|
990
|
+
startedAt: entry.startedAt || migrationStartedAt,
|
|
991
|
+
}, operationOptions);
|
|
992
|
+
} else if (pidState === true) {
|
|
993
|
+
result = await authority.markLegacyBlocked(ctx, {
|
|
994
|
+
blockReason: 'legacy_live_pid', pid, snapshotHash, legacyEvidenceHash: entryHash,
|
|
995
|
+
...(providerTerminal && receiptVerified ? { terminalReceiptId: entry.terminalReceiptId } : {}),
|
|
996
|
+
startedAt: entry.startedAt || migrationStartedAt,
|
|
997
|
+
}, operationOptions);
|
|
998
|
+
} else if (entry.terminalReceiptId && providerTerminal && receiptVerified) {
|
|
999
|
+
result = await authority.importLegacyComplete(ctx, {
|
|
1000
|
+
snapshotHash, legacyEvidenceHash: entryHash, legacyPid: hasPid ? pid : null,
|
|
1001
|
+
terminalReceiptId: entry.terminalReceiptId, startedAt: entry.startedAt || migrationStartedAt,
|
|
1002
|
+
}, operationOptions);
|
|
1003
|
+
} else if (entry.terminalReceiptId && providerTerminal) {
|
|
1004
|
+
result = await authority.markLegacyBlocked(ctx, {
|
|
1005
|
+
blockReason: 'legacy_receipt_unverified', terminalReceiptId: entry.terminalReceiptId,
|
|
1006
|
+
snapshotHash, legacyEvidenceHash: entryHash, startedAt: entry.startedAt || migrationStartedAt,
|
|
1007
|
+
}, operationOptions);
|
|
1008
|
+
} else if (!providerReadable) {
|
|
1009
|
+
result = await authority.markLegacyBlocked(ctx, {
|
|
1010
|
+
blockReason: 'legacy_unreadable', snapshotHash, legacyEvidenceHash: entryHash,
|
|
1011
|
+
startedAt: entry.startedAt || migrationStartedAt,
|
|
1012
|
+
}, operationOptions);
|
|
1013
|
+
} else if ((hasPid || pidReused) && providerState === 'open' && providerVerified
|
|
1014
|
+
&& typeof authority.importLegacyStarting === 'function') {
|
|
1015
|
+
// A proven-reused PID is proof the legacy watcher is DEAD, so an open PR must
|
|
1016
|
+
// import as a recoverable starting row rather than falling through to the
|
|
1017
|
+
// permanent blocked/legacy_lossy branch below (only legacy_live_pid blocks are
|
|
1018
|
+
// ever rechecked, and any blocked row suppresses inline passes). The reused
|
|
1019
|
+
// number must not become the controller — that would defer recovery to an
|
|
1020
|
+
// unrelated live process — so this migrating controller adopts the row.
|
|
1021
|
+
result = await authority.importLegacyStarting(ctx, {
|
|
1022
|
+
snapshotHash, legacyEvidenceHash: entryHash, legacyPid: pid,
|
|
1023
|
+
controllerPid: pidReused ? migrationControllerPid : pid,
|
|
1024
|
+
providerEvidence: { state: 'open' }, startedAt: entry.startedAt || migrationStartedAt,
|
|
1025
|
+
}, operationOptions);
|
|
1026
|
+
} else if (providerState === 'open') {
|
|
1027
|
+
result = await authority.markLegacyBlocked(ctx, {
|
|
1028
|
+
blockReason: hasPid ? 'legacy_unreadable' : 'legacy_lossy',
|
|
1029
|
+
snapshotHash, legacyEvidenceHash: entryHash, startedAt: entry.startedAt || migrationStartedAt,
|
|
1030
|
+
}, operationOptions);
|
|
1031
|
+
} else {
|
|
1032
|
+
result = await authority.markLegacyBlocked(ctx, {
|
|
1033
|
+
blockReason: providerState ? 'legacy_receipt_unverified' : 'legacy_lossy',
|
|
1034
|
+
snapshotHash, legacyEvidenceHash: entryHash, startedAt: entry.startedAt || migrationStartedAt,
|
|
1035
|
+
}, operationOptions);
|
|
1036
|
+
}
|
|
1037
|
+
// A migration that crashed mid-import leaves durable rows it already wrote. If
|
|
1038
|
+
// the PR's provider state has since drifted (an open PR that has closed), the
|
|
1039
|
+
// resumed pass legitimately selects a DIFFERENT decision and the authority
|
|
1040
|
+
// rejects it against the surviving row as `owner_conflict`. That is ordinary
|
|
1041
|
+
// drift, not two writers disagreeing: the durable row carries THIS entry's
|
|
1042
|
+
// legacy evidence hash, so it is our own prior import. Adopt it rather than
|
|
1043
|
+
// escalating to a repo-wide migration conflict that would disable every
|
|
1044
|
+
// watcher launch and inline pass. Only a row whose legacy evidence hash
|
|
1045
|
+
// differs is genuinely divergent.
|
|
1046
|
+
const priorImport = !result?.ok && result?.reason === 'owner_conflict'
|
|
1047
|
+
&& result.record && result.record.legacyEvidenceHash === entryHash
|
|
1048
|
+
? result.record
|
|
1049
|
+
: null;
|
|
1050
|
+
if (priorImport) {
|
|
1051
|
+
expectedRows.push(ownerRereadProjection(priorImport));
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
if (!result?.ok || !result.record) {
|
|
1055
|
+
await authority.publishMigrationConflict({
|
|
1056
|
+
snapshotHash, conflictCode: 'legacy_owner_conflict', updatedAt: now,
|
|
1057
|
+
}, options);
|
|
1058
|
+
return { ok: false, state: 'conflict', reason: 'legacy_owner_conflict' };
|
|
1059
|
+
}
|
|
1060
|
+
expectedRows.push(ownerRereadProjection(result.record));
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const rereadSnapshot = await readSnapshot();
|
|
1064
|
+
const rows = await authority.enumerateOwners({}, options);
|
|
1065
|
+
const gate = await authority.readMigrationGate({}, options);
|
|
1066
|
+
const exact = hashLegacySnapshot(rereadSnapshot) === snapshotHash
|
|
1067
|
+
&& rows?.ok === true
|
|
1068
|
+
&& gate?.ok === true
|
|
1069
|
+
&& gate.gate?.state === 'quarantined'
|
|
1070
|
+
&& gate.gate?.snapshot_hash === snapshotHash
|
|
1071
|
+
&& ownerRowsMatch(expectedRows, rows.records);
|
|
1072
|
+
if (!exact) return { ok: false, state: 'quarantined', reason: 'legacy_reread_mismatch' };
|
|
1073
|
+
if (!holdsLease()) return { ok: false, state: 'quarantined', reason: 'lease_lost' };
|
|
1074
|
+
const completed = await authority.completeMigrationGate({ snapshotHash, updatedAt: now }, options);
|
|
1075
|
+
if (!completed?.ok) return { ok: false, state: 'quarantined', reason: completed?.reason || 'gate_mismatch' };
|
|
1076
|
+
try { await opts.cleanupLegacyEvidence?.(snapshot); } catch {
|
|
1077
|
+
return { ok: true, state: 'complete', snapshotHash, cleanupPending: true };
|
|
1078
|
+
}
|
|
1079
|
+
return { ok: true, state: 'complete', snapshotHash };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async function defaultBuildBroker({ projectRoot, gitCommonDir }) {
|
|
1083
|
+
const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
|
|
1084
|
+
const { createMonitorStore } = require('../../packages/memory');
|
|
1085
|
+
const deps = await buildMigratedKernelIssueDeps({ projectRoot, gitCommonDir });
|
|
1086
|
+
const store = createMonitorStore(deps.kernelDriver);
|
|
1087
|
+
return {
|
|
1088
|
+
broker: deps.kernelBroker,
|
|
1089
|
+
driver: deps.kernelDriver,
|
|
1090
|
+
databaseConfig: { databasePath: deps.kernelDatabasePath },
|
|
1091
|
+
verifyTerminalReceipt: async (receiptId, ownerIdentity) => {
|
|
1092
|
+
const state = await store.readDeliveryState(legacyMonitorId(ownerIdentity.repo, ownerIdentity.pr));
|
|
1093
|
+
return state?.terminal_receipt?.object_id === receiptId;
|
|
1094
|
+
},
|
|
1095
|
+
};
|
|
355
1096
|
}
|
|
356
1097
|
|
|
357
|
-
/**
|
|
358
|
-
* The singleton daemon: acquire the lease (exit if a live foreign owner holds it),
|
|
359
|
-
* heartbeat, converge on a cadence, self-retire when no PRs remain. `opts.once`
|
|
360
|
-
* runs a single converge (for tests); otherwise an interval loop + signal handlers.
|
|
361
|
-
*/
|
|
362
1098
|
async function runDaemon(projectRoot, opts = {}) {
|
|
363
1099
|
const gitCommonDir = opts.gitCommonDir || brokerMod.resolveGitCommonDir(projectRoot);
|
|
364
1100
|
const acquire = opts.acquire || shepherdLease.acquire;
|
|
1101
|
+
const release = opts.release || shepherdLease.release;
|
|
365
1102
|
const startHeartbeat = opts.startHeartbeat || shepherdLease.startHeartbeat;
|
|
366
1103
|
const stopHeartbeat = opts.stopHeartbeat || shepherdLease.stopHeartbeat;
|
|
367
|
-
const
|
|
368
|
-
const
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
return { ok: false, reason
|
|
380
|
-
}
|
|
381
|
-
const token =
|
|
1104
|
+
const ownsLease = opts.ownsLease || (opts.acquire ? (() => true) : shepherdLease.owns);
|
|
1105
|
+
const exit = typeof opts.exit === 'function' ? opts.exit : (opts.exit === false ? () => {} : code => process.exit(code));
|
|
1106
|
+
const held = acquire(projectRoot, { gitCommonDir });
|
|
1107
|
+
if (!held.ok) {
|
|
1108
|
+
// Distinguish "someone else legitimately owns the lease" from "the lease
|
|
1109
|
+
// file is unreadable and its bytes were deliberately left in place" — the
|
|
1110
|
+
// latter needs an operator, not a retry, and migration has NOT run yet.
|
|
1111
|
+
const reason = held.legacyMigrationPending === true
|
|
1112
|
+
? (held.reason || 'legacy-lease-unreadable')
|
|
1113
|
+
: 'foreign-lease';
|
|
1114
|
+
if (reason !== 'foreign-lease') recordDaemonDiagnostic(opts, gitCommonDir, reason);
|
|
1115
|
+
exit(0);
|
|
1116
|
+
return { ok: false, reason };
|
|
1117
|
+
}
|
|
1118
|
+
const token = held.token;
|
|
382
1119
|
const heartbeat = startHeartbeat(projectRoot, { gitCommonDir, token });
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (!broker) {
|
|
392
|
-
try {
|
|
393
|
-
const built = await (opts.buildBroker || defaultBuildBroker)({ projectRoot, gitCommonDir });
|
|
394
|
-
broker = built.broker;
|
|
395
|
-
ownedDriver = built.driver;
|
|
396
|
-
} catch {
|
|
397
|
-
/* kernel genuinely unavailable → run degraded (watcher convergence only) */
|
|
398
|
-
}
|
|
1120
|
+
const ownsBroker = !opts.broker;
|
|
1121
|
+
let built = null;
|
|
1122
|
+
try {
|
|
1123
|
+
built = opts.broker
|
|
1124
|
+
? { broker: opts.broker, driver: opts.driver, databaseConfig: opts.databaseConfig }
|
|
1125
|
+
: await (opts.buildBroker || defaultBuildBroker)({ projectRoot, gitCommonDir });
|
|
1126
|
+
} catch {
|
|
1127
|
+
built = null;
|
|
399
1128
|
}
|
|
400
|
-
|
|
401
|
-
const convergeArgs = { ...opts, gitCommonDir, token, broker };
|
|
402
|
-
|
|
403
|
-
// retire() must NEVER throw: a release / stopHeartbeat / driver.close error must
|
|
404
|
-
// not leave the daemon un-exited (finding 4). Each teardown step swallows its own
|
|
405
|
-
// error so the caller's exit(0) always runs — no un-retired zombie.
|
|
406
1129
|
const retire = async () => {
|
|
407
|
-
try { release(projectRoot, { gitCommonDir, token }); } catch { /* best effort */ }
|
|
1130
|
+
try { release(projectRoot, { gitCommonDir, token }); } catch { /* token-guarded best effort */ }
|
|
408
1131
|
try { stopHeartbeat(heartbeat); } catch { /* best effort */ }
|
|
409
|
-
if (
|
|
410
|
-
try {
|
|
1132
|
+
if (ownsBroker) {
|
|
1133
|
+
try { await built?.broker?.close?.(); } catch { /* best effort */ }
|
|
411
1134
|
}
|
|
412
1135
|
};
|
|
413
|
-
|
|
1136
|
+
if (!built?.driver) {
|
|
1137
|
+
await retire();
|
|
1138
|
+
return { ok: false, reason: 'authority-unavailable' };
|
|
1139
|
+
}
|
|
1140
|
+
const runGh = githubRunner({ ...opts, projectRoot });
|
|
1141
|
+
const repo = normalizeRepository(opts.repo) || resolveCanonicalRepository(runGh);
|
|
1142
|
+
if (!repo) {
|
|
1143
|
+
await retire();
|
|
1144
|
+
return { ok: false, reason: 'repository-unavailable' };
|
|
1145
|
+
}
|
|
1146
|
+
const args = {
|
|
1147
|
+
...opts, gitCommonDir, broker: built.broker, driver: built.driver, repo, runGh,
|
|
1148
|
+
databaseConfig: built.databaseConfig,
|
|
1149
|
+
verifyTerminalReceipt: opts.verifyTerminalReceipt || built.verifyTerminalReceipt,
|
|
1150
|
+
token,
|
|
1151
|
+
};
|
|
1152
|
+
let migration;
|
|
1153
|
+
try {
|
|
1154
|
+
migration = await (opts.migrateLegacyAuthority || migrateLegacyAuthority)(projectRoot, args);
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
recordDaemonDiagnostic(opts, gitCommonDir, 'migration-failed', error);
|
|
1157
|
+
await retire();
|
|
1158
|
+
return { ok: false, reason: 'migration-failed' };
|
|
1159
|
+
}
|
|
1160
|
+
if (!migration?.ok) {
|
|
1161
|
+
recordDaemonDiagnostic(opts, gitCommonDir, 'migration-blocked', migration?.reason);
|
|
1162
|
+
await retire();
|
|
1163
|
+
return { ok: false, reason: migration?.reason || 'migration-blocked' };
|
|
1164
|
+
}
|
|
1165
|
+
const converge = opts.convergeOnce || convergeOnce;
|
|
414
1166
|
if (opts.once) {
|
|
415
|
-
const
|
|
416
|
-
if (
|
|
417
|
-
return { ok: true, token, ...
|
|
1167
|
+
const result = await converge(projectRoot, args);
|
|
1168
|
+
if (daemonCanRetire(result)) await retire();
|
|
1169
|
+
return { ok: true, token, ...result };
|
|
418
1170
|
}
|
|
419
1171
|
|
|
420
|
-
const intervalMs = opts.intervalMs || 60000;
|
|
421
1172
|
let stopped = false;
|
|
422
|
-
let inFlight = false;
|
|
423
|
-
let lastWatchers = []; // finding 2: thread the live watcher set across passes
|
|
1173
|
+
let inFlight = false;
|
|
424
1174
|
let timer = null;
|
|
425
|
-
|
|
1175
|
+
const retireForLeaseLoss = async () => {
|
|
1176
|
+
stopped = true;
|
|
1177
|
+
if (timer) clearInterval(timer);
|
|
1178
|
+
recordDaemonDiagnostic(opts, gitCommonDir, 'lease-lost');
|
|
1179
|
+
await retire();
|
|
1180
|
+
exit(0);
|
|
1181
|
+
};
|
|
1182
|
+
const stillOwns = () => {
|
|
1183
|
+
try { return ownsLease(projectRoot, { gitCommonDir, token }); } catch { return false; }
|
|
1184
|
+
};
|
|
426
1185
|
const runPass = async () => {
|
|
427
|
-
// A tick that fires while the previous pass is still in flight (converge slower
|
|
428
|
-
// than intervalMs) returns immediately, so passes never race on start/stop/reap.
|
|
429
1186
|
if (stopped || inFlight) return;
|
|
1187
|
+
if (!stillOwns()) { await retireForLeaseLoss(); return; }
|
|
430
1188
|
inFlight = true;
|
|
431
1189
|
try {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const passLock = { watchers: lastWatchers, heartbeatAt: new Date(now()).toISOString() };
|
|
436
|
-
const conv = await converge(projectRoot, { ...convergeArgs, lock: passLock });
|
|
437
|
-
if (conv && Array.isArray(conv.watchers)) lastWatchers = conv.watchers;
|
|
438
|
-
// Superseded: a newer daemon reclaimed our stale lease. Stop and exit — retire()
|
|
439
|
-
// won't touch the foreign lock (release is token-guarded), so the new owner is
|
|
440
|
-
// left intact; we just stop spawning/reaping behind it.
|
|
441
|
-
if (conv && conv.leaseLost) {
|
|
442
|
-
stopped = true;
|
|
443
|
-
if (timer) clearInterval(timer);
|
|
444
|
-
await retire();
|
|
445
|
-
exit(0);
|
|
446
|
-
} else if (conv && conv.desiredCount === 0) {
|
|
1190
|
+
const result = await converge(projectRoot, args);
|
|
1191
|
+
if (!stillOwns()) { await retireForLeaseLoss(); return; }
|
|
1192
|
+
if (daemonCanRetire(result)) {
|
|
447
1193
|
stopped = true;
|
|
448
1194
|
if (timer) clearInterval(timer);
|
|
449
1195
|
await retire();
|
|
450
1196
|
exit(0);
|
|
451
1197
|
}
|
|
452
|
-
} catch {
|
|
453
|
-
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
recordDaemonDiagnostic(opts, gitCommonDir, 'converge-failed', error);
|
|
1200
|
+
if (!stillOwns()) await retireForLeaseLoss();
|
|
454
1201
|
} finally {
|
|
455
1202
|
inFlight = false;
|
|
456
1203
|
}
|
|
457
1204
|
};
|
|
458
|
-
|
|
459
|
-
// finding 3: converge IMMEDIATELY on cold start — don't idle for up to intervalMs.
|
|
460
1205
|
await runPass();
|
|
461
1206
|
if (stopped) return { ok: true, token, retired: true };
|
|
462
|
-
|
|
463
|
-
timer = setInterval(runPass, intervalMs);
|
|
464
|
-
// The converge timer is intentionally left REF'd so it keeps the daemon process
|
|
465
|
-
// alive between passes (the heartbeat timer is unref'd inside startHeartbeat).
|
|
466
|
-
|
|
1207
|
+
timer = setInterval(runPass, opts.intervalMs || 60_000);
|
|
467
1208
|
const onSignal = async () => { await retire(); exit(0); };
|
|
468
1209
|
process.on('SIGINT', onSignal);
|
|
469
1210
|
process.on('SIGTERM', onSignal);
|
|
470
|
-
|
|
471
1211
|
return { ok: true, token, heartbeat, timer };
|
|
472
1212
|
}
|
|
473
1213
|
|
|
474
|
-
/**
|
|
475
|
-
* Launch the singleton daemon. Classify the execution home by CAPABILITY presence
|
|
476
|
-
* (`ctx.harness.hasBgShell`), NEVER by harness name; uncertain → detached spawn
|
|
477
|
-
* modeled on `startPrWatcherDetached`. Never throws.
|
|
478
|
-
*/
|
|
479
1214
|
function launchDaemon(ctx = {}) {
|
|
480
|
-
const
|
|
481
|
-
|
|
1215
|
+
const commonRoot = path.basename(ctx.gitCommonDir || '').toLowerCase() === '.git'
|
|
1216
|
+
? path.dirname(ctx.gitCommonDir) : ctx.projectRoot;
|
|
1217
|
+
let argv;
|
|
1218
|
+
let environment;
|
|
1219
|
+
try {
|
|
1220
|
+
argv = forgeArgs(['shepherd', 'daemon'], ctx);
|
|
1221
|
+
const env = githubWorkerEnvironment(commonRoot, ctx);
|
|
1222
|
+
environment = env ? { env } : {};
|
|
1223
|
+
} catch {
|
|
1224
|
+
recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed');
|
|
1225
|
+
return { launched: false };
|
|
1226
|
+
}
|
|
1227
|
+
if (ctx.harness?.hasBgShell && typeof ctx.harness.runBgShell === 'function') {
|
|
482
1228
|
try {
|
|
483
|
-
harness.runBgShell([
|
|
1229
|
+
ctx.harness.runBgShell([process.execPath, ...argv], { cwd: commonRoot, ...environment });
|
|
484
1230
|
return { launched: true, via: 'bg-shell' };
|
|
485
|
-
} catch {
|
|
486
|
-
/* fall through to the detached fail-safe */
|
|
487
|
-
}
|
|
1231
|
+
} catch { /* detached fallback */ }
|
|
488
1232
|
}
|
|
489
|
-
const spawnFn = ctx.spawnProcess || spawn;
|
|
490
1233
|
try {
|
|
491
|
-
const child =
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
);
|
|
496
|
-
|
|
497
|
-
if (child && typeof child.unref === 'function') child.unref();
|
|
498
|
-
return { launched: true, via: 'detached', pid: child && child.pid != null ? child.pid : null };
|
|
1234
|
+
const child = (ctx.spawnProcess || spawn)(process.execPath, argv, {
|
|
1235
|
+
cwd: commonRoot, detached: true, stdio: 'ignore', windowsHide: true, ...environment,
|
|
1236
|
+
});
|
|
1237
|
+
child?.on?.('error', () => recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed'));
|
|
1238
|
+
child?.unref?.();
|
|
1239
|
+
return { launched: true, via: 'detached', pid: child?.pid ?? null };
|
|
499
1240
|
} catch {
|
|
1241
|
+
recordDaemonDiagnostic(ctx, ctx.gitCommonDir, 'launch-failed');
|
|
500
1242
|
return { launched: false };
|
|
501
1243
|
}
|
|
502
1244
|
}
|
|
503
1245
|
|
|
504
|
-
/**
|
|
505
|
-
* Build a live, migrated kernel broker (+ its owned driver) for the daemon. Uses the
|
|
506
|
-
* same createLocalBroker-backed factory the CLI uses, so listOpenPrs/upsertPr/retirePr
|
|
507
|
-
* are the real instance methods. The caller closes `driver` on retire.
|
|
508
|
-
*/
|
|
509
|
-
async function defaultBuildBroker({ projectRoot, gitCommonDir }) {
|
|
510
|
-
const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
|
|
511
|
-
const deps = await buildMigratedKernelIssueDeps({ projectRoot, gitCommonDir });
|
|
512
|
-
return { broker: deps.kernelBroker, driver: deps.kernelDriver };
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/**
|
|
516
|
-
* Whether the default-ON `rail.auto_shepherd` gate permits the autonomous trigger.
|
|
517
|
-
* Reuses ship.js's `autoShepherdRailEnabled` — the SAME resolver `forge push`,
|
|
518
|
-
* `forge ship`, and `forge shepherd adopt` honor — so one `forge gate disable
|
|
519
|
-
* rail.auto_shepherd` turns the whole autonomous surface off. Lazy-required to keep
|
|
520
|
-
* the per-command trigger cheap and avoid an eager/circular load; FAIL-OPEN (returns
|
|
521
|
-
* enabled) if the resolver can't be read, and never throws.
|
|
522
|
-
*/
|
|
523
1246
|
function railAutoShepherdEnabled(projectRoot) {
|
|
524
|
-
try {
|
|
525
|
-
|
|
526
|
-
} catch {
|
|
527
|
-
return true; // config unreadable → fail open (default-ON), never block the trigger's own path
|
|
528
|
-
}
|
|
1247
|
+
try { return require('../commands/ship').autoShepherdRailEnabled(projectRoot); }
|
|
1248
|
+
catch { return true; }
|
|
529
1249
|
}
|
|
530
1250
|
|
|
531
|
-
/**
|
|
532
|
-
* True iff a kernel DB already exists for `projectRoot` — the SAME no-lazy-create
|
|
533
|
-
* invariant `forge prime` honors (orientation.hasExistingKernelDb). The trigger must
|
|
534
|
-
* CREATE NOTHING in an uninitialized or setup/init TARGET repo (else `setup --dry-run`
|
|
535
|
-
* and `init` would sprout a shepherd.lock and pollute output). SILENT: a no-op `warn`
|
|
536
|
-
* suppresses resolveGitCommonDir's fallback message on a non-git dir. Never throws.
|
|
537
|
-
*/
|
|
538
1251
|
function kernelInitialized(projectRoot) {
|
|
539
1252
|
try {
|
|
540
|
-
// Resolve the git-common-dir with a FAST, SUBPROCESS-FREE read — NEVER `git
|
|
541
|
-
// rev-parse` (its 30s timeout would block every registry command on the dispatch
|
|
542
|
-
// finally, and a git subprocess pollutes bare-repo command tests). Common checkout:
|
|
543
|
-
// <root>/.git is a dir. Linked worktree: <root>/.git is a file `gitdir: …/worktrees/x`
|
|
544
|
-
// whose common dir is the part before `/worktrees/`.
|
|
545
1253
|
const gitPath = path.join(projectRoot, '.git');
|
|
546
|
-
const
|
|
547
|
-
let commonDir;
|
|
548
|
-
if (
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
const
|
|
552
|
-
if (!m) return false;
|
|
553
|
-
const wtGitDir = path.resolve(projectRoot, m[1].trim());
|
|
1254
|
+
const stat = fs.statSync(gitPath);
|
|
1255
|
+
let commonDir = gitPath;
|
|
1256
|
+
if (!stat.isDirectory()) {
|
|
1257
|
+
const match = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitPath, 'utf8'));
|
|
1258
|
+
if (!match) return false;
|
|
1259
|
+
const worktreeGitDir = path.resolve(projectRoot, match[1].trim());
|
|
554
1260
|
const marker = `${path.sep}worktrees${path.sep}`;
|
|
555
|
-
const
|
|
556
|
-
commonDir =
|
|
1261
|
+
const index = worktreeGitDir.lastIndexOf(marker);
|
|
1262
|
+
commonDir = index >= 0 ? worktreeGitDir.slice(0, index) : worktreeGitDir;
|
|
557
1263
|
}
|
|
558
1264
|
return fs.existsSync(path.join(commonDir, 'forge', 'kernel.sqlite'));
|
|
559
|
-
} catch {
|
|
560
|
-
return false;
|
|
561
|
-
}
|
|
1265
|
+
} catch { return false; }
|
|
562
1266
|
}
|
|
563
1267
|
|
|
564
|
-
/** Empty enumeration for a cold-tick loser that lost the lease race (backs off). */
|
|
565
|
-
function emptyEnum(gitCommonDir) {
|
|
566
|
-
return {
|
|
567
|
-
desired: { openPrs: [], gitCommonDir },
|
|
568
|
-
observed: { lease: null, leaseFresh: false, prRows: [], liveWatcherPids: [] },
|
|
569
|
-
};
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/**
|
|
573
|
-
* The per-command / session-start trigger. Runs the `tick()` debounce; the hot
|
|
574
|
-
* path (a fresh daemon lease) short-circuits in-process with a single lock read
|
|
575
|
-
* and no spawn. Only on the cold (G3) path does it ARBITRATE via the O_EXCL lease:
|
|
576
|
-
* the acquire-winner launches the singleton daemon (which does the real
|
|
577
|
-
* `gh pr list` enumeration + converge), and a loser backs off — no spawn. The
|
|
578
|
-
* arbitration lease is released immediately after launch so the spawned daemon can
|
|
579
|
-
* take sole ownership; the daemon's own `acquire` is the final singleton authority,
|
|
580
|
-
* so even a race that double-launches still yields exactly one live daemon.
|
|
581
|
-
*
|
|
582
|
-
* The gh enumeration deliberately lives in the DAEMON, not here, so this trigger
|
|
583
|
-
* NEVER runs a blocking subprocess on the command's critical path.
|
|
584
|
-
*
|
|
585
|
-
* CONTRACT: never throws, never blocks (no await), never affects the command.
|
|
586
|
-
*/
|
|
587
1268
|
function fireAndForget(ctx = {}) {
|
|
588
1269
|
try {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
if (!projectRoot) return;
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
// Both guards run BEFORE any git/lock touch so `setup --dry-run` / `init` stay
|
|
597
|
-
// side-effect- AND output-clean (kernelInitialized is silent). Checked here, not the
|
|
598
|
-
// caller, so every trigger site (dispatch, session-start) is covered uniformly.
|
|
599
|
-
if (ctx.dryRun) return;
|
|
600
|
-
if (!(ctx.kernelInitialized || kernelInitialized)(projectRoot)) return;
|
|
601
|
-
// Config kill-switch (same gate ship/push/adopt honor): a maintainer who ran
|
|
602
|
-
// `forge gate disable rail.auto_shepherd` gets a fully inert trigger — no lease,
|
|
603
|
-
// no enumeration, no daemon spawn. Cheap + fail-open, inside the dispatch try.
|
|
604
|
-
const railEnabled = ctx.railEnabled || railAutoShepherdEnabled;
|
|
605
|
-
if (!railEnabled(projectRoot)) return;
|
|
606
|
-
let gitCommonDir = ctx.gitCommonDir;
|
|
607
|
-
if (!gitCommonDir) {
|
|
608
|
-
try {
|
|
609
|
-
gitCommonDir = brokerMod.resolveGitCommonDir(projectRoot, { warn: () => {} });
|
|
610
|
-
} catch {
|
|
611
|
-
return;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
1270
|
+
const env = ctx.env || process.env;
|
|
1271
|
+
if (env.FORGE_SHEPHERD_DISABLE || env.NODE_ENV === 'test' || env.BUN_ENV === 'test'
|
|
1272
|
+
|| env.CI || env.GITHUB_ACTIONS || env.GITLAB_CI || ctx.dryRun || !ctx.projectRoot) return;
|
|
1273
|
+
if (!(ctx.kernelInitialized || kernelInitialized)(ctx.projectRoot)) return;
|
|
1274
|
+
if (!(ctx.railEnabled || railAutoShepherdEnabled)(ctx.projectRoot)) return;
|
|
1275
|
+
const gitCommonDir = ctx.gitCommonDir
|
|
1276
|
+
|| brokerMod.resolveGitCommonDir(ctx.projectRoot, { warn: () => {} });
|
|
614
1277
|
const acquire = ctx.acquire || shepherdLease.acquire;
|
|
615
1278
|
const release = ctx.release || shepherdLease.release;
|
|
616
|
-
const launch = ctx.launch || launchDaemon;
|
|
617
|
-
const tickFn = ctx.tick || defaultTick;
|
|
618
|
-
|
|
619
1279
|
let token = null;
|
|
1280
|
+
let legacyMigrationPending = false;
|
|
620
1281
|
const enumerate = () => {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
return emptyEnum(gitCommonDir);
|
|
1282
|
+
const result = acquire(ctx.projectRoot, { gitCommonDir, preserveLegacy: true });
|
|
1283
|
+
if (result.ok) token = result.token;
|
|
1284
|
+
legacyMigrationPending = result.legacyMigrationPending === true;
|
|
1285
|
+
return { desired: { openPrs: [], gitCommonDir }, observed: { ownerRows: [], ownerRowsOk: false, prRows: [] } };
|
|
626
1286
|
};
|
|
627
|
-
const
|
|
628
|
-
if (token == null) return;
|
|
629
|
-
// RELEASE the arbitration lease BEFORE launching so the spawned daemon can
|
|
630
|
-
// acquire it. Holding it during launch races the child's runDaemon().acquire():
|
|
631
|
-
// the child would see a fresh foreign owner and exit, and after we then release,
|
|
632
|
-
// the bumped cold-tick sentinel suppresses re-launch until the next throttle
|
|
633
|
-
// window — leaving NO daemon running.
|
|
1287
|
+
const executeTick = () => {
|
|
1288
|
+
if (token == null && !legacyMigrationPending) return;
|
|
634
1289
|
const held = token;
|
|
635
1290
|
token = null;
|
|
636
|
-
|
|
637
|
-
|
|
1291
|
+
legacyMigrationPending = false;
|
|
1292
|
+
if (held != null) {
|
|
1293
|
+
try { release(ctx.projectRoot, { gitCommonDir, token: held }); } catch { /* best effort */ }
|
|
1294
|
+
}
|
|
1295
|
+
try { (ctx.launch || launchDaemon)({ ...ctx, gitCommonDir }); } catch { /* best effort */ }
|
|
638
1296
|
};
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
1297
|
+
(ctx.tick || defaultTick)({
|
|
1298
|
+
gitCommonDir, now: ctx.now, enumerate, execute: executeTick, minInterval: ctx.minInterval,
|
|
1299
|
+
});
|
|
1300
|
+
} catch { /* never affect triggering command */ }
|
|
644
1301
|
}
|
|
645
1302
|
|
|
646
1303
|
module.exports = {
|
|
647
|
-
|
|
648
|
-
writeClaimMarker,
|
|
649
|
-
readClaimMarker,
|
|
1304
|
+
normalizeRepository,
|
|
650
1305
|
gatherDesired,
|
|
651
1306
|
gatherObserved,
|
|
652
|
-
verifiedKill,
|
|
653
1307
|
execute,
|
|
654
1308
|
convergeOnce,
|
|
655
1309
|
runDaemon,
|
|
656
1310
|
launchDaemon,
|
|
1311
|
+
writeDaemonDiagnostic,
|
|
657
1312
|
defaultBuildBroker,
|
|
1313
|
+
migrateLegacyAuthority,
|
|
1314
|
+
defaultReadLegacySnapshot,
|
|
1315
|
+
defaultReadProviderState,
|
|
1316
|
+
hashLegacySnapshot,
|
|
658
1317
|
fireAndForget,
|
|
659
1318
|
};
|