Repository navigation
Talker mode, delegated jobs, and supervised tool calls in Matrix rooms - #218
Merged
Merged
Conversation
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
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.
38 commits; the full account is the
0.7.1.57section of NEWS.md. cornelius has run this branch since early October (currentlybe3c2ac).Talker mode and delegated jobs
jobs.max_workersbounds 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.chat()too.thinkingsetting 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/modelnow changes the model replies use.Supervised tool calls (
R/supervisor.R,R/exec-scan.R,R/job-monitor.R)auto_approve_asksno 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 undersupervisorin the user's config only.Read-only tools and cut results
git_status,git_diff,git_logno longer run configured programs; path escapes closed in the read-only tools; a git ref must resolve to commits.read_handlegainsop = "grep"andop = "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 at6136393.Found by CI, fixed here: on macOS
/tmpand/varare links into/private, and the scanner compared a path's resolved form with the literal scratch roots, so a write to/tmp/xwas flagged as outside the project (0385dcd,scan_new()inR/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 rawtempfile()path. CI now prints the failing assertions fromtinytest.Rout.failand lets both runners finish.Version:
8a76aecbumps to 0.7.1.57. #217 is at .57 too from a separate branch; whichever merges second gets re-bumped.