feat(harness): meta-harness and PM launching off T3 (#1523, row 41)
Slice 1 S6. packages/harness runs a role session under the pi or claude
adapter behind a tool gate, and packages/seat holds the session and its
process. The bus host gains a launch socket: `mosaic bus start
<business> --pm <harness>:<model>` runs the PM without a window, the PM
launches role sessions through the broker within the business file's
`launch.max`, and every launch, refusal and end is a launch-log line and
a broker event. `mosaic talk`, `mosaic launches off|on|list` and
`mosaic stop <run>` are new CLI verbs. packages/bus gains the IPC ops
identity, authorize, refuse, endLaunch and credentialStatus, plus
Broker.endLaunch (decision 77); no socket verb, event kind or schema
changed. The session environment is an allowlist, and the runner exits
before claiming if a founder credential reaches it.
Filbert built it (agents/filbert/work/s6). Darkwing reviewed on #1523:
round 1 changes (26991/26995), round 2 changes (27004/27010), round 3
changes (27012/27032), round 4 approve (27033/27034) on manifest
08a78972, 42 paths.
Sage's gate on 64b0f9f1 (12:32Z), sequential and teed: node suites
business 60, bus 74, cli 83, control-board 124, conversation 182,
discord 178, harness 56, ledger 78, mosaic 69, queue 149, runs 41,
seat 27, tasks 51, webui 39, every one 0 failed; test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98 and 26 without Docker, all 0 failed.
64b0f9f1..ee7f909b touches none of the 42 paths. It landed after the
Q14 hold ended at 2026-10-11T17:19:23.857Z. The recorded PM-to-coder
run on a scratch data root is still owed before row 41 moves to done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -81,7 +81,8 @@ export class Broker {
|
||||
#store;
|
||||
#businesses;
|
||||
#sessions = new Map();
|
||||
#runs = new Set();
|
||||
#runs = new Map();
|
||||
#ended = new Set();
|
||||
#secretCheck;
|
||||
#clock = 0;
|
||||
constructor({ store, businesses, secretCheck = () => {} }) {
|
||||
@@ -169,7 +170,6 @@ export class Broker {
|
||||
if (record.address !== undefined) string(record.address, 1024);
|
||||
const key = record.business + ':' + record.run;
|
||||
if (this.#runs.has(key)) fail('duplicate-run');
|
||||
this.#runs.add(key);
|
||||
const session = { ...record, address: record.address ?? null, human: false };
|
||||
this.#secretCheck(record);
|
||||
const old = this.#store.get(
|
||||
@@ -185,9 +185,43 @@ export class Broker {
|
||||
};
|
||||
if (old) {
|
||||
if (old.actor_role !== record.role || old.body !== JSON.stringify(body)) fail('launch-record-mismatch');
|
||||
// An ended run stays ended across broker restarts.
|
||||
if (this.#store.get("SELECT 1 FROM events WHERE business=? AND kind='session.ended' AND subject=?", record.business, record.run))
|
||||
fail('run-ended');
|
||||
} else this.#store.transaction(() => this.#event(session, 'session.launched', body, record.run));
|
||||
this.#runs.set(key, session);
|
||||
return this.#cap(session);
|
||||
}
|
||||
// Trusted launcher API, the counterpart of bindLaunch and likewise NOT a socket verb. The S6 host
|
||||
// calls it once when a launched run's process exits: session.ended is written, the run's claim is
|
||||
// released if it still holds one, and every capability of the run stops working.
|
||||
endLaunch(record) {
|
||||
keys(record, ['business', 'run', 'reason', 'exitCode'], ['business', 'run', 'reason']);
|
||||
this.#business(identifier(record.business));
|
||||
const key = record.business + ':' + identifier(record.run);
|
||||
const session = this.#runs.get(key) ?? fail('unknown-run');
|
||||
if (this.#ended.has(key)) fail('run-ended');
|
||||
identifier(record.reason);
|
||||
const exitCode = record.exitCode ?? null;
|
||||
if (exitCode !== null && !Number.isSafeInteger(exitCode)) fail('invalid-request');
|
||||
this.#ended.add(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(() => {
|
||||
const h = this.#holder(session.business, session.role);
|
||||
const released = h?.op === 'claim' && h.holder_run === session.run;
|
||||
if (released) this.#claimEnd(session, h, 'release', null);
|
||||
this.#event(session, 'session.ended', { reason: record.reason, exitCode, released }, session.run);
|
||||
return { released };
|
||||
});
|
||||
}
|
||||
// Trusted launcher API: records a refusal the host decided itself (instance list, capacity,
|
||||
// credentials) as action.refused against the caller, the way request() records its own.
|
||||
refuse(cap, code) {
|
||||
const s = this.#session(cap);
|
||||
if (!/^[a-z-]{1,64}$/.test(code)) fail('invalid-request');
|
||||
return this.#refused(s, new BusError(code), null).code;
|
||||
}
|
||||
// Trusted human transport invokes only AFTER checking its CLI process and launch ancestry.
|
||||
bindHuman(record) {
|
||||
keys(record, ['business', 'human', 'via', 'outsideAgent'], ['business', 'human', 'via', 'outsideAgent']);
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
// never command arguments, stdout or service-token-bearing environment variables.
|
||||
import { startBroker } from './runtime.mjs';
|
||||
import { BusError } from './broker.mjs';
|
||||
const LAUNCH_OPS = new Set(['identity', 'authorizeLaunch', 'refuse', 'endLaunch', 'credentialStatus']);
|
||||
function launchOp(m) {
|
||||
if (m.op === 'identity') return runtime.identity(m.cap);
|
||||
if (m.op === 'authorizeLaunch') return runtime.authorizeLaunch(m.cap, m.instance);
|
||||
if (m.op === 'refuse') return runtime.refuse(m.cap, m.code);
|
||||
if (m.op === 'endLaunch') return runtime.endLaunch(m.record);
|
||||
return runtime.credentialStatus(m.business, m.role);
|
||||
}
|
||||
// The host's request id comes back on the reply, so a late reply can't
|
||||
// answer a later request (host.mjs). A message without one gets none.
|
||||
const tag = (message, reply) => (Number.isSafeInteger(message?.id) ? { ...reply, id: message.id } : reply);
|
||||
let runtime,
|
||||
booted = false,
|
||||
closing = false;
|
||||
@@ -25,9 +36,18 @@ if (!process.send) {
|
||||
try {
|
||||
if (message?.op === 'bindLaunch' && runtime) {
|
||||
try {
|
||||
process.send({ ok: true, launch: runtime.bindLaunch(message.record) });
|
||||
process.send(tag(message, { ok: true, launch: runtime.bindLaunch(message.record) }));
|
||||
} catch (e) {
|
||||
process.send({ ok: false, error: e instanceof BusError ? e.code : 'bind-refused' });
|
||||
process.send(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'bind-refused' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// S6 launcher ops, one reply each, like bindLaunch.
|
||||
if (LAUNCH_OPS.has(message?.op) && runtime) {
|
||||
try {
|
||||
process.send(tag(message, { ok: true, result: launchOp(message) }));
|
||||
} catch (e) {
|
||||
process.send(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'launch-op-refused' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -52,7 +72,7 @@ if (!process.send) {
|
||||
}
|
||||
process.send({ ok: true, path: runtime.path, launches: runtime.launches, readers: runtime.readers });
|
||||
} catch (e) {
|
||||
process.send?.({ ok: false, error: e instanceof BusError ? e.code : 'startup-refused' }, () =>
|
||||
process.send?.(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'startup-refused' }), () =>
|
||||
close(2),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Broker, BusError } from './broker.mjs';
|
||||
import { Credentials } from './credentials.mjs';
|
||||
import { serve } from './server.mjs';
|
||||
import { verifyHuman } from './human.mjs';
|
||||
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records.
|
||||
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
|
||||
// Neither a business-file writer nor a socket-accessible configuration endpoint.
|
||||
// `tasks` is S3's adapter factory: ({broker, credentials, businesses}) => {handle, timeout, close}.
|
||||
export async function startBroker({
|
||||
@@ -68,11 +68,34 @@ export async function startBroker({
|
||||
return broker.bindHuman({ business, human, via: 'cli', outsideAgent: true });
|
||||
},
|
||||
});
|
||||
// S6 launcher operations. The host decides capacity and the instance list; the broker
|
||||
// authorizes role.launch and keeps the evidence, refusals included.
|
||||
function authorizeLaunch(cap, instance) {
|
||||
try {
|
||||
return broker.authorize(cap, 'role.launch', { target: instance });
|
||||
} catch (e) {
|
||||
if (!(e instanceof BusError)) throw e;
|
||||
broker.refuse(cap, e.code);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// Metadata only (service, date, state); the token stays in Credentials.
|
||||
function credentialStatus(business, role) {
|
||||
return credentials
|
||||
.status()
|
||||
.filter((c) => c.instance === business + '/' + role)
|
||||
.map(({ service, date, state }) => ({ service, date, state }));
|
||||
}
|
||||
return {
|
||||
broker,
|
||||
credentials,
|
||||
path,
|
||||
bindLaunch,
|
||||
endLaunch: (r) => broker.endLaunch(r),
|
||||
identity: (cap) => broker.identity(cap),
|
||||
authorizeLaunch,
|
||||
refuse: (cap, code) => broker.refuse(cap, code),
|
||||
credentialStatus,
|
||||
launches: bound,
|
||||
readers: readCaps,
|
||||
async close() {
|
||||
|
||||
Reference in New Issue
Block a user