merge-steward 0.34.0 → 0.34.2
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/README.md +2 -0
- package/dist/cli/commands/queue.js +1 -1
- package/dist/cli/commands/service.js +1 -1
- package/dist/github/pr-client.js +1 -2
- package/dist/graceful-shutdown.d.ts +2 -1
- package/dist/graceful-shutdown.js +23 -2
- package/dist/runtime-format.js +1 -1
- package/dist/server.js +7 -3
- package/dist/service-queue.d.ts +1 -5
- package/dist/service-queue.js +3 -14
- package/dist/service-runtime.d.ts +4 -2
- package/dist/service-runtime.js +25 -6
- package/dist/service.d.ts +1 -1
- package/dist/service.js +2 -2
- package/dist/types.d.ts +1 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,8 @@ merge-steward queue reconcile --repo <id> # force one reconcile tick
|
|
|
66
66
|
merge-steward service logs --lines 100
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
Each repository reconcile tick is bounded by `reconcileStaleAfterMs` (five minutes by default). If a tick exceeds that threshold, Merge Steward records the failed runtime state, performs bounded service cleanup, and exits unsuccessfully so its `Restart=always` systemd unit restarts from the durable queue. It never starts a second reconciler beside the stuck tick.
|
|
70
|
+
|
|
69
71
|
`pr status`, `queue status`, `queue show`, and `queue reconcile` auto-resolve `--repo` and `--pr` from the current git checkout. `pr status` supports `--wait --timeout <s> --poll <s>` for blocking until a terminal state. Exit codes:
|
|
70
72
|
|
|
71
73
|
| Code | Meaning |
|
|
@@ -253,7 +253,7 @@ export function formatReconcileRequestText(result) {
|
|
|
253
253
|
? `; latest action ${result.runtime.lastReconcileEvent.action} PR #${result.runtime.lastReconcileEvent.prNumber}`
|
|
254
254
|
: "";
|
|
255
255
|
const guidance = result.runtime.staleTick
|
|
256
|
-
? "
|
|
256
|
+
? "the reconcile watchdog is restarting the service"
|
|
257
257
|
: "wait for the current tick before restarting";
|
|
258
258
|
return `Reconcile already running for ${age}${latest}; ${guidance}.`;
|
|
259
259
|
}
|
|
@@ -102,6 +102,6 @@ function staleRuntimeLines(repos) {
|
|
|
102
102
|
.map((repo) => {
|
|
103
103
|
const event = repo.runtime?.lastReconcileEvent;
|
|
104
104
|
const latest = event ? `; latest action ${event.action} PR #${event.prNumber}` : "";
|
|
105
|
-
return `Warning: ${repo.repoId} reconcile
|
|
105
|
+
return `Warning: ${repo.repoId} reconcile watchdog is restarting the service after ${formatDurationMs(repo.runtime?.tickAgeMs)}${latest}`;
|
|
106
106
|
});
|
|
107
107
|
}
|
package/dist/github/pr-client.js
CHANGED
|
@@ -25,8 +25,7 @@ export class GitHubPRClient {
|
|
|
25
25
|
const result = await exec("gh", [
|
|
26
26
|
"pr", "view", String(prNumber),
|
|
27
27
|
"--repo", this.repoFullName,
|
|
28
|
-
//
|
|
29
|
-
// detect stacked PRs at admission time.
|
|
28
|
+
// The base ref lets admission detect stacked PRs.
|
|
30
29
|
"--json", "number,title,headRefName,headRefOid,baseRefName,reviewDecision,state,mergeStateStatus",
|
|
31
30
|
], { githubRepoFullName: this.repoFullName });
|
|
32
31
|
const data = JSON.parse(result.stdout);
|
|
@@ -8,4 +8,5 @@ export declare function createGracefulShutdown(options: {
|
|
|
8
8
|
logger: ShutdownLogger;
|
|
9
9
|
cleanup: () => Promise<void>;
|
|
10
10
|
terminate?: (code: number) => void;
|
|
11
|
-
|
|
11
|
+
forceTerminateAfterMs?: number;
|
|
12
|
+
}): (trigger: string, exitCode?: number) => Promise<void>;
|
|
@@ -1,28 +1,49 @@
|
|
|
1
1
|
export function createGracefulShutdown(options) {
|
|
2
2
|
let shutdownPromise;
|
|
3
3
|
let firstTrigger;
|
|
4
|
+
let terminated = false;
|
|
4
5
|
const terminate = options.terminate ?? ((code) => {
|
|
5
6
|
process.exitCode = code;
|
|
6
7
|
setImmediate(() => process.exit(code));
|
|
7
8
|
});
|
|
8
|
-
|
|
9
|
+
const terminateOnce = (code) => {
|
|
10
|
+
if (terminated)
|
|
11
|
+
return;
|
|
12
|
+
terminated = true;
|
|
13
|
+
terminate(code);
|
|
14
|
+
};
|
|
15
|
+
return (trigger, exitCode) => {
|
|
9
16
|
if (shutdownPromise) {
|
|
10
17
|
options.logger.warn({ service: options.service, trigger, firstTrigger }, "Shutdown already in progress");
|
|
11
18
|
return shutdownPromise;
|
|
12
19
|
}
|
|
13
20
|
firstTrigger = trigger;
|
|
14
21
|
options.logger.info({ service: options.service, trigger }, "Shutdown requested");
|
|
22
|
+
const forcedTermination = exitCode !== undefined && options.forceTerminateAfterMs !== undefined
|
|
23
|
+
? setTimeout(() => {
|
|
24
|
+
options.logger.error({ service: options.service, trigger, forceTerminateAfterMs: options.forceTerminateAfterMs }, "Shutdown deadline exceeded; terminating");
|
|
25
|
+
terminateOnce(exitCode);
|
|
26
|
+
}, options.forceTerminateAfterMs)
|
|
27
|
+
: undefined;
|
|
28
|
+
forcedTermination?.unref?.();
|
|
15
29
|
shutdownPromise = options.cleanup()
|
|
16
30
|
.then(() => {
|
|
31
|
+
if (forcedTermination)
|
|
32
|
+
clearTimeout(forcedTermination);
|
|
17
33
|
options.logger.info({ service: options.service, trigger }, "Shutdown complete");
|
|
34
|
+
if (exitCode !== undefined) {
|
|
35
|
+
terminateOnce(exitCode);
|
|
36
|
+
}
|
|
18
37
|
})
|
|
19
38
|
.catch((error) => {
|
|
39
|
+
if (forcedTermination)
|
|
40
|
+
clearTimeout(forcedTermination);
|
|
20
41
|
options.logger.error({
|
|
21
42
|
service: options.service,
|
|
22
43
|
trigger,
|
|
23
44
|
error: error instanceof Error ? error.message : String(error),
|
|
24
45
|
}, "Shutdown failed");
|
|
25
|
-
|
|
46
|
+
terminateOnce(1);
|
|
26
47
|
});
|
|
27
48
|
return shutdownPromise;
|
|
28
49
|
};
|
package/dist/runtime-format.js
CHANGED
|
@@ -29,7 +29,7 @@ export function formatRuntimeActivity(runtime) {
|
|
|
29
29
|
if (latest)
|
|
30
30
|
lines.push(`Latest action: ${latest}`);
|
|
31
31
|
if (runtime.staleTick) {
|
|
32
|
-
lines.push(`Warning: reconcile
|
|
32
|
+
lines.push(`Warning: reconcile watchdog is restarting the service after threshold ${formatDurationMs(runtime.staleTickThresholdMs)}.`);
|
|
33
33
|
}
|
|
34
34
|
return lines;
|
|
35
35
|
}
|
package/dist/server.js
CHANGED
|
@@ -17,7 +17,7 @@ import { discoverRepoSettings } from "./github-repo-discovery.js";
|
|
|
17
17
|
import { resolveSecretWithSource } from "./resolve-secret.js";
|
|
18
18
|
import { setRuntimeGitHubAuthProvider } from "./exec.js";
|
|
19
19
|
import { readFileSync, existsSync } from "node:fs";
|
|
20
|
-
async function createRepoInstance(config, policy, logger, botIdentity) {
|
|
20
|
+
async function createRepoInstance(config, policy, logger, botIdentity, onReconcileWatchdog) {
|
|
21
21
|
const repoUrl = `https://github.com/${config.repoFullName}.git`;
|
|
22
22
|
const clone = new CloneManager(config.clonePath, repoUrl, config.repoFullName, config.gitBin, logger);
|
|
23
23
|
await clone.ensureClone();
|
|
@@ -31,7 +31,7 @@ async function createRepoInstance(config, policy, logger, botIdentity) {
|
|
|
31
31
|
const ci = new GitHubActionsRunner(config.repoFullName, () => policy.getRequiredCheckRules(), () => policy.shouldRequireAllChecksOnEmptyRequiredSet());
|
|
32
32
|
const github = new GitHubPRClient(config.repoFullName);
|
|
33
33
|
const eviction = new GitHubCheckRunReporter(config.repoFullName, config.server.bind, config.server.port, config.server.publicBaseUrl, config.admissionLabel, config.mergeQueueCheckName);
|
|
34
|
-
const service = new MergeStewardService(config, policy, store, git, ci, github, eviction, git, logger);
|
|
34
|
+
const service = new MergeStewardService(config, policy, store, git, ci, github, eviction, git, logger, onReconcileWatchdog);
|
|
35
35
|
return { config, service, store };
|
|
36
36
|
}
|
|
37
37
|
function githubHeaders(token) {
|
|
@@ -211,6 +211,7 @@ export async function startMultiServer() {
|
|
|
211
211
|
}
|
|
212
212
|
await app.close();
|
|
213
213
|
},
|
|
214
|
+
forceTerminateAfterMs: 15_000,
|
|
214
215
|
});
|
|
215
216
|
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
216
217
|
process.once("SIGINT", () => void shutdown("SIGINT"));
|
|
@@ -258,7 +259,10 @@ export async function startMultiServer() {
|
|
|
258
259
|
githubRequiredChecks: policy.getRequiredChecks(),
|
|
259
260
|
requireAllChecksOnEmptyRequiredSet: policy.shouldRequireAllChecksOnEmptyRequiredSet(),
|
|
260
261
|
}, "Resolved GitHub protection requirements");
|
|
261
|
-
const instance = await createRepoInstance(config, policy, logger.child({ repoId: config.repoId }), botIdentity)
|
|
262
|
+
const instance = await createRepoInstance(config, policy, logger.child({ repoId: config.repoId }), botIdentity, async (runtime) => {
|
|
263
|
+
logger.error({ repoId: config.repoId, runtime }, "Reconcile watchdog is restarting merge-steward");
|
|
264
|
+
await shutdown(`reconcile_watchdog:${config.repoId}`, 1);
|
|
265
|
+
});
|
|
262
266
|
if (shuttingDown) {
|
|
263
267
|
instance.store.close();
|
|
264
268
|
return;
|
package/dist/service-queue.d.ts
CHANGED
|
@@ -19,11 +19,7 @@ export declare class MergeStewardQueueCommands {
|
|
|
19
19
|
issueKey?: string;
|
|
20
20
|
priority?: number;
|
|
21
21
|
prTitle?: string;
|
|
22
|
-
/**
|
|
23
|
-
* Plan §8.4: PR's base ref. When this matches another open PR's
|
|
24
|
-
* `branch` (head ref), the entry is stacked and admission orders
|
|
25
|
-
* it immediately behind the parent.
|
|
26
|
-
*/
|
|
22
|
+
/** A matching parent head branch makes this a stacked queue entry. */
|
|
27
23
|
baseRefName?: string;
|
|
28
24
|
}): QueueEntry | undefined;
|
|
29
25
|
scanStartupAdmissions(): Promise<void>;
|
package/dist/service-queue.js
CHANGED
|
@@ -189,17 +189,8 @@ export class MergeStewardQueueCommands {
|
|
|
189
189
|
return false;
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
// stacked. Enqueue it only when the parent is already in the
|
|
195
|
-
// queue — otherwise the child's spec would build against the
|
|
196
|
-
// unmerged parent's pre-spec content. Positions are monotonic
|
|
197
|
-
// (`nextPosition`), so the child's position is always greater
|
|
198
|
-
// than the parent's; sibling PRs admitted between the parent's
|
|
199
|
-
// enqueue and the child's tryAdmit can sit between them. That's
|
|
200
|
-
// fine: queue head selection orders by (priority, position), so
|
|
201
|
-
// the parent is still processed before the child. The functional
|
|
202
|
-
// guarantee is "parent before child", not strict adjacency.
|
|
192
|
+
// A stacked PR waits for its parent queue entry. Monotonic positions
|
|
193
|
+
// guarantee parent-before-child ordering, not strict adjacency.
|
|
203
194
|
const baseRefName = status.baseRefName ?? null;
|
|
204
195
|
if (baseRefName && baseRefName !== this.config.baseBranch) {
|
|
205
196
|
const parentEntry = this.findActiveEntryByBranch(baseRefName);
|
|
@@ -330,9 +321,7 @@ export class MergeStewardQueueCommands {
|
|
|
330
321
|
}
|
|
331
322
|
return next;
|
|
332
323
|
}
|
|
333
|
-
//
|
|
334
|
-
// the active queue entry whose `branch` (head ref) matches `name`,
|
|
335
|
-
// or undefined when no such entry is in the queue.
|
|
324
|
+
// Find the active entry whose head branch matches `name`.
|
|
336
325
|
findActiveEntryByBranch(name) {
|
|
337
326
|
return this.store.listActive(this.config.repoId).find((entry) => entry.branch === name);
|
|
338
327
|
}
|
|
@@ -15,6 +15,7 @@ export declare class MergeStewardRuntime {
|
|
|
15
15
|
private readonly specBuilder;
|
|
16
16
|
private readonly logger;
|
|
17
17
|
private readonly beforeTick?;
|
|
18
|
+
private readonly onReconcileWatchdog?;
|
|
18
19
|
private tickTimer;
|
|
19
20
|
private staleTickTimer;
|
|
20
21
|
private tickInProgress;
|
|
@@ -23,7 +24,8 @@ export declare class MergeStewardRuntime {
|
|
|
23
24
|
private lastTickOutcome;
|
|
24
25
|
private lastTickError;
|
|
25
26
|
private lastReconcileEvent;
|
|
26
|
-
|
|
27
|
+
private watchdogTickStartedAt;
|
|
28
|
+
constructor(config: StewardConfig, policy: GitHubPolicyCache, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: SpeculativeBranchBuilder, logger: Logger, beforeTick?: (() => Promise<void>) | undefined, onReconcileWatchdog?: ((runtime: QueueRuntimeStatus) => Promise<void> | void) | undefined);
|
|
27
29
|
start(): Promise<void>;
|
|
28
30
|
stop(): Promise<void>;
|
|
29
31
|
triggerReconcile(): Promise<QueueReconcileResult>;
|
|
@@ -32,7 +34,7 @@ export declare class MergeStewardRuntime {
|
|
|
32
34
|
private recoverTerminalQueueLabels;
|
|
33
35
|
private scheduleNextTick;
|
|
34
36
|
private getTickAgeMs;
|
|
35
|
-
private
|
|
37
|
+
private scheduleReconcileWatchdog;
|
|
36
38
|
private clearStaleTickTimer;
|
|
37
39
|
private runTick;
|
|
38
40
|
}
|
package/dist/service-runtime.js
CHANGED
|
@@ -10,6 +10,7 @@ export class MergeStewardRuntime {
|
|
|
10
10
|
specBuilder;
|
|
11
11
|
logger;
|
|
12
12
|
beforeTick;
|
|
13
|
+
onReconcileWatchdog;
|
|
13
14
|
tickTimer;
|
|
14
15
|
staleTickTimer;
|
|
15
16
|
tickInProgress = false;
|
|
@@ -18,7 +19,8 @@ export class MergeStewardRuntime {
|
|
|
18
19
|
lastTickOutcome = "idle";
|
|
19
20
|
lastTickError = null;
|
|
20
21
|
lastReconcileEvent = null;
|
|
21
|
-
|
|
22
|
+
watchdogTickStartedAt = null;
|
|
23
|
+
constructor(config, policy, store, git, ci, github, eviction, specBuilder, logger, beforeTick, onReconcileWatchdog) {
|
|
22
24
|
this.config = config;
|
|
23
25
|
this.policy = policy;
|
|
24
26
|
this.store = store;
|
|
@@ -29,6 +31,7 @@ export class MergeStewardRuntime {
|
|
|
29
31
|
this.specBuilder = specBuilder;
|
|
30
32
|
this.logger = logger;
|
|
31
33
|
this.beforeTick = beforeTick;
|
|
34
|
+
this.onReconcileWatchdog = onReconcileWatchdog;
|
|
32
35
|
}
|
|
33
36
|
async start() {
|
|
34
37
|
this.logger.info({ pollIntervalMs: this.config.pollIntervalMs }, "Steward service starting");
|
|
@@ -45,6 +48,9 @@ export class MergeStewardRuntime {
|
|
|
45
48
|
while (this.tickInProgress && Date.now() < deadline) {
|
|
46
49
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
47
50
|
}
|
|
51
|
+
if (this.tickInProgress) {
|
|
52
|
+
this.logger.warn({ runtime: this.getRuntimeStatus() }, "Stopping while a reconcile tick is still active; process restart will recover from durable queue state");
|
|
53
|
+
}
|
|
48
54
|
this.logger.info("Steward service stopped");
|
|
49
55
|
}
|
|
50
56
|
async triggerReconcile() {
|
|
@@ -118,18 +124,27 @@ export class MergeStewardRuntime {
|
|
|
118
124
|
return null;
|
|
119
125
|
return Math.max(0, Date.now() - startedMs);
|
|
120
126
|
}
|
|
121
|
-
|
|
127
|
+
scheduleReconcileWatchdog(startedAt) {
|
|
122
128
|
this.clearStaleTickTimer();
|
|
123
129
|
const timer = setTimeout(() => {
|
|
124
130
|
if (!this.tickInProgress || this.lastTickStartedAt !== startedAt)
|
|
125
131
|
return;
|
|
132
|
+
this.staleTickTimer = undefined;
|
|
133
|
+
this.watchdogTickStartedAt = startedAt;
|
|
134
|
+
this.lastTickOutcome = "failed";
|
|
135
|
+
this.lastTickError = `Reconcile tick exceeded the ${this.config.reconcileStaleAfterMs}ms stale threshold; service restart requested.`;
|
|
126
136
|
const runtime = this.getRuntimeStatus();
|
|
127
|
-
this.logger.
|
|
137
|
+
this.logger.error({
|
|
128
138
|
startedAt,
|
|
129
139
|
tickAgeMs: runtime.tickAgeMs,
|
|
130
140
|
staleTickThresholdMs: runtime.staleTickThresholdMs,
|
|
131
141
|
lastReconcileEvent: runtime.lastReconcileEvent,
|
|
132
|
-
}, "Reconcile tick
|
|
142
|
+
}, "Reconcile watchdog detected a stuck tick; requesting service restart");
|
|
143
|
+
void (async () => {
|
|
144
|
+
await this.onReconcileWatchdog?.(runtime);
|
|
145
|
+
})().catch((error) => {
|
|
146
|
+
this.logger.error({ startedAt, error: error instanceof Error ? error.message : String(error) }, "Reconcile watchdog could not restart the service");
|
|
147
|
+
});
|
|
133
148
|
}, this.config.reconcileStaleAfterMs);
|
|
134
149
|
timer.unref?.();
|
|
135
150
|
this.staleTickTimer = timer;
|
|
@@ -148,7 +163,9 @@ export class MergeStewardRuntime {
|
|
|
148
163
|
this.lastTickOutcome = "running";
|
|
149
164
|
this.lastTickError = null;
|
|
150
165
|
this.lastReconcileEvent = null;
|
|
151
|
-
this.
|
|
166
|
+
this.watchdogTickStartedAt = null;
|
|
167
|
+
const startedAt = this.lastTickStartedAt;
|
|
168
|
+
this.scheduleReconcileWatchdog(startedAt);
|
|
152
169
|
try {
|
|
153
170
|
await this.beforeTick?.();
|
|
154
171
|
await reconcile({
|
|
@@ -179,7 +196,9 @@ export class MergeStewardRuntime {
|
|
|
179
196
|
this.logger[level]({ ...event }, `Queue: ${event.action} PR #${event.prNumber}`);
|
|
180
197
|
},
|
|
181
198
|
});
|
|
182
|
-
this.
|
|
199
|
+
if (this.watchdogTickStartedAt !== startedAt) {
|
|
200
|
+
this.lastTickOutcome = "succeeded";
|
|
201
|
+
}
|
|
183
202
|
}
|
|
184
203
|
catch (error) {
|
|
185
204
|
this.lastTickOutcome = "failed";
|
package/dist/service.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export declare class MergeStewardService {
|
|
|
21
21
|
private readonly runtime;
|
|
22
22
|
private readonly queueCommands;
|
|
23
23
|
private readonly watchQueries;
|
|
24
|
-
constructor(config: StewardConfig, policy: GitHubPolicyCache, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: SpeculativeBranchBuilder, logger: Logger);
|
|
24
|
+
constructor(config: StewardConfig, policy: GitHubPolicyCache, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: SpeculativeBranchBuilder, logger: Logger, onReconcileWatchdog?: ((runtime: QueueRuntimeStatus) => Promise<void> | void) | undefined);
|
|
25
25
|
/** Expose the GitHub client for webhook handler branch→PR lookups. */
|
|
26
26
|
get githubApi(): GitHubPRApi;
|
|
27
27
|
start(): Promise<void>;
|
package/dist/service.js
CHANGED
|
@@ -18,7 +18,7 @@ export class MergeStewardService {
|
|
|
18
18
|
runtime;
|
|
19
19
|
queueCommands;
|
|
20
20
|
watchQueries;
|
|
21
|
-
constructor(config, policy, store, git, ci, github, eviction, specBuilder, logger) {
|
|
21
|
+
constructor(config, policy, store, git, ci, github, eviction, specBuilder, logger, onReconcileWatchdog) {
|
|
22
22
|
this.config = config;
|
|
23
23
|
this.policy = policy;
|
|
24
24
|
this.store = store;
|
|
@@ -31,7 +31,7 @@ export class MergeStewardService {
|
|
|
31
31
|
this.queueCommands = new MergeStewardQueueCommands(config, policy, store, github, specBuilder, logger);
|
|
32
32
|
this.runtime = new MergeStewardRuntime(config, policy, store, git, ci, github, eviction, specBuilder, logger, async () => {
|
|
33
33
|
await this.queueCommands.scanEligibleOpenPrs();
|
|
34
|
-
});
|
|
34
|
+
}, onReconcileWatchdog);
|
|
35
35
|
this.watchQueries = new MergeStewardWatchQueries(config, store, this.runtime);
|
|
36
36
|
}
|
|
37
37
|
/** Expose the GitHub client for webhook handler branch→PR lookups. */
|
package/dist/types.d.ts
CHANGED
|
@@ -227,11 +227,7 @@ export interface PRStatus {
|
|
|
227
227
|
branch: string;
|
|
228
228
|
headSha: string;
|
|
229
229
|
title?: string | undefined;
|
|
230
|
-
/**
|
|
231
|
-
* Plan §8.4: PR's base ref. When this names another open PR's
|
|
232
|
-
* `branch` (head ref), the PR is stacked and admission must defer
|
|
233
|
-
* until the parent is in the queue.
|
|
234
|
-
*/
|
|
230
|
+
/** Base ref used to recognize and order stacked PRs. */
|
|
235
231
|
baseRefName?: string | undefined;
|
|
236
232
|
mergeable: boolean;
|
|
237
233
|
mergeStateStatus?: string | undefined;
|