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 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
- ? "inspect logs before restarting"
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 tick is stale after ${formatDurationMs(repo.runtime?.tickAgeMs)}${latest}`;
105
+ return `Warning: ${repo.repoId} reconcile watchdog is restarting the service after ${formatDurationMs(repo.runtime?.tickAgeMs)}${latest}`;
106
106
  });
107
107
  }
@@ -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
- // Plan §8.4 change 3a: include baseRefName so the queue can
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
- }): (trigger: string) => Promise<void>;
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
- return (trigger) => {
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
- terminate(1);
46
+ terminateOnce(1);
26
47
  });
27
48
  return shutdownPromise;
28
49
  };
@@ -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 tick appears stale; threshold ${formatDurationMs(runtime.staleTickThresholdMs)}.`);
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;
@@ -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>;
@@ -189,17 +189,8 @@ export class MergeStewardQueueCommands {
189
189
  return false;
190
190
  }
191
191
  }
192
- // Plan §8.4: stack-aware admission. When the PR's base ref
193
- // names another open PR's branch (head ref), the entry is
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
- // Plan §8.4: lookup helper used by stack-aware admission. Returns
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
- constructor(config: StewardConfig, policy: GitHubPolicyCache, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: SpeculativeBranchBuilder, logger: Logger, beforeTick?: (() => Promise<void>) | undefined);
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 scheduleStaleTickWarning;
37
+ private scheduleReconcileWatchdog;
36
38
  private clearStaleTickTimer;
37
39
  private runTick;
38
40
  }
@@ -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
- constructor(config, policy, store, git, ci, github, eviction, specBuilder, logger, beforeTick) {
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
- scheduleStaleTickWarning(startedAt) {
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.warn({
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 appears stale");
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.scheduleStaleTickWarning(this.lastTickStartedAt);
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.lastTickOutcome = "succeeded";
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.34.0",
3
+ "version": "0.34.2",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {