Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/calm-ravens-reconcile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cotal-ai/manager": patch
"@cotal-ai/cli": patch
"@cotal-ai/connector-core": patch
---

Report failed static lifecycle reconciliation and retry the same durable terminal in process with a bounded schedule.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
smoke:manager-reconcile-redrive
5 changes: 4 additions & 1 deletion bin/smoke/required-arg-seam.smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,10 @@ const SEAMS: Seam[] = [
// 118/87 -> 119/88: static-lifecycle.smoke.ts's #1274 crash-resume cell (driveTerminalDirect) opens
// one more lifecycle-executor connection to plant a terminalizing slot and drive runStaticTerminal
// as a resume would. It is under smoke/, harness residue not product connect, and states tls: false.
{ fn: "standaloneConnectOpts", key: "tls", sites: 119, untypecheckedSites: 88 },
// 119/88 -> 124/92: #774 adds one typechecked provisioner re-read before each static reconcile
// retry, plus four smoke-side connections for the isolated broker acceptance fixture (orphan write,
// observer, caller, and exact terminal gate inspection). Every site states tls: false.
{ fn: "standaloneConnectOpts", key: "tls", sites: 124, untypecheckedSites: 92 },
];

/**
Expand Down
14 changes: 14 additions & 0 deletions docs/run-a-mesh.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ continues, but prints a named `connector <name> unavailable` line and records th
manager's `status` response. Available connector rows record the absolute paths boot resolved.
Spawn keeps the same pre-mint check as a backstop for connectors registered after boot.

On an authenticated manager start, unfinished static lifecycle rows reconcile while the control
endpoint is already serving. The manager `status` response and `cotal status --components` report
the `staticReconciliation` state, the last sweep counts, and each failed alias with its durable
phase and literal disposition. A failed exact terminal is retried in the same process after 1, 5,
and 30 seconds. Each attempt re-reads the durable slot and re-enters the same deterministic terminal
operation; the delays only schedule work and never release the lifecycle fence.

The four-attempt budget is per manager process. An exhausted row stays held and reports
`retry-exhausted` with the remedy to restart the manager. The next process derives a fresh budget
from the still-authoritative durable row. A `recovered` row remains visible until the next static
reconciliation sweep, then clears. This component reports reconciliation outcomes. It does not say
whether footprint cleanup completed independently of the terminal result; that separate durable
projection remains tracked by #1274.

There is no supported `cotal service install` command yet. Running the manager as a launchd agent or
systemd user service remains operator-managed; service installation is separate from this boot-time
detection behavior.
Expand Down
2 changes: 1 addition & 1 deletion extensions/connector-core/src/docs-bundle.generated.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions implementations/cli/smoke/component-health.smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@ try {
const present = cli("status", "--components", "--space", SPACE, "--server", server);
const presentText = `${present.stdout}${present.stderr}`;
check("live lease-holder that never serves exits present-not-serving (2)", present.status === 2, presentText);
check("manager row names not-serving, its PID, and its unreported phase",
/manager\s+not-serving/.test(presentText) && presentText.includes(`pid ${held.pid}`) && presentText.includes("phase not reported by this manager build"), presentText);
check("manager row names not-serving, its PID, and its unreported static reconciliation",
/manager\s+not-serving/.test(presentText) && presentText.includes(`pid ${held.pid}`) && presentText.includes("static reconciliation not reported by this manager build"), presentText);
check("manager row names the lease holder rather than substituting service success",
/lease holder local\./.test(presentText) && presentText.includes("serve no answer"), presentText);

Expand Down
37 changes: 31 additions & 6 deletions implementations/cli/src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,14 @@ async function componentEp(target: MeshTarget): Promise<{ ep: CotalEndpoint; clo
async function managerServiceHealth(
target: MeshTarget,
auth: { creds?: string; caller: { owner: string; actor: string; uid: string } },
): Promise<{ instanceId?: unknown; runtime?: unknown }> {
): Promise<{
instanceId?: unknown;
runtime?: unknown;
staticReconciliation?: {
state?: unknown;
failures?: Array<{ alias?: unknown; phase?: unknown; disposition?: unknown; nextRetryAt?: unknown; remedy?: unknown }>;
};
}> {
const nc = await connect({
servers: target.server,
...standaloneConnectOpts(auth.creds ? { creds: auth.creds, tls: target.tlsRequired } : { tls: target.tlsRequired }),
Expand All @@ -618,7 +625,14 @@ async function managerServiceHealth(
const response = await invokeCommand(nc, target.space, service, "status", undefined, { deadlineMs: 3_000 });
if (response.reply.ok !== true)
throw new EpEnvelopeError(response.reply.error?.code === "unavailable" ? "unavailable" : "failed-precondition", response.reply.error?.message ?? "manager status refused");
return response.reply.data as { instanceId?: unknown; runtime?: unknown };
return response.reply.data as {
instanceId?: unknown;
runtime?: unknown;
staticReconciliation?: {
state?: unknown;
failures?: Array<{ alias?: unknown; phase?: unknown; disposition?: unknown; nextRetryAt?: unknown; remedy?: unknown }>;
};
};
} finally {
await nc.drain().catch(() => nc.close());
}
Expand All @@ -631,7 +645,7 @@ async function managerHealth(target: MeshTarget, context: LocalProcessContext):
// permission to replace it with a network answer. Name that failed local control surface first.
if (record.kind === "unattributable" || record.kind === "unknown") {
facts.push(record.kind === "unattributable" ? "unattributable pidfile" : "pid liveness unestablishable");
facts.push("phase not reported by this manager build");
facts.push("static reconciliation not reported by this manager build");
return { name: "manager", verdict: "refused", facts };
}
if (record.kind === "dead") facts.push("stale pidfile");
Expand All @@ -657,13 +671,24 @@ async function managerHealth(target: MeshTarget, context: LocalProcessContext):
});
facts.push(`service instance ${served.instanceId ?? "unreported"}`);
facts.push(`runtime ${served.runtime ?? "unreported"}`);
facts.push("phase not reported by this manager build");
const reconcile = served.staticReconciliation;
if (!reconcile) {
facts.push("static reconciliation not reported by this manager build");
} else {
facts.push(`static reconciliation ${String(reconcile.state ?? "unreported")}`);
for (const failure of reconcile.failures ?? []) {
const next = typeof failure.nextRetryAt === "string" ? ` nextRetryAt=${failure.nextRetryAt}` : "";
const remedy = typeof failure.remedy === "string" ? ` remedy=${failure.remedy}` : "";
facts.push(`static alias=${String(failure.alias ?? "unreported")} phase=${String(failure.phase ?? "unreported")} disposition=${String(failure.disposition ?? "unreported")}${next}${remedy}`);
}
}
facts.push("serve reachable");
// A manager service without its own liveness lease is a contradicted component surface, not a
// healthy one. It still reports reachability, but cannot claim the required lease holder.
return { name: "manager", verdict: lease ? "serving" : "not-serving", facts };
const reconcileFailed = reconcile?.state === "failed";
return { name: "manager", verdict: lease && !reconcileFailed ? "serving" : "not-serving", facts };
} catch (e) {
facts.push("phase not reported by this manager build");
facts.push("static reconciliation not reported by this manager build");
// A no-responder service rail or an absent manager registry is definitive no-service evidence.
// The lease and PID answer whether that missing service belongs to an extant component (not
// serving) or an absent one; any other failed probe remains a refusal.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"suite": "implementations/manager/smoke/manager-reconcile-redrive.smoke.ts",
"guard": "startup static reconciliation reports a failed sweep and re-drives the same durable exact terminal in process",
"command": "pnpm smoke:manager-reconcile-redrive",
"proveWith": "node scripts/mutation-proof.mjs --config implementations/manager/smoke/fixtures/manager-reconcile-redrive.mutations.json",
"why": [
"The suite starts a real authenticated nats-server through serverConfig and an in-process Manager, then plants three durable orphan lifecycle rows.",
"The first mutation changes only the failed aggregate branch. The earliest named cell observes the completed first sweep after all three shipped terminal paths ran, so it reddens because failure became success-shaped reporting rather than because an earlier row short-circuited.",
"The second mutation removes only the retry scheduling call after the real middle-row terminal failed. Earlier aggregate, durable hold, and status cells still run; the named re-drive cell is the first one that requires a second exact terminal entry."
],
"mutations": [
{
"name": "report a failed reconciliation sweep with the clean-success aggregate",
"file": "implementations/manager/src/manager.ts",
"find": " console.error(`! static reconcile completed: ${sweep.attempted} attempted, ${sweep.succeeded} succeeded, ${sweep.failed} failed; failed=${failed.join(\",\")}`);",
"replace": " console.error(`✓ static reconcile completed: ${sweep.attempted} attempted, ${sweep.attempted} succeeded, 0 failed`);",
"expectRed": "the first sweep is non-success and names the failed alias, phase, and disposition",
"cell": "the first sweep is non-success and names the failed alias, phase, and disposition"
},
{
"name": "do not schedule an in-process re-drive after the exact terminal fails",
"file": "implementations/manager/src/manager.ts",
"find": " if (this.staticReconcileSweepsInFlight > 0) item.disposition = \"retry-scheduled\";\n else this.scheduleStaticReconcileRetry(key, item);",
"replace": " item.disposition = \"retry-exhausted\";\n item.remedy = \"restart this manager for a fresh per-process retry budget\";",
"expectRed": "the same manager process re-drives the failed alias to retired",
"cell": "the same manager process re-drives the failed alias to retired"
}
]
}
Loading
Loading