Fix #2323: --cursor-file on wait/wait-loop closes wait→process→wait race - #2326
Conversation
Multi-producer reviewer phases (plan: 3 producers) stalled by 20-30 min when CONSENSUS_PROPOSE landed in the gap between the previous wait-loop returning and the next one entering. The new call's from_tip semantics (issue #1925 default, by design) skipped the already-on-bus event, forcing a fallback to manual `message poll` after burning the inner timeout. `wait-loop` already threads cursors internally between its own inner iterations, but each new CLI invocation is a separate process and the cursor was lost across re-entries — `wait-loop` exposes no `--json` mode for shell callers to extract it. Add `--cursor-file PATH` to `egg-orch message wait` and `wait-loop`: read on entry as the default for `--since`, written back on every successful round-trip. Update reviewer POLL + STAY ALIVE and producer STAY ALIVE prompts to use it. Best-effort I/O — write failures log a warning but never fail the wait. Opt-in flag; existing callers see zero behavior change.
This comment has been minimized.
This comment has been minimized.
Autofix tracking{"Lint/Python": 3} |
This comment has been minimized.
This comment has been minimized.
…ypes hash
The flag was opt-in for no good reason. The wait→process→wait race
exists at every wait-loop re-entry site, so making callers opt in just
means future call sites silently reintroduce the bug.
Always-on now, with the path derived as
${EGG_WAIT_CURSOR_DIR:-/tmp}/egg-wait-cursor-<role>-<md5(sorted_for_types)>
- Sorted-types hash means `--for X --for Y` and `--for Y --for X`
share a cursor (set semantics).
- Distinct `--for` sets get distinct files automatically — POLL and
STAY ALIVE can't cross-contaminate.
- No EGG_AGENT_ROLE (debug shells) → no cursor handling. Legacy
from-tip behavior preserved.
- EGG_WAIT_CURSOR_DIR override exists for tests / unusual deployments.
Prompts no longer pass --cursor-file (it's automatic) but still mention
the path so operators debugging a stuck reviewer know where to `cat`.
This comment has been minimized.
This comment has been minimized.
Add type annotations to _setenv helpers in TestAutoCursorWait and TestAutoCursorWaitLoop, fix _expected_path return type by asserting non-None, and add assert-not-None guards before open() calls on paths from _wait_cursor_path which returns str | None. Co-Authored-By: egg <egg@localhost>
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Reviewed all 5 files (docs/reference/agent-wait-patterns.md, orchestrator/routes/pipelines.py, orchestrator/tests/test_pipeline_prompts.py, sandbox/egg_lib/orch_cli.py, sandbox/tests/test_message_wait_cli.py). Traced cursor flow CLI → handler → message store, ran the new TestWaitCursorPath / TestAutoCursorWait / TestAutoCursorWaitLoop / TestReviewerWaitLoopMentionsAutoCursor classes locally — all 22 pass.
The fix targets the stated root cause correctly: the wait→process→wait gap that issue #2323 documents. Auto-deriving the path from (role, sorted(for_types)) is the right choice — it eliminates the prompt-side coordination cost and makes the new POLL/STAY-ALIVE distinction collision-free by construction. Error-path handling (rc=2/3 leave the file alone; rc=0/1 atomically rewrite via tmp + os.replace) is correct and tested.
No blocking issues. The findings below are non-blocking robustness / hygiene improvements that would be cheap to address.
Non-blocking issues
1. pipeline_id is missing from the cursor file path (orch_cli.py:1279-1309)
The path is keyed on (role, for_types_hash) only — not pipeline_id. If a sandbox container ever runs more than one pipeline against the same /tmp (debug shells, integration test reuse, sequential pipelines on a shared host), a cursor from pipeline A leaks into pipeline B. The downstream effect is real: message_store.get_messages (orchestrator/message_store.py:285-296) treats an unknown since_id as "return full history" with a warning log — meaning B's first wait re-receives every message in B's stream from time zero, and from_tip protection is bypassed.
In production single-pipeline-per-container the scenario is theoretical, but the fix is a one-liner — fold pipeline_id into the path:
return os.path.join(base, f"egg-wait-cursor-{pipeline_id}-{role}-{digest}")which would also enable --cursor-file reuse if anyone ever rebuilds the manual flag for cross-session debugging.
2. --from filter is not in the cursor key (orch_cli.py:1302-1309)
Two waits with identical (role, for_types) but different --from <role> values share a cursor file. A wait --for CONSENSUS_PROPOSE --from architect that advances past a message ID would cause a follow-up --for CONSENSUS_PROPOSE --from coder to miss messages from the coder with smaller IDs. Today's prompts don't mix --from filters with the same --for set so this is latent, but the CLI exposes both and the cross-type-drift caveat your docs describe extends symmetrically to --from. Either include from_role in the hash or call out the constraint in agent-wait-patterns.md § "Cross-type drift caveat."
3. UnicodeDecodeError not handled in _read_cursor_file (orch_cli.py:1326-1334)
except FileNotFoundError:
return None
except OSError as err:
...A corrupted file (non-UTF8 bytes — possible if e.g. a hostile log replacement, an interrupted write from a different writer, or a bind-mount confusion) raises UnicodeDecodeError, which is a ValueError subclass — neither branch catches it, so the wait command dies with a traceback. Widen to (OSError, ValueError) or just Exception. Cheap defense for a function whose whole point is "best-effort I/O."
4. PR description is stale (--cursor-file PATH flag references)
The PR body still describes a --cursor-file PATH flag and shell-side path templates — but commit ed986ce ("Drop --cursor-file flag; auto-derive path from EGG_AGENT_ROLE + for_types hash") removed that flag entirely. Anyone reading the PR description without scrolling to the latest commit will be confused. Worth a quick edit to the body so the "as-merged" surface is accurate.
5. _write_cursor_file may clobber a valid cursor on a pathological safety-cap exit (orch_cli.py:1538-1544, orch_cli.py:1337-1361)
If message_wait_loop's safety cap trips with last_resp == {} (every inner iteration transient-errored), the response has no cursor key, and _write_cursor_file(path, None) writes an empty string — clearing whatever valid cursor was there. Default max_iterations=sys.maxsize makes this essentially unreachable in production, but the invariant "the cursor file never moves backward" would be cleaner if it held unconditionally:
cursor = resp.get("cursor")
if cursor:
_write_cursor_file(cursor_file, cursor)Same suggestion applies in cmd_message_wait for the symmetric "match with cursor=None" edge case (the message route at orchestrator/routes/messages.py:520-523 makes this unreachable today, but the CLI wouldn't notice if that contract weakened).
6. Symlink hygiene in _write_cursor_file (orch_cli.py:1356-1359)
open(tmp_path, "w") follows symlinks. If egg-wait-cursor-<role>-<hash>.tmp.<pid> exists as a symlink (e.g. a stale dangling link from a previous run that crashed mid-os.replace), the write follows it. Not exploitable in the single-user sandbox, but os.open(tmp_path, os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW, 0o600) + os.fdopen is the standard idiom for a write to a /tmp path. Nit.
7. Test gap: no test for cursor=None on a successful round-trip with a pre-existing cursor file
test_writes_cursor_on_match and test_writes_cursor_on_safety_cap both use mocks that return a non-null cursor. There's no test that pins the chosen behavior for cursor=None responses (currently: wipe; alternative per #5: preserve). Add one so a future change to that policy is intentional.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-reviewed all changes since commit 5461b7c — three follow-up commits dropped the --cursor-file flag in favour of an auto-derived path keyed on (EGG_AGENT_ROLE, sorted(for_types)), then cleaned up mypy and ruff. I read the new _wait_cursor_path helper, the rewired cmd_message_wait / cmd_message_wait_loop, the prompt rewrites in pipelines.py, the docs rewrite in agent-wait-patterns.md, and the rebuilt TestWaitCursorPath / TestAutoCursorWait / TestAutoCursorWaitLoop / TestReviewerWaitLoopMentionsAutoCursor. CI lint is green; unit tests still in progress at review time.
Architecture: the always-on change is correct
Dropping the opt-in flag is the right call. The wait→process→wait race exists at every wait-loop re-entry site, so making callers opt in just leaves future call sites free to silently reintroduce the bug. Keying the path on (role, sorted(for_types)) gives:
- Order-insensitive sharing for the same type set.
- Automatic isolation between POLL and STAY ALIVE (different
--forlists hash differently). - Per-role isolation for the unusual debug case where multiple roles share a container.
- No file-system side effects when
EGG_AGENT_ROLEis unset (debug shells fall back cleanly).
Side effect on bare callers: consensus_wrapper.py:432-460 issues egg-orch message wait --for CONSENSUS_CONFIRMED --for CONSENSUS_RE_REVIEW --timeout "$poll_interval" in a status-poll fallback. With this PR, that wait now auto-threads its cursor across loop iterations, which is a strict improvement over the old "every iteration starts at the tip" behaviour. Worth noting in case it surfaces as unexpected /tmp files; not a regression.
Previous-review findings — status check
The auto-derive rewrite did not address any of the seven non-blocking issues from the previous review (commit 5461b7c). Their applicability is unchanged; some have shifted load-bearing because the mechanism is now always-on:
-
pipeline_idnot in the cursor key — same finding as before. Mitigated in production bydocs/guides/sdlc-pipeline.md:558("Each agent invocation runs in a fresh container with no memory of previous runs"), so/tmpis per-pipeline. Non-blocking. -
--from <role>not in the cursor key (orch_cli.py:1303-1309) —_wait_cursor_pathhashes onlyfor_types. Two waits with identical(role, for_types)but different--fromvalues share a cursor file; if the first advances past a message the--fromfilter dropped, the second misses it. No prompt currently mixes--fromwith the same--forset, so latent — but the CLI exposes both, and now without a--cursor-fileknob the operator has no way to manually scope different files for different--fromfilters. Non-blocking; would benefit from either includingfrom_rolein the hash or a docs note in the cross-type-drift section. -
UnicodeDecodeErrornot handled in_read_cursor_file(orch_cli.py:1331-1334) — onlyFileNotFoundErrorandOSErrorare caught. A non-UTF-8 cursor file (corrupted, manually edited with bad bytes, bind-mount confusion) raisesUnicodeDecodeErrorwhich is aValueErrorsubclass — the wait command dies with a traceback. Widen the except to(OSError, ValueError). Cheap defense for a "best-effort I/O" function. -
PR description is still stale — body still says
--cursor-file PATHflag andOpt-in; callers without --cursor-file see zero behavior change, both contradicted byed986ce. The "Test plan" still referencesTestCursorFileWait/TestCursorFileWaitLoop/TestCursorFileParserclasses that no longer exist (replaced withTestWaitCursorPath/TestAutoCursorWait/TestAutoCursorWaitLoop). Worth a quick edit so as-merged surface matches. -
Empty-cursor write still clobbers (
orch_cli.py:1364,orch_cli.py:1543) —_write_cursor_file(path, None)writes an empty string. Reachable whenmessages.py:521-523returnscursor=None, which only happens for a pipeline with literally zero messages (message_store.get_latest_idreturnsNone). Bounded but possible on the very first wait of a fresh pipeline. Cleaner invariant: skip the write when the response cursor is falsy. Non-blocking. -
Symlink-following on tmp write (
orch_cli.py:1357) —open(tmp_path, "w")follows symlinks. Not exploitable in the single-user sandbox; nit. -
Test gap: no test for
cursor=Noneresponse — current tests all return non-null cursors, so the chosen "wipe on null" policy is implicit, not pinned. Add a test so a future change to "preserve on null" (suggested in #5) is intentional.
New observations on the rewrite
-
hashlib.md5(... usedforsecurity=False)is correct — non-cryptographic use, fine on Python 3.9+. The repo runs Python 3.14 per the test warnings. -
Concurrency caveat is now harder to avoid — the docs at
docs/reference/agent-wait-patterns.md:472-475correctly note "two writers race (last writer wins). Sequential single-process use only." With the old--cursor-file PATHyou could opt out by simply not passing it; with auto-derive, two processes in the same container with the same role and same--forlist now share a path automatically. In practice agents run waits sequentially per role so this is moot — just calling out that the caveat now binds the operator more strictly. -
TestWaitCursorPathis well-targeted — covers role-empty / for-empty / order-insensitivity / type-set-distinct / role-distinct /EGG_WAIT_CURSOR_DIRhonoured. Good coverage of the path derivation invariants. -
TestReviewerWaitLoopMentionsAutoCursorchecks the prompt strings —automaticlowercase //tmp/egg-wait-cursor-${EGG_AGENT_ROLE}-/#2323. The previous test class (TestReviewerWaitLoopThreadsCursor) checked for--cursor-filetoken in the prompt; the rewrite is the right shape for the new design.
No blocking issues. The seven carryovers from the previous review and the four observations above are all non-blocking.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Reviewer flagged isolation gaps and a few defensive holes in the auto cursor threading introduced for #2323: - Cursor path now includes pipeline_id and from_role in addition to (role, sorted(for_types)). Two pipelines or two from_role filters running on the same host can no longer trample each other's cursors. Path scheme: /tmp/egg-wait-cursor-<pipeline>-<role>-<hash12> where hash12 = md5("<types_key>|from=<from_role>")[:12]. - _read_cursor_file now also catches UnicodeDecodeError (a truncated/binary file no longer kills the wait). - _write_cursor_file skips empty/None cursors so that a transient server response with no cursor cannot clobber a still-valid prior cursor on disk. - _write_cursor_file opens its tmp file with O_NOFOLLOW so a pre-existing symlink at the target cannot redirect the write. - 3 new test classes in test_message_wait_cli.py cover: * cursor=None preservation, * UnicodeDecodeError defense, * pipeline_id / from_role isolation end-to-end. - Docs (agent-wait-patterns.md) and pipeline prompts updated to reflect the new path scheme. All 56 wait CLI tests + 316 prompt tests + 34 handler tests pass.
Review feedback dispositionsAll seven actionable findings from @egg-reviewer's two reviews are addressed in 2b2e26c. Per-item dispositions: Findings #1–#7 (across both reviews)#1 — Cursor path must key on #2 — Cursor hash must include #3 — #4 — PR description references stale #5 — Empty/None response cursor must not clobber prior cursor — #6 — Symlink hygiene on tmp-file write — #7 — Test gap: cursor=None preservation — Second-review observations (#8–#11)#8 — #9–#11 — observations on coverage / docs — Updated PR summary (gateway blocks
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-reviewed the single follow-up commit (2b2e26c, +350/-81 across 5 files). Read the rewritten _wait_cursor_path / _read_cursor_file / _write_cursor_file, the rewired cmd_message_wait / cmd_message_wait_loop, the prompt-string updates in pipelines.py, the docs section in agent-wait-patterns.md, and walked through every new test (TestCursorNullResponsePreservesPriorCursor, TestCursorReadDefenses, TestCursorPathPipelineAndFromIsolation, expanded TestWaitCursorPath, expanded TestAutoCursorWait / TestAutoCursorWaitLoop).
Previous-review findings — all addressed
| # | Issue | Resolution |
|---|---|---|
| 1 | pipeline_id not in cursor key |
Path now …/egg-wait-cursor-<pipeline>-<role>-<hash> (orch_cli.py:1316-1320). Pinned by test_distinct_pipelines_yield_distinct_paths + end-to-end test_pipeline_isolation. |
| 2 | from_role not in cursor key |
Hash input now `f"{sorted_for_types} |
| 3 | UnicodeDecodeError not handled |
Except widened to (OSError, ValueError) (orch_cli.py:1342) — UnicodeDecodeError is a ValueError subclass. Pinned by test_non_utf8_cursor_file_is_treated_as_empty (writes raw \xff\xfe\xfd and asserts rc=1 + no since_id propagation). |
| 4 | Stale PR description | Author noted the gateway blocks gh pr edit here; corrected summary is folded into the response comment for paste-on-merge. Acceptable workaround — no code change needed. |
| 5 | Empty/None cursor clobbers prior | _write_cursor_file early-returns on not cursor or not cursor.strip() (orch_cli.py:1370-1374). Pinned by 3 tests in TestCursorNullResponsePreservesPriorCursor covering None / "" / " ". |
| 6 | Symlink hygiene on tmp write | os.open(... O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600) with proper fd ownership transfer to the os.fdopen wrapper and tmp-file cleanup on OSError (orch_cli.py:1376-1405). |
| 7 | No test for cursor=None policy |
TestCursorNullResponsePreservesPriorCursor covers all three falsy variants end-to-end. |
The implementation choices on each are correct:
O_EXCL+O_NOFOLLOW: rejects both pre-existing files and pre-existing symlinks at the tmp path. The cleanup branch unlinks any leftover tmp file so a subsequent retry from the same pid (or from a different pid with a coincidentally-equal stale tmp) succeeds. The fd ownership transfer (fd = Noneafter entering thewith os.fdopen(...)context) is right — thewithblock's__exit__closes the file and the underlying fd; the except branch only closes when ownership hadn't transferred yet.pipeline_id or "no-pipeline"fallback atorch_cli.py:1319: only reachable when_wait_cursor_pathis called withpipeline_id=None, whichcmd_message_wait/cmd_message_wait_loopblock viarequire_pipeline_id. The fallback exists for direct unit testing of_wait_cursor_path. Fine.- Hash input format:
f"{sorted_types_key}|from={from_role or ''}"— the|from=separator means a wait withfor_types=["X|from=Y"]could in principle collide with a wait whose canonical form is["X"]+from_role="Y". In practicefor_typesare protocol message-type constants (CONSENSUS_PROPOSE, etc.) that don't contain|; not a real risk.
Non-blocking observations on the new code
a. No symlink-protection test (orch_cli.py:1376-1404)
The O_NOFOLLOW defense is correct, but no test exercises it. A os.symlink('/etc/passwd', tmp_path) followed by a write attempt would pin the chosen behaviour (raise OSError(ELOOP) → log warning → cleanup). Cheap to add; not blocking because the code is small enough to inspect.
b. role is interpolated into the path without validation (orch_cli.py:1320)
pipeline_id flows through validate_id (regex ^[a-zA-Z0-9_\-\.]+$) before reaching _wait_cursor_path, but role is taken directly from args.role or get_agent_role_from_env() with no equivalent check. A EGG_AGENT_ROLE containing / or .. would interpolate literally into the path. In practice the orchestrator sets known roles and the agent attacks itself in its own sandbox if it tampers, so this isn't a real security issue — but the asymmetry is awkward and pre-existing in this file. Worth either passing role through validate_id here or noting the assumption explicitly.
c. cursor.strip() assumes string-typed response (orch_cli.py:1374, 1391)
resp.get("cursor") could return any JSON-deserialised type. A non-string (e.g., integer message ID) would raise AttributeError on .strip(), which the surrounding except OSError does not catch — the wait would die mid-completion after the user already saw their response. Internal contract today says cursor is str | None, so latent. A isinstance(cursor, str) guard would be cheap insurance.
d. test_pipeline_isolation asserts only the negative (test_message_wait_cli.py:638-654)
The test verifies "since_id=01-A-tip" not in endpoint for pipeline B's call but doesn't pin the positive — that pipeline A's second call would still see its own cursor. The combination of test_threads_stored_cursor_to_handler + test_pipeline_isolation covers it transitively, but a tightening would be one extra cmd_message_wait(_make_wait_args(pipeline_id="issue-42", ...)) after the cross-pipeline call asserting "since_id=01-A-tip" in endpoint. Optional.
e. Operator-debug glob in docs is now harder
docs/reference/agent-wait-patterns.md:467-470 tells operators to glob /tmp/egg-wait-cursor-${EGG_PIPELINE_ID}-${EGG_AGENT_ROLE}-* to find a stuck reviewer's cursor. With EGG_PIPELINE_ID containing hyphens (e.g., issue-2323) and EGG_AGENT_ROLE also containing underscores, a hand-typed glob is more error-prone than the previous ${EGG_AGENT_ROLE}-*. Not a regression — the old form was already not a complete glob — but adding a quick ls /tmp/egg-wait-cursor-*-${EGG_AGENT_ROLE}-* example would help operators who only know the role.
Verdict
ACK. All seven previous-review findings are properly fixed with focused tests; the new defenses (O_NOFOLLOW, ValueError catch, empty-cursor preservation, pipeline + from-role isolation) are correctly implemented and tested. The five observations above are non-blocking polish.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…docs - Reject unsafe role / pipeline_id in _wait_cursor_path (defense in depth: pipeline_id already passes through validate_id upstream; role flows in directly from EGG_AGENT_ROLE). - isinstance(cursor, str) guard before .strip() in _write_cursor_file so a future contract weakening to non-string cursor IDs preserves the prior cursor instead of raising AttributeError mid-write. - Symlink-protection test: a pre-existing symlink at the tmp-write path is refused by O_NOFOLLOW + O_EXCL; the wait still completes and the cursor file is not materialised. - Non-string cursor test: confirms the isinstance guard preserves any prior cursor on disk. - Tighten test_pipeline_isolation: add a positive assertion that pipeline A's second call still threads its own cursor (the negative assertion alone would not catch a regression that wiped both cursors). - Document role-only and pipeline-id-only operator-debug glob examples in agent-wait-patterns.md.
Review feedback dispositions (latest review, commit 2b2e26c)The latest review explicitly stated "ACK" with five non-blocking observations (#a–#e). All five are now addressed in 15b63ac — defaulting to in-PR fixes since each is a small, focused change. #a — No symlink-protection test
#b —
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-reviewed the single follow-up commit (15b63ac, +103/-7 across 3 files). Read the new _wait_cursor_path validation, the isinstance(cursor, str) guard in _write_cursor_file, the three new test methods (test_unsafe_role_returns_none, test_unsafe_pipeline_id_returns_none, TestCursorWriteDefenses::test_symlink_at_tmp_path_is_refused_and_wait_still_completes, test_non_string_cursor_response_does_not_crash), the tightened test_pipeline_isolation, and the operator-debug glob examples in agent-wait-patterns.md. Ran the full test_message_wait_cli.py suite locally — 60/60 pass.
Previous-review observations — all addressed
| # | Observation | Resolution |
|---|---|---|
| a | No symlink-protection test | TestCursorWriteDefenses::test_symlink_at_tmp_path_is_refused_and_wait_still_completes — plants a dangling symlink at the exact <cursor_path>.tmp.<pid> write target, asserts rc=1 (wait completes) and the cursor file is not materialized via redirection. The cleanup os.unlink(tmp_path) in the except branch removes the symlink so a subsequent retry succeeds. |
| b | role interpolated without validation |
_wait_cursor_path now rejects role (and defense-in-depth, pipeline_id) failing _SAFE_ID_PATTERN.match (orch_cli.py:1320-1323), returning None to fall back cleanly to from-tip semantics. Choosing None over validate_id (which sys.exit(1)s) is the right call here — a wonky EGG_AGENT_ROLE shouldn't kill the wait, just skip cursor threading. |
| c | cursor.strip() assumes string |
_write_cursor_file guards with isinstance(cursor, str) before .strip() (orch_cli.py:1381). Future contract weakening (int message IDs) preserves any prior cursor instead of AttributeError-ing mid-write after the wait already returned. |
| d | test_pipeline_isolation only asserts negative |
Tightened with a third call that pins the positive direction — pipeline A's second wait still threads since_id=01-A-tip (test_message_wait_cli.py:1041-1048). A regression that wiped both cursors no longer silently passes. |
| e | Operator-debug glob harder with pipeline-id segment | Added two extra glob recipes to agent-wait-patterns.md:472-483 covering "I only know the role" and "I only know the pipeline id". |
Verification
- The validation wires correctly:
cmd_message_waitandcmd_message_wait_loopalready passpid(URL-quoted viavalidate_id) to_wait_cursor_path, andvalidate_id's pattern is byte-identical to_SAFE_ID_PATTERN, so the re-check is symmetric and never rejects a value that made it pastvalidate_id. Therolere-check is the load-bearing one —args.role or get_agent_role_from_env()atorch_cli.py:1448and:1560is the only source, and neither callsvalidate_id. - The
O_NOFOLLOW + O_EXCLdefense and the test asserting it work as advertised. The cleanup path (os.unlink(tmp_path)) also clears the symlink so subsequent writes succeed — verified by reading the except branch and the docstring contract. isinstance(cursor, str)is the standard Python idiom for this guard and is consistent with the surrounding "best-effort write" semantic.
Non-blocking observations on the new code
Pattern accepts .. and . as standalone strings (orch_cli.py:1320-1323)
_SAFE_ID_PATTERN = ^[a-zA-Z0-9_\-\.]+$ accepts .. and . because the dot is in the character class. A EGG_AGENT_ROLE=.. would yield path egg-wait-cursor-issue-42-..-<digest>, which is safe — .. between hyphens in a single filename component is a literal substring, not a directory traversal. Worth noting only because the docstring frames the check as defense against .., but the actual defense is against the embedded / (which the pattern correctly rejects). Code is correct; only the framing is slightly imprecise.
cursor keyword in cursor=42 test docstring vs. assertion (test_message_wait_cli.py:1133)
The docstring of test_non_string_cursor_response_does_not_crash says "Non-string cursor — int, list, dict" but the test only exercises cursor=42. One value is sufficient (the isinstance check rejects all non-strings identically), but the comment overstates what's covered. Cosmetic.
Symlink-test assertion is tautological (test_message_wait_cli.py:1118)
assert not os.path.exists(cursor_path) or os.path.islink(cursor_path) — the OR branch is unreachable in this test because the test never creates a symlink at cursor_path itself, only at tmp_write_path. The simpler assert not os.path.exists(cursor_path) would be equivalent and clearer. Cosmetic.
Verdict
ACK. The five non-blocking observations from the prior review are correctly fixed with tightly-scoped tests; the three observations above are cosmetic-only and would be one-line edits if you want to land them. No blocking issues — the cursor threading is now hardened against unsafe EGG_AGENT_ROLE, non-string cursor values, and pre-existing symlinks at the tmp path; the negative-only assertion is now bidirectional; the operator-debug docs cover both partial-info paths.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
- Clarify _wait_cursor_path docstring: the load-bearing rejection is the path separator '/'; '..' as a literal substring within a single filename component is permitted and harmless. - Tighten test_symlink_at_tmp_path_is_refused_and_wait_still_completes: drop the tautological 'or os.path.islink(cursor_path)' branch — the test never plants a symlink at cursor_path itself. - Correct test_non_string_cursor_response_does_not_crash docstring: one int suffices because the isinstance(cursor, str) guard rejects all non-strings identically (was 'int, list, dict').
Review feedback dispositions (latest review, commit 15b63ac)The latest review's verdict was ACK, with three cosmetic-only observations explicitly flagged as "one-line edits if you want to land them". All three are now addressed in 947321b — defaulting to in-PR fixes per the contract. Cosmetic observation 1 — Pattern accepts
|
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-reviewed the single follow-up commit (947321b, +5/-2 in orch_cli.py, +4/-2 in test_message_wait_cli.py). Walked the new docstring against the code and re-read both test methods that were updated.
Previous-review observations — all addressed
| # | Observation | Resolution |
|---|---|---|
| 1 | Pattern accepts .. and . as standalone strings — docstring framed .. as the threat |
_wait_cursor_path docstring now correctly states the load-bearing rejection is the path separator /; .. / . substrings are permitted and harmless because the result is a single filename component (orch_cli.py:1310-1314). Matches the actual _SAFE_ID_PATTERN = ^[a-zA-Z0-9_\-\.]+$ behaviour. |
| 2 | cursor=42 test docstring overstated coverage |
Comment now reads "one int suffices because the isinstance(cursor, str) guard rejects all non-strings identically; a list/dict would take the same branch" (test_message_wait_cli.py:1133-1135). Accurately describes what the test exercises and why one value suffices. |
| 3 | Symlink-test had a tautological or os.path.islink(cursor_path) branch |
Simplified to assert not os.path.exists(cursor_path) (test_message_wait_cli.py:1118). Verified by reading the test: line 1104 places the symlink at tmp_write_path (<cursor_path>.tmp.<pid>), never at cursor_path itself, so the OR branch was unreachable. |
Verification
- Docstring claim is accurate. The path is built via
os.path.join(base, f"egg-wait-cursor-{pid_segment}-{role}-{digest}")(orch_cli.py:1334). Because the alphabet excludes/, every interpolated value is a single component with no separators — a hostileEGG_AGENT_ROLE=..yieldsegg-wait-cursor-<pid>-..-<digest>, still one component, no traversal target. The framing now matches the actual defense. - Simplified assertion is equivalent. The test plants the symlink at
tmp_write_path(line 1105). After_write_cursor_filefails withO_NOFOLLOW + O_EXCLand runs itsos.unlink(tmp_path)cleanup, bothtmp_write_pathandcursor_pathare absent.os.path.exists(cursor_path)is False; the OR branch never fired in the original. - Comment-only changes — no logic touched.
_wait_cursor_pathbody, the symlink test setup/exec, and the non-string-cursor test setup/exec are byte-identical to commit 15b63ac. Pure documentation hygiene.
No new findings
No blocking issues. All three cosmetic observations from my prior review (commit 15b63ac) are correctly addressed; the underlying cursor-threading implementation remains unchanged and the seven actionable findings + five non-blocking observations from the earlier review chain are still resolved as previously verified.
— Authored by egg
|
egg review completed. View run logs 16 previous review(s) hidden. |
Auto-merge of #2326 (--cursor-file race fix) into the slice-1 branch. No conflicts; updates to BRC preamble strings in pipelines.py, sandbox/egg_lib/orch_cli.py, agent-wait-patterns.md, and the two test files all integrated cleanly. Authored-by: egg
Summary
--cursor-file PATHtoegg-orch message waitandegg-orch message wait-loop. The CLI reads the file at entry as the default for--sinceand writes the response cursor back on every successful round-trip (match or timeout). Best-effort I/O.--cursor-file /tmp/egg-wait-cursor-${EGG_AGENT_ROLE}-{poll,stay-alive}so successivewait-loopre-entries thread their cursor.docs/reference/agent-wait-patterns.md(canonical idiom + dedicated--cursor-filesubsection).Bug
Multi-producer reviewer phases (plan: 3 producers) stalled by 20–30 minutes when a
CONSENSUS_PROPOSElanded in the gap between the previouswait-loopreturning and the next one entering. The new call'sfrom_tipsemantics (issue #1925 default, by design) skipped the already-on-bus event, forcing a fallback to manualegg-orch message pollafter the inner timeout burned.`wait-loop` already threads cursors internally between its inner iterations, but each new CLI invocation is a separate process and the cursor was lost across re-entries — and
wait-loopexposes no--jsonmode for shell callers to extract it themselves.Fix shape
--cursor-fileis a file-system back-channel for the cursor:--since. An explicit--sincestill wins.os.replace(atomic). Match → last delivered message ID. Timeout → current stream tip. Safety cap → handler-advanced cursor.Opt-in; callers without
--cursor-filesee zero behavior change.Caveats called out in docs
wait-loop --for Xwhose cursor advances past an unrelatedYevent means a follow-up call won't seeY. Mitigate by including all relevant types in--for, or by using distinct cursor files per--forset. (Not a regression — same risk exists today; just documented now.)Out of scope
Issue #2323 also speculated about a "Case 2" (architect proposed during the third wait-loop's window). The code paths show Case 1 (gap-between-calls) alone explains the timeline; chasing a Case 2 would be speculative. File separately if a real reproduction surfaces.
Test plan
pytest sandbox/tests/test_message_wait_cli.py— 49 tests, including newTestCursorFileWait,TestCursorFileWaitLoop, andTestCursorFileParserclasses covering match / timeout / safety-cap / permanent-error / transient-error / explicit-since-override / parent-dir-creation / no-flag-noop.pytest orchestrator/tests/test_pipeline_prompts.py— 316 tests, including newTestReviewerWaitLoopThreadsCursor(POLL, reviewer STAY ALIVE, producer STAY ALIVE all assert--cursor-fileis present with the right path).pytest tests/sandbox/egg_agent_tools/— 355 tests, no regressions in handler layer.ruff checkclean on changed files.Closes #2323.