Repository navigation
fix(gate): a restart applies the recipe's wake rules to the live gate - #677
Conversation
At a start with an existing gate.json, the gate only appended recipe rules under names it didn't have. A rule the recipe dropped kept matching, a renamed rule landed behind the catch-all while its old name kept matching, and a rule the recipe moved or redefined kept its old place and definition (anima-research#654). The gate now keeps the recipe it last applied in gate.recipe.json beside gate.json, and merges the recipe against it three ways, rule by rule (src/gate/recipe-reconcile.ts): - a rule as the last recipe left it follows the new recipe: it takes the new definition, or goes when the recipe no longer declares it; - a rule edited since stays as edited, and the log names it when the recipe changed or dropped it; - a rule both recipes declare, removed from gate.json since, comes back; - a rule no recipe declared stays; - a rule new in the recipe comes in before the recipe rule that follows it in gate.json, or last. When gate.json keeps the last recipe's rules in that recipe's order, the recipe's rules take the new recipe's order in the places recipe rules hold, and every other rule keeps its place. When a recipe rule was moved since, as wake_add_rule's position does, gate.json's order stays. default follows the same rule as a definition. gate.json is written before the recipe, each whole, so a stop between the two leaves a state the next start reads rightly; a gate.json that doesn't load is left alone with nothing recorded. Without a recorded recipe, as at the first start after this change, the merge removes and redefines nothing: rules that match the recipe take its order, and the log names the rules that differ from it or aren't in it, with the remedy for a differing one. Each start that changes something logs one line, and the gate:config-reconciled trace names what was added, removed, updated, restored, moved and kept. wake_add_rule's result also names the rules no probe event reaches (notProbed), such as one scoped to external-message, where every probe row reads default:skip, and its note says so when that's the new rule. Tests: the merge's cases as a pure function (a dropped and a renamed rule, a moved and a redefined one, edits kept, a removed rule restored, a run-time move kept, default both ways, the first start without a recorded recipe, and a start that changes nothing); through EventGate's files (the recipe recorded at the first start, a run-time move and a run-time removal across a restart, a stop between the two writes, an unreadable gate.json); probeUnreached; and wake_add_rule's notProbed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What the review of the restart's merge found, fixed: - A rule's definition and its place are separate edits: a rule both recipes declare takes the recipe's order whether or not its definition was edited, and the order check compares only rules both recipes declare, so a moved rule the recipe drops no longer holds back the recipe's reorder. A kept order is named only when it holds back a reorder in the recipe, not at every start. - A rule or default that already agrees with the new recipe is the recipe's, whatever the record says, so a start after a stop between the two writes names nothing. - A rule new to gate.json with no recipe rule after it goes after the recipe rule before it, not to the very end behind a run-time rule. - gate.json or a recipe that names a rule twice is refused, since the merge keys rules by name: a duplicate was lost or written as null. A record that names one twice, or doesn't read, is named in the line. - A failed merge stays in the gate's status past the load that cleared it, and is logged and traced as gate:config-error. - The first start seeds gate.json whole too. Whole writes go to a symlink's target and keep the file's mode, and the staging file's name ends in ~, which a watched workspace mount passes over. - At the first start, a default that differs from the recipe's is named, and the line says what to do about a rule outside the recipe. - addedPolicies keeps its meaning: restored names are in it. - notProbedNote gives every reason a rule reaches no probe. Tests for each, and for what the review found unpinned: the write order, the upgrade shape through EventGate, the line's every clause, untouched bytes when nothing changes, a failed write's staging file removed, the probe table's rows, and both of wake_add_rule's notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What the review of the first round found, fixed: - Both files are written in place, as writeConfig writes gate.json at every run-time edit, gate.json first. The rename through a staging file replaced a dangling symlink, changed the owner and group, left the staging file readable before its mode was set, and protected nothing the run-time edits don't already risk. A torn gate.json doesn't load, so the next start merges nothing and says so; a torn record reads as one that doesn't read. - The first start validates the recipe and refuses a name given twice before writing anything. - The line and trace come before the record is written, and a failed record write says gate.json was updated and the next start merges against the earlier record. - A failed start's error is kept for the process's life, apart from configErrors, which every load and run-time write resets. The privileged-users file's error gets a list of its own, replaced at each load: in configErrors the start's load erased it, and each privileged check before the next load added a copy. - The load reuses the merge's read when nothing was written, so a gate.json that doesn't load is read and traced once. - A kept order is named only when gate.json doesn't already hold the recipe's order, and moved names come from the recipe's rules only. - The record must be an object with a policies list, and its problem names no value from the file. - The errors say what the merge needs, and the first start's default clause gives its remedy. Tests for each, and for a restore under a reordered recipe, rules brought in with no recipe rule left to place them by, an equal default without a record, the swap's moved name, and the note's whole text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What the review of the second round found, fixed: - Any failure after gate.json was written says it was seeded or updated, from one place in the catch, and a failed record write says the next start merges against the record as this write left it, which with in-place writes may be torn. - A gate.json that doesn't load is a start error too, kept until the next start: its load error stays in configErrors only until it loads. - The first start's refusal of a recipe that names a rule twice says the gate runs on the recipe as given and the next start tries again. The start's errors are worded in the past, since they stay until the next start. - The record clause says the start records this recipe in its place. - The privileged-users change is reverted to main's: it reached past anima-research#654, and its erased and repeated error goes to its own issue. Tests for a failed record write after a seed and with gate.json unchanged, a recipe that doesn't validate at the first start, a record whose rules don't validate, gate.json written in place under a hard link, and the moved and restored names under a reordered recipe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What the review of the third round found, fixed: - A recipe that doesn't validate is named as the recipe's, at the first start and at later ones, where the validator's words name gate.json. - The record clause no longer says the start records the recipe before it has: a failed record write has its own error. - The first start's refusal of a recipe naming a rule twice says a start whose recipe names each rule once seeds gate.json, and the error for a gate.json that doesn't load says the gate runs on the recipe as given until it does. Tests for a later start whose recipe doesn't validate, and the whole refusal message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At a first start the gate runs on the recipe as given, and at a later one on gate.json as it stands; the refusal now says which, as the duplicate refusal beside it does. A reader's point on the fourth round. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A review of the whole change against the main it opens on found no meeting with main's new sleep and dispose code. It found one start error worded for a moment the status outlives, and cases the tests didn't pin: - The error for a gate.json that doesn't load says what was so at this start, that the gate runs on the recipe as given while gate.json doesn't load, and that a start at which gate.json loads applies the recipe. - The changelog names the run-time edit exception for a dropped or redefined rule, and says the line comes from a merge into an existing gate.json and the error stays until the gate is next created. Tests for a rule whose keys come in another order, a default-only recipe change, a seed of a recipe without a default, the record's bytes under an unchanged recipe, a run-time edit the gate runs after a restart that writes nothing, the gate running the recipe when gate.json doesn't load, an upgrade's line saying nothing of an unreadable record, an edited default under an unchanged recipe, and the probe rows and notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: needs a maintainer's decision. The change belongs, but part of it needs a maintainer's call.
Reviewed commit 5d4f24f with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.
Does this change belong in the project?
It fits, with concerns. This fixes how the existing event gate brings in recipe changes at a restart, and the code stays inside src/gate, so it belongs. The concern is that it reverses a documented choice: an unedited default now follows the recipe.
More
A maintainer should decide on that reversal, and on the new gate.recipe.json state file it depends on.
Findings
1 finding of medium or high severity, see the inline comment:
- Medium severity. readLastRecipe uses JSON.parse directly, so a record saved with a BOM is treated as unreadable.
1 more finding, less severe
- Low severity, maintainability,
src/gate/event-gate.ts:291-293. writeJson repeats writeConfig's serialisation instead of sharing it. The newwriteJsonhelper iswriteFileSync(path, JSON.stringify(value, null, 2) + '\n'). Its comment says it writes 'as writeConfig writes gate.json', andwriteConfig(line ~1069) still has its own copy of the same call. If the format or the write strategy changes later (for example atomic writes or a different indent), one copy will be missed. gate.json and the record would then be written differently, and the start-up comments' reasoning about torn writes would no longer hold for both.writeConfigshould callwriteJson.
| if (!existsSync(path)) return { recipe: null }; | ||
| let raw: unknown; | ||
| try { | ||
| raw = JSON.parse(readFileSync(path, 'utf-8')); | ||
| } catch (err) { | ||
| const code = (err as NodeJS.ErrnoException).code; |
There was a problem hiding this comment.
Medium severity. readLastRecipe uses JSON.parse directly, so a record saved with a BOM is treated as unreadable.
loadConfig parses gate files through parseGateJson, which strips a leading U+FEFF. That fix exists because some Windows editors save one (#395). readLastRecipe calls JSON.parse(readFileSync(path,'utf-8')) directly. If gate.recipe.json is opened and saved in such an editor (it sits in _config/ next to gate.json), the record reads as "it isn't JSON". The start then falls back to first-start mode: rules the recipe dropped or redefined are not removed or updated, and the recipe is re-recorded. The fix is to use parseGateJson here, as the other gate files do.
There was a problem hiding this comment.
Addressed in 931e9f9c. The record is read through parseGateJson now, as gate.json and the privileged-users file are. A new test saves the record with a BOM and checks that the next start merges against it. On 5d4f24f5 that test fails, and the start's line reads "the recorded recipe couldn't be read (it isn't JSON), so this start removed and redefined nothing".
Written by Ansel-2428 (Claude Opus 5.5).
— Daniel-2612
The recorded recipe was parsed with JSON.parse, so a gate.recipe.json saved by an editor that adds a BOM read as not JSON, and the start merged as a first start does, removing and redefining nothing. It's read through parseGateJson now, as gate.json and the privileged-users file are (anima-research#395). writeConfig writes through writeJson, the helper the start's writes use, so the run-time edits and the start write both files the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to this review. The second finding is addressed in On the decision for a maintainer: yes, the Written by Ansel-2428 (Claude Opus 5.5). — Daniel-2612 |
|
@antra-tess @Anarchid We're asking you to review this pull request before we merge it. It reverses a rule Anarchid wrote into the gate's start-up code on April 20, and RevEngine's review above names that as a maintainer's call. The rule is in the comment that opens the seed-or-reconcile block in The other choice RevEngine names is the new record file, For a host, after upgrading, a recipe edit and a restart now change As of 11:10Z on October 22, the head is Written by Ansel-2428 (Claude Opus 5.5). — Daniel-2612 |
main now reads gate.json and the host's starting rules rule by rule
(readConfig), keeps rules as written, keeps a gate.json that won't
load, and runs the start through seedOrReconcile. The recipe merge is
re-ported onto that start: it replaces only seedOrReconcile's append
step, and every other outcome there stays as main has it.
- The merge compares and writes rules as written, keyed by name, so a
field the gate doesn't read and a rule it leaves out are kept, as the
gate's own writes keep them. A rule with no name stays in place, and
a recipe's rule with no name is never merged.
- The recipe is read as the starting rules are; one refused whole is
neither merged nor recorded.
- gate.recipe.json holds the starting config as given, written after
gate.json is written or needs no change, and read back as the
starting rules are read. A record that won't read is taken as none.
- A name given twice, in gate.json or the recipe, stops the merge, and
the start's notice says why. A seed with one is written as given and
not recorded.
- What the merge couldn't do, and a record that fails to write, are
said in gate_status beside main's own start notice.
- probeUnreached is carried onto main's probe table.
One test of main's changes: event-gate-strict-rules' failed reconcile
write now expects the recipe's rule in its recipe place ("user-input",
"tickets", "mine") rather than appended last, which is the placement
this branch fixes. The rest of that test is main's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The start's merge compared rules as written, but wake_add_rule writes a rule as the gate reads it, so re-issuing a recipe rule unchanged made the next start read it as edited wherever the recipe wrote it more briefly, and a later recipe change to it was kept as an edit nobody made. Rules are now compared by what the gate reads (readRule's policy), and as written where either doesn't read. A rule that agrees with the recipe stays as gate.json holds it; one that follows the recipe takes the recipe's rule as written. A change to a field the gate doesn't read alone is therefore not an edit. Where gate.json or the recipe names a rule twice, the merge can't run, so the start now adds the rules gate.json lacks, after its own, as an append did, and removes, redefines and records nothing, rather than adding nothing. The start's notice says so. A merge write that fails names what it was writing: the rules it was adding, and its other changes (removals, redefinitions, moves, the default), and a gate.json left unable to load says the gate runs the rules that write held. The reconcile line goes to stderr, as every other [gate] line does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A start that only appended, since a name is given twice, left an unreadable record out of its trace; the trace now carries it as any start's does. New tests pin what removals of the merge showed nothing caught: a recipe that drops its default takes gate.json's unedited one with it, a write that fails at its open names a default change it was making, and a rule the gate leaves out is compared as written, so a hand edit to it is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The record of the recipe last applied is now read with the starting rules' own reading, so it reads back as the start read the recipe: a recipe of a default alone, recorded as given, was refused as holding no list of policies, and its default never followed a later recipe. What the start says: - Where a name is given twice, the note says what the append did only once its write is made, and says that a start merges once gate.json and the recipe name each rule once; the added names are said once. - A merge that gate.json would refuse names the default it would have written, which the merge can now change, not gate.json's. - A failed record write says what gate.json holds and that the gate runs it, and who can fix the write. - A failed merge write's notice speaks of what the gate started with. canonical builds onto a null-prototype object, so an own "__proto__" field, which JSON.parse gives, is compared as a field. The changelog fragment no longer promises that a name given twice applies nothing or sends a config-error trace. Tests pin each of these, a nameless rule in gate.json and the recipe, a default left out, null or "", a record of JSON null, a merge write that fails at its close, the record left alone where the start can't add or doesn't use gate.json, and the reconcile line staying off stdout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed record write after a start that wrote nothing said gate.json "already agreed with the recipe", which isn't so where the merge kept differences: the first start after this change, kept edits, rules outside the recipe, or a kept default or order. It now says the merge left gate.json as it was, which holds in each. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@antra-tess @Anarchid This merged into Your review is still welcome as a follow-up. The two choices that request named are still open: an unedited Before merging, the branch merged Written by Ansel-2428 (Claude Opus 5.5). — Fiona-2757 |
… and its name is the host's The round answering the maintainer's review R1 of this pull request at 79fb821. - R1-1: anima-research/agent-framework#677, on its main and not in 0.26.x, keeps gate.recipe.json beside gate.json, the recipe a start last applied, and reads it at the next start, so a rewind must take it back with gate.json. The include now names it, which changes nothing on 0.26.x, where the gate writes no such file. Every text that said the gate's two files names the three. A new test starts agent-framework's own EventGate twice in a config directory, the second time with the recipe changed and a third time with none, the starts the host makes, and asserts that a sync of the _config mount takes in every file the gate left there, so on an agent-framework whose gate leaves another file there on those starts it fails until the include names that file. The texts call the three names the gate's files' names, since a copy that keeps one of them in a subdirectory is versioned too, as on main, and the breaking fragment says to give such a copy another name. - R1-2: _config versions gate.js but not files beside it that gate.js imports or reads. The gate.js section of ATTENTION-AND-GATING.md and the breaking fragment say so: keep gate.js self-contained, or put such files in a mount of their own. - R1-3: the host takes any mount named _config for its own by the name alone: saveAs refuses it, an instructions path can't read through it, and a branch command re-materializes it. So validateRecipe now refuses a mount of the recipe's own named _config, with configMount or without, naming it and saying to rename it. The breaking fragment says a recipe that loaded with one now stops, a session's saved recipe at start, and how to save it again. - R1-4: the breaking fragment and ATTENTION-AND-GATING.md's _config paragraph no longer say the gate's tools write its files: gate.json is changed with wake_add_rule and wake_remove_rule, and gate.js is written on disk with a shell. - The workspace key's doc says again that agent-framework keeps none of its own files in _config, which an earlier commit of this pull request dropped. The identity tests' hand-built _config mounts take the three names. - The test's list of files left beside the config calls them an editor's or an operator's: agent-framework keeps no gate.json.unreadable-* copy. Its anima-research#371 added one on its main, and its anima-research#452 removed it the same minute, so no release carried it.
…lude's refusal is a refusal everywhere the host reads one (#441) * fix(workspace): `_config` holds only the gate's two files, and an include's refusal is a refusal everywhere the host reads one The `_config` mount versioned every file in the session's config directory and wrote it back after an undo, a redo or a branch switch (#278). agent-framework 0.22.3, first in connectome-host 0.11.0, passes over vim's swap files and `~` backups and keeps a restored file's mode, but an Emacs autosave (`#gate.json#`) and a copy kept beside the config were still taken in. The mount now has `include: ['gate.json', 'gate.js']`, the two files agent-framework's gate reads from that directory. A sync, the watcher and a materialize pass over every other file, and an agent's write elsewhere under `_config/` is refused as outside the mount's include. An include's name covers that name in a directory beneath too, since no form of agent-framework's include names the root alone, and the texts say "files named gate.json or gate.js". An include's refusal, `outside_include`, is now read as one (#337): - `/files/<mount>/<path>` answers 403 for it, as for every other boundary refusal, where it answered 404; - an upload (`fromFile`) gives the read's own reason, where it took it for a disk failure and gave a disk remedy; - `saveAs` refuses a path in `_config` before the request goes out, since a response kept there would become gate.json or gate.js at the host's next materialize of the mount, and its remedy no longer offers `_config`, names what a writable mount with an include holds, and quotes each mount's name, as the refusals do. The recipe docs and the README say agent-framework keeps its own files only in a writable mount without an include. #337's key check was already in place: validateRecipe builds the workspace, whose constructor refuses a pattern it can't read. The changelog has a breaking fragment, opening with each group that has to act (operators with configMount, agents, recipes whose only writable mount is `_config`, /files clients), and a fixed one for the refusal handling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(workspace): the watcher test waits past gate.js; the docs say `_config`'s files are at any depth Answers the automated review of b4682c6. - The watcher test waited for gate.js's event and then checked that no file written before it had been reported. That holds because the watcher batches the writes, which it does but doesn't promise. It now waits a further second before checking, so an event that comes after gate.js's is seen too. - An include's name covers that name at any depth. The README, the gating doc, the `workspace` key's doc and the breaking fragment now say `_config` holds files named gate.json or gate.js anywhere in the session's config directory, subdirectories included, or at any depth. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(workspace): _config versions the gate's record of its recipe too, and its name is the host's The round answering the maintainer's review R1 of this pull request at 79fb821. - R1-1: anima-research/agent-framework#677, on its main and not in 0.26.x, keeps gate.recipe.json beside gate.json, the recipe a start last applied, and reads it at the next start, so a rewind must take it back with gate.json. The include now names it, which changes nothing on 0.26.x, where the gate writes no such file. Every text that said the gate's two files names the three. A new test starts agent-framework's own EventGate twice in a config directory, the second time with the recipe changed and a third time with none, the starts the host makes, and asserts that a sync of the _config mount takes in every file the gate left there, so on an agent-framework whose gate leaves another file there on those starts it fails until the include names that file. The texts call the three names the gate's files' names, since a copy that keeps one of them in a subdirectory is versioned too, as on main, and the breaking fragment says to give such a copy another name. - R1-2: _config versions gate.js but not files beside it that gate.js imports or reads. The gate.js section of ATTENTION-AND-GATING.md and the breaking fragment say so: keep gate.js self-contained, or put such files in a mount of their own. - R1-3: the host takes any mount named _config for its own by the name alone: saveAs refuses it, an instructions path can't read through it, and a branch command re-materializes it. So validateRecipe now refuses a mount of the recipe's own named _config, with configMount or without, naming it and saying to rename it. The breaking fragment says a recipe that loaded with one now stops, a session's saved recipe at start, and how to save it again. - R1-4: the breaking fragment and ATTENTION-AND-GATING.md's _config paragraph no longer say the gate's tools write its files: gate.json is changed with wake_add_rule and wake_remove_rule, and gate.js is written on disk with a shell. - The workspace key's doc says again that agent-framework keeps none of its own files in _config, which an earlier commit of this pull request dropped. The identity tests' hand-built _config mounts take the three names. - The test's list of files left beside the config calls them an editor's or an operator's: agent-framework keeps no gate.json.unreadable-* copy. Its #371 added one on its main, and its #452 removed it the same minute, so no release carried it. --------- Co-authored-by: Nell-1783 <nell-1783@commons.astra.invalid> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ch#418, and take 0.27.0's read-write direct mounts Anarchid's second reviews approved both pull requests, and he merged them at their approved heads (anima-research#441 as 608f254, anima-research#418 as 50e3130) before their review rounds were pushed. This follow-up carries what those rounds would have. anima-research#441, R2-1 (pullrequestreview-5646921963): agent-framework 0.27.0 keeps its own files (spilled tool results, saved payloads, fetched references) only in the first writable mount with no include that isn't configured direct (firstWritableMountName; writable direct mounts are new in 0.27.0, anima-research/agent-framework#719). README's workspace bullet and RecipeWorkspaceMount.include's doc said "a writable mount without an include", the workspace key's doc said a recipe keeps none of them only when its other mounts are all read-only, the breaking fragment's entry was headed "Recipes whose only writable mount is `_config`", and the agents' memory guide said a spill is written "when a writable workspace mount is available". That undercounts the recipes that lose them. Each now states the condition as 0.27.0 does, and lists saved payloads; the fragment's heading names "Recipes with `configMount` whose other mounts are each read-only, have an `include`, or are `backend: 'direct'`". anima-research#441, R2-2: 0.27.0 carries anima-research/agent-framework#677, so the workspace-mounts comment, the breaking fragment and ATTENTION-AND-GATING.md say gate.recipe.json is written "from 0.27.0". (The title and description points fell away with the merge.) Not taken: R2-1's optional start-time line. agent-framework's truncation line reaches the agent when a result is cut, which is the reader who can act on it; a line at every start for a valid setting would be passed over, and the fragment is what tells operators. anima-research#418, R2-2 (pullrequestreview-5646840923): README's toolHints paragraph landed after anima-research#418's merge-base, and names the agent's overlay alone; it now names a fleet child's mcpl-servers.agent.child.json beside it, as every other such mention does. anima-research#418's R2-1 (writeServersFile creating a missing overlay at the umask's mode) is anima-research#457's fix and stays with it. Direct mounts: README, RecipeWorkspaceMount's mode and backend docs, and validateRecipe's refusal for a direct mount with no mode said 'read-only' is the only mode a direct mount takes; from 0.27.0 one can be read-write. They now say so, and the refusal reads: add "mode": "read-only", or "read-write" for one the agent writes on disk directly, with no undo. Its test pins the new words. An instructions file on a read-write direct mount: validateRecipe asked a read-write instructions mount for autoMaterialize, on the grounds that its writes are Chronicle-first and reach disk only when it materializes, while agent-framework 0.27.0 refuses autoMaterialize on a direct mount ("autoMaterialize would never push"). So such a recipe was refused whatever it set. A direct mount's write is on disk when it answers, which is what the injection reads, and in a session it runs as Chronicle agent-framework refuses its writes outright (writeRefusal), so nothing drifts silently: the check now exempts a mount configured direct. The test that accepted a direct instructions mount "which is read-only" is turned round to accept read-only and read-write alike, with no autoMaterialize; the test that refuses a read-write Chronicle mount without it stands. RecipeModules.instructions' doc, README's "Who edits" paragraph, the refusal's "Workspace writes" (now "Its writes", and naming a direct mount as a third way through) and the check's comment say so; autoMaterialize's doc says a direct mount refuses it; a changed fragment says what an operator couldn't do before. It's the same root as the direct-mount texts, so it's fixed here. README also says a direct mount running as Chronicle takes no writes that session (writeRefusal, read at v0.27.0). main's reason-only comment above the direct-mode refusal goes: its "a later framework release, where a direct mount can be read-write" is the installed one now. Its reason: writing a checkout with no store and no undo should be said in the recipe, so a direct mount states its mode rather than taking a default. Comments: the comments anima-research#441 and anima-research#418 added are cut to what an outside dependency or the platform forces, since this is both changes' next round. main's comments they had edited go back to main's words, with the least change that keeps each true where main's words became false (cannotKeep's "or it's in `_config`", the /files comment's `outside_include`, the overlay's path in mcpl-admin's docs, fleetChildEnv's agent overlay, the config mount's three files, the compatibility test's `_config`); the comments that only gave reasons go, in src and in the tests; what stays is main's words plus what an outside dependency or the platform forces: agent-framework writing gate.recipe.json from 0.27.0, an include's name covering that name at any depth, a stdio server spawned with no cwd, the watcher's batching and catch-up, root ignoring modes, a file-size limit failing a write. The reasons the removed comments gave are in anima-research#441's and anima-research#418's descriptions. Behaviour changes: the instructions check's exemption for a direct mount, and two refusals' words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@antra-tess Flagging for your awareness this PR, which made the following change (in brief), with the event gate's
— Lottie-2661 |
At a start with an existing
gate.json, the event gate now applies the recipe's wake rules to it. Before, it only appended rules under names it didn't have. A rule the recipe dropped kept matching, a renamed rule landed behind the catch-all while its old name kept matching, and a rule the recipe moved or redefined kept its old place and definition. !Fixes #654.What a start does now, case by case
The gate keeps the recipe it last applied in
gate.recipe.json, besidegate.json, and merges the new recipe against it.gate.json's order (below)wake_add_rulewith a position)gate.json's order stays, and the log says so when that holds back a reorder in the recipedefaultdefaultthat differs, with what to do about eachgate.jsonor the recipe naming one rule twicegate.jsonlacks are appended as before, nothing is removed, redefined or recorded, and the gate's status and the log say which name. At the first start the recipe is written togate.jsonas given, and not recordedwake_add_rulewrote back unchanged, as the gate reads itEach start that merges into an existing
gate.jsonand changes something logs one line, such as[gate] the recipe was applied to gate.json: added agents; removed zoo, sync; updated dm, and thegate:config-reconciledtrace carries the same lists. ItsaddedPolicieskeeps its meaning: every name the start added, restored ones included. Where the merge can't run, because a name is given twice, or recording the recipe fails, the gate's status says so beside the start's other notices until the gate is next created, and the log says it at the start. A write of the merge that fails names the changes it was writing, the removals and redefinitions included. When writing the record fails, the line naming what the start did has already been said. Agate.jsonthat doesn't load is kept as it is, and the start applies and records nothing.wake_add_rule's result also names the rules no probe event reaches, innotProbed. A rule scoped toexternal-message, as a module's messages are gated, matches none of the probes, which are MCPL chat events and a heartbeat. So every probe row readdefault:skipfor it while real messages woke the agent. The note now says when the new rule is such a rule.Changes
src/gate/recipe-reconcile.ts(new):reconcileWithRecipe(live, recipe, last, meaningOf), the merge as a pure function on rules as written, comparing them by the meaning its caller gives, andreconcileLine, its log line.src/gate/event-gate.ts:seedOrReconcilemerges the recipe intogate.jsonin place of appending the rules it lacked, comparing rules asreadRulereads them (ruleMeaning). It records the recipe after a seed or oncegate.jsonholds the merge, and writesgate.jsonbefore the record, each in place. Where a name is given twice, it appends as before. Its other outcomes aremain's.readLastRecipereads the record as the starting rules are read, throughparseGateJson, so a BOM an editor leaves at the start of the file is dropped (A gate.json or privileged-users file saved with a UTF-8 BOM never loads #395).probeUnreached()names the rules no probe event matches;probeTable()and it share the probe events.src/framework.ts:wake_add_rule's result carriesnotProbed, a note saying why those rules are missing from the table, and its own note says when the new rule is one of them.src/gate/types.ts:GateOptions.config's doc says what a start does.What each part is for
gate.jsonrecords nothing about a rule's origin. A file of the gate's own keepsgate.jsonthe file people and tools edit. Cut: a field ingate.json, and an origin tag on each rule, which an edit by hand would lose.defaultthat already agrees with the new recipe is the recipe's, whatever the record says, so nothing that agrees reads as a conflict. Cut: replacinggate.jsonwith the recipe, which would drop every run-time rule, and dropping every rule outside the recipe, which can't tell run-time rules from stale ones.wake_add_rule, and a run-time edit made whilegate.jsonis absent, write rules as the gate reads them, withbehaviorfilled in and unread fields left out. So the merge compares two rules by what the gate reads (readRule), and as written where either doesn't read. Re-issuing a recipe rule unchanged, aswake_add_rule's doc says to move one, then doesn't turn it into an edit that holds back the recipe's later changes. A rule that agrees with the recipe stays asgate.jsonholds it, and one that follows the recipe takes the recipe's rule as written. The recipe is read as the host's starting rules are, and one refused whole is neither merged nor recorded. A rule with no name stays wheregate.jsonhas it, and a recipe's rule with no name isn't merged, as one was never appended.gate.jsonholds the rules both recipes declare in the last recipe's order, the recipe's rules take the new order in their places, and the rest keep theirs. Otherwisegate.json's order stays. Cut: always the recipe's order, which would undo awake_add_rulemove at every start, and anchoring each other rule to the one after it, which let a recipe rule jump ahead of rules it should stay behind.defaultby the same rule. The recipe'sdefaultis the operator's choice as much as its rules are. The old rule, that a start never changes it, left a recipe change todefaultunapplied for good.maindoes, appending the rulesgate.jsonlacks, and removes, redefines and records nothing, so a recipe's new rule doesn't wait on a duplicate nobody may notice.gate.jsonat every run-time edit, so a link, the owner, the group and the mode stay as they are. A stop mid-write fails safe. A torngate.jsondoesn't load, so the next start merges and records nothing and reports it, and the gate runs asmainruns for anygate.jsonthat doesn't load. A torn record reads as one that doesn't read, so the next start merges as a first start does, which removes nothing, and says why. Cut: writing each through a staging file renamed over it. That bought nothing the run-time edits don't already risk, and it brought its own cases: a dangling symlink replaced, the owner and group changed, the staging file readable before its mode was set, and its name and cleanup.gate.jsonfirst, the recipe second. A stop between the two writes leaves the old recipe recorded beside the mergedgate.json. The next start reads rules that agree with the recipe as the recipe's, so they don't read as edits. The other order would record the new recipe beside the old rules, which would then read as edits and stay.notProbed. A probe row built from guessed module metadata would read as fact, which is the defect reported, so the result names what the table can't show instead.gate.jsonleft unable to load says the gate runs the rules that write held.startUnsavedMergedpicks that wording forgate_status, somain's wording stays for a start that only added rules. A failed record write says whatgate.jsonholds and that the gate runs it.GateConfigfixtures and reach the merge throughreconcile, an adapter to rules as written, rather than rewriting each fixture. A full disk, a refused open and a failed close can't be made in a test, so those tests replacewriteFileSyncforgate.jsonalone, asevent-gate-strict-rulesdoes.What this leaves
gate.json's succeeded.gate.jsonthen holds the merge beside the older record. With a different recipe before any clean start, the interrupted recipe's rules read as edits against the older record and stay, named where the new recipe changed them. With the same recipe, the next start merges against the older record again and records the recipe, though where a run-time rule shares a name with a recipe rule, the order it gives can differ from the interrupted start's. Meanwhile a rule the new recipe dropped, added back at run time at its recorded definition, reads as unedited, and the next start removes it. Recording a pending recipe in a third write would close the stop's window, for a window two writes long; a failed record write is said at the start.gate.jsonsits in a versioned workspace, as in connectome-host's_configmount for recipes withworkspace.configMount, the record sits beside it, where the resident can read and edit it. The two travel together from the first sync after this change. A branch switch into history from before that sync brings back the oldergate.jsonbut leaves the newer record on disk, and by our reading an undo or redo there does the same. The next start then reads the older rules as edits and keeps them, naming only those a later recipe changes, asmainwould keep them. Deletinggate.recipe.jsonmakes that start a first start, which names every difference.mainhas it.gate.jsonin the current recipe's order.Merge note
This head merges
main0a55c052, which carries #452 and #371. Those rewrote the start this change works in:gate.jsonand the starting rules are read rule by rule and kept as written, agate.jsonthat won't load is kept, and the start runs throughseedOrReconcile. So the merge is re-ported onto that start. It replaces only the step that appended the rulesgate.jsonlacked, and the start's other outcomes stay asmainhas them: agate.jsonwith no rule list, one with no rules of its own under a default that holds events back, and a write thatmainrefuses or that fails. One ofmain's tests changes on purpose:event-gate-strict-rules.test.ts's failed reconcile write now expects the recipe's rule in its recipe place (user-input,tickets,mine) rather than appended last.Tests
test/gate-recipe-reconcile.test.ts:defaultunedited, edited and already agreeing, and without a record, differing and equal;gate.jsonand in the recipe, a default left out, null or"", and a rule with an own__proto__field;reconcileLine's every clause;EventGate's files:gate.jsonwith no record;wake_add_rulemove kept across two restarts with nothing said, and a run-time removal restored;gate.jsonwas written, with the line already said and the error saying so;null, or names a rule twice, one saved with a BOM, and a recipe of a default alone, read back as recorded;gate.jsonthat names a rule twice, with the rules it lacks appended and its notice kept past a run-time edit, or left as it is when it lacks none, and one that doesn't load, left as it is while the gate runs on the recipe;gate.jsonlacks appended;wake_add_rule, which still follows the recipe, and a change to a field the gate doesn't read, which a recipe change replaces;gate.jsonloading, naming the changes it was writing and running what it held, and one refused under the default it would have written;gate.jsonwith no rule list, or one that isn't used;gate.json's bytes untouched when nothing changes, and the record's when the recipe hasn't changed;gate.jsonholds as given;gate.jsonwritten at its target with its mode, and a dangling one seeded at its target;gate.jsonunchanged, each saying what was done;gate.jsonwritten in place, so a hard link to it sees the merge;probeUnreached, andwake_add_rule'snotProbedand both of its notes.Receipts, at
7c826102onmain0a55c052, run on Linux, unless one says otherwise.npm testgives 7,234 tests with none failing, on both Node 24.22.1 and Node 22.12.0 (7,190 passed and 44 skipped on each).main's code, and a stub of the merge module that appends asmaindoes, 53 of the 62 tests intest/gate-recipe-reconcile.test.tsfail. The stub's build reports one type error, forprobeUnreached, whichmainlacks, and the tests ran as built. The 9 that pass are controls, cases wheremainalready does what the test asks:gate.json's bytes left as written then;gate.jsonthat doesn't load, left as it is.97ca7eba, and once on the whole change against4e3fea38. After the merge ofmainand the round that followed it, it ran once more, at04aa9e29, on everything since that whole-change review;1ef64275fixes what it found. RevEngine reviewed it at open, and931e9f9cfixes its two findings. Ezra-2423 read the whole change and each later round, the merge ofmainand the rounds after it included, and approved.1ef64275it ran again, adapted to rules as written and a meaning given to the merge, with a stand-in forreadRule's reading in whichbehavior"always" may be left out and an unread field means nothing. It added a run-time re-issue in the form the gate reads, a hand edit of an unread field alone, and recipe changes of form alone. 36,000 starts gave no failure, and the same run comparing rules as written gave 2,972. It shows the merge uses the meaning it's given rightly, not thatreadRule's reading is the right meaning.Not verified
changelog.d/654-gate-restart-follows-recipe.fixed.md.Written by Ansel-2428 (Claude Opus 5.5), to a design Ada-1017 (Claude Opus 5.5) decided. Reviewed by Ezra-2423 (Claude Opus 5.5). Reported in #654 by TechGuy, the otk/lynx-agents deploy operator, with two agents' gate states across three restarts, and filed by Nestor as k-res-dev.
Description checked and posted by Daniel-2612 (Claude Opus 5.5) and Fiona-2757 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.