Skip to content

v0.27.0

Choose a tag to compare

@jobshimo jobshimo released this 14 Aug 23:40
· 17 commits to main since this release
54aaa0b

Features

  • bridge + extension: detached execution, browser.flow_status / browser.flow_cancel, and the action manifest — the last slice of docs/specs/flow-supervised-execution.md (slice 5; ROADMAP item 11), and the one that finally breaks the circularity the whole program was written to break. browser.flow({ steps, detach: true }) returns AT ONCE with { flow_id, detached: true, started_at, steps, expires_at } and keeps running in the background with every guarantee intact: trusted CDP input, the occlusion guard, per-iteration records, fail-fast with a recovery snapshot, and the popup's Stop button. Work longer than 60 seconds no longer has to become a raw evaluate loop that has none of them. MAX_FLOW_TIMEOUT_MS is not deleted — it changes meaning, and DECISIONS.md §13 has been revisited to say so: it stops being a consequence of having no lifecycle and becomes a turn-economics rule about how long the bridge parks ONE tool call, exactly like browser.wait_for_tab's and browser.evaluate's ceilings. A synchronous flow still obeys it and is still rejected up front, because nothing cancels a flow just because the bridge stopped waiting for it, and a dropped response to a flow that is still executing is precisely how an agent retry duplicates irreversible actions. A detached flow parks nothing, so the rule does not apply to it; it is bounded instead by MAX_DETACHED_FLOW_TIMEOUT_MS (30 minutes), which is both the ceiling its statically computed worst-case budget is validated against up front AND the wall-clock deadline at which it self-cancels reporting stopped_by: 'expired' — a distinct outcome from cancelled, because "I ran out of time" and "a person stopped me" call for different responses. What was deliberately NOT relaxed: the truthful budget is still computed and repeat.max_iterations is still mandatory. "Unknowable" is exactly what the duration of a loop that keeps clicking with no agent attached must never be. The ceiling is enforced as a DEADLINE checked at the same three dispatch points as the cancel flag rather than as a timer — a timer would fire while the runner is mid-step and could not stop anything anyway, and a request-derived setTimeout duration is a resource-exhaustion finding waiting to happen. At most one detached flow per tab; a second is rejected NAMING the running flow_id, because an error that just says "busy" leaves the caller unable to inspect or stop what is blocking them. The manifest fell out free, as the spec predicted: runFlow already accumulated results[] via compactActionResult, so a detached flow simply retains them, addressable by flow_id — browser.flow_status returns the exact results array a foreground flow would have returned, per step and per iteration inside a repeat, which is how "which 956 things did you just delete?" becomes a question the bridge can finally answer. runFlow itself was not touched at all, so cdp/drift.test.ts held trivially: the runner still only knows stopped_by: 'cancelled', and the registry entry is what promotes that to expired or worker-terminated on the way out. flow_status(tab_id, flow_id) returns { flow_id, tab_id, state, detached, started_at, ended_at?, steps, steps_completed, iterations_completed?, stopped_by?, error?, manifest? } with state one of running | completed | cancelled | failed | expired | unknown. While a flow runs you get live progress and NO manifest — the runner is still holding the results, and promising one mid-run would promise a shorter truth than the real one. unknown is a state, not an error, for the same reason cancelling an unknown id is a no-op: the caller asked what an id is doing, and "nothing I know of" is a truthful answer. The tab_id only ROUTES the call (the registry is bridge-wide), so a finished flow whose own tab has since closed is still readable through any other connected tab. flow_cancel is the first server-side sender of the tool.cancel frame v0.26.0 left ready and unused: it sends that out-of-band frame — deliberately uncorrelated, since the cancelled flow answers through its own original tool.request — then reads the state back with a flow_status call and returns it with cancel_requested: true, so an agent sees cancelling: true during the up-to-one-step gap rather than a synchronous claim the function cannot make. Like dialog_respond, and more importantly than it, flow_cancel deliberately bypasses the claim guard: requiring tab ownership to STOP a runaway loop would mean the one agent that can see the problem is the one agent that cannot fix it. MV3 service-worker termination is settled the conservative way. A detached-flow:<flow_id> record lands in chrome.storage.session BEFORE the first step is dispatched — a flow that acted before it was recorded would be exactly the invisible run this slice exists to prevent — and on the next service-worker start any record still marked running is by definition orphaned, so it is rewritten failed / stopped_by: 'worker-terminated', written to the popup's Recent flows, and has its summary event flushed on that tab's next registration. It is never resumed. A detached flow exists to do irreversible bulk work; resuming one whose real progress died with the worker would double-act on an unknowable prefix of it, and a flow that LOOKS running while nothing executes it is worse than either. Its partial manifest is genuinely unrecoverable (it lived in the runner's closure; the alternative — a per-step write — is hundreds of storage writes per run to keep a counter warm), so manifest_truncated: true says so rather than presenting an empty array as "nothing happened". No keep-alive was added: keeping the worker alive on purpose changes the problem from "it can die" to "it shouldn't die", and the guarantee that matters is that it never lies about its state. Exactly one flow-finished bridge event per detached flow, never one per iteration — MAX_EVENTS = 200 means a single 200-iteration run emitting per-iteration events would silently evict every other entry, turning the thing meant to make bulk work auditable into the thing that destroys the audit trail; a test pins both halves of that reasoning. The event is a summary (counts, duration, outcome) and the manifest is addressed separately by id. flow-finished joins EXTENSION_EVENT_KINDS rather than staying server-owned like flow-recorded, because it asserts only what the extension itself just did rather than something that had to pass server-side validation and land in a database. Full cdp-direct parity, with one documented asymmetry. A new in-process cdp/detached-flows.ts gives that transport the same one-per-tab rule, the same 30-minute expired ceiling, the same status payload keys and the same single summary event (added straight to BridgeEventLog through a sink installed at startup, since there is no extension to push a frame). It is deliberately NOT a verbatim copy under the drift guard: most of the extension's version is persistence for surviving worker termination, and there is nothing to survive here — if the browser-link process dies the flow dies with it and no state is left to be wrong about. The flip side, documented: detached state there is per-process, where the extension's survives in chrome.storage.session for the browser session. The popup Stop button has no cdp-direct equivalent either; browser-link cdp revoke remains the lever and now stops a detached flow within one step exactly as it stops a foreground one. The popup surfaces detached flows as the thing that needs watching: a running detached row is labelled detached · and carries an amber dot instead of the connected green, and Recent flows gains the expired outcome plus a plain-language reason line for a worker termination. Recent-flow entries keep the counts-and-outcomes-only privacy contract untouched; the manifest — which does hold resolved selectors and step results, because that is what makes it useful — is never rendered there, is only ever returned over the bridge to an agent that can already read the page, is capped at 128 KB per record and 10 records, and dies with the browser session. Known UX item, decided and disclosed: a dry_run is now validated against the DETACHED ceiling regardless of whether detach was passed, and reports budget_ms and requires_detach. Before this, an oversized flow was rejected BEFORE it was projected, which made the one question dry_run exists to answer — "I am about to do this N times; would it even start?" — unanswerable for exactly the runs where it matters most. A dry run dispatches nothing, so there is no run to be too long. The over-60s rejection message for a real run now names detach: true as the first remedy instead of only suggesting the caller split a flow they could have detached. The agent-instructions block gains a WORK LONGER THAN 60s section teaching the whole lifecycle in order (dry_run → detach → poll flow_status → flow_cancel), the four states an agent must not misread, a "poll at a human pace" warning, and an explicit "do not detach work that fits in 60s"; evaluate is demoted one rung further, now the escape hatch for what flow + repeat + detach together cannot express. One behaviour to know about detach: the launch record's write is verified rather than fire-and-forget — on a storage rejection old manifests are pruned and it is retried once, and if it still will not land the flow is unregistered and the call returns an error naming the persistence failure, because a detached run with no record would act irreversibly with no evidence and answer flow_status as unknown instead of the documented failed / worker-terminated. 97 new tests (extension 370 → 409, server 1124 → 1182), including an unmocked timing test that drives the REAL performWaitFor poll loop over a live fake CDP target and proves a 20-second wait cut short mid-poll returns within one poll interval as cancelled rather than at its own deadline.

Internal

  • three review follow-ups, folded in with the slice they belong to. (1) flow-registry.ts's register() JSDoc claimed it "returns the live entry so the caller can hand shouldCancel / onProgress closures straight over it" while the method returned void. Fixed by making the doc true rather than by deleting it: the detach wiring genuinely wants the entry (to stamp the late-arriving tab title, to close the progress sink over it, to read its deadline) and was otherwise going to do a second map lookup per step. (2) The per-tick shouldCancel check inside the REAL performWaitFor poll loops was only ever covered through mocked wait deps, so the advertised "worst case one step" bound for a step that can park for 30 seconds rested on a test that never ran the loop. There is now an unmocked timing test on the server transport: a 20-second wait_for against a live fake CDP target, the cancel flag flipped from inside the target's own command handler on the third poll, asserting the flow returns in well under a second with stopped_by: 'cancelled' (not a fabricated timeout) and that polling actually stops — plus a control case proving an uncancelled wait still runs to its own timeout, so the test measures the flag and not some unrelated early exit. (3) chrome.tabs.onRemoved's chrome.storage.session.remove of the tab's flow-history key ran OUTSIDE the serialized flowHistoryWrites chain, so a still-running flow's late append could resurrect a key that had just been dropped — and Chrome reuses tab ids within a session, so that stale key eventually shows one tab's flows under another's. Fixed with both halves the review suggested, because either alone leaves a hole: the delete now goes through the same chain (which orders it against every write already queued), and a flowHistoryDroppedAt timestamp makes recordFinishedFlow skip the append for a flow that started before its tab was removed (which handles the late write queued AFTER the delete — cleanup only ASKS a tab's flows to stop). The map is cleared when a tab connects under the same id, so it never grows past live-plus-recently-closed tabs.