Compare commits

...
16 changed files with 554 additions and 70 deletions

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
# Row 239 (#1745) build notes (append only)
## 2026-10-22 b1 (Honker)
Design d1 5da5f28f6 was approved by Duckworth with C1 and C2 and no blockers (duckworth-0165, note
agents/duckworth/work/row239/REVIEW-design-d1.md). The code is on honker/row239 from aca16404c. No test
has run yet: every run waits for Sage's CLEAR.
### Duckworth's conditions and answers
- C1 (option a): `outcome-unknown-revoked` is in the Console's `OPEN` list (webui bus.mjs), so
`sendOutcome` answers it as `outcome-unknown` with `sent: 'unknown'` and never "Nothing changed". T6
covers it and M3f is its mutant.
- C2: `write()` sets `revoked = true` after `beforeWrite()` returns, and a catch around the whole
`withLock(...)` call marks the error. So a throw from the lock's release in its finally is marked as
well. This is structural and has no test, as Duckworth said.
- Q1: `beforeWrite` runs after both validations. Q2: two codes. Q3: M2 removes the `:188` check (now
organization.mjs:195).
### Notes N1-N5
- N1: `beforeWrite` must not write the same business, because it would find its own lock and refuse
`organization-busy`. This is said in the comments on `write()` and `commit()`. Today's revokes (the
bus's credential revoke) write no business file.
- N2: the lock is held across the revoke. A fire or a delete now keeps other writers of that business
out for as long as the revoke takes. They refuse `organization-busy`, as they do for any held lock.
- N3: a revoke that fails partway (some tokens revoked, then `credential-revoke-failed`) behaves as
today. The error carries no `revoked` mark, because `beforeWrite` didn't return, and nothing is
written. This row doesn't change that.
- N4: a delete closed with `outcome-unknown-revoked` exits 3 in the CLI, as every closed refusal does.
Exit 1 (transport EXIT) applies only to a direct verb answer.
- N5: I grepped for "before the write" and similar comments. The ones left (lifecycle-revoke.test:2,
instance-settings:56, cli instances.mjs:53, serve.mjs:365) are still true and are unchanged. T21 in
bus lifecycle-revoke.test:173 is unchanged.
### Differences from the design
- The business tests are in lifecycle-settings.test.mjs, not instance-settings.test.mjs. The fire tests
and their fixture already live there. The fixture now returns `dir` and `rolesDir` too.
- T3 covers T3b (the delete) in the same test, looping over fire and delete. It also checks that a
revoke throwing `credential-revoke-failed` carries no mark and writes nothing.
- T4b is the plain-code check. The design's "T4b, outcome-unknown-revoked for an fs-style error" is in
T4, which checks EIO with and without the mark for both a fire and a delete.
- The bus test's fake org gets `deleteError`, thrown from its `instances.set` branch, so T4 can drive a
delete.
- transport.test gets one assertion, `busExit("outcome-unknown-revoked") === 1`, for M3g.
- A latent Console text: a direct (not closed-request) `revision-conflict-revoked` answer in the
Console would still get " Nothing changed." appended, because only `OPEN` codes skip it. That sentence
is true there: the file is unchanged. No direct human path gives this code today, since a delete
reaches the Console as a closed request's refusal.
- The Console's STATUS entry for `outcome-unknown-revoked` (503) can't be reached while the code is in
`OPEN`. It stays for symmetry with the CLI and is harmless.
### What changed
- packages/business/src/organization.mjs: `write()` takes `beforeWrite`, runs it under the lock after
the revision check and both validations, and marks any later error `revoked`. The body is reindented
inside the try.
- packages/business/src/instance-settings.mjs: `instanceSettings` takes an optional `writer`. `commit()`
passes `beforeWrite` to `writer.write` instead of calling it. The early check stays, and its comment
is rewritten.
- packages/bus/src/broker.mjs: `#organizationFail` maps a marked error to `revision-conflict-revoked` or
`outcome-unknown-revoked` before the existing mappings. `#fireJob`'s notice adds "Its credential
tokens may already be revoked." for a `-revoked` code.
- packages/bus/src/runtime.mjs: comment only.
- packages/cli/src/instances.mjs: two REASONS entries. packages/cli/src/transport.mjs: EXIT
`outcome-unknown-revoked` 1.
- packages/webui/src/serve.mjs: two AGENT_REASONS entries. packages/webui/src/bus.mjs: `OPEN` entry and
two STATUS entries.
- Tests:
- business lifecycle-settings.test: "row 239 T1", "row 239 T2", "row 239 T3".
- bus fire-revokes-grants.test: "row 239 T4", "row 239 T4b".
- cli instances.test: "row 239 T5 CLI". cli transport.test: one assertion in "busExit and
refuseInsideAgent".
- webui agents.test: "row 239 T6".
- bus channel-proof.test re-pins packages/cli/src/transport.mjs (662642a8...) and
packages/webui/src/bus.mjs (1eba9774...), with a comment on each. Neither changes a proof or the
transport. The design's file list missed this. The pin grep found it before the first run.
### Mutants for the gate (exact-once replacements)
- M1 (instance-settings.mjs, beforeWrite before the lock): replace
` const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });` with
` if (beforeWrite) beforeWrite();\n const receipt = writer.write(business, next, { revision, actor, context });`.
The kill is lifecycle-settings "row 239 T1" (the revoke spy has a call).
- M2 (organization.mjs, the fire refusal dropped): delete the line
` if (revision !== before) fail("revision-conflict", "organization revision changed; read again");`.
The kill is "row 239 T1" (the write lands over the hand edit).
- M3a (organization.mjs, the mark dropped): ` if (revoked && e instanceof Error) e.revoked = true;`
becomes ` void revoked;`. The kills are "row 239 T2" and "row 239 T3" (business). T4 drives the mark
through a fake org, so it doesn't see M3a.
- M3b (broker.mjs): delete the line
` if (e.revoked) fail(e.code === "revision-conflict" ? "revision-conflict-revoked" : "outcome-unknown-revoked");`.
The kill is fire-revokes-grants "row 239 T4".
- M3c (cli instances.mjs): delete the five lines from ` "revision-conflict-revoked": "the business file`
through ` "business file again with \`mosaic business instances\` before trying again",`. The kill is
instances.test "row 239 T5 CLI".
- M3d (webui serve.mjs): delete the two lines starting ` 'revision-conflict-revoked': 'the business file`
and ` 'outcome-unknown-revoked': 'the agent\'s tokens`. The kill is agents.test "row 239 T6".
- M3e (broker.mjs): ` const tokens = code.endsWith('-revoked') ? ' Its credential tokens may already be revoked.' : '';`
becomes ` const tokens = '';`. The kill is "row 239 T4".
- M3f (webui bus.mjs, C1): the `OPEN` line loses `, 'outcome-unknown-revoked'`. The kill is "row 239 T6"
(the launch POST answers 503 outcome-unknown, sent unknown).
- M3g (cli transport.mjs): delete the line ` "outcome-unknown-revoked": 1,`. The kill is transport.test
"busExit and refuseInsideAgent".
A kill counts only as a failed assertion in the named test, not a load error or a different test.
### Gate plan (mos-1275 timeouts)
- My row 256 gate (2026-10-22 10:34Z, same machine) took 590s for the cli package, 57s for webui and
38s for test-foundation, the longest shell script.
- Packages: `timeout -k 10 1800` per package (3x the cli run).
- Each scripts/test-*.sh: `timeout -k 10 300` (more than 3x the 38s longest).
- Single files and mutants: `timeout -k 10 600` per file run (cli instances.test whole is the longest
single file).
- Order on Sage's CLEAR: the touched test files whole (business lifecycle-settings, bus
fire-revokes-grants and channel-proof, cli instances and transport, webui agents), then M1-M3g, then
the full gate.
- Before the in-review move: grep packages/*/tests for the changed comment and error text and for
64-hex pins on the changed files, and drift-check BASE..refactor against them.
## 2026-10-22 r2 rebase (Honker)
- Duckworth approved b1 b0c28366f statically, with no blockers (duckworth-0168). On N1 (`endsWith('-revoked')`
would also match `access-revoked`, which can't reach a fire job today), the check stays as it is, so the
approved candidate doesn't change before the gate (honker-0163).
- Rows 253 (48e450ce8) and 275 landed. b1 is rebased onto refactor 94c33a43c as honker/row239-r2. The
push branch honker/row239 isn't rewritten.
- Drift between aca16404c and 94c33a43c on the 15 paths: only packages/bus/src/broker.mjs, where row 275
changes the viewer verbs. Neither of row 239's two hunks (`#fireJob` and `#organizationFail`) moves in
meaning. The rebase applied with no conflict. The two pinned files (cli transport.mjs, webui bus.mjs)
and channel-proof.test have no upstream change, so the b1 pins stand.
- Each M1-M3g replacement string still matches exactly once.
- The touched test files run whole again on the new base, in the slot (the rebase rule).
+159
View File
@@ -0,0 +1,159 @@
# Row 239 design: the revoke under the writer's lock, and a fire test
Brief: docs/plans/2026-10-20_agent-lifecycle.md, "Brief: row 194 follow-up, the revoke under the writer's
lock and a fire test" (blob 3b3b3b2b9), from Duckworth's row 194 b3 N1 and mos-1158. Base aca16404c (row
194 landed as fd6dd327c, row 281 as e479521d3). Builder Honker, reviewer Duckworth, gate owner Sage.
Draft 1, 2026-10-22.
## Today
- `instance-settings.mjs` `commit()` runs the checks, then compares the caller's revision with a fresh
`writer.read` (row 194 b3, sage-1634), then calls `beforeWrite` (the fire's or the delete's credential
revoke), then `writer.write`.
- `writer.write` (`organization.mjs`) takes the lock, runs `recover`, checks the revision again (`:188`),
validates, prepares, checks the bytes once more before publication (`:221`), renames and commits.
- So two windows are left.
- A write that lands between `commit()`'s read and the writer's lock still gets a revoke and then a
`revision-conflict`.
- Any refusal after the revoke ran (the `:221` check, or an I/O failure the bus reports as
`outcome-unknown`) uses the same text as one before it, so nothing says the tokens may be gone.
- Row 281 left this path alone. Its fire job (`broker.mjs` `#fireJob`) runs step G (grants), then step B,
which is this same `write('instance.fire')`, and reports a step B failure as `fire.failed` along with a
human notice that names the code. Its delete job only does step G. The delete itself still commits
through `lifecycleDelete` → `instances.set` op delete → `commit()`.
## What changes
### 1. `beforeWrite` runs inside `writer.write`, under the lock (organization.mjs)
`write(id, document, { revision, actor, detail, context, beforeWrite = null })`. The order under the lock:
1. `recover`, read, then the `:188` revision check. A refusal here revokes nothing.
2. Projects-protected and both validations. A refusal here revokes nothing either. I put `beforeWrite`
after these rather than straight after `:188`, so a document the writer would refuse anyway never
costs a revoke. Q1 asks Duckworth to confirm this.
3. `beforeWrite()`. If it throws (today `credential-revoke-failed`), the error propagates as it is and
nothing has been written, as now.
4. Prepare, the `:221` check, rename and commit, wrapped so that any error thrown after step 3 gets
`revoked = true` set on it before it is rethrown. That covers `revision-conflict` from `:221`, an
`organization-refused` from `log()`, a barrier throw, and an fs error.
`record()` and the other writers (`launch-settings`, `plugin-settings`, `credentialVerbs`) pass no
`beforeWrite` and behave as before.
### 2. commit() passes beforeWrite through (instance-settings.mjs)
`commit()` passes `beforeWrite` to `writer.write` and no longer calls it itself. The row 194 b3 early check
stays. It costs one read, keeps T21's refusal before any validation work, and is no longer what protects
the revoke. Its comment is rewritten to say so, and the "(BUILD.md follow-up)" sentence goes.
`instanceSettings({ ..., writer = organizationWriter({ dir, rolesDir }) })` takes an optional writer, so a
test can inject one (the brief's "injected writer"). `runtime.mjs` passes none.
### 3. The bus names a refusal after a revoke (broker.mjs `#organizationFail`)
The bus answers with a code only, and the CLI and Console look the text up by code. So the "may already be
revoked" signal has to be a code. Two new codes:
- `revision-conflict-revoked`: `e.revoked && e.code === 'revision-conflict'`.
- `outcome-unknown-revoked`: `e.revoked` with any other code. The write may or may not have landed, since a
failure after the rename is recovered as committed.
Both are checked before the existing mappings. An error without `revoked` maps exactly as today, so a
refusal before any revoke keeps today's code and text. Q2 asks about the names.
### 4. The texts
- CLI `instances.mjs` REASONS:
- `revision-conflict-revoked`: "the business file changed before the write, after the agent's tokens
were revoked; the tokens may already be revoked and the instance can't use them; read the instances
again and run the same command again, which is safe".
- `outcome-unknown-revoked`: "the write's outcome is unknown and the agent's tokens may already be
revoked; read the instances before trying again".
- Console `serve.mjs` AGENT_REASONS: the same two, in the Console's wording ("delete again").
- `#fireJob`'s `fire.failed` notice: when the code ends in `-revoked`, it adds "Its credential tokens may
already be revoked." The chief's synchronous fire (no grants) answers with the code, as every chief verb
does.
- `transport.mjs` EXIT: `outcome-unknown-revoked` exits 1, as `outcome-unknown` does. Console
`bus.mjs` STATUS: `revision-conflict-revoked` 409, `outcome-unknown-revoked` 503. A delete's code
reaches both as a closed request's `refusal`, so these two only matter for a direct verb answer.
## Tests
- **T1, the brief's fire test** (business `instance-settings.test.mjs`). The fire uses an injected writer
that wraps the real one. Its `write` makes one hand edit of the business file (a vars change on another
instance) and then calls the real `write`, so the edit lands after `commit()`'s early check and before
the lock's `:188` check. Expected:
- the fire refuses `revision-conflict` without `revoked`;
- the revoke spy has no calls;
- the business file's bytes are the hand edit's;
- changes.jsonl has no new prepared entry.
- **T2** (same file). A fire on a real writer built with `barrier: stage => stage === 'prepared' && edit()`.
The revoke runs, then `:221` refuses. Expected: `revision-conflict` with `revoked === true`, one revoke
call, the file's bytes are the edit's, and the last receipt is `aborted`.
- **T3** (same file). The same, with a `prepared` barrier that throws. Expected: the error has
`revoked === true`, one revoke call, and the file unchanged. A delete through the same writer gives the
same result (T3b).
- **T4** (bus `fire-revokes-grants.test.mjs`, its injected `f.org`). `f.org.fireError` is a
`revision-conflict` with `revoked: true`. Expected:
- the job's `fire.failed` carries `revision-conflict-revoked`, and the inbox message says "may already
be revoked";
- the same with no `grants` (the synchronous fire) refuses `revision-conflict-revoked`;
- a plain `revision-conflict` still maps to `revision-conflict` and the message lacks the sentence.
- T4b: `outcome-unknown-revoked` for an fs-style error with `revoked`.
- **T5** (cli `lifecycle.test.mjs` or `instances.test.mjs`, whichever already reads a closed delete's
refusal). A delete closed with `revision-conflict-revoked` prints the new text, and one closed with
`revision-conflict` prints today's.
- **T6** (webui `agents.test.mjs`). The same pair for the Console's reason.
- T21 (bus `lifecycle-revoke.test.mjs`) stays as it is, for delete.
## Mutants
| Mutant | Change | Killed by |
| --- | --- | --- |
| M1 | `beforeWrite` before the lock: `commit()` calls `beforeWrite()` itself and passes none to `write` | T1 (the revoke spy has a call) |
| M2 | The fire refusal dropped: the `:188` revision check removed | T1 (the write lands over the hand edit) |
| M3a | The mark dropped: no `revoked = true` in the catch | T2, T3, and T4 through the codes |
| M3b | `#organizationFail` ignores `revoked` | T4 |
| M3c | CLI text: the two REASONS entries removed | T5 |
| M3d | Console text: the two AGENT_REASONS entries removed | T6 |
| M3e | The notice: the `fire.failed` sentence removed | T4 |
M3a-M3e together are the brief's "drop the may already be revoked text".
## Files
- `packages/business/src/organization.mjs`
- `packages/business/src/instance-settings.mjs`
- `packages/business/tests/instance-settings.test.mjs`
- `packages/bus/src/broker.mjs`
- `packages/bus/tests/fire-revokes-grants.test.mjs`
- `packages/cli/src/instances.mjs`
- `packages/cli/src/transport.mjs`
- one cli test file
- `packages/webui/src/serve.mjs`
- `packages/webui/src/bus.mjs`
- `packages/webui/tests/agents.test.mjs`
- This packet.
## Gate
As the brief says:
- Duckworth approves the manifest.
- The bus, business, cli and webui suites and every `scripts/test-*.sh` are green on Sage's slot, with
the touched test files run whole.
- M1-M3e are killed.
Before the in-review move I grep the tests for the changed comment and error text, and for hash pins on the
changed files.
## Questions for Duckworth
- **Q1.** Should `beforeWrite` run after the writer's validations (my pick) or straight after `:188` (the
brief's literal place)? Running it later means a document the writer would refuse revokes nothing.
- **Q2.** Are two new codes, `revision-conflict-revoked` and `outcome-unknown-revoked`, the right way to
carry the signal? The alternative is to give `BusError` a detail field, which every CLI and Console
reader would then have to learn. The codes are additive and no existing code changes meaning.
- **Q3.** The brief's mutant "drop the fire refusal": I read it as removing the `:188` check (M2). If you
meant the fire test's own refusal, say so and I'll redefine it.
+6 -1
View File
@@ -1957,11 +1957,13 @@ export class Broker {
} catch (e) {
const code = e instanceof BusError ? e.code : 'outcome-unknown';
const list = out.grants.map((g) => g.grant).join(', ') || 'none';
// Row 239 (#1745): a step B refusal after the writer's revoke ran.
const tokens = code.endsWith('-revoked') ? ' Its credential tokens may already be revoked.' : '';
this.#store.transaction(() => {
this.#event(sc, 'action.allowed', { action: 'fire.failed', job, step, code }, name);
this.#humanNotice(
sc.business,
`Fire of ${name}: job ${job} revoked its grants (${list}) but stopped at step ${step} (${code}). The instance is still firing. Run the fire again once ${code} is cleared.`,
`Fire of ${name}: job ${job} revoked its grants (${list}) but stopped at step ${step} (${code}).${tokens} The instance is still firing. Run the fire again once ${code} is cleared.`,
);
});
}
@@ -4415,6 +4417,9 @@ export class Broker {
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
// Row 239 (#1745): the writer marks an error raised after a fire's or a delete's revoke ran, so
// the human reads that the tokens may already be revoked. Without the mark, today's code.
if (e.revoked) fail(e.code === "revision-conflict" ? "revision-conflict-revoked" : "outcome-unknown-revoked");
if (e.code === "revision-conflict") fail("revision-conflict");
if (e.code?.startsWith("organization-")) fail(e.code);
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
+5 -5
View File
@@ -148,11 +148,11 @@ export async function startBroker({
const levels=organization?await harnessLevels():()=>null;
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels}):null;
// Row 194 (#1692, brief item 8, row 199 D13): a fire or a delete revokes the agent's credentials
// after the writer's checks and before its write. Fire revokes each service of the entry and keeps
// the store directory, delete calls remove (sage-1593). A service the running bus doesn't know (an
// agent created since the start) has nothing in memory, so it goes on, and then no token or tmp
// file may remain in the agent's store directory (sage-1592). Any failure stops the verb with the
// file unchanged.
// after the writer's checks and before its write, under the writer's lock (row 239). Fire revokes
// each service of the entry and keeps the store directory, delete calls remove (sage-1593). A
// service the running bus doesn't know (an agent created since the start) has nothing in memory,
// so it goes on, and then no token or tmp file may remain in the agent's store directory
// (sage-1592). Any failure stops the verb with the file unchanged.
const revokeFailed = (message) => new OrganizationError('credential-revoke-failed', message);
function revoke(business, agent, how, services) {
const instance = business + '/' + agent;
+5 -2
View File
@@ -17,7 +17,8 @@ import { Client } from "../src/client.mjs";
const baselines = {
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
"packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
// Row 239 (#1745): EXIT outcome-unknown-revoked 1; no proof or transport change.
"packages/cli/src/transport.mjs": "662642a8ce739fcb6c188ad07125a94e008c8a305eced127c8041d472c56e55b",
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
// organization-unknown-instance, organization-credentials-out-of-scope, organization-launch-not-configured,
// organization-no-change, organization-project-mismatch, organization-unresolvable-instance), no proof or
@@ -25,7 +26,9 @@ const baselines = {
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
// organization-tracker-bot-taken 409; no proof or transport change.
"packages/webui/src/bus.mjs": "15e8123ffdbb6d067e8a516f5823efdb5de3c869963764f27cd14bb9e4ce3313",
// Row 239 (#1745): outcome-unknown-revoked joins OPEN (duckworth-0165 C1), and STATUS gets
// revision-conflict-revoked 409 and outcome-unknown-revoked 503; no proof or transport change.
"packages/webui/src/bus.mjs": "1eba977443b3e308dbc5b08567b11e1059bb238a5f57939c6917dd775156e5cf",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
@@ -133,8 +133,9 @@ async function setup(t) {
},
};
// The fake organization writer. A fire delists the name and a delete drops the role, each with a
// reload, as the runtime reloads after a commit. `fireError` makes the fire's write refuse.
const org = { log: [], fireError: null };
// reload, as the runtime reloads after a commit. `fireError` makes the fire's write refuse, and
// `deleteError` the delete's (row 239).
const org = { log: [], fireError: null, deleteError: null };
const sw = { state: { version: 1, halted: false, generation: GENERATION } };
let broker;
const organization = (business, op, args) => {
@@ -146,6 +147,7 @@ async function setup(t) {
return { revision: REV, transaction: 't-' + org.log.length };
}
if (op === 'instances.set') {
if (org.deleteError) throw org.deleteError;
delete doc.roles[args.instance];
doc.launch.instances = doc.launch.instances.filter((n) => n !== args.instance);
broker.reloadBusiness('demo', doc);
@@ -952,3 +954,44 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
assert.ok(!f.doc.launch.instances.includes('coder'));
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 1);
});
// Row 239 (#1745): the writer marks an error raised after its own revoke ran (revoked: true), and the
// bus names it, so the human reads that the tokens may already be revoked. Unmarked, today's code.
test("row 239 T4: a refusal after the writer's revoke is named -revoked, in the job's fire.failed and notice, a synchronous fire and a delete", async (t) => {
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
const out = f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'revision-conflict-revoked']);
const [message] = f.inbox();
assert.ok(message.includes('(revision-conflict-revoked). Its credential tokens may already be revoked. The instance is still firing.'), message);
// helper has no grant, so its fire writes synchronously and answers with the code.
await rejects(() => f.fire('helper'), 'revision-conflict-revoked');
// Any other marked error: the write may have landed.
f.org.fireError = Object.assign(new Error('EIO: i/o error'), { code: 'EIO', revoked: true });
await rejects(() => f.fire('helper'), 'outcome-unknown-revoked');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
await rejects(() => f.fire('helper'), 'revision-conflict');
f.org.fireError = null;
f.org.deleteError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
assert.equal(f.remove('helper').code, 'revision-conflict-revoked');
f.org.deleteError = Object.assign(new Error('EIO: i/o error'), { code: 'EIO', revoked: true });
assert.equal(f.remove('helper').code, 'outcome-unknown-revoked');
f.org.deleteError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
assert.equal(f.remove('helper').code, 'revision-conflict');
});
test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice says nothing of the tokens", async (t) => {
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
const out = f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.code], [out.job, 'revision-conflict']);
const [message] = f.inbox();
assert.ok(message.includes('(revision-conflict). The instance is still firing.'), message);
assert.ok(!message.includes('may already be revoked'), message);
});
+9 -10
View File
@@ -144,9 +144,8 @@ function createdCredentials(old, instance, definition, args) {
// checkRoutes, row 65). Row 194 (brief item 8, row 199 D13): revoke(business, instance, how,
// services) revokes the instance's credentials for a fire or a delete and throws
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
// nothing.
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null }) {
const writer = organizationWriter({ dir, rolesDir });
// nothing. Row 239 (#1745): writer is the organization writer, injected only by tests.
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) {
// The definitions in rolesDir and whether add and set accept each one.
function definitions() {
@@ -379,8 +378,9 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
}
}
// The checks every write runs, then beforeWrite (a fire's or a delete's revoke), then the write. A
// check that refuses revokes nothing, and a revoke that throws writes nothing.
// The checks every write runs, then the write, which runs beforeWrite (a fire's or a delete's
// revoke) under its lock (row 239). A check that refuses revokes nothing, and a revoke that throws
// writes nothing. beforeWrite must not write this business: the writer holds its lock.
function commit(business, old, next, { revision, actor, context, beforeWrite = null }) {
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
@@ -410,12 +410,11 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
}
}
// Row 194 N1 (sage-1634): a delete writes on the revision its request read when it was raised,
// so a write since then makes it stale. The revision is checked here, before the revoke, because
// the writer's own check comes after it. A write between this read and the writer's lock still
// revokes and then refuses (BUILD.md follow-up).
// so a write since then makes it stale. This early check refuses it without taking the lock. The
// writer checks again under its lock, before the revoke (row 239), and that check is what keeps
// a stale fire or delete from revoking.
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
if (beforeWrite) beforeWrite();
const receipt = writer.write(business, next, { revision, actor, context });
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
+62 -47
View File
@@ -177,55 +177,70 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
// date, reason), never a value; the other setters pass none. context, when given, is logged with
// the change: row 194's hire and fire carry the chief's run and the decision there (D2.2).
function write(id, document, { revision, actor, detail, context }) {
// Row 239 (#1745): beforeWrite (a fire's or a delete's credential revoke) runs under the lock,
// after the revision check and the validations, so a refusal there revokes nothing. An error
// after it returns carries revoked: true, the lock's release included (duckworth-0165 C2), and the
// bus names it so the human reads that the tokens may already be revoked. beforeWrite must not
// write this business itself: it would find its own lock and refuse organization-busy.
function write(id, document, { revision, actor, detail, context, beforeWrite = null }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, ({ home, parent }) => {
recover(id);
const file = businessFilePath(id, dir);
const oldBytes = regular(file);
const before = sha256(oldBytes);
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
const old = JSON.parse(oldBytes);
// C2-04 M9: every organization setter preserves the whole project
// mapping, including library playgrounds and hybrid universes.
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
const written = JSON.parse(bytes);
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
validateBusinessDocument(written, file, { rolesDir });
const after = sha256(bytes);
const transaction = randomUUID();
const tx = join(home, transaction);
directory(tx);
const previous = join(tx, "previous.json");
durable(previous, oldBytes);
const candidate = join(parent, "." + id + "." + transaction + ".tmp");
durable(candidate, bytes);
syncDir(tx);
const event = {
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
barrier("prepared");
if (sha256(regular(file)) !== before) {
log(id, { ...event, state: "aborted" });
rmSync(candidate);
fail("revision-conflict", "organization revision changed before publication");
}
renameSync(candidate, file);
syncDir(parent);
barrier("renamed");
log(id, { ...event, state: "committed" });
return { revision: after, transaction, previous };
});
let revoked = false;
try {
return withLock(id, ({ home, parent }) => {
recover(id);
const file = businessFilePath(id, dir);
const oldBytes = regular(file);
const before = sha256(oldBytes);
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
const old = JSON.parse(oldBytes);
// C2-04 M9: every organization setter preserves the whole project
// mapping, including library playgrounds and hybrid universes.
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
const written = JSON.parse(bytes);
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
validateBusinessDocument(written, file, { rolesDir });
if (beforeWrite) {
beforeWrite();
revoked = true;
}
const after = sha256(bytes);
const transaction = randomUUID();
const tx = join(home, transaction);
directory(tx);
const previous = join(tx, "previous.json");
durable(previous, oldBytes);
const candidate = join(parent, "." + id + "." + transaction + ".tmp");
durable(candidate, bytes);
syncDir(tx);
const event = {
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
barrier("prepared");
if (sha256(regular(file)) !== before) {
log(id, { ...event, state: "aborted" });
rmSync(candidate);
fail("revision-conflict", "organization revision changed before publication");
}
renameSync(candidate, file);
syncDir(parent);
barrier("renamed");
log(id, { ...event, state: "committed" });
return { revision: after, transaction, previous };
});
} catch (e) {
if (revoked && e instanceof Error) e.revoked = true;
throw e;
}
}
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
@@ -4,9 +4,10 @@
import test, { after } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync, rmSync } from "node:fs";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { instanceSettings } from "../src/instance-settings.mjs";
import { OrganizationError, organizationWriter } from "../src/organization.mjs";
import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs";
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
@@ -36,7 +37,7 @@ function fixture(mutate) {
const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line));
const onDisk = () => JSON.parse(readFileSync(file, "utf8"));
const revision = () => settings.read("acme").revision;
return { root, file, settings, validate, log, onDisk, revision };
return { root, dir, rolesDir, file, settings, validate, log, onDisk, revision };
}
// Each refusal: its code (or the validator's BusinessError), and the file's bytes unchanged.
@@ -244,3 +245,73 @@ test("row 256 checkDelete passes the human's creative delete and refuses another
assert.throws(() => f.settings.checkFire("acme", "bean"), e => e.code === "organization-definition-refused");
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
// Row 239 (#1745): a fire's or a delete's revoke runs inside the writer, under its lock, after the
// revision check. revoking(f, writer, fail) builds settings on f's files with a spy revoke and that writer.
function revoking(f, writer, fail = null) {
const calls = [];
const revoke = (business, agent, how, services) => {
calls.push([business, agent, how, services]);
if (fail) throw fail;
};
const settings = instanceSettings({ dir: f.dir, rolesDir: f.rolesDir, system: systemFor(f.root), revoke, ...(writer ? { writer } : {}) });
return { settings, calls };
}
const receipts = f => (existsSync(f.settings.writer.logFile("acme")) ? f.log() : []);
// A hand edit: the same document in other bytes, so the revision changes and the file still validates.
const handEdit = f => {
const bytes = JSON.stringify(f.onDisk()) + "\n";
writeFileSync(f.file, bytes);
return bytes;
};
test("row 239 T1 a fire on a revision that goes stale before the writer's lock refuses there and revokes nothing", () => {
const f = fixture();
const real = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir });
let edited = null;
// The edit lands after commit()'s early check and before the writer takes its lock.
const writer = { ...real, write: (id, document, options) => { edited = handEdit(f); return real.write(id, document, options); } };
const { settings, calls } = revoking(f, writer);
const before = receipts(f).length;
assert.throws(() => settings.fire("acme", { name: "sec-1" }, { actor: "chief", context: CONTEXT }),
e => e.code === "revision-conflict" && e.revoked === undefined);
assert.deepEqual(calls, []);
assert.equal(readFileSync(f.file, "utf8"), edited);
assert.equal(receipts(f).length, before);
});
test("row 239 T2 a write after the revoke refused before publication carries revoked, and the file keeps the edit", () => {
const f = fixture();
let edited = null;
const writer = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir, barrier: stage => { if (stage === "prepared") edited = handEdit(f); } });
const { settings, calls } = revoking(f, writer);
assert.throws(() => settings.fire("acme", { name: "sec-1" }, { actor: "chief", context: CONTEXT }),
e => e.code === "revision-conflict" && e.revoked === true);
assert.deepEqual(calls, [["acme", "sec-1", "fire", []]]);
assert.equal(readFileSync(f.file, "utf8"), edited);
assert.equal(receipts(f).at(-1).state, "aborted");
});
test("row 239 T3 any error after the revoke carries revoked, for a fire and a delete; a revoke that throws carries nothing", () => {
for (const [how, act] of [
["fire", settings => settings.fire("acme", { name: "sec-1" }, { actor: "chief", context: CONTEXT })],
["delete", (settings, f) => settings.set("acme", { revision: f.revision(), instance: "sec-1", op: "delete" }, "jason")],
]) {
const f = fixture();
const bytes = readFileSync(f.file, "utf8");
const writer = organizationWriter({ dir: f.dir, rolesDir: f.rolesDir, barrier: stage => { if (stage === "prepared") throw new Error("stopped at prepared"); } });
const { settings, calls } = revoking(f, writer);
assert.throws(() => act(settings, f), e => e.message === "stopped at prepared" && e.revoked === true, how);
assert.equal(calls.length, 1, how);
assert.equal(calls[0][2], how);
assert.equal(readFileSync(f.file, "utf8"), bytes, how);
const g = fixture();
const gBytes = readFileSync(g.file, "utf8");
const failing = revoking(g, null, new OrganizationError("credential-revoke-failed"));
assert.throws(() => act(failing.settings, g), e => e.code === "credential-revoke-failed" && e.revoked === undefined, how);
assert.equal(failing.calls.length, 1, how);
assert.equal(readFileSync(g.file, "utf8"), gBytes, how);
assert.deepEqual(receipts(g), [], how);
}
});
+6
View File
@@ -55,6 +55,12 @@ export const REASONS = {
"credential-revoke-failed": "a credential couldn't be revoked, so the business file is unchanged; tokens already revoked " +
"stay revoked and the instance can't use them; clear what else the agent's store directory holds and run the same " +
"command again, which is safe",
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock; these follow one that ran.
"revision-conflict-revoked": "the business file changed after the agent's tokens were revoked and before the write, so " +
"the file is unchanged; the tokens may already be revoked and the instance can't use them; read it again with " +
"`mosaic business instances` and run the same command again, which is safe",
"outcome-unknown-revoked": "the agent's tokens may already be revoked and the write's outcome is unknown; read the " +
"business file again with `mosaic business instances` before trying again",
"capacity-full": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
"limit-reached": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
"unknown-role": "the business has no such instance",
+1
View File
@@ -42,6 +42,7 @@ const EXIT = {
"tester-off": 3,
"tester-refused": 3,
"outcome-unknown": 1,
"outcome-unknown-revoked": 1,
"response-too-large": 1,
"invalid-response": 1,
};
+22
View File
@@ -316,6 +316,28 @@ test("row 194 CLI V5: delete polls to the transaction and the read lists the nam
assert.match(failed.err, new RegExp(`^mosaic: workspace-rename-failed: .*run the same delete again to retry the rename \\(request [^,]+, transaction ${s.log().at(-1).transaction}\\)$`, "m"));
});
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock. A delete closed after
// that revoke ran says the tokens may already be revoked; a plain revision-conflict keeps its text.
test("row 239 T5 CLI: a delete closed -revoked says the tokens may already be revoked; a plain revision-conflict doesn't", async (t) => {
let refusal = "revision-conflict-revoked";
const s = await setup(t, { hook: closing(() => ({ refusal })) });
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--revision", await s.revision()]);
assert.equal(added.code, 0, added.err);
const del = async () => s.mosaic(["instance", "delete", "acme", "sec-1", "--revision", await s.revision()]);
let r = await del();
assert.equal(r.code, 3, r.err);
assert.match(r.err, /^mosaic: revision-conflict-revoked: the business file changed after the agent's tokens were revoked .*; the tokens may already be revoked .*which is safe \(request [^)]+\)$/m);
refusal = "outcome-unknown-revoked";
r = await del();
assert.equal(r.code, 3, r.err);
assert.match(r.err, /^mosaic: outcome-unknown-revoked: the agent's tokens may already be revoked and the write's outcome is unknown; .* \(request [^)]+\)$/m);
refusal = "revision-conflict";
r = await del();
assert.equal(r.code, 3, r.err);
assert.match(r.err, /^mosaic: revision-conflict: the business file changed since that revision; .* \(request [^)]+\)$/m);
assert.doesNotMatch(r.err, /may already be revoked/);
});
test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call", async (t) => {
const s = await setup(t);
const rev = "a".repeat(64);
+2
View File
@@ -45,6 +45,8 @@ test("a bus code on stderr becomes the exit code; garbage and timeouts are outco
test("busExit and refuseInsideAgent", () => {
assert.equal(busExit("unauthenticated"), 3);
assert.equal(busExit("invalid-request"), 2);
// Row 239 (#1745): a write whose outcome is unknown after its revoke exits as outcome-unknown does.
assert.equal(busExit("outcome-unknown-revoked"), 1);
assert.doesNotThrow(() => refuseInsideAgent(humanEnv()));
assert.throws(() => refuseInsideAgent({ PI_AGENT_DIR: "/x" }), (e) => e.exitCode === 3 && /PI_AGENT_DIR/.test(e.message));
});
+4 -1
View File
@@ -75,7 +75,8 @@ export const busReader = options => views({ call: busCaller(options) });
// A send's error, sorted. The broker writes in one transaction, so any code it
// returns means nothing was written. A transport that lost the answer, or an
// answer that can't be read, leaves the outcome open: the message may exist.
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large'];
// Row 239 (duckworth-0165 C1): so does a write that failed after its revoke.
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large', 'outcome-unknown-revoked'];
export function sendOutcome(err) {
const code = err instanceof BusReadError ? err.code : 'outcome-unknown';
return OPEN.includes(code) ? { code: 'outcome-unknown', sent: 'unknown' } : { code, sent: false };
@@ -87,6 +88,8 @@ const STATUS = {
'human-required': 403, 'peercred-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503,
'revision-conflict':409,'organization-busy':409,'organization-uncertain':503,
// Row 239 (#1745): a fire's or a delete's write refused after its revoke ran.
'revision-conflict-revoked': 409, 'outcome-unknown-revoked': 503,
'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400,
// Row 148: the spec and docs editor. A stale base, a busy or dirty repository and an unchanged
// file are the user's to resolve by reloading; a path or content the project refuses is a 400.
+3
View File
@@ -364,6 +364,9 @@ const AGENT_REASONS = {
'workspace-rename-failed': 'the business file is changed and the name retired, but the workspace stayed where it was; delete it again to retry the rename',
// Brief item 8, deviation 11 (sage-1620): the revoke runs before the write, so a retry is safe.
'credential-revoke-failed': 'a credential couldn\'t be revoked, so the business file is unchanged. Tokens already revoked stay revoked and the instance can\'t use them. Clear what else the agent\'s store directory holds and delete again, which is safe',
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock; these follow one that ran.
'revision-conflict-revoked': 'the business file changed after the agent\'s tokens were revoked and before the write, so the file is unchanged. The tokens may already be revoked and the instance can\'t use them. Read the role instances again and delete again, which is safe',
'outcome-unknown-revoked': 'the agent\'s tokens may already be revoked and the write\'s outcome is unknown. Read the role instances again before trying',
'capacity-full': 'a launch limit is reached',
'limit-reached': 'a launch limit is reached',
'no-route': 'the instance has no harness, model and thinking yet; set its route first',
+18
View File
@@ -220,3 +220,21 @@ test('row 194 N5: agents.js is served as JavaScript and index.html loads it once
const html = await get(s, '/', { host: 'console.example', 'x-user': 'jason' });
assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
});
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock. A delete closed after
// that revoke ran says the tokens may already be revoked; a plain revision-conflict keeps its text.
// duckworth-0165 C1: a direct answer of outcome-unknown-revoked is an open outcome, never "Nothing changed".
test('row 239 T6: a delete closed -revoked says the tokens may already be revoked, and outcome-unknown-revoked is an open outcome', async t => {
let refusal = 'revision-conflict-revoked';
const s = await setup(t, { agent: async (verb, args) => { if (verb !== 'instance.request') throw new BusReadError('outcome-unknown-revoked'); return { ...ANSWERS['instance.request'](args), done: { run: null, transaction: null, refusal } }; } });
const reason = async () => (await get(s, `/api/agent/request?id=${ID}`)).body.reason;
assert.match(await reason(), /business file changed after the agent's tokens were revoked .*\. The tokens may already be revoked .* delete again, which is safe$/);
refusal = 'outcome-unknown-revoked';
assert.match(await reason(), /tokens may already be revoked and the write's outcome is unknown\. Read the role instances again before trying$/);
refusal = 'revision-conflict';
assert.match(await reason(), /the business file changed since you read it/);
assert.doesNotMatch(await reason(), /may already be revoked/);
const unknown = await send(s, '/api/agent/launch', { instance: 'coder' });
assert.deepEqual([unknown.status, unknown.body.error, unknown.body.sent], [503, 'outcome-unknown', 'unknown']);
assert.doesNotMatch(unknown.body.message, /Nothing changed/);
});