row 302 (#1809, Vikunja 588): a run threadIdle or landRelease released answers launcher-released on every call needing the claim until it ends, role.claim refuses it (Dewey D1), and the runner exits stopped without releasing again

This commit is contained in:
jason.woltje committed 2026-10-23 00:50:20 -05:00
1 parent f799e8b155
commit 64f6558ee4
5 files changed
+48 -20

No files matched your search

+8
View File
@@ -324,6 +324,14 @@ verb. It rechecks the thread and releases the run's claim in one call, and retur
launcher's snapshot. The launcher signals the run only after a release, so a message sent
after the snapshot gets a turn from the same run or waits as `pending` for the next one. It
refuses `not-holder` for a run that doesn't hold that thread and `run-ended` for an ended run.
After a release, and until the run ends, every call of the run's that needs the claim
(`message.receive`, `state`, `message.read`, `message.send` and every other verb that checks
the holder) refuses `launcher-released`, not `not-holder` (row 302). `role.claim` refuses it
too, before the claim, so the run can't claim again. A call
the broker answers after the release and before the signal is that stop, not a lost claim, so
the runner exits 0. The launch-landing pass's `landRelease` (`packages/cli/README.md`) answers
the same way for the role run it releases. Any other lost claim (a stop, a revoke, a clear, a
limit, a newer holder) still refuses `not-holder`.
A message delivered to a thread run that then ended without reading it is stranded, as
`stranded` (Agent state, below) describes for a role. It counts as neither `pending` nor
+12 -1
View File
@@ -419,6 +419,10 @@ export class Broker {
#hostRuns = new Map();
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
#stops = new Map();
// Row 302 (#1809): runs the launcher released, by business:run, until endLaunch. threadIdle and
// landRelease release and then signal, so the run's own calls in between answer launcher-released,
// not not-holder, and the runner exits stopped instead of as a lost session.
#launcherReleased = new Set();
#clock = 0;
#processing;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
@@ -1003,7 +1007,8 @@ export class Broker {
if (s.reader) fail('read-only');
if (s.human) fail('agent-required');
const h = this.#own(s);
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run)) fail('not-holder');
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run))
fail(this.#launcherReleased.has(s.business + ':' + s.run) ? 'launcher-released' : 'not-holder');
return h;
}
// Row 264a (#1769, T2): the generation a thread run holds, its latest claim's in its session, or
@@ -1053,6 +1058,7 @@ export class Broker {
if (t?.holder !== record.run) fail('not-holder');
if (t.pending || t.unread) return { released: false, pending: t.pending, unread: t.unread };
this.#store.transaction(() => this.#claimEnd(session, this.#own(session), 'release', null));
this.#launcherReleased.add(key);
return { released: true };
}
// Row 271 (264d, #1776, D7 steps 3-5): the business's clears still in the launcher's hands, as of
@@ -1307,6 +1313,7 @@ export class Broker {
const exitCode = record.exitCode ?? null;
if (exitCode !== null && !Number.isSafeInteger(exitCode)) fail('invalid-request');
this.#ended.add(key);
this.#launcherReleased.delete(key);
for (const [cap, s] of this.#sessions)
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
return this.#store.transaction(() => {
@@ -4161,6 +4168,7 @@ export class Broker {
);
if (unread) return { released: false, unread };
this.#store.transaction(() => this.#claimEnd(session, h, 'release', null, null, 'land-relaunch'));
this.#launcherReleased.add(key);
return { released: true };
}
// Row 194 (#1692, D1.1): the launcher's lifecycle pass. Trusted launcher API, not socket verbs.
@@ -5149,6 +5157,9 @@ export class Broker {
case 'role.claim': {
keys(a, []);
if (s.human) fail('agent-required');
// Row 302 (#1809, Dewey's D1): a run the launcher released is being stopped and doesn't claim
// again, or a later not-holder on that claim would answer launcher-released.
if (this.#launcherReleased.has(s.business + ':' + s.run)) fail('launcher-released');
// Row 121: a thread run claims its thread, so idle threads hold nothing (REQ-THREAD-2).
// Row 264a (#1769): the claim is the run's session's, with the session's next generation.
if (s.thread) {
+2
View File
@@ -563,6 +563,8 @@ each holder that `launch.instances` lists:
per run as `run <run>: landRelease refused: <code>`. After a release the
launch log gets a `land-relaunch` record (`instance`, `run`,
`decision`) and the runner gets SIGTERM, as `mosaic stop` sends it. The
run's calls between the release and the signal refuse `launcher-released`,
so the runner exits 0 either way (row 302). The
next tick starts the instance with the decision in its `landing`.
The launcher keeps none of this on disk. A host that restarts ends the
+1 -1
View File
@@ -293,7 +293,7 @@ it never touches the disk and isn't in the environment or argv.
| Exit | Meaning | Launch end reason |
|---|---|---|
| 0 | stopped | `stopped` |
| 0 | stopped: by a signal, or the launcher's idle stop or land-relaunch released the claim (`launcher-released`) | `stopped` |
| 2 | bad run directory or stdin | `failed` |
| 20 | founder credentials | `founder-credentials` |
| 21 | `role.claim` refused | `claim-refused` |
+25 -18
View File
@@ -53,6 +53,9 @@ export const FOUNDER_ENV = Object.freeze([
const USABLE = new Set(["valid", "expiring", "rotation-due"]);
// Capabilities that stopped working: the run ended, the claim was revoked.
const SESSION_GONE = new Set(["unauthenticated", "not-holder", "run-ended"]);
// Row 302 (#1809): the launcher's idle stop or land-relaunch released the claim and will stop the run.
// That is a stop, not a lost session, so the run exits stopped even when its call lands before the signal.
const LAUNCHER_RELEASED = "launcher-released";
const BODY_MAX = 32000;
const DEFAULTS = Object.freeze({ turnTimeout: 900, pollInterval: 2000, brokerRetries: 30 });
@@ -482,6 +485,20 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
let code = EXIT.stopped;
let failures = 0;
// Whether a broker refusal ends the run, and how: a launcher release stops it, a gone session is 22.
// The reply site has logged its failure already, so it passes say=false.
let released = false;
const ended = (err, say = true) => {
if (err.code === LAUNCHER_RELEASED) {
log("runner: session released by the launcher");
released = true;
return true;
}
if (!SESSION_GONE.has(err.code)) return false;
if (say) log(`runner: session stopped working: ${err.code}`);
code = EXIT.session;
return true;
};
let n = 0;
const sleep = (ms) => new Promise((r) => {
const t = setTimeout(r, ms);
@@ -500,9 +517,8 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
try {
block = stateBlock(await client.call("state"), new Date().toISOString());
} catch (err) {
if (SESSION_GONE.has(err.code)) {
log(`runner: session stopped working: ${err.code}`);
code = EXIT.session;
// A run in its landing turns hasn't received, so landRelease keeps it; thread runs have no landing.
if (ended(err)) {
gone = true;
break;
}
@@ -524,11 +540,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
messages = await client.call("message.receive");
failures = 0;
} catch (e) {
if (SESSION_GONE.has(e.code)) {
log(`runner: session stopped working: ${e.code}`);
code = EXIT.session;
break;
}
if (ended(e)) break;
if (++failures >= session.brokerRetries) {
log(`runner: broker unreachable after ${failures} tries: ${e.code ?? e.message}`);
code = EXIT.broker;
@@ -545,11 +557,8 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
try {
block = stateBlock(await client.call("state"), new Date().toISOString());
} catch (e) {
if (SESSION_GONE.has(e.code)) {
log(`runner: session stopped working: ${e.code}`);
code = EXIT.session;
break loop;
}
// Neither release happens while a delivered message is unread, so this sees one only by a bug.
if (ended(e)) break loop;
unread = `could not read the current state: ${e.code ?? e.message}`;
}
++n;
@@ -576,17 +585,15 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
await client.call("message.read", { id: m.id });
} catch (e) {
log(`runner: reply to ${m.id} failed: ${e.code ?? e.message}`);
if (SESSION_GONE.has(e.code)) {
code = EXIT.session;
break loop;
}
// As for the state above: the message isn't read yet, so no launcher release has happened.
if (ended(e, false)) break loop;
}
}
if (!messages.length) await sleep(session.pollInterval);
}
server.close();
rmSync(session.toolSocket, { force: true });
if (code === EXIT.stopped) {
if (code === EXIT.stopped && !released) {
try {
await client.call("role.release");
} catch (e) {