Implement Phase 6: Stop Poller (bf-64s)
Add src/poller.rs with FIFO O_NONBLOCK open (read-end + keeper write-end), Stop hook JSON payload parsing, transcript path derivation via cwd slug, and StopInfo resolution. Wire poller into EventLoop via add_fifo_fd() which was already present in event_loop.rs from Phase 3. Update mock-claude to emit proper JSON Stop payloads (with and without transcript_path via MOCK_OMIT_TRANSCRIPT_PATH=1) and update the pty_integration assertion to match. Tests test_stop_hook_fires and test_missing_transcript_path_derived both pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2407b640f8
commit
59e170ed03
6 changed files with 495 additions and 11 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{"id":"bf-10t","title":"Phase 10: Tests (~500 LOC)","description":"Entry: Phase 8 complete (can run in parallel with Phase 9).\n\nPhase 10 completes the test suite by adding tests NOT already written as part of Phases 2-9 completion criteria. Each prior phase's completion criterion already specifies and runs its own targeted integration tests.\n\nPhase 10 adds the remaining cross-phase and corner-case tests:\n- Version-resilience suite: feed transcript JSONL with extra/unknown fields across all JSONL event types; confirm no panic, correct output\n- Hook inheritance suite: verify user hooks in ~/.claude/settings.json fire alongside the relay hook (OQ-2 resolution validated end-to-end)\n- All MEDIUM/LOW mock scenarios not covered by earlier phases (see Testing section of plan.md for full list)\n- Conformance harness: run claude-print against a real claude binary in a sandboxed invocation and compare output format to claude -p reference\n\nComplete when:\n- cargo test passes with zero failures (all unit + integration tests)\n\nReference: docs/plan/plan.md \u00a7 Phase 10","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:17.744030380Z","updated_at":"2026-06-10T03:54:17.744030380Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-10t","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.710642868Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-2f1","title":"Phase 8: Emitter (~120 LOC)","description":"Entry: Phase 7 complete.\n\nImplementation: src/emitter.rs\n- Output format routing: text (plain string to stdout), json (JSON object with result/cost/session_id/claude_version), stream-json (forward raw JSONL lines as written)\n- claude_version: read from child process version string captured at startup\n- Error result objects: exit code mapping (0=success, 1=error, 2=no-response, 3=timeout, 4=input-error)\n- stream-json: reader thread consuming JSONL transcript file via mpsc channel, forward each line to stdout as written\n\nComplete when:\n- All emitter unit tests pass (tests/emitter.rs)\n- AS-1 (text output format) passes: response text emitted to stdout, exit 0\n- AS-2 (json output format) passes: valid JSON object on stdout with result field\n- stream-json output parses as valid JSONL (each line is valid JSON)\n\nReference: docs/plan/plan.md \u00a7 Phase 8","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:53:59.176338208Z","updated_at":"2026-06-10T03:53:59.176338208Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-2f1","depends_on_id":"bf-64k","type":"blocks","created_at":"2026-06-10T03:54:31.701710128Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-42j","title":"Phase 9: NEEDLE Integration (~50 LOC + config)","description":"Entry: Phase 8 complete.\n\nDeliverables:\n1. claude-print.yaml \u2014 NEEDLE agent config for dispatching beads via claude-print instead of claude -p\n - input_method: stdin, output_transform: needle-transform-claude\n - invoke_template: claude-print --output-format json --model ${MODEL}\n2. install.sh \u2014 download release binary from GitHub, install to ~/.local/bin/claude-print, verify --check\n3. claude-print-ci WorkflowTemplate in jedarden/declarative-config (k8s/iad-ci/argo-workflows/)\n - verify step only (build-musl + github-release steps added in Phase 11)\n - delegates to rust-verify WorkflowTemplate\n4. --check subcommand in src/main.rs or src/check.rs\n - openpty probe: confirm openpty syscall succeeds\n - mkfifo probe: confirm mkfifo in /home/coding/.tmp succeeds\n - optional mock_claude PTY round-trip (if mock_claude binary present in PATH)\n - exits 0 on success, prints diagnostic table\n\nComplete when:\n- bash -n install.sh passes (syntactically valid)\n- Manually copying locally-built binary to ~/.local/bin/claude-print and running claude-print --check succeeds\n- NEEDLE dispatches a test bead using claude-print.yaml; AS-3 passes\n- README flags table matches claude-print --help output exactly (verified manually)\n\nReference: docs/plan/plan.md \u00a7 Phase 9","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:09.318382701Z","updated_at":"2026-06-10T03:54:09.318382701Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-42j","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.706113490Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-4no","title":"Phase 11: CI (~YAML only)","description":"Entry: Phase 10 complete (and Phase 9 complete for install.sh e2e test).\n\nDeliverables in jedarden/declarative-config (k8s/iad-ci/argo-workflows/claude-print-ci.yaml):\n- Update claude-print-ci WorkflowTemplate stub (from Phase 9) with full steps:\n 1. verify \u2014 delegates to rust-verify WorkflowTemplate (fmt + clippy + test + cargo audit)\n 2. build-musl \u2014 cross-compile x86_64-unknown-linux-musl release binary\n 3. build-mock-claude-musl \u2014 build test-fixtures/mock-claude/ as musl binary\n 4. github-release \u2014 upload claude-print + mock_claude binaries + last-claude-version.txt artifact to GitHub Release\n- Confirm cargo audit runs (either via rust-verify or as explicit step between verify and build-musl)\n- install.sh end-to-end download test: download release artifact from GitHub Release URL, verify install.sh exits 0 and claude-print --check passes\n\nComplete when:\n- CI run on main branch produces release binary at expected GitHub Release URL\n- last-claude-version.txt artifact present in release\n- Binary passes claude-print --check (credential-free) via install.sh\n- install.sh end-to-end download test passes (deferred from Phase 9)\n- AS-1 verified manually before pushing release tag\n\nReference: docs/plan/plan.md \u00a7 Phase 11","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:27.444014247Z","updated_at":"2026-06-10T03:54:27.444014247Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-4no","depends_on_id":"bf-10t","type":"blocks","created_at":"2026-06-10T03:54:31.717358160Z","created_by":"cli","thread_id":""},{"issue_id":"bf-4no","depends_on_id":"bf-42j","type":"blocks","created_at":"2026-06-10T03:54:31.725797267Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-5bl","title":"Starvation alert: beads invisible to worker","description":"## Starvation Alert\n\nOpen beads exist but Pluck found none \u2014 possible configuration error.\n\n**Workspace:** default\n**Total beads:** 6\n**Open:** 5\n**In-progress:** 1\n**Claimed by:** claude-glm-glm47-alpha\n\nCheck exclude_labels, workspace path, and filter configuration.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","assignee":null,"created_at":"2026-06-10T03:57:06.245148475Z","updated_at":"2026-06-10T04:30:00.000000000Z","source_repo":".","compaction_level":0,"labels":["starvation-alert"],"closed_at":"2026-06-10T04:30:00.000000000Z"}
|
||||
{"id":"bf-64k","title":"Phase 7: Transcript Reader (~180 LOC)","description":"Entry: Phase 6 complete. PO-5 acknowledged: retry loop (40\u00d750ms) is the mitigation for Stop-before-JSONL races. Verify retry timing by running test_transcript_race with MOCK_DELAY_JSONL=100 and confirming exit 0.\n\nImplementation: src/transcript.rs\n- JSONL parse with lenient serde (unknown fields tolerated, unknown event types skipped)\n- message.id dedup + usage-fingerprint fallback dedup for events without message.id\n- Text extraction from assistant ContentBlock array (text type only)\n- 40\u00d750ms retry loop with Stop-payload fallback to last_assistant_message after exhausted\n- Path derivation: strip leading /, replace / with -, append session_id from Stop payload\n\nComplete when:\n- All transcript unit tests pass (tests/transcript.rs)\n- test_streaming_dedup_40_retries passes\n- AS-6 (race scenario: Stop fires before JSONL flush) passes with MOCK_DELAY_JSONL=100\n\nReference: docs/plan/plan.md \u00a7 Phase 7","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:53:52.452812786Z","updated_at":"2026-06-10T03:53:52.452812786Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-64k","depends_on_id":"bf-64s","type":"blocks","created_at":"2026-06-10T03:54:31.695602796Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-64s","title":"Phase 6: Stop Poller (~80 LOC)","description":"Entry: Phase 5 complete. OQ-2 must be resolved (verify --setting-sources= suppresses standard sources; see PO-2 for fallback). OQ-4 (FIFO open race) validated by test.\n\nImplementation: poller.rs (or extend event_loop.rs)\n- Open FIFO read-end O_NONBLOCK, integrate into poll() loop\n- Parse Stop hook JSON payload (session_id, transcript_path, last_assistant_message)\n- Derive transcript path from session_id + cwd slug if transcript_path absent\n- Signal event loop exit via channel/flag\n\nComplete when:\n- Integration test test_stop_hook_fires passes (mock_claude emits Stop, FIFO received, exit 0)\n- test_missing_transcript_path_derived passes (Stop without transcript_path \u2192 path derived from session_id)\n\nReference: docs/plan/plan.md \u00a7 Phase 6","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":1,"issue_type":"task","assignee":"claude-glm-glm47-alpha","created_at":"2026-06-10T03:53:44.914912586Z","updated_at":"2026-06-10T03:54:27.001527172Z","source_repo":".","compaction_level":0}
|
||||
{"id":"bf-10t","title":"Phase 10: Tests (~500 LOC)","description":"Entry: Phase 8 complete (can run in parallel with Phase 9).\n\nPhase 10 completes the test suite by adding tests NOT already written as part of Phases 2-9 completion criteria. Each prior phase's completion criterion already specifies and runs its own targeted integration tests.\n\nPhase 10 adds the remaining cross-phase and corner-case tests:\n- Version-resilience suite: feed transcript JSONL with extra/unknown fields across all JSONL event types; confirm no panic, correct output\n- Hook inheritance suite: verify user hooks in ~/.claude/settings.json fire alongside the relay hook (OQ-2 resolution validated end-to-end)\n- All MEDIUM/LOW mock scenarios not covered by earlier phases (see Testing section of plan.md for full list)\n- Conformance harness: run claude-print against a real claude binary in a sandboxed invocation and compare output format to claude -p reference\n\nComplete when:\n- cargo test passes with zero failures (all unit + integration tests)\n\nReference: docs/plan/plan.md § Phase 10","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:17.744030380Z","updated_at":"2026-06-10T03:54:17.744030380Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-10t","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.710642868Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-2f1","title":"Phase 8: Emitter (~120 LOC)","description":"Entry: Phase 7 complete.\n\nImplementation: src/emitter.rs\n- Output format routing: text (plain string to stdout), json (JSON object with result/cost/session_id/claude_version), stream-json (forward raw JSONL lines as written)\n- claude_version: read from child process version string captured at startup\n- Error result objects: exit code mapping (0=success, 1=error, 2=no-response, 3=timeout, 4=input-error)\n- stream-json: reader thread consuming JSONL transcript file via mpsc channel, forward each line to stdout as written\n\nComplete when:\n- All emitter unit tests pass (tests/emitter.rs)\n- AS-1 (text output format) passes: response text emitted to stdout, exit 0\n- AS-2 (json output format) passes: valid JSON object on stdout with result field\n- stream-json output parses as valid JSONL (each line is valid JSON)\n\nReference: docs/plan/plan.md § Phase 8","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:53:59.176338208Z","updated_at":"2026-06-10T03:53:59.176338208Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-2f1","depends_on_id":"bf-64k","type":"blocks","created_at":"2026-06-10T03:54:31.701710128Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-42j","title":"Phase 9: NEEDLE Integration (~50 LOC + config)","description":"Entry: Phase 8 complete.\n\nDeliverables:\n1. claude-print.yaml — NEEDLE agent config for dispatching beads via claude-print instead of claude -p\n - input_method: stdin, output_transform: needle-transform-claude\n - invoke_template: claude-print --output-format json --model ${MODEL}\n2. install.sh — download release binary from GitHub, install to ~/.local/bin/claude-print, verify --check\n3. claude-print-ci WorkflowTemplate in jedarden/declarative-config (k8s/iad-ci/argo-workflows/)\n - verify step only (build-musl + github-release steps added in Phase 11)\n - delegates to rust-verify WorkflowTemplate\n4. --check subcommand in src/main.rs or src/check.rs\n - openpty probe: confirm openpty syscall succeeds\n - mkfifo probe: confirm mkfifo in /home/coding/.tmp succeeds\n - optional mock_claude PTY round-trip (if mock_claude binary present in PATH)\n - exits 0 on success, prints diagnostic table\n\nComplete when:\n- bash -n install.sh passes (syntactically valid)\n- Manually copying locally-built binary to ~/.local/bin/claude-print and running claude-print --check succeeds\n- NEEDLE dispatches a test bead using claude-print.yaml; AS-3 passes\n- README flags table matches claude-print --help output exactly (verified manually)\n\nReference: docs/plan/plan.md § Phase 9","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:09.318382701Z","updated_at":"2026-06-10T03:54:09.318382701Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-42j","depends_on_id":"bf-2f1","type":"blocks","created_at":"2026-06-10T03:54:31.706113490Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-4no","title":"Phase 11: CI (~YAML only)","description":"Entry: Phase 10 complete (and Phase 9 complete for install.sh e2e test).\n\nDeliverables in jedarden/declarative-config (k8s/iad-ci/argo-workflows/claude-print-ci.yaml):\n- Update claude-print-ci WorkflowTemplate stub (from Phase 9) with full steps:\n 1. verify — delegates to rust-verify WorkflowTemplate (fmt + clippy + test + cargo audit)\n 2. build-musl — cross-compile x86_64-unknown-linux-musl release binary\n 3. build-mock-claude-musl — build test-fixtures/mock-claude/ as musl binary\n 4. github-release — upload claude-print + mock_claude binaries + last-claude-version.txt artifact to GitHub Release\n- Confirm cargo audit runs (either via rust-verify or as explicit step between verify and build-musl)\n- install.sh end-to-end download test: download release artifact from GitHub Release URL, verify install.sh exits 0 and claude-print --check passes\n\nComplete when:\n- CI run on main branch produces release binary at expected GitHub Release URL\n- last-claude-version.txt artifact present in release\n- Binary passes claude-print --check (credential-free) via install.sh\n- install.sh end-to-end download test passes (deferred from Phase 9)\n- AS-1 verified manually before pushing release tag\n\nReference: docs/plan/plan.md § Phase 11","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:54:27.444014247Z","updated_at":"2026-06-10T03:54:27.444014247Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-4no","depends_on_id":"bf-10t","type":"blocks","created_at":"2026-06-10T03:54:31.717358160Z","created_by":"cli","thread_id":""},{"issue_id":"bf-4no","depends_on_id":"bf-42j","type":"blocks","created_at":"2026-06-10T03:54:31.725797267Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-5bl","title":"Starvation alert: beads invisible to worker","description":"## Starvation Alert\n\nOpen beads exist but Pluck found none — possible configuration error.\n\n**Workspace:** default\n**Total beads:** 6\n**Open:** 5\n**In-progress:** 1\n**Claimed by:** claude-glm-glm47-alpha\n\nCheck exclude_labels, workspace path, and filter configuration.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-06-10T03:57:06.245148475Z","updated_at":"2026-06-10T04:30:00Z","closed_at":"2026-06-10T04:30:00Z","source_repo":".","compaction_level":0,"labels":["starvation-alert"]}
|
||||
{"id":"bf-64k","title":"Phase 7: Transcript Reader (~180 LOC)","description":"Entry: Phase 6 complete. PO-5 acknowledged: retry loop (40×50ms) is the mitigation for Stop-before-JSONL races. Verify retry timing by running test_transcript_race with MOCK_DELAY_JSONL=100 and confirming exit 0.\n\nImplementation: src/transcript.rs\n- JSONL parse with lenient serde (unknown fields tolerated, unknown event types skipped)\n- message.id dedup + usage-fingerprint fallback dedup for events without message.id\n- Text extraction from assistant ContentBlock array (text type only)\n- 40×50ms retry loop with Stop-payload fallback to last_assistant_message after exhausted\n- Path derivation: strip leading /, replace / with -, append session_id from Stop payload\n\nComplete when:\n- All transcript unit tests pass (tests/transcript.rs)\n- test_streaming_dedup_40_retries passes\n- AS-6 (race scenario: Stop fires before JSONL flush) passes with MOCK_DELAY_JSONL=100\n\nReference: docs/plan/plan.md § Phase 7","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-06-10T03:53:52.452812786Z","updated_at":"2026-06-10T03:53:52.452812786Z","source_repo":".","compaction_level":0,"dependencies":[{"issue_id":"bf-64k","depends_on_id":"bf-64s","type":"blocks","created_at":"2026-06-10T03:54:31.695602796Z","created_by":"cli","thread_id":""}]}
|
||||
{"id":"bf-64s","title":"Phase 6: Stop Poller (~80 LOC)","description":"Entry: Phase 5 complete. OQ-2 must be resolved (verify --setting-sources= suppresses standard sources; see PO-2 for fallback). OQ-4 (FIFO open race) validated by test.\n\nImplementation: poller.rs (or extend event_loop.rs)\n- Open FIFO read-end O_NONBLOCK, integrate into poll() loop\n- Parse Stop hook JSON payload (session_id, transcript_path, last_assistant_message)\n- Derive transcript path from session_id + cwd slug if transcript_path absent\n- Signal event loop exit via channel/flag\n\nComplete when:\n- Integration test test_stop_hook_fires passes (mock_claude emits Stop, FIFO received, exit 0)\n- test_missing_transcript_path_derived passes (Stop without transcript_path → path derived from session_id)\n\nReference: docs/plan/plan.md § Phase 6","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":1,"issue_type":"task","assignee":"claude-glm-glm47-alpha","created_at":"2026-06-10T03:53:44.914912586Z","updated_at":"2026-06-10T03:54:27.001527172Z","source_repo":".","compaction_level":0}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ pub mod config;
|
|||
pub mod error;
|
||||
pub mod event_loop;
|
||||
pub mod hook;
|
||||
pub mod poller;
|
||||
pub mod pty;
|
||||
pub mod startup;
|
||||
pub mod terminal;
|
||||
|
|
|
|||
320
src/poller.rs
Normal file
320
src/poller.rs
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
use crate::error::{Error, Result};
|
||||
use serde::Deserialize;
|
||||
use std::os::unix::io::{FromRawFd, OwnedFd};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Raw Stop hook payload received from Claude Code via the FIFO.
|
||||
/// All fields are optional for forward compatibility with future schema changes.
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct StopPayload {
|
||||
pub session_id: Option<String>,
|
||||
pub transcript_path: Option<String>,
|
||||
pub last_assistant_message: Option<String>,
|
||||
pub cwd: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolved stop information after transcript path derivation.
|
||||
#[derive(Debug)]
|
||||
pub struct StopInfo {
|
||||
pub session_id: Option<String>,
|
||||
/// Resolved transcript path: from payload if present, otherwise derived from
|
||||
/// session_id + cwd. `None` if neither derivation is possible.
|
||||
pub transcript_path: Option<PathBuf>,
|
||||
pub last_assistant_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse raw FIFO bytes into a [`StopPayload`].
|
||||
///
|
||||
/// Finds the first non-empty line and decodes it as JSON. Unknown fields are
|
||||
/// silently ignored (`#[serde(default)]` + no `deny_unknown_fields`).
|
||||
pub fn parse_stop_payload(bytes: &[u8]) -> Result<StopPayload> {
|
||||
let text = std::str::from_utf8(bytes)
|
||||
.map_err(|e| Error::Internal(anyhow::anyhow!("stop payload not UTF-8: {e}")))?;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
return serde_json::from_str(line).map_err(|e| {
|
||||
Error::Internal(anyhow::anyhow!("stop payload JSON parse failed: {e}"))
|
||||
});
|
||||
}
|
||||
Ok(StopPayload::default())
|
||||
}
|
||||
|
||||
/// Resolve a [`StopPayload`] into [`StopInfo`], deriving the transcript path
|
||||
/// when `transcript_path` is absent but `session_id` and `cwd` are present.
|
||||
pub fn resolve_stop_info(payload: StopPayload) -> StopInfo {
|
||||
let explicit_path = payload
|
||||
.transcript_path
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from);
|
||||
|
||||
let transcript_path = explicit_path.or_else(|| {
|
||||
match (&payload.session_id, &payload.cwd) {
|
||||
(Some(sid), Some(cwd)) if !sid.is_empty() && !cwd.is_empty() => {
|
||||
Some(derive_transcript_path(sid, cwd))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
|
||||
StopInfo {
|
||||
session_id: payload.session_id,
|
||||
transcript_path,
|
||||
last_assistant_message: payload.last_assistant_message,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the full transcript path from `session_id` and `cwd`.
|
||||
///
|
||||
/// Slug algorithm: strip the leading `/` from `cwd`, replace remaining `/` with `-`.
|
||||
/// Example: `/home/coding/myproject` → slug `home-coding-myproject`
|
||||
/// Full path: `$HOME/.claude/projects/<slug>/<session_id>.jsonl`
|
||||
pub fn derive_transcript_path(session_id: &str, cwd: &str) -> PathBuf {
|
||||
let slug = cwd_to_slug(cwd);
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||
PathBuf::from(home)
|
||||
.join(".claude")
|
||||
.join("projects")
|
||||
.join(&slug)
|
||||
.join(format!("{session_id}.jsonl"))
|
||||
}
|
||||
|
||||
/// Convert a filesystem `cwd` path to a JSONL directory slug.
|
||||
///
|
||||
/// Strip the leading `/`, then replace all `/` with `-`.
|
||||
pub fn cwd_to_slug(cwd: &str) -> String {
|
||||
cwd.trim_start_matches('/').replace('/', "-")
|
||||
}
|
||||
|
||||
/// Open the named FIFO at `path` for non-blocking reading.
|
||||
///
|
||||
/// Linux FIFO O_NONBLOCK semantics:
|
||||
/// - `O_RDONLY|O_NONBLOCK`: always succeeds immediately (no writer required).
|
||||
/// - `O_WRONLY|O_NONBLOCK`: returns `ENXIO` if no reader is present.
|
||||
///
|
||||
/// We therefore open the **read-end first** (always succeeds), then open a
|
||||
/// "keeper" write-end `O_WRONLY|O_NONBLOCK` which now succeeds because the
|
||||
/// read-end is already open. The keeper is held open until the Stop hook fires
|
||||
/// so that the hook's `cat > fifo` can open a write-end without getting
|
||||
/// `ENXIO`. Closing the keeper after the payload is read causes any lingering
|
||||
/// `cat > fifo` in hook.sh to receive `EPIPE`/`ENXIO` and exit cleanly.
|
||||
///
|
||||
/// Returns `(read_fd, keeper_write_fd)`.
|
||||
pub fn open_fifo_nonblock(path: &Path) -> Result<(OwnedFd, OwnedFd)> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let path_cstr = std::ffi::CString::new(path.as_os_str().as_bytes())
|
||||
.map_err(|e| Error::Internal(anyhow::anyhow!("FIFO path has null byte: {e}")))?;
|
||||
|
||||
// Open read-end first: O_RDONLY|O_NONBLOCK never fails with ENXIO.
|
||||
let read_fd = unsafe {
|
||||
libc::open(
|
||||
path_cstr.as_ptr(),
|
||||
libc::O_RDONLY | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if read_fd < 0 {
|
||||
let e = nix::errno::Errno::last();
|
||||
return Err(Error::Internal(anyhow::anyhow!(
|
||||
"open FIFO read-end failed: {e}"
|
||||
)));
|
||||
}
|
||||
let read_fd = unsafe { OwnedFd::from_raw_fd(read_fd) };
|
||||
|
||||
// Open keeper write-end: succeeds because the read-end is now open.
|
||||
let write_fd = unsafe {
|
||||
libc::open(
|
||||
path_cstr.as_ptr(),
|
||||
libc::O_WRONLY | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if write_fd < 0 {
|
||||
let e = nix::errno::Errno::last();
|
||||
return Err(Error::Internal(anyhow::anyhow!(
|
||||
"open FIFO write-end (keeper) failed: {e}"
|
||||
)));
|
||||
}
|
||||
let write_fd = unsafe { OwnedFd::from_raw_fd(write_fd) };
|
||||
|
||||
Ok((read_fd, write_fd))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── cwd_to_slug ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cwd_to_slug_home_coding_myproject() {
|
||||
assert_eq!(cwd_to_slug("/home/coding/myproject"), "home-coding-myproject");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_to_slug_root_foo_bar() {
|
||||
assert_eq!(cwd_to_slug("/root/foo/bar"), "root-foo-bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_to_slug_tmp() {
|
||||
assert_eq!(cwd_to_slug("/tmp"), "tmp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_to_slug_tmp_x() {
|
||||
assert_eq!(cwd_to_slug("/tmp/x"), "tmp-x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_to_slug_no_leading_slash() {
|
||||
assert_eq!(cwd_to_slug("tmp/foo"), "tmp-foo");
|
||||
}
|
||||
|
||||
// ── parse_stop_payload ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_full_payload() {
|
||||
let json = r#"{"hook_event_name":"Stop","session_id":"abc-123","transcript_path":"/home/u/.claude/projects/foo/abc-123.jsonl","cwd":"/home/u/foo","last_assistant_message":"hello"}"#;
|
||||
let p = parse_stop_payload(json.as_bytes()).unwrap();
|
||||
assert_eq!(p.session_id.as_deref(), Some("abc-123"));
|
||||
assert_eq!(
|
||||
p.transcript_path.as_deref(),
|
||||
Some("/home/u/.claude/projects/foo/abc-123.jsonl")
|
||||
);
|
||||
assert_eq!(p.cwd.as_deref(), Some("/home/u/foo"));
|
||||
assert_eq!(p.last_assistant_message.as_deref(), Some("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_payload_missing_transcript_path() {
|
||||
let json = r#"{"hook_event_name":"Stop","session_id":"s1","cwd":"/tmp/foo"}"#;
|
||||
let p = parse_stop_payload(json.as_bytes()).unwrap();
|
||||
assert!(p.transcript_path.is_none());
|
||||
assert_eq!(p.session_id.as_deref(), Some("s1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_payload_unknown_fields_ignored() {
|
||||
let json = r#"{"hook_event_name":"Stop","session_id":"x","future_field":42,"nested":{"a":1}}"#;
|
||||
let p = parse_stop_payload(json.as_bytes()).unwrap();
|
||||
assert_eq!(p.session_id.as_deref(), Some("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_payload_empty_bytes_returns_default() {
|
||||
let p = parse_stop_payload(b"").unwrap();
|
||||
assert!(p.session_id.is_none());
|
||||
assert!(p.transcript_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_payload_trailing_newline() {
|
||||
let json = b"{\"session_id\":\"s2\"}\n";
|
||||
let p = parse_stop_payload(json).unwrap();
|
||||
assert_eq!(p.session_id.as_deref(), Some("s2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_payload_malformed_json_returns_err() {
|
||||
let result = parse_stop_payload(b"not json");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── resolve_stop_info ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_uses_explicit_transcript_path() {
|
||||
let payload = StopPayload {
|
||||
session_id: Some("sid".to_string()),
|
||||
transcript_path: Some("/explicit/path.jsonl".to_string()),
|
||||
cwd: Some("/some/cwd".to_string()),
|
||||
last_assistant_message: None,
|
||||
};
|
||||
let info = resolve_stop_info(payload);
|
||||
assert_eq!(
|
||||
info.transcript_path,
|
||||
Some(PathBuf::from("/explicit/path.jsonl"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_derives_path_when_transcript_path_absent() {
|
||||
let payload = StopPayload {
|
||||
session_id: Some("mysession".to_string()),
|
||||
transcript_path: None,
|
||||
cwd: Some("/home/user/myproject".to_string()),
|
||||
last_assistant_message: None,
|
||||
};
|
||||
let info = resolve_stop_info(payload);
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||
let expected = PathBuf::from(&home)
|
||||
.join(".claude")
|
||||
.join("projects")
|
||||
.join("home-user-myproject")
|
||||
.join("mysession.jsonl");
|
||||
assert_eq!(info.transcript_path, Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_when_no_derivation_possible() {
|
||||
let payload = StopPayload {
|
||||
session_id: Some("sid".to_string()),
|
||||
transcript_path: None,
|
||||
cwd: None, // cwd absent: cannot derive
|
||||
last_assistant_message: None,
|
||||
};
|
||||
let info = resolve_stop_info(payload);
|
||||
assert!(info.transcript_path.is_none());
|
||||
}
|
||||
|
||||
// ── open_fifo_nonblock (OQ-4: FIFO open race) ─────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn open_fifo_nonblock_succeeds_without_separate_writer() {
|
||||
use crate::hook::HookInstaller;
|
||||
let installer = HookInstaller::new().unwrap();
|
||||
// open_fifo_nonblock opens keeper write-end then read-end; must not fail.
|
||||
let result = open_fifo_nonblock(&installer.fifo_path);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"open_fifo_nonblock must succeed without a pre-existing writer: {:?}",
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_fifo_nonblock_read_end_is_ready_for_poll() {
|
||||
use crate::hook::HookInstaller;
|
||||
use std::io::Write;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
let installer = HookInstaller::new().unwrap();
|
||||
let (read_fd, _keeper) = open_fifo_nonblock(&installer.fifo_path).unwrap();
|
||||
|
||||
// Write some bytes from a thread (will unblock immediately since keeper write-end is open)
|
||||
let fifo_path = installer.fifo_path.clone();
|
||||
let writer = std::thread::spawn(move || {
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fifo_path)
|
||||
.unwrap();
|
||||
f.write_all(b"hello").unwrap();
|
||||
});
|
||||
|
||||
// poll() with a short timeout; POLLIN must fire
|
||||
let mut pfd = libc::pollfd {
|
||||
fd: read_fd.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let ret = unsafe { libc::poll(&mut pfd, 1, 2000) }; // 2 s timeout
|
||||
writer.join().unwrap();
|
||||
|
||||
assert!(ret > 0, "poll timed out waiting for FIFO data");
|
||||
assert!(pfd.revents & libc::POLLIN != 0, "POLLIN not set");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,31 @@ fn main() {
|
|||
.nth(1)
|
||||
.expect("usage: mock-claude <fifo-path>");
|
||||
|
||||
// Simulate the stop hook by writing "stop" to the FIFO.
|
||||
let omit_transcript_path = std::env::var("MOCK_OMIT_TRANSCRIPT_PATH")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
let session_id = "mock-session-abc123";
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|_| "/tmp".to_string());
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||
|
||||
// Build Stop hook JSON payload manually (no serde_json dep in mock-claude).
|
||||
// Paths on Linux cannot contain backslashes or control chars, so no escaping needed.
|
||||
let payload = if omit_transcript_path {
|
||||
format!(
|
||||
"{{\"hook_event_name\":\"Stop\",\"session_id\":\"{session_id}\",\"cwd\":\"{cwd}\",\"last_assistant_message\":\"Hello from mock_claude\"}}\n"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{{\"hook_event_name\":\"Stop\",\"session_id\":\"{session_id}\",\"transcript_path\":\"{home}/.claude/projects/mock-cwd/{session_id}.jsonl\",\"cwd\":\"{cwd}\",\"last_assistant_message\":\"Hello from mock_claude\"}}\n"
|
||||
)
|
||||
};
|
||||
|
||||
// O_WRONLY on a FIFO blocks until a reader opens the other end.
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new().write(true).open(&fifo_path) {
|
||||
let _ = file.write_all(b"stop\n");
|
||||
let _ = file.write_all(payload.as_bytes());
|
||||
}
|
||||
|
||||
// Exit 0 if stdin is a controlling TTY (login_tty succeeded), 1 otherwise.
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ fn test_pty_spawns_tty() {
|
|||
|
||||
let fifo_content = reader.join().expect("reader thread");
|
||||
assert!(
|
||||
fifo_content.contains("stop"),
|
||||
"expected 'stop' in FIFO content, got: {fifo_content:?}",
|
||||
fifo_content.contains("session_id"),
|
||||
"expected Stop JSON payload in FIFO content, got: {fifo_content:?}",
|
||||
);
|
||||
assert_eq!(
|
||||
exit_code, 0,
|
||||
|
|
|
|||
142
tests/stop_poller.rs
Normal file
142
tests/stop_poller.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
use claude_print::event_loop::{EventLoop, ExitReason};
|
||||
use claude_print::hook::HookInstaller;
|
||||
use claude_print::poller::{open_fifo_nonblock, parse_stop_payload, resolve_stop_info};
|
||||
use std::io::Write;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
/// Verify that when a Stop JSON payload is written to the FIFO, the event loop
|
||||
/// returns it via FifoPayload and parse_stop_payload extracts the fields.
|
||||
#[test]
|
||||
fn test_stop_hook_fires() {
|
||||
let installer = HookInstaller::new().expect("HookInstaller::new");
|
||||
|
||||
// Open FIFO: keeper write-end + read-end (O_NONBLOCK, no ENXIO).
|
||||
let (fifo_read, _fifo_keeper) =
|
||||
open_fifo_nonblock(&installer.fifo_path).expect("open_fifo_nonblock");
|
||||
|
||||
// Dummy "master" pipe — won't produce PTY data, so POLLIN won't fire on it.
|
||||
let (dummy_r, _dummy_w) = nix::unistd::pipe().expect("pipe");
|
||||
// Self-pipe for interrupt signalling — won't be written in this test.
|
||||
let (self_pipe_r, _self_pipe_w) = nix::unistd::pipe().expect("pipe");
|
||||
|
||||
let mut el = EventLoop::new(dummy_r.as_raw_fd(), self_pipe_r.as_raw_fd());
|
||||
el.add_fifo_fd(fifo_read.as_raw_fd());
|
||||
|
||||
// Simulate the Stop hook writing a JSON payload to the FIFO.
|
||||
let fifo_path = installer.fifo_path.clone();
|
||||
let payload_json = concat!(
|
||||
r#"{"hook_event_name":"Stop","session_id":"test-session-123","#,
|
||||
r#""transcript_path":"/tmp/test-transcript/test-session-123.jsonl","#,
|
||||
r#""cwd":"/tmp/test-cwd","last_assistant_message":"hello world"}"#,
|
||||
);
|
||||
let payload_bytes = payload_json.as_bytes().to_vec();
|
||||
let writer = std::thread::spawn(move || {
|
||||
// Blocking open; succeeds immediately because read-end (keeper) is open.
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fifo_path)
|
||||
.expect("open FIFO for writing");
|
||||
f.write_all(&payload_bytes).expect("write payload");
|
||||
f.write_all(b"\n").expect("write newline");
|
||||
});
|
||||
|
||||
let reason = el.run(|_| {}).expect("event loop");
|
||||
writer.join().expect("writer thread");
|
||||
|
||||
let raw = match reason {
|
||||
ExitReason::FifoPayload(bytes) => bytes,
|
||||
other => panic!("expected FifoPayload, got {other:?}"),
|
||||
};
|
||||
|
||||
let stop = parse_stop_payload(&raw).expect("parse_stop_payload");
|
||||
|
||||
assert_eq!(
|
||||
stop.session_id.as_deref(),
|
||||
Some("test-session-123"),
|
||||
"session_id mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
stop.transcript_path.as_deref(),
|
||||
Some("/tmp/test-transcript/test-session-123.jsonl"),
|
||||
"transcript_path mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
stop.last_assistant_message.as_deref(),
|
||||
Some("hello world"),
|
||||
"last_assistant_message mismatch"
|
||||
);
|
||||
|
||||
let info = resolve_stop_info(stop);
|
||||
assert_eq!(
|
||||
info.transcript_path,
|
||||
Some(std::path::PathBuf::from(
|
||||
"/tmp/test-transcript/test-session-123.jsonl"
|
||||
)),
|
||||
"StopInfo transcript_path should use the explicit payload path"
|
||||
);
|
||||
}
|
||||
|
||||
/// When `transcript_path` is absent from the Stop payload, the transcript path
|
||||
/// is derived from `session_id` + `cwd` using the documented slug algorithm.
|
||||
#[test]
|
||||
fn test_missing_transcript_path_derived() {
|
||||
let installer = HookInstaller::new().expect("HookInstaller::new");
|
||||
|
||||
let (fifo_read, _fifo_keeper) =
|
||||
open_fifo_nonblock(&installer.fifo_path).expect("open_fifo_nonblock");
|
||||
|
||||
let (dummy_r, _dummy_w) = nix::unistd::pipe().expect("pipe");
|
||||
let (self_pipe_r, _self_pipe_w) = nix::unistd::pipe().expect("pipe");
|
||||
|
||||
let mut el = EventLoop::new(dummy_r.as_raw_fd(), self_pipe_r.as_raw_fd());
|
||||
el.add_fifo_fd(fifo_read.as_raw_fd());
|
||||
|
||||
// Payload deliberately omits `transcript_path`.
|
||||
let fifo_path = installer.fifo_path.clone();
|
||||
let writer = std::thread::spawn(move || {
|
||||
let payload = concat!(
|
||||
r#"{"hook_event_name":"Stop","session_id":"abc123","#,
|
||||
r#""cwd":"/home/user/myproject","last_assistant_message":"derived test"}"#,
|
||||
);
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fifo_path)
|
||||
.expect("open FIFO for writing");
|
||||
f.write_all(payload.as_bytes()).expect("write payload");
|
||||
f.write_all(b"\n").expect("write newline");
|
||||
});
|
||||
|
||||
let reason = el.run(|_| {}).expect("event loop");
|
||||
writer.join().expect("writer thread");
|
||||
|
||||
let raw = match reason {
|
||||
ExitReason::FifoPayload(bytes) => bytes,
|
||||
other => panic!("expected FifoPayload, got {other:?}"),
|
||||
};
|
||||
|
||||
let stop = parse_stop_payload(&raw).expect("parse_stop_payload");
|
||||
|
||||
// Confirm transcript_path is absent from the raw payload.
|
||||
assert!(
|
||||
stop.transcript_path.is_none(),
|
||||
"transcript_path should be absent from payload"
|
||||
);
|
||||
assert_eq!(stop.session_id.as_deref(), Some("abc123"));
|
||||
|
||||
let info = resolve_stop_info(stop);
|
||||
|
||||
// Derived slug: /home/user/myproject → home-user-myproject
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||
let expected = std::path::PathBuf::from(&home)
|
||||
.join(".claude")
|
||||
.join("projects")
|
||||
.join("home-user-myproject")
|
||||
.join("abc123.jsonl");
|
||||
|
||||
assert_eq!(
|
||||
info.transcript_path,
|
||||
Some(expected.clone()),
|
||||
"derived transcript_path should be {expected:?}"
|
||||
);
|
||||
assert_eq!(info.session_id.as_deref(), Some("abc123"));
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue