Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
f7e14f6
feat(chronicle): RecordJournal — typed records that don't follow bran…
Oct 7, 2026
4f5151d
Merge #169 (fix/109-materialize-freshness, e667499) — the base shelf-…
Oct 7, 2026
db713ac
Merge feat/record-journal (f7e14f6) — shared base with shelves 351/354
Oct 7, 2026
2153e3d
fix(chronicle): a RecordJournal write that fails after reaching the s…
Oct 7, 2026
1a826c1
Merge feat/record-journal (2153e3d) — reconciliation fix
Oct 7, 2026
cdf604d
feat(workspace): truthful listings under a three-way rule (shelf-383)
Oct 7, 2026
776065f
test(workspace): the three-way rule, its faults, and the batch hook (…
Oct 7, 2026
a8ca487
docs(changelog): truthful workspace listings, their shape changes, an…
Oct 7, 2026
ddb90b0
fix(workspace): no materialize writes or unlinks outside the mount; t…
Oct 7, 2026
fecf12b
fix(workspace): review findings at ddb90b03 - branch-bound completion…
Oct 7, 2026
b9573d1
fix(workspace): barrier obligations outlive failed passes; mutations …
Oct 7, 2026
648aa73
fix(workspace): the barrier obligation holds at every entry point
Oct 7, 2026
9414997
fix(workspace): bind effects and listings where they happen; only a p…
Oct 7, 2026
7db0a74
fix(workspace): absence and barriers bound to the directories that pr…
Oct 7, 2026
b011b30
fix(workspace): one checked-at-use boundary, stated as it is; pending…
Oct 7, 2026
e3651ab
test(workspace): two race hooks match the path by name, not its lexic…
Oct 7, 2026
6aa31c2
fix(workspace): no write through other hard links; grep and path sync…
Oct 8, 2026
fd043ac
fix(workspace): a bare materialize takes up what the evidence says di…
Oct 10, 2026
3580ea9
docs(changelog): a bare materialize takes up only what disk still owes
Oct 10, 2026
634791b
fix(workspace): a push refused on a branch change leaves the pin and …
Oct 10, 2026
d6b1a85
fix(workspace): a path sync keeps a binary or oversize file disk agre…
Oct 10, 2026
3bc6aed
fix(workspace): a mount root that isn't the directory disk agreed wit…
Oct 10, 2026
21c0d8d
fix(workspace): #169's saved baselines are not evidence: they name no…
Oct 11, 2026
629aedf
fix(workspace): the rule leaves another branch's adoption pending, ca…
Oct 11, 2026
b6532dc
fix(workspace): a forced push reports a missing workspace copy; an in…
Oct 11, 2026
26fc219
fix(workspace): trust, identity and refusals checked where they're used
Oct 11, 2026
905e846
fix(workspace): a damaged branch intent is unverified, a path sync ke…
Oct 11, 2026
d0fac04
fix(workspace): the batch hook drops a stale result and skips a hook …
Oct 11, 2026
4fa06c5
test(workspace): the FIFO swap matches the path the read opens, also …
Oct 12, 2026
4310431
fix(chronicle): an append that throws inside the store blocks the jou…
Oct 12, 2026
aa771bf
fix(workspace): no decision is taken from disk-agreement evidence onc…
Oct 12, 2026
672ab84
fix(workspace): an absence is proven only while every directory the w…
Oct 12, 2026
b2de7e5
fix(framework): a batch result is dropped when a new round reuses its…
Oct 12, 2026
24daa28
fix(workspace): a pass, an effect and a directory sync look at the ro…
Oct 12, 2026
f0622f3
fix(workspace): a replaced root reads the same in every effect
Oct 12, 2026
cb51ef2
docs(workspace): the changelog says no more than the code does
Oct 12, 2026
9f4ebc8
fix(workspace): a push records nothing it observed under a replaced r…
Oct 12, 2026
a3b0ab6
docs(workspace): four changelog phrases narrowed to what the code does
Oct 12, 2026
515c097
fix(workspace): grep reads with the branch its pass decided on; listi…
Oct 12, 2026
53baeea
fix(workspace): a path sync's merged result says which branch its pas…
Oct 12, 2026
6f3d088
fix(workspace): each after-batch scan records its own outcome, and on…
Oct 12, 2026
d6f2cdf
fix(workspace): a mount stays running late until the newest late scan…
Oct 12, 2026
12678d0
fix(workspace): the module takes no work once stop has saved its pins…
Oct 12, 2026
8424581
fix(workspace): a materialize before start keeps its pin, and the hel…
Oct 12, 2026
2ae3554
test(workspace): the root-swap tests match the resolved root, so they…
Oct 12, 2026
45f638b
Merge main (#236, #241, #238, #240, #242, #237, #169, #221) into fix/…
Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion changelog.d/materialize-freshness-guard.fixed.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
- **`materialize` no longer silently overwrites disk files changed by another writer** (#109). Before writing, each file's disk hash is compared against what the workspace layer last materialized; a mismatch means a shell/tool edit happened since, and the path is refused and listed in the result (`skipped`, with the remedy) instead of silently reverted. A disk copy that exists but cannot be read (e.g. a write-only file) is refused the same way, with the filesystem error in the reason, rather than treated as absent. `force: true` remains the deliberate override. Refused paths stay owed to every later materialize (and count in `status.pendingChanges`) without holding back the sequence the branch guard reads. `sync` re-pins the baseline when it adopts a disk copy, so the agent's next edit to that file is not refused. Baselines and refused paths persist across restarts, and a first materialize that finds every file already in place still pins the branch. Files whose disk copy already matches the target bytes are left untouched (mtime preserved). The post-undo/redo `_config` restore keeps its overwrite semantics.
- **`materialize` no longer silently overwrites disk files changed by another writer** (#109). A file disk changed since it last agreed with the workspace is refused and listed in the result (`skipped`, with the remedy) instead of silently reverted. So is a disk copy that exists but can't be read (e.g. a write-only file), with the filesystem error in the reason, rather than treated as absent. `force: true` remains the deliberate override. A refused workspace edit stays owed: every later materialize takes it up again, and it counts in `status.pendingChanges`. A file the workspace hasn't changed, whose disk copy changed, isn't owed: a materialize naming it refuses it, and one without a path leaves it, for a `sync` of the path (take disk) or a `materialize` naming it with `force` (take the workspace). What disk last agreed with is kept as evidence that survives restarts and branch switches (see the truthful-listings entry), and a `sync` that takes a disk copy records that agreement, so the agent's next edit to that file isn't refused. A first materialize that finds every file already in place still pins the branch, and a file whose disk copy already matches the target bytes is left untouched (mtime preserved). The post-undo/redo `_config` restore keeps its overwrite semantics.
15 changes: 15 additions & 0 deletions changelog.d/tool-batch-hook.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
- **`Module.onToolBatchComplete(agentName)`**: called once an agent's tool batch completes,
when the round's last pending result arrives and before it is provided. It therefore runs
before anything that continues the turn: re-inference, stream resume or the end of the turn.
The framework awaits every module's hook in parallel, each bounded at 30 s and fail-open; a
hook that throws (before returning its promise or after) or times out is traced as
`module:batch_hook_failed` and the round goes on.
The agent's state is checked again afterwards, so a result whose turn was cancelled or
replaced during the hook is dropped (traced as `tool:result_dropped`). A hook still running
from an earlier batch, past its bound, is skipped rather than called again. The hook is
awaited in the framework's event loop, so a slow one holds every agent's event processing,
not only the batch's agent. Script-inner tool calls don't trigger it, nor does a batch whose
error results the framework fills in after its stream ended before its tools answered.
- Workspace: `materialize` accepts `applyDeletions`, `WorkspaceConfig` accepts
`agentActionScanDeadlineMs` (default 20000, kept below the hook's 30 s bound), and
`workspace:deleted` events carry `conflicts` when a disk deletion met a newer workspace edit.
18 changes: 18 additions & 0 deletions changelog.d/workspace-listing-shapes.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
- **Workspace tool-output consumers:** `glob` returns `matches` as objects,
`{ path, state, size?, mimeType?, conflict?, note? }`, instead of path strings. `sync` reports
`conflicts` as `{ path, kind, diskCopy }`. `sync` without a path no longer replaces a workspace
draft with a differing disk copy: the draft stays, and where disk changed too, the path is a
conflict until a `sync` of that path or a forced `materialize` resolves it, or the two converge.
For an `unknown-provenance` or `store-origin-collision` conflict, the forced `materialize` must
name the path: one without a path leaves it. `ls`, `grep` and `sync` otherwise only add fields
(each `ls` file entry's `state`; grep's `state`, `version`, `skipped` and `incomplete`; a path
sync's `discarded`).
- **Hosts and module authors reading workspace internals:** `MountState` no longer has
`materializedHashes` or `refusedPaths`. Disk-agreement evidence lives in `workspace/disk-agreement`
chronicle records, and branch-local intent in each mount's `workspace/<mount>/intent` tree state.
The module's saved state carries neither field, so a build from #169's branch started on a
store this release wrote begins with no freshness baselines.
- **Prompt cache:** the descriptions of the workspace's `ls`, `glob`, `grep`, `sync`,
`materialize` and `delete` tools change, and `sync` and `materialize` take new parameters, so
the first request after the upgrade that carries those tools' definitions misses the cache from
its tool block on.
106 changes: 106 additions & 0 deletions changelog.d/workspace-truthful-listings.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
- **Workspace listings tell the truth about disk, and no scan discards a workspace edit.** `ls`,
`glob` and `grep` check their scope against disk before answering, under one rule over disk, the
workspace store and what disk last agreed with. Files created by a shell show up, including
binary and oversize ones, listed as `disk-only` with their size and, for images, their type.
A file deleted by a shell leaves the workspace on every scan path (a watcher event, `sync`, a
listing, the scan after a restart, and a watcher reattach) where the workspace held what disk
last agreed with; a workspace edit of it stays, as a `deleted-on-disk` conflict. Each file entry
carries a state:
`synced`, `workspace-draft`, `workspace-deleted`, `not-in-branch`, `disk-only`, `conflict` (with
its kind) or `disk-missing-provenance-unknown`. Where disk couldn't be checked, the state is
`unverified`, and `incomplete` names the unreadable, ignored or capped regions. A pending
workspace edit is never overwritten by a scan, a watcher event or a lazy read. When disk and
the workspace both changed, the path becomes a conflict that stays until a `sync` of that path
(take disk), a forced `materialize` (take the workspace) or convergence. A text disk version is
kept in the store; a binary or oversize one is referenced, and the listing says if it changed
since. `grep` labels a kept disk version `recorded-disk` once disk has changed since, and lists
a conflict's disk side it couldn't search as skipped. A `sync` of a path lists under
`discarded` each workspace change it gave up for disk's state.
- **A mount root that isn't the directory disk last agreed with is unavailable, not empty.** The
workspace records the root's identity (its device and inode) with the first evidence it
gathers there. When the drive under a read-write mount is unmounted, the watcher recreates its
mountpoint as an empty directory; that root is listed as unavailable (`incomplete`, saying what
to do), so no scan reads its emptiness as every file deleted, and no `materialize`, forced or
not, writes into it. When the drive comes back at the same path, the mount is available again;
one that comes back elsewhere leaves it unavailable until it is back at that path. If the
directory was replaced on purpose, or a drive came back under a new device number, `sync` with
`acceptRoot: true` takes the root as it is now: what was recorded about the old one is set
aside, and every file is compared afresh.
- **A workspace deletion stays deleted, and materialize never deletes from disk unasked.** Without
`autoMaterialize`, a deleted file's disk copy, unchanged since disk last agreed with the
workspace, is listed as `workspace-deleted`, and no scan or lazy read brings that copy back; a
copy changed on disk since is a conflict. `materialize` lists the deletions it left on disk;
`applyDeletions: true` removes those whose disk copy is still one the workspace held, and
`force` too removes those changed since. A `sync` of the path takes disk's copy back into the
workspace when it's one the workspace can hold (text within the size limit). Once the deletion
is confirmed on disk, a file that later appears at the path is a new file, adopted like any
other.
`autoMaterialize` writes and deletes follow materialize's freshness rule: a disk copy changed
by another writer since it last agreed is refused, not overwritten or unlinked.
- **A `materialize` without a path takes up only what disk still owes:** workspace edits,
entries never checked against disk, conflicts and workspace deletions. It no longer depends on
the sequence the mount was last materialized at (`status.lastMaterializedSeq`), which is saved
only at a clean stop: after a restart that lost it, a bare `materialize` took up the whole tree.
A file the workspace hasn't changed is left as disk has it, with or without `force`, unless its
path is given. A path the evidence says nothing about is treated the same way. Where disk holds
a different copy (`unknown-provenance`, or `store-origin-collision` for a file the workspace
created), only a `materialize` that names the path with `force` overwrites it. Where an entry
from before the evidence has no disk copy, only one that names the path writes it, and a `sync`
of the path drops it from the workspace instead. A bare `materialize` trusts the evidence: if
disk was replaced behind the workspace, a `sync` takes disk's copies, and naming the paths with
`force` writes the workspace's. `status.pendingChanges` counts what disk owes by the evidence;
an entry never checked against disk isn't counted until a listing or a `materialize` checks it.
- **Undo and branch switches are not mistaken for disk edits.** What disk last agreed with is kept
in global chronicle records rather than branch state, so selecting an earlier state keeps that
state as a draft over the later disk version, and a file present only in another branch's
history is listed `not-in-branch`, never ingested or unlinked. Each write or unlink a
materialize makes, and each adoption that ingests a disk change or removes a file, is recorded
as an intent ahead of it and completed once it is durable, so a crash or a torn record tail
never costs an edit. If an append of disk-agreement evidence fails inside Chronicle, the
workspace refuses operations that need that evidence and reports why. Those operations remain
unavailable until the host restarts and reloads the store's records. The append may have
reached the log without becoming readable in this process, so deciding a conflict from
possibly incomplete evidence would be unsafe. A binary or oversize disk version, for which the workspace drops its copy,
forgets the evidence first instead. After a crash, an interrupted write or unlink is decided by what disk shows (a copy
matching neither the one before nor the one intended is an `interrupted` conflict), and one
that shows on disk without proof it is durable is completed only by the next `materialize`,
which redoes it; an interrupted adoption is settled by its own branch's tree. This replaces
#109's stop-time baselines and refused paths,
which an abrupt exit or a branch switch could lose or rewind. A store that saved them doesn't
import them, since they name no root: its first pass records each file disk agrees with, and
lists any other as a conflict or of unknown provenance.
- **`watch: 'on-agent-action'` works as documented:** after each completed tool batch, before
anything continues the agent's turn, the mount is scanned. A scan that outlasts
`agentActionScanDeadlineMs` stops holding the round and continues in the background; the miss
is reported on `status` (`lastAgentActionScan`) and as a `workspace:agent-action-scan-incomplete`
event, as are the regions a finished scan couldn't observe (`incomplete`). Batches that come
while a scan waits share it, and while a scan that outlasted its deadline still runs, a later
batch isn't held: its miss is recorded at once, and its scan comes after. The hold is in the
framework's event loop, so while it lasts every agent's events wait.
- Disk text is stored as its raw bytes rather than through a UTF-8 round trip, so a file in
another encoding no longer differs from its own stored copy.
- `status` with `mount` reports that mount alone, and refuses one that doesn't exist. A
`materialize` that names a read-only mount lists it under `skipped`, rather than answering as
if nothing was owed there.
- `materialize` and `autoMaterialize` check each write and unlink where it happens, not only when
it is planned. A file is opened in its directory without truncating and located before any
byte changes: never through a symlink the mount doesn't follow, or a directory that resolves
outside the mount, and never into a file with other hard links, whose other names (a package
store's, say) a write in place would change too, even with `force`. Unless `force`, it must
also still hold what the materialize decided on, so a disk edit or a new file that appears
meanwhile is refused, not overwritten. Refused paths are listed as skipped with the reason.
Where the platform can sync a directory (not Windows, nor a filesystem that refuses it), every
directory from a written file up to the mount root is synced before the write counts as done,
directories it had to create included. Listings likewise accept a directory's contents only
while it is still the directory at its path, and a scan, write or unlink accepts what it found
only while the mount root is still the one it began under: a scan that finds the root replaced
when it looks again treats what it observed as unverified, and reports the root. Each check is made where the operation happens,
against what is there at that moment. Node has no `openat`, so a parent directory replaced
concurrently in the window between a check and a create, `mkdir`, unlink or listing can still
redirect that one operation: an empty file or directory can appear outside the mount, or a
same-named file there can be unlinked. Nor can a write or an unlink be bound to the moment of
its last check: an edit to the open file between the last look and the truncate can be
overwritten, and a file put at the path, or an edit made to it, between the last look and the
unlink can be removed. Content writes stay bound to the
descriptor verified, inside the mount and with no other hard links, before modification; a
hard link made to the file after that check shares the write.
38 changes: 38 additions & 0 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6694,6 +6694,44 @@ export class AgentFramework {
result: event.result,
});
}
// The round's last pending result: let modules catch up with what the
// batch's tools did (the workspace scans `on-agent-action` mounts) before
// anything continues the turn. Bounded and fail-open; the agent may be
// reset or cancelled meanwhile, so its state is checked again after.
if (
agent && agent.state.status === 'waiting_for_tools'
&& agent.state.pending.size === 1 && agent.state.pending.has(event.callId)
) {
// The call this result answers, as this round holds it: a new round
// that reuses the call's id holds a new entry for it.
const owed = agent.state.pending.get(event.callId);
const failures = await this.moduleRegistry.notifyToolBatchComplete(event.agentName);
for (const failure of failures) {
this.emitTrace({ type: 'module:batch_hook_failed', agentName: event.agentName, module: failure.module, error: failure.error });
}
// The turn may have been reset, cancelled or replaced while the hook
// ran, even back to waiting on a new batch, whose calls may reuse this
// one's id: this result is still owed only if this agent still waits
// on this round's call.
const after = agent.state as AgentState;
if (
this.agents.get(event.agentName) !== agent
|| after.status !== 'waiting_for_tools' || after.pending.get(event.callId) !== owed
) {
console.warn(
`[framework] Dropping tool-result for agent '${event.agentName}' (callId=${event.callId}): ` +
`its turn changed while modules caught up with the batch (status '${after.status}').`,
);
this.emitTrace({
type: 'tool:result_dropped',
agentName: event.agentName,
callId: event.callId,
agentStatus: (agent.state as AgentState).status,
result: event.result,
});
return;
}
}
if (agent && agent.state.status === 'waiting_for_tools') {
agent.provideToolResult(event.callId, event.result);

Expand Down
Loading
Loading