-
Notifications
You must be signed in to change notification settings - Fork 0
Make the Stop hook async and UserPromptSubmit single-process (v3.0.3) #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
379ffc7
1be683e
6d67af2
b27e9d9
2660b9c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,7 @@ | |
| "Stop": [ | ||
| { | ||
| "hooks": [ | ||
| { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/update-handoff.sh\"" } | ||
| { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/update-handoff.sh\"", "async": true } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With the Stop hook now backgrounded, a user who submits the next prompt immediately after crossing the token threshold can hit Useful? React with 👍 / 👎. |
||
| ] | ||
| } | ||
| ] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -451,6 +451,22 @@ if os.environ.get('PREP_COMPACT_TEST_REPLACE_FAIL'): | |
| except OSError: pass | ||
| sys.exit(0) | ||
|
|
||
| # Async-write ordering guard: with the Stop hook now async, a slower run for | ||
| # the same session can resume after a newer run already replaced the handoff. | ||
| # os.replace prevents partial reads but not last-writer-wins reordering, so skip | ||
| # the replace when the on-disk handoff's transcript_mtime_at_write is newer than | ||
| # this run's (a stale run must not clobber a fresher handoff). Fail-open. | ||
| try: | ||
| if os.path.exists(handoff_path): | ||
| with open(handoff_path, 'r', encoding='utf-8') as f: | ||
| _existing = json.load(f) | ||
| _emt = _existing.get('transcript_mtime_at_write') | ||
|
Comment on lines
+460
to
+463
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In an async overlap, this read-before-replace guard can still let an older Stop run clobber a newer handoff: run A can pass this check while the file is absent or older, then run B writes a fresher handoff, and finally run A resumes at Useful? React with 👍 / 👎. |
||
| if isinstance(_emt, (int, float)) and _emt > transcript_mtime: | ||
| os.unlink(tmp_path) | ||
| sys.exit(0) | ||
|
Comment on lines
+464
to
+466
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two async Stop hooks overlap and the later run has already written a handoff for a newer transcript, this branch deletes the older run's temp file and exits. If the rapid subsequent turn pushed a user request or file reference out of the 1 MB transcript tail, the newer run could not capture that data from the prior handoff yet, and this skip drops the only run that did capture it; Useful? React with 👍 / 👎. |
||
| except Exception: | ||
| pass | ||
|
|
||
| # Replace with one retry on PermissionError (Windows: target held open). | ||
| for attempt in (1, 2): | ||
| try: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -91,7 +91,7 @@ fi | |
| # After T6: EXPECTED_PASS=87, SKIPPED=39 (T6 tests not Stop-dep) | ||
| # After T7: EXPECTED_PASS=88, SKIPPED=39 (T7 test not Stop-dep) | ||
| # PR-comment fix: +3 Stop-dep (T-32cap +1 short-still-captured, T-32prior +2) | ||
| EXPECTED_PASS=130 | ||
| EXPECTED_PASS=131 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| SKIPPED=0 | ||
|
|
||
| run_hook() { | ||
|
|
@@ -667,6 +667,22 @@ PRIOR_INTACT=$("$PY" -c "import json; d=json.load(open('$HANDOFF')); print('yes' | |
| assert_eq "T-38p: simulated replace fail -> prior preserved (sentinel intact)" "yes" "$PRIOR_INTACT" | ||
| assert_true "T-38p: stderr warning printed on simulated fail" '[[ "$ERR" == *"replace failed twice, prior preserved"* ]]' | ||
|
|
||
| # --- T-38g: async ordering guard — a newer on-disk handoff is NOT clobbered by an older run. | ||
| # Pre-write a handoff with a far-future transcript_mtime_at_write; the run (whose | ||
| # fixture transcript has a normal mtime) must SKIP the replace, leaving the | ||
| # sentinel written_at untouched. Distinguishes the guard from cumulative_files | ||
| # merge (which would preserve prior entries even on a write). | ||
| cleanup | ||
| "$PY" -c " | ||
| import json | ||
| prior = {'version':'3.0','session_id':'s38g','cwd':'/sample/cwd','transcript_path':'/x','transcript_mtime_at_write':9999999999.0,'written_at':'2099-01-01T00:00:00Z','cumulative_files':[],'recent_files':[],'in_progress_status':'unknown','in_progress':[],'recent_task_launches':[],'recent_user_requests':[]} | ||
| with open('$(to_native "$CACHE/handoff-s38g.json")','w') as f: json.dump(prior, f) | ||
| " | ||
| run_stop_hook '{"session_id":"s38g","transcript_path":"'"$FIX/transcript-handoff-multi-turn.jsonl"'","cwd":"/sample/cwd","permission_mode":"default","hook_event_name":"Stop"}' >/dev/null | ||
| HANDOFF=$(to_native "$CACHE/handoff-s38g.json") | ||
| WRITTEN_AT=$("$PY" -c "import json; d=json.load(open('$HANDOFF')); print(d.get('written_at',''))") | ||
| assert_eq "T-38g: newer on-disk handoff preserved (guard skips stale replace)" "2099-01-01T00:00:00Z" "$WRITTEN_AT" | ||
|
|
||
| # T-52: file noise filter — .git/temp/plugin-data excluded, real path kept | ||
| cleanup | ||
| FIX_ENV="$FIX" "$PY" -c " | ||
|
|
@@ -754,7 +770,7 @@ assert_eq "T-56: legit =-char path kept" "yes" "$("$PY" -c "import json;d=json | |
| assert_eq "T-56: shell-junk line rejected" "no" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any(('SCRIPT_DIR' in p) or ('SKILL=' in p) or ('prep-compact/SKILL.md' in p) for p in d['recent_files']) else 'no')")" | ||
|
|
||
| else | ||
| SKIPPED=59 | ||
| SKIPPED=60 | ||
| fi # STOP_FIXTURE_OK | ||
|
|
||
| # =================================================================== | ||
|
|
@@ -828,7 +844,11 @@ assert_true "T-46: valid lower-root candidate chosen" '[[ "$RPATH" == *"zzz-inli | |
| # T-46b semantic-malformed (cwd is a list, not a string) is skipped, not fatal | ||
| reset_pdata | ||
| mkdir -p "$PDATA/aaa-inline" | ||
| "$PY" -c "import json;json.dump({'cwd':['oops']},open(r'$PDATA/aaa-inline/handoff-sidA.json','w'))" | ||
| # to_native: the path is interpolated INTO the python -c code, so MSYS does not | ||
| # translate it (unlike an argv path); without this, Windows python resolves the | ||
| # /tmp/... MSYS path to a non-existent C:\tmp\... and the malformed fixture is | ||
| # never written, making this test pass for the wrong reason. | ||
| "$PY" -c "import json;json.dump({'cwd':['oops']},open(r'$(to_native "$PDATA/aaa-inline/handoff-sidA.json")','w'))" | ||
| write_handoff "zzz-inline" "sidA" "C:/proj/one" | ||
| run_resolve_f "sidA" "C:/proj/one" | ||
| assert_eq "T-46b: non-string cwd skipped, scan continues -> HIT" "HIT" "$RSTATUS" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The official Claude Code hook docs state that async hooks start in the background and Claude continues without waiting, so with this flag a user can submit the next prompt before
update-handoff.shhas replacedhandoff-<sid>.json.check-context-size.shonly checks that the handoff file exists before telling the model it “is current”, so in that window/prep-compactcan read the previous turn's files/todos/requests while the reminder claims it is fresh. Either keep this hook synchronous or add a freshness/in-progress check before advertising the handoff as current.Useful? React with 👍 / 👎.