fix(bus,webui): pd review round, supersede after apply, one pack hire loop per org, hireError, fromPack (#1873, lane pd)

Review pd-review.md (changes 6). Fixed:
- P2#2: the owner's pack hire withdraws a pack-raised inbox decision only
  after its replacement hire applied; a replacement that refuses (route
  missing, prepare refusal) leaves the old decision open in the inbox.
- P3#3: an apply whose hire loop throws after the apply committed still
  answers the apply receipt, with no hires and the code as hireError; the
  route passes a code through and the card says the hires did not run.
- P3#5: the packs read marks an awaiting-owner line this pack's own hire
  raised fromPack: true; the owner's card counts it in "Hire the N".
- P3#6: one pack hire loop per business; a second refuses
  organization-busy and raises nothing.
- P2#1 docs: TOOLS.md and the bus README say no member surface calls
  pack.hire.console yet (the member surface is a recorded follow-up).

Deferred: P3#4 project agents naming their pack (projectUnion is shared
with the runtime).
This commit is contained in:
jason.woltje committed 2026-11-14 16:09:46 -06:00
1 parent 359b3299b4
commit 3e53af3941
9 files changed
+159 -30

No files matched your search

+1 -1
View File
@@ -346,7 +346,7 @@ For local development on a named instance only, `mosaic bus start BUSINESS --dev
The hire form and the autonomy panel from a terminal (E2, #1873, P1-3). `mosaic org role-templates|hire|fire|change ORG` and `mosaic org autonomy ORG [set|review]` send the page's own request to the running Console's org route (`/api/orgs/ORG/decisions/hire/templates|start`, `/api/orgs/ORG/autonomy[/review]`), as `org reload` does, so the Console applies the same owner, `--send`, origin, body and live-session checks and the same bus verbs run (`hire.templates.console`, `hire.start.console`, `autonomy.get.console`, `autonomy.set.console`, `autonomy.review.console`). `hire` and `change` read the templates first and send the chosen template's name and sha256, as the form does. `autonomy set` needs the 64-hex `--revision` from `mosaic org autonomy ORG` and sends every setting the panel sends, with only the named ones changed; a stale revision is refused `revision-conflict`; an empty `--authority-resolver ""` clears the resolver, as emptying the panel's field does. These verbs need a named instance (`MOSAIC_CONFIG` set to `~/.config/mosaic-NAME/config.json`) whose Console runs with `--dev-local-owner`; the default instance is refused with exit 3. A Console refusal exits 3 with the Console's code and message; `outcome-unknown` exits 1; an unknown template exits 2. Every one of these verbs, reads included, refuses inside an agent run. `mosaic tasks create|edit|archive` are the task board's three writes: the arguments pass the board's own check (`workspace-tasks.mjs parseTaskWrite`) and go to the bus as the person (`tasks.create|edit|archive`, human transport), so the bus runs its human, field, digest and archive checks. A stale `--expect` is refused `task-conflict`; read the digest with `mosaic tasks --json`. The hire form and the autonomy panel from a terminal (E2, #1873, P1-3). `mosaic org role-templates|hire|fire|change ORG` and `mosaic org autonomy ORG [set|review]` send the page's own request to the running Console's org route (`/api/orgs/ORG/decisions/hire/templates|start`, `/api/orgs/ORG/autonomy[/review]`), as `org reload` does, so the Console applies the same owner, `--send`, origin, body and live-session checks and the same bus verbs run (`hire.templates.console`, `hire.start.console`, `autonomy.get.console`, `autonomy.set.console`, `autonomy.review.console`). `hire` and `change` read the templates first and send the chosen template's name and sha256, as the form does. `autonomy set` needs the 64-hex `--revision` from `mosaic org autonomy ORG` and sends every setting the panel sends, with only the named ones changed; a stale revision is refused `revision-conflict`; an empty `--authority-resolver ""` clears the resolver, as emptying the panel's field does. These verbs need a named instance (`MOSAIC_CONFIG` set to `~/.config/mosaic-NAME/config.json`) whose Console runs with `--dev-local-owner`; the default instance is refused with exit 3. A Console refusal exits 3 with the Console's code and message; `outcome-unknown` exits 1; an unknown template exits 2. Every one of these verbs, reads included, refuses inside an agent run. `mosaic tasks create|edit|archive` are the task board's three writes: the arguments pass the board's own check (`workspace-tasks.mjs parseTaskWrite`) and go to the bus as the person (`tasks.create|edit|archive`, human transport), so the bus runs its human, field, digest and archive checks. A stale `--expect` is refused `task-conflict`; read the digest with `mosaic tasks --json`.
Starter packs (#1873): `mosaic org pack list`, `mosaic org pack preview ID PACK [--project P]`, `mosaic org pack apply ID PACK --digest D [--project P]` and `mosaic org pack hire ID PACK [--project P]` call the bus as the organization's human (`packs`, `pack.preview`, `pack.apply`, `pack.hire`). Ruling 2026-11-14 ("If I choose a starter pack and apply it, that is a decision!"): a person's apply is the hire decision. After the pack commits, the same act hires each proposed agent through the hire-decisions path and the receipt's `hires` lists `{name, state, decision | code}` per proposal (`hired`, `exists`, `open`, `awaiting-owner`, `refused`). The owner's apply approves the flagged and credential lines; a credential line is approved but not bound, so the agent's launch refuses `credential-unbound` until its services are bound. A member with `org.hire` (WebUI bus verb `pack.hire.console`) hires only the proposals with no flagged or credential line, and the rest wait for the owner in the inbox. An agent's apply hires nothing. `pack hire` reruns the loop on a pack already applied and hires none twice; it refuses `pack-not-applied` when the pack has no packs entry, and a proposal refuses `pack-hire-route-missing` when the chief of staff has no harness and model. All pack words refuse inside an agent run (exit 3). The WebUI pack card's "Hire the N proposed agents" is `POST /api/orgs/<id>/packs/hire`. Details: `packages/cli/README.md`, `packages/bus/README.md` and `packages/webui/README.md`, "Starter pack hires"; design: `docs/plans/2026-11-12_starter-packs-design.md` section 3. Starter packs (#1873): `mosaic org pack list`, `mosaic org pack preview ID PACK [--project P]`, `mosaic org pack apply ID PACK --digest D [--project P]` and `mosaic org pack hire ID PACK [--project P]` call the bus as the organization's human (`packs`, `pack.preview`, `pack.apply`, `pack.hire`). Ruling 2026-11-14 ("If I choose a starter pack and apply it, that is a decision!"): a person's apply is the hire decision. After the pack commits, the same act hires each proposed agent through the hire-decisions path and the receipt's `hires` lists `{name, state, decision | code}` per proposal (`hired`, `exists`, `open`, `awaiting-owner`, `refused`). The owner's apply approves the flagged and credential lines; a credential line is approved but not bound, so the agent's launch refuses `credential-unbound` until its services are bound. The bus verb `pack.hire.console` also takes a member's console cap with `org.hire`: it hires only the proposals with no flagged or credential line, and the rest wait for the owner in the inbox. No member surface calls it yet: the WebUI packs routes and the CLI `pack` words are the owner's. An owner's pack hire also replaces and approves a hire this pack's own member hire left in the inbox; the old decision is withdrawn only after the new hire applies. Two pack hires on one organization at once: the second refuses `organization-busy`; an apply whose hire loop did not run still answers the apply's receipt with `hireError` and no hires. An agent's apply hires nothing. `pack hire` reruns the loop on a pack already applied and hires none twice; it refuses `pack-not-applied` when the pack has no packs entry, and a proposal refuses `pack-hire-route-missing` when the chief of staff has no harness and model. All pack words refuse inside an agent run (exit 3). The WebUI pack card's "Hire the N proposed agents" is `POST /api/orgs/<id>/packs/hire`. Details: `packages/cli/README.md`, `packages/bus/README.md` and `packages/webui/README.md`, "Starter pack hires"; design: `docs/plans/2026-11-12_starter-packs-design.md` section 3.
The bus and Console lifecycle verbs refuse inside an agent run (agent-guard, #1873): `mosaic bus start`, `bus stop`, `bus restart` and `bus recover`, and `mosaic console start`, `console stop` and `console restart`, and `mosaic org archive` and `org delete`, which stop the organization's bus, and `mosaic org restore`, which starts it (`org restore --no-bus` starts nothing and is not refused), and `mosaic org role-templates`, `hire`, `fire`, `change` and `autonomy` (E2). Each exits 3 with `refused: this runs only from a human shell, outside any agent run (found MARKER in the environment)` before it reads anything. It checks the environment it is given and its own process environment, so a sanitized environment does not hide a marker. A usage error (a missing or extra argument, `--pm` with `--no-launch`, or a bad `--dev-insecure-bind`/`--dev-allow-host`) still comes first (exit 4). `mosaic bus status` and `mosaic console status` stay open to agents. The Console's Restart bus and Restart Console controls keep working: the Console starts its bus host with its own environment, which carries no marker, and Restart Console relaunches the Console under its supervisor without going through the CLI. The markers are the ones `packages/cli/src/transport.mjs` lists. The check is cooperative: a process that drops the markers from its environment is not stopped by it. The bus and Console lifecycle verbs refuse inside an agent run (agent-guard, #1873): `mosaic bus start`, `bus stop`, `bus restart` and `bus recover`, and `mosaic console start`, `console stop` and `console restart`, and `mosaic org archive` and `org delete`, which stop the organization's bus, and `mosaic org restore`, which starts it (`org restore --no-bus` starts nothing and is not refused), and `mosaic org role-templates`, `hire`, `fire`, `change` and `autonomy` (E2). Each exits 3 with `refused: this runs only from a human shell, outside any agent run (found MARKER in the environment)` before it reads anything. It checks the environment it is given and its own process environment, so a sanitized environment does not hide a marker. A usage error (a missing or extra argument, `--pm` with `--no-launch`, or a bad `--dev-insecure-bind`/`--dev-allow-host`) still comes first (exit 4). `mosaic bus status` and `mosaic console status` stay open to agents. The Console's Restart bus and Restart Console controls keep working: the Console starts its bus host with its own environment, which carries no marker, and Restart Console relaunches the Console under its supervisor without going through the CLI. The markers are the ones `packages/cli/src/transport.mjs` lists. The check is cooperative: a process that drops the markers from its environment is not stopped by it.
+10 -4
View File
@@ -1154,11 +1154,17 @@ decision}`. The route is the chief of staff's resolved harness and model; with
none the proposal refuses `pack-hire-route-missing`. The entry is written with none the proposal refuses `pack-hire-route-missing`. The entry is written with
no credential material, so a credential line leaves its services `unbound`. no credential material, so a credential line leaves its services `unbound`.
A proposal whose instance exists is `exists`; one an open hire decision names is A proposal whose instance exists is `exists`; one an open hire decision names is
`open`, so nothing is hired twice. The owner approves every line, and withdraws `open`, so nothing is hired twice. The owner approves every line, and replaces
an open decision an earlier hire of the same pack left for the owner before an open decision an earlier hire of the same pack left for the owner: the new
hiring the agent itself. A member's flagged or credentialed hires are raised after hire is raised and applied first, and the old decision is withdrawn only once it
has applied, so a replacement that refuses leaves the old one in the inbox. A member's flagged or credentialed hires are raised after
the others, left open for the owner's inbox (`awaiting-owner`). A hire that the others, left open for the owner's inbox (`awaiting-owner`). A hire that
refuses records `action.allowed {operation: 'pack.hire.refused', code}` and the refuses records `action.allowed {operation: 'pack.hire.refused', code}` and the
loop goes on. The `packs` read adds `hired` and `open` to each applied entry, loop goes on. The `packs` read adds `hired` and `open` to each applied entry,
and the `instances` read adds `pack: {id, origin, project}` to each instance a and the `instances` read adds `pack: {id, origin, project}` to each instance a
pack hire created. pack hire created. An `awaiting-owner` line this pack's own hire raised carries
`fromPack: true` (the owner's pack hire would replace it). One pack hire loop
runs per business at a time; a second refuses `organization-busy`. An apply
whose hire loop throws after the apply committed still answers the apply's
receipt, with no hires and the loop's code as `hireError`. No member surface
calls `pack.hire.console` yet; the Console's packs routes are the owner's.
+28 -12
View File
@@ -639,6 +639,9 @@ export class Broker {
#builtinBinding; #builtinBinding;
#organization; #organization;
#hires; #hires;
// Businesses with a pack hire loop running: a second loop on the same business refuses
// organization-busy rather than raising a second decision for the same agent.
#packHiring = new Set();
#autonomyHost; #autonomyHost;
#pluginService; #pluginService;
#credentialVerbs; #credentialVerbs;
@@ -6360,9 +6363,13 @@ export class Broker {
}; };
// Ruling 2026-11-14: a person's apply is the hire decision. Once the apply has committed, the // Ruling 2026-11-14: a person's apply is the hire decision. Once the apply has committed, the
// same call hires the proposals (#packHires); an agent's apply never hires. // same call hires the proposals (#packHires); an agent's apply never hires.
// The apply has committed by then, so a hire loop that throws (organization-busy, a store error)
// still returns the apply's receipt, with no hires and the loop's code as hireError.
if (verb === 'pack.apply' && !agent) { if (verb === 'pack.apply' && !agent) {
const hired = async (value) => { const hired = async (value) => {
const out = await this.#packHires(s, () => this.#packHireCaller(s), { pack: args.pack, project: args.project ?? null }); let out;
try { out = await this.#packHires(s, () => this.#packHireCaller(s), { pack: args.pack, project: args.project ?? null }); }
catch (e) { out = { hires: [], hireError: e instanceof BusError && typeof e.code === 'string' ? e.code : 'outcome-unknown' }; }
return done({ ...value, ...out }); return done({ ...value, ...out });
}; };
return Promise.resolve(result).then(value => value, packFail).then(hired); return Promise.resolve(result).then(value => value, packFail).then(hired);
@@ -6835,7 +6842,8 @@ export class Broker {
for (const h of a.proposedHires) { for (const h of a.proposedHires) {
if (Object.hasOwn(level, h.name)) continue; if (Object.hasOwn(level, h.name)) continue;
const decision = open.get(JSON.stringify([h.name, a.project ?? null])); const decision = open.get(JSON.stringify([h.name, a.project ?? null]));
if (decision) { a.open.push({ name: h.name, reason: h.reason, state: 'awaiting-owner', decision }); continue; } // fromPack: this pack's own hire raised it, so the owner's pack hire replaces and approves it.
if (decision) { a.open.push({ name: h.name, reason: h.reason, state: 'awaiting-owner', decision, ...(raisedBy.has(decision) && mine(raisedBy.get(decision)) ? { fromPack: true } : {}) }); continue; }
const last = events.filter(e => mine(e) && e.hire === h.name).at(-1); const last = events.filter(e => mine(e) && e.hire === h.name).at(-1);
if (last?.operation === 'pack.hire.refused') a.open.push({ name: h.name, reason: h.reason, state: 'refused', code: last.code }); if (last?.operation === 'pack.hire.refused') a.open.push({ name: h.name, reason: h.reason, state: 'refused', code: last.code });
else a.open.push({ name: h.name, reason: h.reason, state: 'proposed' }); else a.open.push({ name: h.name, reason: h.reason, state: 'proposed' });
@@ -6856,6 +6864,12 @@ export class Broker {
// refuses records action.allowed pack.hire.refused with its code, and the loop goes on. // refuses records action.allowed pack.hire.refused with its code, and the loop goes on.
async #packHires(s, callerOf, { pack, project = null }) { async #packHires(s, callerOf, { pack, project = null }) {
if (!this.#organization || !this.#hires) fail('not-configured'); if (!this.#organization || !this.#hires) fail('not-configured');
if (this.#packHiring.has(s.business)) fail('organization-busy');
this.#packHiring.add(s.business);
try { return await this.#packHireLoop(s, callerOf, { pack, project }); }
finally { this.#packHiring.delete(s.business); }
}
async #packHireLoop(s, callerOf, { pack, project }) {
const caller = callerOf(); const caller = callerOf();
const read = () => this.#organization(s.business, 'pack.state', { pack, project }, caller.actor, {}); const read = () => this.#organization(s.business, 'pack.state', { pack, project }, caller.actor, {});
const state = read(); const state = read();
@@ -6874,18 +6888,13 @@ export class Broker {
if (state.names.includes(name)) { out.push({ name, state: 'exists' }); continue; } if (state.names.includes(name)) { out.push({ name, state: 'exists' }); continue; }
const openId = open.get(JSON.stringify([name, project])); const openId = open.get(JSON.stringify([name, project]));
// The owner's pack hire approves every line, so an open decision an earlier pack hire of this // The owner's pack hire approves every line, so an open decision an earlier pack hire of this
// same pack left for the owner is withdrawn by the owner and replaced by one approved now. Its // same pack left for the owner is replaced by one approved now (its pin may be stale after the
// pin may be stale after the hires approved since. Any other open decision is left alone. // hires approved since). The old one is withdrawn only once the new hire has applied: a
// replacement that refuses leaves it open in the owner's inbox. Any other open decision is left alone.
let supersedes = null; let supersedes = null;
if (openId) { if (openId) {
const by = raisedBy.get(openId); const by = raisedBy.get(openId);
if (caller.owner !== true || !by || by.pack !== entry.id || (by.origin ?? null) !== (entry.origin ?? null) || (by.project ?? null) !== project) { out.push({ name, state: 'open', decision: openId }); continue; } if (caller.owner !== true || !by || by.pack !== entry.id || (by.origin ?? null) !== (entry.origin ?? null) || (by.project ?? null) !== project) { out.push({ name, state: 'open', decision: openId }); continue; }
this.#store.transaction(() => {
if (this.#closed(openId)) return;
const seq = Number(this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
openId, this.#now(), 'withdrawn', caller.actor, null, null, caller.via ?? 'console').lastInsertRowid);
this.#event(s, 'human.input', { kind: 'answer', decision: openId, op: 'withdrawn', choice: null, via: caller.via ?? 'console', subject: caller.via === 'cli' ? null : s.console, decisionSeq: seq }, openId);
});
supersedes = openId; supersedes = openId;
} }
let input; let input;
@@ -6894,11 +6903,18 @@ export class Broker {
input = { op: 'hire', level: project === null ? 'org' : 'project', ...(project === null ? {} : { project }), name, role: this.#pinRole(state.noHire, hire), route: structuredClone(state.route), input = { op: 'hire', level: project === null ? 'org' : 'project', ...(project === null ? {} : { project }), name, role: this.#pinRole(state.noHire, hire), route: structuredClone(state.route),
reason: typeof hire.reason === 'string' && hire.reason.trim() ? hire.reason : `Proposed by starter pack ${entry.id}.` }; reason: typeof hire.reason === 'string' && hire.reason.trim() ? hire.reason : `Proposed by starter pack ${entry.id}.` };
} catch (e) { refused(name, e); continue; } } catch (e) { refused(name, e); continue; }
let r;
try { try {
const r = await this.#raiseHire(s, input, true, { caller: callerOf, pack: provenance, defer: caller.owner !== true }); r = await this.#raiseHire(s, input, true, { caller: callerOf, pack: provenance, defer: caller.owner !== true });
} catch (e) { refused(name, e); continue; }
if (r?.deferred) { deferred.push(input); continue; } if (r?.deferred) { deferred.push(input); continue; }
if (supersedes) this.#store.transaction(() => {
if (this.#closed(supersedes)) return;
const seq = Number(this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
supersedes, this.#now(), 'withdrawn', caller.actor, null, null, caller.via ?? 'console').lastInsertRowid);
this.#event(s, 'human.input', { kind: 'answer', decision: supersedes, op: 'withdrawn', choice: null, via: caller.via ?? 'console', subject: caller.via === 'cli' ? null : s.console, decisionSeq: seq }, supersedes);
});
out.push({ name, state: 'hired', decision: r.id, ...(supersedes ? { supersedes } : {}) }); out.push({ name, state: 'hired', decision: r.id, ...(supersedes ? { supersedes } : {}) });
} catch (e) { refused(name, e); }
} }
for (const input of deferred) { for (const input of deferred) {
try { try {
+43
View File
@@ -253,3 +253,46 @@ test('PD the new codes are in the refusal table with a rail and a next step that
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[c].nextStep)); assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[c].nextStep));
} }
}); });
test('PD the owner supersede withdraws an inbox decision only after its replacement hires; a refused replacement leaves it open', async t => {
const h = await harness(t, { route: false });
h.install(mixkit(h.rolesDir));
await apply(h.call, { pack: 'mixkit' });
const setRoute = (vars) => { const doc = h.file(); if (vars) doc.roles.chief.vars = { ...vars }; else delete doc.roles.chief.vars; writeFileSync(h.path, JSON.stringify(doc, null, 2) + '\n'); };
setRoute(ROUTE);
const out = await h.call('pack.hire.console', { pack: 'mixkit' }, h.member);
const waiting = out.hires.filter(x => x.state === 'awaiting-owner');
assert.equal(waiting.length, 2);
// The packs read marks each member-raised line as raised by a pack hire.
const open = (await h.call('packs.console')).applied.find(a => a.id === 'mixkit').open;
assert.deepEqual(open.map(o => [o.name, o.state, o.fromPack]), [['coder-1', 'awaiting-owner', true], ['reviewer-1', 'awaiting-owner', true]]);
// The chief loses its route; the owner's pack hire cannot build a replacement.
setRoute(null);
const owner = await h.call('pack.hire.console', { pack: 'mixkit' });
assert.deepEqual(owner.hires.map(x => [x.name, x.state, x.code ?? null]), [['scout-1', 'exists', null], ['coder-1', 'refused', 'pack-hire-route-missing'], ['reviewer-1', 'refused', 'pack-hire-route-missing']]);
// Both inbox decisions are still open: nothing withdrew them.
for (const x of waiting) assert.deepEqual(h.store.all('SELECT op FROM decision_events WHERE decision=?', x.decision), []);
// With the route back, the owner's pack hire replaces them, withdrawing each only once its replacement applied.
setRoute(ROUTE);
const done = await h.call('pack.hire.console', { pack: 'mixkit' });
assert.deepEqual(done.hires.map(x => [x.name, x.state, x.supersedes ?? null]), [['scout-1', 'exists', null], ['coder-1', 'hired', waiting[0].decision], ['reviewer-1', 'hired', waiting[1].decision]]);
for (const x of waiting) assert.deepEqual(h.store.all('SELECT op, by FROM decision_events WHERE decision=?', x.decision).map(r => [r.op, r.by]), [['withdrawn', 'jason']]);
});
test('PD a second pack hire on the same organization while one runs refuses organization-busy and raises nothing', async t => {
const h = await harness(t, { route: false });
await apply(h.call);
const doc = h.file();
doc.roles.chief.vars = { ...ROUTE };
writeFileSync(h.path, JSON.stringify(doc, null, 2) + '\n');
const first = h.call('pack.hire.console', { pack: 'coding' });
const second = h.call('pack.hire.console', { pack: 'coding' });
await assert.rejects(second, /organization-busy/);
const hired = await first;
assert.deepEqual(hired.hires.map(x => [x.name, x.state]), [['pm-1', 'hired'], ['coder-1', 'hired'], ['reviewer-1', 'hired']]);
// One decision per hire: the refused call raised nothing.
assert.equal(h.op('decision.raise').length, 3);
// Once the loop ends the latch is free again: the next pack hire runs and finds nothing to hire.
const later = await h.call('pack.hire.console', { pack: 'coding' });
assert.ok(later.hires.every(x => x.state === 'exists'), JSON.stringify(later));
});
+7 -3
View File
@@ -76,12 +76,15 @@ peers in dev mode is a recorded follow-up.
"Damnit. If I choose a starter pack and apply it, that is a decision!" "Damnit. If I choose a starter pack and apply it, that is a decision!"
`POST /api/orgs/<id>/packs/apply` (`pack.apply.console`) hires the pack's `POST /api/orgs/<id>/packs/apply` (`pack.apply.console`) hires the pack's
proposed agents in the same act, and its receipt is `{revision, added, pack, proposed agents in the same act, and its receipt is `{revision, added, pack,
hires}`. `POST /api/orgs/<id>/packs/hire` with `{pack, project?}` hires}`; when the apply committed but its hire loop did not run, `hires` is
empty and `hireError` names the code, and the card says the hires did not run.
`POST /api/orgs/<id>/packs/hire` with `{pack, project?}`
(`pack.hire.console`) runs the same loop on a pack already applied and answers (`pack.hire.console`) runs the same loop on a pack already applied and answers
`{pack, hires}`; `pack-not-applied` is 404 and `right-required` 403. Each hire `{pack, hires}`; `pack-not-applied` is 404 and `right-required` 403. Each hire
line is `{name, state, decision | code}`. `GET /api/orgs/<id>/packs` adds, to line is `{name, state, decision | code}`. `GET /api/orgs/<id>/packs` adds, to
each applied entry, `hired` (the agents its hires created) and `open` (each each applied entry, `hired` (the agents its hires created) and `open` (each
proposal not hired, with its reason, its state and its decision or code). The proposal not hired, with its reason, its state and its decision or code, and
`fromPack: true` on an `awaiting-owner` line this pack's own hire raised). The
packs routes stay the owner's: a member's pack hire is the bus verb's packs routes stay the owner's: a member's pack hire is the bus verb's
(`pack.hire.console` needs `org.hire`, and the flagged hires wait for the (`pack.hire.console` needs `org.hire`, and the flagged hires wait for the
owner), but no member page calls it yet. owner), but no member page calls it yet.
@@ -89,7 +92,8 @@ owner), but no member page calls it yet.
The pack card says what applying does before the click ("applying is the hire The pack card says what applying does before the click ("applying is the hire
decision, and approves the flagged and credential lines below"), shows "N decision, and approves the flagged and credential lines below"), shows "N
agents hired" and the open proposals with their reasons after it, and offers agents hired" and the open proposals with their reasons after it, and offers
"Hire the N proposed agents" for proposals not waiting for the owner. Apply, "Hire the N proposed agents" for proposals not waiting for the owner, and for
the owner's own `fromPack` lines, which the owner's hire replaces and approves. Apply,
hire and fire dispatch `mosaic-org-changed`, so the Agents page and the hire and fire dispatch `mosaic-org-changed`, so the Agents page and the
organization page redraw in the same cycle instead of at the next 10-second organization page redraw in the same cycle instead of at the next 10-second
poll. Each agent card a pack hire made says "from pack X". The owner's Agents poll. Each agent card a pack hire made says "from pack X". The owner's Agents
+4 -2
View File
@@ -130,7 +130,7 @@ export async function packsHTTP(req, res, { path, owner, read, write, workflows
if (!Array.isArray(result.packs)) return refuse(503, 'outcome-unknown'); if (!Array.isArray(result.packs)) return refuse(503, 'outcome-unknown');
const applied = Array.isArray(result.applied) ? result.applied.filter(plain).map(a => ({ id: a.id, origin: a.origin ?? null, project: a.project ?? null, const applied = Array.isArray(result.applied) ? result.applied.filter(plain).map(a => ({ id: a.id, origin: a.origin ?? null, project: a.project ?? null,
hired: Array.isArray(a.hired) ? a.hired.filter(n => typeof n === 'string') : [], hired: Array.isArray(a.hired) ? a.hired.filter(n => typeof n === 'string') : [],
open: Array.isArray(a.open) ? a.open.filter(plain).map(o => ({ name: o.name, reason: typeof o.reason === 'string' ? o.reason : '', state: o.state, ...(typeof o.code === 'string' ? { code: o.code } : {}), ...(typeof o.decision === 'string' ? { decision: o.decision } : {}) })) : [] })) : []; open: Array.isArray(a.open) ? a.open.filter(plain).map(o => ({ name: o.name, reason: typeof o.reason === 'string' ? o.reason : '', state: o.state, ...(typeof o.code === 'string' ? { code: o.code } : {}), ...(typeof o.decision === 'string' ? { decision: o.decision } : {}), ...(o.fromPack === true ? { fromPack: true } : {}) })) : [] })) : [];
return json(res, 200, { packs: result.packs, applied, writable: Boolean(write) }); return json(res, 200, { packs: result.packs, applied, writable: Boolean(write) });
} }
if (step === 'preview') { if (step === 'preview') {
@@ -146,7 +146,9 @@ export async function packsHTTP(req, res, { path, owner, read, write, workflows
return json(res, 200, { pack: result.pack, hires }); return json(res, 200, { pack: result.pack, hires });
} }
if (!REVISION.test(result.revision ?? '')) return refuse(503, 'outcome-unknown'); if (!REVISION.test(result.revision ?? '')) return refuse(503, 'outcome-unknown');
return json(res, 200, { revision: result.revision, added: Array.isArray(result.added) ? result.added : [], ...(step === 'apply' ? { pack: result.pack, hires: hires ?? [] } : {}) }); // hireError: the apply committed but its hire loop did not run (organization-busy, outcome-unknown).
const hireError = step === 'apply' && typeof result.hireError === 'string' && /^[a-z0-9-]{1,64}$/.test(result.hireError) ? { hireError: result.hireError } : {};
return json(res, 200, { revision: result.revision, added: Array.isArray(result.added) ? result.added : [], ...(step === 'apply' ? { pack: result.pack, hires: hires ?? [], ...hireError } : {}) });
} }
// SP6: the workflow list and editor's read. Only workflows, the names a step role may resolve to and the // SP6: the workflow list and editor's read. Only workflows, the names a step role may resolve to and the
+6 -2
View File
@@ -40,6 +40,8 @@
'definition-not-hireable': 'its role is on the no-hire list', 'definition-not-hireable': 'its role is on the no-hire list',
'starter-pack-proposal-stale': 'the pack changed since it was applied; apply it again', 'starter-pack-proposal-stale': 'the pack changed since it was applied; apply it again',
'human-required': 'only a person can hire', 'human-required': 'only a person can hire',
'organization-busy': 'another pack hire on this organization was running',
'outcome-unknown': 'the bus did not say why',
}; };
const whyText = code => HIRE_WHY[code] ?? code; const whyText = code => HIRE_WHY[code] ?? code;
const OPEN_STATE = { 'awaiting-owner': 'waiting for the owner in the inbox', proposed: 'proposed, not hired yet' }; const OPEN_STATE = { 'awaiting-owner': 'waiting for the owner in the inbox', proposed: 'proposed, not hired yet' };
@@ -120,7 +122,9 @@
const hired = `<p data-pack-hired="${esc(a.project ?? '')}"><strong>${n === 0 ? 'No agents hired yet' : `${n} ${n === 1 ? 'agent' : 'agents'} hired`}</strong>${where}${n ? `: ${a.hired.map(x => `<code>${esc(x)}</code>`).join(', ')}` : ''}</p>`; const hired = `<p data-pack-hired="${esc(a.project ?? '')}"><strong>${n === 0 ? 'No agents hired yet' : `${n} ${n === 1 ? 'agent' : 'agents'} hired`}</strong>${where}${n ? `: ${a.hired.map(x => `<code>${esc(x)}</code>`).join(', ')}` : ''}</p>`;
const open = a.open.filter(plainObj); const open = a.open.filter(plainObj);
const lines = open.length ? `<ul class="pack-open" data-pack-open>${open.map(o => `<li data-pack-open-hire="${esc(o.name)}" data-pack-open-state="${esc(o.state)}"><code>${esc(o.name)}</code>: ${esc(o.reason || 'No reason given.')} <span class="muted">${esc(o.state === 'refused' ? `not hired: ${whyText(o.code)}` : OPEN_STATE[o.state] ?? o.state)}</span></li>`).join('')}</ul>` : ''; const lines = open.length ? `<ul class="pack-open" data-pack-open>${open.map(o => `<li data-pack-open-hire="${esc(o.name)}" data-pack-open-state="${esc(o.state)}"><code>${esc(o.name)}</code>: ${esc(o.reason || 'No reason given.')} <span class="muted">${esc(o.state === 'refused' ? `not hired: ${whyText(o.code)}` : OPEN_STATE[o.state] ?? o.state)}</span></li>`).join('')}</ul>` : '';
const hireable = open.filter(o => o.state !== 'awaiting-owner').length; // The page is the owner's, and the owner's pack hire also approves a line this pack's own hire
// left in the inbox (fromPack). A decision raised some other way stays the inbox's.
const hireable = open.filter(o => o.state !== 'awaiting-owner' || o.fromPack === true).length;
const button = hireable && s.writable ? `<button class="btn" type="button" data-pack-hire-button data-pack-hire-project="${esc(a.project ?? '')}" data-focus-key="${esc(`pack-hire:${org}:${entry.id}:${a.project ?? ''}`)}">Hire the ${hireable} proposed ${hireable === 1 ? 'agent' : 'agents'}</button>` : ''; const button = hireable && s.writable ? `<button class="btn" type="button" data-pack-hire-button data-pack-hire-project="${esc(a.project ?? '')}" data-focus-key="${esc(`pack-hire:${org}:${entry.id}:${a.project ?? ''}`)}">Hire the ${hireable} proposed ${hireable === 1 ? 'agent' : 'agents'}</button>` : '';
return `<div class="pack-applied">${hired}${lines}${button}</div>`; return `<div class="pack-applied">${hired}${lines}${button}</div>`;
}).join(''); }).join('');
@@ -174,7 +178,7 @@
const r = await call(`${base(org)}/packs/apply`, { pack: p.pack, digest: p.value.digest, ...(p.project ? { project: p.project } : {}) }); const r = await call(`${base(org)}/packs/apply`, { pack: p.pack, digest: p.value.digest, ...(p.project ? { project: p.project } : {}) });
const added = Array.isArray(r.added) && r.added.length ? r.added.join(', ') : 'nothing new'; const added = Array.isArray(r.added) && r.added.length ? r.added.join(', ') : 'nothing new';
s.preview = null; s.preview = null;
s.status = `Applied. Added ${added}. ${hireSummary(r.hires)}`; s.status = `Applied. Added ${added}. ${typeof r.hireError === 'string' ? `The hires did not run: ${whyText(r.hireError)}. Use Hire on the pack card to run them.` : hireSummary(r.hires)}`;
applied = true; applied = true;
} }
} catch (error) { s.status = error.message; } } catch (error) { s.status = error.message; }
@@ -29,7 +29,9 @@ const PREVIEW = {
skills: { notEnabled: [] }, chief: true, plan: {}, digest: REV('d'), skills: { notEnabled: [] }, chief: true, plan: {}, digest: REV('d'),
}; };
async function fixture(t) { // busy: the bus's apply committed but its hire loop did not run (hireError), and the packs read lists one line a
// member's pack hire left in the owner's inbox (fromPack), one raised some other way and one refused.
async function fixture(t, { busy = false } = {}) {
const verbs = [], decisions = []; const verbs = [], decisions = [];
const hired = new Map(); // name -> its pack provenance, as the bus's instances read reports it const hired = new Map(); // name -> its pack provenance, as the bus's instances read reports it
let applied = null, route = false, instanceReads = 0; let applied = null, route = false, instanceReads = 0;
@@ -42,7 +44,8 @@ async function fixture(t) {
return { name: h.name, state: 'hired', decision }; return { name: h.name, state: 'hired', decision };
}); });
const listed = () => applied ? [{ id: 'software-team', origin: 'shipped', project: null, hired: [...hired.keys()], const listed = () => applied ? [{ id: 'software-team', origin: 'shipped', project: null, hired: [...hired.keys()],
open: PREVIEW.hires.filter(h => !hired.has(h.name)).map(h => ({ name: h.name, reason: `Proposed by starter pack software-team for ${h.definition} work.`, state: 'refused', code: 'pack-hire-route-missing' })) }] : []; open: PREVIEW.hires.filter(h => !hired.has(h.name)).map(h => ({ name: h.name, reason: `Proposed by starter pack software-team for ${h.definition} work.`,
...(!busy || h.name === 'scout-1' ? { state: 'refused', code: 'pack-hire-route-missing' } : { state: 'awaiting-owner', decision: `inbox-${h.name}`, ...(h.name === 'coder-1' ? { fromPack: true } : {}) }) })) }] : [];
const read = async (verb, args) => { const read = async (verb, args) => {
verbs.push([verb, args]); verbs.push([verb, args]);
if (verb === 'packs.console') return { packs: PACKS, applied: listed() }; if (verb === 'packs.console') return { packs: PACKS, applied: listed() };
@@ -52,7 +55,7 @@ async function fixture(t) {
}; };
const write = async (verb, args) => { const write = async (verb, args) => {
verbs.push([verb, args]); verbs.push([verb, args]);
if (verb === 'pack.apply.console') { applied = true; return { revision: REV('f'), added: ['board'], pack: { id: args.pack, version: '1.0.0' }, hires: hireLoop() }; } if (verb === 'pack.apply.console') { applied = true; return { revision: REV('f'), added: ['board'], pack: { id: args.pack, version: '1.0.0' }, ...(busy ? { hires: [], hireError: 'organization-busy' } : { hires: hireLoop() }) }; }
if (verb === 'pack.hire.console') return { pack: args.pack, hires: hireLoop() }; if (verb === 'pack.hire.console') return { pack: args.pack, hires: hireLoop() };
throw Object.assign(new Error('not-configured'), { code: 'not-configured' }); throw Object.assign(new Error('not-configured'), { code: 'not-configured' });
}; };
@@ -164,3 +167,20 @@ test('PD apply hires the proposals, the pack card counts them, Hire the N propos
assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-agents] [data-agent-card]').length`), 4); assert.equal(await b.evaluate(`document.querySelectorAll('[data-org-agents] [data-agent-card]').length`), 4);
assert.equal(await b.evaluate(`document.querySelector('[data-agents-receipt]').textContent`), 'Fired reviewer-1. Recorded decision fire-1.'); assert.equal(await b.evaluate(`document.querySelector('[data-agents-receipt]').textContent`), 'Fired reviewer-1. Recorded decision fire-1.');
}); });
test('PD an apply whose hire loop did not run says so; the owner card counts a pack-raised inbox line as hireable and leaves the inbox its own', { timeout: 90000 }, async t => {
const f = await fixture(t, { busy: true });
const { b } = f;
await b.navigate(`http://127.0.0.1:${f.port}/#/orgs/acme/organization`);
await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-preview-button]')`, 'pack list');
await b.evaluate(`document.querySelector('[data-pack="software-team"] [data-pack-preview-button]').click();true`);
await waitFor(b, `document.querySelector('[data-pack-apply]')`, 'preview');
await b.evaluate(`document.querySelector('[data-pack-apply]').click();true`);
await waitFor(b, `/^Applied\\./.test(document.querySelector('[data-packs-status]')?.textContent??'')`, 'apply status');
assert.equal(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), 'Applied. Added board. The hires did not run: another pack hire on this organization was running. Use Hire on the pack card to run them.');
await waitFor(b, `document.querySelector('[data-pack="software-team"] [data-pack-open]')`, 'open lines');
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-pack-open-hire]')].map(l=>[l.dataset.packOpenHire,l.dataset.packOpenState])`),
[['coder-1', 'awaiting-owner'], ['reviewer-1', 'awaiting-owner'], ['scout-1', 'refused']]);
// coder-1 was left in the inbox by a pack hire, so the owner's pack hire approves it; reviewer-1 is the inbox's.
assert.equal(await b.evaluate(`document.querySelector('[data-pack-hire-button]').textContent`), 'Hire the 2 proposed agents');
});
+37 -3
View File
@@ -25,7 +25,7 @@ import { close } from './fixture.mjs';
const FLOW = (title) => ({ description: `${title}.`, steps: [{ title, state: 'todo', roles: ['human'] }] }); const FLOW = (title) => ({ description: `${title}.`, steps: [{ title, state: 'todo', roles: ['human'] }] });
const BOARD = { columns: [{ state: 'todo', label: 'Backlog' }, { state: 'in-progress', label: 'Building' }, { state: 'in-review', label: 'Review' }, { state: 'blocked', label: 'Blocked' }, { state: 'done', label: 'Merged' }], layout: 'kanban' }; const BOARD = { columns: [{ state: 'todo', label: 'Backlog' }, { state: 'in-progress', label: 'Building' }, { state: 'in-review', label: 'Review' }, { state: 'blocked', label: 'Blocked' }, { state: 'done', label: 'Merged' }], layout: 'kanban' };
async function fixture(t, { send = true, workflows = true, route = false } = {}) { async function fixture(t, { send = true, workflows = true, route = false, tap = null } = {}) {
const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); }); const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); });
const root = mkdtempSync(join(tmpdir(), 'pk-')); const root = mkdtempSync(join(tmpdir(), 'pk-'));
const rolesDir = rolesCopy(root), system = systemFor(root); const rolesDir = rolesCopy(root), system = systemFor(root);
@@ -57,7 +57,9 @@ async function fixture(t, { send = true, workflows = true, route = false } = {})
const upstream = `http://127.0.0.1:${board.address().port}`; const upstream = `http://127.0.0.1:${board.address().port}`;
const consoleCall = kind => async (verb, args, ctx) => { calls.push([kind, verb, Object.keys(args).sort()]); return new Client({ path, console: { subject: ctx.subject } }).call(verb, args); }; const consoleCall = kind => async (verb, args, ctx) => { calls.push([kind, verb, Object.keys(args).sort()]); return new Client({ path, console: { subject: ctx.subject } }).call(verb, args); };
const requestRead = consoleCall('read'); const requestRead = consoleCall('read');
const packsWrite = packsCaller(consoleCall('write')); // tap: a test's view of each write's bus result before the route shapes it.
const writer = packsCaller(consoleCall('write'));
const packsWrite = tap ? async (verb, args, ctx) => tap(verb, await writer(verb, args, ctx)) : writer;
const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true }; const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true };
let active = true; let active = true;
const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) }; const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) };
@@ -71,7 +73,7 @@ async function fixture(t, { send = true, workflows = true, route = false } = {})
sso.publicOrigin = base; sso.publicHost = new URL(base).host; sso.publicOrigin = base; sso.publicHost = new URL(base).host;
undo.push(() => close(web)); undo.push(() => close(web));
const onDisk = () => JSON.parse(readFileSync(file, 'utf8')); const onDisk = () => JSON.parse(readFileSync(file, 'utf8'));
return { base, file, root, calls, onDisk, flowReads, boardHits, failFlows: () => { flowFails = true; }, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } }; return { base, file, root, calls, onDisk, broker: runtime.broker, flowReads, boardHits, failFlows: () => { flowFails = true; }, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } };
} }
function call(f, path, method = 'GET', body, headers = {}) { function call(f, path, method = 'GET', body, headers = {}) {
@@ -412,3 +414,35 @@ test('PD with no chief route the apply records the pack and each hire says pack-
assert.deepEqual(list.applied[0].hired, []); assert.deepEqual(list.applied[0].hired, []);
assert.deepEqual(list.applied[0].open.map(o => [o.name, o.state, o.code, typeof o.reason]), [['pm-1', 'refused', 'pack-hire-route-missing', 'string'], ['coder-1', 'refused', 'pack-hire-route-missing', 'string'], ['reviewer-1', 'refused', 'pack-hire-route-missing', 'string']]); assert.deepEqual(list.applied[0].open.map(o => [o.name, o.state, o.code, typeof o.reason]), [['pm-1', 'refused', 'pack-hire-route-missing', 'string'], ['coder-1', 'refused', 'pack-hire-route-missing', 'string'], ['reviewer-1', 'refused', 'pack-hire-route-missing', 'string']]);
}); });
test('PD a member-raised awaiting-owner line reads fromPack; the owner hire route approves it; an apply whose hire loop did not run says hireError', async t => {
const f = await fixture(t);
const [, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
await call(f, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest });
const doc = f.onDisk();
doc.roles.chief.vars = { harness: 'pi', model: 'claude-opus-5-5' };
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n');
// A member with org.hire runs the pack hire on the bus (no WebUI route serves a member yet).
const member = f.broker.bindHireConsole({ business: 'demo', actor: 'alice', subject: { provider: 'test', iss: 'test', sub: 'alice' }, rights: ['org.hire', 'board.read'] });
const out = await f.broker.request(member, { verb: 'pack.hire.console', args: { pack: 'coding' } });
const waiting = out.hires.filter(x => x.state === 'awaiting-owner').map(x => x.name);
assert.ok(waiting.length > 0, JSON.stringify(out));
const [, list] = await call(f, `${ORG}/packs`);
const open = list.applied[0].open.filter(o => o.state === 'awaiting-owner');
assert.deepEqual(open.map(o => [o.name, o.fromPack]), waiting.map(n => [n, true]));
const [status, hired] = await call(f, `${ORG}/packs/hire`, 'POST', { pack: 'coding' });
assert.equal(status, 200, JSON.stringify(hired));
assert.deepEqual(hired.hires.filter(x => waiting.includes(x.name)).map(x => [x.name, x.state]), waiting.map(n => [n, 'hired']));
assert.deepEqual((await call(f, `${ORG}/packs`))[1].applied[0].open, []);
// The bus answers an apply whose hire loop did not run with hireError; the route passes a code through
// and drops anything that is not one.
for (const [given, shown] of [['organization-busy', 'organization-busy'], ['Not A Code!', undefined], [7, undefined]]) {
const g = await fixture(t, { tap: (verb, result) => verb === 'pack.apply.console' ? { ...result, hires: [], hireError: given } : result });
const [, p] = await call(g, `${ORG}/packs/preview`, 'POST', { pack: 'coding' });
const [code, receipt] = await call(g, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: p.digest });
assert.equal(code, 200, JSON.stringify(receipt));
assert.equal(receipt.hireError, shown);
assert.deepEqual(receipt.hires, []);
}
});