Skip to content

Browser: the hub alone ends a remote tab, kept by holder files and working agents (F47 step 1) - #316

Closed
immakermatty wants to merge 1 commit into
mainfrom
agent/DEV-6656-917-hub-holds
Closed

immakermatty wants to merge 1 commit into
mainfrom
agent/DEV-6656-917-hub-holds

Conversation

@immakermatty

@immakermatty immakermatty commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Status: ready for review. The F47 design (#314) is merged as 342db2f; this PR is step 1 on top of main.

Why

F47 step 1 (plan DEV-6656 task 917). Root decision 0195 point 11 says a remote tab ends when the last place holding it lets go and no agent works in it, after a short grace with Vrátit. A close in Přehled or in the Launchpad's strip ends it for everyone. Today the view's hub only closes tabs it opened itself (F39 point 9):

  • an agent's window is never closed, so windows pile up until the browser restarts (the memory problem 0195 answers);
  • a tab sitting unviewed in a strip can close although the strip still shows it;
  • there is no list of tabs, and no way to close one for everyone.

What changes

  • Holds (src/browser/people/holds.ts). The hub reads:

    • the holder files the apps' servers write, $XDG_RUNTIME_DIR/lazurio-browser/holders/<strip>.json. The file name must match strip, only target ids count, and size limits apply;
    • agent-browser's bindings, which keep a tab only while the session's daemon runs (<session>.pid names a live process). A binding alone no longer counts, because agent-browser keeps it past the daemon on purpose.

    Both directories are watched and re-read every 30 s. Reads fail closed (Pablo's review of 0d0cb87). Only a directory or file that does not exist reads as holding nothing. Any other failure (permission, I/O), and a holder or binding file that is not one, throws, so the hub keeps its last reading and no tab ends. A session counts as ended only when its .pid is gone or names a well-formed process id (^[0-9]+$, ≥ 2) that no longer runs; an empty, blank, zero, signed or otherwise unclear .pid counts as working.

  • The hub decides for every page target (hub.ts):

    • a held tab never ends for lack of viewers;
    • an unheld, unviewed tab ends after 8 s when its last hold went (the wireframe's Vrátit window), 30 s after its last viewer (F39), and 10 minutes when nothing ever viewed or held it;
    • before closing, the holds are read again, and a failed read keeps the tab.
  • The hub's blank window. The unit restarts Chrome when its last window closes. So the hub keeps Chrome's first about:blank window (or opens one), lists it nowhere and never closes it. When someone navigates it, it becomes a normal tab and the hub opens a new blank one.

  • The list of live tabs (Pablo's findings on Docs: F47 designs Nová karta, the holders of a remote tab and the Environment's overview #314). The hub writes $XDG_RUNTIME_DIR/lazurio-browser/tabs.json:

    • each list is a fresh Target.getTargets snapshot, one at a time;
    • it is stamped with the boot-clock time it began (boot_id + /proc/uptime), never the wall clock;
    • snapshots are taken after each discovery, whenever a target appears or ends, and every 30 s while connected; with no connection nothing is written.

    keptTabs(stored, list, boot) is the apps' reconciliation rule (steps 2 and 4). An id goes only when a list of the current boot, taken after its record, lacks it. A record from an earlier boot, or one without a time, is older than every list of this boot, and a list of another boot is ignored. readBootClock gives the time.

  • Loopback requests for the Launchpad (service.ts): GET /.lazurio/tabs (facts per tab: opener, holders, working agents, grace, closing), POST /.lazurio/tabs {url, strip}, POST /.lazurio/tabs/<id>/close (everyone, 8 s) and .../restore. Through the gateway (the view's own host) they answer 404. Any request with Origin or Sec-Fetch-Site gets 403, because pages in the Environment's own browser reach the loopback too. While the hub does not know the browser's targets, close and restore answer 503; a close accepted before goes on after the reconnect.

  • The view page posts lazurio-browser:closed to its app when its tab ends.

  • lazurio browser serve creates the holder directory (0700), watches it and agent-browser's socket directory, and writes the list.

  • F47 gets a "Step 1, implemented" note.

What does not change

  • No app writes holder files yet. Steps 2 (Launchpad) and 4 (T3 Code) do.
  • No Folder text and no template revision. The manual's tab_gone recovery already covers a window that ended.
  • No release in this PR.

Behaviour change to know before release

Until T3 Code writes its holder files (step 4), a T3 Code thread's window stays while its agent works in it or someone views it. Otherwise it ends about an hour after the agent's last command (agent-browser's idle timeout + 8 s). The agent's next command then answers tab_gone, and lazurio browser window opens a new one. Before, such windows never closed. I'd release this together with T3 Code's step 4 rather than alone.

Verification

  • bun run check on this head: 1937 pass, 7 skip, 0 fail, static checks included (biome, tsc, check:public, build/smoke proofs). The watcher test writes until the watch sees a change, because on macOS a watch can miss the instant it starts; the hub's 30 s read covers that in production.
  • New view tests (FakeBrowser):
    • an agent's tab stays while it works and ends after the release grace once its daemon ends;
    • a strip's tab survives without viewers; a hold within the grace keeps it;
    • after a start, unheld tabs get 10 minutes;
    • a failed holds read lets nothing go;
    • the blank window is adopted, not listed, replaced when used, and created when missing;
    • loopback-only and browser refusals (pure classifyRequest);
    • the list's facts;
    • close for everyone plus Vrátit, also across a lost browser connection;
    • the live list is written on create and close, not during a disconnect, replaced after a browser restart, and written again on the 30 s beat;
    • a live tab whose targetCreated has not reached the hub is in the list (snapshot);
    • an unreadable holder directory (chmod 000, the real reader) lets no tab go until a successful read shows the release;
    • while the browser is away, close and restore answer 503 and nothing changes;
    • an agent with an empty .pid keeps its tab past every grace (the real reader); a .pid of an ended process then lets it go;
    • snapshots stay stamped by the boot clock when the wall clock steps back an hour.
  • New unit tests (tests/environment-browser-holds.test.ts):
    • parsing and limits of holder files;
    • fail-closed reads: an unreadable directory, an unreadable holder file, a broken holder file and a broken binding file each throw;
    • .pid: "", " ", "0", "-1", "1", "+5", "4.2", an 11-digit id and "x" count as working; a live well-formed id is working, an ended one or a missing file is not;
    • joining holder files with live and ended daemons;
    • missing directories;
    • processAlive;
    • the watcher, including a directory created later;
    • atomic 0600 writes of the list;
    • readBootClock and parseBootTime;
    • keptTabs: a later list of this boot drops, a later record keeps, an earlier boot's record goes, another boot's list is ignored, the ended-before-record case, and a wall-clock step.

Risks

🤖 Generated with Claude Code

@immakermatty
immakermatty force-pushed the agent/DEV-6656-917-hub-holds branch 2 times, most recently from 8c96c8c to 0d0cb87 Compare October 11, 2026 16:20
@immakermatty
immakermatty changed the base branch from agent/DEV-6656-917-new-tab-design to main October 11, 2026 16:20

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty — exact-head QA of 0d0cb87bbd2fddaea09aafa4b04f382064a2fbb2 (F47 step 1). CHANGES_REQUESTED.

  1. [P1] Fail closed when reading holders/bindings fails (src/browser/people/holds.ts:261-266,278-300; hub.ts:1448-1476). readHolds turns every readdir error into an empty directory and individual readFile errors into missing holds. Thus the hub's promised final re-read succeeds with zero holds on EACCES/I/O failure, runs the 8-second release grace, and sends Target.closeTarget for a tab actually held by the Launchpad/T3 or a live agent. Reproduced on this SHA: a holder initially listed the target; after chmod 000 the directory returned EACCES, readHolds returned zero holders, and a BrowserHub fixture closed the held target (initial held=1, closed=0 -> after EACCES held=0, closed=[target]). Only true ENOENT/missing directories should mean empty; other read errors must propagate or preserve the tab, including the final pre-close read. Add a regression test with an unreadable directory / holder file and assert no close until a successful fresh read establishes release.

  2. [P2] Disconnected close/restore contradict the F47 failure contract (src/browser/people/service.ts:322-333, hub.ts:425-460; docs/decisions.md:7151). GET tabs returns 503 while disconnected, but if a viewer keeps a tab in memory after connection loss, POST close returns 202 and POST restore returns 200 instead of the specified 503. Reproduced with a retained viewed tab: connected=false, close returned a deadline, restore returned true. Gate both mutations on hub readiness (without losing closes already accepted before the disconnect), and test this path.

Focused verification on the immutable checkout: 55 pass, 0 fail across environment-browser-holds and environment-browser-view; git diff --check clean. The passing tests do not cover the two failure paths above. No merge or push performed.

@immakermatty
immakermatty force-pushed the agent/DEV-6656-917-hub-holds branch from 0d0cb87 to 91125f8 Compare October 11, 2026 16:40
@immakermatty

Copy link
Copy Markdown
Contributor Author

Both fixed in 91125f8; re-review requested on that head.

  1. Fail closed (holds.ts readHolds). Only ENOENT reads as nothing: a missing directory, or a file removed between the listing and the read. Everything else throws:

    • any other readdir/readFile error (EACCES, EIO);
    • a <strip>.json that is not a valid holder;
    • a .target that is not JSON.

    refreshHolds then keeps the last reading, and the pre-close read in closeIfUnused keeps the tab. An unclear .pid counts as working. App temporary files (no <strip>.json name) are skipped before reading.

    • New hub test with the real reader: a holder directory set to chmod 000 lets no tab go past the release grace. Once readable again with the release written, the tab ends.
    • New unit test: an unreadable directory, an unreadable holder file, a broken holder file and a broken binding file each reject.
  2. 503 while disconnected (service.ts). close and restore answer 503 while !hub.ready. A close accepted before the disconnect keeps its entry and finishes after the reconnect (the existing test). New test: a retained viewed tab, browser dropped, both answer 503 and nothing is closing.

bun run check: 1935 pass, 0 fail. I also made the watcher test write until the watch sees a change: on macOS a watch can miss the instant it starts, and the hub's 30 s read covers that.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty — exact-head re-review of 91125f8 (F47 step 1): CHANGES_REQUESTED. The prior unreadable-holder and disconnected close/restore findings are addressed; the two focused test files pass (58 pass, 0 fail), and git diff --check is clean.

[P1] An unclear .pid still releases a working agent tab (src/browser/people/holds.ts:327-330). Number(pidText.trim()) converts an empty or whitespace-only file to 0, and "0"/"-1" are accepted as integer PIDs; processAlive returns false for them. In an isolated fixture with a valid codex-1.target binding, readHolds returned bound=["codex-1"], working=[] for each of "", " ", "0", and "-1", whereas "not a pid" correctly returned a working session. The hub therefore starts the release grace and its final read can close this agent tab despite an indeterminate PID file. Treat empty/invalid/nonpositive PID text as unclear/working, only a well-formed positive PID proven dead as released; add regression cases for empty/whitespace/0/negative values (including the no-close hub path).

The live CI on this head is also red: tests (2/2) fails in tests/tools-team-github.test.ts (profile update expected 200/updated but returned 400/operation-failed); check (ubuntu-24.04) is consequently failed. It may be unrelated to F47, but must be resolved or rerun green before merge. No merge or push performed.

@immakermatty
immakermatty force-pushed the agent/DEV-6656-917-hub-holds branch from 91125f8 to b840390 Compare October 11, 2026 16:57
@immakermatty

Copy link
Copy Markdown
Contributor Author

Fixed in b840390; re-review requested on that head. readHolds now counts a session as ended only when its .pid is gone or names a well-formed process id (^[0-9]{1,10}$, ≥ 2) proven not running. Everything else counts as working and keeps the tab: empty, whitespace, 0, -1, 1, +5, 4.2, an 11-digit id, or any other text.

New tests:

  • unit: each of those values is working; a live well-formed id is working; an ended id or a missing .pid is not;
  • hub, with the real reader: a binding with an empty .pid keeps its tab past the release and unviewed graces (40/60 ms); writing a well-formed id of an ended process then ends it.

bun run check on this head: 1937 pass, 0 fail.

About the red CI on 91125f8: tests/tools-team-github.test.ts ("a gh login started on a Work Environment stops when a profile change makes it a Team one") belongs to the tools login, not this PR. It passed on every earlier head of this branch (#316: d06a604, 8c96c8c, 0d0cb87) and on main 342db2f, and it passes 5/5 locally on 91125f8. This push reruns CI. If it fails again, I'll open an issue for it with the log.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty — exact-head re-review of b840390 (F47 step 1): CHANGES_REQUESTED for the required CI gate only. The previous P1 is fixed: malformed/empty .pid remains working, and the real-reader hub test covers the no-close path. In an isolated checkout the two focused test files pass (60 pass, 0 fail); git diff --check is clean. The base remains main 342db2f, with no unresolved review threads.

The live tests (2/2) check failed again on this exact head, in the unchanged tests/tools-team-github.test.ts case "a gh login started on a Work Environment stops when a profile change makes it a Team one": /api/update expected 200/updated but got 400/operation-failed (recoveryMayBeRequired: true). The required check (ubuntu-24.04) therefore failed too. I do not attribute this failure to F47, but cannot approve a red required CI gate. Please capture the log in the issue you proposed, investigate/fix or rerun to green on this exact head, then request another exact-head review. No merge or push performed.

immakermatty added a commit that referenced this pull request Oct 11, 2026
… failing busy (#272) (#317)

Since #270 the shell setup cache starts a background read right after a
profile change, under the Folder's operation lock. A person's next change, or
a tools change after a sign-in, then found the lock held and answered
400 operation-failed with recoveryMayBeRequired, although it would have
succeeded a moment later. The same race failed CI often enough on 2026-10-11
to block merges (main's rerun and PR #316, three times).

withFolderOperationLock now waits, at most 3 s, while the holder is this
process's own short read (withFolderReadLock counts its holds per state
directory until the lock is released). Another process's holder, or this
process's own mutation, is still refused at once, so the kernel lock stays
exclusive and a mutation from elsewhere keeps the immediate refusal.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rking agents (F47 step 1)

Decision F47 points 4-6 (root decision 0195 points 9-12). The view's hub now
decides for every page target, not only the tabs it opened:
- held while a holder file in $XDG_RUNTIME_DIR/lazurio-browser/holders lists it
  or an agent-browser session bound to it has a live daemon (a binding alone no
  longer counts: agent-browser keeps it past the daemon);
- unheld and unviewed tabs end after 8 s when their last hold went (Vrátit), 30 s
  after their last viewer, 10 minutes when nothing ever viewed or held them; the
  holds are read again before a tab is closed, and a failed read keeps it;
- one blank window of the hub's own stays, because Chrome ends with its last
  window and the unit restarts it.

The hub writes the ids of the live tabs to $XDG_RUNTIME_DIR/lazurio-browser/
tabs.json from a fresh Target.getTargets snapshot, stamped with the boot-clock
time it began (boot id and /proc/uptime, never the wall clock), after each
discovery, on change and every 30 s while connected, one at a time. keptTabs is
the apps' reconciliation rule: an id goes only when a list of the current boot
taken after its record lacks it. The hub answers GET/POST /.lazurio/tabs and
POST /.lazurio/tabs/<id>/close|restore on loopback only, refusing them through
the gateway and to any request with Origin or Sec-Fetch-Site. The view page
tells its app lazurio-browser:closed when its tab ends.

This changes F39 point 9: an agent's window ends once no agent works in it and
nothing holds or views it, and after a restart unheld tabs get 10 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@immakermatty
immakermatty force-pushed the agent/DEV-6656-917-hub-holds branch from b840390 to a2cd44d Compare October 11, 2026 17:49
@immakermatty

Copy link
Copy Markdown
Contributor Author

Rebased onto main 1cd5818, which has #317 (the fix for #272, the profile-update race that failed this PR's CI 3 times and a rerun of main). New head a2cd44d; the step-1 code is unchanged since your review of b840390. Only lock.ts and its tests come from main underneath.

CI on a2cd44d is green. The first attempt of tests (2/2) timed out once in tests/shell-artifact.test.ts ("the same inputs give the same archive…", 60 s, under 4 workers); the rerun of the failed jobs passed. main 1cd5818's own run is also green, Linux and macOS.

@agentrozjedemeai agentrozjedemeai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@immakermatty — APPROVED on exact head a2cd44d (base 1cd5818). The F47 step-1 patch is unchanged from my b840390 review (matching stable patch-id and identical source/tests); the only delta brings in #317 from main, addressing the earlier unrelated race. Isolated checkout: 60 focused tests passed, 0 failed (bun --smol, max concurrency 1), and git diff --check is clean. The exact-head Platform checks rerun is green, including static checks, both test shards and required Ubuntu aggregate; main CI on 1cd5818 is green on Linux and macOS. The first run had one 60 s timeout in shell-artifact under four workers; the rerun passed. No unresolved review threads or pending competing requests. No merge performed.

@immakermatty

Copy link
Copy Markdown
Contributor Author

Thanks, approved on a2cd44d. I'm not merging this yet, on purpose. Matěj decided on 2026-10-11 to release step 1 together with T3 Code's task 501 (HumanAndMachine-ai/mission-control-data#329). Until T3 writes its threads' holder files, a T3 thread's window would end about an hour after its agent's last command whenever nobody views it.

Platform release candidates are cut from main by other threads too, so merging now could ship that early. Steps 2 and 3 stack on this branch. I'll merge 1 → 3 when T3's release is ready, re-requesting exact-head reviews after the rebases.

@immakermatty

Copy link
Copy Markdown
Contributor Author

Merged through #333 by rebase, on Matěj's instruction "Publikuj a releasuj". This PR's commit is edf58c0 on main, patch-identical to a2cd44d per git range-diff. The repository doesn't allow merge commits on main, so the approved stack was merged as one rebase.

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.

2 participants