feat(sdk): Phase 2b — filesystem clients, watch, waitForPort, waitForLog (CORE-58) - #584
Conversation
stat/list/mkdir/remove/move on sandbox.files, mapping the wire FileStat to numbers/Date DTOs. mkdir is always mkdir -p (the daemon exposes no non-recursive variant); a missing path surfaces as FileNotFoundError with the path in context, and a non-recursive remove of a non-empty directory keeps the daemon's FAILED_PRECONDITION routing.
files.watch(path, {recursive}) yields typed FsEvents from WatchDir:
renames arrive paired (old path + renamedTo), keepalives are filtered,
and the daemon's clean end (sandbox stop) terminates the iterator. A
mid-stream transport drop is ConnectionLostError with no auto-reconnect
— watch events cannot be replayed — while daemon-typed errors, notably
the inotify-overflow re-list-and-re-watch guidance, keep their class.
commands.list() maps ListExecutions rows to CommandInfo summaries
(id/tty/state/timestamps, exit-as-data with the 128+signal convention)
so lost handles are rediscoverable via commands.get(). The new
sandbox.ports namespace carries waitForPort(port, {timeoutMs}): the
guest watches its own listen table, and a deadline expiry — the
daemon's DEADLINE_EXCEEDED or the client's grace bound — surfaces as a
TimeoutError naming the waitForPort knob, never the per-RPC one.
handle.waitForLog(pattern, {timeoutMs}) resolves with the first log
line matching a substring or regex — purely SDK-side, per the design
doc: the offset-addressed output replays from the start through the
Phase 2a resumable attach loop, so pre-call lines match immediately and
transport drops resume seamlessly. Matching is line-oriented with
per-channel streaming decoders (chunk-split lines and UTF-8 boundaries
are safe; memory bounded by the longest line). The deadline aborts the
attach and surfaces as a TimeoutError naming the waitForLog knob; exit
without a match is a typed non-timeout error.
stat/list/mkdir/remove/move on sandbox.files (async tree + regenerated sync twin), mapping the wire FileStat to a frozen dataclass with int/datetime fields. All file methods now accept PurePosixPath as well as str. mkdir is always mkdir -p; a missing path raises FileNotFoundError with the path in context, and a non-recursive remove of a non-empty directory keeps the daemon's FAILED_PRECONDITION routing.
files.watch(path, recursive=) returns an AsyncFileWatch/FileWatch pair (context-manager form for early exit; sync reads genuinely block) yielding typed FsEvents from WatchDir: renames arrive paired (old path + renamed_to), keepalives are filtered, and the daemon's clean end (sandbox stop) terminates the iterator. A mid-stream transport drop is ConnectionLostError with no auto-reconnect — watch events cannot be replayed — while daemon-typed errors, notably the inotify-overflow re-list-and-re-watch guidance, keep their class.
commands.list() maps ListExecutions rows to CommandInfo summaries (id/tty/state/timestamps, exit-as-data with the 128+signal convention) so lost handles are rediscoverable via commands.get(). The new sandbox.ports namespace (async tree + regenerated sync twin) carries wait_for_port(port, timeout=): the guest watches its own listen table, and a deadline expiry — the daemon's DEADLINE_EXCEEDED or the client's grace bound — surfaces as a TimeoutError naming the wait_for_port knob, never the per-RPC one.
handle.wait_for_log(pattern, timeout=) returns the first log line matching a substring or compiled regex — purely SDK-side, per the design doc: the offset-addressed output replays from the start through the Phase 2a resumable attach loop, so pre-call lines match immediately and transport drops resume seamlessly. Matching is line-oriented with per-channel byte buffers (chunk-split lines are safe; memory bounded by the longest line). The deadline is observed per stream frame (keepalive cadence bounds the lag; the sync tree genuinely blocks, so this is the honest semantics for both flavors) and surfaces as a TimeoutError naming the wait_for_log knob; exit without a match is a typed non-timeout error.
Both live-daemon loops now drive the mkdir/write/stat/list/move/remove roundtrip (with the FILE_NOT_FOUND and non-recursive-remove guards), a recursive watch observing a marker write, waitForPort against a background nc listener (plus the no-listener timeout), commands.list rediscovery, and waitForLog catching a delayed echo while the command keeps running. Both README feature tables drop the 2b-deferred rows; Template statics remain the only deferral.
|
Your Claude subscription has hit its usage limit. It resets at 12am (UTC). Re-trigger Pullfrog after the reset, or add an Add repo secret → · Model settings → · Setup docs → · Ask in Discord →
|
|
Your Claude subscription has hit its usage limit. It resets at 12am (UTC). Re-trigger Pullfrog after the reset, or add an Add repo secret → · Model settings → · Setup docs → · Ask in Discord →
|
Greptile SummaryThe PR completes the Phase 2b SDK surface in both Python and TypeScript.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the previously reported explicit-zero timeout collision is prevented at each SDK boundary before any request is sent, while fractional rounding is documented intentional behavior.
|
| Filename | Overview |
|---|---|
| sdk/python/src/arcbox/_async/ports.py | Adds asynchronous port readiness waiting with boundary validation, daemon-budget clamping, and timeout-error normalization; the prior explicit-zero issue is fixed. |
| sdk/python/src/arcbox/_sync/ports.py | Provides the generated synchronous equivalent of port readiness waiting and its corrected timeout validation. |
| sdk/typescript/src/ports.ts | Adds the TypeScript ports namespace with pre-transport timeout validation and documented whole-second wire conversion. |
| sdk/python/src/arcbox/_async/files.py | Adds typed filesystem verbs and non-replayable asynchronous filesystem watch handling. |
| sdk/typescript/src/files.ts | Adds TypeScript filesystem metadata, mutation, and watch surfaces. |
| sdk/python/src/arcbox/_async/commands.py | Adds command listing and resumable, line-oriented wait-for-log support. |
| sdk/typescript/src/commands.ts | Adds command discovery and wait-for-log behavior to the TypeScript SDK. |
| sdk/python/tests/test_list_port.py | Verifies invalid port-wait budgets are rejected without issuing transport requests. |
| sdk/typescript/test/list-port.test.ts | Covers port-wait request mapping, timeout handling, and corrected invalid-budget rejection. |
Reviews (7): Last reviewed commit: "test(sdk): pin the inf-disables-the-boun..." | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 00e37ffa0d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ention docs waitForPort/wait_for_port now reject zero, negative, NaN, and infinite budgets at the boundary: 0 silently collided with the wire's use-the-default sentinel (a 30 s wait instead of an immediate check). Python wait_for_log checks its deadline before processing each frame and before the exit flush, so a frame landing past expiry cannot flip a timeout into a late success — matching the strictness TS gets from aborting the attach. commands.list docs now state the real retention: exited executions are dropped five minutes after exit (EXITED_RETENTION in virt/arcbox-vm), not kept for the sandbox's lifetime.
There was a problem hiding this comment.
ℹ️ Three cross-SDK parity nits, all non-blocking. No defects found.
Scope note. The push delta since the last synchronize (00e37ff..35f34a9) is lint-only — a noop import, a loop-condition reorder, and an extracted watched binding in sdk/typescript/test/e2e.test.ts. But this PR has never had a Pullfrog review (only greptile-apps and chatgpt-codex-connector), so I read the full diff end-to-end rather than just the delta: 10 commits, 28 files, +3259/-58.
The 2b surface lands cleanly and the two SDKs track each other closely. Worth calling out as verified rather than assumed:
- Both scanners handle a multi-byte UTF-8 sequence split across chunk boundaries — TS via a per-channel
TextDecoderwith{ stream: true }, Python by buffering bytes per channel and decoding only complete lines. TS also resetspattern.lastIndex, so a global/sticky regex doesn't skip lines. - Open proto3 enums have
"unknown"fallbacks on bothFileKindandFsEventKind, with tests feeding99through each. scripts/gen_sync.pypicked up both new classes (AsyncFileWatch,AsyncPorts), so the generated sync tree isn't silently stale.- The tests assert exact values (
stat.size == 8,stat.mode == 0o644,(137, "SIGKILL"), fullcontextdicts) rather than shape — real coverage.
The three items below are all places where the two SDKs answer the same wire situation differently. None blocks the merge; the deadline one (first comment) is the only one I'd want a decision on rather than a shrug.
Claude Opus | 𝕏
…ssification wait_for_log's deadline now also bounds a stream that stops producing frames entirely: the shared attach loop takes an optional deadline and caps the connect phase and every read gap at the remaining budget, so a wedged guest cannot outlive the caller's timeout (the expired read drains through the retry budget into the deadline translation). Plain output/wait_for_exit attaches stay unbounded — long-lived streams must not time out on idle. Python's watch and events streams now classify a zero-frame failure the way TypeScript does: the delivered flag flips on the first frame (keepalives included), so an unavailable end with nothing delivered is the unreachable-daemon error, while anything after frames flowed stays ConnectionLostError.
There was a problem hiding this comment.
Reviewed changes
-
Verified all three round-1 parity findings against the code rather than against the reply text: the
wait_for_logdeadline now bounds a silent stream,files.watchclassifies a zero-frame drop the way TS does, andwaitForPortrejects nonsensical budgets in both SDKs. -
Traced the new attach read-gap plumbing from
wait_for_logthrough_attach_eventsandServerStreamintohttpx.Timeout, checking which exception types survive each hop. -
Checked the daemon and guest sources against the docstrings this round added — the
WatchDirimmediate keepalive and the five-minute exited-execution retention are both real. -
Confirmed the round-2 tests fail without their fixes, including the one that reads back the recorded per-request
readtimeout.
ℹ️ One follow-on from the round-2 fix, inline. Non-blocking — the three round-1 threads are genuinely closed.
Scope note. incrementalDiffPath covered 2bc44cb..2302fb9 only, which skips 2bc44cba even though the prior Pullfrog review sat at 35f34a92. I reviewed the real delta, 35f34a92..2302fb92 — 15 files, +296/-69, commits 2bc44cba ("review round 1") and 2302fb92 ("review round 2").
Both rounds went further than the review asked, and the extra reach is the part worth calling out:
-
Fix 2 was applied to
_stream_eventsas well as_stream_watch._stream_eventsis a Phase 2a surface nobody flagged, and it carried the same headers-based flag — so the fix closed a divergence that was never reported. -
The
commands.list()retention docstring is now specific ("five minutes guest-side"), and it matchesEXITED_RETENTION = Duration::from_secs(5 * 60)invirt/arcbox-vm/src/sandbox/execution.rs:25. Thedelivered-on-first-frame reasoning is likewise backed by the guest:write_watch_keepaliveruns before the event loop (guest/arcbox-agent/src/sandbox/files.rs:225,294), so aWatchDirstream really does confirm itself with an immediate frame. -
test_the_attach_read_gap_is_bounded_by_the_remaining_budgetasserts onrequest.extensions["timeout"]["read"]— it pins the plumbing at the transport boundary rather than at the SDK's own abstraction, which is what makes it able to fail.
One asymmetry I checked and am deliberately not raising as a defect: Events has no immediate keepalive (with_keepalive only fires after 15 s idle, app/arcbox-api/src/connect/mod.rs), so an established _stream_events subscription that drops inside that window is now reported as ConnectionFailedError — "daemon unreachable" — even though the HTTP 200 proves it was reached. Blast radius is small since ConnectionLostError subclasses ConnectionFailedError, and this is the direction the round-1 review recommended; flagging it now would just be flip-flopping. Recording it so it reads as a known trade-off rather than an oversight.
Claude Opus | 𝕏
…dline Routing the deadline into httpx's read timeout made non-finite budgets escape as raw socket errors (OverflowError for inf, ValueError for NaN — neither typed by the boundary). wait_for_log now rejects NaN with InvalidArgumentError and treats math.inf as bound-disabled, matching the TS flavor's POSITIVE_INFINITY semantics; -inf is simply an already expired deadline.
There was a problem hiding this comment.
Reviewed changes
The delta since the round-2 review at 2302fb92 — two commits, 7 files, the waitForPort docstring and the wait_for_log NaN/infinity fix.
-
896a68bb(docs only) adds the fractional-budget sentence towaitForPort/wait_for_portin both SDKs. Checked it against the code rather than taking it at face value:math.ceil(timeout)andMath.ceil(timeoutMs / 1000)do round up, so a sub-second budget can never land on the reserved0— the sentence is accurate. -
d95e3f9dfixes the round-2 finding. NaN is rejected insidewrap_errors, so the raisedInvalidArgumentErrorcarries thecommands.wait_for_logoperation stamp the new test asserts on;math.infmaps todeadline = None, which is the disabled bound rather than a bound of infinity. Both the async source and the generated sync twin carry the fix and the two new imports (math,InvalidArgumentError), sogen_syncwas re-run.
Checked rather than assumed:
-
I measured the
socket.settimeoutoverflow boundary on this box instead of reasoning about it: it raisesOverflowError: timestamp out of range for platform time_tabove ~9.2e9 seconds, not theOSError/EINVAL one might expect. That leaves Python without TS's2**31 - 1ceiling on a finite budget — but the gap only opens at ~292 years, so a guard for it would be bloat. Recording it, not raising it. -
All four prior Pullfrog threads were re-read against the current code and are genuinely closed; the author had already resolved each one, so there was no reply work this round.
-
I am deliberately not re-raising the
wait_for_logtimeout <= 0/-infdivergence from TS. The author answered it explicitly on the resolved_async/ports.py:46thread —wait_for_loghas no wire sentinel to collide with, so a ≤0 budget there means "already expired", consistent withwait_for_exit's deadline arithmetic. That is a settled design decision, and the-inf → TimeoutErrorassertion codifies it on purpose.
One nitpick inline on the new test. Non-blocking — the fix itself is correct and complete for what it set out to do.
ℹ️ Nitpicks
See the inline comment on sdk/python/tests/test_wait_for_log.py.
Claude Opus | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
The delta since the round-3 review at d95e3f9d is a single test-only commit — 3a6bec41, one file, +30/-14, all in sdk/python/tests/test_wait_for_log.py. It closes the round-3 nitpick.
-
Hoisted the recorder: the previously function-nested
Recordersubclass plus its module-localread_timeoutslist became a module-levelRecordingAttach(FlakyAttach)with a per-instanceread_timeouts. Both deadline tests now share it, andtest_the_attach_read_gap_is_bounded_by_the_remaining_budgetreads its own instance's list rather than a list shared across three daemons — the unbounded case tightened fromread_timeouts[-1] is Noneto the exactunbounded.read_timeouts == [None]in the process. -
Verified the new
infassertion is actually fix-sensitive, which is precisely what round 3 said the old one was not. Traced it through rather than trusting the commit message: pre-fix,timeout=float("inf")gavedeadline = inf→read_timeout = max(inf - now, 0.05) == inf→httpx.Timeout(inf)→request.extensions["timeout"]["read"] == inf, soassert daemon.read_timeouts[-1] is Nonefails. Post-fix,timeout == math.infmaps todeadline = None,_attachis handedread_timeout=None,ServerStreampassestimeout=Nonetohttpx.Client.stream, and the extension carriesNone. The assertion now pins themath.inf → deadline = Nonebranch specifically. -
assert daemon.read_timeouts == []is a genuine addition too: it pins that the NaN rejection happens at the boundary insidewrap_errors, before any dial reaches the transport, rather than merely raising at some point during the call.
Checked rather than assumed:
-
The recurring scope caveat from rounds 2 and 3 did not recur —
git diff --stat d95e3f9d..3a6bec41matches the supplied incremental diff exactly (1 file, 30 insertions, 14 deletions), so the incremental base and the prior review head agree this round. I still read the authoritative full diff end-to-end; nothing outside the delta changed disposition. -
All four prior Pullfrog threads across rounds 1–3 were re-read against the current code and are genuinely closed; the author had already resolved each, so there was no reply work this round.
-
Considered and dropped one consistency nit: line 231 uses
read_timeouts[-1] is Nonewhere its neighbour at line 250 uses the exact== [None]. Withdie_afterunsetFlakyAttachserves exactly one dial per handle, so the two are equivalent here — raising it would be a style preference dressed as a finding.
Claude Opus | 𝕏

Summary
CORE-58 Phase 2b — the final SDK surface gap. Client support in both SDKs for everything #582 implemented daemon-side, following the Phase 2a (#583) patterns throughout.
Filesystem path verbs —
files.stat/list/mkdir/remove/moveFileStat→ idiomatic DTOs: TS numbers/Date, Python frozen dataclass withint/datetime; kinds map to"file" | "directory" | "symlink" | "other"with"unknown"future-proofing.str | PurePosixPathacross allfilesmethods (a hostPathqualifies on POSIX).mkdiris alwaysmkdir -p— the daemon exposes no non-recursive variant (filesystem.proto), so noparentsknob is offered;modeis (0 = daemon default 0755, mirroring the write-mode convention).FileNotFoundErrorwithpathin context (ErrorInfoFILE_NOT_FOUND); a non-recursive remove of a non-empty directory keeps the FAILED_PRECONDITION routing (SandboxStateError).files.watch(path, {recursive})— WatchDir streamingFsEvent(created/modified/removed/renamed); renames arrive paired in one event (path= old,renamedTo/renamed_to= new).SandboxFileWatchEnd(sandbox stop) terminates the iterator normally.ConnectionLostError, no auto-reconnect — watch events cannot be replayed (same rationale asevents()).AsyncFileWatch/FileWatchpair (context-manager form for early exit; sync reads genuinely block, likeEventStream).commands.list()— ListExecutionsCommandInfosummaries (id/tty/state/timestamps, exit-as-data with the Phase 2a128 + signalconvention;errorfor executions that ended without an observed exit). The rediscovery path: pick an id,commands.get(id)for a live handle.ports.waitForPort(port, {timeoutMs})/ports.wait_for_port(port, timeout=)portsnamespace over the WaitForPort RPC (guest listen-table watch; no client polling). 0 on the wire = daemon default 30 s; the daemon's 600 s cap is mirrored client-side for the grace deadline (effective + 5 s, exempt from the per-RPC knob likewaitForExit).TimeoutErrornaming the waitForPort knob, mirroringconnect()'s discipline (neverRequestTimeoutError's wrong suggestion).handle.waitForLog(pattern, {timeoutMs})/wait_for_log(pattern, timeout=)ArcBoxError. Deadline →TimeoutErrornaming the knob. TS aborts the attach with anAbortSignal(exact); Python observes the deadline per stream frame (keepalive cadence bounds the lag — the honest semantics for a genuinely-blocking sync tree, kept identical in the async twin), and an exhausted re-attach budget past the deadline resolves to the timeout, not stream death.Tests
nc -l(plus the no-listener timeout),commands.listrediscovery, and waitForLog catching a delayed echo while the command keeps running.Live e2e (VZ, isolated daemon, hardware)
cargo test -p arcbox-e2e --test sdk_ts -- --ignored: green — vitest 4/4 (incl. the new 2b test), 12.7 s.cargo test -p arcbox-e2e --test sdk_py -- --ignored: green — pytest 6/6 (incl.test_sync_phase_2b_surface+test_async_watch_observes_a_write), 13.0 s.Gates (exit 0 on every commit)
lint/format:check/test/typecheck/buildruff check/ruff format --check/pyright/gen_sync.py --check/pytestBoth README feature tables updated;
Templatestatics remain the only deferral.