conversation: release a cohort scope after a proven stop (row 50, #1536)
releaseCohort sends `release` only to a scope shim that answers hello with the recorded invocation ID, then waits for systemd to drop the unit. The controller releases once per claim, after a proven force stop and on close of a proven-stopped binding; every uncertain path keeps the scope as evidence. The harness's killShims refuses units outside ^mosaic-chat-, R5 checks liveShims positively, and K19's wait on proc.exited is bounded. Dewey's candidate, manifest 375594fc (8 files), approved by Filbert (comment 27053) and Darkwing (comment 27055). Normal engine exit still leaves the scope; the follow-up is #1537. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -14,7 +14,9 @@
|
||||
// every member and wait a bounded grace; freeze `engine` and wait for
|
||||
// `frozen 1`; enumerate every member with pid and start time; write
|
||||
// `cgroup.kill`; wait for `populated 0`. Only when all of that succeeded is
|
||||
// membership complete. Anything unavailable ends the stop `uncertain`.
|
||||
// membership complete. Anything unavailable ends the stop `uncertain`. The
|
||||
// scope stays up after the stop either way; `releaseCohort` ends it once the
|
||||
// controller has recorded a proven stop.
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
@@ -202,6 +204,28 @@ export async function forceStopCohort({ kind, unitName, invocationId, shimSocket
|
||||
};
|
||||
}
|
||||
|
||||
// Ends a scope whose cohort is proven stopped (#1536). The shim exits on
|
||||
// `release` only while `engine` reads `populated 0`, and systemd then
|
||||
// collects the empty scope. Call it only after a `proven` stop whose claim
|
||||
// is recorded `stopped`: after an `unavailable` one, the scope and its shim
|
||||
// are the evidence a later stop reads, and a collected scope is an absent
|
||||
// observation, never proof. Returns { outcome: "released" | "unavailable",
|
||||
// ... }; `unit` is `absent` once systemd no longer lists the scope.
|
||||
export async function releaseCohort({ kind, unitName, invocationId, shimSocket, waitMs = 3000 }) {
|
||||
if (kind !== "scope") return { outcome: "unavailable", reason: `a ${kind} cohort has no scope to release` };
|
||||
const hello = await shimRequest(shimSocket, "hello");
|
||||
if (!hello.ok) return { outcome: "unavailable", reason: hello.unavailable };
|
||||
if (!invocationId || hello.invocationId !== invocationId) return { outcome: "unavailable", reason: "the shim does not answer for the recorded scope; nothing released" };
|
||||
const released = await shimRequest(shimSocket, "release");
|
||||
if (!released.ok) return { outcome: "unavailable", reason: released.unavailable };
|
||||
const end = Date.now() + waitMs;
|
||||
for (;;) {
|
||||
if (systemdUnits.lookup({ unitName }).state === "absent") return { outcome: "released", unit: "absent" };
|
||||
if (Date.now() >= end) return { outcome: "released", unit: "still listed" };
|
||||
await sleep(20);
|
||||
}
|
||||
}
|
||||
|
||||
export function cohortProof({ binding, stop, result }) {
|
||||
return sealProof(record("cohortProof", {
|
||||
id: newId("cohort-proof"), authority: AUTHORITY, conversation: binding.scope.conversation, execution: binding.execution,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { processStart } from "../../discord/src/journal.mjs";
|
||||
import { ALREADY_ACTIVE, ClaimStore, FOREIGN_HOST, UNSAFE_REPLACEMENT, machineId, seatKey, sessionKey } from "./claim.mjs";
|
||||
import { AUTHORITY, PgroupLauncher, bootProof, cohortProof, cohortRefOf, effectReport, forceStopCohort, systemdUnits } from "./cohort.mjs";
|
||||
import { AUTHORITY, PgroupLauncher, bootProof, cohortProof, cohortRefOf, effectReport, forceStopCohort, releaseCohort, systemdUnits } from "./cohort.mjs";
|
||||
import { EngineLink } from "./engine.mjs";
|
||||
import { DIALOG_METHODS, KNOWN_UNSHOWN, NOTIFY_METHODS, deltaBlocks, isFoldedUpdate, messageBlocks, partsOf, roleOf, toolResultText } from "./events.mjs";
|
||||
import { LineSplitter, encodeLine, parseLine } from "./framing.mjs";
|
||||
@@ -224,12 +224,13 @@ export class Controller {
|
||||
this.eligibility = new Map();
|
||||
this.outcomeUnknown = new Set();
|
||||
this.escalating = null;
|
||||
this.scopeReleases = new Map();
|
||||
this.abortWritten = new Set();
|
||||
this.preflightOk = false;
|
||||
this.server = null;
|
||||
this.sockets = new Set();
|
||||
this.events = [];
|
||||
this.evidence = { dropped: { lines: 0, bytes: 0 }, unknownEvents: {}, unshown: {}, folded: 0, dialogs: [], notices: 0, gaps: [], overlaps: [], uncertain: [], stops: [], refusedRevisions: [], internal: [], stderrTail: "" };
|
||||
this.evidence = { dropped: { lines: 0, bytes: 0 }, unknownEvents: {}, unshown: {}, folded: 0, dialogs: [], notices: 0, gaps: [], overlaps: [], uncertain: [], stops: [], releases: [], refusedRevisions: [], internal: [], stderrTail: "" };
|
||||
}
|
||||
|
||||
get binding() {
|
||||
@@ -1555,9 +1556,34 @@ export class Controller {
|
||||
this.#push({ kind: "binding", binding: b });
|
||||
this.#emit("stopped", { stop: stop.id });
|
||||
this.#evidenceAdd("stop", { stop: stop.id, mode: "force-stop", outcome: "stopped", proofs: { cohort: proof.id, effects: effects.id }, resumed });
|
||||
const proven = this.claim;
|
||||
await this.#pause("scope-release", { stop: stop.id });
|
||||
await this.#releaseScope("force-stop", proven);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Ends the scope of a claim recorded `stopped` on a cohort proof, once per
|
||||
// claim (#1536). The force stop calls it for the claim it proved, once
|
||||
// that claim is recorded `stopped`; close calls it again, so a release the
|
||||
// stop didn't reach still happens. Every `fail` path above returns before
|
||||
// it: after an uncertain stop the scope and its shim stay as evidence. It
|
||||
// never rejects: a failed release is an `unavailable` entry.
|
||||
#releaseScope(why, claim = this.claim) {
|
||||
const rec = claim?.record;
|
||||
if (!rec || rec.state !== "stopped" || rec.proof?.kind !== "cohortProof" || !rec.shim) return null;
|
||||
let pending = this.scopeReleases.get(claim.claimId);
|
||||
if (!pending) {
|
||||
pending = releaseCohort({ kind: rec.engine?.kind, unitName: rec.unitName, invocationId: rec.invocationId, shimSocket: rec.shim }).catch((err) => ({ outcome: "unavailable", reason: `release: ${err.code ?? err.message}` })).then((result) => {
|
||||
const entry = { kind: "release", claim: claim.claimId, unitName: rec.unitName, why, ...result, at: this.now().toISOString() };
|
||||
this.evidence.releases.push(entry);
|
||||
this.#push({ kind: "evidence", evidence: entry });
|
||||
return result;
|
||||
});
|
||||
this.scopeReleases.set(claim.claimId, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
// check.mjs `stopped`.
|
||||
#stopped(s) {
|
||||
const b = this.b, p = this.stoppedProof;
|
||||
@@ -1659,10 +1685,12 @@ export class Controller {
|
||||
return "revoked";
|
||||
}
|
||||
|
||||
// Test and shutdown hook. Never a release: the claim is unchanged.
|
||||
// Test and shutdown hook. Never a claim release: the claim is unchanged.
|
||||
// A binding proven stopped has its scope released (#1536).
|
||||
async close({ killEngine = false } = {}) {
|
||||
const exec = this.exec;
|
||||
if (exec) exec.closing = true;
|
||||
if (this.b?.state === "stopped" && this.#stopped(this.stops.get(this.b.stop))) await this.#releaseScope("close");
|
||||
if (killEngine && typeof exec?.proc?.kill === "function") exec.proc.kill();
|
||||
else if (killEngine && exec?.proc?.pid) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user