merge-steward 0.34.1 → 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/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-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/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
|
}
|
|
@@ -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;
|
|
@@ -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. */
|