diff --git a/.github/workflows/repair-cluster-worker.yml b/.github/workflows/repair-cluster-worker.yml
index 24b287b690..b546a046da 100644
--- a/.github/workflows/repair-cluster-worker.yml
+++ b/.github/workflows/repair-cluster-worker.yml
@@ -892,3 +892,17 @@ jobs:
if: ${{ always() && failure() && steps.crabfleet_session.outcome == 'success' && (steps.repair_requeue.outputs.count == '' || steps.repair_requeue.outputs.count == '0' || steps.requeue_dispatch.outcome != 'success') }}
continue-on-error: true
run: pnpm run repair:action-session -- update --state blocked --phase action_failed --summary "Repair Action failed before all completion gates passed" --completion-reason action_failed
+
+ - name: Reconcile failed automerge telemetry
+ if: ${{ always() && failure() && inputs.automerge_session_id != '' && (steps.repair_requeue.outputs.count == '' || steps.repair_requeue.outputs.count == '0' || steps.requeue_dispatch.outcome != 'success') }}
+ continue-on-error: true
+ env:
+ AUTOMERGE_SESSION_ID: ${{ inputs.automerge_session_id }}
+ AUTOMERGE_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ CLAWSWEEPER_STATUS_INGEST_TOKEN: ${{ secrets.CLAWSWEEPER_STATUS_INGEST_TOKEN }}
+ GITHUB_TOKEN: ${{ github.token }}
+ run: |
+ node scripts/dashboard-reconcile-automerge.ts \
+ --session-id "$AUTOMERGE_SESSION_ID" \
+ --run-url "$AUTOMERGE_RUN_URL" \
+ --run-conclusion failure
diff --git a/dashboard/automerge-metrics.ts b/dashboard/automerge-metrics.ts
index 9e4ac1a63f..a7ee7c6656 100644
--- a/dashboard/automerge-metrics.ts
+++ b/dashboard/automerge-metrics.ts
@@ -114,6 +114,7 @@ export function summarizeAutomergeMetrics(
range?: string;
repo?: string | null;
policyVersion?: string | null;
+ sessionId?: string | null;
now?: string;
activeOnly?: boolean;
sessionLimit?: number;
@@ -131,7 +132,8 @@ export function summarizeAutomergeMetrics(
const filtered = allEvents.filter(
(event) =>
(!options.repo || event.repository === options.repo) &&
- (!options.policyVersion || event.policy_version === options.policyVersion),
+ (!options.policyVersion || event.policy_version === options.policyVersion) &&
+ (!options.sessionId || event.session_id === options.sessionId),
);
const sessions = projectSessions(filtered);
const terminal = sessions.filter(
@@ -192,7 +194,7 @@ export function summarizeAutomergeMetrics(
return options.activeOnly ? left - right : right - left;
})
.slice(0, sessionLimit);
- const filtersActive = Boolean(options.repo || options.policyVersion);
+ const filtersActive = Boolean(options.repo || options.policyVersion || options.sessionId);
const telemetrySince = filtersActive
? (filtered[0]?.occurred_at ?? null)
: ((ledger as AutomergeMetricLedger | null)?.telemetry_since ??
diff --git a/dashboard/worker.ts b/dashboard/worker.ts
index ba88af3847..eaaf2109ec 100644
--- a/dashboard/worker.ts
+++ b/dashboard/worker.ts
@@ -893,6 +893,7 @@ async function automergeMetricsJson(request, env) {
range: url.searchParams.get("range") ?? "7d",
repo: url.searchParams.get("repo"),
policyVersion: url.searchParams.get("policy_version"),
+ sessionId: url.searchParams.get("session_id"),
activeOnly: url.searchParams.get("active_only") === "true",
sessionLimit: Number(url.searchParams.get("session_limit")),
}),
@@ -9352,11 +9353,11 @@ function renderAutomergeProduct(data) {
return '
n=' + fmt.format(bucket.merged_count) + '
';
}).join("");
const chart = '' + points + '
' + (activeAutomergeChart === "success" ? '● normal sample · ○ fewer than 5 terminal sessions · gaps mean no terminal sample' : '● p50 · amber p90 · gaps mean no merged sample') + '
';
- const outcomeLabels = { merged: "Merged", maintainer_stopped: "Maintainer stopped", repair_cap_exhausted: "Repair cap exhausted", pr_closed: "PR closed", automerge_disabled: "Automerge disabled" };
+ const outcomeLabels = { merged: "Merged", repair_failed: "Repair workflow failed", maintainer_stopped: "Maintainer stopped", repair_cap_exhausted: "Repair cap exhausted", pr_closed: "PR closed", automerge_disabled: "Automerge disabled" };
const outcomes = Object.entries(outcomeLabels).map(entry => '' + esc(entry[1]) + '' + fmt.format(data.terminal_outcomes?.[entry[0]] || 0) + '
').join("");
const efficiency = [['0 base sync sessions', data.repair_efficiency?.zero_base_sync], ['1 base sync session', data.repair_efficiency?.one_base_sync], ['2+ base sync sessions', data.repair_efficiency?.multiple_base_sync], ['Multi-rebase rate', value(summary.multi_rebase_rate_percent, "%")]].map(entry => '' + esc(entry[0]) + '' + esc(entry[1] ?? 0) + '
').join("");
const details = 'Terminal outcomes
' + outcomes + 'Repair efficiency
' + efficiency + ' ';
- const rows = (data.sessions || []).map(session => '| ' + linkClass(session.pr_url, session.repository + '#' + session.item_number, "item-link") + ' | ' + esc(session.state || 'unknown') + ' | ' + esc(session.policy_version) + ' | ' + esc(session.activated_at ? since(session.activated_at) : 'missing') + ' | ' + esc(session.terminal_at ? since(session.terminal_at) : since(session.last_event_at)) + ' | ' + fmt.format(session.base_sync_count || 0) + ' | ' + fmt.format(session.repairs || 0) + ' | ' + esc(session.last_reason || '') + ' ' + linkClass(session.run_url, 'run', 'pill run-link') + ' |
').join("");
+ const rows = (data.sessions || []).map(session => '| ' + linkClass(session.pr_url, session.repository + '#' + session.item_number, "item-link") + ' | ' + esc(outcomeLabels[session.state] || session.state || 'unknown') + ' | ' + esc(session.policy_version) + ' | ' + esc(session.activated_at ? since(session.activated_at) : 'missing') + ' | ' + esc(session.terminal_at ? since(session.terminal_at) : since(session.last_event_at)) + ' | ' + fmt.format(session.base_sync_count || 0) + ' | ' + fmt.format(session.repairs || 0) + ' | ' + esc(session.last_reason || '') + ' ' + linkClass(session.run_url, 'run', 'pill run-link') + ' |
').join("");
const sessions = 'Recent automerge sessions
Showing up to 30 latest sessions in the selected window| PR | State | Policy | Activated | Terminal / age | Syncs | Repairs | Last reason |
' + (rows || '| No session telemetry in this range. |
') + '
';
document.getElementById("automerge-product").innerHTML = kpis + chart + details + sessions;
}
diff --git a/docs/automerge-optimization-plan.md b/docs/automerge-optimization-plan.md
new file mode 100644
index 0000000000..f566ac0522
--- /dev/null
+++ b/docs/automerge-optimization-plan.md
@@ -0,0 +1,159 @@
+# Automerge Reliability Improvement Plan
+
+## Purpose
+
+Make ClawSweeper automerge predictable, recoverable, and safe by moving from
+production-discovered failures to local, replayable evidence. This plan is a
+sequence: do not treat an apparent workflow success as proof that an automerge
+attempt reached a safe product terminal state.
+
+## Scope and safety boundary
+
+- All early validation uses fixed revisions, local bare Git remotes, and a
+ fail-closed GitHub simulator.
+- It never writes GitHub comments, labels, branches, merges, or closes items.
+- It does not change production automerge behavior during the stable-red
+ phase. Production fixes begin only after a reproducer is accepted.
+- Local tests must use explicit process, tree, and state oracles rather than
+ treating a command exit code as the complete result.
+
+## Phases
+
+### 1. Establish stable red evidence
+
+Build a proof-producing E2E harness around production artifacts. Each proof
+binds the candidate revision and executable/dependency digests, fixture digest,
+event sequence, fault point, phase, product outcome, Git tree state, and child
+process observation.
+
+The gate has two deliberate modes:
+
+- `candidate`: a known product violation is red and exits non-zero.
+- `reproducer`: the same exact fingerprint is a confirmed historical
+ reproducer and exits zero.
+
+The initial evidence set includes OpenClaw runtime incidents, state-publication
+tree-loss cases, immutable canonical paths, duplicate delivery/replay,
+replacement runs, modeled crashes, and mutation-sensitive head/base/check/
+review/permission/label drift.
+
+Stable red requires deterministic inputs and scheduling, the same phase and
+normalized fingerprint on every run, no timeout/network/race substitute
+failure, ten of ten fast repetitions, and three of three real OpenClaw
+repetitions. A model-only result is evidence, not a release-candidate pass.
+
+### 2. Repair one proved invariant at a time
+
+For each accepted red proof, make the narrowest production change that restores
+the violated invariant. Preserve the reproducer, add the green counterpart,
+and require candidate mode to pass without weakening the oracle. Do not hide a
+failure with retries, larger timeouts, skips, or a broader fixture.
+
+### 3. Validate the GitHub contract minimally
+
+After local evidence is green, validate only the platform-specific contract
+that cannot be modeled locally: Actions delivery and cancellation behavior,
+GitHub permission/ruleset combinations, merge-queue semantics, and API
+eventual consistency. Keep the validation proposal-only until its scope is
+explicitly authorized.
+
+### 4. Refactor toward durable state ownership
+
+Move the automerge path toward explicit durable intent, attempt, outcome, and
+publication records. Reconciliation must be idempotent: repeated commands,
+deliveries, and restarts may observe or finish an existing fact but must not
+create a second logical merge, comment, or publication.
+
+Split responsibilities into small modules:
+
+- admission and immutable job intent;
+- exact-head/readiness verdicts;
+- execution and containment;
+- durable result/outcome recording;
+- reconciliation and router recovery;
+- state-tree publication with CAS/tree-preservation checks;
+- proof and operational reporting.
+
+The state repository remains the durable status surface. A missing, stale, or
+conflicting fact blocks mutation rather than selecting a best-effort path.
+
+## Required invariants
+
+- Exact head/base, checks, review, permission, and protected-label facts are
+ revalidated immediately before a merge mutation.
+- A workflow's successful process exit cannot override leaked descendants or a
+ blocked product state.
+- State publication preserves concurrent siblings, merge parents, and existing
+ immutable entries; a conflicting canonical path fails closed.
+- A crash after durable intent is recoverable without duplicate work; a crash
+ after merge records one outcome without a second merge.
+- Candidate, fixture, dependency, and proof identity drift are harness errors.
+
+## Exit criteria
+
+The stable-red phase is complete only when all required incident and topology
+scenarios have deterministic proofs, fast and real repetition inventories are
+complete, controls pass, the full local gate has a measured hot-cache budget,
+and no local test touches live GitHub state. The handoff is a failure inventory
+and replayable proof bundle, not a production fix.
+
+## Current status
+
+This is a handoff record. The stable-red phase is **not complete** and no
+production Automerge behavior has been changed.
+
+### Completed and evidence-backed
+
+- The CLI supports explicit flow, publication, model, and runtime scenario
+ selection; candidate and reproducer exit semantics are distinct and
+ fail-closed.
+- Proof summaries bind the candidate `HEAD`, production `dist/` digest,
+ dependency digest, fixture identity, event/fault sequence, terminal state,
+ and child-process observation. Candidate identity is rechecked after every
+ scenario.
+- The real OpenClaw git-hooks incident is confirmed against pinned revisions:
+ `openclaw.unsafe-core-hookspath` at `target-setup-git-safety`.
+- Both historical publication P1 cases are confirmed 10/10 against
+ `5c28770b`: `state-publication.concurrent-sibling-lost` and
+ `state-publication.merge-tree-entry-lost` at `state-publication-rebuild`.
+- The immutable canonical-path conflict is safe 10/10: different bytes are
+ blocked and the original remote tree entry is preserved.
+- Pending-run replacement, duplicate command replay, both crash models, and
+ all modeled mutation-sensitive drift cases are deterministic 10/10. They
+ intentionally remain `evidence-only`, not a candidate-release pass.
+- Focused proof/candidate/gate/model tests (23), syntax checks, formatting, and
+ diff checks passed on Node 24.
+
+### Incomplete or explicitly blocked
+
+- The real process-leak case is **not confirmed**. With the pinned target tree,
+ the real `pnpm check:changed` exits at the max-lines ratchet before the
+ historical "exit 0 with four descendants" oracle. Its proof is correctly a
+ `harness-error`; do not skip the ratchet or relabel this as confirmed.
+- The flow 10/10 run completed but its inventory is `unstable`: flow fixture
+ digests currently include per-run temporary Git metadata. Fix the flow
+ fixture-digest contract before accepting repetition evidence; do not simply
+ remove fixture identity from the stability signature.
+- The full Node 24 `pnpm run check` was started in the shared workspace while
+ other independent checks were also active. Re-run it in a quiet workspace and
+ record the complete result before declaring the phase ready for repair.
+
+### Next owner checklist
+
+1. Reconstruct the historical process-leak workflow context from read-only
+ evidence and explain the ratchet discrepancy while retaining real setup and
+ `check:changed`; then require 3/3 warmup/final fingerprint agreement.
+2. Make the flow fixture digest represent stable fixture content rather than
+ generated commit metadata, add a focused regression test, and rerun flow
+ 10/10. A stable signature must still bind the actual fixture inputs.
+3. Preserve the existing publication/model inventories, then compose a gate
+ inventory by fixed candidate revision rather than pretending one candidate
+ covers incompatible historical revisions.
+4. Run the complete Node 24 check in isolation. Do not repair unrelated apply
+ tests as part of this E2E-only phase.
+5. Only after every stable-red exit criterion has evidence may the next PR
+ begin a narrow production repair; retain every red reproducer as its oracle.
+
+Useful local proof roots from the latest run are `/tmp/clawsweeper-e2e-closeout-*`.
+They are disposable evidence, not repository fixtures; regenerate them rather
+than relying on their lifetime.
diff --git a/scripts/dashboard-reconcile-automerge.ts b/scripts/dashboard-reconcile-automerge.ts
index a6369df0bf..0df678807a 100644
--- a/scripts/dashboard-reconcile-automerge.ts
+++ b/scripts/dashboard-reconcile-automerge.ts
@@ -7,7 +7,11 @@ const DEFAULT_LIMIT = 8;
const MAX_LIMIT = 20;
const DEFAULT_TIMEOUT_MS = 15_000;
const MAX_TIMEOUT_MS = 30_000;
-const LOOKBACK_MS = 24 * 60 * 60 * 1000;
+const LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000;
+const REPAIR_WORKFLOW = "repair-cluster-worker.yml";
+const REPAIR_REQUEUE_DISPATCH_STEP = "Requeue source-head repair races";
+const DEFAULT_WORKFLOW_REPOSITORY = "openclaw/clawsweeper";
+const REPAIR_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "action_required"]);
type ActiveSession = {
session_id: string;
@@ -24,44 +28,86 @@ type ReconcileOptions = {
env?: NodeJS.ProcessEnv;
fetcher?: typeof fetch;
now?: string;
+ sessionId?: string | null;
+ runUrl?: string | null;
+ runConclusion?: string | null;
+};
+
+type Terminal = {
+ outcome: "merged" | "pr_closed" | "repair_failed";
+ occurredAt: string;
+ reason: string;
+ runUrl?: string | null;
};
export async function reconcileAutomergeProductMetrics({
env = process.env,
fetcher = fetch,
now = new Date().toISOString(),
+ sessionId = null,
+ runUrl = null,
+ runConclusion = null,
}: ReconcileOptions = {}) {
+ const normalizedSessionId = nullableText(sessionId);
+ const normalizedRunUrl = nullableText(runUrl);
+ const normalizedRunConclusion = nullableText(runConclusion)?.toLowerCase() ?? null;
+ if (normalizedSessionId && !parseSessionId(normalizedSessionId)) {
+ return skippedResult("invalid_session_id", 0);
+ }
+ if (normalizedRunUrl && !parseRunUrl(normalizedRunUrl)) {
+ return skippedResult("invalid_run_url", 0);
+ }
+ if (normalizedRunConclusion && !REPAIR_FAILURE_CONCLUSIONS.has(normalizedRunConclusion)) {
+ return skippedResult("invalid_run_conclusion", 0);
+ }
+
const ingestToken = String(env.CLAWSWEEPER_STATUS_INGEST_TOKEN ?? "").trim();
- if (!ingestToken) return { ok: true, skipped: "ingest_token_missing", candidates: 0 };
+ if (!ingestToken) return skippedResult("ingest_token_missing", 0);
const statusUrl = trimTrailingSlash(
env.CLAWSWEEPER_STATUS_URL || "https://clawsweeper.openclaw.ai",
);
const ingestUrl = env.CLAWSWEEPER_STATUS_INGEST_URL || `${statusUrl}/api/events`;
const githubToken = env.GITHUB_TOKEN || env.GH_TOKEN || "";
- const limit = boundedInt(env.CLAWSWEEPER_AUTOMERGE_RECONCILE_LIMIT, DEFAULT_LIMIT, MAX_LIMIT);
+ const workflowRepository =
+ nullableText(env.CLAWSWEEPER_AUTOMERGE_WORKFLOW_REPOSITORY) ?? DEFAULT_WORKFLOW_REPOSITORY;
+ if (!repositoryName(workflowRepository)) return skippedResult("invalid_workflow_repository", 0);
+ const limit = normalizedSessionId
+ ? 1
+ : boundedInt(env.CLAWSWEEPER_AUTOMERGE_RECONCILE_LIMIT, DEFAULT_LIMIT, MAX_LIMIT);
const timeoutMs = boundedInt(
env.CLAWSWEEPER_AUTOMERGE_RECONCILE_TIMEOUT_MS,
DEFAULT_TIMEOUT_MS,
MAX_TIMEOUT_MS,
);
const nowMs = Date.parse(now);
+ if (!Number.isFinite(nowMs)) return skippedResult("invalid_now", 0);
const deadline = AbortSignal.timeout(timeoutMs);
try {
const metricsUrl = new URL("/api/automerge-metrics", `${statusUrl}/`);
- metricsUrl.searchParams.set("range", "24h");
+ metricsUrl.searchParams.set("range", "7d");
metricsUrl.searchParams.set("active_only", "true");
metricsUrl.searchParams.set("session_limit", String(limit));
+ if (normalizedSessionId) metricsUrl.searchParams.set("session_id", normalizedSessionId);
const metrics = await fetchJson(metricsUrl, fetcher, deadline);
- const candidates = activeSessions(metrics, nowMs).slice(0, limit);
+ const candidates = activeSessions(metrics, nowMs)
+ .filter((session) => !normalizedSessionId || session.session_id === normalizedSessionId)
+ .slice(0, limit);
+ if (normalizedSessionId && candidates.length === 0) {
+ return skippedResult("session_not_active", 0);
+ }
- // Durable active sessions already form the retry queue. Re-reading authoritative
- // PR state closes both past and future delivery gaps without a second outbox.
+ // Durable active sessions form the retry queue. PR state remains authoritative;
+ // only an open PR permits the lower-priority repair-run failure observation.
const results = await Promise.all(
candidates.map((session) =>
reconcileSession({
session,
+ runUrlOverride: normalizedRunUrl,
+ runConclusionOverride: normalizedRunConclusion,
+ observedAt: new Date(nowMs).toISOString(),
+ workflowRepository,
ingestUrl,
ingestToken,
githubToken,
@@ -73,15 +119,21 @@ export async function reconcileAutomergeProductMetrics({
return {
ok: true,
candidates: candidates.length,
- github_reads: results.filter((result) => result.githubRead).length,
+ github_reads: results.reduce((total, result) => total + result.githubReads, 0),
terminal: results.filter((result) => result.terminal).length,
delivered: results.filter((result) => result.delivered).length,
failed: results.filter((result) => result.error).length,
+ skipped: results.filter((result) => result.skipped).length,
+ results,
};
} catch (error) {
return {
ok: false,
candidates: 0,
+ github_reads: 0,
+ terminal: 0,
+ delivered: 0,
+ failed: 1,
error: errorMessage(error),
};
}
@@ -89,6 +141,10 @@ export async function reconcileAutomergeProductMetrics({
async function reconcileSession({
session,
+ runUrlOverride,
+ runConclusionOverride,
+ observedAt,
+ workflowRepository,
ingestUrl,
ingestToken,
githubToken,
@@ -96,52 +152,98 @@ async function reconcileSession({
signal,
}: {
session: ActiveSession;
+ runUrlOverride: string | null;
+ runConclusionOverride: string | null;
+ observedAt: string;
+ workflowRepository: string;
ingestUrl: string;
ingestToken: string;
githubToken: string;
fetcher: typeof fetch;
signal: AbortSignal;
}) {
+ let githubReads = 0;
try {
const pr = await fetchJson(
- new URL(
- `/repos/${encodeURIComponent(session.repository.split("/")[0]!)}/${encodeURIComponent(
- session.repository.split("/")[1]!,
- )}/pulls/${session.item_number}`,
- "https://api.github.com",
- ),
+ githubApiUrl(session.repository, `/pulls/${session.item_number}`),
fetcher,
signal,
- {
- Accept: "application/vnd.github+json",
- "X-GitHub-Api-Version": "2022-11-28",
- "User-Agent": "openclaw-clawsweeper-automerge-reconciler",
- ...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
- },
+ githubHeaders(githubToken),
);
- const terminal = authoritativeTerminal(pr);
- if (!terminal) return { githubRead: true, terminal: false, delivered: false };
- const event = terminalEvent(session, terminal);
+ githubReads += 1;
+ let terminal = authoritativePrTerminal(pr);
+ if (!terminal) {
+ const associatedRunUrl = runUrlOverride ?? nullableText(session.run_url);
+ const runIdentity = associatedRunUrl ? parseRunUrl(associatedRunUrl) : null;
+ if (!runIdentity || runIdentity.repository !== workflowRepository) {
+ return sessionSkip(
+ session,
+ githubReads,
+ runIdentity ? "external_run_repository" : "run_url_missing_or_invalid",
+ );
+ }
+ const run = await fetchJson(
+ githubApiUrl(runIdentity.repository, `/actions/runs/${runIdentity.runId}`),
+ fetcher,
+ signal,
+ githubHeaders(githubToken),
+ );
+ githubReads += 1;
+ terminal = authoritativeRepairFailure(
+ run,
+ runIdentity,
+ workflowRepository,
+ runConclusionOverride,
+ observedAt,
+ );
+ if (!terminal) return sessionSkip(session, githubReads, "repair_run_not_failed");
+ if (!runConclusionOverride) {
+ // repair-cluster-worker dispatches one non-matrix execute job for one
+ // automerge_session_id. Its receipt/cluster/execute jobs fit one 100-row
+ // page, so this run-wide step is session-specific. Revisit this contract
+ // before adding a matrix or multiple automerge sessions to that workflow.
+ // The matched step is requeue_dispatch, not the preceding count detector;
+ // its workflow condition requires a nonzero count before it can succeed.
+ const jobs = await fetchJson(
+ githubApiUrl(
+ runIdentity.repository,
+ `/actions/runs/${runIdentity.runId}/jobs?filter=latest&per_page=100`,
+ ),
+ fetcher,
+ signal,
+ githubHeaders(githubToken),
+ );
+ githubReads += 1;
+ const requeued = repairRunSuccessfullyRequeued(jobs);
+ if (requeued === null) return sessionSkip(session, githubReads, "repair_run_jobs_invalid");
+ if (requeued) return sessionSkip(session, githubReads, "repair_run_successfully_requeued");
+ }
+ }
+
const response = await fetcher(ingestUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${ingestToken}`,
"Content-Type": "application/json",
},
- body: JSON.stringify(event),
+ body: JSON.stringify(terminalEvent(session, terminal)),
signal,
});
return {
- githubRead: true,
+ session_id: session.session_id,
+ githubReads,
terminal: true,
delivered: response.ok,
+ skipped: null,
error: response.ok ? null : `ingest returned ${response.status}`,
};
} catch (error) {
return {
- githubRead: true,
+ session_id: session.session_id,
+ githubReads,
terminal: false,
delivered: false,
+ skipped: "github_or_network_error",
error: errorMessage(error),
};
}
@@ -155,13 +257,12 @@ function activeSessions(value: unknown, nowMs: number): ActiveSession[] {
return sessions.filter((value): value is ActiveSession => {
if (!value || typeof value !== "object") return false;
const session = value as Partial;
+ const identity = parseSessionId(String(session.session_id ?? ""));
const lastEventAt = Date.parse(String(session.last_event_at ?? ""));
return (
!session.terminal_at &&
- typeof session.session_id === "string" &&
- /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(session.repository ?? "")) &&
- Number.isInteger(session.item_number) &&
- Number(session.item_number) > 0 &&
+ identity?.repository === session.repository &&
+ identity?.itemNumber === session.item_number &&
Number.isFinite(lastEventAt) &&
lastEventAt >= nowMs - LOOKBACK_MS &&
lastEventAt <= nowMs
@@ -169,25 +270,76 @@ function activeSessions(value: unknown, nowMs: number): ActiveSession[] {
});
}
-function authoritativeTerminal(value: unknown) {
+function authoritativePrTerminal(value: unknown): Terminal | null {
if (!value || typeof value !== "object") return null;
const pr = value as { merged_at?: unknown; closed_at?: unknown; state?: unknown };
const mergedAt = isoTimestamp(pr.merged_at);
- if (mergedAt) return { outcome: "merged", occurredAt: mergedAt } as const;
+ if (mergedAt) {
+ return {
+ outcome: "merged",
+ occurredAt: mergedAt,
+ reason: "reconciled from authoritative GitHub PR merged_at",
+ };
+ }
const closedAt = isoTimestamp(pr.closed_at);
if (String(pr.state ?? "").toLowerCase() === "closed" && closedAt) {
- return { outcome: "pr_closed", occurredAt: closedAt } as const;
+ return {
+ outcome: "pr_closed",
+ occurredAt: closedAt,
+ reason: "reconciled from authoritative GitHub PR closed_at",
+ };
}
return null;
}
-function terminalEvent(
- session: ActiveSession,
- terminal: { outcome: "merged" | "pr_closed"; occurredAt: string },
-) {
+function authoritativeRepairFailure(
+ value: unknown,
+ identity: { repository: string; runId: number; url: string },
+ workflowRepository: string,
+ reportedConclusion: string | null,
+ observedAt: string,
+): Terminal | null {
+ if (!value || typeof value !== "object") return null;
+ const run = value as Record;
+ const repository = repositoryName((run.repository as Record | null)?.full_name);
+ const workflowPath = String(run.path ?? "").split("@")[0];
+ const apiConclusion = String(run.conclusion ?? "").toLowerCase();
+ const status = String(run.status ?? "").toLowerCase();
+ // The workflow knows an earlier step has irreversibly failed while GitHub still
+ // reports the current run as in progress. Once GitHub exposes a terminal result,
+ // that authoritative conclusion must win over the early best-effort report.
+ const useReportedConclusion =
+ status === "in_progress" && !apiConclusion && reportedConclusion !== null;
+ const conclusion = useReportedConclusion ? reportedConclusion : apiConclusion;
+ const completedAt = useReportedConclusion
+ ? observedAt
+ : status === "completed"
+ ? isoTimestamp(run.updated_at)
+ : null;
+ if (
+ Number(run.id) !== identity.runId ||
+ repository !== workflowRepository ||
+ workflowPath !== `.github/workflows/${REPAIR_WORKFLOW}` ||
+ !(status === "completed" || useReportedConclusion) ||
+ !REPAIR_FAILURE_CONCLUSIONS.has(conclusion) ||
+ !completedAt
+ ) {
+ return null;
+ }
+ return {
+ outcome: "repair_failed",
+ occurredAt: completedAt,
+ reason: `reconciled from ${REPAIR_WORKFLOW} conclusion ${conclusion}`,
+ runUrl: identity.url,
+ };
+}
+
+function terminalEvent(session: ActiveSession, terminal: Terminal) {
return {
event_type: "clawsweeper.automerge_metric",
- event_id: `${session.session_id}:terminal:github-pr:${terminal.outcome}:${terminal.occurredAt}`,
+ // Reporters for the same outcome share an identity, while competing terminal
+ // observations stay distinct so event-level dedupe cannot choose the winner.
+ event_id: `${session.session_id}:terminal:reconcile:${terminal.outcome}`,
session_id: session.session_id,
phase: "terminal",
occurred_at: terminal.occurredAt,
@@ -196,13 +348,60 @@ function terminalEvent(
policy_version: session.policy_version || "immediate-v1",
state: null,
outcome: terminal.outcome,
- reason: `reconciled from authoritative GitHub PR ${terminal.outcome === "merged" ? "merged_at" : "closed_at"}`,
+ reason: terminal.reason,
pr_url:
session.pr_url || `https://github.com/${session.repository}/pull/${session.item_number}`,
- run_url: session.run_url || null,
+ run_url: terminal.runUrl ?? session.run_url ?? null,
};
}
+function repairRunSuccessfullyRequeued(value: unknown) {
+ if (!value || typeof value !== "object") return null;
+ const payload = value as { total_count?: unknown; jobs?: unknown };
+ if (!Array.isArray(payload.jobs)) return null;
+ const totalCount = Number(payload.total_count);
+ if (!Number.isInteger(totalCount) || totalCount < 0 || totalCount > payload.jobs.length) {
+ return null;
+ }
+ return payload.jobs.some((job) => {
+ if (!job || typeof job !== "object") return false;
+ const steps = (job as { steps?: unknown }).steps;
+ return (
+ Array.isArray(steps) &&
+ steps.some(
+ (step) =>
+ step &&
+ typeof step === "object" &&
+ String((step as Record).name ?? "") === REPAIR_REQUEUE_DISPATCH_STEP &&
+ String((step as Record).conclusion ?? "").toLowerCase() === "success",
+ )
+ );
+ });
+}
+
+function parseSessionId(value: string) {
+ const match = /^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9]\d*):([^:]+):(.+)$/.exec(value);
+ if (!match || !Number.isFinite(Date.parse(match[4]!))) return null;
+ return { repository: match[1]!, itemNumber: Number(match[2]!) };
+}
+
+function parseRunUrl(value: string) {
+ try {
+ const url = new URL(value);
+ const match = /^\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/actions\/runs\/([1-9]\d*)\/?$/.exec(
+ url.pathname,
+ );
+ if (url.protocol !== "https:" || url.hostname !== "github.com" || !match) return null;
+ return {
+ repository: `${match[1]}/${match[2]}`,
+ runId: Number(match[3]),
+ url: `https://github.com/${match[1]}/${match[2]}/actions/runs/${match[3]}`,
+ };
+ } catch {
+ return null;
+ }
+}
+
async function fetchJson(
url: URL,
fetcher: typeof fetch,
@@ -214,6 +413,51 @@ async function fetchJson(
return response.json() as Promise;
}
+function githubApiUrl(repository: string, path: string) {
+ const [owner, name] = repository.split("/");
+ return new URL(
+ `/repos/${encodeURIComponent(owner!)}/${encodeURIComponent(name!)}${path}`,
+ "https://api.github.com",
+ );
+}
+
+function githubHeaders(token: string) {
+ return {
+ Accept: "application/vnd.github+json",
+ "X-GitHub-Api-Version": "2022-11-28",
+ "User-Agent": "openclaw-clawsweeper-automerge-reconciler",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ };
+}
+
+function sessionSkip(session: ActiveSession, githubReads: number, skipped: string) {
+ return {
+ session_id: session.session_id,
+ githubReads,
+ terminal: false,
+ delivered: false,
+ skipped,
+ error: null,
+ };
+}
+
+function skippedResult(skipped: string, candidates: number) {
+ return {
+ ok: true,
+ skipped,
+ candidates,
+ github_reads: 0,
+ terminal: 0,
+ delivered: 0,
+ failed: 0,
+ };
+}
+
+function repositoryName(value: unknown) {
+ const text = String(value ?? "").trim();
+ return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(text) ? text : null;
+}
+
function isoTimestamp(value: unknown) {
const timestamp = Date.parse(String(value ?? ""));
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
@@ -224,6 +468,11 @@ function boundedInt(value: unknown, fallback: number, maximum: number) {
return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback;
}
+function nullableText(value: unknown) {
+ const text = String(value ?? "").trim();
+ return text || null;
+}
+
function trimTrailingSlash(value: string) {
return String(value).replace(/\/+$/, "");
}
@@ -232,8 +481,63 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
+function usage() {
+ return `Usage: node scripts/dashboard-reconcile-automerge.ts [options]
+
+Reconcile active automerge sessions against authoritative PR and repair workflow state.
+
+Options:
+ --session-id Reconcile one active automerge session
+ --run-url Override that session's associated GitHub Actions run URL
+ --run-conclusion
+ Report a failure already established by the current workflow
+ --help Show this help
+
+Output is one JSON object. Exit codes: 0 completed/skipped safely, 1 operational
+failure, 2 invalid CLI arguments.`;
+}
+
+function parseCliArgs(argv: string[]) {
+ let sessionId: string | null = null;
+ let runUrl: string | null = null;
+ let runConclusion: string | null = null;
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--help") return { help: true, sessionId, runUrl, runConclusion };
+ if (arg !== "--session-id" && arg !== "--run-url" && arg !== "--run-conclusion") {
+ throw new Error(`unknown argument: ${arg}`);
+ }
+ const value = argv[index + 1];
+ if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`);
+ if (arg === "--session-id") sessionId = value;
+ else if (arg === "--run-url") runUrl = value;
+ else runConclusion = value;
+ index += 1;
+ }
+ if (runUrl && !sessionId) throw new Error("--run-url requires --session-id");
+ if (runConclusion && (!sessionId || !runUrl)) {
+ throw new Error("--run-conclusion requires --session-id and --run-url");
+ }
+ return { help: false, sessionId, runUrl, runConclusion };
+}
+
+export function automergeReconcileExitCode(result: { ok?: unknown; failed?: unknown }) {
+ return result.ok === true && Number(result.failed ?? 0) === 0 ? 0 : 1;
+}
+
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
if (invokedPath === import.meta.url) {
- const result = await reconcileAutomergeProductMetrics();
- console.log(JSON.stringify(result, null, 2));
+ try {
+ const args = parseCliArgs(process.argv.slice(2));
+ if (args.help) {
+ console.log(usage());
+ } else {
+ const result = await reconcileAutomergeProductMetrics(args);
+ console.log(JSON.stringify(result, null, 2));
+ process.exitCode = automergeReconcileExitCode(result);
+ }
+ } catch (error) {
+ console.error(JSON.stringify({ ok: false, error: errorMessage(error) }, null, 2));
+ process.exitCode = 2;
+ }
}
diff --git a/scripts/e2e/automerge.mjs b/scripts/e2e/automerge.mjs
index a519d758cd..b9becde1dd 100755
--- a/scripts/e2e/automerge.mjs
+++ b/scripts/e2e/automerge.mjs
@@ -3,14 +3,28 @@
/**
* Definition: run the repository-owned ClawSweeper automerge E2E harness.
* Parameters: --scenario, --candidate-root, --output, and --keep are optional.
- * Outputs: step logs plus summary.json under test-results/automerge by default;
- * exit 0 means the selected production flow reached its asserted terminal state.
+ * Outputs: proof summaries under test-results/automerge by default. In
+ * candidate mode, a known product violation exits non-zero; in reproducer mode,
+ * the same exact fingerprint exits zero.
+ * Safety: every GitHub-looking token/API path in this harness is backed by a
+ * local fake gh binary and local bare remotes; live GitHub mutation is out of
+ * scope for this stable-red phase.
* Decision: external commands fail closed so newly introduced GitHub dependencies
* cannot silently turn this into a partial integration test.
*/
import path from "node:path";
-import { AUTOMERGE_E2E_SCENARIOS, runAutomergeE2E } from "../../test/e2e/automerge/run.mjs";
+import { runFlowScenario } from "../../test/e2e/automerge/flow-scenarios.mjs";
+import { writeGateInventory } from "../../test/e2e/automerge/gate-inventory.mjs";
+import { runModelScenario } from "../../test/e2e/automerge/model-scenarios.mjs";
+import { runPublicationScenario } from "../../test/e2e/automerge/publication-scenarios.mjs";
+import { AUTOMERGE_E2E_SCENARIOS } from "../../test/e2e/automerge/run.mjs";
+import { runRuntimeScenario } from "../../test/e2e/automerge/runtime-scenarios.mjs";
+import {
+ SCENARIO_IDS,
+ scenarioManifest,
+ scenariosForSuite,
+} from "../../test/e2e/automerge/scenarios.mjs";
const args = parseArgs(process.argv.slice(2));
if (args.help) {
@@ -23,22 +37,31 @@ Description:
a real local Git remote.
Options:
- --scenario Scenario to run; use all for the full suite
+ --scenario Scenario to run; use all for the legacy flow suite
(default: happy-path)
+ --suite flow, publication, model, or quick
+ --mode candidate or reproducer (default: candidate)
+ --repeat Repeat each selected scenario deterministically
--expect success or setup-identity-failure (default: success)
--list-scenarios Print supported scenario names
--candidate-root Built ClawSweeper checkout to validate (default: cwd)
+ --openclaw-mirror Read-only OpenClaw checkout for real runtime fixtures
--output Failure and proof artifact root
--keep Keep the temporary scenario workspace
-h, --help Show this help
Outputs: