Skip to content

Talker mode, delegated jobs, and supervised tool calls in Matrix rooms - #218

Merged
TroyHernandez merged 44 commits into
mainfrom
multi-bot-rooms
Oct 8, 2026
Merged

TroyHernandez merged 44 commits into
mainfrom
multi-bot-rooms

Conversation

@TroyHernandez

@TroyHernandez TroyHernandez commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

38 commits; the full account is the 0.7.1.57 section of NEWS.md. cornelius has run this branch since early October (currently be3c2ac).

Talker mode and delegated jobs

  • A room's bot answers as a talker and hands work to a doer, a persistent per-session job worker with boundary checkpoints and a durable job ledger: a job is never re-run blindly, runs in the directory its record names, and its result is retried until posted. One writing job per checkout, through numbered lock generations. Idle workers are retired and jobs.max_workers bounds how many a bot keeps.
  • delegate(task, review = TRUE): a read-only reviewer reads the checkout under its lock against whole-checkout snapshots, with the shared and project instructions. The doer starts with the project's context.
  • A bot's rooms can hand work to each other; a hand-off to a moved room is refused, and a no-match says what was checked.
  • Jobs come to the CLI and chat() too.
  • thinking setting names the Anthropic thinking type; reasoning settings move to the doer with the model; llm.api floor 0.1.9.13 and the thinking-call workaround dropped. Subscription providers get a default talker model. Matrix /model now changes the model replies use.

Supervised tool calls (R/supervisor.R, R/exec-scan.R, R/job-monitor.R)

auto_approve_asks no longer approves everything. Every call in a room goes through rules in code (shell commands and R code read for credentials, writes outside the project, sudo, other machines, publishing, destructive git), then, with the setting on, a monitor: a read-only model in its own process with the project's instructions that approves, refuses with a reason the doer reads, or passes the call to a person, who answers by reaction. Matrix approvals accept only an operator or the room's one human. Settings under supervisor in the user's config only.

Read-only tools and cut results

  • git_status, git_diff, git_log no longer run configured programs; path escapes closed in the read-only tools; a git ref must resolve to commits.
  • read_handle gains op = "grep" and op = "lines"; the talker, reviewer and monitor get it; each Matrix room keeps its own cut results (.o_NNN).

Tests: full suite 5219/0 locally at be3c2ac; CI green on both runners at 6136393.

Found by CI, fixed here: on macOS /tmp and /var are links into /private, and the scanner compared a path's resolved form with the literal scratch roots, so a write to /tmp/x was flagged as outside the project (0385dcd, scan_new() in R/exec-scan.R). Three tests assumed the machine's shape: a six-level climb out of the temp directory, no global git filter config (the runners have git-lfs), and lock lookups by the raw tempfile() path. CI now prints the failing assertions from tinytest.Rout.fail and lets both runners finish.

Version: 8a76aec bumps to 0.7.1.57. #217 is at .57 too from a separate branch; whichever merges second gets re-bumped.

bot_reaction_verdict() accepted a reaction from any account except the
bot itself. Once a second bot shares a room, that bot could approve
this bot's tool calls.

Approvers are now the configured operators, or, with none configured,
the room's one human when it has exactly one. Anyone else's reaction is
skipped rather than read as a denial. Membership is read fresh for each
approval. A room with no possible approver gets a notice naming the fix
and the request is declined without waiting out the timeout. Bots not
listed under `bots` count as humans, so an unknown bot closes the room
to approvals.
A job is one piece of work a talker hands to a worker. Its record is a
directory under the state dir holding intent.json (written at creation),
dispatch.json (written just before hand-off), outcome.json (written
once, first writer wins), and cancel.json (a request the owner acts on).
Status is derived from which files exist, so no update rewrites a field
in place.

job_recover() follows viento's claim model: a job never dispatched is
retryable and stays queued; a job dispatched with no outcome is settled
indeterminate and never re-run, since the worker may have acted. Hop
limits are enforced at creation, and a child job inherits its parent's
limits without being able to raise them.

Nothing calls the ledger yet; the worker and talker wiring follow.
Each conversation session gets one callr worker that runs its jobs one
at a time. job_submit() queues a job and returns at once; job_pump()
advances the session's jobs with zero-timeout polls and returns events
(started, settled, restored) for the surface to present.

The worker persists across jobs so loaded packages, objects, and its
own history stay warm. At each job boundary it checkpoints its global
environment, in the run_r worker's corteza_workspace_v1 format, plus
its history and attached packages, writing the marker file last. A
replacement worker restores the last checkpoint and reports it.
Checkpoints are keyed per session and role.

Cancellation stops the worker and settles the job cancelled; a worker
that dies mid-job leaves the job indeterminate; a wall-clock limit
fails the job; a worker that cannot start fails the job without a
dispatch record. The workspace save and decode are factored out of the
run_r worker so both workers share one format, and the worker options
helper takes a config field so job workers read job_worker_options.
Subagent children deny every "ask" because they have no channel to the
user. Job workers now replace that callback with a bridge: the worker
writes approvals/<req>.request.json in its job directory and waits for
an answer file, closing the request as approved, denied, timeout, or
cancelled. job_pump() reports each new request once as an "approval"
event and returns immediately; the surface asks and calls job_answer().

An answer authorizes one request of one job. It is refused once the
request is closed or the job has ended, so an approval arriving after a
cancellation, an expiry, or a restart cannot resume work.

Workers also take the originating session's policy channel. They were
always "console", whose default tensor allows code exec that the
"matrix" column asks about, so a job delegated from a room would have
run with looser permissions than the room itself. subagent_turn_init()
gains an additive `channel` argument for this, defaulting to "console".
With `talker: {enabled: true}` in a bot config, each room session runs
on a fast model (Haiku 4.5 for anthropic, gpt-6-luna for openai, or
`talker$model`) with read-only tools plus delegate, job_status, and
job_cancel. The bot's configured model moves to the room's job worker.
The job tools are hidden from sessions not in talker mode.

bot_run_step() pumps every room's jobs after each poll and polls every
2 s while work is in flight. A finished job is posted to the room or
thread it came from and recorded in that session's history. An
approval request posts a prompt with seeded reactions; the answer is
read from a later poll's reactions, so no job holds the loop.
auto_approve_asks answers job requests as it does a turn's. On startup
the bot classifies its own unfinished jobs and tells each room.

Jobs now carry an owner (the bot's Matrix id, or "local"). Every bot on
a machine shares the ledger and two bots in one room share a session
key, so listing, dispatch, cancellation, answers, and recovery are all
scoped to the owner.
bot_apply_model_command() set session$model, but turn() dispatches on
session$model_map$cloud (.resolve_model()), which nothing touched. A
/model switch renamed the badge and every reply stayed on the old
model. The badge had the same split from the other side: session$model
started unset, so a session created from cfg$model was badged with the
provider's default model.

bot_session_model() and bot_set_session_model() now read and write
model_map$cloud, and the menu, /model, /status, the badge, and the
creation-time default all go through them. Talker sessions re-stamp
their default after switching to the talker model, so the badge does
not treat every talker room as switched.
A job whose role writes (the doer) now takes its checkout's lock at
dispatch and releases it on every ending: done, failed, cancelled,
wall-clock limit, worker death, or a worker that never started. A
second writer for the same checkout stays queued, its session gets one
"blocked" event naming the holder, and it starts once the lock is free.

The checkout is the git toplevel, so jobs in two subdirectories of one
repo exclude each other. Locks live in the shared state dir, so they
hold across bots on one machine. A lock is a directory created with
dir.create(), which fails if it exists, so taking it is atomic. It is
stale, and taken over, when its job has ended or vanished or its owning
process is gone; a stale lock is renamed aside and verified before
removal so two takers cannot both win.

Only jobs honor the lock. Direct edits from tmux, an editor, or a
non-talker corteza session do not take it.
With `talker: {enabled: true}` in the corteza config, the shared REPL
loop puts its session in talker mode and runs delegated jobs on the
same worker and ledger as the Matrix rooms. Neither input path can
print while it waits for a line, so jobs are pumped before each prompt:
a finished job's result is shown and added to the talker's history,
and an approval request is asked inline (only y/yes approves). /jobs
lists or shows jobs and /cancel stops one. Exiting, by /quit or EOF,
cancels open jobs so they end with a known outcome.

Local jobs are owned by their process (local:<host>:<pid>), since
several CLIs and chat()s can run at once. At start-up a talker REPL
settles jobs whose local process has exited: dispatched ones become
indeterminate, queued ones are cancelled, and those for this directory
are listed.

Sessions not in talker mode get none of this: no job key, no ledger
reads, no recovery.
Checkpoint directories were a digest of the session key and role only.
Every REPL uses the key "repl", so CLIs in different projects shared
saved R objects and history, and two bots in one room shared one
checkpoint.

A checkpoint identity now has four parts: owner, key, workspace, and
role. It names the directory and is recorded in checkpoint.json, and a
worker restores only a checkpoint whose recorded identity matches its
own. A REPL's checkpoint owner and key are its host and conversation id
rather than its pid, so resuming a conversation restores its workspace
and two conversations open at once never share one.
Two interleavings let the old lock admit concurrent writers. The lock
directory existed before holder.json was written, and a missing holder
read as stale, so a second process could take over a lock being
acquired. And release checked the holder and then deleted the
directory, so a release that ran late could delete a newer holder's
lock and let a third writer in.

A lock is now a sequence of generations. Acquiring writes holder.json
into a private temp directory and renames it to gen-<n+1>; a rename
onto an existing name fails, so exactly one of any number of racers
creates the next generation, and it exists with its holder already in
place. Taking over a stale lock is the same step, with nothing deleted
or moved aside. Release only marks the releaser's own generation, and
numbers are never reused, so a late release cannot free a newer
holder. Older generations are pruned; the newest is kept.

job_read_file() now reads the bytes before parsing. Handed a path that
vanished after an existence check, jsonlite::fromJSON() parsed the
path string as JSON and errored; a racing prune exposed it.

Tests replay both interleavings and race six processes for one free
lock, each held open until all have tried; exactly one wins.
Dispatch marked the job running, called the worker, and only then set
the session's current job. If the call threw, the job stayed running
and kept its checkout lock, and no later pump had a current job to
inspect, so it never ended.

A failure to write the dispatch record now fails the job (no worker
was given it). A failure in the worker call stops the worker and
settles the job indeterminate, since the worker may have received it.
Either way the checkout lock is released and the session can dispatch
again.
Shutdown returned early when no jobs were open, so leaving chat() or
the CLI after jobs had finished left the idle worker running until the
process ended. It now closes the worker on every exit, and
run_repl_loop() registers it with on.exit() so an exit by error or
interrupt also cancels open jobs and closes the worker.
Pruning frees old generation numbers. An acquirer that chose n, then
paused while others acquired and released n, took n+1, and pruned n,
could resume and recreate n, and report holding the lock while n+1 was
held.

Acquisition now commits in two steps: create n, then check no higher
generation exists. A recreated number is always below the highest,
since the generation whose creation pruned it is never pruned, so the
check catches it; the stray generation is removed and acquisition
looks again.

The new test replays that sequence. A process stress test cycling
acquire and release across prunes is added as a smoke test; timing
alone does not reach this race window, which a probe with the check
disabled confirmed.
The check added in 97b1351 listed the lock root to see whether a
created generation was still the highest. A listing taken while
another process prunes can come back empty, max(integer()) is -Inf,
and the check passed: a delayed acquirer recreated a pruned number and
held the lock beside the real holder. Rejecting the empty case would
not make a listing-based check sound, since a directory scan that
overlaps a prune can miss both newer generations and their removal.

Every race found in this lock came from a generation name becoming
creatable again. The lock now never deletes or renames anything in a
lock root, so the rename that creates generation n can succeed exactly
once, however long an acquirer pauses. job_lock_commit() and
job_lock_prune() are gone; a stale listing costs a failed rename and a
retry. Release searches newest first and stops at its own generation.

The cost is one small directory per writing job per checkout, kept
until an offline cleanup exists.

Tests replay the delayed acquirer against three later generations and
assert on the source that the lock functions have no removal path.
delegate(task, review = TRUE), or jobs$review in the config, follows a
doer's finished job with a review job in the same session. The reviewer
is a separate worker with read-only tools, no bash or run_r, no
provider web search, and file access confined to the checkout. It gets
the doer's task and report plus the git state recorded when the job
started (HEAD and, for a dirty tree, a `git stash create` snapshot that
touches nothing), so its diff shows only that job's work. It answers
with a verdict and findings; jobs$reviewer can put it on another
provider or model.

The checkout stays locked from the doer's start to the review's end.
The review job is created, and the lock handed to it with
job_lock_transfer(), before the doer's job is settled, so no queued
writer from this bot or another can get in between. Reviewers hold the
lock like writers do.

Two changes to the worker make that workable. A session now keeps one
worker per role, parking the idle one, so a review does not cost the
doer its loaded state. And dispatch passes over a queued job that is
waiting on a lock instead of stopping at it; otherwise a review would
wait behind the older writer it blocks.

The reviewer cannot run tests: that needs an isolated copy of the
checkout. A review does not start a revision; its report goes back to
the talker and the user.
Three ways out of a session confined with allowed_paths, all through
tools that policy treats as reads:

- git_run() built a shell command line with system2(), so a ref such
  as "HEAD; touch x" given to git_diff or git_log ran a command. This
  held for every session, confined or not. git now runs through
  processx with no shell, and a ref starting with "-" is refused, since
  git would read it as an option (--output writes a file, --no-index
  reads outside the repository).
- The git tools never checked their `path`, so they read any repository
  on the machine. They now validate it like the file tools, validate
  git_diff's file filter, and when the repository root is outside what
  the session may read, limit git to the directory.
- grep_files checked its directory but not the files its glob expanded
  to, so "../other/*.R" or a symlink read outside. Each file is now
  checked where it resolves. list_files applies the same check to what
  a recursive listing reaches through symlinked directories.

git_diff also passes --no-textconv beside --no-ext-diff.

The hall monitor and the job reviewer both rely on these tools for
their confinement. The new tests drive each tool through its real
handler with arguments chosen to get out, and fail against the
previous implementation.
…k was broken

The review baseline was HEAD or a stash commit, neither of which holds
untracked files, so a job's edit to a file that was already untracked
showed up as a whole new file, or not at all. Both ends of the review
are now snapshot commits built on a private copy of the index: tracked
and untracked files as they are, without touching the working tree, the
index, or any ref. Untracked files are left out above a size cap, and
the reviewer is told when they are.

A review queued when its owner died lost its lock, and another job could
change the checkout before the review ran. A review now checks the lock
history when it starts and is cancelled if any other job held the
checkout since the job it reviews.

The note for a failed lock hand-off no longer says the checkout stays
locked.
It counted how many of 150 attempts happened to get the lock and
required more than 20; measured runs gave 19 to 24. Each job now retries
until it holds the lock, so the count is fixed at 30.
The git tools and the job snapshot ran whatever the repository's config
named: an fsmonitor hook, clean filters, the post-index-change hook, and
a transport for a partial clone's lazy fetch. All of that is turned off
for every git call. Filter drivers and allowed protocols are listed from
the config and overridden by name; when a name cannot be overridden, git
is not run. Submodules are compared by commit, since looking inside one
runs git under its own config, and the snapshot no longer uses git add
for the same reason.

A file filter such as ':(top)b/y.R' reached outside a session confined
to a subdirectory, as did a ref of the form <rev>:<path>. Pathspecs are
now literal and such refs are refused.

The review snapshots could cover different files at each end when the
untracked size crossed the limit in between, which showed untouched
files as deleted or added. The end snapshot now follows the start file
by file. Untracked files were also sized from git's quoted listing, so
a name with a tab counted as 0 bytes; names are read NUL-separated.
Refusing <rev>:<path> left the same content reachable by other
spellings: a file's object id, an abbreviation of it, a tag on it, or
'^<id>'. With a session confined to a subdirectory, git_diff printed
files from the rest of the repository through any of them.

A ref is now expanded by git into the objects it names, and each must
be a commit or a tag of one. Diffs and logs between commits are limited
by their pathspec; a blob or a tree is not.
The Matrix bots run on anthropic_claude and openai_codex, which had no
entry, so turning talker mode on for one of them made every session it
opened fail with 'needs a model'.
Talker mode moved the configured model to the doer but left the
session's reasoning_effort and thinking budget behind, so they were
sent with the talker's fast model. Haiku rejects the effort parameter
(API 400), which failed every reply of a bot configured with one.

The settings now go to the doer with the model they were chosen for and
reach its child session. The talker runs on the provider's defaults
unless the talker config names its own, and does not fall back to the
config's. A worker on a different model than the configured one (a
reviewer's own, or jobs$model) does not inherit them.
The provider's own web search is on for a talker session although no
listed tool provides it. Not told, the model opened with 'I don't have
access to real-time scores' and then searched and answered.
Claude Sonnet 5.5 thinks by default and answers 400 to thinking type "disabled"; its setting for answering without thinking first is "between_tools". A talker on that model had no way to ask for it.

`thinking` in the corteza config, a talker block, jobs, or jobs$reviewer is sent as the request's thinking.type. Like reasoning effort it follows the model it was set for: a talker session's own goes to the doer, a worker on another model gets none, a fallback to another model drops it, and /model clears a session's.

Two prefix-matching traps, both covered by tests: `$thinking` on a list returns a thinking_budget_tokens entry, and `thinking = ` passed to llm.api::agent() is matched to its thinking_budget_tokens formal. Lists are read with [[, and the call names the budget so thinking reaches the request body.
llm.api 0.1.9.13 gives agent() a thinking formal. The test that probes agent()'s formals assumed the type always rides the dots, so it would fail once that version is installed. It now reads the type from the formal when there is one. The comment on .agent_call_args() says when the helper can be removed.
llm.api 0.1.9.13 gives agent() a thinking formal, so the type matches it exactly and no longer needs the budget named beside it to stay out of thinking_budget_tokens. .agent_call_args() is removed and the call into agent() is back to a plain do.call.
A job worker's system prompt was its role sentence and the skills catalog. The task text is the only thing a talker hands over, so the shared instructions and the project's AGENTS.md or CLAUDE.md reached the doer only if the talker restated them in the task.

The doer's child process now builds the context a session opened in its directory gets, with its role ahead of it. It is built in the worker for its own directory and not copied from the talker, whose prompt also carries its surface. A reviewer keeps its own prompt (spec$project_context).
One bot serves many rooms, each with its own session, directory, and doer. A talker could only delegate to its own room's doer; work that belonged to another room's project had to be relayed by hand, or done by a doer that held the wrong checkout's lock.

delegate takes a room: its name, id, or project directory. The job is submitted to that room's session, so it runs on that room's doer, in its directory, under its lock, with approvals asked there, and its record says who asked (origin$from).

The room that runs it is told when the job is accepted, by a post there and an entry in its talker's history, so neither the people in that room nor its talker find the doer busy with work they never heard of. The result is posted in both rooms and put in both talkers' histories. Either room can see the job in job_status and cancel it. After a restart, an interrupted hand-off is reported to both rooms.

Only an operator's request is handed over, and a refusal is decided before the room list is read. The hand-off is built per turn in bot_poll() and removed after it, so it never holds a client or sender past the turn.
Two defects in the cross-room hand-off, both reproduced before the fix.

A room was matched and described by the directory its topic names now, while the job ran where its session works, and a session takes its directory when it starts. After a topic edit the hand-off queued the job for the old project and reported the new one. The hand-off now refuses until the two agree. The same agreement is checked where it matters afterwards: a job queued for one directory fails without running if its session has moved, and a live worker is reused only while it is in the session's directory. Replacing a session (/clear, /model) moves its job state to the replacement, so the job in flight is still pumped and settled and its worker is not left unowned.

A send that failed was swallowed: the hand-off said the room had been told, and a result that could not be posted was lost. These posts are now kept in the state directory when they cannot go out and retried by the poll loop with a growing delay, then set aside after a day. The hand-off reports what happened to its notice and says the job is accepted either way, so the talker does not delegate it twice. Only the message is retried, never the job.
A reviewer judged a change against the task alone. It could not say the work broke a project rule, because it was never given the rules.

The reviewer's worker now starts with its own prompt, then the shared instructions file and the project's AGENTS.md or CLAUDE.md (spec$project_context = "instructions"). It does not get corteza's preamble, runtime guidance, the briefing, or workspace identity: those tell an agent how to work, and a reviewer only reads. load_context_bundle() gains instructions_only for this.

Its tools are unchanged: read-only, no network, confined to the checkout. The instructions are read by the worker process before the confinement is set, so the shared file outside the checkout still reaches it.
A session's worker was started by its first job and never closed while the bot ran. A bot has a session per room and per thread, each keeping a doer and, after a review, a reviewer, so the process count only grew.

A worker that is not running a job is closed after jobs$worker_idle_minutes (default 30, 0 to disable), and no more than jobs$max_workers (default 8) are kept, longest idle first. The bot's step does this after the pump, with the settings from the corteza config for the bot's own directory.

Nothing is lost by closing between jobs: the workspace and conversation were checkpointed when the last job ended, and the next job starts a process that restores them. A worker running a job is never closed, and a job waiting on an approval is a running job, so the limit bounds what is kept and not what runs.

A restore after a planned retirement is marked as such. It is reported in gentler words than a crash recovery, and not at all when there was no object to restore.
policy() resolves the paths a tool names in its arguments and does not
parse a shell command or a run_r body, so nothing in code could say
what bash or run_r was about to do.

exec_scan() reads the text. It returns flags, for a person to decide
(credentials, writes outside the checkout, elevated privileges, system
and process control, other machines, publishing, git commands that
throw work away, a download piped into an interpreter), and notes, for
whoever judges the call next (reads outside the project, installs into
the default library, scripts it did not read).

It follows bash -c, r -e, heredocs, command substitutions, system2()
and friends, and scripts inside the project. It reads text and is not a
sandbox: a path built at run time, or code for an interpreter it does
not parse, is not seen.
auto_approve_asks answered yes to every call policy asked about, in a
room's own turns and in its jobs, the credential-path rule's "ask"
included. Nothing looked at the call.

Every room session and every worker it starts now carries a gate that
turn() consults for each call that survived policy, "allow" included:

1. Rules in code (supervisor_route(), exec_scan()). What they catch
   goes to a person. No model is asked.
2. A monitor, where auto_approve_asks is set: a third worker process
   per session, owned by the bot's process like the doer and reviewer,
   with read-only tools, no network, the checkout as its only
   directory, and the shared and project instructions. It approves,
   refuses with a reason the worker reads, or passes the call on.
3. A person, by reaction in the room. Without auto_approve_asks a
   person is asked in place of the monitor, as before.

A worker's rules run in the worker; its request says who should answer.
The bot asks the monitor without waiting and collects the answer on a
later step. A monitor that dies, times out, or answers in another shape
has approved nothing, and the call goes to a person.

The monitor is retired when idle and counted against the worker limit.
A job's result says how its calls were answered. Settings are under
`supervisor` in the user's config only; a project config cannot set
them. bot_approval_cb() no longer has a branch that returns TRUE.
A hand-off to a room that does not exist answered with the bot's whole
room list. A bot in 60 rooms sent 60 lines, the tool result was cut at
40, and the talker could not tell a room that does not exist from one
in the part it was not shown.

The reply now says every room was checked and by what, offers the
nearest names instead of the list, and says when the project directory
exists but no room works in it, with how the work can still be done. A
list of several matches is cut with a count of the rest.
A tool result over the cap is cut to a 40-line preview and the rest
stored under a handle. The notice told the model to read it with
read_handle. The talker did not have that tool, and the tool could not
search: its widest read, print, was cut again on the way back. A talker
shown 40 of 60 rooms could not tell a room that does not exist from one
it was not shown.

read_handle gains op = "grep" (matching lines with their numbers, and
how many lines were searched) and op = "lines" (a range, 40 at a time,
with where to continue). Both return less than the cap. The talker gets
read_handle.

The notice now names a tool the session has: read_handle, or the handle
as a vector in run_r, or neither and says so. It tells the model to
search before saying what the output lacks, and counts the lines it
shows when long lines mean fewer than 40 fit.

A session can own a handle store. Cut results go there under .o_NNN and
only that session's read_handle reads them. Matrix room sessions have
one: a bot's rooms share a process, and its one store would have let a
room open what another room's tools returned. A session whose R runs in
a worker gets one too, since read_handle there asked the worker, which
never held a result cut in the parent.
Both are read-only roles whose grep and diff results can run past the
cap, and neither had a way to read the rest. read_handle reads only the
role's own cut results, from its own process's store. A read_handle call
with no handle, or several, now answers with what it needs instead of an
R error.
The supervisor test climbed six levels from the test project to reach
/etc, which leaves the temp directory only when that directory is at
most five deep. R CMD check nests one Rtmp inside another and a
developer's TMPDIR may sit deeper still; from there six levels end in
/tmp, which is scratch, and the write is routed to the monitor instead
of a person. The climb is now the root's own depth.
R CMD check prints the last 13 lines of a failed test run, which for
this suite is the warnings that trail the real failure; the .Rout.fail
has the assertions. And a failure on one runner cancelled the other,
so a platform-specific failure could not be told from a general one.
The test expected a repository that configures no filter or protocol
to yield nothing to turn off, and failed on the GitHub runners, where
git-lfs registers its filter in the global configuration: four entries
(clean, smudge, process, required) that git_programs_off() rightly
turns off too, but that are the machine's rather than the repository's.
The assertion now reads with the global and system configuration out
of the way.
On macOS /tmp and /var are links into /private, and scan_zone() sees a
path's real form, so a write to /tmp/x was outside every scratch root
and flagged as outside the project. The literal roots are now resolved
too, beside their lexical forms.
A lock is keyed by the checkout as job_checkout() resolves it. The
worker resolves its own, and the test looked the holder up by the raw
tempfile() path, which on macOS differs (/var is a link into /private).
# Conflicts:
#	CLAUDE.md
#	DESCRIPTION
#	NEWS.md
#	R/rooms.R
@TroyHernandez
TroyHernandez merged commit fe26437 into main Oct 8, 2026
2 checks passed
@TroyHernandez
TroyHernandez deleted the multi-bot-rooms branch October 8, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant