release - #62
Merged
Merged
Conversation
Harden relay heartbeat and recovery
* A backlog row says what happened, and neither buried nor blocked is final
The bridge has always written `outcome` and `evidence` onto backlog items and the app model carried them through, but the drawer rendered neither — so the most useful fact about a finished item existed on the wire, in memory, and on screen nowhere. The subtitle now falls back outcome -> condition. The outcome wins wherever both exist: the condition is the question "does this still need doing?" and the outcome is its answer, so once the answer is written the question reads as stale, and the gate is inferable anyway from the item's own text beside a status still sitting at `queued` while what actually happened is inferable from nothing. A blank-but-present string on either side counts as absent, so an empty outcome off the wire cannot swallow a real condition. Evidence deliberately stays off the row: it is the verbatim transcript quote backing the outcome, arbitrarily long and code-shaped inside a chrome row, and this list is scanned for what happened rather than for what was said.
Reordering cost one `handler:configure` round trip per slot, so lifting an item off the tail meant a wholesale replace at a time against a backlog moving underneath it. "Move to top" is one edit, and it targets the slot `nextActionable` reads. It delegates to the existing `_withItemMoved` with a delta of -indexWhere(id), so there is one reorder path rather than two, and the distance is measured against the list the edit is applied to for the same reason that path addresses by id — the list a row was drawn from may have grown behind it. There is no mirror, and not for symmetry's sake: `appendItems` appends, so an item sent to the bottom is overtaken by the very next thing the user says, and the control would make a promise the append path breaks.
`blocked` had no way back to `queued`. Both it and `skipped` are states an item sits in without having been achieved, so one constant now drives the requeue predicate and the menu's visibility and the two cannot drift; `done` and `failed` stay excluded, since re-running an outcome the agent reached behind the user's back is not what revive means. Requeue is withheld where a dependency is itself blocked or failed, because `propagateBlocked` derives the block from exactly those statuses and the edit would bounce on the next decide pass — what frees the item there is dropping the dependency, which stays on the row, and the dependency's status is now named on the waits-on line so the reason is on screen at the moment it becomes true rather than as a permanent caveat. Where nothing is stalling it, the bridge may still re-block on its next pass; that is correct and the client does not fight it. The outcome is dropped on the way through, because the bridge shows the judge an item's outcome verbatim and one left on queued work reports a block that is over.
`AbIcons.moveToTop` is Codicon's `fold_up` rather than a second chevron: the single chevron is this app's fold/unfold mark everywhere else it appears, and a list action borrowing it would teach the glyph a second meaning one row above a real move arrow.
* An instruction in flight is visible, and it holds the backlog still until it lands
`handler:instruct` is fire-and-forget: the bridge answers nothing, runs the extraction behind a per-terminal serial chain and a headless CLI spawn, and appends whatever comes out of it seconds later. `handler:configure` is a wholesale replace with no merge. So an edit made in between - a reorder, a requeue, a delete - shipped a list the new items had never been in and deleted the work the user had just asked for, with nothing on screen to say either that the instruction was still coming or that it had been taken away again. The window is the length of a CLI run and there was no reason to suspect it.
The first half is that the sentence is visible while it is outstanding. It goes in the backlog list, at the tail, wearing `adding` in the status column - not a spinner on the composer, which reports on the app rather than on the user's work and would confiscate the next action for the duration of an extraction the bridge queues a second instruction behind perfectly well. The list is this product's model of what Handler will do, so the honest answer to "did that land?" is the sentence in the list, in the slot `appendItems` will put its items in. The record is also the debounce: the same sentence twice for one terminal is refused until the first lands, and refused out loud, because the bridge appends and nothing there absorbs a duplicate.
Retirement is per terminal and on that terminal's own evidence, because a status frame is not one. The engine serialises every armed session on every handler event, so a second terminal's ordinary supervision raises a frame within milliseconds of a send; retiring on the frame alone took the row away mid-extraction, lifted the debounce so a re-tap stacked the same work twice, and opened the edit lock inside exactly the window it exists to cover. The evidence is the session the sentence was sent against - a backlog no longer the length it was, a re-arm, or a terminal gone from the snapshot. The one outcome that appends nothing and emits no status, a backlog already at the cap, retires off its own activity record instead.
The second half is the lock, and it is built on the first: the refusal quotes the user's sentence back rather than describing the app's state, so the greyed menu and the row already on screen are one fact instead of two. Every affordance that sends a `configure` is held on those terms - the five menu entries through one shared helper so a later one cannot ship a stale list, and the dependency row's cross, which keeps its `onTap` because `onTap: null` would leave the reason reachable by hover alone and therefore not at all on a phone. `updateBacklog` refuses underneath all of it, so an editing surface added later inherits the floor rather than having to remember it. Typing a NEW instruction deliberately does not lock: the bug is `configure`'s replace, not `instruct`'s append, and two appends cannot erase each other.
The lock is client-side on purpose. Making `handler:configure` carry the source ids it was built from would edit the same schema the planned `notifyOnly` removal rewrites, and two migrations over one field is how a pre-v1 wire grows a compatibility shim nobody wanted.
* An arm tells Handler what the session is for, and the drawer says which session that is
arm() has always taken a goal and no caller ever passed one. So every judge prompt rendered "(none stated)", the wrap-up push had nothing to name the session by, and allTerminal - which needs a non-empty backlog - made wrap-up unreachable outright. A supervisor was being armed over the user's working tree with no statement of what the session was started to do, judging on the transcript alone.
Nothing in the app was holding that statement either. session:start.initialPrompt is one-shot launch argv the bridge never persists, SessionEntry brings no prompt back on session:list, and resetNewSessionForm clears the draft the moment a start is accepted - so by the time the user arms, on a different surface at a later moment, their own words are gone everywhere. sessionOpeningPromptsProvider carries them: an in-memory map keyed by session id, written where initialPrompt is decided and read at the single seam both arm surfaces funnel through. In-memory on purpose, since a prompt that outlived a restart would seed a goal onto a session redirected in the terminal hours ago. Not a checkbox on the composer: that asks "will I walk away?" at the moment work starts, and the away hint exists precisely because that decision is made later.
The bridge needed no change, because the machinery was already there. Both goal-write paths call queueExtraction(goal, {onlyIfEmpty: true}), so sending the string populates the backlog as a side effect, and extractAndAppend slices the raw sentence to MAX_ITEM_CHARS up front and appends it when the CLI fails, the account is rate-limited, or the agent is not judgeCapable - so a seeded arm cannot land an empty list. A session nothing remembers passes no goal key at all, which leaves the payload-free arm byte-identical to what it was.
Seeding is a FIRST-arm act and the prompt is forgotten once the bridge confirms one. A disarm leaves the bridge nothing to rehydrate, so the same sentence on a re-arm would extract into an empty backlog again and redo work Handler has already finished, unattended and past the undo offers the first arm retired. Left open deliberately: onlyIfEmpty is right at arm and wrong for a mid-session goal edit, which would reframe the judge while the backlog still described the old goal. Nothing today tells the two cases apart, and there is no goal-edit UI to force the question.
That makes an armed backlog usually non-empty, which is what the drawer is now written for. The title composes the session's name from the same list the Handler tab's cards read, falling back to the bare surface name rather than a raw terminal id - an id tells rows apart on a screen listing several, and in a sheet showing one it is a string with nothing to distinguish it from. An empty list opens with the act rather than the absence, and names the goal where one stands above it, so a user told to add what they want done does not retype the sentence an extraction is already appending. The presets wrap instead of scrolling, because four md chips overrun a narrow phone by about one label and a strip that scrolls says so only to someone already dragging it. And the disclaimer closes for good on its own first-run flag - not a share of handlerArmedOnce, since arming happens on the shield whole sessions before this sheet is first opened, and reading that arm as an acknowledgement would retire a sentence nobody was shown.
* A backlog item's text is the user's to correct, not the extractor's last word
Item text is model output. The extraction pass splits one sentence into several, rewords what it splits, and truncates at MAX_ITEM_CHARS - so an item routinely says something adjacent to what the user asked for, and the judge is then shown that as the statement of work. The only recovery was Delete, retype the sentence, and wait out another extraction round trip, which re-guesses everything else in it.
Withholding the edit was never a decision. The drawer refuses to author a dependency deliberately and says why: a hand-written gate quietly stops work the user asked for and nothing on screen says which one did it. Text carried no such reason - it was an omission that every later pass read as a rule. The condition splits along that same line rather than inheriting either answer. It is editable where the model already wrote one, and clearable to nothing, because correcting a model-authored gate and dropping it both move the item TOWARDS running and neither is undoable on any other surface - 'only if the tests pass' over a sentence the user meant unconditionally is an item that silently never runs. Authoring a gate from nothing is the act the dependency rule refuses, so the field appears iff the item already carries a condition, mirroring the row that prints it.
The edit goes out through the ordinary _sendEdit / handler:configure path rather than a verb of its own, so it inherits Wave 2's in-flight refusal for free and the planned instruction-driven amendment route inherits this surface rather than reworking it. That refusal is the one case where an editor holds something the user cannot get back, so updateBacklog now reports whether the replace went out and the sheet pops only on a send that happened. A button's disabled state cannot cover the frame between the draw and the tap, and reading the hold off the state at build time would be a second copy of the rule and a stale one.
The editor is a route, not a field that grows inside the row: a row that became a form would push the list down the moment the menu closed, and on a phone the keyboard would then cover the list being reordered. It opens with the caret at the end rather than the text selected, since what is in the field is usually mostly right and select-all makes the first keystroke destroy it. The design system had no multi-line input at all - new_session_composer hand-rolls a bare TextField and says so - so AbTextField grows a line budget and AbControlBox a minHeight that is a floor where height is a floor and a ceiling at once. maxLines defaults to 1, which leaves every existing field byte-identical.
The 400-char cap is client-side only, and knowingly. handler:configure does not bound item text - InstructionItemWire.text is a bare z.string() and MAX_ITEM_CHARS lives in the extractor, bounding what it mints rather than what the app may send. It is the right product rule regardless: the judge is shown the backlog as a list, and a hand-edited item should be the same size as an extracted one. If the wire is to hold that line, InstructionItemWire is where it belongs.
* No feed row prints its own protocol word, and an instruction that grants a command says so
The three activity switches had no arm for the commonest decision there is. A pass where Handler decided nothing needed doing fell through every one of them, so the row that outnumbers the rest of the feed took the fallback subtitle - Text(r.decision) - and printed the wire word 'continue' under the judge's sentence. That fallback is also the path an app older than its bridge takes for a kind it has no arm for, which is the case it exists for, so it now renders whatever detail came with the row and never the enum. The continue row itself is named off the run state rather than in words of its own, because the header pill above the feed already says Watching for that state and two spellings on one screen read as two sessions; it keeps the judge's reason, which is the only trace of what Handler saw while the user was away, and takes the muted tone and the eye glyph so a column of them is what the eye skips. Seven of the fifteen kinds had no test at all. The new one pumps every kind through the screen and asserts the raw word never reaches it - a fourth hand-mirror of the enum, deliberately, since it is the only thing that catches this class.
The same leak sat on the row a user is meant to act on. noteFloorWarnings put w.pattern - a regex source - into a floor_warning's detail, which the app renders in mono under a title describeWarning had already written in words. The readable half was always there; the detail is dropped rather than respelled.
authorizeInstruction has always computed which command patterns, paths and hosts an instruction lifts for the rest of the session, and instruct() discarded what it returned. The engine's own comment conceded the cost: over-lifting bought a session-wide grant the user never read. 'Clear out the build dir with rm -rf build' reads as a chore and is also a standing permission - Handler runs that shape from here on without the advisory row that would otherwise name it - and that is the one consequence of the sentence a user cannot read off their own words. So the grant is recorded where it is taken, on the raw payload and before the extraction spawn, since a lift derived from judge-authored output would let a compromised agent widen its own permissions through the extractor.
What the summary may repeat back is narrower than what it grants, in two places. Patterns are regex sources: they are the only stable key a lift can hang on and the one thing that can never be put in front of a user, so operations carries the readable spelling beside them - w.matched for a literal hit, the alias table's canonical command for prose that never spelled it - tiered, because a sentence that lifted the secret-read advisory must not be reported as having allowed a command. And hosts is a deliberate superset: the harvest reads any dotted token as a host so that granting and checking agree with each other, which makes 'dump.json' a host and costs nothing until it is shown. destinations is the half that is shown.
An empty summary records nothing. Most instructions grant nothing at all, and a row that says so every time is exactly what teaches a user to skim past the one row that matters. A lone lift rides in the reason rather than under a count of one, and a wider grant puts the totals there over a sample capped at eight entries and 200 chars - the first entry always rides however long, because a row whose list is empty while its title says two commands reads as a bug in the row.
The drawer echoes it once, under the field, anchored to a send and not to sheet-open: a grant made an hour ago is history the feed already holds, and standing it over the composer on every open would make it a statement of the session's standing permissions, which is a management surface this wave does not build. It is latched, because the status snapshot that retires the sentence lands right behind the activity row carrying the grant, and it is not behind the disclaimer's dismissal, since that flag retires one notice that has been read and this line carries different words every time it appears.
Both hand-mirrors of the decision enum move here - handler/config.ts and protocol.ts - and the app's lockstep comment with them. No new message type: handler:activity carries the kind. handler-activity.jsonl is append-only and read by nothing, and the app parses decision as a free String, so an old file and an old client are both unaffected.
* A sentence that takes an earlier one back edits the backlog instead of arguing with it
"Actually skip the commit" appended a new queued item beside the still-queued commit item. Both stayed actionable, nextActionable returned the commit, and Handler went and did the thing the user had just countermanded. The corrective item could then never close - no transcript evidence can satisfy a change of mind - so allTerminal never went true and the session could not wrap up. Neither existing route reached it: the judge's transitions require a verbatim transcript quote for any terminal move, and handler:configure is a manual wholesale replace on a surface the user may not have open.
So the extractor is shown the open list and answers in a second array beside `items`, never inside it - `items` stays append-only, and the id-collision reasoning in ExtractedItemSchema is untouched. Amendments are applied by the engine and never by the judge, and there is no `status` field at any value: `drop` is a removal from the array rather than a close. That is the load-bearing half. summarize() counts skipped items and the wrap-up push reports them, so closing a countermanded item that way would let the user's own words add to what the session claims it resolved - the inflation SS4.3's citation gate exists to prevent. A removal contributes to no count and no digest, and is precisely what the drawer's Delete already does, so "the user removed this" keeps one meaning across both routes. The door is one-way in the other direction too: an amendment naming a closed item is skipped, so a sentence cannot reopen what the evidence gate closed.
Ids are resolved against the live backlog after the await and unknown ones dropped in silence - the discipline the dangling-ref filter takes, for the same reason: the extractor is an LLM that can now name live ids, and its output is untrusted input. What it may name is bounded by what it was shown. Closed items are left out entirely, since nothing here may reopen one; thirty open ones ride at 120 characters each, and the omission is stated rather than left silent, because an extractor reading the list as complete answers "there is no commit item" by inventing one. Ids and texts go through backlog.ts's oneLine, exported now rather than copied, so a newline in an item cannot forge a list line and hand the extractor an id nobody authored.
A dropped item's id survives in every dependsOn that named it, and nextActionable reads an id it cannot resolve as unsatisfied - so pruning those edges is not tidying, it is the same stranding one level down. A dependent that the removed item was blocking resets to queued and propagateBlocked re-derives, since it only ever moves an item toward blocked and nothing else was left to lift it.
The user did not tap Delete and the drawer may not even be open, so the change gets a feed row rather than a second latched line under the composer: the list already shows the outcome by no longer containing the line, and what it cannot say is why. instruction_amended wears the drawer's own Edit mark, so a change the user makes by hand and one their sentence makes for them wear one glyph. A pure reword leaves backlogTotal unmoved, so the row also retires the outstanding "sending" record itself, and the snapshot it emits is credited rather than read as the next sentence having landed. Both hand-mirrors of the decision enum move together, and handler-activity.jsonl is append-only, so an older app renders the new kind through the unknown-kind fallback.
Two consequences fall out of the composer being able to do this at all. Its verb changes: "Add an instruction..." is a control promising to add at the exact moment the user is cancelling, so the field, the button, the pending label and the lock reason all say send. And a withdrawal must not grant - "actually skip the force push" carries no prohibition word, so the alias table read it as a request and the grant row announced that the user had permitted a force push for the session, the inverse of what they wrote, while silencing the advisory for every pass after it. grantableClauses now drops countermanding clauses alongside prohibiting ones, loosely on purpose: a clause wrongly read as a countermand costs one unlifted advisory row, and a missed one costs a session-wide grant nobody asked for.
bharathm03
temporarily deployed
to
production
September 1, 2026 03:34 — with
GitHub Actions
Inactive
bharathm03
temporarily deployed
to
production
September 1, 2026 03:38 — with
GitHub Actions
Inactive
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.
No description provided.