Add phase/status consistency check observability (B2 Phase 1 step 4/5)
This commit is contained in:
1 parent
2656684392
commit
ab4afba9c7
2 files changed
+204
No files matched your search
@@ -29,6 +29,10 @@ import { buildCredentialMethods } from "./database/credentials";
|
|||||||
import { buildModelCapabilityMethods } from "./database/model-capabilities-crud";
|
import { buildModelCapabilityMethods } from "./database/model-capabilities-crud";
|
||||||
import { MODEL_CAPABILITY_SEED } from "./model-capability-seed";
|
import { MODEL_CAPABILITY_SEED } from "./model-capability-seed";
|
||||||
import { initializeDatabase } from "./database/schema";
|
import { initializeDatabase } from "./database/schema";
|
||||||
|
import {
|
||||||
|
verifyPhaseConsistency,
|
||||||
|
type PhaseConsistencyReport,
|
||||||
|
} from "./database/tasks-phase-consistency";
|
||||||
import { SecretVault } from "./vault";
|
import { SecretVault } from "./vault";
|
||||||
import type { AgentStatus, ConnectorId, ProfileSnapshot, RunStatus } from "@shared/types";
|
import type { AgentStatus, ConnectorId, ProfileSnapshot, RunStatus } from "@shared/types";
|
||||||
|
|
||||||
@@ -185,6 +189,42 @@ export class AppDatabaseBase {
|
|||||||
this._lastInterruptedCount = this.interruptStaleRuns();
|
this._lastInterruptedCount = this.interruptStaleRuns();
|
||||||
this.settings.purgeExpiredRunLogs();
|
this.settings.purgeExpiredRunLogs();
|
||||||
this.wakeup.purgeExpiredWakeupRequests();
|
this.wakeup.purgeExpiredWakeupRequests();
|
||||||
|
|
||||||
|
// B2 Phase 1 step 4/5 — dev-mode dual-write consistency probe.
|
||||||
|
//
|
||||||
|
// Under NODE_ENV=development (vite dev, tests) we log the phase /
|
||||||
|
// lease divergence count every boot. Production builds skip the
|
||||||
|
// scan to keep startup cheap; the renderer snapshot path has no
|
||||||
|
// opinion on the shadow columns yet, so a daily sweep is fine
|
||||||
|
// once step 5/5 lands the lint rule and step 4 graduates to a
|
||||||
|
// background metric.
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
try {
|
||||||
|
const report = verifyPhaseConsistency(this.db, { sampleLimit: 5 });
|
||||||
|
if (report.inconsistent > 0) {
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[tasks-phase-consistency] ${report.inconsistent} row(s) diverge between legacy columns and phase/lease shadow`,
|
||||||
|
report.mismatches,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort probe: a query failure here is diagnostic noise,
|
||||||
|
// not a reason to abort bootstrap. The dev-mode logger above
|
||||||
|
// would have captured the error anyway.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report divergence between the legacy lock columns and the new
|
||||||
|
* phase/lease shadow. Intended for diagnostics — the scan is O(task
|
||||||
|
* rows) and must not run on hot paths. See
|
||||||
|
* `tasks-phase-consistency.verifyPhaseConsistency` for the full
|
||||||
|
* invariant description.
|
||||||
|
*/
|
||||||
|
verifyPhaseConsistency(options: { sampleLimit?: number } = {}): PhaseConsistencyReport {
|
||||||
|
return verifyPhaseConsistency(this.db, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
close() { this.db.close(); }
|
close() { this.db.close(); }
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B2 Phase 1 step 4/5 — dual-write consistency observability.
|
||||||
|
*
|
||||||
|
* `verifyPhaseConsistency` inspects the live invariants between the
|
||||||
|
* legacy `tasks.status` / `tasks.checkout_run_id` / `tasks.active_run_id`
|
||||||
|
* columns and the new `tasks.phase` / `task_leases` shadow surfaces.
|
||||||
|
* It does not mutate anything; it reports the divergence count and a
|
||||||
|
* small sample so an operator (or the startup hook in dev mode) can
|
||||||
|
* confirm dual-write landed the invariants intended by step 3/5.
|
||||||
|
*
|
||||||
|
* The check intentionally accepts that `status = 'in_review'` maps
|
||||||
|
* onto `phase = 'in_progress'` — the canonical phase enum supplied by
|
||||||
|
* the user collapses the review stage into the in-flight phase, so an
|
||||||
|
* `in_review` task with `phase = 'in_progress'` is *not* a divergence.
|
||||||
|
*
|
||||||
|
* This is observability only: step 5/5 adds the ESLint rule that
|
||||||
|
* prevents new divergence from reaching production, and a future
|
||||||
|
* phase swaps the reads over once the mismatch count stabilises at
|
||||||
|
* zero.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PhaseConsistencySample {
|
||||||
|
taskId: string;
|
||||||
|
status: string;
|
||||||
|
phase: string | null;
|
||||||
|
activeRunId: string | null;
|
||||||
|
checkoutRunId: string | null;
|
||||||
|
leaseRunId: string | null;
|
||||||
|
leaseExpiresAt: string | null;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PhaseConsistencyReport {
|
||||||
|
/**
|
||||||
|
* Total number of task rows whose legacy columns disagree with the
|
||||||
|
* shadow surfaces in a way that dual-write should prevent. Zero is
|
||||||
|
* the healthy steady state.
|
||||||
|
*/
|
||||||
|
inconsistent: number;
|
||||||
|
/**
|
||||||
|
* Up to `sampleLimit` offending rows. Operators use these to drive
|
||||||
|
* root-cause investigation without materialising the entire diff.
|
||||||
|
*/
|
||||||
|
mismatches: PhaseConsistencySample[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map from legacy `status` to the expected `phase` under dual-write.
|
||||||
|
* Any status value missing from this map is an observability miss —
|
||||||
|
* the caller is expected to extend both this table and the v50
|
||||||
|
* backfill/trigger simultaneously. Leaving the entry unmapped here
|
||||||
|
* would cause every such row to report as inconsistent forever.
|
||||||
|
*/
|
||||||
|
const STATUS_TO_EXPECTED_PHASE: Record<string, string> = {
|
||||||
|
backlog: "backlog",
|
||||||
|
todo: "todo",
|
||||||
|
in_progress: "in_progress",
|
||||||
|
blocked: "blocked",
|
||||||
|
done: "completed",
|
||||||
|
cancelled: "cancelled",
|
||||||
|
in_review: "in_progress",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RawRow {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
phase: string | null;
|
||||||
|
active_run_id: string | null;
|
||||||
|
checkout_run_id: string | null;
|
||||||
|
lease_run_id: string | null;
|
||||||
|
lease_expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the diff between the legacy lock columns and the new phase
|
||||||
|
* + lease shadow. Reports three kinds of mismatch:
|
||||||
|
*
|
||||||
|
* - `phase_unexpected`: the shadow `phase` does not match what the
|
||||||
|
* current `status` predicts per the mapping table above. Indicates
|
||||||
|
* the trigger fired on an unexpected status value, or the row was
|
||||||
|
* written through a path that bypassed the trigger.
|
||||||
|
* - `lease_missing`: the legacy columns say the task is owned by a
|
||||||
|
* live run (`active_run_id` or `checkout_run_id` non-null) but the
|
||||||
|
* shadow `task_leases` row is absent. Indicates a dual-write site
|
||||||
|
* that still writes the legacy columns only.
|
||||||
|
* - `lease_orphan`: the shadow has a lease but neither legacy
|
||||||
|
* pointer is set. Indicates a lease release that only ran against
|
||||||
|
* the legacy path (or a race that landed the lease after the
|
||||||
|
* legacy clear).
|
||||||
|
*
|
||||||
|
* Expired leases whose legacy pointer is also cleared are ignored —
|
||||||
|
* they are a stale-lock sweep artefact that a later recoverStaleLocks
|
||||||
|
* call would flush anyway.
|
||||||
|
*/
|
||||||
|
export function verifyPhaseConsistency(
|
||||||
|
db: DatabaseSync,
|
||||||
|
options: { sampleLimit?: number } = {},
|
||||||
|
): PhaseConsistencyReport {
|
||||||
|
const sampleLimit = Math.max(0, options.sampleLimit ?? 20);
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.id,
|
||||||
|
t.status,
|
||||||
|
t.phase,
|
||||||
|
t.active_run_id,
|
||||||
|
t.checkout_run_id,
|
||||||
|
l.run_id AS lease_run_id,
|
||||||
|
l.expires_at AS lease_expires_at
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_leases l ON l.task_id = t.id`,
|
||||||
|
)
|
||||||
|
.all() as unknown as RawRow[];
|
||||||
|
|
||||||
|
const mismatches: PhaseConsistencySample[] = [];
|
||||||
|
const nowMs = Date.now();
|
||||||
|
let inconsistent = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const expectedPhase = STATUS_TO_EXPECTED_PHASE[row.status];
|
||||||
|
const hasLegacyOwnership =
|
||||||
|
row.active_run_id !== null || row.checkout_run_id !== null;
|
||||||
|
const hasLease = row.lease_run_id !== null;
|
||||||
|
const leaseExpiresMs = row.lease_expires_at
|
||||||
|
? Date.parse(row.lease_expires_at)
|
||||||
|
: Number.NaN;
|
||||||
|
const leaseExpired =
|
||||||
|
hasLease && Number.isFinite(leaseExpiresMs) && leaseExpiresMs <= nowMs;
|
||||||
|
|
||||||
|
const reasons: string[] = [];
|
||||||
|
|
||||||
|
if (expectedPhase === undefined) {
|
||||||
|
reasons.push(`status=${row.status} has no phase mapping`);
|
||||||
|
} else if (row.phase !== expectedPhase) {
|
||||||
|
reasons.push(`phase=${row.phase ?? "<null>"} expected ${expectedPhase}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLegacyOwnership && !hasLease) {
|
||||||
|
reasons.push("legacy owner set but no lease row");
|
||||||
|
}
|
||||||
|
if (!hasLegacyOwnership && hasLease && !leaseExpired) {
|
||||||
|
reasons.push("lease row present but no legacy owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasons.length === 0) continue;
|
||||||
|
inconsistent += 1;
|
||||||
|
if (mismatches.length < sampleLimit) {
|
||||||
|
mismatches.push({
|
||||||
|
taskId: row.id,
|
||||||
|
status: row.status,
|
||||||
|
phase: row.phase,
|
||||||
|
activeRunId: row.active_run_id,
|
||||||
|
checkoutRunId: row.checkout_run_id,
|
||||||
|
leaseRunId: row.lease_run_id,
|
||||||
|
leaseExpiresAt: row.lease_expires_at,
|
||||||
|
reason: reasons.join("; "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inconsistent, mismatches };
|
||||||
|
}
|
||||||
Reference in new issue
Block a user