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:
2026-10-10 00:14:00 -05:00
co-authored by Claude Opus 5.5
parent ef70ba6a64
commit a9cc522a0c
9 changed files with 459 additions and 17 deletions
+25 -1
View File
@@ -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,
+31 -3
View File
@@ -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 {