Skip to content

Feat/messages - #36

Merged
roodriigoooo merged 7 commits into
mainfrom
feat/messages
Aug 10, 2026
Merged

roodriigoooo merged 7 commits into
mainfrom
feat/messages

Conversation

@roodriigoooo

Copy link
Copy Markdown
Owner

Reconcile: settle a collision without either worker redoing anything

Two workers touch the same lines. Today the overlap view asks who yields, and
every answer to that question throws work away. (forced to promote one and the other is
building on a base that moved, or send it back to redo something it already
finished). The premise was wrong. Both workers branched from one base, which
worker-overlap.ts already relies on to compare pre-image ranges. Two change
sets over a shared base are a three-way merge, and git computes it exactly. So
the question a collision actually poses is not which worker wins, it's what the
file should be.

Combine is that merge. The picker row states its own outcome first:

src/api/limit.ts · how should this settle?
▸ Send nothing
  Combine with w2 · add a per-tenant rate limit to · 1 conflict to resolve in 1 file
  Ask w2 · add a per-tenant rate limit to to yield
  Hand w2 · add a per-tenant rate limit to both diffs to reconcile

Most overlaps come back merges clean. there was never a decision in them, and
stillApplies had been quietly saying so for a while. Where the edits genuinely
meet, the contested file opens in your own editor with git's markers relabelled
by worker and task, and the base between them. You write the resolved file.
One that comes back with a marker still in it is refused by name. Then both workers' work lands in one promotion, and neither is told its base moved the journal entry credits both and says neither needs redoing.

On the principle this revises

P5 recorded that the overlap view is "a reading surface, not a merge tool". The
reason given was mechanical: two sections for one path would be applied twice by
git. That's an argument about the composed patch, which really is
unapplicable. It doesn't generalise to "Docket must never produce a merged
file", and a a three-way merge is a different construction with none of that problem.

What has to hold is about authority: Docket computes the mechanical part of a
reconciliation and hands the human the residue. It never decides one.
A merge
is an observation about which lines both sides changed, same kind of fact as
git apply --check, which P5 already accepted. Auto-merging and promoting would
break the rule. Showing three versions and letting the human write the fourth
doesn't.

How it works

Each side's post-image is built in a scratch index — read-tree,
apply --cached, write-tree committed as a child of the shared base, then
merge-tree --write-tree under diff3. No worktree, no checkout, no
working-copy write; every object produced is unreferenced. The base is picked by
proof rather than ancestry: a candidate head is accepted only if both patches
apply to it.

Reconciliation lives on the promote path rather than beside it. A second door
that also applied patches would be two notions of what promoting means, and this
codebase already paid once for having two implementations of one idea. So a
reconciled change set passes apply --check, the parent-moved confirmation, and
the journal unchanged.

The peer's deliverable closes with a new terminal verb, reconcile. Its row
settles; isDeliverableApproved stays false, because approval has to keep
meaning a human judged one deliverable on its own.

Third exit hands a worker both diffs, the residue, the directive, and the
provenance that the other change is under review and not in the project. Escape
hatch for a conflict that's semantic rather than textual.

The overlap surface could grade a collision and show both sides, but every
exit from it was lossy: promote one worker and the other is left building on
a base that moved, or send it back to redo work it had already finished.

Both workers branched from one base, which worker-overlap.ts already relies
on to compare pre-image ranges. Two change sets over a shared base are a
three-way merge, and git computes it exactly — so for every line the two did
not both touch there is a machine answer, and only the lines they did touch
need a human.

Each side's post-image is built in a scratch index (read-tree, apply --cached,
write-tree), committed as a child of the shared base, and merged with
merge-tree --write-tree under diff3. No worktree, no checkout, no working-copy
write; every object produced is unreferenced. The base is chosen by proof
rather than by ancestry: each candidate head is accepted only if both patches
apply to it, because a patch that applies is proof the tree is a correct
pre-image for it.

merge-tree labels conflicts with the object id it was handed, which tells a
human nothing. The roles are fixed by the marker glyph, so relabelling is
positional and pure, and every label carries a worker and its task.

Outcomes are tri-state, as everything else here is: clean, conflicted with the
regions counted, or a stated reason. "Could not combine" is never allowed to
read as "they are apart".
A promotion has always had one author, so a worker reading the journal could
only ever learn that the ground moved under it. That is the right message when
someone else's change landed on top of yours. It is the wrong message when
your own work is inside what landed, and reconciliation makes that case real.

A promoted entry now carries workerIds. Staleness skips every contributor, not
just the one worker the entry came from, so nobody derives base moved from
edits that are already in — and the entry says so in words, because "your base
moved" reads as "start again" and this is the opposite of that. workerId is
still written alongside it, so an entry appended today reads correctly to a
Docket that predates this.

The peer's deliverable is closed out with a new terminal verb, reconcile: its
row settles and its generation is safe to prune, and isDeliverableApproved
stays false. Approval has to keep meaning that a human judged one deliverable
on its own, which nobody did here.

Guardrails gain the matching sentence, so a worker credited by a promotion
does not read the standard stale-base paragraph as a reason to stop and ask.
The overlap view's question was "who yields", and the promote confirmation's
was "promote anyway". Both accept as given that one worker's work is about to
be thrown away. Combine replaces that premise: the two change sets are merged
over the base they share, and what the human is asked for is the residue.

The row states its own outcome before anything is committed to — "merges
clean, 2 files" or "2 conflicts to resolve in 1 file" — so neither is
discovered halfway through an editor session. Most overlaps turn out to have
no decision in them at all, and stillApplies was already telling us that.

Contested files open in the human's own editor with the relabelled markers.
Native form: git's conflict markers are a vocabulary every developer has, and
a bespoke merge pane would be a new visual language for a job the editor
already does. The set is transactional, and a file handed back with a marker
still in it is refused by name — the gate re-reads what is actually about to
be applied, not what the human was handed.

Reconciliation lives on the promote path rather than beside it. A second door
that also applied patches would be two notions of what promoting means, and
this codebase already paid once for having two implementations of one idea.
The overlap view reaches that path; it does not reimplement it. So a
reconciled change set passes apply --check, the parent-moved confirmation, and
the journal unchanged.

The third exit hands a worker both diffs, the merge residue, the directive,
and the provenance that the other change is under review and not in the
project. The escape hatch for a conflict that is semantic rather than textual,
and deliberately not the default: it costs a worker turn and returns an
unreviewed deliverable, where a human edit is seconds and is reviewed by
construction.

A merge that could not be opened says which peer and why. Showing the older
exits without a word would let the human conclude combining was never possible
for this collision.
…demo

P5 recorded that the overlap view is "a reading surface, not a merge tool",
and the reason given was mechanical: two sections for one path would be
applied twice by git. That is an argument about the composed patch, which
really is unapplicable, and it does not generalise into "Docket must never
produce a merged file" — a three-way merge is a different construction with
none of that problem.

What has to hold is about authority, not mechanism: Docket computes the
mechanical part of a reconciliation and hands the human the residue, and never
decides one. A merge is an observation about which lines both sides changed,
the same kind of fact as apply --check, which P5 already accepted.

P6 written with that restatement, its scope, its non-goals, twelve passing
criteria, and what was decided along the way. CONTEXT gains Reconcile and
Conflict residue as vocabulary and three invariants. Architecture, the full
reference, and the README follow.

The README demo becomes two takes over one fixture. Take A is unchanged except
where the promote confirmation's wording moved. Take B is the new lane, and it
wants both workers ready rather than one running, because a reconciliation
needs both sides frozen.
The take said "delete the markers, keep w3's signature and w2's body" and left
the recorder to invent the resolution on camera, against markers that were
written from memory rather than from a run.

Both are now the fixture's real bytes: the base setup.sh writes, what each
worker produced, the conflict merge-tree actually leaves, and the resolved file
to have on the clipboard before recording. That last file is worth reading on
its own — w3's signature, w2's table, and w2's lookup rewritten against w3's
variable name. Neither worker wrote it and neither could have, which is the
claim the beat exists to make.

Also corrects two things the take was wrong about before reconciliation
existed. Take A's w2 must publish and *then* be woken, not merely be left
running: without a frozen change set there is nothing to grade its side
against, so the card would read `same file` and `o` would have one set of hunks
rather than two. And task labels are the first six words of the spawn task, so
w2's ends on "to" and w3's is clipped — the take now quotes them as they render
instead of as they read well.
docs/readme-demo.md is production notes for the README clips — spawn order,
which worker to leave running, what to have on the clipboard before recording.
It is written for whoever is holding the screen recorder, not for anyone
installing the package, and it belongs beside the other demo guides that are
already local-only.

Untracked and gitignored, same as manual-demo.md and manual-demo-messaging.md.
The file stays on disk; nothing about recording a clip changes.

Two things came out of doing it:

npm pack was shipping the demo guides regardless of .gitignore, because the
`files` field in package.json wins over it. Every published version so far has
carried 36kB of manual-demo-messaging.md, which is a recording script for a
fixture that does not exist in the tarball. `files` now negates all three
guides explicitly. The real docs are unaffected: 87 files, adr/, architecture,
configuration, full-reference, releases.

README linked to docs/manual-demo.md under More docs, which has been ignored
and untracked for some time — so that link was already dead in the published
package. Removed rather than repointed: the manual guides are local tooling and
the README's list is for people reading the shipped docs.
CI has been failing with eight cancelled tests and zero failures, all in
messaging-surface, all reporting "Promise resolution is still pending but the
event loop has already resolved". It reproduces on Node 22 and not on 24 or 26,
which is why it never showed locally.

The cause is in the seam, not the test. collectBroadcastSuggestions races each
advisor against a timeout, and that timer was unref'd. The timer is the entire
isolation guarantee — an advisor that never resolves is precisely what it exists
to survive — so unref'ing it means that in the one case it is there for, it is
the only handle left, the loop drains, and the await never settles. Inside pi
this never surfaced because a live session always has other handles keeping the
loop alive. Under `node --test` the file is the only thing running, so the
process exits and every test after it in the file dies as cancelledByParent.

The timer is now ref'd and always cleared, so it holds the loop for at most the
window and a well-behaved advisor costs nothing. The hung promise stays pending
forever, because a promise cannot be cancelled, but nothing awaits it and it
holds nothing open.

The existing test could not have caught this: it registered a fast advisor
alongside the hung one, so the race always had something else to settle on.
Added the case where every advisor hangs, which is the only configuration where
the timeout is what resolves it.

Checked the other unref'd timers while here — the dock watcher, the peek and
pulse timers, Hunk's comment harvester. All of them fire callbacks or poll
beside something else that keeps the process alive, and none gates a promise
anyone awaits. Unref is right for those.
@roodriigoooo
roodriigoooo merged commit 267468a into main Aug 10, 2026
1 check passed
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