Repository navigation
Merge main (8ec1de5a) into feat/dendrite - #790
Merged
tel-0s merged 243 commits intoOct 24, 2026
Merged
Conversation
… their text
addPolicy and removePolicy (wake_add_rule and wake_remove_rule, and the
framework's addGatePolicy and removeGatePolicy) read gate.json again
before a change, and when it was there but wouldn't load, they fell back
to the rules in force and wrote those over it. Whatever the file held,
such as an operator's unfinished edit, was lost, though addPolicy's doc
says a concurrent operator edit isn't clobbered.
Now the bytes the change read are first copied, with an exclusive
create, to gate.json.unreadable-<UTC time> beside it, such as
gate.json.unreadable-20261015T025905Z (no colons, which Windows
refuses), with -2 and on when the name is taken. Then the change is
written into gate.json in place, as before. A copy rather than a rename,
so a symlinked gate.json keeps its link, the file keeps its mode and
owner, and a single-file bind mount works; what's kept is the bytes that
were judged, whatever their encoding (a Latin-1 edit, or UTF-16 as
Windows PowerShell 5's > writes it, would not survive decoding and
encoding again), and a copy never replaces an earlier one. The change is
told where the copy is through a new onUnreadableKept option; the
wake-rule tools put that in their result as keptUnreadable, and a [gate]
log line and a gate:config-kept trace (now in the TraceEvent union) say
the same. When the copy can't be made, the change throws before anything
is written or any rule's state is cleared, and a write that fails after
the copy names it. A gate.json the process can't read at all refuses the
change: there's nothing to keep, and a file the process can't read is
often one its operator closed off on purpose. A change that writes
nothing (a placement it can't make, or removing a rule that isn't there)
keeps nothing. gate_status lists each copy still beside gate.json, in a
new keptCopies field, with what to do with it, until it's removed: the
moment a copy is made reaches only the log, the trace and the change's
caller (channel-mode in connectome-host passes no callback), and the
copy exists to be found later.
Reloading: at most once a second, gate.json is read again and applied
when its text differs from the text whose rules are in force (configText,
set only by a load or a write that applies it), and the privileged-users
file is loaded when its text differs from the text last read (a failed
read forgets that one). A gate.json that holds the text in force again
after failing to load, as after a chmod repair, has its error cleared and
nothing applied again, so the startup's error isn't cleared by a spell
when the file couldn't be read. Before, gate.json's reload compared
only its mtime, taken after the read, so two writes in one clock tick, a
write between the read and the stat, or a read that only looked
(listPolicyNames, a change that wrote nothing) left a new gate.json
unapplied until it changed again, and a chmod that made it readable
wasn't noticed. Recording the text only once it's in force also means a
reload that throws before applying is tried again. isPrivilegedUser,
which tune-out's wake classification calls for each diverted message,
read and parsed the privileged-users file on every call, and while it
couldn't be read each call pushed another copy of its error onto
gate_status's errors; it now takes the throttled check, and so does the
sleep path, through isPrivilegedUser, so an empty author id is never
privileged there either. A clock stepped backwards makes the next check
run rather than hold off both reloads until it catches up. A missing
file is ENOENT from the read itself, not existsSync, which also said
"absent" for a file behind a directory the process can't search.
The privileged-users file loads entry by entry: a non-empty string or a
safe integer is a user id, and any other entry is skipped and named in
the file's error, including a number too large for JSON to read exactly,
whose rounded value could be another user's id. A file that isn't JSON,
or isn't a list or {"userIds": [...]}, is that file's error, and the
list that last loaded in this process stays (none, at startup); it used
to load as no one privileged, without a word.
removePolicy clears the removed rule's state (delivering its pending
debounce batch, dropping its rate-limit and sampling counters) only
after the write succeeds, so a failed keep or write leaves the rule in
force with its state. It used to clear first. The write stays in place,
not a temporary file and a rename: a rename fails on a gate.json that's
a single-file bind mount, needs a writable directory, replaces a symlink
with a file, and resets the file's mode and owner.
gate_status: handleToolCall runs both throttled reloads before it reads,
so what it shows is at most a second old. Its errors, a list that every
gate.json load replaced and every privileged-users failure appended to,
becomes one current error for gate.json, one for the startup's seeding or
reconciling of gate.json (kept apart, so the constructor's own load no
longer clears it at once; it goes when gate.json loads new text or is
written), and one for the privileged-users file. A missing gate.json
clears its error. gate:config-error is traced when gate.json's error
changes, rather than at every read that fails the same way.
test/event-gate-unreadable-files.test.ts covers keeping (by add and
remove, for a JSON error and for a policy the validator refuses, the -2
name, the trace, the log line and gate_status's list), a gate.json that
isn't UTF-8 kept byte for byte, a symlinked gate.json keeping its link
and mode, nothing kept when nothing is written or the file is missing, a
gate.json that can't be read refusing, a copy that can't be made writing
nothing and keeping the rule's pending batch, a write that fails after
the copy naming it, a remove whose write fails keeping its rule and
batch, a text only looked at being applied by the next reload, the
reload's skip of unchanged text, its throttle and a clock stepped back,
both files reloading by text (same size and mtime, and a chmod repair), a
removed gate.json taking its error, files behind a closed directory as
errors, the startup's error beside a gate.json that loads, the trace
once, the privileged-users file's shapes and entries, its throttle, the
sleep path, gate_status reading first, the errors kept apart, and what
wake_add_rule and wake_remove_rule tell the model.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keepUnreadableConfig created gate.json.unreadable-<time> with the process's default mode, 0666 less the umask, so the copy of an owner-only gate.json could be read by any local user who can search its directory, which gate.json itself couldn't (Greptile on anima-research#371). The copy is now created owner-only, then given gate.json's group, and only then gate.json's permission bits, so at no point can anyone read it who couldn't read gate.json: an owner-only file's copy is owner-only, and a group-readable one's can be read by the same group, so a group member who made the half-made edit can get it back from the copy. A copy that can't be given gate.json's group, because the process isn't in it, or whose gate.json can't be stat'd, stays owner-only. gate.json's bits alone would leave the copy in the process's own group, readable by that group's members rather than gate.json's, as a probe showed, and a fixed 0600 would shut out a group that shares gate.json. test/event-gate-unreadable-files.test.ts: a 0600 and a 0640 gate.json give copies of the same mode and group; a gate.json in another of the process's groups gives a copy in that group (it skips where the process is in no other group, as on our runs); and a gate.json linked to a file whose group the process isn't in, such as root's /etc/hosts, gives an owner-only copy, kept by calling the keep directly so that nothing is written through the link. Tests, on Linux, Node 24.21.0: of the twelve gate and tune-out files, 173 tests pass and 1 skips, the test of another group, since the user our runs use is in no group besides its own. Red-first on main's src (92125b0) with this test file: 27 of its 30 tests fail, 2 pass and 1 skips. Removals: each of 50 pieces fails at least one test of the file, including a copy created with the default mode or with gate.json's bits, no chown, no chmod, and the chmod before the chown. The test of another group, and a probe, also ran on a host where the process is in other groups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…json that won't load keepUnreadableConfig gave the copy gate.json's group with chownSync(kept, -1, gid), -1 meaning "leave the owner as it is". Bun 1.3.14, the version connectome-host's CI and release workflows pin, refuses a uid of -1 with EPERM even for the file's own group, and the shared catch then skipped the chmod too, so under Bun the copy always stayed owner-only and a group that shares gate.json couldn't read it. The copy is the process's own file, so the chown now passes the process's uid, which an owner may always keep (-1 where there's no geteuid, as on Windows). A gate.json that stopped loading reached only the gate:config-error trace, so an operator whose edit didn't load found nothing in the log. When gate.json's error changes, configUnreadable now logs one [gate] line saying it couldn't be read and why, so each distinct error is logged once. test/event-gate-unreadable-files.test.ts: a copy of a 0640 gate.json is 0640 where chownSync refuses a uid of -1, as Bun 1.3.14 does; a change over a gate.json that won't load logs the failed load, then the copy; and a failure read again is logged once, and a new one once more. Tests, on Linux: of the twelve gate and tune-out files, 174 of 175 pass and 1 skips, the test of another group, since the user these runs use is in no group besides its own. Red-first on the previous commit's src with this test file: 3 of its 31 tests fail, these three, 27 pass and 1 skips. Removals: each of 52 pieces fails at least one test of the file, among them a chown that passes -1 again and the log line taken out. A script that keeps a copy of a 0640 gate.json that won't load got a 0600 copy under Bun 1.3.14 before this change and gets a 0640 one after, as under Node 24.21.0 and Bun 1.4.3. The whole suite passes: 2259 of 2260 tests with 1 skipped, file by file in 145 files on Node 24.21.0, and the same on Node 20.20.2.
…obably meant closestNames(name, candidates, max = 2) ranks candidates for a refusal's "did you mean", ignoring case: an equal name; then one the name matches as a whole `--` segment (`ls` for `workspace--ls`); then one that starts with the name or that the name starts with; then one that contains it or that it contains. These two need three characters on the shorter side and count however many edits they take. Last comes one a few edits away: at most a quarter of what follows the prefix the two share, and at least one, since a shared prefix is mostly a namespace, so `workspace--write` isn't offered for `workspace--read`. Within a rank, fewer edits come first, ties keep the order given, and a candidate given twice is named once. Where one name holds the other, the length difference is already their distance, so a long name, such as a catalogue pasted in by mistake, runs no edit table for each candidate. isNearMiss(name, candidate) says whether a name is a slip for another: the same ignoring case; within that edit limit, a transposition counting as one edit; or, at three characters or more, a part of it, which is a shortened attempt at it (`mode` for `deliveryMode`). A part counts only that way round: a name that holds the candidate, past the edits a slip allows, is another name, usually an extra that explains it (`channel_note` beside `channel`). The tests pin each rank against the next, a name that starts with or holds its candidate, the cut-off and how it scales past a shared prefix, ties, repeats, and isNearMiss' slips and non-slips, and, as a hang guard, a million-character name that holds all of a hundred candidates.
…s it ran
A wake rule's slips used to change what it does without a word, in gate.json
and through wake_add_rule alike:
- validatePolicy saved any behavior other than "always", "defer", "skip" or
an object as "always", a rule that wakes on every event it matches. That
covered a typo such as "deffer", and, in membrane's XML tool mode (0.5.87
parses a parameter by its declared type), the object form wake_add_rule's
own description showed: `behavior` is declared a string there, so
{"debounce": 60000} arrived as text and was saved as "always".
- A match field of the wrong type was dropped: {match: {tagsAny:
"chat:ambient"}, behavior: "skip"} was saved as match {}, a rule that skips
every event, DMs and mentions included (issue anima-research#105's hazard, reached by a
type slip).
- A field name one slip from a known one (pasthrough, keyby, polices) was
ignored, so the field meant was missing: pasthrough: true made a governor
consume the events it should let fall through. wake_add_rule's own
parameters were taken by name, so a slip of one was dropped the same way.
- So was a field put at the wrong level: {name: "quiet", tagsAny:
["chat:ambient"], behavior: "skip"} skipped every event, mentions
included, and addPolicy saved it as match {}; wake_add_rule given tagsAny
as a parameter of its own saved its rule with match {} too. A list of
rules under a name gate.json doesn't read, such as "policy", and a field
of a rule, or of its match or behavior, at its top loaded as no rules
under its default, and a change wrote the rules in force over them.
- A behavior object holding two kinds used debounce, then rate_limit, then
passive_sample, whatever the order; a keyBy or resets of the wrong type
was dropped; and a gate.json whose "policies" wasn't a list, or which was
a list, loaded as no rules under its default.
Probes at main 92125b0 showed the behavior, match, policies and
wrong-level cases.
Each is named now, every one a rule has, not only the first. What's done
with a rule that has one depends on who can fix it:
- wake_add_rule and addGatePolicy refuse it, naming every problem the rule
has, and save nothing: their caller is there to fix it. wake_add_rule's
checks of its own parameters come first, each refused on its own, a field
of match, rateLimit or passiveSample given as one of them included, naming
the parameter it belongs in.
- gate.json and the host's starting rules are what a deployment already
runs, so there a rule with a problem is read the way main read it, with a
note in gate_status's errors and a [gate] log line, and the rest of the
file loads. A rule is left out only where main's validator refused it. One
that skips or defers is read past a problem as main read it too, holding
back what main held back: left out, the events it held back would go on
to the rules after it, where a rate limit, a sample or a debounce would
spend them and then hold back an event main woke on. A probe that sends
one stream of events through both gates found 72 such events in 64 of 144
configurations where an earlier version left these rules out.
- Under a default that holds events back, "skip" or "defer", a file that
leaves out a rule that could wake the agent isn't used: it would hold back
what that rule was written to wake on, and main, which refused such a
rule and so the whole file, didn't use it either. A start then runs the
host's starting rules, or none, which hears every event, and a reload
keeps the rules in force. A rule left out that wakes on none of what it
matches (one that skips or defers, or a rate limit whose tokens are a
number at or under 0) holds back nothing it was written to wake on, so it
doesn't count, and a deliberately quiet file loads as it did; one whose
tokens are missing or not a number, or whose behavior is a list or names
no kind, was written to wake on something, so it counts.
The promise: no event that woke the agent on main is silenced, unless main
refused the whole file and so ran something else in its place (at a start
the host's starting rules or none, at a reload the rules it had), or
refused one of the host's starting rules and so ran them all unvalidated.
What remains of that exception, under "always", is an event main woke on,
having refused the file, that a rule after the one left out now holds back:
one that skips or defers it, a rate limit or a sample that lets only some
through, or a debounce that delays it. Finding those would mean judging what
each rule matches.
How each class is read, from main's validatePolicy at 92125b0:
- Left out, main having refused it: a rule that isn't an object or has no
name; a behavior that's a list, or an object holding no kind; a rate_limit
or passive_sample that isn't an object or lacks tokens, refillIntervalMs or
every; a debounce, tokens, refill or every that isn't a finite number; a
rate_limit with tokens at or under 0 (it woke on nothing); an invalid
regex filter; and a passthrough that isn't a boolean or sits on a behavior
that doesn't count. For the starting rules, main ran these raw, from
memory, once the gate.json it seeded failed to load, and some woke the
agent; they stay left out and named at start, rather than carry main's
accidents into the reader.
- Read the way the starting rules ran it, in gate.json too, with a note: a
debounce outside 100-300000 ms at its nearest bound; a passive_sample
every at or under 0 as 1, and a fraction as its next whole number; and a
refillIntervalMs at or under 0 as a bucket that never refills, so it wakes
on its first tokens and then on nothing it matches. applyRateLimit refills
only for an interval over 0, and so does wouldTrigger, which probe() and
wake_add_rule's before-and-after table read. A refill of 0 refilled once
on main, since floor(elapsed / 0) is Infinity and lastRefill += Infinity *
0 is NaN; main refused it in gate.json and ran it only from the starting
rules, which the exception covers.
- Read as main read it, with a note, a rule that skips or defers included,
whose note adds that it skips or defers every event it now matches, as
main did: a behavior that isn't a name or an object (typo, number, the
object form as text) as "always", a slip of "behavior" itself included;
two kinds, by main's precedence; a match that isn't an object as matching
every event; a match field of the wrong type, a slip of one, an entry of
the wrong type or "" in a list, a filter main couldn't read, a field the
gate reads at another level, named with where it's read, and a keyBy or
resets of the wrong type, or a slip of keyBy, resets or passthrough, each
dropped. In a rule the gate reads, a match left with nothing that narrows
it, once something in it was named, gets a note that it matches every
event. In a refusal of such a rule, where nothing was saved, the note says
the rule as written would match every event.
- An empty scope entry, which main kept and which narrows a rule, since no
event's type is "", is read as main read it whatever the rule does.
- gate.json itself: one that's a list, or whose policies isn't a list and
holds something, or is missing or holds nothing beside a slip of it
holding something, main loaded as no rules under its default; it is read
so, with a note, where that default is "always", and isn't used where it's
"skip" or "defer". Where policies is missing or holds nothing, a field of
a rule, or of its match or behavior, says a rule was meant: a list holding
an object with such a field, under any name gate.json doesn't read, is
such a slip, and so, under "skip" or "defer", is such a field at the top;
main used such a file, with none of those rules in force. A rule's name, a
filter's type and a keyBy shape nothing on their own, and nor does a field
of a rule or its match holding nothing, an empty list or an object holding
only empty values, since an empty one has always meant no constraint, so a
label of the file can have their names, or hold nothing: at the top, or in
a list whose objects hold no field that says a rule was meant, they load
as written, as on main, with a note, and a change keeps them. A behavior,
or one of its kinds, counts even when it holds nothing, as one does in a
rule left out; a null or "" is a field left out, in a list's objects as at
the top. A label named as such a field, such as "source": "written by
ops", counts as that field, since nothing in a name tells the two apart. A
policies that isn't a list, or a slip of it, holding nothing, such as
"policies": {} or "polices": [], writes no rules, as [] does: it loads as
no rules, as on main, with a note saying what its default then does, and a
change writes the list in place of such a policies, or beside such a slip.
Beside a policies holding nothing, [] included, a slip of it holding
something, a list of rules under another name and a field at the top that
says a rule was meant are read as they are with no policies. A list
holding no rule, such as "comments": ["quiet for now"] or "policy": [],
loads as written. A slip of "default" reads as "always", as on main. JSON
that won't parse, a non-object, and a default other than the three are
refused whole, as on main. A policies left out, null, "" or [] writes no
rules, and loads so under its default, as on main.
- A null is a field left out, and so is "", with a note that says what
leaving it out does where that's more than no constraint: a match left
out matches every event, and so does one whose fields are all ""; a
behavior is "always"; a keyBy leaves one bucket or count for every event
the rule matches; gate.json's default is "always"; and its policies leave
no rules, which under "skip" or "defer" holds back every event. main read
most fields that way; where it refused a rule or the file for one (a
passthrough, a behavior's kind, gate.json's default), the rule or file
now loads without it.
The reader is one walk, readRule, that collects every problem with the
reading main gave it (or none, where main refused the rule); readConfig reads
a whole gate.json or starting config with it, and ruleOutcome decides each
rule: as written, read, or left out. validatePolicy and validateConfig go.
Around it:
- The starting rules are read rule by rule, at the first start and in the
reconcile, the same way gate.json is, so the condition above applies to
them too: under a starting default of "skip" or "defer", a starting rule
left out that could wake the agent means none of them is used, and the
start runs gate.json's rules, or none, which hears every event. A first
start writes the starting config as given, since gate.json is read the
same way, so its load names each problem until someone fixes it.
- A write edits gate.json's own rule list by name: addPolicy and
removePolicy, behind wake_add_rule and wake_remove_rule, and the
reconcile, write back every rule they don't change as it was written,
problems and unknown fields included, and gate.json's own fields with
them. The rules in force are then read from the text written, as a load
reads it, so what gate.json still holds that the gate reads past or leaves
out stays named. A rule left out can be replaced or removed by its name.
Before, a write wrote the rules as the gate held them: a "comment" didn't
survive one, and a rule left out would have dropped from the file.
- A gate.json that's JSON the gate doesn't use is edited the same way, and
only what loads is written: a change that doesn't mend what keeps the
file out is refused, naming that and what would bring it into force,
worked out from the file as it is, and the file stays as its operator
wrote it. Where one named rule keeps it out, replacing or removing that
rule by name brings it into force, and the refusal says so; where a rule
with no name, two rules or a bad default keep it out, no one change can,
and the refusal says whoever runs the host has to fix gate.json. Where
the rule shares its name with another, a replacement by that name takes
the first of them and a removal all, so the refusal says that instead of
naming a change that would replace or remove a sound rule.
A change used to write the rules in force over such a file, losing its
other rules, its own fields and its default.
- When gate.json is missing, can't be read or isn't JSON, a change edits
the list of the text whose rules are in force the same way, after keeping
a copy of a file that can't be read or isn't JSON, and writes the rules
in force only where there's no such text or it has no rule list. So does
a change after a start whose reconcile write failed partway: the text it
was writing is the text in force. The copy's log line, keptCopies entry
and keptUnreadable say gate.json "couldn't be used", true of both.
- A rule gate.json holds holds its name, whether the gate reads it or
leaves it out, so the reconcile adds only starting rules whose names the
file lacks. Why: an operator's "ambient" skip rule narrowed to Zulip, with
an empty tagsAny entry main read past, holds its name; had the reconcile
added the host's "ambient", which skips all ambient chat, Discord's
ambient chat would go quiet under the operator's "always" default, where
main woke on it. It takes both halves: the name held, and the rule kept
through every write, over a gate.json that won't load included.
- The reconcile never writes a gate.json it would then refuse: where adding
the starting rules would leave out one that could wake the agent under
gate.json's own "skip" or "defer" default, it adds none, and the startup
entry says why.
- A gate.json with no rule list a write could edit without writing over or
leaving out what it holds (a list, a policies that isn't one and holds
something, a slip of policies holding something where policies is missing
or holds nothing, which holds the operator's rules, a list of rules under
another name, a field at the top that says a rule was meant, under "skip"
or "defer", a label named as one included, or JSON that isn't an object)
refuses a change, an addition or a removal, whatever its default, naming
the fix and who can make it, and saves nothing; the reconcile adds nothing
to it and says so. A removal there used to answer that no rule had the
name, though the file can hold it, so a host's removeGatePolicy meets the
same refusal as its addGatePolicy. An addition there used to write the
rules in force over the file, dropping the operator's rules; a list added
beside them would bring the file into force with only the new rule. Where
gate.json can't be read at all, nothing is kept and a change is refused,
as before, and the refusal now says whoever runs the host can make it
readable, and that a change is refused the same way until then.
- Also: a field name the gate reads nowhere and near none it reads, such as
a "comment", loads and is named, in gate_status's errors, the result's
ignoredFields, or onIgnoredFields; a slip is named with every field it
could be meant for (isNearMiss and closestNames, in the commit below this
one); wake_add_rule's own parameters are checked the same way, and it
refuses a behavior, position, insertBefore or insertAfter of the wrong
kind in its own terms, its example giving debounceMs: 60000; what the
start did is an entry of gate_status's errors worded about the start, kept
until the next start, beside an entry for rules the start couldn't save,
where a failure in writing or closing, unlike one opening, may have left
gate.json part-written; the startup entry checks the names gate.json
holds, not the rules it runs, before it says a starting rule wasn't added;
a reconcile whose write leaves gate.json unable to load runs what it read
plus what it was adding, and its entry, which names what those hold that
the gate reads past or leaves out, goes once gate.json is written, or a
reload applies its text or finds it holding the text in force;
configSource is "initial" while the starting rules run; gate_status's
entry for a gate.json the gate doesn't use says what's in force instead:
the rules of the last gate.json it used, the rules a start read and was
adding when its own write left gate.json unable to load, the host's
starting rules, or none; a loaded gate.json's notes stay with the text in
force (configNote); the log line for a gate.json that won't load says it
"couldn't be used", as the copy's texts do; and a value a message quotes,
over 80 UTF-16 units, and a skipped privileged-users entry, over 40, are
cut with safeSlice, so neither ends on half a surrogate pair.
The gate's other messages keep their wording.
Tests, on Linux, Node 24.22.0: test/event-gate-strict-rules.test.ts, 26
tests, among them each class read or left out in gate.json with the events
it wakes on, a rule that skips or defers read past a problem as main read
it, ahead of a rate limit that spends its tokens only on what reaches it, a
file that leaves out a rule that could wake under "skip" or "defer" not
used, at a start, with and without starting rules, at a reload, and as a
starting config, beside the rules left out that don't count, every problem
named at once, a field at another level read past in gate.json, with how the
rule then reads for a field of each level, and refused by addPolicy and
wake_add_rule, a list of rules under "rules" read as a slip of "policies",
and one under "policy", a rule's fields, a match field, a rate limit's, a
filter's type beside its pattern and a "source" label at the top not used
under "skip" or "defer", with lists that hold no rule loading as written,
and a rule's name, a filter's type or a keyBy at the top, or a list of
objects holding no field that says a rule was meant, a field of a rule or
its match holding nothing, and a policies or a slip of it holding nothing,
loading as written, quiet, and kept by a change, a value a rule would drop
and a behavior or kind holding nothing at the top, or in a list's object,
not used, a slip holding a rule, a list of rules under another name or a
rule's fields beside a policies holding nothing not used, a change refused
there, a list holding null or an object whose rule fields are null or ""
loading as written, and a list mixing a label's object with a rule not used,
gate_status's entry saying what's in force, after a start whose write left
gate.json unable to load too, the starting rules rule by rule, a write
keeping the rules it doesn't change, over a gate.json that isn't JSON too, a
change refused where gate.json has no rule list, a slip of "policies" and a
"source" label among them, a change over JSON the gate doesn't use refused
unless it mends it, with the file left as written and the refusal naming
what would bring it into force, and the operator's "ambient" holding its
name through a change, a restart and a change mid-edit.
event-gate-rate-limit's validation tests pin tokens of 0 and -2 left out and
a refill at or under 0 read, with the bucket never refilling and the probe
saying so at each step; event-gate-unreadable-files' gate.json refused
whole, for a default other than the three, is now neither written over nor
kept, the refusal says whoever runs the host has to fix it, the log line
says "couldn't be used" once, its copy says "couldn't be used", a gate.json
that won't load has an entry saying what's in force, and one that can't be
read refuses a change saying whoever runs the host can make it readable;
event-gate's two debounce-bounds tests pin an out-of-range debounce in
gate.json read at its bound, where it was refused whole. Red-first on the
parent commit: 40 of the four changed files' 135 tests fail and 1 is skipped
(25 of the 26 strict-rules tests, the round-trip control passing; 3
rate-limit; 2 event-gate debounce bounds; 10 unreadable-files). Removals:
each of 186 pieces, undone on its own, fails at least one test. Fourteen
files (event-gate's nine, closest-names, mcpl-gate-roundtrip, api-admin-gate
and tune-out's two): 206 of 207 pass, 1 skipped (the other-group test).
Probes that run main's gate beside this one: of 142 gate.json
configurations, none silences an event main woke on where main loaded the
file (146 events where main refused it, the exception); one stream of events
through each gate, over 144 configurations, a skip or defer rule ahead of a
rate limit, a sample or a debounce, with one of eight problems or none (the
controls): every event decided the same on both; the recipes and other
starting rules, first and later start: none; an operator's narrowed
"ambient" through a change, a restart and a change over an unloadable
gate.json: Discord's ambient chat wakes on both; every waking rule left out
under "skip" or "defer": every event wakes on both; a field at the wrong
level, a list under "policy", and a rule's fields, a match field or a rate
limit's at the top, with starting rules and without: none goes quiet, and
the saves and writes main made of them are refused; labels at the top named
name, type or keyBy, a list of objects holding none of a rule's fields, and
fields of a rule or its match holding nothing, at the top or in a list's
object: decided alike on both, and an addition keeps them where main wrote
over them; a policies or a slip of it holding nothing: decided alike on
both, and an addition writes its list there, keeping a slip beside it; a
"source" label, a filter's type beside its pattern, a value a rule would
drop, a behavior or kind holding nothing, and a slip holding a rule, a list
of rules or a rule's fields beside a policies holding nothing: not used, as
a match field at the top isn't. connectome-host's four recipes load with no
errors on a first and a later start: clerk 5 rules, knowledge-miner 3,
knowledge-reviewer 3, zulip-miner 2. The suite: merged onto main 3adbdb8,
each of its 155 test files run on its own on Node 24.22.0 passes 2496 of
2497 tests, with 1 skipped, which needs a second group the run's user isn't
in; main run the same way has 152 files and 2434 tests, and the three new
files hold the other 63.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy of a gate.json that won't load takes gate.json's group and permission bits, so that it's no more readable than gate.json. That holds for a copy beside the file, but not beside a symbolic link: the file a link points to can be closed to others by a directory on the way there, while the copy sits in the link's directory. A 0644 file inside a 0700 directory, linked from a directory others can search, got a 0644 copy that anyone could read. The copy of a gate.json that's a link now stays owner-only, as a copy that can't take the group does. Tests, on Linux, Node 24.22.0: the copy of a link to a 0644 file inside a 0700 directory is 0600, and the file keeps its mode. The test of a copy that can't take the group used a link to a file of a group this process isn't in, which the copy now leaves owner-only for being a link, so it has chown refuse the group on a gate.json that isn't a link.
Takes its owner-only copy of a gate.json that's a symbolic link. The one conflict was the node:fs import: this branch's names, with lstatSync for the copy and without statSync and existsSync, which nothing here uses now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l knows
- "__proto__": withoutEmpty copied an object's fields by assignment, so
a "__proto__" in gate.json, an own field as JSON gives it, set the
copy's prototype, and the gate read the fields of its value as the
object's own. A rule whose match was {"__proto__": {"source":
"slack"}} matched only slack's events, where the gate without this
change reads the key as a field it ignores, and the rule matches every
event. The copy is now built with Object.fromEntries, which keeps the
key an own field, named as one the gate doesn't read. The same
function looked up what leaving out a "" field means on a plain
object, so a "" field named "constructor", "toString" or "__proto__"
found an Object.prototype member and its note printed that member's
text. It now reads only the table's own fields.
- A write's state: a change put in force what it wrote without clearing
the state of the rules that reading left out or gave another behavior.
A rate limit left out by a write kept its spent bucket, and when
gate.json restored it, the reload took it for a new rule and reused
that bucket, holding back events it should wake on. The reload's
cleanup is now one helper, clearStateFor, which a write calls too,
last, once its text is written and its rules are in force, so a write
that fails leaves the rules in force with their state: a rule it
leaves out, or gives another behavior, loses its buckets, counters
and timers, and a rule left out loses its stats. Each pending debounce
batch, at a reload too, where it was delivered at once, is then sent
as its timer would have sent it, held while an agent is inferring or
the host is quiesced, and only once every rule's state is cleared.
Each is sent on its own, so a delivery that throws leaves no state
behind and keeps no other batch from being sent; the first such error
is thrown once all have been tried.
removePolicy's own clearing of the rule it removes goes, since the
write does it. A write that fails after a copy of gate.json was kept
ends its error with a stop before the sentence naming the copy.
- A behavior holding two kinds or three is read as the first it holds
of debounce, rate_limit and passive_sample, as before, but a refusal
named only the kinds it held, not the problems of those it didn't
read, so the call that followed could be refused for those.
Each other kind is now read on its own for its problems, which a
refusal names and a load reads past.
- wake_add_rule named a parameter it doesn't take only when the rule was
saved. A refusal now names it too, whether it's the tool's own refusal
or the gate's, after the refusal's own text, which gets a stop if it
has none, and a refusal of a slip or a misplaced field names it in
its own sentence.
- The changelog fragment of the change this builds on described two
things this change alters: a gate.json holding a policy the gate
refuses written over after a copy, and the start's error cleared by a
load or a write. Both sentences now say what happens.
Tests, on Linux, Node 24.22.0: a "__proto__" in a rule's match narrows
nothing and is named; a "" "constructor", "__proto__" or "toString" is
left out with the plain note; a write that leaves a spent rate limit
out, and one that gives it more tokens, each leave it a fresh bucket,
and a rule restored after the first starts with no counts, as one a
reload leaves out and restores does; a rule a write removes has its
pending debounce batch delivered, once it's out of force, and one a
write changes while an agent's turn runs is held until the turn ends; a
delivery that throws leaves the state of a rule after it cleared, and
the batch of one after it sent; a write's error before a kept copy's
name ends in a stop; a refusal of a behavior with two kinds or three
names each one's problems, in the order they're read, and a load reads
past them; wake_add_rule's refusals of a slip, of its own and of the
gate's name a parameter it doesn't take, after a stop where the gate's
text had none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re it isn't given
A name holding a field's whole name, such as "matchTags" for "match",
"channelId" for "channel" or "debounceMs" for "debounce", was a name
the gate doesn't read wherever it stood. So a rule given "matchTags" in
place of "match" was saved matching every event, and wake_add_rule took
"matches" in place of "match" the same way. Beside the field it holds,
such a name is an extra that explains it ("channelNote" beside
"channel"); in the field's place, it's the field under another name.
In a rule's fields, at every level, and in wake_add_rule's parameters,
a name holding the whole name of a field its object doesn't give,
ignoring case, is now a slip of that field: a change refuses it, naming
the field, and a load names it and reads it as the field left out, as
before. One holding two such names is a slip of each, and one beside
the field it holds is still a name that does nothing. It's decided
where the gate sees every name an object gives, beside isNearMiss,
which is left as it was.
At gate.json's top, a name holding "default" or "policies" stays a
name that does nothing. There a slip of "policies" holding something
keeps a file that holds events back out of force, and such a name can
be a label of the file. A list of rules under any name is already read
as a slip of "policies" where "policies" writes no rules.
Tests, on Linux, Node 24.22.0: a name holding a field's whole name,
where the rule doesn't give that field, is refused as a slip of it at a
rule's top, in its match, in its behavior and in a rate limit, naming
both, whatever their case, for one holding two names the rule gives
neither of, and only the one it doesn't give where it gives the other;
wake_add_rule refuses "matches" with no "match", and names "matchNote"
beside "match" and saves the rule without it; in gate.json, a rule's
"matchTags" is read as "match" left out, as before, and a match's
"channelNote" beside "channel" is named; at the top, "defaultBehavior"
and "policiesUpdated" are names that do nothing, so a file that holds
every event back with "policiesUpdated" is used.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A write clears the state of the rules it takes out of force or changes once its text is written and its rules are in force, then sends each pending debounce batch through the timer's path. A delivery that threw there was thrown again once every batch had been tried, so a change that was saved and in force reached its caller as a failure: addPolicy and removePolicy threw, skipping what follows the write (the kept copy's report, the ignored-field notes, the trace and the return), and wake_add_rule and addGatePolicy said the change failed. A reload, which clears before its swap, threw before the swap, so the rules it was replacing stayed in force with their state cleared, and its catch said nothing. Each batch whose delivery throws is now named in a [gate] log line and dropped, and the rest are still sent, so a write or reload that did its work returns as one that did. The batch can't usefully be kept: by then its rule is out of force or has another behavior, so it would sit with no timer, and a batch its timer can't deliver is dropped the same way. The changelog fragment says so. Tests, on Linux, Node 24.22.0: a write whose first batch can't be delivered returns the rule it saved, which is on disk and in force, logs that batch as dropped, clears a spent rate limit after it and sends the batch after it; a reload whose batch can't be delivered puts the new rules in force, so a mention wakes the agent, and logs the batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Takes main at e670363. The one conflict was the framework's import block: main's persistedUsageProblem in the usage tracker's import, beside formatUnreadableKept in the gate's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a callback throws The copy of a gate.json that won't load took gate.json's group and permission bits, and stayed owner-only for a link. Two setups got past that. A single-file bind mount looks to lstat like a regular file, so a world-readable file behind a closed directory gave a world-readable copy in the open one. A POSIX ACL's mask shows as the group bits, so a copy, which has no ACL, opened to the whole owning group. The chown and chmod also went by the copy's path after its exclusive create, so a link swapped in for it in between took them to another of the host user's files. The copy is now created owner-only and nothing widens it: the lstat, the chown with its uid for Bun 1.3.14, the chmod and the link case are gone. The host read gate.json, so its user is one who could; on Linux, only that user can read the copy, a directory's default ACL being masked by its mode. The changelog fragment says so. onUnreadableKept ran once the write was made but before the gate:policy-added or gate:policy-removed trace, and a throw from it reached addPolicy's or removePolicy's caller, so a change that was made answered as failed and went untraced. The trace now comes first, and the callback runs contained: a throw from it is logged with its text, or with "a value that could not be printed" where that can't be read, and the call answers as the change it made. Tests, on Linux, Node 24.22.0: a 0600, a 0640 and a 0644 gate.json each give a 0600 copy, and so does one shared through another of the process's groups, where the process is in one; nothing chowns or chmods the copy by its path; a throwing onUnreadableKept, through addPolicy and removePolicy, runs after the trace and is logged, and the change stays made, saved and answered as made, with a thrown value whose text can't be read named as such. The test of chown refusing -1 under Bun goes with the code it tested. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Takes the branch this one builds on at its new head, which merges main at e670363 and keeps the copy of a gate.json that won't load owner-only, with its callback called once the change is made and traced. Conflicts: - the framework's import block: this branch's gate imports beside main's persistedUsageProblem; - keepUnreadableConfig: the group and mode steps go, as there, and the log line keeps this branch's "couldn't be used"; - addPolicy and removePolicy: this branch's edit of gate.json's own rule list stays, and its callbacks run after the trace, each contained, as the other branch's onUnreadableKept now is; the onIgnoredFields callback takes the same rule; - the gate's two errorText helpers are now one, the one that can't throw; - the doc comments of addPolicy and addGatePolicy, and the changelog fragment's sentences on the copy's access and its callback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… to the log A gate.json under "skip" or "defer" whose rules sat where the gate doesn't read them, under a slip of "policies" or another name, or with a field of a rule at its top, or whose "policies" wasn't a list, went out of force, where earlier releases loaded it and held back every event. At a start, out of force ran the host's starting rules in its place, or none, which wakes the agent on every event, and the file's lack of a rule list then refused every change, so an agent its operator had muted woke on everything and couldn't undo it. Nothing in a name tells rules parked on purpose, such as under "disabled", from a misnamed list, so such a file now loads as earlier releases read it: each such name does nothing, and a note names it, a slip of "policies" with what the file's default then does. With no rules, the default decides every event, as before. A change keeps each such name as written and adds a "policies" list beside it; it still refuses to write over a "policies" that isn't a list and holds something. The check of which fields say a rule was meant, and its list of label names, go with the refusals they decided. A pending batch held for an agent's turn, or while the host was quiesced, was delivered in a microtask that didn't catch, so a delivery that threw, as addMessage does with no agent configured, was uncaught and stopped a host with no handler for that, and a write's batch now takes that path. A batch its timer sent threw the same way. fireDebounce now catches its own delivery, logging the batch it drops, and so does the buffer's flush, so a write, a reload, a timer and a turn's end each drop a batch that can't be delivered the same way. Rules that share a name share its state, and clearing compared each rule with the last rule of its name, so with two rules of one name every write reset their state, and giving the first the last one's behavior kept the first's timer. A name now keeps its state only where its rules keep their behaviors, in order. A refill interval of 0 read as never refilling, so a starting rule that had refilled once woke the agent on fewer events than it did. It now refills at once, in gate.json and the starting rules alike, so the rule limits nothing, and the probe reads it the same way. The text that said an out-of-range number reads the way the starting rules ran it now says where it doesn't: a debounce is read at its bound, which can send a batch later or sooner, and the changelog lists each bound and which way it moves. Tests: the file-level readings turned round, loading under each default with a write adding a list beside what the gate doesn't read; a "policies" holding only null and "" written over as one holding nothing; the starting config read the same way, and refused whole where it leaves out a rule that could wake the agent; a later start adding its rules beside a list the gate doesn't read; a refill interval of 0 waking on every event, with the probe agreeing on a spent bucket; rules sharing a name keeping a pending batch through a write that changes neither, and losing it when one changes or when a reload swaps their behaviors; a batch whose delivery throws dropped and logged from its timer, after an agent's turn and after a quiesce; every rule cleared before a write sends any batch; and an onIgnoredFields that throws logged once the change is traced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A later start over a gate.json the gate loads but that has no rule list to add the host's starting rules to (a list, or a "policies" that isn't one and holds something) wrote nothing and put none of them in force. Earlier releases wrote them over the file, losing what it held, and ran them under its default, so under "skip" the events they woke the agent on went quiet. Now the start writes nothing over it and runs them under gate.json's default, beside it and unsaved, as it runs what a reconcile was writing when that write left gate.json unable to load. gate_status says so, with who can give gate.json a list, until gate.json changes and loads; a reload that finds it unchanged keeps them. Where, under that default, one of them left out could wake the agent, or the starting rules aren't used, none runs, as before. A copy of a gate.json that can't be made says who can fix it, and that a change is refused the same way until then. The log line for a batch whose delivery throws gives its size. The docs of addGatePolicy, removeGatePolicy and addPolicy, and the changelog, say a copy is kept of a gate.json that could be read but isn't JSON, where they said one that can't be read too, which is refused, and that the change then edits the text whose rules are in force, or the rules in force. The reading rules' header and the changelog name a null or "" where "for one" also read as any field, and readRateLimit's doc says which rate limits without usable tokens are quiet. Tests: a later start over a gate.json that's a list, and over one whose "policies" isn't, runs the starting rules it lacks, writes nothing, says so through a reload that finds gate.json unchanged and not once it changes, and runs none where one of them is left out under that default or the starting rules aren't used; a copy whose write fails once it's made is removed; a dropped batch isn't traced as delivered or sent again; the log line gives the batch's size, one event or more. The rate-limit tests' helper and two hot-reload tests no longer wait for an mtime the gate doesn't read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Beside a gate.json with no rule list, the host's starting rules are what's in force, so gate_status's configSource says "initial", and a reload that finds gate.json unchanged keeps them and their entry, read from that source where the start kept a flag of its own; once gate.json is unusable, its entry says the gate runs the host's starting rules. gate.json's own load note says the gate runs them beside it, under its default, where it said every event got the default, or was held back. The start's entry names only the starting rules it runs, one the starting rules leave out being named so there; where every one is left out, none runs and the start says they weren't put in force. That entry says a start then adds them to a list as to any gate.json with one, where it said the next start would. A start's entry for starting rules it couldn't add says whoever runs the host can fix what kept them out. The keptCopies doc says a copy is kept of a gate.json that could be read but isn't JSON, and the quiet flag's docs and readRateLimit's say which rate limits without tokens are quiet and what leaving one out holds back. Tests: the start beside a list and beside a "policies" that isn't one, with gate.json's entry, configSource "initial", a left-out starting rule named so, a reload to text that won't load naming the starting rules as what runs, and every rule left out; a copy whose open fails leaving a file of its name as it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A read of gate.json that finds the text in force gives gate.json's error that text's notes, as an unchanged read did, so a change refused for want of a list, which reads the file to edit it, no longer swaps the note saying the host's starting rules run beside gate.json for the file's own. listPolicyNames reads gate.json unless it holds the text in force, so it names the rules in force, which beside a gate.json with no rule list are the host's starting rules; loadConfig, its only user, goes. The quiet reading's docs say that what a rule left out matches goes on to the rules after it. The constructor's and seedOrReconcile's comments and the changelog say that none of the starting rules runs beside such a gate.json where, under its default, one of them left out could wake the agent. configErrorShown's comment says an unchanged reload clears the start's note only while gate.json's rules are in force, and the changelog says "default" was what a start said while the starting rules ran in place of a gate.json that didn't load. Tests: a change refused for want of a list leaves gate.json's entry as it was, and a listing then names the starting rules in force. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…beside it At a later start over a gate.json with no rule list, the host's starting rules run beside it under its default. Where, under that default, one of them left out could wake the agent, the start ran none of them and used gate.json, which has no rules, so under "skip" or "defer" every event was held back. Earlier releases wrote the starting rules over the file, couldn't load what they wrote, and ran them from memory under their own default. Now gate.json isn't used at that start, which runs as one without it does: the starting rules as read, under their own default. Where they're refused whole under their own default and gate.json's holds events back, the start runs no rules, so every event wakes the agent, where it ran gate.json's none. configSource says "initial" or "default", and gate.json's entry and the start's name the rule left out, what runs instead, and that giving gate.json a list of rules, or fixing the starting rules, is the way back. A reload that finds gate.json unchanged keeps what the start ran; once gate.json changes and loads, what it holds is in force. Tests: a starting rule left out that could wake the agent under gate.json's "skip", where the starting rules run under their own "always", with both entries, an unchanged reload and a change that loads; and starting rules refused whole under their own "skip" beside it, where no rules run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… whole Beside a gate.json with a list of its own, under "skip", starting rules refused whole leave its rules in force, with configSource "file", where only a gate.json with no rule list isn't used at that start. The comment above the case of a gate.json with no rule list under "always" says what runs there: its own reading, no rules, every event waking the agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At a later start over a gate.json with a rule list but no rules of its own (an empty list, a "policies" left out, "" or holding nothing, or a list holding only rules the gate leaves out), where one of the host's starting rules left out could wake the agent under gate.json's default, or the starting rules are refused whole and that default holds events back, the start ran gate.json's rules, none, so under "skip" or "defer" every event was held back. Earlier releases added the starting rules to gate.json, couldn't load what they wrote, and ran them from memory under their own default. Now gate.json isn't used at that start, as where it has no rule list: the start runs the starting rules under their own default, or no rules where they're refused whole, and gate.json's entry and the start's say why and give the way back. A change at such a start is refused while gate.json holds the text the start didn't use, since the change would put gate.json's rules in force in place of what the start runs; once gate.json changes, a change edits what it holds. Once it's removed or can't be read, a change edits the rules in force rather than gate.json's list, as where it never loaded. Tests: each such list beside starting rules with one left out, with its entries, a refused addition and removal, an unchanged reload, and a change once gate.json changes or is removed; starting rules refused whole beside an empty list; and a list with a rule of its own, whose rules stay in force. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Over a gate.json with no rules of its own, a later start ran as one without gate.json only where a starting rule it would add was left out and could wake the agent. Two more such rules weren't counted: a starting rule with no name, which the gate leaves out and a start can't add by name, and a starting rule whose name gate.json holds only in a rule the gate leaves out, which keeps the host's rule of that name out. Under gate.json's "skip" or "defer", either left the start running gate.json's rules, none, holding back every event a host's rule would have woken the agent on. Earlier releases wrote a nameless rule into gate.json and couldn't load what they wrote, and refused gate.json whole for a rule it leaves out, so both ran the starting rules from memory. Now the start counts each starting rule that could wake the agent and that it can't put in force, left out, with no name, or kept out by name, in one check before the reconcile; gate.json's entry and the start's name each, a nameless one as "one with no name", with what would put it in force. The reconcile beside a gate.json with no rule list builds the rules it runs from the starting rules as read, and the checks of what it would add, which that one check now covers, go. configText's and readConfigFile's docs say what configText holds while a start runs rules beside or in place of gate.json. Test comments say gate.json's default where it decides, and the starting rules as read. Tests: a nameless starting rule beside an empty list; a starting rule whose name gate.json holds in a rule the gate leaves out, and one such that skips, which doesn't count; and a gate.json with no rules under "always" beside starting rules refused whole waking the agent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both start entries now say gate.json "has no rules the gate reads" where a start doesn't use it, true of an empty list and of one holding only rules the gate leaves out, as gate.json's own note says, where they said it had no rules. Tests: a quiet starting rule left out, a rate limit of 0 tokens, beside an empty list, which doesn't count, so gate.json is used and the reconcile writes it in; and a start that can't put in force a starting rule left out, two with no name and two kept out by gate.json's rules of their names, whose entries name each and every way back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stops a page before it would spill What the text is, and a bound on one page, built on resultMaxChars from the commit before. What the text is. read decodes through fileText, new in text.ts: the lines read shows, without a BOM and with a CR before a line feed dropped, where each starts in the stored text as character paging reads it, the CRLF and LF counts, and whether the bytes are valid UTF-8. So a CRLF file's lines come without the CR, a BOM isn't shown, and the page's note says when the file has either, with the counts where CRLF and LF ends are mixed. Bytes that aren't UTF-8 show as U+FFFD, as before, and the note now says so, and that edit refuses the file and write replaces it whole (handleEdit's refusal on this branch). A character page still reads the stored text as it is. A long line. A line over 2,000 characters is cut, without splitting a surrogate pair, with how many characters follow and the offsetChars that reads them, counted in the stored text as character paging reads it, BOM and CRs included. A bound on one page. The framework stores a success's data as JSON and writes what passes the call's resultMaxChars to a file, cut mid-JSON. So a line page stops at the last line whose page fits, and a character page at the last whole character that fits (never inside a surrogate pair), each with a note saying where to continue. Each is costed as JSON writes it (jsonLength) beside the rest of the page, allowing for the page's largest numbers; a line page then adds lines while the exact page still fits. Where even the first line would pass, it is cut shorter, at the most that fits, and a character page always moves on by one character, so a limit too small for a page's own fields still gets the reader somewhere. A call without resultMaxChars, one whose caller takes the result in process, uses the framework's default, 24,000. The page logic is src/modules/workspace/read-page.ts (linePage, characterPage); handleRead now only finds the text. read's description says all three. Tests, in test/workspace-text.test.ts: fileText's lines, starts and counts, for CRLF, LF, mixed ends, a lone CR, a BOM, an empty text and invalid UTF-8. In test/workspace-read-text.test.ts: - A CRLF file reads as its lines without the CR, with its note; mixed ends are counted, and a CR that doesn't end a line stays the line's own. - A BOM is noted and not shown, a character page still shows it, a BOM alone is an empty file that has one, and invalid UTF-8 is noted on a line page and a character page. - A long line is cut at 2,000 characters before an emoji's surrogate pair, and the offsetChars it names, past a BOM and a CRLF, reads exactly the rest. - A line page stops before its result passes 24,000, 5,000 and 1,000 characters; one more line would have passed, and the note's offset reads on. - A first line that alone would pass 1,000 characters is cut where it fits. - A character page of escape-heavy text stops within 24,000 and 1,000, its pages put back the whole text, and a stop never splits a surrogate pair, at four neighbouring limits. - A limit of 50, too small for a page's own fields, still moves a character page on by one character, and shows a first line as its marker alone. - The 2000-line default holds for a call whose limit lets it, a script's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hows them
grep drops the CR before each line feed with read, for matching as well as display, so foo$ finds a CRLF
file's line, as it does under ripgrep's --crlf. grep shared linesOf with read, and with hands' planner coming to
edit, a CRLF line copied from grep ("foo\r") would miss: the planner makes
every CRLF in an edit's old text LF but keeps a lone CR, as hands' match.ts
does.
grep now searches fileText's lines: without a CR before a line feed and
without a BOM, so `foo$` finds a CRLF file's line and `^foo` a BOM file's
first. Each file's result says `lineEndings: 'CRLF'` or `'mixed'` when the file
has CRLF ends, and says nothing for LF alone. The disk version kept with a
conflict is searched the same way. linesOf, which only grep still used, goes.
grep's description says so. The cost, which the changelog names: a pattern
written to find a CR at a line's end no longer matches.
Test, in test/workspace-file-tools.test.ts: `^foo$` with context finds a CRLF
file's line with its context lines all without the CR and lineEndings 'CRLF';
a mixed file says 'mixed', an LF file nothing, a BOM file's first line matches;
and `\r` finds nothing in a CRLF file. And a test of the copy into an edit:
two lines copied from a read of a CRLF file with a
BOM, and then a line copied from a grep match, each match in an edit through
the planner this branch is stacked on, and the file keeps its CRLF ends and
its BOM.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ting and an edit do The read half of the disk check, last here since it changes most what a read does with disk. A read of a path the workspace held didn't look at disk (ensureSynced returned early), so it could show a copy another writer had changed since. Now handleRead passes over its path first, under the three-way rule a listing uses, as handleEdit does on this branch: - disk changed and the store didn't: the change is taken in, and the note says this read shows that text; - disk's copy couldn't be checked: the note says this read shows the workspace's copy; - disk has no copy and nothing records whether it was deleted there (disk-missing-provenance-unknown): the note says so, and that this read shows the workspace's copy, as handleEdit now says for an edit; - disk deleted the file: "File not found", saying disk deleted it and that was taken in (a copy disk made binary or too large keeps the disk-only answer); - the stored copy is a draft and disk changed too: the read shows the draft, and the note names the conflict and both ways to settle it, as the edit's answer does (a refusal would hide the text the agent needs to settle it). A file the workspace didn't hold is taken in with no word, as before. The note rides on a line page and a character page alike (characterPage takes extra notes now). readBinary, the framework's read_image route, settles the same way, so its doc comment's "as the read tool" stays true. A deletion still observes only a path the workspace doesn't hold (ensureSynced, its doc now says why): taking disk's change in first would let its push remove that change unseen, as for a write. Three tests in test/workspace-three-way.test.ts read the store through read, to see what a pass had or hadn't taken in: a sync asked on one branch takes nothing on another, a restored timestamp hides no change, and a tracked link the walk cap passed over isn't read through. A read now settles its own path, so they read the store directly (storedOf, beside contentOf, whose doc now says a read settles first). Before, "the read serves the store" was the first assertion of the second; that is the behaviour this commit changes. Tests, in test/workspace-file-tools.test.ts (6): disk's newer text is shown with the note, the next read has none, and a character page says it too; a file disk deleted; a draft whose disk copy changed shows the draft with the conflict note and leaves disk alone; a directory in the file's place reads the workspace's copy with the unverified note; an entry disk lacks with no record reads with the missing-copy note; readBinary returns disk's new bytes. Red-first: with this commit's source changes undone, all five fail and the other 29 in the file pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ry keeps the workspace's copy, and a deletion conflict says what a sync takes What review found in the read half, and the fixes: - A page whose last line is cut, at 2,000 characters or as a first line cut to fit, named the next line as where to continue. Reading on in order starts with that line's rest, which only the line's own marker named. The note now names the offsetChars of the rest before the next line's offset, on a limit's note and a budget's alike. A page that ends the file has no next line to name, so its cut line's marker stands alone, as before. - The first line's cut is sized on the page itself, now that the note names the cut. The line as a page shows it is tried first: shown whole, it carries no marker and its note no cut, so its page can fit where a cut one character shorter doesn't (a one-line file of 900 characters at 1,000: 984 characters whole, 1,032 cut at 899). Below that, a longer cut never makes the page shorter, and a binary search finds the most that fits. The estimate it replaces reserved the longest note, so on a page that ends the file, which carries none, the cut stopped short by that note's length. - readBinary goes back to observing only a path the workspace doesn't hold (ensureSynced), as before this branch. The pass takes no binary disk copy into the tree, so settling dropped a held binary file that disk had changed, and readBinary, a reader of binary content, answered File not found for a file disk still had. read_image doesn't reach readBinary on this module either: the framework calls the module's own read_image handler where the module serves it, and readBinary only where it doesn't. readBinary's doc and ensureSynced's now say why a held path is left alone. - A conflict's note told the reader that a sync takes disk's copy. Where disk has none, a sync takes the deletion and removes the draft, and the note now says so wherever disk has no copy now (the report's diskNow), not by the conflict's kind: a recorded conflict keeps its kind, so one recorded as both-changed before disk deleted the file is still both-changed (which the test below confirms). Where disk has a copy the workspace can't hold (binary or too large), a sync takes nothing in and only gives up the draft, so the note says what holds in both cases: a sync keeps disk's copy and gives up the workspace's. - The mixed-line-ends note said each line is shown without a CR. Only a CR before a line feed is dropped, and the note says that. - The read tool's description scopes the CRLF and BOM wording to a line page, and says a character page returns the stored text as it is, BOM and CRs included. - The changelog says where a page's bound holds: where its own fields and the least it can show fit, which at 1,000 characters only a path of about 130 characters beside every note a read can carry, or about 700 with none, passes. It also gives the cut-line note, a file disk made binary, the deletion conflict, grep's CR wording, that a path the workspace didn't hold was observed before too, and the prompt-cache miss the changed tool descriptions cost once. It no longer mentions readBinary, which this branch now leaves as main has it. Tests: - workspace-read-text: the first-line cut's note names the rest, and one character more passes the limit, on a page with a note and on a one-line file's page with none; a one-line file whose whole page fits is shown whole, where a cut one character shorter wouldn't fit; a page whose last line is cut before a surrogate pair names the offset its marker does; a page stopped at a cut line by its limit and by the result limit names the rest, and a page that ends the file has no note; a call that carries no resultMaxChars gets pages within 24,000; each of the emoji loop's four limits checks the page's bound; the mixed note's wording. - workspace-file-tools: a read of a draft disk deleted gives the deletion's note, and so does a conflict recorded as both-changed before disk deleted the file; a sync of the path then removes the draft, and a forced materialize writes it back; a draft whose disk copy turned binary gets the note that a sync keeps disk's copy and gives up the draft, and the sync does that; a read of a file disk made binary answers the disk-only text; readBinary serves a held binary file that disk changed as the workspace holds it (in place of the test that pinned the settle). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e, not in showing it The comment above grep's search and the changelog said grep leaves out the CR before a line feed for matching as well as display "as ripgrep's --crlf mode does". ripgrep 14.1.0's --crlf lets `foo$` find `foo\r\n`, but it prints the line with its CR and refuses a `\r` in a pattern, so only the matching is ripgrep's. Both now say so. The changelog also says "no note" where it said "no word", and says what the framework did with a page past the limit: it kept only the start, cut mid-JSON, and wrote the whole result to a workspace file where the agent has one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t finish When the selected branch keeps changing while a read's pass gathers, the pass decides nothing: it leaves no report for the path, only an incomplete reason, "the selected branch kept changing while the scan ran". The read then showed the stored copy with no note, or, for a path the workspace doesn't hold, a plain "File not found", so the caller couldn't tell that its disk check hadn't finished. The read now takes such a pass as a copy disk couldn't be checked, with the pass's reason: the note says the read shows the workspace's copy and why, and a path the workspace doesn't hold answers that disk could not be checked. Tests, in test/workspace-three-way.test.ts beside the listing's: a read of a held file, and one of a file only disk has, each under a branch that switches at every disk read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding R1-3 on anima-research#465: on the page that ends the file, the note was null even when the page's last line was cut, so a page whose toLine equals totalLines read as complete while its last line held a cut marker. A program that checks toLine < totalLines took the file as read whole. The changelog said a cut last line's note names its rest, with no exception. That page's note now names the rest: "Line N is cut: read its rest with offsetChars=K." The first-line search measures the page with its note, so the clause stays within the result limit, and the overhead estimate already allows the longer note that holds the same clause. A page that ends the file on a line shown whole still carries no note of its own. Tests, in test/workspace-read-text.test.ts: a one-line file of 1,500 '"' read at a limit of 1,000, and a four-line file whose last line is 3,000 characters read at 24,000, each now naming the cut line's rest; and a page that ends the file on a short line after a cut one, with no note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and the changelog says how a script's line pages read Review finding R1-2 on anima-research#465: a read whose call carries no resultMaxChars was held to 24,000 characters. The framework sets resultMaxChars on a model's, a script's and the operator's puppet's call, so only a call made in process carries none, unless its caller sets one, and the framework stores no such result. The page's budget fits it to what its receiver keeps whole; a caller that states none takes the data itself. connectome-host's workspace file viewer and its headless file request read with limit 5000, no resultMaxChars, a byte cap of their own and no way to page, and a 3,780-line file came back only as far as line 543. Such a call now pages by its line limit alone, with every line whole, as before pages had a budget: linePage and characterPage take no maxChars for it. CRs and a BOM are left out of its line pages as of any other. ToolCall.resultMaxChars' doc says so for the workspace's read, while its edit and edit_many answers keep the 24,000 default. The changelog names in-process callers. Review finding R1-4: lines are cut at 2,000 characters whatever the call's limit, so a script's 5,000,000-character call gets cut lines too, and the changelog's sentence on scripts read as if their reads were unchanged. Every call that states a limit keeps one rule: its line pages are for reading, and a character page gives the stored text exactly, which each cut line's marker names. The changelog now says so for scripts. Tests, in test/workspace-read-text.test.ts: a call that carries no resultMaxChars reads 2,500 of 3,000 lines of a CRLF file, with its 3,000-character line whole and only the limit's and the CRLF notes, its default page is 2,000 lines, and its character page gives 100,000 characters whole. The tests of the cut line, which leaned on the old default, now carry a model's 24,000. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he model reads The model read each of these four results as one line of JSON: a page's numbered lines escaped inside a `content` string, grep's matches inside nested objects, a receipt's changed region inside `changed`. Each now answers with its text as data (one text block), and the same fields it had beside it as `structured`, which a code_execution script reads as structuredContent (anima-research#262's rule; anima-research#698 sends only a string data as text, so it didn't reach these). The text (result-text.ts): a header of the result's own fields, one `key: value` to a line in their order (`path`, `totalLines`, `fromLine`, `toLine`, `note`; `totalChars`, `offsetChars`, `nextOffsetChars`, null at the end), then labelled sections, `--- label ---`, an empty one left out: - read: `--- content ---`, last, so nothing in a file can read as a section; - grep: a section for each version of a file it matched, labelled `FILE: VERSION (STATE)[; lineEndings: CRLF|mixed]`, a match as `N:text` and its context as `N-text`, `--` between one match's lines and the next's, then `--- skipped ---` (`FILE: REASON (N bytes, MIME)`) and `--- incomplete ---`; - edit: path, size, replacements, note, modeKept, then `--- changed ---`; - edit_many: edits, replacements, filesNotListed, `--- files ---` (one line a file: `PATH: size N, replacements N, edits 1, 2; modeKept: …. NOTE`), then `--- changed ---`. A value written outside content and changed that holds a line break (\n, \r, U+2028, U+2029), such as a path from disk, is written as a JSON string, U+2028/U+2029 escaped too, so it can't start a line that reads as the answer's own and gives back the exact path. The words are the results' own field names, which the model read before as JSON keys, so no tool description changes. The bound: a page and a receipt are held to the call's resultMaxChars as their text measures it, where they were measured as JSON. The workspace can't tell a script's call from a model's, so this holds for both: a model gets what it reads; a script's page is never shorter than before, unless its path or a note holds a line break, and its answer carries the fields beside the text, which the script's figure counts six bytes a character for, so they pass what it takes only for a page of more than half that figure (about 3.7 million characters at the default 128 MiB of script memory) holding control characters. jsonLength and jsonCharLength had no other user and go. The framework's spill notice sized its read-back page for JSON's six-character escapes; it now sizes it as the text measure does. anima-research#465's pending changelog gives two path lengths at which a page at 1,000 characters passes the limit, measured as JSON. Measured as text, they're about 690 with no notes, where it said about 680 (a probe on main and on this change found 682 and 690), and 101 to 137 with the three text notes and a conflict's, where it said 93 to 129. The probe measured one case in that range, 124 on main and 132 here: the text's framing is 8 characters shorter than the JSON's for any page whose path and notes hold nothing JSON escapes, so each end of the range moves by 8. Tests: workspace-result-text.test.ts pins each form, the line-break quoting, each bound as text (pages and receipts that would pass the limit as JSON), and, through the framework, that a model's read reaches its history and the wire as the text and a script's call gets the text and the fields. The workspace and spill tests read fields from `structured`, measure pages and receipts by their text, and the receipt test that pinned the JSON measure is turned round; the mode-kept test reads edit's and edit_many's text too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The line feed between each of a region's lines is part of what the receipt's text holds, and shownRegions counts it. Removing that count failed no test: the receipt tests' regions had few lines, so the reserve kept for the closing notes absorbed the shortfall. Three hundred one-character lines make the feeds a third of the region, and every cap from 2,000 to 3,500 is tried. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- grep's matched and context lines go through shownValue too: read and grep split lines at LF alone, so a line can keep a CR, U+2028 or U+2029, and in grep's text more sections follow it. - grep's context runs as grep writes them: matches whose windows meet or overlap are one run, each line once, a match marked `:` wherever it shows, and `--` only across lines left out. The test that pinned a `--` between touching windows is turned round. - shownValue quotes Unicode's mandatory line breaks, LF, VT, FF, CR, NEL, U+2028 and U+2029, escaping NEL after JSON as it does U+2028 and U+2029. - read-page.ts: contentCost's arm for no content couldn't run, so lengthAt adds the content's label and characters; the doc on a script's answer says its fields include the page's content, the limit counts the text alone, and when the two together can pass the script's limit, half of it, about 3.7 million characters at the default script memory; the moves-on comment says a pair is taken whole with room for one unit. - edit-text.ts: editReceipt's doc says what the base already did, that the line naming what a receipt leaves out comes after its counts and notes even where they leave it too little room. - Changelog: the script threshold scales with script memory; a page is never shorter unless its path or a note holds a line break; the spill advice is longer below a cap of about 12,300, not 2,300; connectome-host needs 0.11.0 or later, before which its viewers show such a file as empty; the line-break set and grep's quoted lines. - Tests: grep's quoted lines and its runs (overlap, gap, touching); grepText with line breaks in its pattern, note, skipped reasons and incomplete places, a label without a state and a skip's facts as it has them; a receipt's note and kept mode with line breaks; VT, FF and NEL in shownValue's unit test; the spill read-back page uses its room, so a halved limitChars fails; stale comments on JSON budgets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Taking shownValue off a match written without context failed no test: the quoting test asked for context, so its matches went through the context path. The same file grepped without context now shows both matched lines quoted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- grep's touching-window assertions compare the whole text, so a `--` before a section's first run fails them. - A new test greps three files with context: each file's run starts afresh, and a context-only line holding a lone CR is quoted. - shownValue's test asserts the exact quoted form of a value holding NEL, two U+2028s, a U+2029 and a VT, pinning each escape and its four digits. - editReceipt's doc says a receipt past its limit reaches its caller as any such result does; read-page.ts loses a double blank line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts, not the clock
Three tests in "a round whose run or wait call watches a script that can
change its routing" took their order from wall-clock margins, and a loaded
host could take the margin away:
- "a call made while a run watches, that waits for room in the host until
the run has yielded, counts toward the later wait's round" needed four:
its 16 slow calls timing out in the script 2 s after they were made, so
the skip_reply came then; the run yielding 3 s after
its child was spawned; the skip_reply's own 2 s outlasting the wait's
start; and the slow calls released 20 ms after the wait's tool:started. A
child started 1.5 s late fails it with { waiting: 0, hostCalls: 16 } at
the wait, as one of our full-suite runs did on a loaded host.
- "a send counts toward its round once the host has it, though its tool is
still running when the run yields" needed the send to reach its tool
within the run's 3 s yield; a child started 3.5 s late fails it.
- "a skip_reply a script makes while a later round's wait watches it keeps
that round's prose private" released its slow call 20 ms after the
wait's tool:started.
Three helpers in the describe give each test its order from events:
- yieldOnEvent makes a run's own yield come due once a condition holds.
For the one call to the run's wait, setTimeout passes through to the real
one and records the callback and handle of the timer the yield sets;
when the condition holds, the helper clears that timer and runs its
callback, so ScriptRun.wait and the manager's observe return through
their own code, as at a yield. Exactly one timer of the yield's length
must be set, or it throws, so the test fails loudly rather than falling
back to the clock if the runner ever yields another way. setTimeout is
restored in a finally, after that one synchronous call. Left to come
due, the runner's own timer still ends a run the code gets wrong, so
the test fails rather than hangs; the calls ask a 60 s yield, which no
event waits on, so the clock never decides an order. The helpers have no
bounds of their own, since a 15 s one would itself order the run on a
slow enough host.
- timeOutOnEvent does the same for the timeouts of a run's calls to one
tool: ScriptRun.call sets a call's timer before it first awaits, so the
wrap records the one timer of toolCallTimeoutMs each such call sets
(exactly one, or it throws), and firing it runs the runner's own
timeout. It fires exactly the count it's given, or throws: fewer would
leave the rest to the 59 s clock, which still comes before the 60 s
yield, so the test would pass on the clock's order.
- stepWatches wraps this framework's AgentScripts.observe, on the
instance, as the stop test wraps its stop: the nth call that watches a
script runs its step's onWatch once it watches (the manager sets the
watch just before it observes), and yields by its step's yieldWhen.
The three tests:
- The first test keeps its mechanism: a call waits for room in the host
only once the script's earlier calls have settled in it while their
tools still hold the host's places, since the script itself sends at most
16 calls at once. Its toolCallTimeoutMs is now 59 s, a length of its
own beside the 60 s yield, so each capture can take only its own timer,
and its 16 slow calls' timeouts come due once all 16 have reached their
tool. The run yields once the skip_reply waits for room (waiting 1,
hostCalls 16). The wait's onWatch records the counts, and that the
skip_reply hadn't reached its tool and no slow tool had returned, then
releases the slow calls; the wait returns once the script ends. The
skip_reply's own 59 s can't come due.
- The send test's run yields once the send has reached its tool.
- The skip_reply test's slow call goes once the wait watches, and it now
asserts that the skip_reply hadn't reached its tool when the wait began
to watch. Its run's 100 ms yield orders nothing: the slow call is held
until the wait watches.
- The release bounds these three tests had (15 s and 30 s timers letting
the held calls go) are gone; done() releases them once the turn is over.
At main 9a66f0a, with each script's child started late through
ScriptsConfig.execPath (a wrapper that sleeps, then execs node): the
describe passed 65 of 65 with no delay; 1.5 s late, only the first test
failed, with waiting 0; 3.5 s late, the send test failed too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A value the text shows as it is could itself be a JSON string: a line of a file reading "a\nb" (a quote, a, a backslash, n, b, a quote) read exactly as the quoted form of a, a line break and b. shownValue now quotes a value that starts with a quote as well as one that holds a line break, so a value shown as it is never starts with one, and one that does is always the quoted form, which gives back exactly what the value holds. The changelog says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-round-trip-guard fix(code-execution): an MCPL peer's blocks too deep for JSON reach a script as an unconvertible result, not a failed dispatch
…pu-bounds test(workspace): replace six absolute CPU-time bounds with deterministic checks
…oop, and check the send test's yield Answers the automated review of anima-research#779: - timeOutOnEvent's per-call guard threw from the wrapped ScriptRun.call, which ScriptRun calls as `void this.call(...)` inside its loop over a chunk's frames, so a throw left that loop and the frames after it were never handled. The guard now rejects the call's own promise, which the runner leaves unawaited: the test fails on the rejection, and the loop goes on. - The setTimeout capture both helpers wrote is now one helper, captureTimers(ms, fn), with fire() for clearing a captured timer and running its callback, so the pass-through, the match on the delay and the restore in a finally live in one place. - stepWatches' steps can take onReturn, which gets what the call returned. The send test's "the send was still running when the run yielded" check read the log after the turn, while the send was held until done(), so it couldn't fail; it now asserts that the run returned with its script still running. - The checks taken at the wait's watch (skipReplyReached, slowReturned, reachedBeforeWait) say in a comment that they're premise checks: they fail only if the slow tool's hold failed. - stepWatches' comment says when each kind of call starts watching: a run's watch is set when its script is made, a wait's just before it observes, and a stop observes only after its stop. yieldOnEvent's guard says the run had ended before its wait when it had, rather than blaming the yield. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a provider hold releases, the scheduler merges the wakes it kept beside the turn's retry into one start with it. That start can park behind an auxiliary call or be held again before it takes its turn token, and abortInference() or a quiesce abandon then dropped it whole, wakes included, so their messages went unanswered. A start that carries a turn on now records the batch it was merged from (MERGED_BATCH, a module-private symbol), each request as it was, until it takes its turn token: - a caller's stop or an abandon that drops it puts the batch's wakes back in the queue, where they run as a turn of their own (after the stop, or at resume); stop() and a disposal put nothing back. This reaches a budget restart's merged start too, whose abandon dropped its wakes; - a scheduler pass that takes it back puts the batch in its place, before its wait and quiesce checks, so each request is judged as itself and the host's shouldInfer decides on every wake; - under a quiesce the retry starts alone and the wakes park until resume, at the pass and at the auxiliary park's re-check, which sends a merged start back as its batch; - a hold's dedup keeps a batch on the request it keeps, and a withdrawal of a coalesced subject's wakes reaches batches. Also: a failed start's catch closes its check window before the error policy's retry starts, so a stop made once that retry is held ends the held turn rather than being taken by a window nothing collects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eError, and any other fault fails the dispatch anima-research#773 guarded the JSON round trip that gives an MCPL peer's structured blocks RFC-005's treatment at any depth on a script's call, so that blocks too deep for JSON.stringify reach the script as a result it can't convert, not as a failed dispatch. Its catch took anything the round trip threw. The blocks come from the wire as JSON, which has no BigInt and no cycles, and, by our reading, the treatment throws no RangeError of its own. So the round trip can legitimately fail in two ways, both RangeErrors (by run, the overflow is one on Node 24.22.1 and Bun 1.4.4): the stack overflow of a result too deep, and a result past the runtime's longest string. Both are results the script can't receive. A fault anywhere else, the treatment's included, was reported to the script as a result that "can't be converted to JSON", with the trace and the call's terminal reading the peer's success. Now the catch takes a RangeError only, and rethrows anything else, so such a fault fails the dispatch, as any fault did before anima-research#773. Either way the blocks don't reach the script, and the thrown error's own message is kept. Tests: a TypeError from the treatment (a getter that throws when a nested block's type is read) and one from JSON.stringify (a BigInt in a peer's block) each fail the dispatch, marked as one, traced tool:failed with the fault's own message, which the script's failure carries too. Wire JSON has neither, so they stand in for a fault. The depth test pins the host's log line, which unconvertibleScriptResult writes as the script's own failure text. Raised by the automated review of anima-research#780, anima-research#773's 0.26.x pick. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eaches a script as unconvertible
The round trip's catch takes any RangeError, the stack overflow and a
result past the runtime's longest string alike, and its comment names
both. Only the overflow had a test, so narrowing the check to the
overflow's message would have passed. A result past the longest string
can't be built in a test, so a toJSON that JSON.stringify calls and
that throws V8's RangeError ("Invalid string length") stands in for it.
The script gets that error's message as a result it can't convert, and
the trace reads the peer's success, with no failed dispatch marked.
Raised by the automated review of this pull request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…space-text feat(workspace)!: read, grep, edit and edit_many answer as the text the model reads
…y-alive fix(scheduler): a turn a provider hold keeps for its retry is alive
…e-execution-test-order test(code-execution): order the routing tests' runs and waits by events, not the clock
…-round-trip-rangeerror fix(code-execution): the script lane's round trip catches only a RangeError, and any other fault fails the dispatch
main gained anima-research#726 (F427, deferred writes found later), anima-research#723 (a write heard mid-turn starts no turn of its own) and more since this branch's merge of 5143b30. Two conflicts, both in the tool boundary's comments in src/framework.ts: - The flush's account of what it injects: this change's "One the render gives null goes in as stored", in place of main's left-out notice, with anima-research#726's account of a write that threw after it was stored: looked up by its id and carried where it landed, and the known limit, one neither read back nor looked up, carried at the boundary that finds it. - After the consumed mark: main's paragraph from anima-research#723 on a message the boundary stored and left out of the injection, which the notice told the agent of. This change leaves no message out of the injection (a null goes in as stored, and a tune-out message is skipped before the render, as before), so the paragraph has nothing to describe and goes. The null branch's comment still names a write found at an earlier flush as the exception, which is anima-research#726's known limit. No code conflicted.
…ts it released Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er-null-as-stored fix(injection): a message the render gives null goes in as stored (anima-research#720) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
….26.2 chore(changelog): add 0.26.2's section to main and remove the fragments it released
…qualified channels meet the registry, tune-out and the reply anchor What main brings since e4d1e46, the sixth catch-up's base: 0.25.0 and 0.26.0, and anima-research#709, anima-research#710, anima-research#719, anima-research#722, anima-research#724, anima-research#728, anima-research#727, anima-research#743, anima-research#744, anima-research#755, anima-research#759, anima-research#761, anima-research#763, anima-research#767, anima-research#707, anima-research#740, anima-research#760, anima-research#726, anima-research#371, anima-research#452, anima-research#768, anima-research#465, anima-research#770, anima-research#771, anima-research#723, anima-research#777, anima-research#776, anima-research#773, anima-research#775, anima-research#778, anima-research#774, anima-research#779 and anima-research#781. Most of the conflicts are where anima-research#710 met the branch: in main a channel the framework acts on is a ChannelRef, { serverId, channelId }, since two MCPL servers can register one id. Conflicts in 14 files (61 hunks), resolved by keeping both sides' intent; where the two had different designs for one thing, the branch's stays, moved onto main's types: - src/framework.ts: main's conversationAgentHomes map stays gone. A fork's home is the registry record's homeChannel, which homeOf reads wherever main read the map (isForkBound, mayTakeGateRoute, canCloseChannels, holdsChannelTraffic, agentForPrimary, the drafts resend, the channel tools' guard, the registry's homeChannelResolver, stop()'s unbinding, releaseAgentRuntime's). The record still keeps a bare id, read as main reads a wake that names its channel without its server (channelNamed), or, where it names no one registered channel, as a home on no server ({ serverId: '' }), which a path reading the pair finds unregistered and one reading the name finds no channel or two for. main's dropContinuations goes into releaseAgentRuntime, so every way a live non-ephemeral agent ends drops a continuation of its turn, and beside the registry's end of an ephemeral run. Arrival carries main's channel ref and the branch's addressed; the addressed-elsewhere notice names channels with main's channelShown and keys its once-per-channel set by destinationKey. isAddressedMessage stays tag-only, with main's ambient marks. publishInputWithTurnLocus gives the locus with its server, and only to a call made inside the turn (callingTurnOf); outside it, a conversation fork's call takes its home, as its dispatched calls do, and anyone else's the registry's default. The tune_out handler keeps the branch's shape (serverId, scope, check, peek) and resolves through ChannelRegistry.resolveChannel, now resolveToolChannelEntry with the calling turn's own channels, check's peek included; its answers name the channel with nameShown. holdForProviderWait holds nothing for an agent a listener of the failure it reports ended (stopAgent), where main's report-before-hold (anima-research#774) meets the branch's stopAgent at a conversation agent's hold after its compile. handleSubconsciousTool gets main's own channels, then the branch's caller. - src/tune-out/coordinator.ts: main's shown() names beside the branch's reader helpers; deliver_summary, cancel_tuneout and speak_in_channel resolve as main's do, for the subconscious and a reader fork alike; cancel_tuneout keeps the branch's serverId and all-except fallback, and a name that picks no channel (one two servers share, or a turn's own channel ownChannelFor refuses) doesn't keep a call naming the scope from ending it; findByChannel goes, as on main. focusChannel() returns a ref. - src/mcpl/channel-registry.ts: markAccepted records no anchor (admission does), and handleIncoming's arrival count of removals, which main's guard there read, goes with it; noteDelivered takes the channel with its server and sets nothing for a channel its server no longer registers, read at admission where main's guard compared removal counts at arrival and acceptance (a message admitted after its server registers the channel again sets the anchor here). buildChannelContext is main's, per server, with the branch's sharedFallback; incomingFor reads the anchor itself, not the speech fallback. globalFallbackFor, sharedFallback, fallbackLocus and focusChannelResolver are on refs. - src/index.ts: main's DefaultErrorPolicy beside the branch's exports. - Tests: the branch's registry stubs take main's shapes; main's appendInTwoParts in batched-wake-locus's rig. Beyond the conflicts: the all-except announcement goes to the excepted channel on its server (publishForAgent refuses a shared bare id since anima-research#710), and tune_out's all-except answer names its channel with nameShown; the tune_out schema's serverId description says channelId takes serverId:channelId too; main's refusals for a tuned-out channel no name picks alone give the tune_out cancel by id and server, since this branch's tune_out takes a serverId; a stale comment naming conversationAgentHomes in channel-registry.ts names the registry; docs/dendrite.md says where a publish naming no channel goes outside the turn, and no longer lists ChannelRef as unbuilt. Test adaptations: main's tests that set conversationAgentHomes give the fork its home through the registry. main's tests written to the latest-inbound rule run under it (reply-server-routes: speech LEGACY; shared-channel-id's registry fixture: latestInboundLocus, and noteDelivered as admission records). Two reply-server-routes tests of an in-turn publish naming no channel now check the branch's rule, the turn's locus through its server. tune_out's shared-channel-id tests wait for the handler's answer, and its shared-id refusal offers serverId. all-except- unit's stub registry has nameShown; present-while-acting and deferred-window-only build routing state in main's shapes. main's marker and removed-server anchor tests record the anchor at admission (noteDelivered), where this branch records it. At this commit alone, the tests where a fork's home has to name its server fail (two servers' channels with one id, or a home the registry doesn't register at that moment); the pull request lists them. The next commit stores the home with its server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge before this keeps a conversation fork's home as the registry's
bare channel id, which can't say which server's channel it is. Where two
MCPL servers register one id, or none does at the moment of use, the fork
is left with a home on no server, and where one other server registers
the id once the home's own withdraws it, the bare id names that server's
channel. main's tests of these cases fail there.
AgentSpec.homeChannel is now { serverId, channelId }, and
conversationForkSpec takes it as homeChannel in place of channelId;
createConversationAgent gives it the router's ref. AgentRecord.homeChannel
is that or a string: a record written before this commit, read as before
(channelNamed, else a home on no server). Such a record is an ended one,
since a conversation fork ends at a restart. The registry snapshot stays
version 1, so code from before this commit can't read the homes it
writes: it finds an object where it expects a string. Going back past
this commit would need those records rewritten, or a version bump that
refuses them.
The derivation notice names the home as the agent is shown it, and a
fork's injections are scoped by its home's id. Tests give homes their
server; a registry test restores a snapshot whose fork kept a bare id,
and channelOfHome's test reads a home with its server as that channel.
This changes the branch's durable format, so it's its own commit, to be
taken, changed or replaced apart from the merge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-research#720 One conflict, in the tool boundary's drain loop (src/framework.ts): anima-research#720 dropped `parts` from what the drain hands back and from `heard`, with the left-out notice that used it; the branch's side keeps its two lines there, releaseLandedMail for each stored message and the skip of a message held from the residents (heldFromResidents), which anima-research#720's side has as the older tuneOut check. The resolution is the branch's lines without `parts`. Everything else merged without conflict. anima-research#720's new present-while-acting test of a re-pin to an addressed message the render gives null runs under the legacy speech rule (speech.locus 'latest-inbound'), as the file's other re-pin tests do on this branch, since the branch's default locus doesn't re-pin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Automated review
Recommendation: merge. The review found nothing of medium or high severity.
Reviewed commit 219bda5 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 the project. This merges main into feat/dendrite to catch the branch up. CONTRIBUTING.md accepts that way of updating a stale branch, and the branch has been caught up the same way six times before.
5 reasons with sources
The one design choice the PR makes, giving fork homes their server, follows main's ChannelRef design and sits in its own commit so the branch owner can decide it separately.
- Merging
maininto a stale branch is an accepted way to update it. "To update a stale branch, rebase ontomainor mergemainin; both are accepted." The PR targets the feature branchfeat/dendrite, notmain, so the branch only moves if its owner merges it. (CONTRIBUTING.md, How changes land) - It follows the branch's habit of recording each catch-up in one changelog fragment. The fragment already has entries for the merges at 0.22.1, 0.22.2 and 0.22.3. This PR appends entries for
a4a74b48and8ec1de5ain the same style, which meets the rule that a behavior-affecting change carries a changelog fragment. (changelog.d/dendrite-main-merge.changed.md) - Conflicts are settled by moving Dendrite's own designs onto
main's types, not by adding a second way to do the same thing. The branch already answeredmain'sconversationAgentHomesfrom the agent registry'shomeChannel. This merge keeps that single home, which the framework reads throughhomeOf, and makes tune_out, focus and the subconscious's tools resolve channels the waymain's channel tools already do. (changelog.d/dendrite-main-merge.changed.md (0.22.1 entry)) - The change to the registry's durable record and to
AgentSpec.homeChannelis in its own commit so the branch owner can decide it separately. Commitcba4d053turns the home into{ serverId, channelId }, in line withmain's #710ChannelRef. A record that still holds a bare id is read the waymainreads a wake that names its channel without its server. Because merges are true merge commits, this split keeps each commit coherent. (CONTRIBUTING.md, How changes land (keep individual commits coherent)) - Most of the diff is
main's own work coming in, not new scope added here. Changes to workspace, gate, CI, auto-continue and the other non-Dendrite areas aremain's merged PRs (0.25.0 to 0.26.2). The work that is new to this PR is the conflict resolution in framework.ts, the tune-out coordinator and the channel registry. The reported test gap fornoteDeliveredis a code-review matter and does not change whether the merge belongs. (PR file list / CHANGELOG.md)
Findings
No findings of medium or high severity.
1 more finding, less severe
- Low severity, test coverage,
test/all-except-e2e.test.ts:200-247. No test covers the noteDelivered change from main's removal-count guard. The merge swapped main's arrival/acceptance removal-count guard for anentryAtcheck at admission (channel-registry.ts noteDelivered). The description says this behaves differently for a message that arrived before its server was removed and is admitted after the server registers the channel again. None of the added or edited tests shown sets up that sequence, so neither the intended new behaviour nor main's old one is pinned. A later change could flip it without any test noticing.
1 task done
tel-0s
added a commit
that referenced
this pull request
Oct 24, 2026
…#793 nissa's #790 (Hilda's merge of main 8ec1de5 onto 83b66ca, Dendrite moved onto #710's server-qualified channels, and a fork's home stored as the channel with its server) and Antra's #793 (reader forks handed what is queued behind the resident's turn; a failed fork gives its messages back; a failed summary leaves the prose as the report; quiet cadence periods derive no fork) are taken as they stand, by fast-forward. This merge adds the 131 commits main gained since 8ec1de5. Two conflicts: the fork's disposal keeps Dendrite's one release path (releaseAgentRuntime, with main's continuation drop) over main's inline teardown, and a routing test's fake framework carries both the registry stub and main's new ephemeralRuns. main's three new conversationAgentHomes checks read the registry's home, and two of main's new tests are read onto this branch: the closed-channel note's fake framework carries a registry stub whose homes a test sets, and the fork-wake refusal's message comes from a privileged user, since admission here gates a fork's channel message as it gates every inbound (Dendrite section D), and a sleeping agent is roused by its heartbeat or a privileged user alone. membrane rises to ^0.7.1 and context-manager to ^0.20 with main. 7515 tests: all pass but the crash probe, which passes alone (a timeout under three suites at once). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
@tel-0s,
feat/dendriteis 240 commits behindmainat8ec1de5a. Among much else, by our reading of #710,mainhas since made every channel the framework acts on a channel with its server. This mergesmainintofeat/dendriteat83b66cae, the head of #546. It's a pull request into your branch, so the branch moves only if you merge it. You made each of the six catch-ups yourself, and this one is yours to take, change or redo your own way.It has three commits:
a4a74b48(a40c6601): the conflicts resolved, and Dendrite's code moved ontomain's channel types.cba4d053): it changes the registry's durable record. I've kept it in its own commit so you can decide it apart from the merge.8ec1de5a(219bda53): the 11 commitsmaingained aftera4a74b48, 0.26.2's changelog and fix(injection): a message the render gives null goes in as stored #720. It has one conflict, described below.Changes
What
mainbringsThere are 229 commits from
e4d1e468, the sixth catch-up's base, toa4a74b48. They include 0.25.0 and 0.26.0, and these merges since: #709, #710, #719, #722, #724, #728, #727, #743, #744, #755, #759, #761, #763, #767, #707, #740, #760, #726, #371, #452, #768, #465, #770, #771, #723, #777, #776, #773, #775, #778, #774, #779 and #781. The third commit adds #720 and #786,main's changelog for 0.26.2.Most of the conflicts are where #710 met Dendrite's code. In
main, a channel the framework acts on is aChannelRef,{ serverId, channelId }, because two MCPL servers can register the same id.mainalso names a shared channel to the agent by its server-qualified id,serverId:channelId, and takes that form back in every channel tool. Dendrite's code was written on bare channel ids.Conflicts
What this section says
maindoes is from reading it ata4a74b48, and at8ec1de5afor the tool boundary's drain.The first merge had 61 conflicting hunks in 14 files:
src/framework.ts(20),src/tune-out/coordinator.ts(9),src/mcpl/channel-registry.ts(4),src/index.ts(1) and 10 test files. Each was resolved by keeping both sides' intent. Where the two sides had different designs for one thing, I kept Dendrite's and moved it ontomain's types:A fork's home.
mainkept it in aconversationAgentHomesmap ofChannelRefs. Dendrite keeps it in the registry record'shomeChannel. The registry stays the one place, and a framework helper (homeOf) reads the record's home for every site that readmain's map. In the merge commit the record still holds a bare id, whichhomeOfreads asmainreads a wake that names its channel without its server (channelNamed): the one registered channel with that id, or none.The latest inbound message. Dendrite records it at admission (
noteDelivered);mainrecords it where the registry accepts a message, and added a guard against a removed server's message. The anchor stays at admission and now carries its server.noteDeliveredsets nothing for a channel its server doesn't register at that moment. That reads registration at admission, wheremain's guard compared the server's removal count at arrival and at acceptance. So a message that arrived before its server's removal and is admitted once the server registers the channel again sets the anchor here, as a message delivered on a live channel.main's guard would set nothing for it.incomingForreads the anchor itself rather than the speech fallback, which underspeech.locus: 'delivery'is the all-except hold's excepted channel while one is open, and none otherwise.tune_outandfocus. Dendrite's handler (with itsserverIdinput) stays, and it now resolves the channel the waymain's tools do. Inside the calling turn, an id of the turn's own channel means that channel. A server-qualified name works too, and a name two servers' channels answer to is refused with them as candidates. Its answers name the channel as the agent can name it back.check'speektakes the same names and refuses what they refuse, a channel no server registers now included, and shows that channel's lines on its server. Under the all-except scope,check'sallExcept.heldstill counts a channel no server registers now, whichpeekthen refuses; its lines are in the release's dump.The subconscious's tools.
handleSubconsciousTooltakesmain'sownchannels and then Dendrite'scaller.deliver_summary(from the subconscious or a reader fork),cancel_tuneoutandspeak_in_channelresolve their channel asmain's do.cancel_tuneoutkeeps Dendrite'sserverId(an exact pair, which it reads though the tool's schema doesn't list it, as at83b66cae) and the all-except fallback. A name that picks no channel is refused, as everywhere: one two servers' channels answer to, or one of the turn's own channels its server no longer registers. The exception is a call namingscope: 'all-except', which still ends that hold, as at83b66cae, unless its channel names a channel with a tune-out of its own.Continuations.
main's fix(turns): a turn stays alive while its continuation waits to start #722 drops a turn's continuation when a conversation fork is disposed. Dendrite releases every non-ephemeral agent that ends through one path,releaseAgentRuntime, so the drop is there. Every way a live one ends now takes it:stopAgent, an end that cascades from a parent's, and the fork's disposal. A fork whose agent is already gone when the idle sweep ends it has nothing to release, as onmain.The tool boundary's drain (the third commit). fix(injection): a message the render gives null goes in as stored #720 dropped
partsfrom what the drain hands back, with the left-out notice that used it. Dendrite's loop there keeps its two lines,releaseLandedMailfor each stored message and the skip of a message held from the residents (heldFromResidents), withoutparts.Other places where Dendrite's code met
main's:isAddressedMessagestays tag-only where both sides changed its calls, andmain's ambient marks stay.main'schannelShown, and keeps its once-per-channel set by server and id.channel_publishnaming no channel goes to the turn's locus with its server (publishInputWithTurnLocus). It now does so only for a call made inside the turn, asmainreads every channel name. A call made outside it, by a script left running after its turn or by a host'sexecuteToolCallnaming the agent, gets the registry's default, except a conversation fork's, which goes to its home, as its dispatched calls do. At83b66caeall of them took the locus, which for a fork is its home. AserverIdgiven with nochannelIdstill names the home's id on that server, by the registry's rule for a default, as83b66caenamed the locus's; a fork's dispatched call is refused that, andexecuteToolCallapplies no fork guard, onmainas here.main's order of a failure's report and its hold (fix(scheduler): a turn a provider hold keeps for its retry is alive #774) meetsstopAgent: a conversation agent's hold after its compile reports the turn'sinference:faileditself, and a listener of it can end the agent before the hold takes the turn's retry. That hold now holds nothing for an agent that has ended, as for a framework that's stopping, so no retry outlives the agent as a turn still alive. At the other failure sites the registry already holds the ended agent no longer live, so they hold nothing for it.focusChannel()returns the excepted channel with its server, and the all-except announcement goes to the excepted channel on its server.main'spublishForAgentrefuses a bare id two servers register, where at83b66caethe announcement posted on the first registered server.tune_outschema'sserverIddescription now also sayschannelIdtakesserverId:channelId.main's refusals for a tuned-out channel that no name picks alone saidtune_outcan't cancel it. That's true ofmain'stune_out, which takes noserverId. This branch's takes one, so they now give the cancel by id and server.The second commit: homes carry their server
The merge alone can't home a fork on one of two servers' channels with one id. Its record says
general, andgeneralthen names two channels, so the fork is left with a home on no server, and refuses to act. A home its server doesn't register at the moment of use has the same problem, and where one other server registers the id then, the bare id names that server's channel, so the fork would act there.main's tests of both cases fail at the merge commit (23 tests in 7 files, listed under the checks).So the second commit stores the home as
{ serverId, channelId }:AgentSpec.homeChannelis{ serverId, channelId }, andconversationForkSpectakeshomeChannelin place ofchannelId.AgentRecord.homeChannelis that or a string. A string is a record written before this commit, read as above. By our reading of the registry'srestore, such a record is always an ended one, since a conversation fork ends at a restart.This is a change to your durable format, so it's your call. I chose
main's shape so that a home is the same valuemainuses everywhere else. If you'd rather store it another way, the merge commit is the place to start.Not changed here
Some of Dendrite's own all-except and reader code still names or groups channels by bare id. That was already so before this merge, and only matters where one name picks two channels: where two servers register one id, or where one channel's id is another's server-qualified name:
enterAllExcept's refusals and the release notices name the excepted channel by id;cancelAllExceptgroups the held backlog by channel id, so two servers' channels with one id share one dump;check()'sallExcept.heldis keyed by id;serverId.mainnames such channels by server-qualified id and keys them by server and id. Separately,parkedAnchorsis keyed by the server and the event id joined with a colon, so two pairs can share a key where a colon sits in either part (servera:bwith eventc, and serverawith eventb:c). I've left these to you.Tests
Test changes
main's tests that setconversationAgentHomesnow give the fork its home through the registry (bindAsConversationFork, or a registeredconversationForkSpec).main's tests written to the latest-inbound rule run under it.reply-server-routessetsspeech: { locus: 'latest-inbound' }, andshared-channel-id's registry fixture setslatestInboundLocus: trueand records delivered messages withnoteDelivered, as admission does.main'sreply-server-routestests assumed a publish naming no channel inside a turn goes to the latest message's channel. Here it goes to the turn's locus, so they now check that, through the locus's server, and nowhere once that server withdraws it.tune_out's tests inshared-channel-idwait for the answer, which this branch's handler gives once its body settles, a refusal included. The shared-id refusal offersserverId, since thistune_outtakes one.main's shapes (aChannelReflocus,engagedbydestinationKey).shared-channel-id's test of a tuned-out channel that no name picks alone now expects the cancel by id and server.main's anchor tests, a marker's and a removed server's message, record the anchor withnoteDelivered, where this branch records it, so each fails without the rule it names.executeToolCallnaming a fork, outside its turn, posts in the fork's home through its server (reply-server-routes);check'speek,focusinside a turn, and the subconscious'scancel_tuneoutnaming the scope, with a name another server shares or a turn's own channel that's gone (all-except-e2e); a listener ofinference:failedthat stops the agent leaves nothing held, at a conversation agent's hold after its compile (whose log line says the agent ended), and at a resident's stream that throws where a host's hold would take it, which the registry's live check refuses first (held-turn-retry); howchannelOfHomereads a home, and atune_outcancel by id and server (shared-channel-id).Checks
At the head (
219bda53), on Node 24.22.1, each test file in its own process withnpm test's flags:With your dependency branches. With the published chronicle and context-manager, which lack
JsStore.viewandContextManager.derive, the suite skips the reader-fork and derived-agent tests, which are among the 54 skipped. So I also ran them on a build with chronicled553ce9(feat/dendrite-views-main) and context-managerfea7a940(feat/dendrite-fork-main) linked in, at a build of the head from before its last edits, which changed only comments, docs and the changelog fragment:dendrite-derivepasses 16 of 16, andtune-out-reader-speechandtune-out-reader-toolspass;tune-out-reader-forks-e2epasses 4 of 4.With those branches linked, that file's first test, "runs one subconscious cycle as forks", timed out in 3 of 8 runs at builds of this merge's head. It also timed out in 5 of 8 at
83b66cae, so I don't read it as this merge's. Its other three tests passed in every run.At the merge commit alone (
a40c6601), 23 tests in 7 files fail, each where a fork's home has to name its server:batched-wake-locus(1),channel-lifecycle-order(2),conversation-routing(3),reply-server-routes(4),route-failure-targeting(6),shared-channel-id(6),tune-out-e2e(1).The 23 tests
batched-wake-locus:channel-lifecycle-order:conversation-routing:reply-server-routes:route-failure-targeting:shared-channel-id:tune-out-e2e:The review probes from #546 (
nissa-seru/agent-frameworkreview/546-probesat353fd8b7) gave the same outcome and the same output lines, apart from temporary paths, at83b66caeand at the head as it stood before its last edits to comments, docs and the changelog fragment. For the head, two of their lines callgetBindingwithmain'sChannelRefwhere83b66caetakes a bare id. They cover boot redelivery, durability, held cancels, reopening, self-cancels and storage. They also cover held mail across a kill; registry writes failing during a result's delivery, its release, a stop, a run's end and a fork's idle reap; and a registry file whose mail isn't a list. With them randendrite-durability,dendrite-lifecycleandroute-failure-targeting. The probes' dependency lines differ:mainbrings context-manager 0.20.1 and membrane 0.7.1.Not verified
fea7a940(feat/dendrite-fork-main, at 0.19.0), where this merge'spackage.jsonasks for^0.20.0. I haven't run the merge with a context-manager that has both 0.20's changes and your fork branch'sderive.changelog.d/dendrite-main-merge.changed.mdgains this merge's entry.Written by Hilda-2681 (Claude Opus 5.5). Imogen-2490 (Claude Opus 5.5) read the merge and its fixes, and internal reviews read it in three rounds.
Description checked and posted by Lottie-2661 (Claude Opus 5.5).
🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared
nissa-seruaccount.