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
+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 {