merge-steward 0.26.2 → 0.27.0
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/dist/admin-types.d.ts +2 -0
- package/dist/cli/commands/pr-status.d.ts +9 -1
- package/dist/cli/commands/pr-status.js +33 -3
- package/dist/cli/commands/queue.d.ts +2 -1
- package/dist/cli/commands/queue.js +18 -1
- package/dist/cli/commands/service.js +11 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -0
- package/dist/http-multi.js +1 -0
- package/dist/runtime-format.d.ts +4 -0
- package/dist/runtime-format.js +35 -0
- package/dist/service-runtime.d.ts +7 -5
- package/dist/service-runtime.js +55 -0
- package/dist/service.d.ts +2 -6
- package/dist/types.d.ts +18 -0
- package/dist/watch/App.js +4 -1
- package/dist/watch/api.d.ts +2 -3
- package/dist/watch/format.js +1 -1
- package/package.json +1 -1
package/dist/admin-types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DiscoveredRepoSettings } from "./github-repo-discovery.ts";
|
|
2
|
+
import type { QueueRuntimeStatus } from "./types.ts";
|
|
2
3
|
export type RepoRuntimeState = "initializing" | "ready" | "failed";
|
|
3
4
|
export interface RepoRuntimeStatus {
|
|
4
5
|
repoId: string;
|
|
@@ -9,6 +10,7 @@ export interface RepoRuntimeStatus {
|
|
|
9
10
|
readyAt?: string | undefined;
|
|
10
11
|
failedAt?: string | undefined;
|
|
11
12
|
lastError?: string | undefined;
|
|
13
|
+
runtime?: QueueRuntimeStatus | undefined;
|
|
12
14
|
}
|
|
13
15
|
export interface ServiceHealthResponse {
|
|
14
16
|
ok: boolean;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QueueEntry } from "../../types.ts";
|
|
1
|
+
import type { QueueEntry, QueueEventSummary, QueueRuntimeStatus, QueueWatchSnapshot } from "../../types.ts";
|
|
2
2
|
import type { Output, ParsedArgs } from "../types.ts";
|
|
3
3
|
import { type ResolveCommandRunner } from "../resolve.ts";
|
|
4
4
|
import { type PrGitHubOverview } from "./pr-github.ts";
|
|
@@ -14,6 +14,9 @@ export interface PrStatusReport {
|
|
|
14
14
|
exitCode: number;
|
|
15
15
|
reason?: string;
|
|
16
16
|
queueEntry?: QueueEntry;
|
|
17
|
+
queueSource?: "service" | "database";
|
|
18
|
+
queueRuntime?: QueueRuntimeStatus;
|
|
19
|
+
queueLatestEvent?: QueueEventSummary;
|
|
17
20
|
github?: PrGitHubOverview;
|
|
18
21
|
checkedAt: string;
|
|
19
22
|
}
|
|
@@ -22,14 +25,19 @@ export declare function classifyGitHubOverview(overview: PrGitHubOverview): {
|
|
|
22
25
|
kind: PrStatusReport["kind"];
|
|
23
26
|
reason?: string;
|
|
24
27
|
};
|
|
28
|
+
export declare function latestEventForPr(snapshot: QueueWatchSnapshot, prNumber: number): QueueEventSummary | undefined;
|
|
25
29
|
export interface BuildReportOptions {
|
|
26
30
|
repoId: string;
|
|
27
31
|
repoFullName: string;
|
|
28
32
|
prNumber: number;
|
|
29
33
|
queueEntry?: QueueEntry | undefined;
|
|
34
|
+
queueSource?: "service" | "database" | undefined;
|
|
35
|
+
queueRuntime?: QueueRuntimeStatus | undefined;
|
|
36
|
+
queueLatestEvent?: QueueEventSummary | undefined;
|
|
30
37
|
github?: PrGitHubOverview | undefined;
|
|
31
38
|
}
|
|
32
39
|
export declare function buildPrStatusReport(options: BuildReportOptions): PrStatusReport;
|
|
40
|
+
export declare function formatReportText(report: PrStatusReport): string;
|
|
33
41
|
export interface HandlePrStatusOptions {
|
|
34
42
|
parsed: ParsedArgs;
|
|
35
43
|
stdout: Output;
|
|
@@ -4,6 +4,7 @@ import { ServiceApiError, fetchLocalJson, loadRepoConfigById } from "../system.j
|
|
|
4
4
|
import { defaultResolveRunner, resolvePrNumber, resolveRepo } from "../resolve.js";
|
|
5
5
|
import { parseIntegerFlag } from "../args.js";
|
|
6
6
|
import { fetchPrGitHubOverview } from "./pr-github.js";
|
|
7
|
+
import { formatRuntimeActivity } from "../../runtime-format.js";
|
|
7
8
|
export function classifyQueueEntry(entry) {
|
|
8
9
|
switch (entry.status) {
|
|
9
10
|
case "merged": return "merged";
|
|
@@ -79,9 +80,18 @@ function findEntryForPr(snapshot, prNumber) {
|
|
|
79
80
|
}
|
|
80
81
|
async function loadQueueEntry(config, prNumber) {
|
|
81
82
|
try {
|
|
82
|
-
const snapshot = await fetchLocalJson(config.repoId, "/queue/watch?eventLimit=
|
|
83
|
+
const snapshot = await fetchLocalJson(config.repoId, "/queue/watch?eventLimit=40");
|
|
83
84
|
const entry = findEntryForPr(snapshot, prNumber);
|
|
84
|
-
|
|
85
|
+
if (!entry)
|
|
86
|
+
return { kind: "not_found" };
|
|
87
|
+
const latestEvent = latestEventForPr(snapshot, prNumber);
|
|
88
|
+
return {
|
|
89
|
+
kind: "found",
|
|
90
|
+
entry,
|
|
91
|
+
source: "service",
|
|
92
|
+
runtime: snapshot.runtime,
|
|
93
|
+
...(latestEvent ? { latestEvent } : {}),
|
|
94
|
+
};
|
|
85
95
|
}
|
|
86
96
|
catch (error) {
|
|
87
97
|
if (error instanceof ServiceApiError) {
|
|
@@ -106,6 +116,14 @@ async function loadQueueEntry(config, prNumber) {
|
|
|
106
116
|
}
|
|
107
117
|
}
|
|
108
118
|
}
|
|
119
|
+
export function latestEventForPr(snapshot, prNumber) {
|
|
120
|
+
for (let index = snapshot.recentEvents.length - 1; index >= 0; index -= 1) {
|
|
121
|
+
const event = snapshot.recentEvents[index];
|
|
122
|
+
if (event?.prNumber === prNumber)
|
|
123
|
+
return event;
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
109
127
|
export function buildPrStatusReport(options) {
|
|
110
128
|
const checkedAt = new Date().toISOString();
|
|
111
129
|
if (options.queueEntry) {
|
|
@@ -119,6 +137,9 @@ export function buildPrStatusReport(options) {
|
|
|
119
137
|
terminal: isTerminalKind(kind),
|
|
120
138
|
exitCode: exitCodeForKind(kind),
|
|
121
139
|
queueEntry: options.queueEntry,
|
|
140
|
+
...(options.queueSource ? { queueSource: options.queueSource } : {}),
|
|
141
|
+
...(options.queueRuntime ? { queueRuntime: options.queueRuntime } : {}),
|
|
142
|
+
...(options.queueLatestEvent ? { queueLatestEvent: options.queueLatestEvent } : {}),
|
|
122
143
|
checkedAt,
|
|
123
144
|
};
|
|
124
145
|
}
|
|
@@ -139,7 +160,7 @@ export function buildPrStatusReport(options) {
|
|
|
139
160
|
}
|
|
140
161
|
throw new Error("buildPrStatusReport requires either queueEntry or github overview");
|
|
141
162
|
}
|
|
142
|
-
function formatReportText(report) {
|
|
163
|
+
export function formatReportText(report) {
|
|
143
164
|
const lines = [
|
|
144
165
|
`Repo: ${report.repoFullName} (${report.repoId})`,
|
|
145
166
|
`PR: #${report.prNumber}`,
|
|
@@ -156,6 +177,12 @@ function formatReportText(report) {
|
|
|
156
177
|
lines.push(`Head SHA: ${entry.headSha}`);
|
|
157
178
|
if (entry.waitDetail)
|
|
158
179
|
lines.push(`Wait detail: ${entry.waitDetail}`);
|
|
180
|
+
if (report.queueRuntime) {
|
|
181
|
+
lines.push(...formatRuntimeActivity(report.queueRuntime));
|
|
182
|
+
}
|
|
183
|
+
if (report.queueLatestEvent) {
|
|
184
|
+
lines.push(`Latest event for this PR: ${report.queueLatestEvent.toStatus}${report.queueLatestEvent.detail ? ` (${report.queueLatestEvent.detail})` : ""} at ${report.queueLatestEvent.at}`);
|
|
185
|
+
}
|
|
159
186
|
}
|
|
160
187
|
if (report.github) {
|
|
161
188
|
const gh = report.github;
|
|
@@ -232,6 +259,9 @@ export async function handlePrStatus(options) {
|
|
|
232
259
|
repoFullName: resolvedRepo.repoFullName,
|
|
233
260
|
prNumber: resolvedPr.prNumber,
|
|
234
261
|
queueEntry: queueResult.entry,
|
|
262
|
+
queueSource: queueResult.source,
|
|
263
|
+
...(queueResult.runtime ? { queueRuntime: queueResult.runtime } : {}),
|
|
264
|
+
...(queueResult.latestEvent ? { queueLatestEvent: queueResult.latestEvent } : {}),
|
|
235
265
|
});
|
|
236
266
|
}
|
|
237
267
|
const overview = await fetchGh(resolvedRepo.repoFullName, resolvedPr.prNumber);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type QueueWatchSnapshot } from "../../types.ts";
|
|
1
|
+
import { type QueueReconcileResult, type QueueWatchSnapshot } from "../../types.ts";
|
|
2
2
|
import type { ParsedArgs, Output } from "../types.ts";
|
|
3
3
|
import { type ResolveCommandRunner } from "../resolve.ts";
|
|
4
4
|
export declare function formatQueueStatusText(source: "service" | "database", snapshot: QueueWatchSnapshot): string;
|
|
5
5
|
export declare function handleQueue(parsed: ParsedArgs, stdout: Output, resolveCommand?: ResolveCommandRunner): Promise<number>;
|
|
6
|
+
export declare function formatReconcileRequestText(result: QueueReconcileResult): string;
|
|
@@ -6,6 +6,7 @@ import { formatJson, writeOutput } from "../output.js";
|
|
|
6
6
|
import { ServiceApiError, loadRepoConfigById, fetchLocalJson } from "../system.js";
|
|
7
7
|
import { resolveRepo } from "../resolve.js";
|
|
8
8
|
import { buildQueueSummary } from "../../watch/dashboard-model.js";
|
|
9
|
+
import { formatDurationMs, formatRuntimeActivity } from "../../runtime-format.js";
|
|
9
10
|
async function resolveRepoIdWithCwdFallback(parsed, positionalIndex, resolveCommand) {
|
|
10
11
|
const positional = parsed.positionals[positionalIndex];
|
|
11
12
|
if (positional)
|
|
@@ -84,6 +85,7 @@ export function formatQueueStatusText(source, snapshot) {
|
|
|
84
85
|
`Queued: ${snapshot.summary.queued} preparing: ${snapshot.summary.preparingHead} validating: ${snapshot.summary.validating} merging: ${snapshot.summary.merging}`,
|
|
85
86
|
`Merged: ${snapshot.summary.merged} evicted: ${snapshot.summary.evicted} dequeued: ${snapshot.summary.dequeued}`,
|
|
86
87
|
snapshot.summary.headPrNumber ? `Head PR: #${snapshot.summary.headPrNumber}` : "Head PR: none",
|
|
88
|
+
...formatRuntimeActivity(snapshot.runtime),
|
|
87
89
|
...(snapshot.runtime.lastTickOutcome === "failed"
|
|
88
90
|
? [
|
|
89
91
|
"Last tick: failed",
|
|
@@ -241,7 +243,7 @@ export async function handleQueue(parsed, stdout, resolveCommand) {
|
|
|
241
243
|
else {
|
|
242
244
|
writeOutput(stdout, [
|
|
243
245
|
`Repo: ${repoId}`,
|
|
244
|
-
result
|
|
246
|
+
formatReconcileRequestText(result),
|
|
245
247
|
`Last outcome: ${result.runtime.lastTickOutcome}`,
|
|
246
248
|
].join("\n") + "\n");
|
|
247
249
|
}
|
|
@@ -253,3 +255,18 @@ export async function handleQueue(parsed, stdout, resolveCommand) {
|
|
|
253
255
|
}
|
|
254
256
|
throw new UsageError(`Unknown queue command: ${subcommand}`, "queue");
|
|
255
257
|
}
|
|
258
|
+
export function formatReconcileRequestText(result) {
|
|
259
|
+
if (result.started)
|
|
260
|
+
return "Reconcile started.";
|
|
261
|
+
if (result.reason === "already_running") {
|
|
262
|
+
const age = formatDurationMs(result.runtime.tickAgeMs);
|
|
263
|
+
const latest = result.runtime.lastReconcileEvent
|
|
264
|
+
? `; latest action ${result.runtime.lastReconcileEvent.action} PR #${result.runtime.lastReconcileEvent.prNumber}`
|
|
265
|
+
: "";
|
|
266
|
+
const guidance = result.runtime.staleTick
|
|
267
|
+
? "inspect logs before restarting"
|
|
268
|
+
: "wait for the current tick before restarting";
|
|
269
|
+
return `Reconcile already running for ${age}${latest}; ${guidance}.`;
|
|
270
|
+
}
|
|
271
|
+
return "Reconcile request accepted; a tick was already in progress.";
|
|
272
|
+
}
|
|
@@ -3,6 +3,7 @@ import { UsageError } from "../types.js";
|
|
|
3
3
|
import { parseIntegerFlag } from "../args.js";
|
|
4
4
|
import { formatJson, writeOutput } from "../output.js";
|
|
5
5
|
import { fetchServiceHealthStatus, formatCommandFailure, parseSystemctlShowOutput, runSystemctl } from "../system.js";
|
|
6
|
+
import { formatDurationMs } from "../../runtime-format.js";
|
|
6
7
|
const UNIT_NAME = "merge-steward.service";
|
|
7
8
|
export async function handleService(parsed, stdout, runCommand) {
|
|
8
9
|
const subcommand = parsed.positionals[1];
|
|
@@ -71,6 +72,7 @@ export async function handleService(parsed, stdout, runCommand) {
|
|
|
71
72
|
health.reachable
|
|
72
73
|
? `Health: ${health.ok ? "ok" : "unhealthy"} (HTTP ${health.status})`
|
|
73
74
|
: `Health: not reachable (${health.error})`,
|
|
75
|
+
...(health.reachable && health.health ? staleRuntimeLines(health.health.repos) : []),
|
|
74
76
|
]
|
|
75
77
|
.filter(Boolean)
|
|
76
78
|
.join("\n") + "\n");
|
|
@@ -94,3 +96,12 @@ export async function handleService(parsed, stdout, runCommand) {
|
|
|
94
96
|
}
|
|
95
97
|
throw new UsageError(`Unknown service command: ${subcommand}`, "service");
|
|
96
98
|
}
|
|
99
|
+
function staleRuntimeLines(repos) {
|
|
100
|
+
return repos
|
|
101
|
+
.filter((repo) => repo.runtime?.staleTick)
|
|
102
|
+
.map((repo) => {
|
|
103
|
+
const event = repo.runtime?.lastReconcileEvent;
|
|
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}`;
|
|
106
|
+
});
|
|
107
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export declare const stewardConfigSchema: z.ZodObject<{
|
|
|
10
10
|
flakyRetries: z.ZodDefault<z.ZodNumber>;
|
|
11
11
|
speculativeDepth: z.ZodDefault<z.ZodNumber>;
|
|
12
12
|
pollIntervalMs: z.ZodDefault<z.ZodNumber>;
|
|
13
|
+
reconcileStaleAfterMs: z.ZodDefault<z.ZodNumber>;
|
|
13
14
|
server: z.ZodDefault<z.ZodObject<{
|
|
14
15
|
bind: z.ZodDefault<z.ZodString>;
|
|
15
16
|
port: z.ZodDefault<z.ZodNumber>;
|
package/dist/config.js
CHANGED
|
@@ -16,6 +16,7 @@ export const stewardConfigSchema = z.object({
|
|
|
16
16
|
/** Max speculative branches to maintain in parallel. 1 = serial mode. */
|
|
17
17
|
speculativeDepth: z.number().int().min(1).default(10),
|
|
18
18
|
pollIntervalMs: z.number().int().min(1000).default(30_000),
|
|
19
|
+
reconcileStaleAfterMs: z.number().int().min(1000).default(5 * 60_000),
|
|
19
20
|
server: z.object({
|
|
20
21
|
bind: z.string().default("127.0.0.1"),
|
|
21
22
|
port: z.number().int().default(8790),
|
package/dist/http-multi.js
CHANGED
|
@@ -48,6 +48,7 @@ export async function buildMultiRepoHttpServer(options) {
|
|
|
48
48
|
...(record.readyAt ? { readyAt: record.readyAt } : {}),
|
|
49
49
|
...(record.failedAt ? { failedAt: record.failedAt } : {}),
|
|
50
50
|
...(record.lastError ? { lastError: record.lastError } : {}),
|
|
51
|
+
...(record.instance ? { runtime: record.instance.service.getRuntimeStatus() } : {}),
|
|
51
52
|
})),
|
|
52
53
|
}));
|
|
53
54
|
app.get("/admin/runtime/auth", async () => githubAdmin.getStatus());
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { QueueRuntimeStatus, ReconcileEventSummary } from "./types.ts";
|
|
2
|
+
export declare function formatDurationMs(ms: number | null | undefined): string;
|
|
3
|
+
export declare function formatReconcileEvent(event: ReconcileEventSummary | null | undefined): string | null;
|
|
4
|
+
export declare function formatRuntimeActivity(runtime: QueueRuntimeStatus): string[];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function formatDurationMs(ms) {
|
|
2
|
+
if (ms === null || ms === undefined || !Number.isFinite(ms))
|
|
3
|
+
return "unknown duration";
|
|
4
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
5
|
+
if (seconds < 60)
|
|
6
|
+
return `${seconds}s`;
|
|
7
|
+
const minutes = Math.floor(seconds / 60);
|
|
8
|
+
const remainder = seconds % 60;
|
|
9
|
+
if (minutes < 60)
|
|
10
|
+
return remainder === 0 ? `${minutes}m` : `${minutes}m${remainder}s`;
|
|
11
|
+
const hours = Math.floor(minutes / 60);
|
|
12
|
+
const minuteRemainder = minutes % 60;
|
|
13
|
+
return minuteRemainder === 0 ? `${hours}h` : `${hours}h${minuteRemainder}m`;
|
|
14
|
+
}
|
|
15
|
+
export function formatReconcileEvent(event) {
|
|
16
|
+
if (!event)
|
|
17
|
+
return null;
|
|
18
|
+
return `${event.action} PR #${event.prNumber}${event.detail ? ` (${event.detail})` : ""} at ${event.at}`;
|
|
19
|
+
}
|
|
20
|
+
export function formatRuntimeActivity(runtime) {
|
|
21
|
+
if (!runtime.tickInProgress)
|
|
22
|
+
return [];
|
|
23
|
+
const age = formatDurationMs(runtime.tickAgeMs);
|
|
24
|
+
const started = runtime.lastTickStartedAt ? `, started ${runtime.lastTickStartedAt}` : "";
|
|
25
|
+
const lines = [
|
|
26
|
+
`Reconcile: ${runtime.staleTick ? "stale" : "running"} for ${age}${started}`,
|
|
27
|
+
];
|
|
28
|
+
const latest = formatReconcileEvent(runtime.lastReconcileEvent);
|
|
29
|
+
if (latest)
|
|
30
|
+
lines.push(`Latest action: ${latest}`);
|
|
31
|
+
if (runtime.staleTick) {
|
|
32
|
+
lines.push(`Warning: reconcile tick appears stale; threshold ${formatDurationMs(runtime.staleTickThresholdMs)}.`);
|
|
33
|
+
}
|
|
34
|
+
return lines;
|
|
35
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Logger } from "pino";
|
|
2
2
|
import type { GitOperations, CIRunner, GitHubPRApi, EvictionReporter, SpeculativeBranchBuilder } from "./interfaces.ts";
|
|
3
3
|
import type { QueueStore } from "./store.ts";
|
|
4
|
-
import type { QueueBlockState, QueueRuntimeStatus } from "./types.ts";
|
|
4
|
+
import type { QueueBlockState, QueueReconcileResult, QueueRuntimeStatus } from "./types.ts";
|
|
5
5
|
import type { StewardConfig } from "./config.ts";
|
|
6
6
|
import type { GitHubPolicyCache } from "./github-policy.ts";
|
|
7
7
|
export declare class MergeStewardRuntime {
|
|
@@ -16,25 +16,27 @@ export declare class MergeStewardRuntime {
|
|
|
16
16
|
private readonly logger;
|
|
17
17
|
private readonly beforeTick?;
|
|
18
18
|
private tickTimer;
|
|
19
|
+
private staleTickTimer;
|
|
19
20
|
private tickInProgress;
|
|
20
21
|
private lastTickStartedAt;
|
|
21
22
|
private lastTickCompletedAt;
|
|
22
23
|
private lastTickOutcome;
|
|
23
24
|
private lastTickError;
|
|
25
|
+
private lastReconcileEvent;
|
|
24
26
|
private currentQueueBlock;
|
|
25
27
|
private readonly waitingMainInfoAt;
|
|
26
28
|
private static readonly WAITING_MAIN_INFO_INTERVAL_MS;
|
|
27
29
|
constructor(config: StewardConfig, policy: GitHubPolicyCache, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: SpeculativeBranchBuilder, logger: Logger, beforeTick?: (() => Promise<void>) | undefined);
|
|
28
30
|
start(): Promise<void>;
|
|
29
31
|
stop(): Promise<void>;
|
|
30
|
-
triggerReconcile(): Promise<
|
|
31
|
-
started: boolean;
|
|
32
|
-
runtime: QueueRuntimeStatus;
|
|
33
|
-
}>;
|
|
32
|
+
triggerReconcile(): Promise<QueueReconcileResult>;
|
|
34
33
|
getRuntimeStatus(): QueueRuntimeStatus;
|
|
35
34
|
getCurrentQueueBlock(): QueueBlockState | null;
|
|
36
35
|
getGitHubPolicy(): import("./github-policy.ts").GitHubPolicySnapshot;
|
|
37
36
|
private scheduleNextTick;
|
|
37
|
+
private getTickAgeMs;
|
|
38
|
+
private scheduleStaleTickWarning;
|
|
39
|
+
private clearStaleTickTimer;
|
|
38
40
|
private runTick;
|
|
39
41
|
private describeMainBroken;
|
|
40
42
|
private getMissingRequiredChecks;
|
package/dist/service-runtime.js
CHANGED
|
@@ -11,11 +11,13 @@ export class MergeStewardRuntime {
|
|
|
11
11
|
logger;
|
|
12
12
|
beforeTick;
|
|
13
13
|
tickTimer;
|
|
14
|
+
staleTickTimer;
|
|
14
15
|
tickInProgress = false;
|
|
15
16
|
lastTickStartedAt = null;
|
|
16
17
|
lastTickCompletedAt = null;
|
|
17
18
|
lastTickOutcome = "idle";
|
|
18
19
|
lastTickError = null;
|
|
20
|
+
lastReconcileEvent = null;
|
|
19
21
|
currentQueueBlock = null;
|
|
20
22
|
// Per-entry timestamp of the last info-level `merge_waiting_main` log.
|
|
21
23
|
// The event fires every tick while the gate waits, so subsequent ticks
|
|
@@ -44,6 +46,7 @@ export class MergeStewardRuntime {
|
|
|
44
46
|
clearTimeout(this.tickTimer);
|
|
45
47
|
this.tickTimer = undefined;
|
|
46
48
|
}
|
|
49
|
+
this.clearStaleTickTimer();
|
|
47
50
|
const deadline = Date.now() + 10_000;
|
|
48
51
|
while (this.tickInProgress && Date.now() < deadline) {
|
|
49
52
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
@@ -54,16 +57,23 @@ export class MergeStewardRuntime {
|
|
|
54
57
|
const started = await this.runTick();
|
|
55
58
|
return {
|
|
56
59
|
started,
|
|
60
|
+
...(!started ? { reason: "already_running" } : {}),
|
|
57
61
|
runtime: this.getRuntimeStatus(),
|
|
58
62
|
};
|
|
59
63
|
}
|
|
60
64
|
getRuntimeStatus() {
|
|
65
|
+
const tickAgeMs = this.getTickAgeMs();
|
|
66
|
+
const staleTickThresholdMs = this.config.reconcileStaleAfterMs;
|
|
61
67
|
return {
|
|
62
68
|
tickInProgress: this.tickInProgress,
|
|
63
69
|
lastTickStartedAt: this.lastTickStartedAt,
|
|
64
70
|
lastTickCompletedAt: this.lastTickCompletedAt,
|
|
65
71
|
lastTickOutcome: this.lastTickOutcome,
|
|
66
72
|
lastTickError: this.lastTickError,
|
|
73
|
+
tickAgeMs,
|
|
74
|
+
staleTickThresholdMs,
|
|
75
|
+
staleTick: tickAgeMs !== null && tickAgeMs >= staleTickThresholdMs,
|
|
76
|
+
lastReconcileEvent: this.lastReconcileEvent,
|
|
67
77
|
};
|
|
68
78
|
}
|
|
69
79
|
getCurrentQueueBlock() {
|
|
@@ -79,6 +89,36 @@ export class MergeStewardRuntime {
|
|
|
79
89
|
this.tickTimer = setTimeout(() => void this.runTick(), this.config.pollIntervalMs);
|
|
80
90
|
this.tickTimer.unref?.();
|
|
81
91
|
}
|
|
92
|
+
getTickAgeMs() {
|
|
93
|
+
if (!this.tickInProgress || !this.lastTickStartedAt)
|
|
94
|
+
return null;
|
|
95
|
+
const startedMs = Date.parse(this.lastTickStartedAt);
|
|
96
|
+
if (!Number.isFinite(startedMs))
|
|
97
|
+
return null;
|
|
98
|
+
return Math.max(0, Date.now() - startedMs);
|
|
99
|
+
}
|
|
100
|
+
scheduleStaleTickWarning(startedAt) {
|
|
101
|
+
this.clearStaleTickTimer();
|
|
102
|
+
const timer = setTimeout(() => {
|
|
103
|
+
if (!this.tickInProgress || this.lastTickStartedAt !== startedAt)
|
|
104
|
+
return;
|
|
105
|
+
const runtime = this.getRuntimeStatus();
|
|
106
|
+
this.logger.warn({
|
|
107
|
+
startedAt,
|
|
108
|
+
tickAgeMs: runtime.tickAgeMs,
|
|
109
|
+
staleTickThresholdMs: runtime.staleTickThresholdMs,
|
|
110
|
+
lastReconcileEvent: runtime.lastReconcileEvent,
|
|
111
|
+
}, "Reconcile tick appears stale");
|
|
112
|
+
}, this.config.reconcileStaleAfterMs);
|
|
113
|
+
timer.unref?.();
|
|
114
|
+
this.staleTickTimer = timer;
|
|
115
|
+
}
|
|
116
|
+
clearStaleTickTimer() {
|
|
117
|
+
if (this.staleTickTimer) {
|
|
118
|
+
clearTimeout(this.staleTickTimer);
|
|
119
|
+
this.staleTickTimer = undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
82
122
|
async runTick() {
|
|
83
123
|
if (this.tickInProgress)
|
|
84
124
|
return false;
|
|
@@ -86,6 +126,8 @@ export class MergeStewardRuntime {
|
|
|
86
126
|
this.lastTickStartedAt = new Date().toISOString();
|
|
87
127
|
this.lastTickOutcome = "running";
|
|
88
128
|
this.lastTickError = null;
|
|
129
|
+
this.lastReconcileEvent = null;
|
|
130
|
+
this.scheduleStaleTickWarning(this.lastTickStartedAt);
|
|
89
131
|
try {
|
|
90
132
|
await this.beforeTick?.();
|
|
91
133
|
let tickQueueBlockEvent = null;
|
|
@@ -103,6 +145,7 @@ export class MergeStewardRuntime {
|
|
|
103
145
|
flakyRetries: this.config.flakyRetries,
|
|
104
146
|
policy: this.policy,
|
|
105
147
|
onEvent: (event) => {
|
|
148
|
+
this.lastReconcileEvent = summarizeReconcileEvent(event);
|
|
106
149
|
const isWarn = event.action === "evicted" || event.action === "spec_build_conflict"
|
|
107
150
|
|| event.action === "ci_failed"
|
|
108
151
|
|| event.action === "merge_rejected" || event.action === "budget_exhausted";
|
|
@@ -142,6 +185,7 @@ export class MergeStewardRuntime {
|
|
|
142
185
|
this.logger.error({ err: error instanceof Error ? { message: error.message, stack: error.stack } : error }, "Reconcile tick failed");
|
|
143
186
|
}
|
|
144
187
|
finally {
|
|
188
|
+
this.clearStaleTickTimer();
|
|
145
189
|
this.tickInProgress = false;
|
|
146
190
|
this.lastTickCompletedAt = new Date().toISOString();
|
|
147
191
|
this.scheduleNextTick();
|
|
@@ -191,3 +235,14 @@ export class MergeStewardRuntime {
|
|
|
191
235
|
return requiredChecks.filter((check) => !available.has(check.trim().toLowerCase()));
|
|
192
236
|
}
|
|
193
237
|
}
|
|
238
|
+
function summarizeReconcileEvent(event) {
|
|
239
|
+
return {
|
|
240
|
+
at: event.at,
|
|
241
|
+
entryId: event.entryId,
|
|
242
|
+
prNumber: event.prNumber,
|
|
243
|
+
action: event.action,
|
|
244
|
+
...(event.detail ? { detail: event.detail } : {}),
|
|
245
|
+
...(event.ciRunId ? { ciRunId: event.ciRunId } : {}),
|
|
246
|
+
...(event.specBranch ? { specBranch: event.specBranch } : {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
package/dist/service.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { Logger } from "pino";
|
|
2
2
|
import type { GitOperations, CIRunner, GitHubPRApi, EvictionReporter, SpeculativeBranchBuilder } from "./interfaces.ts";
|
|
3
|
-
import type { QueueEntry, QueueEntryDetail, IncidentRecord, QueueRuntimeStatus, QueueWatchSnapshot } from "./types.ts";
|
|
3
|
+
import type { QueueEntry, QueueEntryDetail, IncidentRecord, QueueReconcileResult, QueueRuntimeStatus, QueueWatchSnapshot } from "./types.ts";
|
|
4
4
|
import type { StewardConfig } from "./config.ts";
|
|
5
5
|
import type { GitHubPolicyCache, GitHubPolicyRefreshResult, GitHubPolicySnapshot } from "./github-policy.ts";
|
|
6
6
|
import type { QueueStore } from "./store.ts";
|
|
7
|
-
import { MergeStewardRuntime } from "./service-runtime.ts";
|
|
8
7
|
/**
|
|
9
8
|
* Merge steward service. The public shell stays thin and delegates
|
|
10
9
|
* lifecycle, queue mutations, and query shaping to focused helpers.
|
|
@@ -51,10 +50,7 @@ export declare class MergeStewardService {
|
|
|
51
50
|
getEntryDetail(entryId: string, options?: {
|
|
52
51
|
eventLimit?: number;
|
|
53
52
|
}): QueueEntryDetail | undefined;
|
|
54
|
-
triggerReconcile(): Promise<
|
|
55
|
-
started: boolean;
|
|
56
|
-
runtime: ReturnType<MergeStewardRuntime["getRuntimeStatus"]>;
|
|
57
|
-
}>;
|
|
53
|
+
triggerReconcile(): Promise<QueueReconcileResult>;
|
|
58
54
|
getGitHubPolicy(): GitHubPolicySnapshot;
|
|
59
55
|
refreshGitHubPolicyFromWebhook(reason: string): Promise<GitHubPolicyRefreshResult>;
|
|
60
56
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -141,6 +141,24 @@ export interface QueueRuntimeStatus {
|
|
|
141
141
|
lastTickCompletedAt: string | null;
|
|
142
142
|
lastTickOutcome: "idle" | "running" | "succeeded" | "failed";
|
|
143
143
|
lastTickError: string | null;
|
|
144
|
+
tickAgeMs?: number | null;
|
|
145
|
+
staleTickThresholdMs?: number;
|
|
146
|
+
staleTick?: boolean;
|
|
147
|
+
lastReconcileEvent?: ReconcileEventSummary | null;
|
|
148
|
+
}
|
|
149
|
+
export interface ReconcileEventSummary {
|
|
150
|
+
at: string;
|
|
151
|
+
entryId: string;
|
|
152
|
+
prNumber: number;
|
|
153
|
+
action: ReconcileAction;
|
|
154
|
+
detail?: string | undefined;
|
|
155
|
+
ciRunId?: string | undefined;
|
|
156
|
+
specBranch?: string | undefined;
|
|
157
|
+
}
|
|
158
|
+
export interface QueueReconcileResult {
|
|
159
|
+
started: boolean;
|
|
160
|
+
reason?: "already_running" | undefined;
|
|
161
|
+
runtime: QueueRuntimeStatus;
|
|
144
162
|
}
|
|
145
163
|
export interface QueueStatusSummary {
|
|
146
164
|
total: number;
|
package/dist/watch/App.js
CHANGED
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
|
4
4
|
import { fetchGatewayHealth, fetchSnapshot, triggerReconcile } from "./api.js";
|
|
5
|
+
import { formatDurationMs } from "../runtime-format.js";
|
|
5
6
|
import { buildDashboard, matchRepoRef, repoSelector, stepRepo } from "./dashboard-model.js";
|
|
6
7
|
import { HelpBar } from "./HelpBar.js";
|
|
7
8
|
import { OverviewView } from "./OverviewView.js";
|
|
@@ -147,7 +148,9 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
|
|
|
147
148
|
return;
|
|
148
149
|
try {
|
|
149
150
|
const result = await triggerReconcile(gatewayBaseUrl, repoId);
|
|
150
|
-
setFlashMessage(result.started
|
|
151
|
+
setFlashMessage(result.started
|
|
152
|
+
? `reconcile tick completed for ${repoId}`
|
|
153
|
+
: `reconcile already running for ${formatDurationMs(result.runtime.tickAgeMs)} in ${repoId}`);
|
|
151
154
|
}
|
|
152
155
|
catch (error) {
|
|
153
156
|
setFlashMessage(error instanceof Error ? error.message : String(error));
|
package/dist/watch/api.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ServiceHealthResponse } from "../admin-types.ts";
|
|
2
|
-
import type { QueueEntryDetail, QueueWatchSnapshot } from "../types.ts";
|
|
2
|
+
import type { QueueEntryDetail, QueueReconcileResult, QueueWatchSnapshot } from "../types.ts";
|
|
3
3
|
export declare class ServiceApiError extends Error {
|
|
4
4
|
readonly options?: {
|
|
5
5
|
status?: number;
|
|
@@ -15,6 +15,5 @@ export declare function fetchGatewayHealth(gatewayBaseUrl: string): Promise<Serv
|
|
|
15
15
|
export declare function fetchEntryDetail(gatewayBaseUrl: string, repoId: string, entryId: string): Promise<QueueEntryDetail>;
|
|
16
16
|
export declare function triggerReconcile(gatewayBaseUrl: string, repoId: string): Promise<{
|
|
17
17
|
ok: true;
|
|
18
|
-
|
|
19
|
-
}>;
|
|
18
|
+
} & QueueReconcileResult>;
|
|
20
19
|
export declare function dequeueEntry(gatewayBaseUrl: string, repoId: string, entryId: string): Promise<void>;
|
package/dist/watch/format.js
CHANGED
|
@@ -164,7 +164,7 @@ export function specChainLabel(entry, allEntries) {
|
|
|
164
164
|
}
|
|
165
165
|
export function runtimeLabel(runtime) {
|
|
166
166
|
if (runtime.tickInProgress) {
|
|
167
|
-
return "running";
|
|
167
|
+
return runtime.staleTick ? "stale" : "running";
|
|
168
168
|
}
|
|
169
169
|
if (runtime.lastTickOutcome === "idle") {
|
|
170
170
|
return "idle";
|