fix(core): artifact provenance, store-corruption guard, an opaque payload channel for run_task, and a runtime-free core - #35
Conversation
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 28 minutes Limit details: You’ve used the included review currently available. Your 68 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (38)
Comment |
extractPatternsFromSummary inferred engineering identifiers by regex over free-form agent prose, and got them wrong on real jobs: any hex-shaped word became the commit SHA, so a precondition a manager merely restated, and a CI run id pasted inside a URL, were both recorded as commits the job never made. A fabricated SHA also suppressed deriveNextStep's "Review changes and commit." The branch capture class excluded quotes but not markdown, so it kept a trailing backtick and comma. A SHA is now taken only from a GitHub commit URL, or from a hex run that stands alone, is introduced by a commit cue, and is not adjacent to an input marker like "expected" or "base". Branch names are stripped of markdown, and command-derived names now beat prose ones -- previously the commandsRun loop was dead code because prose always ran first. Artifacts.provenance records how each inferred identifier was obtained, so a prose-scraped guess is no longer presented to a supervisor as an established fact.
2a88103 to
c4b3625
Compare
Both JSON-backed stores answered any load failure with `[]` and one stderr line. That is a lie when the file exists but cannot be read, and a self-erasing one: SessionManager rehydrates nothing, then `persistActiveJobs` does a whole-map overwrite, so the first save after a failed load writes the truncated set over the only file that could have shown what was lost. Every in-flight job at restart was orphaned silently, and the evidence destroyed itself. The stores now distinguish "not there" (empty is a fact, unchanged) from "could not be read" (empty is a lie). An unreadable file is renamed aside as `<file>.corrupt-<timestamp>` before anything can overwrite it, and the degradation is reported through an `onDegraded` sink rather than only to stderr. If the preservation itself fails, the store refuses to save at all — not persisting is recoverable, shredding the only copy is not. A file that parses but is not an array counts as unreadable too: it is something other than this store's contents, and answering `[]` for it erases it exactly the way a syntax error did. The attachment store gets the identical treatment, and needs it more: it holds every session that has ever attached, so a silent empty load discards lineage rather than only work currently in flight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`task` and `context` reach the agent as ONE conversational message, so there is no way to say "this part is not addressed to you". A brief of the form "you are the manager; write the context to a file; then launch the worker" therefore reaches the manager AND the worker — the manager faithfully passes the whole brief onward, the worker reads the same manager instructions, concludes it is the manager, and launches another worker. Observed on two independent dispatches; every status surface above the worker looked healthy throughout. `run_task` gains an optional `payload`. The server materialises it to a file (mode 0600, under `~/.clawconnect/payloads/` by default) and the agent's message gains only the path plus two sentences saying the contents are opaque data to hand onward rather than instructions to follow. The bytes never enter the instruction stream, which is what makes this structural rather than a wording fix. Deliberately the generic shape: ClawConnect does not launch the downstream worker and must not know what will consume the payload. It never parses, interprets, templates, truncates, or echoes one, and no read tool returns the contents — `get_task` reports `payloadPath` so a supervisor can see a payload existed and where it went. Retention is TTL-based (24h), never terminal-based. The worker routinely outlives the job that launched it — that is what a delegated handoff is — so deleting on job completion would pull the file out from under a live reader. The sweep runs at startup and opportunistically on write, and can never fail a dispatch. Declared once in the shared capability surface, so both transports serve it; surface-parity covers that. This changes the run_task declaration, so toolsetVersion shifts and connectors need a refresh. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
runtime-modules.ts states the intent plainly: the seam exists "without teaching ClawConnect anything about any particular runtime… A host's own runtime bridge is one such module; ClawConnect neither ships it nor knows it exists." That is not what the code did. Core shipped LocalTmuxFleetAdapter — tmux plus a hardcoded `~/.claude-fleet/<handle>/meta.json` convention — with a FleetAdapter interface threaded through GatewayPool, a CLAUDE_FLEET_RUNTIME_ID constant, a `"fleet-transcript"` literal in the core ResultSource type, and BOTH entrypoints constructing the adapter by default. Core knew about exactly one runtime while claiming to know about none. The adapter moves to examples/local-tmux-runtime/ and reaches a deployment through CLAWCONNECT_AGENT_SESSION_RUNTIME_MODULES like any host module. It is plain JavaScript with no build step and no import from @clawconnect/core, because that is the honest demonstration: a runtime module needs nothing from ClawConnect but the registry object it is handed. Its tests come along and run in the default suite. Two judgement calls: `"fleet-transcript"` collapses into `"agent-session"` rather than being expressed neutrally. Its stronger trust gate is not lost — it was always enforced inside the adapter (the tmux pane must have ENDED, the transcript entry must date itself) and still is, inside the module. What core cannot do is VERIFY that claim, and a fixed enum restating an unverifiable claim is worse than not making it. A reader who wants to know what answered reads `agentSession.runtime` on the same snapshot, which names the actual runtime rather than a category. An attach/replace directive must now NAME its runtime. It defaulted to "claude-fleet", which is how a hardcoded runtime id survived in the layer whose whole claim is that it knows none — and with no built-in adapter the default would attach a session to something nobody registered. The neutral `<agent-session>` marker has always required it, so this makes the two agree. The neutral path is unchanged and its tests still pass; the recovery tests that drove core's rules through the adapter now drive them through a registered runtime, which is the only path there is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e silently `FilePayloadStore.write()` returned undefined on failure and logged to stderr, so `submitTask` dispatched the task with no payload note at all — indistinguishable from a task that never had a payload. The task text routinely names the file, so the agent was handed a brief referencing data it could not find, and the only trace was a line in a log nobody reads. The comment defended this as best-effort, which conflated two opposite cases. A SWEEP failure being invisible is correct and stays: nobody asked for it, nothing reads its result, and cleanup must never block a dispatch. A WRITE is load-bearing BY CONSTRUCTION — the caller explicitly passed a payload — so dropping it is the same defect this branch keeps fixing, a failed operation rendering as a state of the world. Failing is recoverable (the caller retries); silently degrading is not. `write` now returns `string` and throws. The `string | undefined` return type was itself the invitation, so there is no longer a "returned nothing" branch to carry on from. A job id that fails SAFE_ID_RE throws too: ids are minted internally, so that is an invariant violation in this process, and returning undefined hid a bug in id minting behind a merely-absent payload. A failed write also unlinks whatever reached disk, since the dispatch is being refused and a chmod that failed would otherwise leave a payload with looser permissions than one may keep. `submitTask` refuses through the same rejection path a "session busy" collision already used, so both transports surface it identically — run_task throws at the tool boundary and the capability layer renders an isError result. That path is now one implementation rather than two: `rejectSubmit`, which deliberately touches neither the session's latest-job pointer, nor its history, nor the attachment directive, nor persistActiveJobs. Nothing is dispatched and no job is left half-created. A payload passed with NO payload store configured is treated the same way, because it is the same silent degradation. That is reachable: createMcpServer does not default a payload directory. Tasks passing no payload are unaffected, including against a completely broken payload directory. No fallback, no flag, no opt-in degrade mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@coderabbitai review The earlier review attempt hit a rate limit and the branch has advanced since. Current head is Note for reviewers: |
|
|
Five commits. The first was already reviewed; the rest landed on top. Every
one of them is the same defect class in a different place — a failed
operation rendering as an ordinary state of the world — which is why they
travel together.
c4b3625run_taskartifact provenance — stop inventing commit SHAs out of agent prosece9607c57d3e72run_taskb0c65d5packages/coreb44ce7fImportant
The
run_taskdeclaration changed (change 3 adds apayloadargument),so
toolsetVersionshifts and every connector holding an approved snapshotneeds a Refresh on its action configuration. ChatGPT freezes the catalog
approved at setup — restarting the service is not enough, and a newly added
action arrives disabled by default. Confirm with
get_connection_info: aclient whose cached catalog disagrees with the server's
toolsetVersionisholding a stale snapshot rather than talking to a broken server.
1 —
run_taskartifact provenance (c4b3625)The defect
extractPatternsFromSummaryinfers engineering identity by regex over afree-form agent summary, and presents the guess as a fact. Three terminal job
records were observed carrying identifiers for work that never happened.
commitShacame from/\b([0-9a-f]{7,40})\b/— the first hex-shaped wordanywhere in the summary. That matches:
-is a word boundary, so\bdoes not exclude it0-9is a subset of the hex class, so any 7+ digit number is eligibleThis is not cosmetic.
deriveNextStepbranches onartifacts.commitSha, so afabricated SHA suppressed the correct next step ("Review changes and commit.")
on a job that had changed files and committed nothing.
branchNamecame back as`feature-x`,— the capture class excludedquotes but not backticks or sentence punctuation, so markdown rode along.
The fix
A SHA now requires a commit-ish claim. Accepted only from a GitHub commit
URL, or from a hex run that clears three checks: it stands alone (the
characters either side may not be
-,_,/, or alphanumeric, which alsorejects runs longer than 40); it is introduced by an adjacent commit cue
(
commit/committed/sha/HEAD, reachable across markdown and a linkingword); and it is not adjacent to a marker that makes it an input rather than
an output (
expected,base,from,was,previous,parent).The input-marker check is anchored to the hex run rather than scanning a fixed
character window, so an ordinary sentence — "the change was reviewed and
committed as `a1b2c3d4`" — still reads as a real commit even though it
contains "was".
branchNamestops capturing markdown, and trailing.,;:)]}is stripped.An inferred value now says it is inferred.
Artifacts.provenancerecordshow each identifier was obtained:
"command"means it was read off a command the agent actually ran (acheckout -bincommandsRun);"summary-text"means it was scraped fromprose, which is a guess about what the prose meant. Command evidence now wins
where both exist — previously prose was consulted first and the command branch
never got a chance. The field is optional, so job records persisted before it
existed deserialize unchanged; no migration, no shim.
Readers surface it. The MCP and CLI JSON paths pass
artifactswholesale,so provenance rides along. The one place these are rendered for a human —
packages/cli/src/output.ts— now marks a prose-derived value rather thanprinting it beside command-derived ones as equally established.
2 — A failed store read must not destroy the store (
ce9607c)The defect
JsonFileJobStore.load()andJsonFileAttachmentStore.load()returned[]onany read or parse error, logging one line to stderr. That is a lie when the
file exists but cannot be read, and a self-erasing one:
SessionManager.rehydrateFromStorerehydrates nothing, thenpersistActiveJobsdoes a whole-map overwrite — so the first save after afailed load writes the truncated set over the only file that could have shown
what was lost.
The store holds in-flight jobs for restart recovery, so the consequence was
that every in-flight job at restart was orphaned and silently unrecoverable,
and the only trace was a stderr line nobody reads. The attachment store has
the identical shape and is worse in one way: it persists every session that has
ever had an attachment, so a failed load discards lineage, not just active work.
The fix
Distinguish "not there" (empty is a fact — unchanged) from "could not be
read" (empty is a lie).
<file>.corrupt-<timestamp>beforeanything can overwrite it. The subsequent save is then harmless: it writes
a fresh file and the preserved copy is untouched.
persisting is recoverable; shredding the only copy is not.
something other than this store's contents, and answering
[]for it erasesit exactly the way a syntax error did.
Where it becomes visible. Nothing existing was suitable —
TelemetryEventis structurally scoped to the task-contract tools and has no shape for this —
so the store takes an
onDegradedsink,GatewayPoolcollects what itreports, and
get_connection_infoserves it asdegradedStores. That is thetool a supervisor already calls when something looks inconsistent, and its
description now says so. The field is omitted entirely when nothing
degraded, so its presence is the signal.
Both stores are kept identical. No conflict with how
SessionManagerusesthem surfaced: it only ever calls
load()once at construction andsave()with the current set, and neither contract changed.
3 — An opaque payload channel for
run_task(57d3e72)The defect, reproduced twice on real dispatches
taskandcontextare delivered to the agent as one conversationalmessage. There is no way to say "this part is not addressed to you."
A brief of the form "you are the manager; write the context to a file; then
launch the worker" therefore reaches the manager AND — because the manager
faithfully passes the whole brief onward — reaches the worker, which reads
the same manager instructions, concludes it is the manager, and launches
another worker. Observed on two independent dispatches: the worker's first
assistant message was "I'll write the prompt file, then launch...", followed
by a large file write and a shell launch. Manager-ness is contagious down the
chain, and every status surface above the worker looked healthy throughout.
The fix — content never enters the instruction stream
run_taskgains an optionalpayload. The server materialises it to a fileand tells the agent only the path. The bytes never appear in the
conversation, which is what makes this structural rather than a wording fix.
0600, under a dedicated directory (default~/.clawconnect/payloads/, overridable viapayloadDir/CLAWCONNECT_PAYLOAD_DIR,following how the job/attachment store directories are already configured).
Named by job id.
saying plainly that the contents are opaque, not addressed to the agent, and
not to be acted on. Two sentences — it is a delivery note, not instructions.
payloadPathis recorded on the job and returned byget_task, so asupervisor can see a payload existed and where it went. No read tool returns
the contents, including
get_task detail="prompt".routinely outlives the job that launched it — that is what a delegated
handoff is, and it is the case that motivated this — so deleting on job
completion would pull the file out from under a live reader. The sweep runs
at startup and opportunistically on write, is rate-limited, and can never
fail a dispatch.
Deliberately the generic shape: ClawConnect does not launch the downstream
worker and must not know what will consume the payload. It provides a side
channel and a path; who reads it, and how, is the caller's and the agent's
business.
payloadis optional, so every existing caller keeps a byte-identical message.It is declared once in the shared capability surface, so both transports serve
it —
test/surface-parity.test.tscovers that, and now also asserts theargument's presence and shape on both.
4 — Get the concrete runtime out of
packages/core(b0c65d5)Why
packages/core/src/runtime-modules.tsstates the design intent plainly: theseam exists "without teaching ClawConnect anything about any particular
runtime… A host's own runtime bridge is one such module; ClawConnect neither
ships it nor knows it exists."
That is not what the code did.
packages/coreshippedLocalTmuxFleetAdapter— shelling out to
tmux, hard-coding the~/.claude-fleet/<handle>/meta.jsonconvention — plus
CLAUDE_FLEET_RUNTIME_ID, aFleetAdapterinterfacethreaded through
GatewayPool, a"fleet-transcript"literal in the coreResultSourcetype, and a"claude-fleet"default for any attach directivethat named no runtime. Both entrypoints constructed the adapter by default.
So core knew about exactly one runtime while claiming to know about none.
Verified safe: no tmux sessions on the live deployment,
CLAWCONNECT_AGENT_SESSION_RUNTIME_MODULESunset, and the only attachmentrecords in the live store are August smoke tests already
status: "detached".What changed
The adapter moves to
examples/local-tmux-runtime/and reaches adeployment through
CLAWCONNECT_AGENT_SESSION_RUNTIME_MODULESlike any hostmodule. Core keeps only the neutral registry, the attachment model, and the
callback seam. Both entrypoints now register nothing by default — asserted
directly, since an entrypoint quietly constructing a runtime is precisely what
made the old claim false.
No shims, no flags, no runtime guards.
Judgement call 1 — the legacy transcript path collapses into
"agent-session"ResultSourceloses"fleet-transcript". That value existed because reading aClaude Code transcript off disk is a stronger provenance claim than an
arbitrary runtime's reply.
That gate is not lost — it was never in core. It lived inside the adapter
(the tmux pane must have ended, and the transcript entry must carry its own
timestamp) and it still lives inside the module, which is where the evidence
is. Core keeps every check it can actually make: the turn must be a
completed one, the answer must be datable, it must post-date the job it would
answer, and it must survive the compare-and-set.
What core cannot do is verify a runtime's evidentiary claim, and a fixed enum
restating an unverifiable claim is worse than not making it — it reads as
established where it is hearsay. A reader who wants to know what answered reads
agentSession.runtimeon the same snapshot, which names the actual runtimerather than a category. That is strictly more precise than the enum it replaces.
Judgement call 2 — where the adapter lives: an example, not a package or a deletion
Git history is a sufficient record of code, but not of the seam being
usable. The repo documents
CLAWCONNECT_AGENT_SESSION_RUNTIME_MODULESandshipped no worked example, and this repo's own rule is that a documented
integration the shipped binary cannot perform is the exact bug that mechanism
exists to fix. Deleting outright would have left that gap wider.
A package was the wrong weight: build config, exports and workspace
membership for something core explicitly says it does not ship. So it is an
example — and deliberately plain JavaScript with no build step and no
import from
@clawconnect/core. An operator points the env var straight atruntime.mjsand it loads. That is the honest demonstration: a runtime moduleneeds nothing from ClawConnect but the registry object it is handed.
Its tests came along (converted to drive
inspectthrough the seam) and livein
test/, becauseexamples/is not a workspace package and a test fileoutside every project is not picked up by the default
vp testrun — a checknobody runs is a check that does not exist.
One extra change this forced
An attach/replace directive must now name its runtime. It defaulted to
"claude-fleet", which is how a hardcoded runtime id survived in the layerwhose whole claim is that it knows none — and with no built-in adapter, that
default would attach a session to something nobody registered. The neutral
<agent-session>marker has always requiredruntime, so this makes thetwo agree rather than inventing a rule. An id nobody registered still reads
back as a normalized
unknown_runtimeresult, never an error.GatewayPooland the neutral runtime path are otherwise unchanged, and theirtests still pass. The recovery tests that drove core's rules through the
adapter now drive the same rules through a registered runtime, which is the
only path there is.
5 — A payload write failure must fail the dispatch (
b44ce7f)The defect
Change 3 above shipped
FilePayloadStore.write()returningundefinedonfailure and logging only to stderr.
submitTaskthen dispatched the task withno payload note at all — indistinguishable from a task that never had a
payload. The task text routinely names the file, so the agent was handed a
brief referencing data it could not find, and the only trace was a line in a
log nobody reads.
The comment defended this as best-effort. That conflated two opposite cases:
sweepwriteWhich makes it the same defect as the other four:
artifacts.tsstoppedpresenting a guess as a fact, the store guard stopped a failed read reading as
"empty", and this one still let a failed write read as "no payload was passed".
Failing is recoverable — the caller retries. Silently degrading is not.
The fix
writereturnsstringand throws. Thestring | undefinedreturn typewas itself the invitation; there is no longer a "returned nothing" branch to
carry on from. A job id failing
SAFE_ID_REthrows too — ids are mintedinternally, so that is an invariant violation in this process, and returning
undefinedhid a bug in id minting behind a merely-absent payload. A failedwrite also unlinks whatever reached disk: the dispatch is being refused, so the
file is unreferenced garbage, and a
chmodthat failed would otherwise leave apayload with looser permissions than one may keep.
submitTaskrefuses through the same rejection path a "session busy"collision already used, so both transports surface it identically —
run_taskthrows at the tool boundary and the capability layer renders an
isErrorresult. A caller can tell "your payload could not be stored" from "that agent
is busy" by the message, and the error states that nothing is running so a
retry is not a duplicate submit.
That path is now one implementation rather than two:
rejectSubmit, whichdeliberately touches neither the session's latest-job pointer, nor its history,
nor the attachment directive, nor
persistActiveJobs. Nothing is dispatchedand no job is left half-created.
One case the brief did not name, fixed for the same reason: a payload
passed with no payload store configured at all previously produced no file,
silently. That is reachable —
createMcpServerdeliberately does not default apayload directory — and it is the identical silent degradation, so it gets the
identical answer. The stale comment in
server.tspromising the old behaviouris corrected.
sweepis untouched. No fallback, no flag, no opt-in degrade mode.Verification
Lint is byte-identical to the pre-change baseline — 22 findings, of which 7
are the documented pre-existing
TS2322errors inapps/chatgpt/src/widget/state.test.ts(collectFollowUpWakes' Map/Setliterals) and 15 are pre-existing warnings. Nothing new was introduced and
nothing pre-existing was absorbed.
New coverage
packages/core/src/payload-channel.test.ts0600; its contents absent from the actual text handed to the gateway; the path and not-addressed-to-you note present; no payload ⇒ byte-identical message;payloadPathon the job and inget_task; contents returned by no read tool; TTL sweep removes an old file and keeps a fresh one; a sweep failure never fails a dispatch. Plus (change 5): an unwritable payload dir refuses the task naming the payload and the underlyingerrno, dispatches nothing, and leaves no half-created job or persisted state; a missing payload store refuses identically;run_taskthrows rather than returning a jobId; an id failing the filename guard throws; and the same broken dir still dispatches an ordinary no-payload taskpackages/core/src/store-health.test.tsget_connection_infoasdegradedStores, and is omitted when healthypackages/core/src/job-store.test.ts,attachment-store.test.tstest/example-local-tmux-runtime.test.tsinspectonly, reports liveness with no state while the pane is up, a dated completed turn once it is gone, path-traversal containment, and abort behaviourtest/surface-parity.test.tspayloadserved identically by both transports, and still optionalNot done, deliberately
apps/chatgpt/.job-store, inside the repoworking tree. Out of scope here — moving it needs a data migration on a live
deployment.
🤖 Generated with Claude Code