Skip to content

fix(rpc): stop a turn that is waiting on an ask_user question - #1073

Merged
LivXue merged 21 commits into
mainfrom
fix/stop_turn_waiting_on_question
Nov 1, 2026
Merged

LivXue merged 21 commits into
mainfrom
fix/stop_turn_waiting_on_question

Conversation

@silverLXT

@silverLXT silverLXT commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Pressing stop while an ask_user question is on screen did not stop the
turn. QuestionBroker.await_question caught CancelledError together with
TimeoutError and returned the question's default, so the cancellation ended
inside the broker: the tool reported "user did not answer; proceed with best
judgment", the model went on with its own recommendation or asked again, and
turn.cancel waited for that whole continued turn while the page already
showed the turn as stopped. Reproduced on the web page with a headless
browser: after stop the same turn still ran a 45 s command and finished with
an answer, and a prompt that asks again when unanswered put a second question
on screen.

The turn now ends where it was stopped:

  • await_question re-raises CancelledError when a cancellation of the
    calling task arrives while the wait is pending. The count is snapshotted
    before the wait because Task.cancelling() is cumulative: a task that
    swallowed an earlier cancellation must not turn someone else's cancelled
    future into its own. The prompt is still closed with clarify.closed.
  • A pending future cancelled by anyone else still resolves to the default.
    The ACP elicitor already turns a cancelled task into a cancelled form, so
    its behavior is unchanged; its docstrings and the contract papers now
    describe the new exit.
  • A stint's first round could leave StintDriver.start without a receipt
    while its approval was on screen (a Stop, or load_playbook's 120 s
    ceiling). The record was left running, and a later request took it up
    through resume without asking. start now claims the record and starts a
    stamp-only beat before the approval, and closes the record as stopped on
    any such exit before a round is opened.
  • The close reads, checks and writes the record under one
    write_transaction, so a pause, stop, extend or claim that landed
    during the question survives, and a record another process or host claimed
    is left to it. A cleanup that cannot read or write is logged and the
    cancellation still propagates; only a cancellation or a timeout is blamed
    on the approval.
  • StintStore.touch moves only touched_at_ms under the write lock and
    compares the holder's pid and host, so the beat cannot undo a concurrent
    stop or pause and cannot keep another host's claim alive. extend refuses
    a first round that never opened, and says whether starting it again asks
    for approval; resume asks before taking one up and closes it on refusal
    or cancellation. A first round paused for the hourly dispatch budget stops
    the pre-approval beat, so holding() is false as soon as the record is
    paused.

Review follow-ups on the final head, one commit each:

  • A close that loses the race to a round another caller already opened no
    longer stops the beat table's current entry. It stops only the beat this
    caller started, and only when no round opened, so a live round keeps the
    heartbeat that keeps a second host off its checkout.
  • The close now also asks the caller's own record whether round one opened.
    _submit opens the round on the object before writing it, so a failed
    write no longer records stopped for a round that is running (which is not
    unfinished and would let a second stint open on the same project).
  • resume closes a never-opened first round on "not approved" only. A
    delegation pause or a validation refusal leaves the record interrupted
    and retryable instead of terminal; an unanswered question (a timeout, or
    one the broker could not deliver) fails safe to _NOT_APPROVED and closes
    like any other unanswered ask.
  • The dispatch budget is charged after the first round's confirmation, so a
    budget pause on round one follows an approval. The record now carries
    first_round_approved, and resume takes that round up without asking a
    second time. Records written before the field read as not approved and are
    asked again.
  • resume claims the stint only when it is going to ask. A resume that asks
    nobody has nothing to keep alive while it preflights, and a stop during
    that window puts the record back where the resume found it: a pause stays
    a pause, and a stale running becomes interrupted, so the next resume
    is not refused for the five-minute stale window.

Follow-ups after the second review round:

  • The stopped-preflight restore re-reads and writes under the same
    transaction as the first-round close, and leaves alone a record whose
    round opened or that another process has moved or claimed.
  • The transient-refusal comment now names the refusals that actually reach
    that branch, and says where an unanswered question lands.
  • The open-round write re-reads the record and writes it under one
    write_transaction. A stop or pause that landed while the approval
    question was on screen therefore survives the round being recorded:
    advance ends or parks the stint after that round instead of the record
    being revived to running. A record that cannot be read is replaced
    rather than turning a dispatched round into an error the turn reads.
  • A new end-to-end test stops a lane while the real broker holds its
    question. It drives the real scheduler, lane, QuestionBroker and
    AskUserTool under a fake run_turn -- the seam where the loop runs a
    tool -- and fails if the broker answers the cancellation with the default.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

  • Relevant tests pass locally
  • Relevant lint / type checks pass locally
  • User-facing docs or screenshots are updated when needed

Native Windows 11, Python 3.12.13, PYTHONUTF8=1, isolated
USERPROFILE/TEMP, exact-head worktree, uv run --frozen --no-sync:

pytest tests/test_playbook_stint.py tests/test_stint_record.py
-> 186 passed, 9 failed

pytest tests/test_playbook_stint.py tests/test_stint_record.py \
  tests/test_question_broker.py tests/test_ask_user_tool.py \
  tests/test_rpc_turn_cancel.py tests/test_spine_scheduler.py \
  tests/test_acp_elicitation.py tests/test_acp_ask_user.py \
  tests/test_acp_questions.py tests/test_cli_playbook_commands.py \
  tests/test_rpc_playbooks.py tests/test_a2a_asking.py \
  tests/test_agents_oncall_flow_escalation_tools.py \
  tests/test_subagent_dag_core.py tests/test_rpc_spine.py
-> 1200 passed, 18 failed, 15 skipped

All 18 failures are environment-only and have the same IDs at the merge
base: 8 Windows symlink privilege (WinError 1314), 1 os.kill on a dead
pid, 2 path/fixture failures in test_rpc_playbooks, and 7 POSIX reference
paths in test_subagent_dag_core. The broader run was made on the final
tree.

The six new regression tests fail on the head before their fixes and pass
here: dropping the broker's re-raise fails the end-to-end stop test,
withdrawing the open-round record write fails the two out-of-band
stop/pause tests, withdrawing the stopped-preflight restore fails both
stopped-resume tests, and withdrawing the open-round read fallback fails the
unreadable-record test. Reverting the earlier _close_first_round change
still fails the two tests that cover the beat and the failed-write close.

ruff check and ruff format --check pass on the touched files, and the
repository's commit-message lint passes over the branch. Internal contract
docs (CONTEXT.md, docs/Proactivity-Implementation.md, and the ACP
elicitation design spec) are updated; no user-facing screenshots are needed.

Risk

  • Security impact considered -- no new external input or permission
    surface; the change only lets a stopped turn's cancellation reach the
    turn.
  • Backward compatibility considered -- first_round_approved is an
    additive record field; older builds drop unknown keys and read it as
    false, so they ask once more rather than run unapproved. The broker's new
    raise only reaches callers whose task was cancelled, and those callers
    were audited. The new record writes preserve every field they re-read, so
    older builds read the same file.
  • Rollback path is clear -- revert the follow-up commits or the branch;
    records carrying the new field remain readable.

Related Issues

#1048 (independent; no dependency)

Stopping a turn while its ask_user question was on screen did not stop it.
QuestionBroker.await_question answered the cancellation with the question's
default, so the cancellation ended inside the broker: the tool returned
"the user did not answer, proceed with best judgment", the model carried on
with its own recommendation or asked the question again, and turn.cancel
waited for that whole continued turn before replying. The person had pressed
stop and the page said the turn was stopped.

A cancellation aimed at the waiting task now propagates after the prompt is
closed. A pending future cancelled by anyone else still resolves to the
default, as before. The elicitor already turns a cancelled task into a
cancelled form, so its behaviour is unchanged; two docstrings that described
the old broker are reworded.

Co-authored-by: Claude (claude-opus-5-5) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: update the remaining cancellation contract documentation to match the new propagation behavior.

I found one introduced contract-documentation mismatch, marked inline. The implementation itself looks correct: I traced turn.cancel through TurnHandle/Lane to cancellation of the actual running turn task, checked the broker cleanup and clarify.closed path, and verified that future-only cancellation and cancel_all() still resolve to the default.

Coverage included the full diff, relevant callers, scheduler/turn cancellation architecture, file history, the canonical Runtime context, AGENTS.md/CLAUDE.md rules, backward compatibility, and whether tests were weakened (they were not). Branch/commit naming and source/test-file rules conform; dependency and asset rules are not implicated.

Verification:

  • Initial targeted run: setup error before test bodies because the fresh environment lacked workspace package raven_everos.
  • uv sync --all-packages: passed.
  • uv run pytest tests/test_question_broker.py tests/test_ask_user_tool.py tests/test_rpc_turn_cancel.py -q: 134 passed.
  • uv run pytest tests/test_acp_elicitation.py tests/test_acp_ask_user.py -q: 73 passed.
  • git diff --check github/main...HEAD: passed.

Comment thread raven/rpc/question_broker.py Resolved
@LivXue

LivXue commented Oct 23, 2026

Copy link
Copy Markdown
Member

Windows compatibility review of head 105bcf7123cb0623cf4880af87831f05673ed6a0, base b27b0ae97492f799ac3c551a8ca2bc844cbe9912. Inspected the complete four-file diff, description/discussion pages and thread, broker/ask-user/ACP callers, turn.cancel and scheduler cancellation, and prompt-close cleanup. The exact-SHA source archive was independently matched to Git tree 295b41da95289885f3b82b1c14171068180d073f.

No Windows compatibility issue was found in the inspected scope. The change distinguishes cancellation of the calling task from cancellation of its pending future using asyncio task APIs already supported by the project's Python requirement and already used by the ACP elicitor. No platform-specific path, subprocess, signal, dependency, or I/O API was added.

Native Windows verification with Python 3.13.12 and the default Proactor event-loop policy: 207 tests passed (question broker, ask_user and RPC turn cancellation: 134; ACP elicitation/questions: 73). These exercise task cancellation propagation, clarify.closed cleanup, future-only cancellation, timeout/EOF defaults, and ACP cancellation handling. git diff --check passed; inspected file contents still matched the pinned head.

The existing cancellation-contract documentation discussion was read; this is a Windows compatibility review, without an overall merge verdict. Coverage limits: UTF-8 mode was enabled; no real browser Stop interaction, live ACP subprocess/channel session, Python 3.12 run, or full repository suite was exercised.

Exact verification commands and results

Each Python invocation used an isolated uv environment and temporary profile/Raven/OAuth/temp directories. UTF-8 mode was enabled and imports were asserted to belong to the selected checkout.

$run = 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\1292e0f475b0450bb12d681f2a4fa415'
$env:UV_PROJECT_ENVIRONMENT = Join-Path $run 'python-env'
$env:UV_PYTHON = '3.13'
$env:PYTHONUTF8 = '1'

# 1073-question: 134 passed, 0 failed, 0 skipped; exit 0
Set-Location -LiteralPath 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\1292e0f475b0450bb12d681f2a4fa415\pr1073\head-archive'
$env:PYTHONPATH = 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\1292e0f475b0450bb12d681f2a4fa415\pr1073\head-archive' + ';' + (Join-Path $run 'probes')
uv 'run' '--frozen' '--no-sync' 'pytest' 'tests/test_question_broker.py' 'tests/test_ask_user_tool.py' 'tests/test_rpc_turn_cancel.py' '-p' 'no:randomly' '-n' '0' '-x' '--tb=short' '--basetemp' 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\1292e0f475b0450bb12d681f2a4fa415\checks\1073-question\pytest' '--junitxml' 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\1292e0f475b0450bb12d681f2a4fa415\checks\1073-question\junit.xml'

# 1073-acp: 73 passed, 0 failed, 0 skipped; exit 0
Set-Location -LiteralPath 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\1292e0f475b0450bb12d681f2a4fa415\pr1073\head-archive'
$env:PYTHONPATH = 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\1292e0f475b0450bb12d681f2a4fa415\pr1073\head-archive' + ';' + (Join-Path $run 'probes')
uv 'run' '--frozen' '--no-sync' 'pytest' 'tests/test_acp_elicitation.py' 'tests/test_acp_ask_user.py' '-p' 'no:randomly' '-n' '0' '-x' '--tb=short' '--basetemp' 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\1292e0f475b0450bb12d681f2a4fa415\checks\1073-acp\pytest' '--junitxml' 'C:\Users\livxu\AppData\Local\Temp\raven-windows-review\1292e0f475b0450bb12d681f2a4fa415\checks\1073-acp\junit.xml'
$bare = Join-Path $run 'objects.git'
git --git-dir=$bare diff --check b27b0ae97492f799ac3c551a8ca2bc844cbe9912..295b41da95289885f3b82b1c14171068180d073f # exit 0

Event-loop check, from the same checkout/environment:

uv run --frozen --no-sync python -c "import asyncio,os;loop=asyncio.new_event_loop();print(os.name, type(loop).__name__);loop.close()"
# exit 0: nt ProactorEventLoop

…ault

AskUserTool.ask_direct still promised an empty answer on cancellation
because the broker never raised, and the CONTEXT.md entry listed
cancellation among the broker's fail-safe defaults. Both now separate the
asking task being cancelled, which raises, from the pending wait being
cancelled from outside, which still answers the default.

Co-authored-by: Claude (claude-opus-5-5) <noreply@anthropic.com>
@LivXue
LivXue requested review from 0xKT and gloryfromca October 23, 2026 07:54
@silverLXT
silverLXT requested a review from LivXue as a code owner October 23, 2026 08:07

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

The new revision resolves the sole prior blocker. I reviewed the complete change as previously assessed plus the 105bcf7123cb..4ddee4ef5e58 delta, checked the updated caller and canonical Runtime contract against the broker behavior, and found no new issue. My original thread has been replied to and resolved.

Coverage: repository rules and canonical terminology, the diff and relevant callers, cancellation architecture, backward compatibility, history, and test integrity. The added commit changes documentation/comments only and does not weaken tests.

Verification:

  • uv run pytest tests/test_question_broker.py tests/test_ask_user_tool.py tests/test_rpc_turn_cancel.py tests/test_acp_elicitation.py tests/test_acp_ask_user.py -q: 207 passed.
  • git diff --check github/main...HEAD: passed.

@LivXue

LivXue commented Oct 23, 2026

Copy link
Copy Markdown
Member

Windows compatibility follow-up at head 4ddee4ef5e58bbf8f6ad2f5876815c0712797579, base/merge base b27b0ae97492f799ac3c551a8ca2bc844cbe9912. Inspected the complete seven-file diff, broker/ask-user/ACP callers, scheduler and turn.cancel propagation, prompt-close cleanup, updated cancellation contract text, tests and all discussion pages.

No Windows compatibility issue found in that scope. The caller-task versus future-only distinction uses asyncio task APIs already supported by the project's Python requirement; no path, subprocess, signal, dependency or platform I/O operation was added. The previous cancellation-documentation discussion is reflected in the updated caller/Runtime descriptions.

Native Windows verification, Python 3.13.12 with PYTHONUTF8=1, in an isolated exact-head checkout and temporary uv/profile/Raven/OAuth/test environment:

uv run --frozen --no-sync pytest tests/test_question_broker.py tests/test_ask_user_tool.py tests/test_rpc_turn_cancel.py tests/test_spine_scheduler.py tests/test_acp_elicitation.py tests/test_acp_ask_user.py tests/test_acp_questions.py -p no:randomly -n 0 -x --tb=short --basetemp C:\Users\livxu\AppData\Local\Temp\raven-windows-review\79783fbc38724cf7967237cdeb44f8f8\checks\1073-cancellation\pytest --junitxml C:\Users\livxu\AppData\Local\Temp\raven-windows-review\79783fbc38724cf7967237cdeb44f8f8\checks\1073-cancellation\junit.xml
uv run --frozen --no-sync python -c 'import asyncio,os; loop=asyncio.new_event_loop(); print(os.name, type(loop).__name__); loop.close()'

303 passed, no skips/failures, exit 0; the event-loop probe returned nt ProactorEventLoop. Coverage includes caller cancellation, externally cancelled futures, timeout/EOF defaults, clarify.closed cleanup, scheduler/RPC cancellation, and ACP question/elicitation handling. All seven changed files were rechecked against the pinned head; git diff --check passed.

Limits: no real browser Stop interaction, live ACP subprocess/channel session, Python 3.12 run, or full repository suite was performed.

Comment thread raven/agent/tools/ask_user.py Resolved
@LivXue

LivXue commented Oct 23, 2026

Copy link
Copy Markdown
Member

Blocking: 1 finding needs correction before this revision can merge; see the inline note.

Scope: I reviewed only the raven/agent/** portion of this PR -- 1 of 7 changed files, raven/agent/tools/ask_user.py, where the change is the ask_direct docstring. The other 6 files were read as context and are not covered by this verdict. Since the PR changes a contract that readers inside raven/agent/ rely on -- the question broker no longer turns the asking task's cancellation into the default answer -- I also read those readers, which the PR leaves unchanged.

What I examined: QuestionBroker.await_question (raven/rpc/question_broker.py:219-235) as context, and each reader of the old "the broker never raises" contract inside raven/agent/: AskUserTool.execute (raven/agent/tools/ask_user.py:502-591), WiringMixin._confirm_graph and WiringMixin._direct_ask (raven/agent/loop/wiring.py:1936-1975, :1637-1667), SubAgentDagTool._confirmed (raven/agent/subagent/dag_tool.py:2181-2234) with the playbook and stint paths above it, and LoadPlaybookTool under the registry's wait_for ceiling (raven/agent/tools/registry.py:1034-1044). The broker change itself is correct: on Python 3.12 asyncio.wait_for still delivers TimeoutError at its own deadline, which answers the default, and only a cancellation of the asking task itself propagates. AskUserTool.execute releases question_lock on the way out, the pending slot is cleared and clarify.closed is sent, and the turn's cancel path closes the call with "[interrupted] this call never returned" (raven/agent/loop/turn_path.py:2947), so the next turn no longer reads that the user did not answer. The tool's description and parameters are unchanged, so the model sees nothing new.

Findings:

  • F1, Blocker, inline on raven/agent/tools/ask_user.py:349 -- a stint whose first-round approval is cancelled, by Stop or by load_playbook's 120 s ceiling, is left running, and the next request for the same playbook takes it up through resume with confirm=False, so it runs although its approval was never answered. Reproduced on this head and on the merge base; the inline note has the mechanism and the runs.
  • F2, Nonblocking, a continuation of gloryfromca's resolved documentation finding rather than a new one -- the module docstring of raven/agent/tools/ask_user.py (lines 3-8) still says "the loop never sees an exception", which stops being true when the turn is stopped: execute now hands CancelledError to the loop, as the probe below shows. One direction: say there what the ask_direct docstring now says.

Verification:

  • Exports of this head and of the merge base b27b0ae97492, each run printing the raven/__init__.py it imported.
  • pytest tests/test_question_broker.py tests/test_ask_user_tool.py tests/test_rpc_turn_cancel.py tests/test_acp_elicitation.py tests/test_acp_ask_user.py tests/test_acp_questions.py tests/test_agents_oncall_flow_escalation_tools.py -n 0 on this head: 296 passed.
  • This head's tests/test_question_broker.py and tests/test_ask_user_tool.py against the merge base's raven/: the 3 cancellation tests fail; the test for a pending wait cancelled from outside passes, as a regression guard should.
  • A probe stopping a turn at an open ask_user question: on this head execute raises CancelledError, question_lock is released, the pending slot is None, and both clarify.request and clarify.closed are sent; on the merge base the same stop returns "user did not answer; proceed with best judgment".
  • F1, both triggers on both trees: as in the inline note. tests/test_playbook_stint.py -k 'refused or approve or not_approved' on this head: 16 passed, which is why the existing guard does not catch it.

Checked and not reported: ConfirmBroker still swallows a cancellation, but it serves only cli.dispatch's worker thread, not a turn. ApprovalBroker, CredentialBroker and A2aQuestionBroker already let a cancellation through, so this PR brings the question broker in line with them. Readers that handle CancelledError themselves are unchanged: Elicitor.elicit, the ACP client's AskUserResponder, the permission gate, and the DAG replan confirmation, which writes nothing before it asks. A stopped question no longer files note_unanswered, which is right, since that text says the turn continued with best judgment. Not run: a real page or turn.cancel over RPC end to end, and the full suite.

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: close a first-round stint record when its approval wait is cancelled.

The newly reported stint-lifecycle evidence invalidates my earlier clean stance. I traced the existing inline finding's path: StintDriver.start() writes a running record before _submit(); caller-task cancellation now escapes _confirmed() as CancelledError; _submit() converts only budget and explicit non-approval exceptions into receipts; and start() closes the record only after an error receipt. The cancelled path therefore leaves a fresh running record that blocks an immediate retry and can later resume without confirmation. The registry's 120-second wait_for also reaches that path without an explicit Stop.

I am not duplicating the existing inline blocker or resolving another reviewer's thread.

Verification: uv run pytest tests/test_playbook_stint.py tests/test_ask_user_tool.py tests/test_question_broker.py -q passed (273 tests), but the suite does not cover cancellation during first-round stint approval.

With a stopped turn now raising out of its open question, a stint's first
round could leave StintDriver.start without a receipt: the turn stopped, or
load_playbook's tool ceiling ran out, while the "Run it / Not now" question
was on screen. start closed the record only on an Error receipt, so the
record stayed running with round one on it. The next request for the same
playbook was refused as if something were advancing it, and once the stamp
went stale it was taken up through resume, which asks nobody, so a round
nobody approved ran.

start now closes the record as stopped on any exit from _submit that is not
a receipt, while no round has been opened, and re-raises. The ask_user
module docstring no longer says the loop never sees an exception.

Co-authored-by: Claude (claude-opus-5-5) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

The new revision resolves the stint-lifecycle issue that changed my prior stance. StintDriver.start() now marks a persisted first-round record stopped on any exceptional exit before the round opens, then re-raises the original cancellation or timeout. Already-opened rounds remain under their existing run/heartbeat lifecycle. The regression tests cover both a stopped task through a real question broker and the tool-ceiling path, and verify that a later start does not silently resume the unapproved stint.

I reviewed the 4ddee4ef5e58..9e56c76ebfc0 delta in the context of the complete change, repository rules/canonical terminology, relevant callers and history, backward compatibility, cancellation architecture, and test integrity. I found no new issue or weakened test. The remaining open inline thread belongs to its original reviewer, so I have not resolved or re-graded it.

Verification:

  • uv run pytest tests/test_question_broker.py tests/test_ask_user_tool.py tests/test_rpc_turn_cancel.py tests/test_acp_elicitation.py tests/test_acp_ask_user.py tests/test_playbook_stint.py -q: 369 passed.
  • git diff --check github/main...HEAD: passed.

@LivXue

LivXue commented Oct 23, 2026

Copy link
Copy Markdown
Member

Windows compatibility re-review of the complete nine-file diff at 9e56c76ebfc075815fb3656daddedc8b5cafea57, base/merge base b27b0ae97492f799ac3c551a8ca2bc844cbe9912. Inspected broker/ask-user/ACP cancellation, scheduler and RPC cancel callers, the new first-round stint cleanup, atomic record persistence, tests, contract text and all current discussions.

No Windows compatibility issue found in the inspected scope. Native Windows verification passed 303 related cancellation tests and both new first-round regression tests. Coverage includes calling-task versus future-only cancellation, timeout/EOF defaults, clarify.closed cleanup, and RPC/scheduler/ACP behavior.

Two additional probes reused the repository's first-round cancellation/ceiling scenarios under a real directory containing spaces and non-ASCII characters, and explicitly asserted ProactorEventLoop: 2 passed. They verify that the record becomes stopped, an unapproved round is not submitted, resume has nothing to take up, and the subsequent permitted start is fresh. This independently checks the fix discussed in the existing stint-lifecycle thread without adding a duplicate finding.

Runtime: native Windows, Python 3.13.12, UTF-8 mode; exact-head source imports and temporary uv/profile/Raven/OAuth/test resources were isolated outside the active checkout. All nine changed files were rechecked against the pinned head and git diff --check passed.

Exact verification commands, from the isolated head checkout
uv run --frozen --no-sync pytest tests/test_question_broker.py tests/test_ask_user_tool.py tests/test_rpc_turn_cancel.py tests/test_spine_scheduler.py tests/test_acp_elicitation.py tests/test_acp_ask_user.py tests/test_acp_questions.py -p no:randomly -n 0 -x --tb=short --basetemp C:\Users\livxu\AppData\Local\Temp\raven-windows-review\3d295dd1b92a4b4b84fe8b2eb91f85c0\checks\1073-cancellation\pytest --junitxml C:\Users\livxu\AppData\Local\Temp\raven-windows-review\3d295dd1b92a4b4b84fe8b2eb91f85c0\checks\1073-cancellation\junit.xml
uv run --frozen --no-sync pytest tests/test_playbook_stint.py::TestRunning::test_a_first_round_stopped_while_its_approval_waits_closes_the_stint tests/test_playbook_stint.py::TestRunning::test_a_first_round_whose_approval_outlives_a_tool_ceiling_closes_the_stint -p no:randomly -n 0 -x --tb=short --basetemp C:\Users\livxu\AppData\Local\Temp\raven-windows-review\3d295dd1b92a4b4b84fe8b2eb91f85c0\checks\1073-stint\pytest --junitxml C:\Users\livxu\AppData\Local\Temp\raven-windows-review\3d295dd1b92a4b4b84fe8b2eb91f85c0\checks\1073-stint\junit.xml
uv run --frozen --no-sync pytest C:\Users\livxu\AppData\Local\Temp\raven-windows-review\3d295dd1b92a4b4b84fe8b2eb91f85c0\probes\test_1073_windows.py -v -s -p no:randomly -n 0 -x --tb=short --basetemp C:\Users\livxu\AppData\Local\Temp\raven-windows-review\3d295dd1b92a4b4b84fe8b2eb91f85c0\checks\1073-windows\pytest --junitxml C:\Users\livxu\AppData\Local\Temp\raven-windows-review\3d295dd1b92a4b4b84fe8b2eb91f85c0\checks\1073-windows\junit.xml

The external probe emitted an unknown imported production_timing pytest-marker warning because its root did not use the repository config. Both async probe bodies executed and passed; this was not a product failure.

Limits: the ceiling test uses the repository's shortened 0.2s limit. No live browser Stop, ACP subprocess/channel session, LLM-backed stint, other Python version, real UNC share, full playbook/repository suite or concurrent-reader file-lock stress was exercised. These results do not establish full compatibility or a general merge verdict.

LivXue and others added 3 commits October 31, 2026 21:47
A first-round approval can stand for the broker's whole budget, and no
round is open to beat for. The record went stale after five minutes, so
a second request took the stint up through `resume` with `confirm=False`
and ran the round nobody approved. Claim the record before asking and
start a stamp-only beat; `StintStore.touch` moves only `touched_at_ms`
under the write lock, so a stop or an extend is not undone by the beat.

Close a first round that never opened on every non-receipt exit, but
re-read first: a pause, stop or extend that landed during the question
survives, and a record another process claimed is left to it. A failed
cleanup write is logged rather than raised, and only a cancellation or a
timeout is blamed on the approval. `extend` refuses a first round that
never opened, and `resume` asks before taking one up.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
…aits

`Task.cancelling()` is cumulative. A task that swallowed an earlier
cancellation kept the count for life, so a pending future cancelled by
someone else was mistaken for a new cancellation of the calling task and
raised out of `await_question`. Remember the count before the wait and
propagate only when it grows. The real ACP route is covered: a stopped
`AskViaTool` question now raises instead of answering empty.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>
The broker now raises when the calling task is cancelled, but the
`QuestionResponder` and `SupportsDirectAsk` papers, the ACP design note
and the proactivity reference still described a broker that never
raises. Update those and the ACP test doubles, which answered a
cancellation with an empty string as the broker used to.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

This revision adds three substantive follow-ups beyond the previously reviewed head: it finishes the asking contracts and design references, distinguishes new caller cancellation from a task's cumulative historical cancellation count, and hardens the first-round stint lifecycle with a live pre-approval claim, stamp-only heartbeat updates, safe cleanup, and re-confirmation on legacy/unopened resumes. I traced the broker, ACP, turn, stint start/resume/extend, heartbeat, atomic persistence, and cross-process guard paths and found no plain error.

Coverage included the complete github/main...HEAD diff, the 9e56c76ebfc0..180822b6aed2 delta, repository rules and canonical terminology, callers and history, backward compatibility, architecture boundaries, and whether tests were weakened. The new tests add the missing cancellation, cumulative-count, heartbeat, persistence, resume, extend, and cleanup cases. My own prior thread remains resolved; the remaining open thread was started by another reviewer and is theirs to close.

Verification:

  • Initial targeted run: 457 setup errors because the recreated environment lacked workspace package raven_everos; no test body ran.
  • uv sync --all-packages: passed.
  • uv run pytest tests/test_question_broker.py tests/test_ask_user_tool.py tests/test_rpc_turn_cancel.py tests/test_acp_elicitation.py tests/test_acp_ask_user.py tests/test_acp_questions.py tests/test_playbook_stint.py tests/test_stint_record.py tests/test_atomic_io.py -q: 457 passed, with 2 Python 3.13 multiprocessing deprecation warnings.
  • git diff --check github/main...HEAD: passed.

@LivXue

LivXue commented Oct 31, 2026

Copy link
Copy Markdown
Member

Windows compatibility review — PR #1073 @ 180822b6aed25e00489ad1d394a540a94adf1065

Scope: the PR's delta since the previously reviewed head 9e56c76ebf is 4 commits / 13 files (+528/−83): the question broker now counts only a cancellation that arrives while its wait is pending (task.cancelling() > cancels_before), a first-round stint approval is claimed/heartbeated before the question, StintStore.touch moves only the stamp under atomic_update, _close_first_round re-reads before writing, and resume re-asks when round one never opened; docs and tests follow. Base is b27b0ae974.

Finding (inherited, present at base — not introduced by this PR): a dead holder is never detected on Windows, so stint recovery waits for the five-minute stale window instead of resuming at once.

  • Where: raven/stint/record.py:354-369 (StintRecord.holder_gone), reached from abandoned() and the recovery/adrift paths this PR works on (raven/playbook/stint.py:1028, :1376; raven/stint/record.py:133).
  • Mechanism: holder_gone() only reports "gone" when os.kill(holder_pid, 0) raises ProcessLookupError. On Windows that call does not raise for a dead pid: it returns None (probed on this host with both Python 3.12.13 and 3.13.12 against a reaped child, and Windows signal.CTRL_C_EVENT is 0, not a TerminateProcess probe). The except OSError: return False then reports the holder as alive, so only stale() (heartbeat older than STALE_AFTER_SEC) can reclaim the stint.
  • Evidence: tests/test_playbook_stint.py::TestResume::test_a_dead_holder_is_as_good_as_a_quiet_one fails identically at this head and at base b27b0ae974 — assert [] == ['stint-x'] after writing a fresh stamp with a dead holder_pid. Windows impact: after a gateway crash mid-round, a restarted gateway cannot resume that stint until the heartbeat ages out (five minutes), whereas POSIX resumes immediately.
  • Remedy direction: make the liveness probe platform-aware — on Windows use OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) + GetExitCodeProcess() (STILL_ACTIVE) via ctypes (no new dependency), or add psutil.pid_exists; keep the existing host check and the ProcessLookupError rule for POSIX. Since the function's docstring states the "only a ProcessLookupError says gone" rule, the rule needs a Windows arm.

No new Windows-specific issue was found in this PR's delta. The new heartbeat path writes through atomic_update (portalocker-backed on Windows) and keeps every other field as read; _close_first_round re-reads under the same store; none of the changed code adds a path, argv, process, socket, encoding, or permission surface.

Verification (native Windows 11; detached worktrees D:\tmp\rv-1073 at head and D:\tmp\rv-base-1073 at base b27b0ae974; offline venv from each tree's lock; scratch HOME/USERPROFILE; PYTHONUTF8=1; PYTHONPATH pinned):

  • uv run --no-sync pytest tests/test_playbook_stint.py tests/test_question_broker.py tests/test_stint_record.py tests/test_ask_user_tool.py tests/test_acp_elicitation.py tests/test_acp_ask_user.py tests/test_acp_questions.py -q -n 0 --tb=line
    • head: 9 failed, 411 passed (69.8 s)
    • base: 9 failed, 395 passed (68.5 s)
    • the same nine failing IDs at both: eight tests/test_playbook_stint.py cases blocked by WinError 1314 symlink privileges, plus the dead-holder case above. The 16 added tests pass.
  • uv run --no-sync pytest "tests/test_playbook_stint.py::TestResume::test_a_dead_holder_is_as_good_as_a_quiet_one" -q -n 0 at head → 1 failed in 4.6 s, assert [] == ['stint-x'].
  • Probe on this host: os.kill(<pid of a waited-for child>, 0) returns None on Python 3.12.13 and 3.13.12; os.kill(999999, 0) raises OSError [WinError 87], not ProcessLookupError.

Coverage gaps: the eight symlink-privilege failures are environmental on this host and identical at base; no live gateway restart or cross-process stint recovery was exercised end to end.

The diff coverage gate asks for 90% of changed lines. The resume refusal
and refused dispatch left two close branches unexecuted, and the
stamp-only touch left its missing-file and unreadable-file fallbacks
unexecuted. Four tests close those seven lines.

Co-authored-by: Claude (claude-fable-5) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

The only delta from the previously reviewed clean head is a tests-only coverage commit. It adds focused cases for resumed first-round refusal, resumed dispatch failure, and the missing/unreadable StintStore.touch fallbacks. The tests exercise the intended branches, preserve the existing behavioral expectations, and do not weaken coverage.

I reviewed the 180822b6aed2..067eaf2d086b delta in the context of the complete github/main...HEAD change, repository rules, relevant callers and history, backward compatibility, architecture, and test integrity. No new finding. My own prior thread remains resolved; I did not alter another reviewer's thread.

Verification:

  • uv run pytest tests/test_playbook_stint.py tests/test_stint_record.py -q: 180 passed.
  • git diff --check github/main...HEAD: passed.

@LivXue

LivXue commented Oct 31, 2026

Copy link
Copy Markdown
Member

Windows compatibility re-review — PR #1073 @ 067eaf2d086b00d59ec1da6cdf1ccbd6da8fbf22

Scope: the delta since the previously reviewed head 180822b6ae is 1 commit / 2 files (+59), tests only: a refused first round taken up by resume is closed like start closes it, a resume whose dispatch is refused returns the record to interrupted, and StintStore.touch no-ops on a missing or unreadable record without rewriting the bad file. No production code changed.

Windows angle: nothing platform-specific was added; the new cases use plain files under tmp_path and the existing store, with no symlinks, subprocesses, or process-group assumptions.

Verification (native Windows 11; detached worktree D:\tmp\rv-1073b at head; offline venv from the same lock; scratch HOME/USERPROFILE; PYTHONUTF8=1; PYTHONPATH pinned):

  • uv run --no-sync pytest "tests/test_playbook_stint.py::TestResume::test_a_refused_first_round_taken_up_is_closed" "tests/test_playbook_stint.py::TestResume::test_resuming_a_round_that_cannot_start_leaves_it_interrupted" "tests/test_stint_record.py::test_touching_a_missing_stint_does_nothing" "tests/test_stint_record.py::test_touching_an_unreadable_stint_does_nothing" -q -n 0 -v → 4 passed in 19.95 s.
  • uv run --no-sync pytest tests/test_stint_record.py -q -n 0 → 8 passed in 4.56 s.

Previously reported inherited issue — still open at this head (source unchanged): tests/test_playbook_stint.py::TestResume::test_a_dead_holder_is_as_good_as_a_quiet_one still fails (1 failed in 6.46 s, assert [] == ['stint-x']) because raven/stint/record.py:354-369 (holder_gone) still treats only ProcessLookupError as "gone", and Windows os.kill(pid, 0) returns None for a dead pid (probed on Python 3.12.13 and 3.13.12). This commit adds tests around the first-round paths but does not change the liveness probe.

Coverage gaps: same as the previous review — the eight WinError 1314 symlink cases remain environmental, and no live gateway restart/cross-process recovery was exercised.

LivXue and others added 4 commits October 31, 2026 23:28
A pid is unique per host, not across hosts: `holder_gone` already compared
the pair, but the beat and the first-round close compared only the pid. A
record claimed by the same pid on another machine looked like ours, so our
beat kept it alive and our close could stop it under the other host.

`StintStore.touch` and `_close_first_round` now compare `holder_host` when
the record has one, and the beat passes the record's host. A record with no
host recorded is still accepted, so an older stint file stays usable.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The close re-read the record to preserve a concurrent pause, stop, extend
or claim, then wrote it back in a second lock acquisition. An update that
landed between the two was overwritten by the stop.

Read, checks and write now share one `write_transaction`, so an update lands
either before the read (and is preserved) or after the write (and wins).
The whole cleanup is also guarded: a record that cannot be read or written
is logged, and the cancellation or timeout that brought us here is what the
turn sees -- a cleanup error must not become the tool result.

Co-Authored-By: Claude Code <noreply@anthropic.com>
`extend` refuses a first round that never opened and tells the caller to
start the playbook again. The sentence always promised that restarting
"asks for approval", which is false for a `confirm: false` playbook -- and
the new first-round close makes that state reachable for one.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A first round turned down by the hourly dispatch budget writes the record
PAUSED and returns without opening a round. The pre-approval beat stayed
alive until its next tick, so `holding()` kept the CLI at "Holding this
terminal" for up to HEARTBEAT_EVERY_SEC after the pause was already final.

Co-Authored-By: Claude Code <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

Reviewed the four-commit delta from 067eaf2d086b and rechecked it in the complete github/main...HEAD diff. The cross-host holder comparison matches holder_gone, the first-round close is serialized under the existing re-entrant per-path write lock, the restart message follows spec.confirm, and the budget-pause path now releases its pre-approval beat immediately.

Coverage this round: repository rules (AGENTS.md, CLAUDE.md, and CONTEXT-MAP.md), the diff, affected callers and commit history, backward compatibility for hostless records, test-strengthening versus weakening, and the runtime/RPC architecture boundary. I found no concrete regression after trying to refute the concurrency and ownership changes.

Verification: git diff --check 067eaf2d086b..HEAD passed; uv run pytest tests/test_playbook_stint.py tests/test_stint_record.py -q passed (185 tests).

@LivXue

LivXue commented Oct 31, 2026

Copy link
Copy Markdown
Member

Windows compatibility re-review — PR #1073 @ 27ef8c23c7936b838a82f14d2c0d8af714368379

Scope: the delta since the previously reviewed head 067eaf2d is 4 commits / 4 files (+227/−29): the stint holder is now read as a pid+host pair in StintStore.touch and _close_first_round, the first-round close runs under one write_transaction, the pre-approval heartbeat is stopped when the budget pauses the stint, and the restart message only promises an approval when the playbook asks for one.

Windows angle: no new platform surface. The new write_transaction in raven/playbook/stint.py::_close_first_round takes the same portalocker (LockFileEx) sidecar lock as store.write; raven/utils/atomic_io.py::_locked is reentrant per thread and the block contains no await, so the nested store.write cannot self-deadlock on Windows, and the holder-host comparison uses socket.gethostname(), consistent within a host.

Previously reported inherited issue — still open (source unchanged): tests/test_playbook_stint.py::TestResume::test_a_dead_holder_is_as_good_as_a_quiet_one still fails identically at this head and at base b27b0ae974. raven/stint/record.py::holder_gone still treats only ProcessLookupError as "gone"; Windows os.kill(pid, 0) returns None for a dead pid (probed on Python 3.12.13 and 3.13.12), so a crashed holder is only reclaimed by the stale-heartbeat window. The new pid+host pairing does not change that probe.

Verification (native Windows 11; detached worktrees D:\tmp\rv-1073c at head and D:\tmp\rv-base-1073c at base b27b0ae974; offline venv from each lock; scratch HOME/USERPROFILE; PYTHONUTF8=1; PYTHONPATH pinned):

  • uv run --no-sync pytest tests/test_playbook_stint.py tests/test_stint_record.py -q -n 0 --tb=no
    • head: 9 failed, 176 passed (77.7 s)
    • base: 9 failed, 155 passed (72.6 s)
    • the sorted FAILED ID lists are identical (diff empty): eight WinError 1314 symlink-privilege cases plus the dead-holder case above. The 21 added tests pass.

Coverage gaps: the full suite was not rerun; the remaining eight failures are environmental on this host (symlink privileges) and match base; no live gateway restart or cross-process stint recovery was driven.

A close that lost the race to a round another caller had opened stopped
the beat table's current entry before checking whether it had a round to
close. That entry belonged to the running round, so a second host could
take the same checkout up once its stamp went stale.

Stop only the beat this caller started, and only when no round opened.

Co-authored-by: Claude (deepseek-flash) <noreply@anthropic.com>
LivXue and others added 4 commits November 1, 2026 12:39
`_submit` opens the round on the record object before it writes. A write
that failed between the two left the file without a run id while the
graph was running, and the first-round close then recorded the stint as
stopped -- which is not unfinished, so a second start could open another
stint on the same project.

Ask the caller's own object whether round one opened, and leave the
record running when it did.

Co-authored-by: Claude (deepseek-flash) <noreply@anthropic.com>
`resume` closed a first round that never opened on every Error receipt,
so a delegation pause, a validation refusal, or a question that could
not be delivered was recorded as "the first round was refused" and the
stint became terminal. Only the person's "not approved" is an answer to
that question; every other receipt leaves the record interrupted, as a
resume that never asked already did.

Co-authored-by: Claude (deepseek-flash) <noreply@anthropic.com>
The dispatch budget is charged after the first round's confirmation, so a
first round that the budget turned away was approved. It has no run id
for `_first_round_opened` to read, and a later resume asked the same
question again -- and could close "not approved" a round the person had
already approved when they could not answer a second time.

Record that approval on the stint when the budget pause follows it, and
let resume take the round up without asking again.

Co-authored-by: Claude (deepseek-flash) <noreply@anthropic.com>
Only the approval wait needs a claim before the round opens: that is the
wait a second process could otherwise take up. A resume that asks nobody
still claimed the stint, so a stop during its preflight left the record
looking held by a live process with no beat, and the next resume was
refused until the stamp went stale.

Claim only when this resume is going to ask; `_submit` claims again once
the round opens.

Co-authored-by: Claude (deepseek-flash) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; suggestions only, and they are marked inline.

Reviewed the five-commit delta from 27ef8c23c793 and rechecked it against the complete github/main...HEAD diff. The beat ownership, dispatched-round write failure, transient resume refusal, and budget-paused approval changes are internally consistent. One retry-delay edge remains in the no-confirm resume path; it has a working escape and is marked as a nonblocking follow-up inline.

Coverage this round: repository rules (AGENTS.md, CLAUDE.md, and CONTEXT-MAP.md), the diff, affected callers and commit history, backward compatibility of the new persisted field, test-strengthening versus weakening, and the runtime/RPC architecture boundary.

Verification: git diff --check 27ef8c23c793..HEAD passed; uv run pytest tests/test_playbook_stint.py tests/test_stint_record.py -q passed (190 tests). A direct state check confirmed that the no-confirm write preserves the remote holder but refreshes the timestamp enough for _held() to return true.

Comment thread raven/playbook/stint.py Resolved
@LivXue

LivXue commented Nov 1, 2026

Copy link
Copy Markdown
Member

Windows compatibility re-review — PR #1073 @ a03bef38e3418715639777b9627d332ea90fb125

Scope: the delta since the previously reviewed head 27ef8c23 is 5 commits / 3 files (+289/−21), one per finding of the independent review: a close that declines stops only the caller's own beat, a dispatched round whose write failed is left open (both the fresh and the caller's record are consulted), a resume refusal that the person did not make stays retryable (interrupted), a budget pause after a first-round "yes" records first_round_approved so a later resume does not re-ask, and a resume that asks nobody no longer claims the holder.

Windows angle: no new platform surface. The close still runs under write_transaction (the portalocker sidecar lock) with no await inside, and the new StintRecord.first_round_approved field round-trips through asdict() / the key-filtered from_dict(), so a record written before the field reads as not approved by default rather than failing to load.

Previously reported inherited issue — still open (source unchanged): tests/test_playbook_stint.py::TestResume::test_a_dead_holder_is_as_good_as_a_quiet_one still fails identically at this head and at base b27b0ae974: StintRecord.holder_gone still treats only ProcessLookupError as gone, while Windows os.kill(pid, 0) returns None for a dead pid, so a crashed holder is only reclaimed by the stale-heartbeat window (remedy unchanged: a Windows liveness probe via OpenProcess/GetExitCodeProcess).

Verification (native Windows 11; detached worktrees D:\tmp\rv-1073d at head and D:\tmp\rv-base-1073d at base b27b0ae974; offline venv from each lock; scratch HOME/USERPROFILE; PYTHONUTF8=1; PYTHONPATH pinned):

  • uv run --no-sync pytest tests/test_playbook_stint.py tests/test_stint_record.py -q -n 0 --tb=no
    • head: 9 failed, 181 passed (74.5 s)
    • base: 9 failed, 155 passed (81.3 s)
    • sorted FAILED ID lists are identical (diff empty): eight WinError 1314 symlink-privilege cases plus the dead-holder case above. The 26 added tests pass.

Coverage gaps: the full suite was not rerun; the remaining eight failures are environmental on this host and match base; no live gateway restart or cross-process stint recovery was driven.

No Windows-specific issue introduced by this fix pass.

LivXue and others added 4 commits November 1, 2026 18:15
A resume that asks nobody writes `running` before `_submit` so a second
caller cannot take the same round up while it preflights. Stopped there,
nothing put the record back: it kept a fresh stamp, and the next resume
was refused for the whole stale window although nothing advanced the
stint -- up to five minutes when the holder is another host that
`holder_gone()` cannot disprove.

Restore what the resume found, under the same write transaction the
first-round close uses: a pause stays a pause, a stale `running` becomes
`interrupted`, and a record whose round opened or that another process
has moved or claimed is left alone. The transient-refusal branch writes
through the same helper instead of a whole stale snapshot.

Co-authored-by: Claude (deepseek-flash) <noreply@anthropic.com>
The comment listed "a question that could not be delivered" as a
transient refusal that leaves the record interrupted. It does not: the
broker fails an undeliverable question safe to the default, `_confirmed`
reads that as no answer, and the driver closes the record as not
approved. Name the refusals that do reach the branch, and say where an
unanswered question lands, so the comment matches the code.

Co-authored-by: Claude (deepseek-flash) <noreply@anthropic.com>
The first round's approval can stand for the broker's whole budget, and
a `stop` or `pause` from another window can land while the question is
up. `_submit` then wrote its pre-question snapshot back, reviving a
record the person had just stopped: `advance` ends a stint stopped out
of band after its running round, and parks a paused one, but only if
the status survives the write.

Re-read the record and write the opened round under one
`write_transaction`. The status the person set is kept; a record that
cannot be read is replaced rather than losing a dispatched round's run
id.

Co-authored-by: Claude (deepseek-flash) <noreply@anthropic.com>
The broker and the tool are covered separately; nothing drove a lane
stop through the real scheduler, lane, broker and tool to prove the
cancellation reaches the task the broker is holding. The fake
`run_turn` is the seam where the loop runs a tool, so everything below
it is the real path.

Co-authored-by: Claude (deepseek-flash) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

Reviewed the four-commit delta from a03bef38e341 and rechecked it in the complete github/main...HEAD diff. The previous retry-delay follow-up is fixed: a no-confirm resume that stops before opening now restores paused/interrupted state under the write lock, while a concurrently opened or reclaimed round is left alone. The opened-round transaction also preserves an out-of-band stop or pause, and the new RPC-spine test reaches the real scheduler cancellation path while the question is pending.

Coverage this round: repository rules (AGENTS.md, CLAUDE.md, and CONTEXT-MAP.md), the diff, affected callers and commit history, persistence/backward compatibility, test-strengthening versus weakening, and the runtime/RPC architecture boundary. I found no concrete regression after refuting the state-restoration and concurrent-write candidates.

Verification: git diff --check a03bef38e341..HEAD passed; uv run pytest tests/test_playbook_stint.py tests/test_stint_record.py tests/test_rpc_spine.py -q passed (281 tests).

@LivXue

LivXue commented Nov 1, 2026

Copy link
Copy Markdown
Member

Second review round, fixed in four commits on 80a5ae12d.

  • 385102bd9 -- a resume that asks nobody and is stopped in preflight now
    puts the record back where it found it (a pause stays paused, a stale
    running becomes interrupted) under one write_transaction. This
    closes the retry window: _held() is false immediately, so the next
    resume is not refused for STALE_AFTER_SEC. The restore leaves a record
    alone when its round opened a new run id, or when another process moved or
    claimed it. (Reply in the stint thread has the two tests.)
  • 7968acd1f -- the transient-refusal comment now names the refusals that
    actually reach that branch (delegation paused, a validation refusal, a
    dispatch that never started); an undeliverable or timed-out question is
    _NOT_APPROVED and closes, which is what the code already did.
  • 61b6341be -- _submit records an opened round under write_transaction
    and keeps a stop/pause that landed while the approval question stood.
    Previously the pre-question snapshot was written back, reviving a record
    advance would otherwise end or park. An unreadable record is replaced
    rather than losing a dispatched round's run id.
  • 80a5ae12d -- an end-to-end test stops a lane while the real broker holds
    its question: real scheduler, lane, QuestionBroker and AskUserTool,
    with the fake at the loop's run_turn seam. It fails if the broker
    answers the cancellation with the default.

Verification on the pushed tree (native Windows, Python 3.12.13, isolated
USERPROFILE/TEMP, uv run --no-sync):

pytest tests/test_playbook_stint.py tests/test_stint_record.py
-> 186 passed, 9 failed

pytest <the 15 related files listed in the description>
-> 1200 passed, 18 failed, 15 skipped

The 18 failures are the same environment-only IDs as the merge base: 8
WinError 1314 symlink-privilege cases, 1 dead-pid os.kill case, 2
test_rpc_playbooks path cases, and 7 POSIX reference paths in
test_subagent_dag_core. Each new test fails when its fix is withdrawn: the
broker re-raise revert fails the end-to-end test, the record-write and
restore reverts fail the three stint tests, and the read-fallback revert
fails the unreadable-record test. ruff check and ruff format --check
pass. CI on the pushed head: 22 passed, 0 failed, 2 skipped (the first
Windows ACP stdio run hit a PermissionError on its temp store.json in
test_acp_mirasim_server.py; the rerun of that job passed).

@LivXue

LivXue commented Nov 1, 2026

Copy link
Copy Markdown
Member

Windows compatibility re-review — PR #1073 @ 80a5ae12d53abb5af1f7124f6a7bee2e95151abb

Scope: the delta since the previously reviewed head a03bef38e is 4 commits / 3 files (+353/−13): _restore_unopened puts a resume's record back when its round never opened (a stale running becomes interrupted) under the store's write transaction, _record_opened_round records a dispatched round without reviving an out-of-band stop/pause, the round-opened helpers are generalised with _round_opened_since, and a new tests/test_rpc_spine.py case cancels a turn while its question is on screen.

Windows angle: no new platform surface. Both new write paths run under write_transaction (the portalocker sidecar lock) with no await inside, and the re-read-before-write shape matches the earlier fixes; nothing branches on the platform.

Previously reported inherited issue — still open (source unchanged): tests/test_playbook_stint.py::TestResume::test_a_dead_holder_is_as_good_as_a_quiet_one still fails identically at this head and at base b27b0ae974 because StintRecord.holder_gone still treats only ProcessLookupError as gone, and Windows os.kill(pid, 0) returns None for a dead pid (remedy unchanged: a Windows liveness probe via OpenProcess/GetExitCodeProcess).

Verification (native Windows 11; detached worktrees D:\tmp\rv-1073e at head and D:\tmp\rv-base-1073e at base b27b0ae974; offline venv from each lock; scratch HOME/USERPROFILE; PYTHONUTF8=1; PYTHONPATH pinned):

  • uv run --no-sync pytest tests/test_playbook_stint.py tests/test_stint_record.py tests/test_rpc_spine.py -q -n 0 --tb=no
    • head: 9 failed, 272 passed (89.2 s)
    • base: 9 failed, 240 passed (85.9 s)
    • the sorted FAILED ID lists are identical (diff empty): eight WinError 1314 symlink-privilege cases plus the dead-holder case above. The 32 added tests pass.

Coverage gaps: the full suite was not rerun; the remaining eight failures are environmental on this host and match base; no live gateway restart or cross-process stint recovery was driven.

No Windows-specific issue introduced by this delta.

CONTEXT.md keeps "task" for a unit of delegated work, not for the
asyncio object a write or a question waits on. Two docstrings added with
the review fixes used it for that object; say caller in both places.

Co-authored-by: Claude (deepseek-flash) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

No blockers; this can merge as far as I am concerned.

Reviewed the one-commit delta from 80a5ae12d53a and checked it in the complete github/main...HEAD diff. It changes only two explanatory uses of “task” to “caller”, matching the broker contract without altering code, assertions, or test coverage.

Coverage this round: repository rules (AGENTS.md, CLAUDE.md, and CONTEXT-MAP.md), the diff and commit history, the already-reviewed callers and architecture boundary, backward compatibility, and whether tests were weakened. With no executable delta, those behavioral areas remain unchanged and no concrete failure was introduced.

Verification: git diff --check 80a5ae12d53a..HEAD passed; uv run pytest tests/test_playbook_stint.py tests/test_rpc_spine.py -q passed (273 tests).

@LivXue LivXue left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved at ecf162023. The two docstrings from the re-review now name the awaited coroutine a caller, the word the project keeps for it, and no code or assertions changed. The review round's behavioral fixes keep their six regression tests, each red when its fix is withdrawn. CI is green on this head (22 passed, 2 skipped), and every thread is resolved.

@LivXue
LivXue merged commit 68b35d9 into main Nov 1, 2026
25 checks passed
@LivXue
LivXue deleted the fix/stop_turn_waiting_on_question branch November 1, 2026 11:24
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.

3 participants