From bc7ec8c5ce024d24f9e95ab34e5e539af6594501 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 22 Oct 2026 18:51:08 +0900 Subject: [PATCH 1/6] feat(handoff): show read-only single-request status details --- docs/handoff-runtime.md | 16 +- docs/i18n-manifest.json | 2 +- docs/ja/handoff-runtime.md | 16 +- docs/ko/handoff-runtime.md | 16 +- docs/zh-CN/handoff-runtime.md | 16 +- session_peer.py | 104 +++++- session_peer_core/handoff.py | 104 +++++- tests/core/test_handoff_status_view.py | 439 +++++++++++++++++++++++++ 8 files changed, 680 insertions(+), 33 deletions(-) create mode 100644 tests/core/test_handoff_status_view.py diff --git a/docs/handoff-runtime.md b/docs/handoff-runtime.md index 7cc0c6d0..8f91ed21 100644 --- a/docs/handoff-runtime.md +++ b/docs/handoff-runtime.md @@ -32,12 +32,26 @@ For Codex correlation-only mode there is no pending explicit wait: SIGINT after Verified structured SSH-self addresses keep the local message limit after normalization, including prepared-ID reuse and dry-run. Actual SSH destinations still have the 90,000-character command-argument limit. The protected metadata child additionally bounds each encoded JSON input/output frame to 1 MiB, including UTF-8 encoding and JSON escapes; this can reject otherwise valid local-sized input before submission. -The sender uses one original per-destination budget for metadata, harmless target probing, durable intent, one native effect and cleanup. Its protected metadata child preserves From/Reply and structured SSH-self normalization without renewing detection timeouts. A consumed intent refuses before any new probe. Required remote delivered/acknowledged waits refuse before SSH submission; best-effort requests report unsupported and mint no capability, remote ledger or collector. The effect rechecks the original Claude generation or canonical Codex home/writer context. An explicit Codex generation request is unsupported, not silently removed. Read-only status/wait never invokes SSH submission. +The sender uses one original per-destination budget for metadata, harmless target probing, durable intent, one native effect and cleanup. Its protected metadata child preserves From/Reply and structured SSH-self normalization without renewing detection timeouts. A consumed intent refuses before any new probe. Required remote delivered/acknowledged waits refuse before SSH submission; best-effort requests report unsupported and mint no capability, remote ledger or collector. The effect rechecks the original Claude generation or canonical Codex home/writer context. An explicit Codex generation request is unsupported, not silently removed. Status/wait never invokes SSH submission. The probe records an old remote boot-bound monotonic anchor before replying. Only after strict response validation does the sender compute both remaining durations from one original clock sample. The remote effect uses that old anchor plus the remaining durations, including exactly five seconds within the total for cleanup; changed boot, future/expired anchor or malformed context refuses before native effect. No UTC or cross-host clock origins are compared. SSH shutdown does not prove cancellation of remote submission or consumption. Home mapping is the trusted remote handler's actual expanduser/resolve result, including owned symlink aliases; it is not a sender-verified inode, atomic incarnation or universal native storage guarantee. The actual command must enter the closed private handler before config/source/SSH children start. Hidden options are not authentication: the receiver quotes every external body line once. Strict native profiles and request/target/home/context proofs preserve complete submission evidence after disconnect, timeout, diagnostic overflow or interruption, but never salvage an overflowing protocol stdout prefix. Connection-verified SSH identity and safe reply metadata remain separate from native facts. Private proofs, body, clock anchors and native context are not reflected into public outputs. Owned fake-SSH/source-stream/SQLite/queue fixtures verify this shipped slice, not actual remote hosts, native model consumption or installed reverse-SSH receipt bootstrap. +## Read one request's status + +```sh +session-peer handoff status --correlation-id PREPARED_UUID +``` + +Use an existing prepared ID. The human-readable view shows one snapshot from the local owner's ledger: known submission facts, injection observation, ACK assurance and whether it was late, the latest stored wait, retry prohibition and supported next actions. The ACK and wait are shown separately: a late ACK does not rewrite an expired wait. The latest stored wait is not a complete wait history. ACK lateness is relative to the first wait, which may differ from the latest stored wait. Expired or quarantined history shows receipt and injection details as unavailable; a pending ACK without a supported keep-waiting action does not establish that further waiting is available. The JSON field schema and exit-code meanings are retained: a successful query returns 0 and a failed query returns 1; query success is not proof of receipt. + +Status does not submit or wake, initialize or repair a ledger, mint receipt authority, or change waits. Its read-only ledger path does not save changes; in-memory retention may still show a tombstone with details unavailable. Explicit quarantine after restore sets submission to unknown and retains terminal waits on disk; the view shows the latest wait only when a valid public snapshot can be formed. Known lifecycle limitation: quarantine clears ACK detail, so a retained satisfied wait cannot satisfy the existing public validator; status refuses with invalid_handoff (exit 1). It does not repair or rewrite that history. This remains a #181 lifecycle gate. Existing status logic may query an already-running collector for clock evidence to determine next actions, but does not start one. The view does not read transcripts or message bodies, or expose raw capabilities. + +The collector clock query is also read-only. An older collector may reject that query, so `keep_waiting` may be absent from the available next actions; status does not fall back to a writing query or restart the collector. The ledger snapshot and optional collector clock observation are separate, not an atomic combined observation. After a wait fails with history_unavailable and the collector clock becomes available again, status now preserves known submission and that failed wait without offering keep_waiting. Other wait-creation semantics are unchanged. A tombstone's latest-wait detail is shown as unavailable due to expired history, not as evidence that no wait was requested. + +This is a single local query, not continuous watching or remote monitoring. Windows handoff and Codex ACK remain unsupported. [#296](https://github.com/abruption/session-peer/issues/296) remains open for a future bounded watch mode. + ## Evidence, privacy and timing A complete inbox write is submission, not consumption or turn completion. Only an authenticated correlated receipt, or a separate explicit operator attestation with provable ordering, produces acknowledged. An early receipt during native write/drain is saved as bounded unclassified evidence. It is classified only after independently known submission and original-clock ordering; classification cannot invent a receipt or backdate an ACK. A committed ACK survives collector restart; unused authority expires conservatively on restart. Duplicates still require original token-hash proof, including after expiry. Manual confirmation never rewrites an old wait. diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index a5c9f04a..81dd0f35 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -20,7 +20,7 @@ "docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", "docs/download-metrics.md": "ded60e7b84d14ce779f8d639516b4e390358a06ef76b677dc9c1f997fb626a9f", - "docs/handoff-runtime.md": "b2ecd28949dabd8b0c63371a1d3e8d732a2dcd07ce972e5737241732b4991dfb", + "docs/handoff-runtime.md": "95d7d8af27705a6c9d66842345c5055c06383036212156cb046ea010e193cdbe", "docs/mcp.md": "a64010cdd63f2cde6a0052450bed2d05faa0f19c93c76143cb0d585d36f8ff62", "docs/multi-home-list.md": "f8d8e3adf727e4842ea7c4d45e8f6523b11d0513bf2509d4d1204645875e6489", "docs/paired-devices.md": "44c70f99d3f8f8ff9f6f86d128913ada69f4cc4e794c724cfc214f7f8ea78f20", diff --git a/docs/ja/handoff-runtime.md b/docs/ja/handoff-runtime.md index 0d4f771f..bed1839c 100644 --- a/docs/ja/handoff-runtime.md +++ b/docs/ja/handoff-runtime.md @@ -32,12 +32,26 @@ Codex の相関 ID 専用モードには進行中の明示的待機がありま 構造化された SSH 自己宛先がローカルと確認された場合、準備済み ID の再利用と試験実行でもローカルのメッセージ上限を維持します。実際の SSH 宛先には、コマンド引数の 90,000 文字上限が適用されます。保護されたメタデータ子プロセスは UTF-8 と JSON エスケープを含む入出力 JSON フレームをそれぞれ 1 MiB に制限するため、ローカルの文字数上限内の入力でも提出前に拒否される場合があります。 -送信者はメタデータ、送信を伴わない対象確認、永続的な提出意図、一回の実際の提出と後始末に、宛先ごとの元の予算を使います。保護されたメタデータ子プロセスは探知時間を更新せず From/Reply と構造化 SSH 自己宛先の正規化を維持します。使用済みの意図は新しい確認前に拒否します。必須のリモート配信・受領確認待機は SSH 提出前に拒否し、ベストエフォート要求は unsupported を返して権限やリモート記録・コレクターを作りません。提出前に元の Claude 世代、または正規化された Codex ホーム・書き込みプロセス情報を再確認します。Codex 世代指定は未対応として拒否し、黙って削除しません。読み取り専用の状態・待機は SSH 提出を行いません。 +送信者はメタデータ、送信を伴わない対象確認、永続的な提出意図、一回の実際の提出と後始末に、宛先ごとの元の予算を使います。保護されたメタデータ子プロセスは探知時間を更新せず From/Reply と構造化 SSH 自己宛先の正規化を維持します。使用済みの意図は新しい確認前に拒否します。必須のリモート配信・受領確認待機は SSH 提出前に拒否し、ベストエフォート要求は unsupported を返して権限やリモート記録・コレクターを作りません。提出前に元の Claude 世代、または正規化された Codex ホーム・書き込みプロセス情報を再確認します。Codex 世代指定は未対応として拒否し、黙って削除しません。状態照会・待機は SSH 提出を行いません。 確認処理は返信前にリモート起動に結びつく単調時計の古い基準点を記録します。送信者は厳密に返信を検証した後、元の時計を一度だけ読み、二つの残り時間を求めます。リモート提出は古い基準点と残り時間を使い、総予算内の後始末に正確に 5 秒を確保します。起動変更、未来・期限切れの基準点、不正な文脈は提出前に拒否します。UTC や機器間の時計の原点を比較しません。SSH 終了はリモート提出や消費の取り消しを証明しません。ホームの対応は信頼したリモート処理器による実際の expanduser/resolve の結果で、専用シンボリックリンクを含みます。送信者による独立した inode 検証、原子的なプロセス世代、一般的な保存先保証ではありません。 実際の引数は設定・ソース・SSH 子プロセス開始前に限定された非公開処理器に結びつく必要があります。隠しオプションは認証ではなく、受信側は外部本文を各行一度引用します。厳密なネイティブ形式と要求・対象・ホーム・文脈検証は、切断、時間切れ、診断超過、中断後も完全な提出証拠を保ちます。プロトコル stdout 超過時には残った正常な接頭部分を成功として採用しません。同一接続で確認した SSH 識別情報と安全な返信メタデータはネイティブ事実と分けて保持します。非公開証拠、本文、時計基準点、内部文脈は公開出力に反映しません。専用の偽 SSH・ソース転送・SQLite・キュー試験はこの公開済みの範囲の検証であり、実際のリモートホスト、モデル消費、導入済み逆方向 SSH 確認経路の証拠ではありません。 +## 1 件の要求の状態を確認する + +```sh +session-peer handoff status --correlation-id PREPARED_UUID +``` + +準備済みの ID を使います。人が読むための表示は、ローカル所有者の記録を一度読み、確認済みの提出事実、入力の注入の観測、ACK の確認根拠と遅延の有無、最後に保存された待機、再試行の禁止、利用できる次の操作を示します。ACK と待機は別々に表示するため、遅れて届いた ACK が期限切れの待機を書き換えることはありません。最後に保存された待機は、待機履歴の全体ではありません。ACK の遅延は最初の待機を基準とし、最後に保存された待機とは異なる場合があります。期限切れや隔離済みの記録では受領確認・注入の詳細を確認不可と表示し、待機継続の操作が利用できない場合、ACK 待ちの表示だけで追加の待機が可能だとは判断できません。JSON フィールドの構造と終了コードの意味は維持します。照会成功は 0、失敗は 1 であり、照会成功は受領の証拠ではありません。 + +状態照会は提出やモデルの起動、記録の初期化や修復、受領確認権限の発行、待機の変更を行いません。読み取り専用の記録処理は変更を保存しませんが、メモリ上で保持期間を適用した結果、詳細のない保持用記録(tombstone)が表示される場合があります。復元後の明示的な隔離は提出状態を unknown にし、終了済みの待機をディスクに保持します。表示は有効な公開結果を構成できる場合に限り、最後の待機を示します。既知のライフサイクル上の制約として、隔離で ACK の詳細が消えると、保持された satisfied の待機は既存の公開検証条件を満たせず、状態照会は invalid_handoff(終了コード 1)で拒否されます。履歴を修復したり書き換えたりはしません。これは #181 に残るライフサイクルの検証項目です。既存の状態照会処理は、次の操作を判断する時計の証拠を得るために起動済みのコレクターへ照会する場合がありますが、コレクターを起動しません。表示のために会話履歴やメッセージ本文を読んだり、生の権限トークンを公開したりしません。 + +コレクターの時計照会も読み取り専用です。古いコレクターはこの照会を拒否する場合があるため、次の操作に `keep_waiting` が含まれないことがあります。状態照会は書き込みを伴う照会へ切り替えたり、コレクターを再起動したりしません。記録のスナップショットと任意のコレクター時計の観測は別々であり、原子的にまとめて観測した証拠ではありません。待機が history_unavailable で失敗した後にコレクター時計を再び確認できる場合、状態照会は確認済みの提出と失敗した待機を保持し、keep_waiting を案内しません。ほかの待機作成の意味は変わりません。保持用記録の最新待機の詳細は、履歴の期限切れにより確認不可と表示し、待機が要求されなかった証拠とは扱いません。 + +これはローカルでの単発照会であり、継続監視やリモート監視ではありません。Windows handoff と Codex ACK は引き続き未対応です。[#296](https://github.com/abruption/session-peer/issues/296) は、今後の実行範囲を制限した watch モードのために未完了のまま維持します。 + ## 証拠・プライバシー・時間制限 受信箱への完全な書き込みは提出であり、消費やターン完了ではありません。認証された対応する受領確認、または時間関係を証明できる明示的な運用者の確認だけが acknowledged を生成します。書き込み・応答処理中に届いた早い確認は、サイズ制限付きの未分類証拠として保存します。提出と元の時計の順序を証明してから分類し、確認を作り出したり時刻を遡らせたりしません。確定した確認はコレクター再起動後も保持し、未使用の権限は保守的に失効させます。期限後の重複でも元のトークンハッシュの証明が必要です。手動確認は過去の待機を書き換えません。 diff --git a/docs/ko/handoff-runtime.md b/docs/ko/handoff-runtime.md index 86c160e5..92ce6d3e 100644 --- a/docs/ko/handoff-runtime.md +++ b/docs/ko/handoff-runtime.md @@ -32,12 +32,26 @@ Codex의 연관 ID 전용 전송에는 진행 중인 명시적 대기가 없습 구조화된 SSH 자기 주소가 로컬로 확인되면 준비된 ID 재사용과 시험 실행에서도 로컬 메시지 한도를 유지합니다. 실제 SSH 대상에는 명령 인자 한도인 90,000자가 적용됩니다. 보호된 메타데이터 자식 프로세스는 UTF-8 인코딩과 JSON 이스케이프를 포함한 입력·출력 JSON 프레임 각각을 1 MiB로 제한하므로, 로컬 문자 한도에 맞는 입력도 제출 전에 거부될 수 있습니다. -송신 측은 메타데이터, 전송 없는 대상 확인, 영속적인 제출 의도 기록, 단 한 번의 실제 제출과 정리에 대상별 원래 예산을 사용합니다. 보호된 메타데이터 자식 프로세스는 탐지 시간을 새로 부여하지 않고 From·Reply와 구조화된 SSH 자기 주소의 로컬 변환을 유지합니다. 이미 시도한 의도는 새 조회 전에 거부합니다. 원격 전달·수신 확인이 필수인 대기는 SSH 제출 전에 거부하고, 최선형 요청은 unsupported를 반환하며 권한이나 원격 기록·수집기를 만들지 않습니다. 실제 제출은 원래 Claude 세대 또는 정규화한 Codex 홈·쓰기 프로세스 정보를 다시 검사합니다. Codex 세대 고정 요청은 지원하지 않으며 조용히 제거하지 않습니다. 읽기 전용 상태·대기는 SSH 제출을 수행하지 않습니다. +송신 측은 메타데이터, 전송 없는 대상 확인, 영속적인 제출 의도 기록, 단 한 번의 실제 제출과 정리에 대상별 원래 예산을 사용합니다. 보호된 메타데이터 자식 프로세스는 탐지 시간을 새로 부여하지 않고 From·Reply와 구조화된 SSH 자기 주소의 로컬 변환을 유지합니다. 이미 시도한 의도는 새 조회 전에 거부합니다. 원격 전달·수신 확인이 필수인 대기는 SSH 제출 전에 거부하고, 최선형 요청은 unsupported를 반환하며 권한이나 원격 기록·수집기를 만들지 않습니다. 실제 제출은 원래 Claude 세대 또는 정규화한 Codex 홈·쓰기 프로세스 정보를 다시 검사합니다. Codex 세대 고정 요청은 지원하지 않으며 조용히 제거하지 않습니다. 상태 조회·대기는 SSH 제출을 수행하지 않습니다. 예비 확인은 회신 전에 원격 부팅에 연결된 단조 시계 기준점을 기록합니다. 송신 측은 응답을 엄격히 검증한 뒤 원래 시계를 한 번 읽어 남은 두 시간을 계산합니다. 원격 제출은 과거 기준점과 남은 시간을 사용하고 총예산 안에 정확히 5초의 정리 시간을 둡니다. 부팅 변경, 미래·만료된 기준점이나 잘못된 요청은 실제 제출 전에 거부합니다. UTC나 기기별 시계 원점을 비교하지 않습니다. SSH 종료는 원격 제출이나 소비 취소의 증거가 아닙니다. 홈 대응은 신뢰된 원격 처리기가 실제로 expanduser·resolve한 결과이며 전용 심볼릭 링크도 포함합니다. 송신 측에서 독립 검증한 inode, 원자적인 프로세스 세대나 보편적인 저장 경로 보장이 아닙니다. 실제 명령은 설정·소스·SSH 자식 프로세스가 시작되기 전에 제한된 비공개 처리기로 연결되어야 합니다. 숨겨진 옵션은 인증이 아니며, 수신 측은 외부 본문을 줄마다 한 번 인용합니다. 엄격한 네이티브 형식과 요청·대상·홈·상태 검증은 연결 종료, 시간 초과, 진단 한도 초과나 중단 뒤에도 완전한 제출 증거를 유지합니다. 프로토콜 stdout이 넘치면 남은 정상 접두부만으로 성공 처리하지 않습니다. 같은 연결에서 검증된 SSH 신원과 안전한 회신 메타데이터는 네이티브 사실과 구분해 유지합니다. 비공개 증거, 본문, 시계 기준점과 내부 네이티브 상태는 공개 출력에 포함하지 않습니다. 전용 가짜 SSH·소스 전달·SQLite·큐 시험은 이 구현 범위를 검증할 뿐 실제 원격 서버, 모델 소비나 설치된 역방향 SSH 수신 확인 준비의 증거가 아닙니다. +## 요청 한 건의 상태 조회 + +```sh +session-peer handoff status --correlation-id PREPARED_UUID +``` + +이미 준비한 ID를 사용합니다. 사람이 읽는 화면은 로컬 소유자의 기록을 한 번 조회해 확인된 제출 사실, 주입 관측, ACK의 확인 근거와 지연 여부, 마지막으로 저장된 대기, 재시도 금지와 지원되는 다음 조치를 보여줍니다. ACK와 대기는 별도로 표시하므로 늦은 ACK가 만료된 대기를 다시 쓰지 않습니다. 마지막으로 저장된 대기는 전체 대기 이력이 아닙니다. ACK 지연 여부는 첫 대기를 기준으로 하며 마지막으로 저장된 대기와는 다를 수 있습니다. 만료되거나 격리된 기록의 수신 확인·주입 상세는 확인 불가로 표시하며, 대기 계속 조치가 지원되지 않는 대기 중 ACK는 추가 대기가 가능하다는 뜻이 아닙니다. JSON 필드 구조와 종료 코드의 의미는 유지합니다. 조회 성공은 0, 실패는 1이며, 조회 성공은 수신의 증거가 아닙니다. + +상태 조회는 전송·깨우기, 기록 초기화·복구, 수신 확인 권한 발급, 대기 변경을 수행하지 않습니다. 읽기 전용 기록 경로에서는 변경을 저장하지 않지만, 메모리에서 보존 기간을 적용한 결과 상세 정보가 없는 보존 표식(tombstone)이 표시될 수 있습니다. 복원 후 명시적 격리는 제출 상태를 unknown으로 바꾸고 종료된 대기를 디스크에 보존합니다. 화면은 유효한 공개 결과를 구성할 수 있을 때만 마지막 대기를 보여줍니다. 알려진 수명주기 한계로, 격리 시 ACK 상세가 지워지면 보존된 satisfied 대기는 기존 공개 검증 조건을 충족하지 못해 상태 조회가 invalid_handoff(종료 코드 1)로 거부됩니다. 기록을 복구하거나 다시 쓰지는 않으며, 이는 #181의 남은 수명주기 검증 항목입니다. 기존 상태 조회 로직은 다음 조치를 판단할 시계 근거를 얻기 위해 실행 중인 수집기에 질의할 수 있지만 수집기를 시작하지는 않습니다. 이 화면은 대화 기록이나 메시지 본문을 읽거나 원본 권한 토큰을 노출하지 않습니다. + +수집기의 시계 조회도 읽기 전용입니다. 이전 수집기는 이 조회를 거부할 수 있어 가능한 다음 조치에 `keep_waiting`이 없을 수 있습니다. 이때 상태 조회는 기록을 쓰는 질의로 전환하거나 수집기를 재시작하지 않습니다. 기록 스냅샷과 선택적 수집기 시계 관측은 별도이며, 원자적으로 함께 관측한 증거가 아닙니다. 대기가 history_unavailable로 실패한 뒤 수집기 시계를 다시 확인할 수 있게 된 경우, 이제 상태 조회는 확인된 제출과 실패한 대기를 보존하면서 keep_waiting을 안내하지 않습니다. 다른 대기 생성 의미는 바꾸지 않습니다. 보존 표식의 최신 대기 상세는 만료된 기록으로 인해 확인 불가로 표시하며, 대기를 요청하지 않았다는 증거로 해석하지 않습니다. + +이는 로컬에서 한 번 조회하는 기능이며 지속 감시나 원격 모니터링은 아닙니다. Windows handoff와 Codex ACK는 여전히 미지원입니다. [#296](https://github.com/abruption/session-peer/issues/296)은 향후 실행 범위를 제한한 watch 모드를 위해 열린 상태로 유지합니다. + ## 증거·개인정보·시간 제한 수신함 쓰기의 완료는 제출이며, 실제 소비나 대화 차례의 완료가 아닙니다. 인증된 연관 수신 확인 또는 시간 관계를 증명할 수 있는 별도의 명시적 운영자 확인만 acknowledged를 만듭니다. 쓰기·응답 정리 중 빠르게 도착한 확인은 크기가 제한된 미분류 증거로 보관합니다. 실제 제출과 원래 시계의 순서를 확인한 뒤 분류하며, 확인을 새로 만들거나 시각을 소급하지 않습니다. 확정된 확인은 수집기 재시작 후에도 유지하지만 사용하지 않은 권한은 보수적으로 만료합니다. 만료 후 중복 확인도 원래 토큰 해시 증명이 필요합니다. 수동 확인은 기존 대기 이력을 바꾸지 않습니다. diff --git a/docs/zh-CN/handoff-runtime.md b/docs/zh-CN/handoff-runtime.md index d8e87de6..e9af7d43 100644 --- a/docs/zh-CN/handoff-runtime.md +++ b/docs/zh-CN/handoff-runtime.md @@ -32,12 +32,26 @@ Codex 关联 ID 专用模式没有正在进行的显式等待:完整有效队 结构化 SSH 自身地址经验证归一化为本地后,复用已准备 ID 和试运行仍保留本地消息上限。实际 SSH 目标仍受命令参数的 90,000 字符上限约束。受保护的元数据子进程另将输入、输出 JSON 帧各限制为 1 MiB,包含 UTF-8 编码和 JSON 转义;因此符合本地字符上限的输入也可能在提交前被拒绝。 -发送方在每个目标的原始总预算内完成元数据、无提交的目标预检、持久提交意图、唯一一次实际提交和清理。受保护的元数据子进程保留 From/Reply 和结构化 SSH 自身地址归一化,不重置探测时间。已尝试的意图在任何新预检前拒绝。必需的远程送达、收据等待在 SSH 提交前拒绝;尽力而为的请求返回 unsupported,不创建权限、远程日志或收集器。实际提交前重新检查原始 Claude 代次,或规范 Codex 目录、写入进程信息。显式 Codex 代次要求会被拒绝为不支持,而非静默删除。只读状态和等待不会执行 SSH 提交。 +发送方在每个目标的原始总预算内完成元数据、无提交的目标预检、持久提交意图、唯一一次实际提交和清理。受保护的元数据子进程保留 From/Reply 和结构化 SSH 自身地址归一化,不重置探测时间。已尝试的意图在任何新预检前拒绝。必需的远程送达、收据等待在 SSH 提交前拒绝;尽力而为的请求返回 unsupported,不创建权限、远程日志或收集器。实际提交前重新检查原始 Claude 代次,或规范 Codex 目录、写入进程信息。显式 Codex 代次要求会被拒绝为不支持,而非静默删除。状态查询和等待不会执行 SSH 提交。 预检在回复前记录与远程启动绑定的旧单调时钟锚点。发送方严格验证回复后,只读取一次原始时钟来计算两个剩余时间。远程提交使用旧锚点加剩余时间,并在总预算内精确保留五秒清理时间。启动变化、未来或过期锚点、非法上下文均在实际提交前拒绝。不比较 UTC 或不同主机的时钟原点。SSH 结束不能证明远程提交或消费被取消。目录映射是可信远程处理器实际 expanduser/resolve 的结果,包括专属符号链接;不是发送方独立验证的 inode、原子进程代次或普遍的原生存储保证。 实际参数必须在配置、源码、SSH 子进程启动前绑定到封闭的私有处理器。隐藏选项不构成认证;接收方对外部正文逐行引用一次。严格的原生格式及请求、目标、目录、上下文验证保留断连、超时、诊断超限、中断后的完整提交证据,但协议 stdout 超限时绝不将残留的有效前缀视为成功。同一连接验证的 SSH 身份及安全回信元数据与原生事实分开保留。私有证明、正文、时钟锚点及内部上下文不进入公开输出。专属假 SSH、源码传输、SQLite、队列夹具验证的是这一已发布范围,不是实际远程主机、模型消费或已安装的反向 SSH 收据准备证明。 +## 查询一条请求的状态 + +```sh +session-peer handoff status --correlation-id PREPARED_UUID +``` + +使用已准备好的 ID。供人阅读的视图读取本地所有者记录的一次快照,显示已知提交事实、输入注入的观测、ACK 的确认依据及是否迟到、最后保存的等待、禁止重试以及可用的下一步操作。ACK 与等待分别显示,因此迟到的 ACK 不会重写已超时的等待。最后保存的等待并非完整的等待历史。ACK 是否迟到以第一次等待为准,可能不同于最后保存的等待。对于过期或已隔离的记录,收据和注入详情显示为不可用;若没有可用的继续等待操作,ACK 仍在等待的显示并不证明可以继续等待。JSON 字段结构和退出码的含义保持不变:查询成功返回 0,失败返回 1;查询成功不是已收到消息的证明。 + +状态查询不提交消息或唤醒模型,不初始化或修复记录,不签发收据权限,也不改变等待。只读记录路径不保存更改,但在内存中应用保留期限后,仍可能显示不含详细信息的保留标记(tombstone)。恢复后的显式隔离将提交状态设为 unknown,并在磁盘上保留已结束的等待。只有能够形成有效的公开结果时,视图才显示最后一次等待。已知的生命周期限制是:隔离会清除 ACK 详情,因此保留的 satisfied 等待无法满足现有公开验证条件,状态查询会以 invalid_handoff(退出码 1)拒绝。查询不会修复或改写历史;这仍是 #181 的待验证生命周期条件。现有状态查询逻辑可能向正在运行的收集器查询时钟证据,以确定下一步操作,但不会启动收集器。视图不读取对话记录或消息正文,也不暴露原始权限令牌。 + +收集器时钟查询也是只读的。旧版收集器可能拒绝该查询,因此可用的下一步操作可能不包含 `keep_waiting`。状态查询不会改用会写入记录的查询,也不会重启收集器。记录快照与可选的收集器时钟观测是分开的,并非原子性的联合观测。等待因 history_unavailable 失败后,若收集器时钟恢复可用,状态查询现在会保留已知提交事实和失败的等待,同时不提供 keep_waiting。其他等待创建语义不变。保留标记中的最近等待详情显示为因历史过期而不可用,不作为未请求等待的证据。 + +这是一次本地查询,不是持续观察或远程监控。Windows handoff 和 Codex ACK 仍不支持。[#296](https://github.com/abruption/session-peer/issues/296) 保持开放,以跟踪未来限制运行范围的 watch 模式。 + ## 证据、隐私和时间限制 完整的收件箱写入只表示提交,不表示消费或轮次完成。只有经认证且对应请求的收据,或时间顺序可证明的明确操作者确认,才能产生 acknowledged。写入或读取响应期间提前到达的收据先保存为有界的未分类证据。仅在提交事实及原始时钟顺序独立成立后分类,不凭空创建收据或回填 ACK 时间。已提交的 ACK 在收集器重启后保留;未使用权限保守失效。即使过期后的重复请求,也必须证明原始令牌哈希。人工确认不会重写旧等待。 diff --git a/session_peer.py b/session_peer.py index 7bb8797e..a4b6e029 100644 --- a/session_peer.py +++ b/session_peer.py @@ -7419,7 +7419,8 @@ def _save(self, state): os.unlink(temporary) @contextlib.contextmanager - def transaction(self, deadline=None): + def transaction(self, deadline=None, *, read_only=False): + """Validate one locked snapshot; read-only projections never persist retention.""" if IS_WINDOWS or fcntl is None: raise handoff_error("handoff_platform_unsupported") handoff_private_stat(self.root, True) @@ -7472,7 +7473,8 @@ def transaction(self, deadline=None): record.update(phase="tombstone", submission="unknown", native=None, waits=[], ack=None, capability=None, pendingReceipt=None, observe=False, ackRequested=False) yield state - self._save(state) + if not read_only: + self._save(state) except OSError as exc: raise handoff_error("handoff_history_unavailable") from exc finally: @@ -7504,10 +7506,10 @@ def prepare(self, binding, generation, correlation=None, deadline=None, native_c state["records"][correlation] = record return state["epoch"], copy.deepcopy(record) - def record(self, correlation, deadline=None): + def record(self, correlation, deadline=None, *, read_only=False): import copy handoff_uuid(correlation) - with self.transaction(deadline) as state: + with self.transaction(deadline, **({"read_only": True} if read_only else {})) as state: record = state["records"].get(correlation) if record is None: raise handoff_error("handoff_id_unknown") @@ -7751,8 +7753,9 @@ def handoff_cap_hash(epoch, correlation, generation, cap): def handoff_collect(ledger, epoch_id, frame): """Receipt-only operations. No native command, transcript or model access.""" import hmac - with ledger.transaction() as state: - if frame == {"op": "clock"}: + read_only_clock = frame == {"op": "clock", "readOnly": True} and type(frame.get("readOnly")) is bool + with ledger.transaction(**({"read_only": True} if read_only_clock else {})) as state: + if frame == {"op": "clock"} or read_only_clock: return {"ok": True, "clockEpoch": epoch_id} if frame.get("op") == "mint": if set(frame) != {"op", "ledgerEpoch", "correlationId", "targetGeneration"}: @@ -7875,9 +7878,16 @@ def handoff_ipc(root, frame, deadline=None): raise handoff_error("receipt_channel_unavailable") from exc -def handoff_channel_epoch(ledger, deadline=None): +def handoff_channel_epoch(ledger, deadline=None, *, read_only=False): try: - result = handoff_ipc(ledger.root, {"op": "clock"}, deadline) + frame = {"op": "clock"} + if read_only: + # Older collectors reject this extra field before committing a + # transaction. Never retry their write-through legacy clock query. + frame["readOnly"] = True + if deadline is None: + deadline = handoff_now() + 2 + result = handoff_ipc(ledger.root, frame, deadline) return handoff_uuid(result.get("clockEpoch")) except CcPeerError: return None @@ -8016,7 +8026,8 @@ def handoff_public(epoch, record, active_clock=None): result["nextActions"] = ["reconcile"] if (record.get("ackRequested") and record["capability"] and not record["capability"]["revoked"] and not ack and active_clock is not None and active_clock == record["capability"]["clockEpoch"] - and handoff_now_ns() < record["capability"]["expiresNs"] and (not wait or wait["status"] != "unsupported")): + and handoff_now_ns() < record["capability"]["expiresNs"] + and (not wait or (wait["status"] != "unsupported" and wait.get("reason") != "history_unavailable"))): result["nextActions"].append("keep_waiting") if wait and wait["status"] == "pending": result["nextActions"].append("stop_waiting") @@ -8031,6 +8042,55 @@ def handoff_query_error(correlation, context): "retry": {"allowed": False, "reason": "history_unavailable"}}} +def handoff_status_text(public, phase): + """Render only validated public metadata; never accept the private record.""" + handoff_validate_public(public) + if phase not in ("prepared", "attempted", "terminal", "tombstone", "quarantined"): + raise handoff_error("invalid_handoff") + view = human_text(public) + observation, ack, wait = view["observation"], view["ack"], view["wait"] + unavailable = {"tombstone": "expired history", "quarantined": "quarantined history"}.get(phase) + injection = ("unavailable (" + unavailable + ")" if unavailable else + observation["status"] + ("; injection observed" if observation["injectionObserved"] + else "; injection not confirmed")) + receipt = "unavailable (" + unavailable + ")" if unavailable else ack["status"] + if not unavailable and ack["status"] == "pending" and "keep_waiting" not in view["nextActions"]: + receipt += "; waiting availability not established" + lines = [ + "Handoff " + view["correlationId"], + "State: " + view["state"], + "Submission: " + view["submission"]["status"], + "Injection observation: " + injection, + "Receipt (ACK): " + receipt, + ] + if not unavailable and ack["status"] == "acknowledged": + lines.extend([ + " Assurance: " + ack["assurance"], + " Late receipt: " + ("yes (relative to the first wait)" if ack["late"] else "no"), + " Received at (UTC milliseconds): " + str(ack["receivedAtUtcMs"]), + ]) + lines.append("Latest wait: unavailable (expired history)" if phase == "tombstone" else + "Latest wait: " + wait["status"] + "; goal: " + wait["for"]) + if phase != "tombstone" and "operationId" in wait: + lines.append(" Operation: " + wait["operationId"]) + if phase != "tombstone" and "deadlineAtUtcMs" in wait: + lines.append(" Deadline (UTC milliseconds): " + str(wait["deadlineAtUtcMs"])) + if phase != "tombstone" and "reason" in wait: + lines.append(" Reason: " + wait["reason"]) + if phase == "tombstone": + lines.append("History: retained effect fence only; expired detail cannot establish absence of a receipt.") + elif phase == "quarantined": + lines.append("History: explicitly quarantined restored ledger; reconcile with the operator.") + lines.extend([ + "Retry: not allowed (" + view["retry"]["reason"] + ")", + "Next actions: " + (", ".join(view["nextActions"]) or "none advertised"), + "Single ledger snapshot; latest stored wait only. No native submission performed.", + "Collector clock evidence, when available, is checked separately.", + "Submission does not confirm consumption; a receipt does not confirm task completion.", + ]) + return "\n".join(lines) + + def handoff_start_wait(record, goal, deadline, clock_epoch, *, initial_status=None, reason=None): if len(record["waits"]) >= 64: raise handoff_error("handoff_wait_capacity") @@ -8149,20 +8209,36 @@ def cmd_handoff(args): args.correlation_id = frame["correlationId"] correlation = handoff_uuid(args.correlation_id) try: - epoch, record = ledger.record(correlation) + epoch, record = ledger.record(correlation, **({"read_only": True} if action == "status" else {})) except CcPeerError as exc: context = "id_unknown" if exc.details.get("reason") == "handoff_id_unknown" else "ledger_corrupt" if exc.details.get("reason") == "handoff_ledger_corrupt" else "ledger_missing" result = handoff_query_error(correlation, context) if args.json: print(json.dumps(result, ensure_ascii=False)) else: - print("Handoff history unavailable; do not resend.", file=sys.stderr) + diagnostic = "Handoff history unavailable; do not resend." + if action == "status": + reason = { + "handoff_id_unknown": "request ID not found", + "handoff_ledger_corrupt": "invalid ledger", + "handoff_storage_busy": "ledger busy", + "unsafe_handoff_storage": "storage ownership or permissions refused", + "handoff_platform_unsupported": "handoff unsupported on this platform", + }.get(exc.details.get("reason"), "history could not be read") + diagnostic += " Reason: " + reason + ". No history initialized or repaired." + print(diagnostic, file=sys.stderr) return 1 code = handoff_wait(ledger, correlation, args.wait_for, args.wait_timeout) if action == "wait" else 0 - epoch, record = ledger.record(correlation) + if action != "status": + epoch, record = ledger.record(correlation) result = dict(record["native"] or {}) - result.update(ok=code == 0, handoff=handoff_public(epoch, record, handoff_channel_epoch(ledger) if record["capability"] and not record["ack"] else None)) - emit(args.json, result, "Handoff " + result["handoff"]["state"] + "; no native submission performed.", command="handoff") + active_clock = None + if record["capability"] and not record["ack"] and (action != "status" or not record["capability"]["revoked"]): + active_clock = handoff_channel_epoch(ledger, **({"read_only": True} if action == "status" else {})) + result.update(ok=code == 0, handoff=handoff_public(epoch, record, active_clock)) + human = (handoff_status_text(result["handoff"], record["phase"]) if action == "status" and not args.json + else "Handoff " + result["handoff"]["state"] + "; no native submission performed.") + emit(args.json, result, human, command="handoff") return code diff --git a/session_peer_core/handoff.py b/session_peer_core/handoff.py index 7847a1dd..5035a00a 100644 --- a/session_peer_core/handoff.py +++ b/session_peer_core/handoff.py @@ -312,7 +312,8 @@ def _save(self, state): os.unlink(temporary) @contextlib.contextmanager - def transaction(self, deadline=None): + def transaction(self, deadline=None, *, read_only=False): + """Validate one locked snapshot; read-only projections never persist retention.""" if IS_WINDOWS or fcntl is None: raise handoff_error("handoff_platform_unsupported") handoff_private_stat(self.root, True) @@ -365,7 +366,8 @@ def transaction(self, deadline=None): record.update(phase="tombstone", submission="unknown", native=None, waits=[], ack=None, capability=None, pendingReceipt=None, observe=False, ackRequested=False) yield state - self._save(state) + if not read_only: + self._save(state) except OSError as exc: raise handoff_error("handoff_history_unavailable") from exc finally: @@ -397,10 +399,10 @@ def prepare(self, binding, generation, correlation=None, deadline=None, native_c state["records"][correlation] = record return state["epoch"], copy.deepcopy(record) - def record(self, correlation, deadline=None): + def record(self, correlation, deadline=None, *, read_only=False): import copy handoff_uuid(correlation) - with self.transaction(deadline) as state: + with self.transaction(deadline, **({"read_only": True} if read_only else {})) as state: record = state["records"].get(correlation) if record is None: raise handoff_error("handoff_id_unknown") @@ -644,8 +646,9 @@ def handoff_cap_hash(epoch, correlation, generation, cap): def handoff_collect(ledger, epoch_id, frame): """Receipt-only operations. No native command, transcript or model access.""" import hmac - with ledger.transaction() as state: - if frame == {"op": "clock"}: + read_only_clock = frame == {"op": "clock", "readOnly": True} and type(frame.get("readOnly")) is bool + with ledger.transaction(**({"read_only": True} if read_only_clock else {})) as state: + if frame == {"op": "clock"} or read_only_clock: return {"ok": True, "clockEpoch": epoch_id} if frame.get("op") == "mint": if set(frame) != {"op", "ledgerEpoch", "correlationId", "targetGeneration"}: @@ -768,9 +771,16 @@ def handoff_ipc(root, frame, deadline=None): raise handoff_error("receipt_channel_unavailable") from exc -def handoff_channel_epoch(ledger, deadline=None): +def handoff_channel_epoch(ledger, deadline=None, *, read_only=False): try: - result = handoff_ipc(ledger.root, {"op": "clock"}, deadline) + frame = {"op": "clock"} + if read_only: + # Older collectors reject this extra field before committing a + # transaction. Never retry their write-through legacy clock query. + frame["readOnly"] = True + if deadline is None: + deadline = handoff_now() + 2 + result = handoff_ipc(ledger.root, frame, deadline) return handoff_uuid(result.get("clockEpoch")) except CcPeerError: return None @@ -909,7 +919,8 @@ def handoff_public(epoch, record, active_clock=None): result["nextActions"] = ["reconcile"] if (record.get("ackRequested") and record["capability"] and not record["capability"]["revoked"] and not ack and active_clock is not None and active_clock == record["capability"]["clockEpoch"] - and handoff_now_ns() < record["capability"]["expiresNs"] and (not wait or wait["status"] != "unsupported")): + and handoff_now_ns() < record["capability"]["expiresNs"] + and (not wait or (wait["status"] != "unsupported" and wait.get("reason") != "history_unavailable"))): result["nextActions"].append("keep_waiting") if wait and wait["status"] == "pending": result["nextActions"].append("stop_waiting") @@ -924,6 +935,55 @@ def handoff_query_error(correlation, context): "retry": {"allowed": False, "reason": "history_unavailable"}}} +def handoff_status_text(public, phase): + """Render only validated public metadata; never accept the private record.""" + handoff_validate_public(public) + if phase not in ("prepared", "attempted", "terminal", "tombstone", "quarantined"): + raise handoff_error("invalid_handoff") + view = human_text(public) + observation, ack, wait = view["observation"], view["ack"], view["wait"] + unavailable = {"tombstone": "expired history", "quarantined": "quarantined history"}.get(phase) + injection = ("unavailable (" + unavailable + ")" if unavailable else + observation["status"] + ("; injection observed" if observation["injectionObserved"] + else "; injection not confirmed")) + receipt = "unavailable (" + unavailable + ")" if unavailable else ack["status"] + if not unavailable and ack["status"] == "pending" and "keep_waiting" not in view["nextActions"]: + receipt += "; waiting availability not established" + lines = [ + "Handoff " + view["correlationId"], + "State: " + view["state"], + "Submission: " + view["submission"]["status"], + "Injection observation: " + injection, + "Receipt (ACK): " + receipt, + ] + if not unavailable and ack["status"] == "acknowledged": + lines.extend([ + " Assurance: " + ack["assurance"], + " Late receipt: " + ("yes (relative to the first wait)" if ack["late"] else "no"), + " Received at (UTC milliseconds): " + str(ack["receivedAtUtcMs"]), + ]) + lines.append("Latest wait: unavailable (expired history)" if phase == "tombstone" else + "Latest wait: " + wait["status"] + "; goal: " + wait["for"]) + if phase != "tombstone" and "operationId" in wait: + lines.append(" Operation: " + wait["operationId"]) + if phase != "tombstone" and "deadlineAtUtcMs" in wait: + lines.append(" Deadline (UTC milliseconds): " + str(wait["deadlineAtUtcMs"])) + if phase != "tombstone" and "reason" in wait: + lines.append(" Reason: " + wait["reason"]) + if phase == "tombstone": + lines.append("History: retained effect fence only; expired detail cannot establish absence of a receipt.") + elif phase == "quarantined": + lines.append("History: explicitly quarantined restored ledger; reconcile with the operator.") + lines.extend([ + "Retry: not allowed (" + view["retry"]["reason"] + ")", + "Next actions: " + (", ".join(view["nextActions"]) or "none advertised"), + "Single ledger snapshot; latest stored wait only. No native submission performed.", + "Collector clock evidence, when available, is checked separately.", + "Submission does not confirm consumption; a receipt does not confirm task completion.", + ]) + return "\n".join(lines) + + def handoff_start_wait(record, goal, deadline, clock_epoch, *, initial_status=None, reason=None): if len(record["waits"]) >= 64: raise handoff_error("handoff_wait_capacity") @@ -1042,20 +1102,36 @@ def cmd_handoff(args): args.correlation_id = frame["correlationId"] correlation = handoff_uuid(args.correlation_id) try: - epoch, record = ledger.record(correlation) + epoch, record = ledger.record(correlation, **({"read_only": True} if action == "status" else {})) except CcPeerError as exc: context = "id_unknown" if exc.details.get("reason") == "handoff_id_unknown" else "ledger_corrupt" if exc.details.get("reason") == "handoff_ledger_corrupt" else "ledger_missing" result = handoff_query_error(correlation, context) if args.json: print(json.dumps(result, ensure_ascii=False)) else: - print("Handoff history unavailable; do not resend.", file=sys.stderr) + diagnostic = "Handoff history unavailable; do not resend." + if action == "status": + reason = { + "handoff_id_unknown": "request ID not found", + "handoff_ledger_corrupt": "invalid ledger", + "handoff_storage_busy": "ledger busy", + "unsafe_handoff_storage": "storage ownership or permissions refused", + "handoff_platform_unsupported": "handoff unsupported on this platform", + }.get(exc.details.get("reason"), "history could not be read") + diagnostic += " Reason: " + reason + ". No history initialized or repaired." + print(diagnostic, file=sys.stderr) return 1 code = handoff_wait(ledger, correlation, args.wait_for, args.wait_timeout) if action == "wait" else 0 - epoch, record = ledger.record(correlation) + if action != "status": + epoch, record = ledger.record(correlation) result = dict(record["native"] or {}) - result.update(ok=code == 0, handoff=handoff_public(epoch, record, handoff_channel_epoch(ledger) if record["capability"] and not record["ack"] else None)) - emit(args.json, result, "Handoff " + result["handoff"]["state"] + "; no native submission performed.", command="handoff") + active_clock = None + if record["capability"] and not record["ack"] and (action != "status" or not record["capability"]["revoked"]): + active_clock = handoff_channel_epoch(ledger, **({"read_only": True} if action == "status" else {})) + result.update(ok=code == 0, handoff=handoff_public(epoch, record, active_clock)) + human = (handoff_status_text(result["handoff"], record["phase"]) if action == "status" and not args.json + else "Handoff " + result["handoff"]["state"] + "; no native submission performed.") + emit(args.json, result, human, command="handoff") return code diff --git a/tests/core/test_handoff_status_view.py b/tests/core/test_handoff_status_view.py new file mode 100644 index 00000000..2a310bb5 --- /dev/null +++ b/tests/core/test_handoff_status_view.py @@ -0,0 +1,439 @@ +"""Read-only status snapshots; private fixtures, no native sessions or messages.""" +import contextlib +import copy +import io +import json +import os +from pathlib import Path +import socket +import tempfile +import threading +import unittest +from unittest import mock + +import session_peer as peer + + +@unittest.skipIf(peer.IS_WINDOWS or peer.fcntl is None, "private POSIX ledger only") +class HandoffStatusView(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory( + prefix="codex-status-", dir=os.environ.get("SESSION_PEER_TEST_TMP")) + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.ledger = peer.HandoffLedger(self.root / "ledger") + self.epoch = self.ledger.initialize() + self.binding = {"agent": "claude", "destination": ["local"], + "target": str(os.getpid()), "home": None, "payloadDigest": "a" * 64} + self.epoch, self.record = self.ledger.prepare(self.binding, "tg1:" + "a" * 64) + self.correlation = self.record["id"] + + def snapshot(self, root=None): + root = root or self.ledger.root + if not root.exists(): + return None + return {path.name: (path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns) + for path in root.iterdir() if path.is_file()} + + def status(self, *, as_json=True, root=None, correlation=None): + command = ["handoff", "status", "--correlation-id", correlation or self.correlation] + if as_json: + command.append("--json") + out, err = io.StringIO(), io.StringIO() + with contextlib.ExitStack() as stack: + stack.enter_context(mock.patch.object(peer, "handoff_root", return_value=root or self.ledger.root)) + # Any of these would turn inspection into an effect or durable write. + for owner, name in ((peer.HandoffLedger, "_save"), (peer, "handoff_wait"), + (peer, "post_to_socket"), (peer, "handoff_codex_submit"), + (peer.subprocess, "Popen")): + stack.enter_context(mock.patch.object(owner, name, side_effect=AssertionError("status performed an effect"))) + stack.enter_context(contextlib.redirect_stdout(out)) + stack.enter_context(contextlib.redirect_stderr(err)) + code = peer.main(command) + return code, json.loads(out.getvalue()) if as_json and out.getvalue() else out.getvalue(), err.getvalue() + + def submitted(self, wait_status=None, acknowledged=False): + with self.ledger.transaction() as state: + record = state["records"][self.correlation] + record.update(phase="terminal", submission="submitted", observe=True, ackRequested=True, + native={"ok": True, "target": {"pid": os.getpid(), "name": "native-private-sentinel"}, + "chars": 4, "dryRun": False}) + if wait_status: + wait = {"for": "acknowledged", "status": wait_status, + "operationId": str(peer.uuid.uuid4()), "deadlineAtUtcMs": 1234, + "deadlineNs": 1234000000, "clockEpoch": None} + if wait_status == "stopped": + wait["reason"] = "stopped_by_operator" + record["waits"].append(wait) + if acknowledged: + record["ack"] = {"status": "acknowledged", "assurance": "operator_confirmed", + "receivedAtUtcMs": 5678, "late": True, + "receiptId": None, "classifiedClockEpoch": None} + return self.ledger.record(self.correlation, read_only=True)[1] + + def test_json_preserves_existing_native_and_public_shape_without_writes(self): + record = self.submitted("timed_out_unknown", acknowledged=True) + before = self.snapshot() + with mock.patch.object(peer.HandoffLedger, "record", autospec=True, + wraps=None, side_effect=lambda ledger, correlation, **kw: + (self.epoch, copy.deepcopy(record))) as lookup: + code, result, _ = self.status() + lookup.assert_called_once() + self.assertTrue(lookup.call_args.kwargs.get("read_only")) + expected = dict(record["native"]) + expected.update(schemaVersion=1, host=peer.local_host(), command="handoff", ok=True, + handoff=peer.handoff_public(self.epoch, record)) + self.assertEqual(code, 0) + self.assertTrue(result == expected, "status changed the public snapshot") + self.assertTrue(before == self.snapshot(), "status changed durable history") + + def test_json_and_text_actual_lookup_preserve_bytes_inode_and_mtime(self): + before = self.snapshot() + for as_json in (True, False): + with self.subTest(as_json=as_json): + code, result, _ = self.status(as_json=as_json) + self.assertEqual(code, 0) + self.assertTrue(before == self.snapshot(), "status changed durable history") + if not as_json: + for label in ("State:", "Submission:", "Injection observation:", "Receipt (ACK):", + "Latest wait:", "Retry:", "Next actions:"): + self.assertIn(label, result) + + def test_late_receipt_and_original_timeout_are_both_visible(self): + self.submitted("timed_out_unknown", acknowledged=True) + before = self.snapshot() + code, output, _ = self.status(as_json=False) + self.assertEqual(code, 0) + for fact in ("Receipt (ACK): acknowledged", "Latest wait: timed_out_unknown; goal: acknowledged", + "Assurance: operator_confirmed", "Received at (UTC milliseconds): 5678", "Late receipt: yes"): + self.assertIn(fact, output) + self.assertNotIn("native-private-sentinel", output) + self.assertTrue(before == self.snapshot(), "late receipt inspection changed history") + + def test_on_time_receipt_is_distinguished_from_late_receipt(self): + self.submitted("satisfied", acknowledged=True) + with self.ledger.transaction() as state: + state["records"][self.correlation]["ack"]["late"] = False + before = self.snapshot() + code, text, _ = self.status(as_json=False) + self.assertEqual(code, 0) + self.assertIn("Late receipt: no", text) + self.assertNotIn("Late receipt: yes", text) + self.assertIn("Latest wait: satisfied; goal: acknowledged", text) + self.assertTrue(before == self.snapshot(), "on-time receipt inspection changed history") + + def test_only_latest_wait_is_rendered_and_previous_wait_remains_unchanged(self): + record = self.submitted("timed_out_unknown", acknowledged=True) + previous = copy.deepcopy(record["waits"][0]) + current = {**previous, "status": "satisfied", "operationId": str(peer.uuid.uuid4()), + "deadlineAtUtcMs": 6789, "deadlineNs": 6789000000} + with self.ledger.transaction() as state: + state["records"][self.correlation]["waits"].append(current) + before = self.snapshot() + code, text, _ = self.status(as_json=False) + self.assertEqual(code, 0) + self.assertIn("Operation: " + current["operationId"], text) + self.assertNotIn(previous["operationId"], text) + self.assertIn("Latest wait: satisfied; goal: acknowledged", text) + self.assertNotIn("Latest wait: timed_out_unknown", text) + _, result, _ = self.status() + self.assertEqual(result["handoff"]["wait"]["operationId"], current["operationId"]) + self.assertTrue(self.ledger.record(self.correlation, read_only=True)[1]["waits"][0] == previous, + "previous wait changed") + self.assertTrue(before == self.snapshot(), "latest wait inspection changed history") + + def test_stopped_wait_status_query_is_success_and_keeps_submission(self): + self.submitted("stopped") + before = self.snapshot() + code, result, _ = self.status() + self.assertEqual(code, 0) + self.assertTrue(result["ok"]) + self.assertEqual(result["handoff"]["submission"], {"status": "submitted"}) + self.assertEqual(result["handoff"]["wait"]["status"], "stopped") + self.assertEqual(result["handoff"]["wait"]["reason"], "stopped_by_operator") + self.assertTrue(before == self.snapshot(), "query rewrote stopped wait") + + def test_unsupported_evidence_does_not_advertise_wait_or_retry(self): + self.submitted("unsupported") + before = self.snapshot() + code, result, _ = self.status() + self.assertEqual(code, 0) + public = result["handoff"] + self.assertEqual(public["ack"]["status"], "unsupported") + self.assertEqual(public["observation"], {"status": "unsupported", "injectionObserved": False}) + self.assertEqual(public["nextActions"], ["reconcile"]) + _, text, _ = self.status(as_json=False) + self.assertIn("Receipt (ACK): unsupported", text) + self.assertIn("Retry: not allowed", text) + self.assertNotIn("keep_waiting", text) + self.assertTrue(before == self.snapshot(), "unsupported status changed history") + + def test_retention_projects_tombstone_without_saving_it(self): + boot = "b" * 64 + with self.ledger.transaction() as state: + record = state["records"][self.correlation] + record.update(historyBoot=boot, createdNs=1) + before = self.snapshot() + with mock.patch.object(peer, "handoff_boot_clock", return_value=boot), \ + mock.patch.object(peer, "handoff_now_ns", return_value=1 + 30 * 86400 * 1000000000): + code, result, _ = self.status() + _, text, _ = self.status(as_json=False) + self.assertEqual(code, 0) + self.assertEqual(result["handoff"]["state"], "unknown") + self.assertEqual(result["handoff"]["nextActions"], ["reconcile"]) + self.assertIn("retained effect fence", text.lower()) + self.assertIn("expired detail", text.lower()) + self.assertTrue(before == self.snapshot(), "status persisted retention projection") + + def test_quarantined_history_caution_without_repair(self): + prior = self.submitted("timed_out_unknown", acknowledged=True) + self.ledger.quarantine_restored() + before = self.snapshot() + code, text, _ = self.status(as_json=False) + self.assertEqual(code, 0) + self.assertIn("quarantin", text.lower()) + self.assertIn("reconcile", text) + _, result, _ = self.status() + self.assertEqual(result["handoff"]["submission"], {"status": "unknown"}) + self.assertEqual(result["handoff"]["state"], "timed_out_unknown") + expected_wait = {key: value for key, value in prior["waits"][-1].items() + if key not in ("deadlineNs", "clockEpoch")} + self.assertTrue(result["handoff"]["wait"] == expected_wait, "quarantine status changed original wait") + self.assertTrue(before == self.snapshot(), "status repaired quarantined history") + + def test_quarantined_satisfied_wait_preserves_existing_refusal_without_repair(self): + # Existing #181 gate: quarantine removes ACK, but preserves terminal + # waits; the frozen public schema cannot represent satisfied without + # ACK. A presentation change must not fabricate ACK or rewrite waits. + self.submitted("satisfied", acknowledged=True) + self.ledger.quarantine_restored() + before = self.snapshot() + for as_json in (True, False): + code, output, error = self.status(as_json=as_json) + self.assertEqual(code, 1) + if as_json: + self.assertEqual(output["reason"], "invalid_handoff") + self.assertNotIn("handoff", output) + else: + self.assertIn("Handoff operation refused", error) + self.assertTrue(before == self.snapshot(), "query rewrote quarantined terminal wait") + + def test_missing_corrupt_and_unknown_id_keep_query_errors_and_initialize_nothing(self): + missing = self.root / "missing" + for context, root, correlation in (("ledger_missing", missing, self.correlation), + ("id_unknown", self.ledger.root, str(peer.uuid.uuid4()))): + with self.subTest(context=context): + before = self.snapshot(root) + code, result, _ = self.status(root=root, correlation=correlation) + self.assertEqual(code, 1) + self.assertTrue(result == peer.handoff_query_error(correlation, context), "query envelope changed") + self.assertTrue(before == self.snapshot(root), "query initialized or changed history") + path = self.ledger.root / "ledger.json" + path.write_bytes(b"{invalid") + before = self.snapshot() + code, result, _ = self.status() + self.assertEqual(code, 1) + self.assertEqual(result["handoffQuery"]["context"], "ledger_corrupt") + self.assertNotIn("handoff", result) + self.assertNotIn("ledgerEpoch", result) + self.assertTrue(before == self.snapshot(), "query repaired corrupt history") + + def test_read_only_transaction_discards_local_mutation_default_still_saves(self): + before = self.snapshot() + with self.ledger.transaction(read_only=True) as state: + state["records"][self.correlation]["observe"] = True + self.assertTrue(before == self.snapshot(), "read-only transaction saved") + with self.ledger.transaction() as state: + state["records"][self.correlation]["observe"] = True + self.assertTrue(self.ledger.record(self.correlation, read_only=True)[1]["observe"]) + + def test_keyboard_interrupt_during_read_does_not_save(self): + before = self.snapshot() + with mock.patch.object(peer, "handoff_boot_clock", side_effect=KeyboardInterrupt): + code, _, _ = self.status(as_json=False) + self.assertEqual(code, 130) + self.assertTrue(before == self.snapshot(), "interrupted read saved history") + + def test_keyboard_interrupt_after_snapshot_does_not_save(self): + before = self.snapshot() + with mock.patch.object(peer, "handoff_public", side_effect=KeyboardInterrupt): + code, _, _ = self.status(as_json=False) + self.assertEqual(code, 130) + self.assertTrue(before == self.snapshot(), "completed read saved before interruption") + + def test_lost_history_never_displays_no_ack_requested_as_historical_fact(self): + self.submitted("timed_out_unknown", acknowledged=True) + self.ledger.quarantine_restored() + _, text, _ = self.status(as_json=False) + self.assertIn("Receipt (ACK): unavailable (quarantined history)", text) + self.assertIn("Injection observation: unavailable (quarantined history)", text) + with self.ledger.transaction() as state: + state["records"][self.correlation].update(historyBoot="b" * 64, createdNs=1) + before = self.snapshot() + with mock.patch.object(peer, "handoff_boot_clock", return_value="b" * 64), \ + mock.patch.object(peer, "handoff_now_ns", return_value=1 + 30 * 86400 * 1000000000): + _, text, _ = self.status(as_json=False) + self.assertIn("Receipt (ACK): unavailable (expired history)", text) + self.assertNotIn("Receipt (ACK): not_requested", text) + self.assertIn("Latest wait: unavailable (expired history)", text) + self.assertNotIn("Latest wait: not_requested", text) + self.assertNotIn(" Operation:", text) + self.assertTrue(before == self.snapshot(), "history presentation persisted retention") + + def test_pending_receipt_without_collector_does_not_offer_wait_or_launch(self): + clock = str(peer.uuid.uuid4()) + peer.handoff_collect(self.ledger, clock, {"op": "mint", "ledgerEpoch": self.epoch, + "correlationId": self.correlation, "targetGeneration": self.record["generation"]}) + self.submitted() + before = self.snapshot() + code, text, _ = self.status(as_json=False) + self.assertEqual(code, 0) + self.assertIn("pending; waiting availability not established", text) + self.assertNotIn("keep_waiting", text) + self.assertTrue(before == self.snapshot(), "missing collector changed history") + with self.ledger.transaction() as state: + state["records"][self.correlation]["capability"]["revoked"] = True + with mock.patch.object(peer, "handoff_channel_epoch", side_effect=AssertionError("revoked clock probe")): + self.assertEqual(self.status()[0], 0) + + def test_read_only_clock_frame_strictness_and_total_budget(self): + before = self.snapshot() + for frame in ({"op": "clock", "readOnly": 1}, {"op": "clock", "readOnly": False}, + {"op": "clock", "readOnly": True, "extra": True}): + with self.assertRaises(peer.CcPeerError), \ + mock.patch.object(peer.HandoffLedger, "_save", side_effect=AssertionError("invalid clock saved")): + peer.handoff_collect(self.ledger, str(peer.uuid.uuid4()), frame) + self.assertTrue(before == self.snapshot(), "invalid query wrote history") + with mock.patch.object(peer, "handoff_now", return_value=100), \ + mock.patch.object(peer, "handoff_ipc", side_effect=peer.handoff_error("receipt_channel_unavailable")) as ipc: + self.assertIsNone(peer.handoff_channel_epoch(self.ledger, read_only=True)) + ipc.assert_called_once_with(self.ledger.root, {"op": "clock", "readOnly": True}, 102) + + def test_read_only_collector_clock_query_does_not_save(self): + before = self.snapshot() + clock = str(peer.uuid.uuid4()) + with mock.patch.object(peer.HandoffLedger, "_save", side_effect=AssertionError("clock query saved")): + result = peer.handoff_collect(self.ledger, clock, {"op": "clock", "readOnly": True}) + self.assertEqual(result["clockEpoch"], clock) + self.assertTrue(before == self.snapshot(), "clock query changed history") + + def test_read_only_clock_over_private_socket_preserves_history(self): + clock = str(peer.uuid.uuid4()) + before = self.snapshot() + errors, frames = [], [] + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.addCleanup(server.close) + path = self.ledger.root / "receipt.sock" + server.bind(str(path)) + path.chmod(0o600) + server.listen(1) + server.settimeout(3) + + def serve_once(): + try: + connection, _ = server.accept() + with connection: + connection.settimeout(3) + raw = bytearray() + while True: + chunk = connection.recv(1024) + if not chunk: + break + raw.extend(chunk) + frame = peer.handoff_json(bytes(raw), 4096) + frames.append(frame) + connection.sendall(peer.HandoffLedger.encode(peer.handoff_collect(self.ledger, clock, frame))) + except BaseException as error: + errors.append(type(error).__name__) + + worker = threading.Thread(target=serve_once, daemon=True) + with mock.patch.object(peer.HandoffLedger, "_save", side_effect=AssertionError("clock query saved")): + worker.start() + result = peer.handoff_channel_epoch(self.ledger, read_only=True) + worker.join(4) + self.assertFalse(worker.is_alive(), "clock fixture did not terminate") + self.assertEqual(errors, []) + self.assertEqual(frames, [{"op": "clock", "readOnly": True}]) + self.assertEqual(result, clock) + self.assertTrue(before == self.snapshot(), "IPC clock query changed durable history") + + def test_status_pending_authority_requests_only_read_only_clock(self): + clock = str(peer.uuid.uuid4()) + peer.handoff_collect(self.ledger, clock, {"op": "mint", "ledgerEpoch": self.epoch, + "correlationId": self.correlation, "targetGeneration": self.record["generation"]}) + self.submitted() + before = self.snapshot() + with mock.patch.object(peer, "handoff_ipc", return_value={"clockEpoch": clock}) as ipc: + code, result, _ = self.status() + self.assertEqual(code, 0) + ipc.assert_called_once() + self.assertEqual(ipc.call_args.args[1], {"op": "clock", "readOnly": True}) + self.assertIn("keep_waiting", result["handoff"]["nextActions"]) + self.assertTrue(before == self.snapshot(), "clock validation changed history") + with mock.patch.object(peer, "handoff_ipc", side_effect=peer.handoff_error("invalid_receipt")) as ipc: + code, result, _ = self.status() + self.assertEqual(code, 0) + self.assertEqual(ipc.call_count, 1, "legacy clock was retried") + self.assertNotIn("keep_waiting", result["handoff"]["nextActions"]) + + def test_recovered_clock_does_not_reopen_history_unavailable_wait(self): + clock = str(peer.uuid.uuid4()) + peer.handoff_collect(self.ledger, clock, {"op": "mint", "ledgerEpoch": self.epoch, + "correlationId": self.correlation, "targetGeneration": self.record["generation"]}) + self.submitted("failed") + with self.ledger.transaction() as state: + state["records"][self.correlation]["waits"][-1]["reason"] = "history_unavailable" + before = self.snapshot() + with mock.patch.object(peer, "handoff_ipc", return_value={"clockEpoch": clock}): + code, result, _ = self.status() + self.assertEqual(code, 0) + public = result["handoff"] + self.assertEqual(public["submission"], {"status": "submitted"}) + self.assertEqual(public["wait"]["status"], "failed") + self.assertEqual(public["wait"]["reason"], "history_unavailable") + self.assertEqual(public["nextActions"], ["reconcile"]) + self.assertTrue(before == self.snapshot(), "recovered clock changed terminal wait") + + def test_legacy_clock_handler_rejects_new_frame_before_saving(self): + def legacy_clock_or_receipt(frame): + # Pinned 87e46e8 handoff_collect entry: a legacy exact clock frame + # returned inside transaction; other non-mint/non-classify frames + # went to private_wire. The new clock frame takes that rejection + # path before the contextmanager reaches its write-through save. + with self.ledger.transaction(): + if frame == {"op": "clock"}: + return {"ok": True, "clockEpoch": str(peer.uuid.uuid4())} + return peer.handoff_private_wire(peer.HandoffLedger.encode(frame)) + + before = self.snapshot() + with mock.patch.object(peer.HandoffLedger, "_save", side_effect=AssertionError("legacy saved")), \ + self.assertRaises(peer.CcPeerError) as caught: + legacy_clock_or_receipt({"op": "clock", "readOnly": True}) + self.assertEqual(caught.exception.details.get("reason"), "invalid_receipt") + self.assertTrue(before == self.snapshot(), "legacy rejection saved history") + + def test_renderer_validates_snapshot_and_never_reflects_untrusted_values(self): + public = peer.handoff_public(self.epoch, self.record) + for mutate in (lambda value: value.update(state="\x1b[31muntrusted"), + lambda value: value.update(body="private-body-sentinel"), + lambda value: value["ack"].update(status="future_status"), + lambda value: value["retry"].update(allowed=True)): + candidate = copy.deepcopy(public) + mutate(candidate) + with self.assertRaises(peer.CcPeerError) as caught: + peer.handoff_status_text(candidate, "prepared") + self.assertNotIn("\x1b", str(caught.exception)) + self.assertNotIn("private-body-sentinel", str(caught.exception)) + + def test_renderer_omits_private_native_fields_and_unsafe_phase_text(self): + public = peer.handoff_public(self.epoch, self.record) + with self.assertRaises(peer.CcPeerError) as caught: + peer.handoff_status_text(public, "\x1b[31mprivate-phase") + self.assertNotIn("\x1b", str(caught.exception)) + self.assertNotIn("private-phase", str(caught.exception)) + text = peer.handoff_status_text(public, "prepared") + self.assertNotIn("\x1b", text) + self.assertIn(self.correlation, text) + for private in (self.epoch, self.record["generation"], self.binding["payloadDigest"]): + self.assertNotIn(private, text) + From 280d5a6ce3472f3d017eacf2b06d546430bb1013 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Thu, 22 Oct 2026 18:51:24 +0900 Subject: [PATCH 2/6] style(tests): remove trailing blank line in status fixtures --- tests/core/test_handoff_status_view.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/core/test_handoff_status_view.py b/tests/core/test_handoff_status_view.py index 2a310bb5..2213db8c 100644 --- a/tests/core/test_handoff_status_view.py +++ b/tests/core/test_handoff_status_view.py @@ -436,4 +436,3 @@ def test_renderer_omits_private_native_fields_and_unsafe_phase_text(self): self.assertIn(self.correlation, text) for private in (self.epoch, self.record["generation"], self.binding["payloadDigest"]): self.assertNotIn(private, text) - From b3c4929dd463c37b98ada3aaf7710ed4be924f4d Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 23 Oct 2026 09:19:05 +0900 Subject: [PATCH 3/6] feat(handoff): add bounded read-only request watch --- docs/cli-reference.md | 11 + docs/handoff-runtime.md | 14 +- docs/i18n-manifest.json | 4 +- docs/ja/cli-reference.md | 11 + docs/ja/handoff-runtime.md | 14 +- docs/ko/cli-reference.md | 11 + docs/ko/handoff-runtime.md | 14 +- docs/zh-CN/cli-reference.md | 11 + docs/zh-CN/handoff-runtime.md | 14 +- session_peer.py | 375 ++++++++++++++- session_peer_core/cli.py | 11 +- session_peer_core/handoff.py | 27 +- session_peer_core/handoff_watch.py | 335 ++++++++++++++ tests/core/test_handoff_watch.py | 657 +++++++++++++++++++++++++++ tests/core/test_handoff_watch_cli.py | 50 ++ tools/generate_session_peer.py | 1 + 16 files changed, 1546 insertions(+), 14 deletions(-) create mode 100644 session_peer_core/handoff_watch.py create mode 100644 tests/core/test_handoff_watch.py create mode 100644 tests/core/test_handoff_watch_cli.py diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 46c79a3f..0cf838d4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -416,6 +416,17 @@ error, `2` CLI usage error or an unresolved target reported as no-target, and is an operational error (`1`); a missing saved thread during dry-run returns `2`. Exit `0` on send does not confirm consumption or a reply. +### Bounded handoff watch (v1.3 candidate) + +```sh +session-peer handoff status --correlation-id PREPARED_UUID --json +session-peer handoff watch --correlation-id PREPARED_UUID --watch-timeout 30 --interval-ms 1000 --json +``` + +Use an existing prepared UUID in the local owner's POSIX ledger. Status remains one snapshot; watch polls for 1..60 seconds (default 30) at 1000..5000 millisecond intervals (default 1000), accepting ASCII integers only and taking at most 61 samples. Watch emits an initial snapshot only if the budget permits a valid public projection, then changed public metadata/historyPhase only; it may emit no snapshots. JSON watch output uses the common schemaVersion 1 NDJSON envelope with operation watch and event snapshot or end, nesting the unchanged handoff object in snapshots; status JSON is unchanged. Timeout completion is exit 0 for watch completion only, not ACK or wait success; SIGINT is 130, history/output failure 1, invalid arguments 2. Missing/corrupt history or ledgerEpoch change stops watching. historyPhase is prepared, attempted, terminal, tombstone or quarantined; attempted means a durable attempt fence, not active execution. + +Watch is read-only: no intents, waits, receipts, ledger writes, submission or wake. It adds no host/device/Windows route or Codex ACK. Limits are 16 KiB per frame and 256 KiB total with a 2 KiB end reserve. Real-fd stdout uses one owned isolated output-only Python helper that sees only bounded public serialized bytes, never changes shared descriptor flags and reads no ledger/native data. The parent enforces the original total budget including IPC, checks it before new ledger read/validation stages, and reserves 100 ms within it for final output and cleanup, so sampling may end earlier. It terminates and reaps its helper on timeout, SIGINT or error; uninterruptible OS operations prevent a hard-real-time guarantee. Partial or unacknowledged writes prohibit subsequent/end frames; consumers must reject truncated final NDJSON lines. Helper completion means OS write completion, not recipient consumption. Snapshots are not a lossless event stream, and local UTC observations do not prove ACK arrival order. See the [handoff runtime guide](handoff-runtime.md) for privacy, retained submission/ACK/wait meanings, read-only collector queries and the existing quarantine gate. Watch launches no native message process or collector. This candidate is not a published release. No payload/capability digests are exposed; public targetGeneration keeps its existing contract. The read-only ledger releases its shared lock after reading immutable snapshot bytes, before parsing and validation. Interrupted final output and cleanup are capped at 100 ms within the remaining total budget. Overall success requires a complete successful end frame and exit 0: cleanup may fail after that frame. A nonzero exit means overall failure, without promoting ACK, discarding independently valid submission facts or permitting retry. + ### Updating For package-managed installs, use the same manager that installed the command: diff --git a/docs/handoff-runtime.md b/docs/handoff-runtime.md index 8f91ed21..5142fbdb 100644 --- a/docs/handoff-runtime.md +++ b/docs/handoff-runtime.md @@ -50,7 +50,19 @@ Status does not submit or wake, initialize or repair a ledger, mint receipt auth The collector clock query is also read-only. An older collector may reject that query, so `keep_waiting` may be absent from the available next actions; status does not fall back to a writing query or restart the collector. The ledger snapshot and optional collector clock observation are separate, not an atomic combined observation. After a wait fails with history_unavailable and the collector clock becomes available again, status now preserves known submission and that failed wait without offering keep_waiting. Other wait-creation semantics are unchanged. A tombstone's latest-wait detail is shown as unavailable due to expired history, not as evidence that no wait was requested. -This is a single local query, not continuous watching or remote monitoring. Windows handoff and Codex ACK remain unsupported. [#296](https://github.com/abruption/session-peer/issues/296) remains open for a future bounded watch mode. +Status remains a single local query. The v1.3 candidate adds bounded local watching below; Windows handoff and Codex ACK remain unsupported. [#296](https://github.com/abruption/session-peer/issues/296) remains open pending review and merge, and this candidate is not a published release. + +## Watch one request for a bounded period + +```sh +session-peer handoff watch --correlation-id PREPARED_UUID --watch-timeout 30 --interval-ms 1000 --json +``` + +Watch polls only an existing local owner's POSIX ledger. The timeout accepts ASCII integers 1..60 seconds (default 30); the interval accepts ASCII integers 1000..5000 milliseconds (default 1000). It takes at most 61 samples and emits an initial snapshot only if the budget permits a valid public projection, then only changes to public metadata or historyPhase; it may emit no snapshots. It is not a lossless event stream. Known submission, ACK assurance, ACK lateness relative to the first wait, and the latest stored wait retain the status view's separate meanings. A local observation time in UTC is not ACK arrival time or ordering evidence. historyPhase is prepared, attempted, terminal, tombstone or quarantined; attempted means a durable attempt fence, not active execution. + +JSON output is NDJSON using the common schemaVersion 1 envelope with watch-specific fields: operation is watch and event is snapshot or end. A snapshot nests the unchanged public handoff object; normal status JSON is unchanged. Deadline completion returns 0 and means only that watching ended, not that an ACK arrived or a wait succeeded. SIGINT returns 130; history or output failure returns 1. Missing/corrupt history or a changed ledgerEpoch stops the watch without inventing a handoff or epoch. The existing quarantined satisfied-wait refusal remains the #181 lifecycle gate described above. + +Each output frame is limited to 16 KiB, total output to 256 KiB, with 2 KiB reserved for the end frame. For real-fd stdout, one owned isolated output-only Python helper receives only bounded public serialized bytes and never changes shared descriptor flags or reads ledger/native data. The parent enforces the original total deadline, including helper IPC, and checks it before new ledger read or validation stages. It reserves 100 ms within that total for final output and cleanup, so sampling may end earlier. The parent terminates and reaps its helper on timeout, SIGINT or error. Uninterruptible OS operations still prevent a hard-real-time guarantee. After a partial or unacknowledged write, no subsequent frame, including end, is sent; consumers must reject a truncated final NDJSON line. A helper completion reply proves OS write completion only, not recipient consumption. Watch does not read native bodies or transcripts or expose capabilities or payload/capability digests; public targetGeneration remains unchanged under its existing contract. It does not create intents, waits or receipts, submit, wake, initialize, repair or write the ledger. It may use the existing read-only clock query on an already-running local collector, never launch a collector or native message process. Host/device routing and Windows watching are unsupported; no Codex ACK support is added. The read-only ledger releases its shared lock after reading immutable snapshot bytes, before parsing and validation. Interrupted final output and cleanup are capped at 100 ms within the remaining total budget. Overall success requires a complete successful end frame and exit 0: cleanup may fail after that frame. A nonzero exit means overall failure, without promoting ACK, discarding independently valid submission facts or permitting retry. ## Evidence, privacy and timing diff --git a/docs/i18n-manifest.json b/docs/i18n-manifest.json index 81dd0f35..990e0bb4 100644 --- a/docs/i18n-manifest.json +++ b/docs/i18n-manifest.json @@ -15,12 +15,12 @@ "docs/ai-assistant-guide.md": "9624ec96b92fb688fae3be3c94cb3564c77985091be451fbf1f056a1af9b9d9c", "docs/antigravity.md": "f2b5122a0ce6e6fb5bd4d8ed50ce2c8bd2dc9c98ec8ce328405cf7c2cd24229d", "docs/architecture/agent-transports.md": "01a4e3942e87acb476595859f2e37f9653ea1e3bc19ebc68dced1adf20a6966f", - "docs/cli-reference.md": "43e9e3232d8975d3e6b17065f40f43ff590184cac503d2c8fdae05142668334b", + "docs/cli-reference.md": "af1d3524bad9020dd4cb9ecb6b23db7207686ee956ae2215c21d01d79551ede1", "docs/compatibility-v1.md": "7e551aad37a369faff7e370728376763efb54c2c9e9cfd8556a0c19aabc3d103", "docs/contracts/handoff-v1.md": "46a953010c49214bd8f969412f26713384fbe09a0ba78ebf0fef75da40d0d889", "docs/diagnostics.md": "0949e53612eeab2cd7c7bbda498e14d71e9cb1b1189aa7722030cb8702cd8481", "docs/download-metrics.md": "ded60e7b84d14ce779f8d639516b4e390358a06ef76b677dc9c1f997fb626a9f", - "docs/handoff-runtime.md": "95d7d8af27705a6c9d66842345c5055c06383036212156cb046ea010e193cdbe", + "docs/handoff-runtime.md": "80800abed85d4af1a2e2001448b39f6cf02ae12912cde0fde90c6863124e5220", "docs/mcp.md": "a64010cdd63f2cde6a0052450bed2d05faa0f19c93c76143cb0d585d36f8ff62", "docs/multi-home-list.md": "f8d8e3adf727e4842ea7c4d45e8f6523b11d0513bf2509d4d1204645875e6489", "docs/paired-devices.md": "44c70f99d3f8f8ff9f6f86d128913ada69f4cc4e794c724cfc214f7f8ea78f20", diff --git a/docs/ja/cli-reference.md b/docs/ja/cli-reference.md index 590eff67..066e5898 100644 --- a/docs/ja/cli-reference.md +++ b/docs/ja/cli-reference.md @@ -404,6 +404,17 @@ SSH `User` 設定に誘導し、再試行されることはありません。 dry-run 中の保存済みスレッドの欠落は `2` を返します。send における終了コード `0` は、 消費または返信を確認するものではありません。 +### 時間制限付き handoff 監視(v1.3 候補) + +```sh +session-peer handoff status --correlation-id PREPARED_UUID --json +session-peer handoff watch --correlation-id PREPARED_UUID --watch-timeout 30 --interval-ms 1000 --json +``` + +ローカル所有者の POSIX 記録にある準備済み UUID を使用します。状態照会は単一スナップショットのままです。監視は 1..60 秒(既定 30)、1000..5000 ミリ秒間隔(既定 1000)で最大 61 回照会し、値は ASCII 整数のみ受け付けます。予算内に有効な公開結果を構成できる場合だけ最初のスナップショットを出力し、その後は公開メタデータ/historyPhase の変更だけを出力します。スナップショットが一つも出ない場合があります。JSON 監視出力は operation watch、event snapshot または end の共通 schemaVersion 1 NDJSON エンベロープを使い、スナップショットに従来の handoff オブジェクトを内包します。status JSON は変わりません。期限完了の終了コード 0 は監視の完了だけを表し、ACK や待機成功ではありません。SIGINT は 130、履歴・出力の失敗は 1、不正な引数は 2 です。履歴の欠落・破損や ledgerEpoch の変更で監視を終了します。 historyPhase は prepared、attempted、terminal、tombstone または quarantined です。attempted は永続的な試行フェンスを意味し、実行中を意味しません。 + +監視は読み取り専用で、intent・待機・受領確認の作成、記録の書き込み、提出や起動を行いません。ホスト・機器・Windows 経路や Codex ACK は追加しません。フレームは 16 KiB、総量は 256 KiB に制限し、終了用に 2 KiB を予約します。実ファイル記述子の標準出力には専用の分離された Python 出力補助プロセスを一つ使用します。サイズ制限付きの直列化された公開バイトだけを受け取り、共有記述子フラグを変えず、記録やネイティブデータも読みません。親は IPC を含め元の総予算を適用し、新しい記録読み取り・検証段階の前にも期限を確認します。総時間内に最終出力と終了処理用の 100 ms を確保するため、照会は早めに終わる場合があります。タイムアウト、SIGINT、エラー時には補助プロセスを終了・回収しますが、中断不能な OS 操作の厳密リアルタイム保証ではありません。部分書き込みや完了応答がない場合は終了を含む後続フレームを禁止し、利用側は切れた最後の NDJSON 行を拒否する必要があります。補助プロセスの完了は OS 書き込み完了であり、受信者の消費ではありません。スナップショットは全イベントを欠落なく伝えるストリームではなく、ローカル UTC 観測は ACK 到着順序を証明しません。プライバシー、保持する提出・ACK・待機の意味、読み取り専用コレクター照会、従来の隔離の制約は [handoff ランタイムガイド](handoff-runtime.md)を参照してください。監視はネイティブメッセージプロセスやコレクターを起動しません。この候補は公開リリースではありません。 ペイロード・権限のダイジェストは公開せず、公開 targetGeneration の既存契約は維持します。 読み取り専用記録は不変のスナップショットバイトを読み終えた後、解析・検証の前に共有ロックを解放します。 中断時の最終出力と終了処理は、残りの総予算内で最大 100 ms に制限します。 全体の成功には完全な成功終了フレームと終了コード 0 の両方が必要です。そのフレームの後に終了処理が失敗する場合があります。非ゼロの終了は全体の失敗であり、ACK を格上げしたり、独立して有効な提出事実を捨てたり、再試行を許可したりするものではありません。 + ### 更新 diff --git a/docs/ja/handoff-runtime.md b/docs/ja/handoff-runtime.md index bed1839c..5710e32a 100644 --- a/docs/ja/handoff-runtime.md +++ b/docs/ja/handoff-runtime.md @@ -50,7 +50,19 @@ session-peer handoff status --correlation-id PREPARED_UUID コレクターの時計照会も読み取り専用です。古いコレクターはこの照会を拒否する場合があるため、次の操作に `keep_waiting` が含まれないことがあります。状態照会は書き込みを伴う照会へ切り替えたり、コレクターを再起動したりしません。記録のスナップショットと任意のコレクター時計の観測は別々であり、原子的にまとめて観測した証拠ではありません。待機が history_unavailable で失敗した後にコレクター時計を再び確認できる場合、状態照会は確認済みの提出と失敗した待機を保持し、keep_waiting を案内しません。ほかの待機作成の意味は変わりません。保持用記録の最新待機の詳細は、履歴の期限切れにより確認不可と表示し、待機が要求されなかった証拠とは扱いません。 -これはローカルでの単発照会であり、継続監視やリモート監視ではありません。Windows handoff と Codex ACK は引き続き未対応です。[#296](https://github.com/abruption/session-peer/issues/296) は、今後の実行範囲を制限した watch モードのために未完了のまま維持します。 +状態照会はローカルの単発照会のままです。v1.3 候補では以下の時間制限付きローカル監視を追加します。Windows handoff と Codex ACK は引き続き未対応です。[#296](https://github.com/abruption/session-peer/issues/296) はレビュー・マージまで未完了とし、この候補は公開リリースではありません。 + +## 1 件の要求を一定時間監視する + +```sh +session-peer handoff watch --correlation-id PREPARED_UUID --watch-timeout 30 --interval-ms 1000 --json +``` + +監視は既存のローカル所有者の POSIX 記録だけを繰り返し照会します。時間制限は ASCII 整数の 1..60 秒(既定 30)、間隔は ASCII 整数の 1000..5000 ミリ秒(既定 1000)です。最大 61 回照会し、予算内に有効な公開結果を構成できる場合だけ最初のスナップショットを出力し、その後は公開メタデータまたは historyPhase の変更だけを出力します。スナップショットが一つも出ない場合があります。全イベントを欠落なく伝えるストリームではありません。確認済みの提出、ACK の根拠、最初の待機に対する ACK の遅延、最新の保存済み待機は、状態表示と同じ別々の意味を保ちます。ローカルの UTC 観測時刻は ACK 到着時刻や順序の証拠ではありません。 historyPhase は prepared、attempted、terminal、tombstone または quarantined です。attempted は永続的な試行フェンスを意味し、実行中を意味しません。 + +JSON 出力は watch 専用フィールドを持つ共通 schemaVersion 1 エンベロープの NDJSON で、operation は watch、event は snapshot または end です。スナップショットは変更のない公開 handoff オブジェクトを内包し、通常の status JSON は維持します。期限での完了は 0 を返し、監視終了だけを意味し、ACK 到着や待機成功は意味しません。SIGINT は 130、履歴・出力の失敗は 1 です。履歴の欠落・破損や ledgerEpoch の変更では、handoff や epoch を作らず監視を終了します。隔離された satisfied 待機の従来の拒否は、上記の #181 ライフサイクル検証項目のままです。 + +出力フレームは各 16 KiB、総量 256 KiB までで、終了フレームに 2 KiB を予約します。実ファイル記述子の標準出力では、専用の分離された Python 出力補助プロセスを一つ使用します。補助プロセスはサイズ制限付きの直列化された公開バイトだけを受け取り、共有記述子フラグを変えず、記録やネイティブデータも読みません。親は補助プロセス IPC を含め元の総期限を適用し、新しい記録読み取り・検証段階の前にも期限を確認します。総時間内に最終出力と終了処理用の 100 ms を確保するため、照会は早めに終わる場合があります。タイムアウト、SIGINT、エラー時には親が自分の補助プロセスを終了・回収します。中断不能な OS 操作があるため厳密リアルタイム保証ではありません。部分的な書き込みや完了応答がない場合、終了を含む後続フレームは送信しません。利用側は切れた最後の NDJSON 行を拒否する必要があります。補助プロセスの完了応答は OS 書き込み完了だけを示し、受信者の消費は証明しません。監視はネイティブ本文や会話履歴を読まず、権限やペイロード・権限のダイジェストを公開せず、公開 targetGeneration の既存契約は維持します。intent・待機・受領確認の作成、提出、起動、初期化、修復、記録の書き込みは行いません。起動済みローカルコレクターの既存の読み取り専用時計照会だけを使用でき、コレクターやネイティブメッセージプロセスは起動しません。ホスト・機器経路と Windows 監視は未対応で、Codex ACK 対応も追加しません。 読み取り専用記録は不変のスナップショットバイトを読み終えた後、解析・検証の前に共有ロックを解放します。 中断時の最終出力と終了処理は、残りの総予算内で最大 100 ms に制限します。 全体の成功には完全な成功終了フレームと終了コード 0 の両方が必要です。そのフレームの後に終了処理が失敗する場合があります。非ゼロの終了は全体の失敗であり、ACK を格上げしたり、独立して有効な提出事実を捨てたり、再試行を許可したりするものではありません。 ## 証拠・プライバシー・時間制限 diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index bc9e92e8..c06a72bd 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -403,6 +403,17 @@ Host web-01 포함한 Codex 큐 거부는 운영 오류(`1`)입니다. dry-run 중 저장된 스레드가 누락된 경우 `2`를 반환합니다. 전송 시의 종료 코드 `0`은 소비나 응답을 확인해주지 않습니다. +### 시간 제한 handoff 감시(v1.3 후보) + +```sh +session-peer handoff status --correlation-id PREPARED_UUID --json +session-peer handoff watch --correlation-id PREPARED_UUID --watch-timeout 30 --interval-ms 1000 --json +``` + +로컬 소유자의 POSIX 기록에 있는 기존 준비 UUID를 사용합니다. 상태 조회는 단일 스냅샷을 유지하며 감시는 1..60초(기본 30) 동안 1000..5000밀리초 간격(기본 1000)으로 최대 61번 조회합니다. 두 값은 ASCII 정수만 허용합니다. 감시는 예산 안에 유효한 공개 결과를 구성할 수 있을 때만 최초 스냅샷을 출력하고 이후 공개 메타데이터/historyPhase가 바뀐 경우만 출력하므로 스냅샷이 없을 수도 있습니다. JSON 감시는 operation watch와 event snapshot 또는 end인 공통 schemaVersion 1 NDJSON 봉투를 사용하고 스냅샷에 기존 handoff 객체를 포함하며 status JSON은 유지합니다. 기한 종료의 코드 0은 감시 완료만 뜻하고 ACK나 대기 성공은 아닙니다. SIGINT는 130, 기록·출력 실패는 1, 잘못된 인자는 2입니다. 기록 부재·손상이나 ledgerEpoch 변경 시 감시를 중단합니다. historyPhase는 prepared, attempted, terminal, tombstone 또는 quarantined이며 attempted는 영속적인 시도 방지 기록을 뜻하고 실행 중이라는 의미는 아닙니다. + +감시는 읽기 전용이며 의도·대기·수신 확인 생성, 기록 쓰기, 제출·깨우기를 수행하지 않습니다. 호스트·기기·Windows 경로나 Codex ACK를 추가하지 않습니다. 프레임은 16 KiB, 전체는 256 KiB로 제한하고 종료용 2 KiB를 남깁니다. 실제 파일 디스크립터 표준 출력에는 전용 격리 Python 출력 보조 프로세스 하나를 사용합니다. 크기가 제한된 직렬화된 공개 바이트만 받고 공유 디스크립터 플래그를 바꾸거나 기록·네이티브 데이터를 읽지 않습니다. 부모는 IPC까지 원래 총예산으로 제한하고 새 기록 읽기·검증 단계 전에도 기한을 확인합니다. 총 시간 안에서 최종 출력·정리에 100 ms를 남기므로 조회는 더 일찍 끝날 수 있습니다. 시간 초과·SIGINT·오류 시 보조 프로세스를 종료·회수하지만 중단할 수 없는 OS 작업의 엄격한 실시간 보장은 아닙니다. 부분 쓰기나 완료 응답 부재 시 종료를 포함한 후속 프레임을 금지하며 소비자는 잘린 마지막 NDJSON 줄을 거부해야 합니다. 보조 프로세스 완료는 OS 쓰기 완료이며 수신자 소비의 증거가 아닙니다. 스냅샷은 모든 이벤트를 빠짐없이 전달하는 스트림이 아니며 로컬 UTC 관측은 ACK 도착 순서의 증거가 아닙니다. 개인정보, 유지되는 제출·ACK·대기의 의미, 읽기 전용 수집기 조회와 기존 격리 조건은 [handoff 런타임 가이드](handoff-runtime.md)를 참고하세요. 감시는 네이티브 메시지 프로세스나 수집기를 시작하지 않습니다. 이 후보는 배포된 릴리스가 아닙니다. 페이로드·권한 다이제스트를 노출하지 않으며 공개 targetGeneration의 기존 계약은 유지합니다. 읽기 전용 기록은 불변 스냅샷 바이트를 읽은 뒤 파싱·검증 전에 공유 잠금을 해제합니다. 중단 시 최종 출력·정리는 남은 총예산 안에서 최대 100 ms로 제한합니다. 전체 성공에는 완전한 성공 종료 프레임과 종료 코드 0이 모두 필요합니다. 해당 프레임 뒤에도 정리가 실패할 수 있습니다. 0이 아닌 종료는 전체 실패이며 ACK를 상향 해석하거나 독립적으로 유효한 제출 사실을 버리거나 재시도해도 된다는 뜻은 아닙니다. + ### 업데이트 diff --git a/docs/ko/handoff-runtime.md b/docs/ko/handoff-runtime.md index 92ce6d3e..76d41d64 100644 --- a/docs/ko/handoff-runtime.md +++ b/docs/ko/handoff-runtime.md @@ -50,7 +50,19 @@ session-peer handoff status --correlation-id PREPARED_UUID 수집기의 시계 조회도 읽기 전용입니다. 이전 수집기는 이 조회를 거부할 수 있어 가능한 다음 조치에 `keep_waiting`이 없을 수 있습니다. 이때 상태 조회는 기록을 쓰는 질의로 전환하거나 수집기를 재시작하지 않습니다. 기록 스냅샷과 선택적 수집기 시계 관측은 별도이며, 원자적으로 함께 관측한 증거가 아닙니다. 대기가 history_unavailable로 실패한 뒤 수집기 시계를 다시 확인할 수 있게 된 경우, 이제 상태 조회는 확인된 제출과 실패한 대기를 보존하면서 keep_waiting을 안내하지 않습니다. 다른 대기 생성 의미는 바꾸지 않습니다. 보존 표식의 최신 대기 상세는 만료된 기록으로 인해 확인 불가로 표시하며, 대기를 요청하지 않았다는 증거로 해석하지 않습니다. -이는 로컬에서 한 번 조회하는 기능이며 지속 감시나 원격 모니터링은 아닙니다. Windows handoff와 Codex ACK는 여전히 미지원입니다. [#296](https://github.com/abruption/session-peer/issues/296)은 향후 실행 범위를 제한한 watch 모드를 위해 열린 상태로 유지합니다. +상태 조회는 로컬 단일 조회를 유지합니다. v1.3 후보에는 아래의 시간 제한 로컬 감시가 추가되며, Windows handoff와 Codex ACK는 여전히 미지원입니다. [#296](https://github.com/abruption/session-peer/issues/296)은 검토·병합 전까지 열어두며 이 후보는 배포된 릴리스가 아닙니다. + +## 요청 한 건을 정해진 시간 동안 감시 + +```sh +session-peer handoff watch --correlation-id PREPARED_UUID --watch-timeout 30 --interval-ms 1000 --json +``` + +감시는 기존 로컬 소유자의 POSIX 기록만 반복 조회합니다. 시간 제한은 ASCII 정수 1..60초(기본 30), 간격은 ASCII 정수 1000..5000밀리초(기본 1000)입니다. 최대 61번 조회하고 예산 안에 유효한 공개 결과를 구성할 수 있을 때만 최초 스냅샷을 출력하며 이후 공개 메타데이터나 historyPhase가 바뀔 때만 출력합니다. 스냅샷을 하나도 출력하지 못할 수 있으므로 모든 이벤트를 빠짐없이 전달하는 스트림은 아닙니다. 확인된 제출, ACK 근거, 첫 대기를 기준으로 한 ACK 지연, 최신 저장 대기는 상태 화면과 같은 별도의 의미를 유지합니다. 로컬 UTC 관측 시각은 ACK 도착 시각이나 순서의 증거가 아닙니다. historyPhase는 prepared, attempted, terminal, tombstone 또는 quarantined이며 attempted는 영속적인 시도 방지 기록을 뜻하고 실행 중이라는 의미는 아닙니다. + +JSON 출력은 watch 전용 필드를 포함한 공통 schemaVersion 1 봉투의 NDJSON이며 operation은 watch, event는 snapshot 또는 end입니다. 스냅샷은 바뀌지 않은 공개 handoff 객체를 포함하며 기존 status JSON은 유지합니다. 기한 종료는 0으로 반환하고 감시 종료만 뜻하며 ACK 도착이나 대기 성공을 의미하지 않습니다. SIGINT는 130, 기록·출력 실패는 1입니다. 기록 부재·손상이나 ledgerEpoch 변경 시 handoff나 epoch를 만들지 않고 감시를 종료합니다. 격리된 satisfied 대기의 기존 거부는 위에 설명한 #181 수명주기 조건으로 유지합니다. + +출력 프레임은 각각 16 KiB, 전체는 256 KiB이며 종료 프레임용 2 KiB를 남깁니다. 실제 파일 디스크립터 표준 출력은 전용 격리 Python 출력 보조 프로세스 하나를 사용하며, 이 프로세스는 크기가 제한된 직렬화된 공개 바이트만 받아 공유 디스크립터 플래그를 바꾸거나 기록·네이티브 데이터를 읽지 않습니다. 부모는 보조 프로세스 IPC까지 원래 총 기한으로 제한하고 새 기록 읽기·검증 단계 전에도 기한을 확인합니다. 총 시간 안에서 최종 출력·정리에 100 ms를 남기므로 조회는 더 일찍 끝날 수 있습니다. 시간 초과·SIGINT·오류 시 부모는 자신이 시작한 보조 프로세스를 종료하고 회수합니다. 중단할 수 없는 OS 작업 때문에 엄격한 실시간 보장은 아닙니다. 쓰기가 부분적이거나 완료 응답이 없으면 종료를 포함한 후속 프레임을 보내지 않습니다. 소비자는 잘린 마지막 NDJSON 줄을 거부해야 합니다. 보조 프로세스의 완료 응답은 OS 쓰기 완료만 뜻하며 수신자의 소비를 증명하지 않습니다. 감시는 네이티브 본문·대화 기록을 읽거나 권한이나 페이로드·권한 다이제스트를 노출하지 않으며 공개 targetGeneration의 기존 계약은 유지합니다. 의도·대기·수신 확인 생성, 제출, 깨우기, 초기화, 복구나 기록 쓰기를 수행하지 않습니다. 실행 중인 로컬 수집기의 기존 읽기 전용 시계 조회만 사용할 수 있으며 수집기나 네이티브 메시지 프로세스를 시작하지 않습니다. 호스트·기기 라우팅과 Windows 감시는 지원하지 않으며 Codex ACK 지원도 추가하지 않습니다. 읽기 전용 기록은 불변 스냅샷 바이트를 읽은 뒤 파싱·검증 전에 공유 잠금을 해제합니다. 중단 시 최종 출력·정리는 남은 총예산 안에서 최대 100 ms로 제한합니다. 전체 성공에는 완전한 성공 종료 프레임과 종료 코드 0이 모두 필요합니다. 해당 프레임 뒤에도 정리가 실패할 수 있습니다. 0이 아닌 종료는 전체 실패이며 ACK를 상향 해석하거나 독립적으로 유효한 제출 사실을 버리거나 재시도해도 된다는 뜻은 아닙니다. ## 증거·개인정보·시간 제한 diff --git a/docs/zh-CN/cli-reference.md b/docs/zh-CN/cli-reference.md index e042fcfa..cd75c74b 100644 --- a/docs/zh-CN/cli-reference.md +++ b/docs/zh-CN/cli-reference.md @@ -288,6 +288,17 @@ Host web-01 退出码:`0` 命令成功(包括列出或预检运行 dry-run),`1` 操作错误,`2` CLI 用法错误或未解析的目标被报告为 no-target,以及 `130` 中断(Ctrl-C)。Codex 队列拒绝(包括缺失 rollout)属于操作错误(`1`);预检运行期间缺失已保存的线程返回 `2`。发送操作退出码为 `0` 并不确认消息已被消费或回复。 +### 限时 handoff 观察(v1.3 候选版本) + +```sh +session-peer handoff status --correlation-id PREPARED_UUID --json +session-peer handoff watch --correlation-id PREPARED_UUID --watch-timeout 30 --interval-ms 1000 --json +``` + +使用本地所有者 POSIX 记录中已有的准备 UUID。状态查询仍为一次快照;观察运行 1..60 秒(默认 30),间隔 1000..5000 毫秒(默认 1000),仅接受 ASCII 整数,最多采样 61 次。观察仅在预算允许生成有效公开结果时输出初始快照,随后仅输出公开元数据/historyPhase 的变化;可能不输出任何快照。JSON 观察输出使用通用 schemaVersion 1 NDJSON 封装,其中 operation 为 watch,event 为 snapshot 或 end,快照嵌套原有 handoff 对象;status JSON 不变。到期完成的退出码 0 仅表示观察完成,并非 ACK 或等待成功;SIGINT 为 130,历史、输出故障为 1,无效参数为 2。历史缺失、损坏或 ledgerEpoch 改变会停止观察。 historyPhase 为 prepared、attempted、terminal、tombstone 或 quarantined;attempted 表示已持久保存的尝试屏障,不表示正在执行。 + +观察为只读操作,不创建意图、等待、收据,不写入记录、提交或唤醒。不增加主机、设备、Windows 路径或 Codex ACK。每帧上限为 16 KiB,总量为 256 KiB,并预留 2 KiB 用于结束帧。真实文件描述符标准输出使用一个专属、隔离的 Python 输出辅助进程;它仅接收有界的已序列化公开字节,不更改共享描述符标志,也不读取记录或原生数据。父进程将 IPC 纳入原始总预算,开始新的记录读取或验证阶段前也检查期限;总时间内预留 100 ms 用于最终输出和清理,因此采样可能提前结束。超时、SIGINT 或错误时终止并回收辅助进程,但不可中断的 OS 操作不具备严格实时保证。部分写入或无完成回复后禁止后续帧、结束帧;消费方必须拒绝被截断的最后一行 NDJSON。辅助进程完成仅表示 OS 写入完成,不证明接收者消费。快照并非无损事件流,本地 UTC 观察不证明 ACK 到达顺序。有关隐私、保留的提交/ACK/等待含义、只读收集器查询及现有隔离限制,参见 [handoff 运行时指南](handoff-runtime.md)。观察不启动原生消息进程或收集器。此候选版本尚未发布。 不公开载荷或权限摘要,公开 targetGeneration 保持既有约定。 只读记录在读取不可变快照字节后、解析和验证前释放共享锁。 中断后的最终输出和清理在剩余总预算内最多使用 100 ms。 整体成功需要完整的成功结束帧和退出码 0:该帧之后清理仍可能失败。非零退出表示整体失败,不表示 ACK 升级、丢弃独立有效的提交事实或允许重试。 + ### 更新 diff --git a/docs/zh-CN/handoff-runtime.md b/docs/zh-CN/handoff-runtime.md index e9af7d43..a964c861 100644 --- a/docs/zh-CN/handoff-runtime.md +++ b/docs/zh-CN/handoff-runtime.md @@ -50,7 +50,19 @@ session-peer handoff status --correlation-id PREPARED_UUID 收集器时钟查询也是只读的。旧版收集器可能拒绝该查询,因此可用的下一步操作可能不包含 `keep_waiting`。状态查询不会改用会写入记录的查询,也不会重启收集器。记录快照与可选的收集器时钟观测是分开的,并非原子性的联合观测。等待因 history_unavailable 失败后,若收集器时钟恢复可用,状态查询现在会保留已知提交事实和失败的等待,同时不提供 keep_waiting。其他等待创建语义不变。保留标记中的最近等待详情显示为因历史过期而不可用,不作为未请求等待的证据。 -这是一次本地查询,不是持续观察或远程监控。Windows handoff 和 Codex ACK 仍不支持。[#296](https://github.com/abruption/session-peer/issues/296) 保持开放,以跟踪未来限制运行范围的 watch 模式。 +状态查询仍是本地单次查询。v1.3 候选版本增加了下述限时本地观察;Windows handoff 和 Codex ACK 仍不支持。[#296](https://github.com/abruption/session-peer/issues/296) 在评审、合并前保持开放,此候选版本尚未发布。 + +## 在限定时间内观察一条请求 + +```sh +session-peer handoff watch --correlation-id PREPARED_UUID --watch-timeout 30 --interval-ms 1000 --json +``` + +观察仅轮询已有的本地所有者 POSIX 记录。超时接受 ASCII 整数 1..60 秒(默认 30),间隔接受 ASCII 整数 1000..5000 毫秒(默认 1000)。最多采样 61 次,仅在预算允许生成有效公开结果时输出初始快照,随后只输出公开元数据或 historyPhase 的变化;可能不输出任何快照。并非无损事件流。已知提交事实、ACK 依据、相对第一次等待的 ACK 迟到情况、最新保存的等待分别保持状态视图的含义。本地 UTC 观察时间不是 ACK 到达时间或顺序证据。 historyPhase 为 prepared、attempted、terminal、tombstone 或 quarantined;attempted 表示已持久保存的尝试屏障,不表示正在执行。 + +JSON 输出是采用通用 schemaVersion 1 封装并包含 watch 专用字段的 NDJSON:operation 为 watch,event 为 snapshot 或 end。快照嵌套不变的公开 handoff 对象,普通 status JSON 保持不变。到期完成返回 0,仅表示观察结束,不表示 ACK 到达或等待成功。SIGINT 返回 130,历史或输出故障返回 1。历史缺失、损坏或 ledgerEpoch 改变时停止观察,不虚构 handoff 或 epoch。对已隔离 satisfied 等待的现有拒绝仍是上述 #181 生命周期条件。 + +每个输出帧上限为 16 KiB,总输出上限为 256 KiB,其中为结束帧预留 2 KiB。真实文件描述符标准输出使用一个专属、隔离的 Python 输出辅助进程;它仅接收有界的已序列化公开字节,不更改共享描述符标志,也不读取记录或原生数据。父进程将辅助进程 IPC 纳入原始总期限,开始新的记录读取或验证阶段前也检查期限。总时间内预留 100 ms 用于最终输出和清理,因此采样可能提前结束。超时、SIGINT 或错误时,父进程终止并回收自己的辅助进程。不可中断的 OS 操作仍不具备严格实时保证。部分写入或未收到完成回复后,不再发送任何后续帧,包括结束帧;消费方必须拒绝被截断的最后一行 NDJSON。辅助进程完成回复仅证明 OS 写入完成,不证明接收者消费。观察不读取原生正文或对话记录,不公开权限或载荷、权限摘要,公开 targetGeneration 保持既有约定;不创建意图、等待或收据,不提交、唤醒、初始化、修复或写入记录。可使用已运行的本地收集器既有的只读时钟查询,但不会启动收集器或原生消息进程。不支持主机、设备路由或 Windows 观察,也不增加 Codex ACK 支持。 只读记录在读取不可变快照字节后、解析和验证前释放共享锁。 中断后的最终输出和清理在剩余总预算内最多使用 100 ms。 整体成功需要完整的成功结束帧和退出码 0:该帧之后清理仍可能失败。非零退出表示整体失败,不表示 ACK 升级、丢弃独立有效的提交事实或允许重试。 ## 证据、隐私和时间限制 diff --git a/session_peer.py b/session_peer.py index a4b6e029..0182bac7 100644 --- a/session_peer.py +++ b/session_peer.py @@ -7359,6 +7359,10 @@ def handoff_private_stat(path, directory=False): return info +class HandoffReadDeadlineExceeded(Exception): + """The caller's read budget elapsed; not evidence of corrupt history.""" + + class HandoffLedger: """Serialized, fsynced snapshots with retained effect fences, not dedup. @@ -7421,11 +7425,17 @@ def _save(self, state): @contextlib.contextmanager def transaction(self, deadline=None, *, read_only=False): """Validate one locked snapshot; read-only projections never persist retention.""" + def check_read_deadline(): + if read_only and deadline is not None and handoff_now() >= deadline: + raise HandoffReadDeadlineExceeded() + + check_read_deadline() if IS_WINDOWS or fcntl is None: raise handoff_error("handoff_platform_unsupported") handoff_private_stat(self.root, True) lock = self.root / "ledger.lock" before = handoff_private_stat(lock) + check_read_deadline() fd = os.open(str(lock), os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)) try: after = os.fstat(fd) @@ -7433,38 +7443,52 @@ def transaction(self, deadline=None, *, read_only=False): raise handoff_error("unsafe_handoff_storage") limit = deadline if deadline is not None else handoff_now() + 5 while True: + check_read_deadline() try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fd, (fcntl.LOCK_SH if read_only else fcntl.LOCK_EX) | fcntl.LOCK_NB) + check_read_deadline() break except BlockingIOError: + check_read_deadline() if handoff_now() >= limit: raise handoff_error("handoff_storage_busy") time.sleep(min(0.01, max(0, limit - handoff_now()))) path = self.root / "ledger.json" + check_read_deadline() initial = handoff_private_stat(path) if initial.st_size > HANDOFF_LEDGER_BYTES: raise handoff_error("handoff_history_unavailable") + check_read_deadline() read_fd = os.open(str(path), os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(read_fd, "rb") as stream: current = os.fstat(stream.fileno()) if (initial.st_dev, initial.st_ino) != (current.st_dev, current.st_ino): raise handoff_error("unsafe_handoff_storage") + check_read_deadline() raw = stream.read(HANDOFF_LEDGER_BYTES + 1) + if read_only: + # Parse/validation use only this immutable local byte snapshot; + # release the shared lock so writers need not wait for them. + fcntl.flock(fd, fcntl.LOCK_UN) + check_read_deadline() try: state = handoff_json(raw, HANDOFF_LEDGER_BYTES) if set(state) != {"schemaVersion", "epoch", "records"} or type(state["schemaVersion"]) is not int or state["schemaVersion"] != 1 or type(state["records"]) is not dict: raise ValueError() handoff_uuid(state["epoch"]) for key, record in state["records"].items(): + check_read_deadline() handoff_uuid(key) if type(record) is not dict or record.get("id") != key: raise ValueError() self._validate_record(record) except (CcPeerError, ValueError, KeyError, TypeError) as exc: raise handoff_error("handoff_ledger_corrupt") from exc + check_read_deadline() boot, now = handoff_boot_clock(), handoff_now_ns() if boot is not None: for record in state["records"].values(): + check_read_deadline() if (record["phase"] != "tombstone" and record["historyBoot"] == boot and now >= record["createdNs"] + 30 * 86400 * 1000000000): # Detail expires only under proven boot/monotonic @@ -7472,6 +7496,7 @@ def transaction(self, deadline=None, *, read_only=False): # survives and still counts toward the intent quota. record.update(phase="tombstone", submission="unknown", native=None, waits=[], ack=None, capability=None, pendingReceipt=None, observe=False, ackRequested=False) + check_read_deadline() yield state if not read_only: self._save(state) @@ -8613,6 +8638,343 @@ def cmd_handoff_send(args): return code +# Read-only sampled handoff projection. Included in the standalone namespace. +HANDOFF_WATCH_FRAME_BYTES = 16 * 1024 +HANDOFF_WATCH_OUTPUT_BYTES = 256 * 1024 +HANDOFF_WATCH_END_RESERVE = 2048 +HANDOFF_WATCH_FINISH_SECONDS = 0.1 +HANDOFF_WATCH_REAP_SECONDS = 0.025 +HANDOFF_WATCH_WRITER_SCRIPT = r''' +import os +import select +import signal +import sys +import threading +import time +try: + signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGINT}) + parent = int(sys.argv[1]) + cutoff = float(sys.argv[2]) + if sys.platform == "darwin": + import ctypes + class Timebase(ctypes.Structure): + _fields_ = [("numer", ctypes.c_uint32), ("denom", ctypes.c_uint32)] + library = ctypes.CDLL(None) + absolute = library.mach_absolute_time + absolute.argtypes, absolute.restype = [], ctypes.c_uint64 + timebase = library.mach_timebase_info + timebase.argtypes, timebase.restype = [ctypes.POINTER(Timebase)], ctypes.c_int + info = Timebase() + if timebase(ctypes.byref(info)) != 0 or not info.numer or not info.denom: + os._exit(1) + def now(): + return (int(absolute()) * int(info.numer) // int(info.denom)) / 1000000000 + else: + def now(): + return time.monotonic_ns() / 1000000000 + def supervise(): + try: + while True: + remaining = cutoff - now() + if os.getppid() != parent or remaining <= 0: + os._exit(1) + time.sleep(min(0.01, remaining)) + except BaseException: + os._exit(1) + if os.getppid() != parent or now() >= cutoff: + os._exit(1) + threading.Thread(target=supervise, daemon=True).start() +except BaseException: + os._exit(1) +def exact(size): + result = bytearray() + while len(result) < size: + chunk = os.read(0, size - len(result)) + if not chunk: + os._exit(0) + result.extend(chunk) + return bytes(result) +try: + while True: + size = int.from_bytes(exact(4), "big") + if not 1 <= size <= 16384: + os._exit(1) + frame = exact(size) + offset = 0 + while offset < size: + try: + count = os.write(1, frame[offset:]) + except BlockingIOError: + remaining = cutoff - now() + if remaining <= 0: + os._exit(1) + select.select([], [1], [], remaining) + continue + if count <= 0: + os._exit(1) + offset += count + os.write(0, b"1") +except BaseException: + os._exit(1) +''' + + +def handoff_watch_timeout(value): + return handoff_timeout(value) + + +def handoff_watch_interval(value): + if (type(value) is not str or re.fullmatch(r"[1-9][0-9]{3}", value, flags=re.ASCII) is None + or not 1000 <= int(value) <= 5000): + raise argparse.ArgumentTypeError("expected an ASCII integer from 1000 to 5000") + return int(value) + + +class HandoffWatchOutputError(Exception): + pass + + +@contextlib.contextmanager +def handoff_watch_defer_interrupt(): + previous_handler = signal.getsignal(signal.SIGINT) + pending = [None] + def defer(signum, frame): + if previous_handler != signal.SIG_IGN: + pending[0] = (signum, frame) + # Masking this thread alone cannot defer CPython handlers when another + # unmasked thread receives a process-directed signal. Record it as well. + signal.signal(signal.SIGINT, defer) + try: + previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGINT}) + try: + yield + finally: + signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask) + finally: + signal.signal(signal.SIGINT, previous_handler) + if pending[0] is not None: + if callable(previous_handler): + previous_handler(*pending[0]) + elif previous_handler == signal.SIG_DFL: + os.kill(os.getpid(), signal.SIGINT) + + +class HandoffWatchOutput: + """Owned writer isolates blocking stdout without changing inherited flags. + + A completion ACK proves only an OS write, never consumer processing. Unknown + completion suppresses subsequent frames. The final 100ms of the original + budget covers the end frame and child cleanup, including 25ms for reaping. + A helper watchdog also exits on parent death or the original shared-clock + deadline, including when the parent is stopped. OS process creation and + uninterruptible I/O remain scheduling limitations. + """ + def __init__(self, stream, deadline): + import io + self.stream, self.deadline = stream, deadline + self.work_deadline = deadline - HANDOFF_WATCH_FINISH_SECONDS + self.channel, self.process = None, None + self.used, self.damaged = 0, False + if isinstance(stream, io.StringIO): + return + child = None + try: + fd = stream.fileno() + self.channel, child = socket.socketpair() + # Register the handle before delivering a pending terminal SIGINT. + with handoff_watch_defer_interrupt(): + self.process = subprocess.Popen( + [sys.executable, "-I", "-S", "-c", HANDOFF_WATCH_WRITER_SCRIPT, + str(os.getpid()), repr(deadline)], + stdin=child, stdout=fd, stderr=subprocess.DEVNULL, env={}, + close_fds=True, start_new_session=True) + except Exception: + self.close() + raise HandoffWatchOutputError() + except BaseException: + self.close() + raise + finally: + if child is not None: + try: + child.close() + except BaseException: + self.close() + raise + + def close(self): + # A pending SIGINT is delivered only after kill/reap has finished. + with handoff_watch_defer_interrupt(): + return self._close() + + def _close(self): + if self.channel is not None: + channel = self.channel + self.channel = None + try: + channel.close() + except OSError: + pass + if self.process is not None: + try: + if self.process.poll() is None: + self.process.kill() + self.process.wait(timeout=max(0, self.deadline - handoff_now())) + except (OSError, subprocess.TimeoutExpired): + return False + return True + + def write(self, text, *, end=False): + raw = (text + "\n").encode("utf-8") + limit = HANDOFF_WATCH_OUTPUT_BYTES - (0 if end else HANDOFF_WATCH_END_RESERVE) + if len(raw) > HANDOFF_WATCH_FRAME_BYTES or self.used + len(raw) > limit: + return "output_limit" + if self.damaged: + return "output_unavailable" + io_deadline = self.deadline - HANDOFF_WATCH_REAP_SECONDS if end else self.work_deadline + if handoff_now() >= io_deadline: + return "output_unavailable" if end else "duration_elapsed" + try: + if self.channel is None: + self.stream.write(raw.decode("utf-8")) + else: + self.channel.settimeout(max(0, io_deadline - handoff_now())) + self.channel.sendall(len(raw).to_bytes(4, "big") + raw) + remaining = io_deadline - handoff_now() + if remaining <= 0: + raise HandoffWatchOutputError() + self.channel.settimeout(remaining) + if self.channel.recv(1) != b"1": + raise HandoffWatchOutputError() + self.used += len(raw) + return None + except Exception: + self.damaged = True + return "output_unavailable" + except BaseException: + self.damaged = True + raise + + +def handoff_watch_frame(args, payload, *, ok): + if args.json: + return json.dumps(json_result("handoff", payload, ok=ok), ensure_ascii=False, + separators=(",", ":"), allow_nan=False) + if payload["event"] == "snapshot": + return ("Watch snapshot " + str(payload["sequence"]) + " (sampled changes)\n" + + handoff_status_text(payload["handoff"], payload["historyPhase"])) + return ("Handoff watch ended: " + payload["reason"] + "; snapshots: " + + str(payload["snapshotsEmitted"]) + "; samples: " + str(payload["samples"]) + ".") + + +def cmd_handoff_watch(args): + if IS_WINDOWS or fcntl is None: + payload = {"operation": "watch", "event": "end", "reason": "unsupported", + "snapshotsEmitted": 0, "samples": 0} + print(handoff_watch_frame(args, payload, ok=False)) + return 1 + # The same absolute deadline covers setup, locking, collector IPC and output. + started = handoff_now() + duration = args.watch_timeout + interval = args.interval_ms + if type(duration) is not int or not 1 <= duration <= 60: + raise handoff_error("invalid_watch_timeout") + if type(interval) is not int or not 1000 <= interval <= 5000: + raise handoff_error("invalid_watch_interval") + total_deadline = started + duration + deadline = total_deadline - HANDOFF_WATCH_FINISH_SECONDS + correlation = handoff_uuid(args.correlation_id) + snapshots, samples = 0, 0 + writer, original_epoch, previous, context = None, None, None, None + reason = "history_unavailable" + try: + writer = HandoffWatchOutput(sys.stdout, total_deadline) + ledger = HandoffLedger() + while handoff_now() < deadline and samples < 61: + samples += 1 + epoch, record = ledger.record(correlation, deadline, read_only=True) + if original_epoch is not None and epoch != original_epoch: + reason = "history_changed" + break + original_epoch = epoch + if handoff_now() >= deadline: + raise HandoffReadDeadlineExceeded() + phase = record["phase"] + if phase not in ("prepared", "attempted", "terminal", "tombstone", "quarantined"): + raise handoff_error("invalid_handoff") + active_clock = None + if record["capability"] and not record["capability"]["revoked"] and not record["ack"]: + active_clock = handoff_channel_epoch(ledger, deadline, read_only=True) + if handoff_now() >= deadline: + raise HandoffReadDeadlineExceeded() + public = handoff_public(epoch, record, active_clock) + if handoff_now() >= deadline: + raise HandoffReadDeadlineExceeded() + projection = (phase, public) + if projection != previous: + payload = {"operation": "watch", "event": "snapshot", "sequence": snapshots + 1, + "historyPhase": phase, "handoff": public} + try: + observed = int(time.time() * 1000) + except (ValueError, OverflowError): + observed = None + if observed is not None and 0 <= observed <= 9007199254740991: + payload["observedAtUtcMs"] = observed + failure = writer.write(handoff_watch_frame(args, payload, ok=True)) + if failure: + reason = failure + break + snapshots += 1 + previous = projection + remaining = deadline - handoff_now() + if remaining <= 0: + raise HandoffReadDeadlineExceeded() + time.sleep(min(interval / 1000, remaining)) + else: + reason = "duration_elapsed" + except KeyboardInterrupt: + reason = "interrupted" + except HandoffWatchOutputError: + reason = "output_unavailable" + except HandoffReadDeadlineExceeded: + reason = "duration_elapsed" + except Exception: + reason = "history_unavailable" + # Context is a closed enum. Never emit the underlying exception or path. + exc = sys.exc_info()[1] + detail = exc.details.get("reason") if isinstance(exc, CcPeerError) else None + context = ("id_unknown" if detail == "handoff_id_unknown" else + "ledger_corrupt" if detail == "handoff_ledger_corrupt" else + "ledger_missing" if isinstance(exc, FileNotFoundError) or + isinstance(exc.__cause__, FileNotFoundError) else None) + finally: + if writer is not None: + if reason == "interrupted": + writer.deadline = min(writer.deadline, handoff_now() + HANDOFF_WATCH_FINISH_SECONDS) + try: + payload = {"operation": "watch", "event": "end", "reason": reason, + "snapshotsEmitted": snapshots, "samples": samples} + if context is not None: + payload["queryContext"] = context + failure = writer.write(handoff_watch_frame(args, payload, ok=reason == "duration_elapsed"), end=True) + if failure and reason != "interrupted": + reason = failure + except KeyboardInterrupt: + reason = "interrupted" + writer.deadline = min(writer.deadline, handoff_now() + HANDOFF_WATCH_FINISH_SECONDS) + except Exception: + reason = "output_unavailable" + finally: + try: + if not writer.close() and reason != "interrupted": + reason = "output_unavailable" + except KeyboardInterrupt: + reason = "interrupted" + except Exception: + reason = "output_unavailable" + return 0 if reason == "duration_elapsed" else 130 if reason == "interrupted" else 1 + + """Private source-streamed native snapshots, not a receipt/collector route.""" HANDOFF_SENDER_PREFLIGHT_ARG = "--_handoff-sender-preflight" @@ -9714,12 +10076,17 @@ def add_common(sub: argparse.ArgumentParser) -> None: handoff = subparsers.add_parser("handoff", help="manage private correlated intents without submitting native messages") handoff_sub = handoff.add_subparsers(dest="handoff_action", required=True) - for action in ("init", "prepare", "status", "wait", "confirm"): + for action in ("init", "prepare", "status", "watch", "wait", "confirm"): sub = handoff_sub.add_parser(action) sub.add_argument("--json", action="store_true") - sub.set_defaults(func=cmd_handoff, no_update_notice=True) - if action in ("status", "wait"): + sub.set_defaults(func=cmd_handoff_watch if action == "watch" else cmd_handoff, no_update_notice=True) + if action in ("status", "watch", "wait"): sub.add_argument("--correlation-id", required=True, type=handoff_cli_uuid) + if action == "watch": + sub.add_argument("--watch-timeout", type=handoff_watch_timeout, default=30, + metavar="SECONDS", help="finite local watch budget, 1..60 seconds (default: 30)") + sub.add_argument("--interval-ms", type=handoff_watch_interval, default=1000, + metavar="MILLISECONDS", help="sample every 1000..5000 ms (default: 1000)") if action == "wait": sub.add_argument("--wait-for", choices=("delivered", "acknowledged"), required=True) sub.add_argument("--wait-timeout", type=handoff_timeout, default=30) diff --git a/session_peer_core/cli.py b/session_peer_core/cli.py index 7b5faa92..91d53d79 100644 --- a/session_peer_core/cli.py +++ b/session_peer_core/cli.py @@ -136,12 +136,17 @@ def add_common(sub: argparse.ArgumentParser) -> None: handoff = subparsers.add_parser("handoff", help="manage private correlated intents without submitting native messages") handoff_sub = handoff.add_subparsers(dest="handoff_action", required=True) - for action in ("init", "prepare", "status", "wait", "confirm"): + for action in ("init", "prepare", "status", "watch", "wait", "confirm"): sub = handoff_sub.add_parser(action) sub.add_argument("--json", action="store_true") - sub.set_defaults(func=cmd_handoff, no_update_notice=True) - if action in ("status", "wait"): + sub.set_defaults(func=cmd_handoff_watch if action == "watch" else cmd_handoff, no_update_notice=True) + if action in ("status", "watch", "wait"): sub.add_argument("--correlation-id", required=True, type=handoff_cli_uuid) + if action == "watch": + sub.add_argument("--watch-timeout", type=handoff_watch_timeout, default=30, + metavar="SECONDS", help="finite local watch budget, 1..60 seconds (default: 30)") + sub.add_argument("--interval-ms", type=handoff_watch_interval, default=1000, + metavar="MILLISECONDS", help="sample every 1000..5000 ms (default: 1000)") if action == "wait": sub.add_argument("--wait-for", choices=("delivered", "acknowledged"), required=True) sub.add_argument("--wait-timeout", type=handoff_timeout, default=30) diff --git a/session_peer_core/handoff.py b/session_peer_core/handoff.py index 5035a00a..f478d36f 100644 --- a/session_peer_core/handoff.py +++ b/session_peer_core/handoff.py @@ -252,6 +252,10 @@ def handoff_private_stat(path, directory=False): return info +class HandoffReadDeadlineExceeded(Exception): + """The caller's read budget elapsed; not evidence of corrupt history.""" + + class HandoffLedger: """Serialized, fsynced snapshots with retained effect fences, not dedup. @@ -314,11 +318,17 @@ def _save(self, state): @contextlib.contextmanager def transaction(self, deadline=None, *, read_only=False): """Validate one locked snapshot; read-only projections never persist retention.""" + def check_read_deadline(): + if read_only and deadline is not None and handoff_now() >= deadline: + raise HandoffReadDeadlineExceeded() + + check_read_deadline() if IS_WINDOWS or fcntl is None: raise handoff_error("handoff_platform_unsupported") handoff_private_stat(self.root, True) lock = self.root / "ledger.lock" before = handoff_private_stat(lock) + check_read_deadline() fd = os.open(str(lock), os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)) try: after = os.fstat(fd) @@ -326,38 +336,52 @@ def transaction(self, deadline=None, *, read_only=False): raise handoff_error("unsafe_handoff_storage") limit = deadline if deadline is not None else handoff_now() + 5 while True: + check_read_deadline() try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fd, (fcntl.LOCK_SH if read_only else fcntl.LOCK_EX) | fcntl.LOCK_NB) + check_read_deadline() break except BlockingIOError: + check_read_deadline() if handoff_now() >= limit: raise handoff_error("handoff_storage_busy") time.sleep(min(0.01, max(0, limit - handoff_now()))) path = self.root / "ledger.json" + check_read_deadline() initial = handoff_private_stat(path) if initial.st_size > HANDOFF_LEDGER_BYTES: raise handoff_error("handoff_history_unavailable") + check_read_deadline() read_fd = os.open(str(path), os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(read_fd, "rb") as stream: current = os.fstat(stream.fileno()) if (initial.st_dev, initial.st_ino) != (current.st_dev, current.st_ino): raise handoff_error("unsafe_handoff_storage") + check_read_deadline() raw = stream.read(HANDOFF_LEDGER_BYTES + 1) + if read_only: + # Parse/validation use only this immutable local byte snapshot; + # release the shared lock so writers need not wait for them. + fcntl.flock(fd, fcntl.LOCK_UN) + check_read_deadline() try: state = handoff_json(raw, HANDOFF_LEDGER_BYTES) if set(state) != {"schemaVersion", "epoch", "records"} or type(state["schemaVersion"]) is not int or state["schemaVersion"] != 1 or type(state["records"]) is not dict: raise ValueError() handoff_uuid(state["epoch"]) for key, record in state["records"].items(): + check_read_deadline() handoff_uuid(key) if type(record) is not dict or record.get("id") != key: raise ValueError() self._validate_record(record) except (CcPeerError, ValueError, KeyError, TypeError) as exc: raise handoff_error("handoff_ledger_corrupt") from exc + check_read_deadline() boot, now = handoff_boot_clock(), handoff_now_ns() if boot is not None: for record in state["records"].values(): + check_read_deadline() if (record["phase"] != "tombstone" and record["historyBoot"] == boot and now >= record["createdNs"] + 30 * 86400 * 1000000000): # Detail expires only under proven boot/monotonic @@ -365,6 +389,7 @@ def transaction(self, deadline=None, *, read_only=False): # survives and still counts toward the intent quota. record.update(phase="tombstone", submission="unknown", native=None, waits=[], ack=None, capability=None, pendingReceipt=None, observe=False, ackRequested=False) + check_read_deadline() yield state if not read_only: self._save(state) diff --git a/session_peer_core/handoff_watch.py b/session_peer_core/handoff_watch.py new file mode 100644 index 00000000..2a256c8e --- /dev/null +++ b/session_peer_core/handoff_watch.py @@ -0,0 +1,335 @@ +# Read-only sampled handoff projection. Included in the standalone namespace. +HANDOFF_WATCH_FRAME_BYTES = 16 * 1024 +HANDOFF_WATCH_OUTPUT_BYTES = 256 * 1024 +HANDOFF_WATCH_END_RESERVE = 2048 +HANDOFF_WATCH_FINISH_SECONDS = 0.1 +HANDOFF_WATCH_REAP_SECONDS = 0.025 +HANDOFF_WATCH_WRITER_SCRIPT = r''' +import os +import select +import signal +import sys +import threading +import time +try: + signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGINT}) + parent = int(sys.argv[1]) + cutoff = float(sys.argv[2]) + if sys.platform == "darwin": + import ctypes + class Timebase(ctypes.Structure): + _fields_ = [("numer", ctypes.c_uint32), ("denom", ctypes.c_uint32)] + library = ctypes.CDLL(None) + absolute = library.mach_absolute_time + absolute.argtypes, absolute.restype = [], ctypes.c_uint64 + timebase = library.mach_timebase_info + timebase.argtypes, timebase.restype = [ctypes.POINTER(Timebase)], ctypes.c_int + info = Timebase() + if timebase(ctypes.byref(info)) != 0 or not info.numer or not info.denom: + os._exit(1) + def now(): + return (int(absolute()) * int(info.numer) // int(info.denom)) / 1000000000 + else: + def now(): + return time.monotonic_ns() / 1000000000 + def supervise(): + try: + while True: + remaining = cutoff - now() + if os.getppid() != parent or remaining <= 0: + os._exit(1) + time.sleep(min(0.01, remaining)) + except BaseException: + os._exit(1) + if os.getppid() != parent or now() >= cutoff: + os._exit(1) + threading.Thread(target=supervise, daemon=True).start() +except BaseException: + os._exit(1) +def exact(size): + result = bytearray() + while len(result) < size: + chunk = os.read(0, size - len(result)) + if not chunk: + os._exit(0) + result.extend(chunk) + return bytes(result) +try: + while True: + size = int.from_bytes(exact(4), "big") + if not 1 <= size <= 16384: + os._exit(1) + frame = exact(size) + offset = 0 + while offset < size: + try: + count = os.write(1, frame[offset:]) + except BlockingIOError: + remaining = cutoff - now() + if remaining <= 0: + os._exit(1) + select.select([], [1], [], remaining) + continue + if count <= 0: + os._exit(1) + offset += count + os.write(0, b"1") +except BaseException: + os._exit(1) +''' + + +def handoff_watch_timeout(value): + return handoff_timeout(value) + + +def handoff_watch_interval(value): + if (type(value) is not str or re.fullmatch(r"[1-9][0-9]{3}", value, flags=re.ASCII) is None + or not 1000 <= int(value) <= 5000): + raise argparse.ArgumentTypeError("expected an ASCII integer from 1000 to 5000") + return int(value) + + +class HandoffWatchOutputError(Exception): + pass + + +@contextlib.contextmanager +def handoff_watch_defer_interrupt(): + previous_handler = signal.getsignal(signal.SIGINT) + pending = [None] + def defer(signum, frame): + if previous_handler != signal.SIG_IGN: + pending[0] = (signum, frame) + # Masking this thread alone cannot defer CPython handlers when another + # unmasked thread receives a process-directed signal. Record it as well. + signal.signal(signal.SIGINT, defer) + try: + previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGINT}) + try: + yield + finally: + signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask) + finally: + signal.signal(signal.SIGINT, previous_handler) + if pending[0] is not None: + if callable(previous_handler): + previous_handler(*pending[0]) + elif previous_handler == signal.SIG_DFL: + os.kill(os.getpid(), signal.SIGINT) + + +class HandoffWatchOutput: + """Owned writer isolates blocking stdout without changing inherited flags. + + A completion ACK proves only an OS write, never consumer processing. Unknown + completion suppresses subsequent frames. The final 100ms of the original + budget covers the end frame and child cleanup, including 25ms for reaping. + A helper watchdog also exits on parent death or the original shared-clock + deadline, including when the parent is stopped. OS process creation and + uninterruptible I/O remain scheduling limitations. + """ + def __init__(self, stream, deadline): + import io + self.stream, self.deadline = stream, deadline + self.work_deadline = deadline - HANDOFF_WATCH_FINISH_SECONDS + self.channel, self.process = None, None + self.used, self.damaged = 0, False + if isinstance(stream, io.StringIO): + return + child = None + try: + fd = stream.fileno() + self.channel, child = socket.socketpair() + # Register the handle before delivering a pending terminal SIGINT. + with handoff_watch_defer_interrupt(): + self.process = subprocess.Popen( + [sys.executable, "-I", "-S", "-c", HANDOFF_WATCH_WRITER_SCRIPT, + str(os.getpid()), repr(deadline)], + stdin=child, stdout=fd, stderr=subprocess.DEVNULL, env={}, + close_fds=True, start_new_session=True) + except Exception: + self.close() + raise HandoffWatchOutputError() + except BaseException: + self.close() + raise + finally: + if child is not None: + try: + child.close() + except BaseException: + self.close() + raise + + def close(self): + # A pending SIGINT is delivered only after kill/reap has finished. + with handoff_watch_defer_interrupt(): + return self._close() + + def _close(self): + if self.channel is not None: + channel = self.channel + self.channel = None + try: + channel.close() + except OSError: + pass + if self.process is not None: + try: + if self.process.poll() is None: + self.process.kill() + self.process.wait(timeout=max(0, self.deadline - handoff_now())) + except (OSError, subprocess.TimeoutExpired): + return False + return True + + def write(self, text, *, end=False): + raw = (text + "\n").encode("utf-8") + limit = HANDOFF_WATCH_OUTPUT_BYTES - (0 if end else HANDOFF_WATCH_END_RESERVE) + if len(raw) > HANDOFF_WATCH_FRAME_BYTES or self.used + len(raw) > limit: + return "output_limit" + if self.damaged: + return "output_unavailable" + io_deadline = self.deadline - HANDOFF_WATCH_REAP_SECONDS if end else self.work_deadline + if handoff_now() >= io_deadline: + return "output_unavailable" if end else "duration_elapsed" + try: + if self.channel is None: + self.stream.write(raw.decode("utf-8")) + else: + self.channel.settimeout(max(0, io_deadline - handoff_now())) + self.channel.sendall(len(raw).to_bytes(4, "big") + raw) + remaining = io_deadline - handoff_now() + if remaining <= 0: + raise HandoffWatchOutputError() + self.channel.settimeout(remaining) + if self.channel.recv(1) != b"1": + raise HandoffWatchOutputError() + self.used += len(raw) + return None + except Exception: + self.damaged = True + return "output_unavailable" + except BaseException: + self.damaged = True + raise + + +def handoff_watch_frame(args, payload, *, ok): + if args.json: + return json.dumps(json_result("handoff", payload, ok=ok), ensure_ascii=False, + separators=(",", ":"), allow_nan=False) + if payload["event"] == "snapshot": + return ("Watch snapshot " + str(payload["sequence"]) + " (sampled changes)\n" + + handoff_status_text(payload["handoff"], payload["historyPhase"])) + return ("Handoff watch ended: " + payload["reason"] + "; snapshots: " + + str(payload["snapshotsEmitted"]) + "; samples: " + str(payload["samples"]) + ".") + + +def cmd_handoff_watch(args): + if IS_WINDOWS or fcntl is None: + payload = {"operation": "watch", "event": "end", "reason": "unsupported", + "snapshotsEmitted": 0, "samples": 0} + print(handoff_watch_frame(args, payload, ok=False)) + return 1 + # The same absolute deadline covers setup, locking, collector IPC and output. + started = handoff_now() + duration = args.watch_timeout + interval = args.interval_ms + if type(duration) is not int or not 1 <= duration <= 60: + raise handoff_error("invalid_watch_timeout") + if type(interval) is not int or not 1000 <= interval <= 5000: + raise handoff_error("invalid_watch_interval") + total_deadline = started + duration + deadline = total_deadline - HANDOFF_WATCH_FINISH_SECONDS + correlation = handoff_uuid(args.correlation_id) + snapshots, samples = 0, 0 + writer, original_epoch, previous, context = None, None, None, None + reason = "history_unavailable" + try: + writer = HandoffWatchOutput(sys.stdout, total_deadline) + ledger = HandoffLedger() + while handoff_now() < deadline and samples < 61: + samples += 1 + epoch, record = ledger.record(correlation, deadline, read_only=True) + if original_epoch is not None and epoch != original_epoch: + reason = "history_changed" + break + original_epoch = epoch + if handoff_now() >= deadline: + raise HandoffReadDeadlineExceeded() + phase = record["phase"] + if phase not in ("prepared", "attempted", "terminal", "tombstone", "quarantined"): + raise handoff_error("invalid_handoff") + active_clock = None + if record["capability"] and not record["capability"]["revoked"] and not record["ack"]: + active_clock = handoff_channel_epoch(ledger, deadline, read_only=True) + if handoff_now() >= deadline: + raise HandoffReadDeadlineExceeded() + public = handoff_public(epoch, record, active_clock) + if handoff_now() >= deadline: + raise HandoffReadDeadlineExceeded() + projection = (phase, public) + if projection != previous: + payload = {"operation": "watch", "event": "snapshot", "sequence": snapshots + 1, + "historyPhase": phase, "handoff": public} + try: + observed = int(time.time() * 1000) + except (ValueError, OverflowError): + observed = None + if observed is not None and 0 <= observed <= 9007199254740991: + payload["observedAtUtcMs"] = observed + failure = writer.write(handoff_watch_frame(args, payload, ok=True)) + if failure: + reason = failure + break + snapshots += 1 + previous = projection + remaining = deadline - handoff_now() + if remaining <= 0: + raise HandoffReadDeadlineExceeded() + time.sleep(min(interval / 1000, remaining)) + else: + reason = "duration_elapsed" + except KeyboardInterrupt: + reason = "interrupted" + except HandoffWatchOutputError: + reason = "output_unavailable" + except HandoffReadDeadlineExceeded: + reason = "duration_elapsed" + except Exception: + reason = "history_unavailable" + # Context is a closed enum. Never emit the underlying exception or path. + exc = sys.exc_info()[1] + detail = exc.details.get("reason") if isinstance(exc, CcPeerError) else None + context = ("id_unknown" if detail == "handoff_id_unknown" else + "ledger_corrupt" if detail == "handoff_ledger_corrupt" else + "ledger_missing" if isinstance(exc, FileNotFoundError) or + isinstance(exc.__cause__, FileNotFoundError) else None) + finally: + if writer is not None: + if reason == "interrupted": + writer.deadline = min(writer.deadline, handoff_now() + HANDOFF_WATCH_FINISH_SECONDS) + try: + payload = {"operation": "watch", "event": "end", "reason": reason, + "snapshotsEmitted": snapshots, "samples": samples} + if context is not None: + payload["queryContext"] = context + failure = writer.write(handoff_watch_frame(args, payload, ok=reason == "duration_elapsed"), end=True) + if failure and reason != "interrupted": + reason = failure + except KeyboardInterrupt: + reason = "interrupted" + writer.deadline = min(writer.deadline, handoff_now() + HANDOFF_WATCH_FINISH_SECONDS) + except Exception: + reason = "output_unavailable" + finally: + try: + if not writer.close() and reason != "interrupted": + reason = "output_unavailable" + except KeyboardInterrupt: + reason = "interrupted" + except Exception: + reason = "output_unavailable" + return 0 if reason == "duration_elapsed" else 130 if reason == "interrupted" else 1 diff --git a/tests/core/test_handoff_watch.py b/tests/core/test_handoff_watch.py new file mode 100644 index 00000000..9c2638fd --- /dev/null +++ b/tests/core/test_handoff_watch.py @@ -0,0 +1,657 @@ +"""Bounded, read-only sampled history; no model or native session is used.""" +import argparse +import contextlib +import copy +import io +import json +import os +from pathlib import Path +import signal +import select +import subprocess +import tempfile +import threading +import time +import unittest +from unittest import mock + +import session_peer as peer + + +class Clock: + def __init__(self): + self.now = 0.0 + + def sleep(self, seconds): + self.now += seconds + + +@unittest.skipIf(peer.IS_WINDOWS or peer.fcntl is None, "POSIX ledger and pipes") +class HandoffWatch(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="codex-watch-", dir=os.environ.get("SESSION_PEER_TEST_TMP")) + self.addCleanup(temporary.cleanup) + self.ledger = peer.HandoffLedger(Path(temporary.name) / "ledger") + self.epoch = self.ledger.initialize() + binding = {"agent": "claude", "destination": ["local"], "target": str(os.getpid()), + "home": None, "payloadDigest": "a" * 64} + self.epoch, self.record = self.ledger.prepare(binding, "tg1:" + "a" * 64) + self.args = argparse.Namespace(correlation_id=self.record["id"], watch_timeout=3, + interval_ms=1000, json=True, no_update_notice=True) + self.clock = Clock() + + def invoke(self, lookup=None, stream=None, *, real_clock=False): + output = stream if stream is not None else io.StringIO() + popen = peer.subprocess.Popen + self.output_processes = [] + def output_helper(command, **kwargs): + self.assertIsNotNone(stream, "unexpected process during captured watch") + self.assertEqual(command[:5], [peer.sys.executable, "-I", "-S", "-c", peer.HANDOFF_WATCH_WRITER_SCRIPT]) + self.assertEqual(command[5], str(os.getpid())) + self.assertEqual(len(command), 7) + self.assertGreater(float(command[6]), peer.handoff_now()) + self.assertEqual(kwargs["stdout"], stream.fileno()) + self.assertEqual(kwargs["env"], {}) + self.assertTrue(kwargs["close_fds"]) + self.assertTrue(kwargs["start_new_session"]) + process = popen(command, **kwargs) + self.output_processes.append(process) + return process + with contextlib.ExitStack() as stack: + if not real_clock: + stack.enter_context(mock.patch.object(peer, "handoff_now", side_effect=lambda: self.clock.now)) + stack.enter_context(mock.patch.object(peer.time, "sleep", side_effect=self.clock.sleep)) + stack.enter_context(mock.patch.object(peer, "handoff_root", return_value=self.ledger.root)) + if lookup is not None: + stack.enter_context(mock.patch.object(peer.HandoffLedger, "record", side_effect=lookup)) + for owner, name in ((peer.HandoffLedger, "_save"), (peer, "handoff_wait"), + (peer, "post_to_socket"), (peer, "handoff_codex_submit")): + stack.enter_context(mock.patch.object(owner, name, side_effect=AssertionError("watch performed an effect"))) + stack.enter_context(mock.patch.object(peer.subprocess, "Popen", side_effect=output_helper)) + stack.enter_context(contextlib.redirect_stdout(output)) + code = peer.cmd_handoff_watch(self.args) + if stream is not None: + self.assertEqual(len(self.output_processes), 1, "expected only the owned output helper") + return code, [json.loads(line) for line in output.getvalue().splitlines()] if stream is None else None + + def snapshot(self): + return {path.name: (path.read_bytes(), path.stat().st_ino, path.stat().st_mtime_ns) + for path in self.ledger.root.iterdir() if path.is_file()} + + def test_ascii_parsers(self): + for value in ("1", "30", "60"): + self.assertEqual(peer.handoff_watch_timeout(value), int(value)) + for value in ("1000", "3000", "5000"): + self.assertEqual(peer.handoff_watch_interval(value), int(value)) + for parser, values in ((peer.handoff_watch_timeout, ("0", "61", "01", "1", "1.0")), + (peer.handoff_watch_interval, ("999", "5001", "01000", "1000", "+1000"))): + for value in values: + with self.assertRaises(argparse.ArgumentTypeError): + parser(value) + + def test_real_ledger_is_unchanged_and_unchanged_samples_are_suppressed(self): + before = self.snapshot() + code, frames = self.invoke() + self.assertEqual(code, 0) + self.assertEqual([row["event"] for row in frames], ["snapshot", "end"]) + self.assertEqual(frames[-1]["samples"], 3) + self.assertEqual(frames[-1]["snapshotsEmitted"], 1) + self.assertTrue(before == self.snapshot(), "watch mutated ledger bytes or metadata") + self.assertEqual(set(frames[0]) - {"observedAtUtcMs"}, + {"schemaVersion", "ok", "host", "command", "operation", "event", "sequence", "historyPhase", "handoff"}) + + def test_phase_changes_emit_and_epoch_change_stops_before_new_facts(self): + changed = copy.deepcopy(self.record) + changed.update(phase="attempted", submission="unknown") + rows = iter([(self.epoch, self.record), (self.epoch, changed), + (str(peer.uuid.uuid4()), self.record)]) + code, frames = self.invoke(lambda *a, **kw: next(rows)) + self.assertEqual(code, 1) + self.assertEqual([row.get("sequence") for row in frames[:-1]], [1, 2]) + self.assertEqual(frames[-1]["reason"], "history_changed") + self.assertEqual(frames[-1]["samples"], 3) + + def test_missing_history_is_closed_error_without_fabricated_handoff(self): + code, frames = self.invoke(lambda *a, **kw: (_ for _ in ()).throw(peer.handoff_error("handoff_id_unknown"))) + self.assertEqual(code, 1) + self.assertEqual(len(frames), 1) + self.assertEqual(frames[0]["queryContext"], "id_unknown") + self.assertNotIn("handoff", frames[0]) + self.assertNotIn("ledgerEpoch", frames[0]) + + def test_busy_and_unsafe_history_do_not_claim_it_is_missing(self): + for reason in ("handoff_storage_busy", "unsafe_handoff_storage", "handoff_history_unavailable"): + self.clock.now = 0 + def lookup(*args, **kwargs): + raise peer.handoff_error(reason) + code, frames = self.invoke(lookup) + self.assertEqual(code, 1) + self.assertEqual(frames[0]["reason"], "history_unavailable") + self.assertNotIn("queryContext", frames[0]) + + def test_missing_files_have_positive_missing_context(self): + with mock.patch.object(peer, "handoff_private_stat", side_effect=FileNotFoundError()): + code, frames = self.invoke() + self.assertEqual(code, 1) + self.assertEqual(frames[0]["queryContext"], "ledger_missing") + + def test_lock_sleep_exhaustion_prevents_second_attempt_or_snapshot_read(self): + attempts = [] + def lock(*args): + attempts.append(1) + self.clock.now = self.args.watch_timeout - peer.HANDOFF_WATCH_FINISH_SECONDS - 0.005 + raise BlockingIOError() + with mock.patch.object(peer.fcntl, "flock", side_effect=lock), \ + mock.patch.object(peer.os, "fdopen", side_effect=AssertionError("read after deadline")), \ + mock.patch.object(peer.HandoffLedger, "_validate_record", side_effect=AssertionError("validation after deadline")): + code, frames = self.invoke() + self.assertEqual(code, 0) + self.assertEqual(len(attempts), 1) + self.assertEqual(frames[0]["reason"], "duration_elapsed") + self.assertNotIn("queryContext", frames[0]) + + def test_lock_acquisition_at_deadline_does_not_start_snapshot_read(self): + def lock(*args): + self.clock.now = self.args.watch_timeout - peer.HANDOFF_WATCH_FINISH_SECONDS + with mock.patch.object(peer.fcntl, "flock", side_effect=lock), \ + mock.patch.object(peer.os, "fdopen", side_effect=AssertionError("read after deadline")), \ + mock.patch.object(peer.HandoffLedger, "_validate_record", side_effect=AssertionError("validation after deadline")): + code, frames = self.invoke() + self.assertEqual(code, 0) + self.assertEqual(frames[0]["reason"], "duration_elapsed") + self.assertNotIn("queryContext", frames[0]) + + def test_parse_consuming_budget_does_not_begin_record_validation(self): + original = peer.handoff_json + def parse(*args, **kwargs): + result = original(*args, **kwargs) + self.clock.now = self.args.watch_timeout - peer.HANDOFF_WATCH_FINISH_SECONDS + return result + with mock.patch.object(peer, "handoff_json", side_effect=parse), \ + mock.patch.object(peer.HandoffLedger, "_validate_record", side_effect=AssertionError("validation after deadline")): + code, frames = self.invoke() + self.assertEqual(code, 0) + self.assertEqual(frames[0]["reason"], "duration_elapsed") + self.assertNotIn("queryContext", frames[0]) + + def test_initial_lookup_and_clock_share_original_deadline(self): + record = copy.deepcopy(self.record) + record["capability"] = {"revoked": False} + calls = [] + def lookup(*args, **kwargs): + calls.append((args, kwargs)) + self.clock.now += 0.25 + return self.epoch, record + def clock(*args, **kwargs): + self.assertEqual(args[1], 2.9) + self.assertEqual(kwargs, {"read_only": True}) + self.clock.now = 2.9 + with mock.patch.object(peer, "handoff_channel_epoch", side_effect=clock): + code, frames = self.invoke(lookup) + self.assertEqual(code, 0) + self.assertEqual(calls[0][0][1], 2.9) + self.assertEqual(calls[0][1], {"read_only": True}) + self.assertEqual(frames[-1]["snapshotsEmitted"], 0) + + def test_ack_does_not_end_watch_and_revoked_capability_never_queries_clock(self): + record = copy.deepcopy(self.record) + record.update(phase="terminal", submission="submitted", ackRequested=True, + ack={"status": "acknowledged", "assurance": "operator_confirmed", "receivedAtUtcMs": 2, "late": False}) + for ack in (record["ack"], None): + self.clock.now = 0 + record["ack"] = ack + record["capability"] = {"revoked": True} + with mock.patch.object(peer, "handoff_channel_epoch", side_effect=AssertionError("unexpected clock query")): + code, frames = self.invoke(lambda *a, **kw: (self.epoch, record)) + self.assertEqual(code, 0) + self.assertEqual(frames[-1]["samples"], 3) + + def test_output_caps_reserve_end_and_fail_without_private_error(self): + with mock.patch.object(peer, "HANDOFF_WATCH_OUTPUT_BYTES", 2100): + code, frames = self.invoke() + self.assertEqual(code, 1) + self.assertEqual(len(frames), 1) + self.assertEqual(frames[0]["reason"], "output_limit") + + def test_sixty_seconds_never_exceeds_sixty_one_samples(self): + self.args.watch_timeout = 60 + code, frames = self.invoke() + self.assertEqual(code, 0) + self.assertLessEqual(frames[-1]["samples"], 61) + self.assertEqual(self.clock.now, 59.9) + + def test_retention_is_only_a_memory_projection(self): + boot = "b" * 64 + with self.ledger.transaction() as state: + state["records"][self.record["id"]].update(historyBoot=boot, createdNs=1) + before = self.snapshot() + with mock.patch.object(peer, "handoff_boot_clock", return_value=boot), \ + mock.patch.object(peer, "handoff_now_ns", return_value=1 + 30 * 86400 * 1000000000): + code, frames = self.invoke() + self.assertEqual(code, 0) + self.assertEqual(frames[0]["historyPhase"], "tombstone") + self.assertTrue(before == self.snapshot(), "watch persisted retention") + + def test_quarantined_satisfied_wait_stays_failure_without_repair(self): + with self.ledger.transaction() as state: + record = state["records"][self.record["id"]] + record.update(phase="terminal", submission="submitted", ackRequested=True, + native={"ok": True, "target": {"pid": os.getpid(), "name": "fixture"}, "chars": 4, "dryRun": False}, + ack={"status": "acknowledged", "assurance": "operator_confirmed", "receivedAtUtcMs": 2, + "late": False, "receiptId": None, "classifiedClockEpoch": None}) + record["waits"] = [{"for": "acknowledged", "status": "satisfied", "operationId": str(peer.uuid.uuid4()), + "deadlineAtUtcMs": 1, "deadlineNs": 1000000, "clockEpoch": None}] + self.ledger.quarantine_restored() + before = self.snapshot() + code, frames = self.invoke() + self.assertEqual(code, 1) + self.assertEqual(len(frames), 1) + self.assertEqual(frames[0]["reason"], "history_unavailable") + self.assertNotIn("queryContext", frames[0]) + self.assertTrue(before == self.snapshot(), "watch repaired quarantined history") + + def test_real_pipe_never_changes_flags_and_reaps_on_interrupt_or_broken_pipe(self): + for interrupt in (False, True): + read_fd, write_fd = os.pipe() + stream = os.fdopen(write_fd, "w") + # Darwin may expose an internal "has been written" bit after the + # first write. Establish it before comparing restorable flags. + os.write(write_fd, b"x") + os.read(read_fd, 1) + original = peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL) + try: + if not interrupt: + os.close(read_fd) + read_fd = None + code, _ = self.invoke(stream=stream, real_clock=True) + self.assertEqual(code, 1) + else: + def lookup(*args, **kwargs): + raise KeyboardInterrupt + code, _ = self.invoke(lookup, stream, real_clock=True) + self.assertEqual(code, 130) + data = os.read(read_fd, 4096) + self.assertEqual(json.loads(data)["reason"], "interrupted") + self.assertEqual(peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL), original) + self.assertTrue(all(process.poll() is not None for process in self.output_processes)) + finally: + stream.close() + if read_fd is not None: + os.close(read_fd) + + def test_full_real_pipe_is_bounded_without_inherited_flag_mutation(self): + read_fd, write_fd = os.pipe() + stream = os.fdopen(write_fd, "w") + os.write(write_fd, b"x") + os.read(read_fd, 1) + original = peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL) + try: + peer.fcntl.fcntl(write_fd, peer.fcntl.F_SETFL, original | os.O_NONBLOCK) + try: + while True: + os.write(write_fd, b"x" * 4096) + except BlockingIOError: + pass + peer.fcntl.fcntl(write_fd, peer.fcntl.F_SETFL, original) + self.args.watch_timeout = 1 + started = time.monotonic() + with mock.patch.object(peer.fcntl, "fcntl", side_effect=AssertionError("inherited flag mutation")): + code, _ = self.invoke(stream=stream, real_clock=True) + self.assertEqual(code, 1) + self.assertLess(time.monotonic() - started, 1.5) + self.assertEqual(peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL), original) + self.assertTrue(all(process.poll() is not None for process in self.output_processes)) + finally: + stream.close() + os.close(read_fd) + + def test_unknown_frame_completion_suppresses_later_frames_and_reaps(self): + read_fd, write_fd = os.pipe() + stream = os.fdopen(write_fd, "w") + writer = None + try: + flags = peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL) + peer.fcntl.fcntl(write_fd, peer.fcntl.F_SETFL, flags | os.O_NONBLOCK) + try: + while True: + os.write(write_fd, b"x" * 4096) + except BlockingIOError: + pass + peer.fcntl.fcntl(write_fd, peer.fcntl.F_SETFL, flags) + os.read(read_fd, 4096) + writer = peer.HandoffWatchOutput(stream, peer.handoff_now() + 0.4) + self.assertEqual(writer.write("s" * 15000), "output_unavailable") + self.assertTrue(writer.damaged) + self.assertEqual(writer.write("end", end=True), "output_unavailable") + self.assertTrue(writer.close()) + self.assertIsNotNone(writer.process.poll()) + finally: + if writer is not None: + writer.close() + stream.close() + os.close(read_fd) + + def test_sigint_during_blocked_output_kills_and_reaps_owned_helper(self): + read_fd, write_fd = os.pipe() + stream = os.fdopen(write_fd, "w") + timer = None + try: + flags = peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL) + peer.fcntl.fcntl(write_fd, peer.fcntl.F_SETFL, flags | os.O_NONBLOCK) + try: + while True: + os.write(write_fd, b"x" * 4096) + except BlockingIOError: + pass + peer.fcntl.fcntl(write_fd, peer.fcntl.F_SETFL, flags) + before = peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL) + timer = threading.Timer(0.1, lambda: os.kill(os.getpid(), signal.SIGINT)) + timer.start() + with mock.patch.object(peer.fcntl, "fcntl", side_effect=AssertionError("inherited flag mutation")): + code, _ = self.invoke(stream=stream, real_clock=True) + self.assertEqual(code, 130) + self.assertTrue(all(process.poll() is not None for process in self.output_processes)) + self.assertEqual(peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL), before) + finally: + if timer is not None: + timer.cancel() + timer.join() + stream.close() + os.close(read_fd) + + def test_tty_output_completes_without_changing_inherited_flags(self): + master, slave = os.openpty() + stream = os.fdopen(slave, "w") + try: + os.write(slave, b"x") + os.read(master, 1) + before = peer.fcntl.fcntl(slave, peer.fcntl.F_GETFL) + self.args.watch_timeout = 1 + with mock.patch.object(peer.fcntl, "fcntl", side_effect=AssertionError("inherited flag mutation")): + code, _ = self.invoke(stream=stream, real_clock=True) + self.assertEqual(code, 0) + rows = [json.loads(line) for line in os.read(master, 8192).decode().splitlines()] + self.assertEqual([row["event"] for row in rows], ["snapshot", "end"]) + self.assertEqual(peer.fcntl.fcntl(slave, peer.fcntl.F_GETFL), before) + self.assertTrue(all(process.poll() is not None for process in self.output_processes)) + finally: + stream.close() + os.close(master) + + def test_control_socket_close_error_does_not_skip_helper_reaping(self): + read_fd, write_fd = os.pipe() + stream = os.fdopen(write_fd, "w") + writer = None + try: + writer = peer.HandoffWatchOutput(stream, peer.handoff_now() + 1) + channel = writer.channel + def close_and_fail(): + channel.close() + raise OSError() + writer.channel = mock.Mock(wraps=channel) + writer.channel.close.side_effect = close_and_fail + self.assertTrue(writer.close()) + self.assertIsNotNone(writer.process.poll()) + finally: + if writer is not None: + writer.close() + stream.close() + os.close(read_fd) + + def test_helper_exits_if_expected_parent_already_died_before_start(self): + process = subprocess.Popen( + [peer.sys.executable, "-I", "-S", "-c", peer.HANDOFF_WATCH_WRITER_SCRIPT, + str(os.getpid() + 1000000), repr(peer.handoff_now() + 1)], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + env={}, close_fds=True, start_new_session=True) + try: + self.assertEqual(process.wait(timeout=2), 1) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=1) + process.stdin.close() + process.stdout.close() + + def test_blocked_helper_exits_after_parent_death_or_stop(self): + # The harness loads the source module so this also tests an in-memory + # generated build without overwriting the checked-in standalone file. + script = ''' +import sys +from pathlib import Path +import session_peer as peer +exec(Path("session_peer_core/handoff_watch.py").read_text(), peer.__dict__) +writer = peer.HandoffWatchOutput(sys.stdout, peer.handoff_now() + 0.8) +print(writer.process.pid, file=sys.stderr, flush=True) +writer.write("blocked output") +writer.close() +''' + for stop in (False, True): + read_fd, write_fd = os.pipe() + parent = None + try: + flags = peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL) + peer.fcntl.fcntl(write_fd, peer.fcntl.F_SETFL, flags | os.O_NONBLOCK) + try: + while True: + os.write(write_fd, b"x" * 4096) + except BlockingIOError: + pass + peer.fcntl.fcntl(write_fd, peer.fcntl.F_SETFL, flags) + parent = subprocess.Popen([peer.sys.executable, "-c", script], + stdout=write_fd, stderr=subprocess.PIPE, + close_fds=True, start_new_session=True) + self.assertTrue(select.select([parent.stderr], [], [], 2)[0]) + worker_pid = int(parent.stderr.readline()) + time.sleep(0.05) + os.kill(parent.pid, signal.SIGSTOP if stop else signal.SIGKILL) + if not stop: + parent.wait(timeout=1) + limit = time.monotonic() + 2 + while True: + result = subprocess.run(["ps", "-o", "stat=", "-p", str(worker_pid)], + capture_output=True, text=True, timeout=1) + state = result.stdout.strip() + if not state or state.startswith("Z"): + break + if time.monotonic() >= limit: + self.fail("output helper survived its owner and original budget") + time.sleep(0.02) + finally: + if parent is not None: + if parent.poll() is None: + parent.kill() + parent.wait(timeout=1) + parent.stderr.close() + os.close(read_fd) + os.close(write_fd) + + def test_unexpected_exceptions_never_emit_success_or_private_diagnostics(self): + for kind in (RuntimeError, MemoryError): + self.clock.now = 0 + def lookup(*args, **kwargs): + raise kind("private diagnostic") + code, frames = self.invoke(lookup) + self.assertEqual(code, 1) + self.assertFalse(frames[-1]["ok"]) + self.assertEqual(frames[-1]["reason"], "history_unavailable") + self.assertNotIn("queryContext", frames[-1]) + self.assertNotIn("private diagnostic", json.dumps(frames)) + + def signal_from_unmasked_thread(self): + def interrupt(): + signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGINT}) + for unused in range(3): + time.sleep(0.005) + os.kill(os.getpid(), signal.SIGINT) + sender = threading.Thread(target=interrupt) + sender.start() + time.sleep(0.04) + sender.join() + + def test_sigint_between_spawn_and_handle_registration_is_deferred_and_reaped(self): + read_fd, write_fd = os.pipe() + stream = os.fdopen(write_fd, "w") + processes = [] + popen = peer.subprocess.Popen + previous_handler = signal.getsignal(signal.SIGINT) + previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, set()) + def spawn(*args, **kwargs): + process = popen(*args, **kwargs) + processes.append(process) + self.signal_from_unmasked_thread() + return process + try: + with mock.patch.object(peer.subprocess, "Popen", side_effect=spawn): + code, unused = self.invoke(stream=stream, real_clock=True) + self.assertEqual(code, 130) + self.assertEqual(len(processes), 1) + self.assertIsNotNone(processes[0].poll()) + self.assertEqual(signal.getsignal(signal.SIGINT), previous_handler) + self.assertEqual(signal.pthread_sigmask(signal.SIG_BLOCK, set()), previous_mask) + finally: + stream.close() + os.close(read_fd) + + def test_scoped_interrupt_deferral_preserves_custom_and_ignored_handlers(self): + previous = signal.getsignal(signal.SIGINT) + calls = [] + def custom(signum, frame): + calls.append(signum) + try: + signal.signal(signal.SIGINT, custom) + with peer.handoff_watch_defer_interrupt(): + self.signal_from_unmasked_thread() + self.assertEqual(calls, []) + self.assertEqual(calls, [signal.SIGINT]) + self.assertIs(signal.getsignal(signal.SIGINT), custom) + signal.signal(signal.SIGINT, signal.SIG_IGN) + with peer.handoff_watch_defer_interrupt(): + self.signal_from_unmasked_thread() + self.assertEqual(signal.getsignal(signal.SIGINT), signal.SIG_IGN) + finally: + signal.signal(signal.SIGINT, previous) + + def test_repeated_sigint_during_final_wait_is_deferred_until_reaping(self): + read_fd, write_fd = os.pipe() + stream = os.fdopen(write_fd, "w") + popen = peer.subprocess.Popen + def spawn(*args, **kwargs): + process = popen(*args, **kwargs) + wait = process.wait + def interrupted_wait(*values, **options): + self.signal_from_unmasked_thread() + return wait(*values, **options) + process.wait = interrupted_wait + return process + try: + def unavailable(*args, **kwargs): + raise peer.handoff_error("handoff_id_unknown") + with mock.patch.object(peer.subprocess, "Popen", side_effect=spawn): + code, unused = self.invoke(unavailable, stream, real_clock=True) + self.assertEqual(code, 130) + self.assertTrue(all(process.poll() is not None for process in self.output_processes)) + finally: + stream.close() + os.close(read_fd) + + def test_snapshot_stat_exhaustion_does_not_open_snapshot(self): + private_stat = peer.handoff_private_stat + opened = [] + original_open = peer.os.open + def stat_then_expire(path, *args): + result = private_stat(path, *args) + if path.name == "ledger.json": + self.clock.now = self.args.watch_timeout - peer.HANDOFF_WATCH_FINISH_SECONDS + return result + def record_open(path, *args, **kwargs): + opened.append(Path(path).name) + return original_open(path, *args, **kwargs) + with mock.patch.object(peer, "handoff_private_stat", side_effect=stat_then_expire), \ + mock.patch.object(peer.os, "open", side_effect=record_open): + code, frames = self.invoke() + self.assertEqual(code, 0) + self.assertNotIn("ledger.json", opened) + self.assertEqual(frames[-1]["reason"], "duration_elapsed") + + def test_readonly_validation_runs_after_snapshot_lock_is_released(self): + validate = peer.HandoffLedger._validate_record + checked = [] + def unlocked_validation(record): + fd = os.open(str(self.ledger.root / "ledger.lock"), os.O_RDWR) + try: + peer.fcntl.flock(fd, peer.fcntl.LOCK_EX | peer.fcntl.LOCK_NB) + checked.append(True) + finally: + os.close(fd) + return validate(record) + with mock.patch.object(peer.HandoffLedger, "_validate_record", side_effect=unlocked_validation): + code, frames = self.invoke() + self.assertEqual(code, 0) + self.assertEqual(len(checked), 3) + + def test_inherited_nonblocking_stdout_waits_for_drain_and_succeeds(self): + read_fd, write_fd = os.pipe() + stream = os.fdopen(write_fd, "w") + drainer = None + try: + flags = peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL) + peer.fcntl.fcntl(write_fd, peer.fcntl.F_SETFL, flags | os.O_NONBLOCK) + try: + while True: + os.write(write_fd, b"x" * 4096) + except BlockingIOError: + pass + before = peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL) + drainer = threading.Timer(0.1, lambda: os.read(read_fd, 1024 * 1024)) + drainer.start() + self.args.watch_timeout = 1 + with mock.patch.object(peer.fcntl, "fcntl", side_effect=AssertionError("inherited flag mutation")): + code, unused = self.invoke(stream=stream, real_clock=True) + drainer.join() + self.assertEqual(code, 0) + rows = [json.loads(line) for line in os.read(read_fd, 8192).decode().splitlines()] + self.assertEqual([row["event"] for row in rows], ["snapshot", "end"]) + self.assertEqual(peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL), before) + finally: + if drainer is not None: + drainer.cancel() + drainer.join() + stream.close() + os.close(read_fd) + + def test_sigint_after_snapshot_with_now_full_pipe_has_immediate_finish_budget(self): + read_fd, write_fd = os.pipe() + stream = os.fdopen(write_fd, "w") + self.args.watch_timeout = 60 + def block_and_interrupt(seconds): + flags = peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL) + peer.fcntl.fcntl(write_fd, peer.fcntl.F_SETFL, flags | os.O_NONBLOCK) + try: + while True: + os.write(write_fd, b"x" * 4096) + except BlockingIOError: + pass + finally: + peer.fcntl.fcntl(write_fd, peer.fcntl.F_SETFL, flags) + os.kill(os.getpid(), signal.SIGINT) + try: + started = time.monotonic() + with mock.patch.object(peer.time, "sleep", side_effect=block_and_interrupt): + code, unused = self.invoke(stream=stream, real_clock=True) + self.assertEqual(code, 130) + self.assertLess(time.monotonic() - started, 1) + self.assertTrue(all(process.poll() is not None for process in self.output_processes)) + finally: + stream.close() + os.close(read_fd) + + def test_cleanup_failure_requires_nonzero_exit_even_after_end_frame(self): + with mock.patch.object(peer.HandoffWatchOutput, "close", return_value=False): + code, frames = self.invoke() + self.assertEqual(code, 1) + self.assertEqual(frames[-1]["reason"], "duration_elapsed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/core/test_handoff_watch_cli.py b/tests/core/test_handoff_watch_cli.py new file mode 100644 index 00000000..f50efc2a --- /dev/null +++ b/tests/core/test_handoff_watch_cli.py @@ -0,0 +1,50 @@ +"""Watch argument bounds fail before ledger or process access.""" +import contextlib +import io +import unittest +from unittest import mock + +import session_peer as peer + + +CORRELATION = "00000000-0000-4000-8000-000000000001" + + +class HandoffWatchCli(unittest.TestCase): + def test_unsupported_platform_is_explicit_before_history_access(self): + output = io.StringIO() + with mock.patch.object(peer, 'IS_WINDOWS', True), contextlib.redirect_stdout(output), \ + mock.patch.object(peer, 'HandoffLedger', side_effect=AssertionError('history accessed')): + code = peer.main(['handoff', 'watch', '--correlation-id', CORRELATION, '--json']) + self.assertEqual(code, 1) + import json + result = json.loads(output.getvalue()) + self.assertEqual(result['reason'], 'unsupported') + self.assertNotIn('handoff', result) + self.assertNotIn('ledgerEpoch', result) + + def test_defaults_and_explicit_bounds(self): + args = peer.build_parser().parse_args(['handoff', 'watch', '--correlation-id', CORRELATION]) + self.assertIs(args.func, peer.cmd_handoff_watch) + self.assertTrue(args.no_update_notice) + self.assertEqual((args.watch_timeout, args.interval_ms), (30, 1000)) + args = peer.build_parser().parse_args(['handoff', 'watch', '--correlation-id', CORRELATION, + '--watch-timeout', '60', '--interval-ms', '5000', '--json']) + self.assertEqual((args.watch_timeout, args.interval_ms, args.json), (60, 5000, True)) + + def test_invalid_options_never_touch_history_or_start_processes(self): + cases = [(flag, value) for flag, values in ( + ('--watch-timeout', ('0', '61', '-1', '1.0', '1e0', '+1', '1')), + ('--interval-ms', ('999', '5001', '1e3', '1000.0', '-1000', '+1000', '1000')), + ('--host', ('fixture',)), ('--wait-for', ('acknowledged',)), + ) for value in values] + cases.append(('--correlation-id', 'invalid')) + for flag, value in cases: + with self.subTest(flag=flag, value=value), \ + contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(io.StringIO()), \ + mock.patch.object(peer, 'HandoffLedger', side_effect=AssertionError('history accessed')), \ + mock.patch.object(peer.subprocess, 'Popen', side_effect=AssertionError('process started')), \ + mock.patch.object(peer.subprocess, 'run', side_effect=AssertionError('process started')): + with self.assertRaises(SystemExit) as result: + peer.main(['handoff', 'watch', '--correlation-id', CORRELATION, flag, value]) + self.assertEqual(result.exception.code, 2) diff --git a/tools/generate_session_peer.py b/tools/generate_session_peer.py index 1cfdc81c..9bf7d3d6 100644 --- a/tools/generate_session_peer.py +++ b/tools/generate_session_peer.py @@ -34,6 +34,7 @@ "handoff_stdin.py", "handoff_process.py", "handoff.py", + "handoff_watch.py", "handoff_remote.py", "sending.py", "relay.py", From 36baf896c951f9855cf202660c7c205525bbd988 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 23 Oct 2026 10:29:48 +0900 Subject: [PATCH 4/6] docs(handoff): refine reviewed Korean watch wording --- docs/ko/cli-reference.md | 2 +- docs/ko/handoff-runtime.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/ko/cli-reference.md b/docs/ko/cli-reference.md index c06a72bd..4abacb87 100644 --- a/docs/ko/cli-reference.md +++ b/docs/ko/cli-reference.md @@ -412,7 +412,7 @@ session-peer handoff watch --correlation-id PREPARED_UUID --watch-timeout 30 --i 로컬 소유자의 POSIX 기록에 있는 기존 준비 UUID를 사용합니다. 상태 조회는 단일 스냅샷을 유지하며 감시는 1..60초(기본 30) 동안 1000..5000밀리초 간격(기본 1000)으로 최대 61번 조회합니다. 두 값은 ASCII 정수만 허용합니다. 감시는 예산 안에 유효한 공개 결과를 구성할 수 있을 때만 최초 스냅샷을 출력하고 이후 공개 메타데이터/historyPhase가 바뀐 경우만 출력하므로 스냅샷이 없을 수도 있습니다. JSON 감시는 operation watch와 event snapshot 또는 end인 공통 schemaVersion 1 NDJSON 봉투를 사용하고 스냅샷에 기존 handoff 객체를 포함하며 status JSON은 유지합니다. 기한 종료의 코드 0은 감시 완료만 뜻하고 ACK나 대기 성공은 아닙니다. SIGINT는 130, 기록·출력 실패는 1, 잘못된 인자는 2입니다. 기록 부재·손상이나 ledgerEpoch 변경 시 감시를 중단합니다. historyPhase는 prepared, attempted, terminal, tombstone 또는 quarantined이며 attempted는 영속적인 시도 방지 기록을 뜻하고 실행 중이라는 의미는 아닙니다. -감시는 읽기 전용이며 의도·대기·수신 확인 생성, 기록 쓰기, 제출·깨우기를 수행하지 않습니다. 호스트·기기·Windows 경로나 Codex ACK를 추가하지 않습니다. 프레임은 16 KiB, 전체는 256 KiB로 제한하고 종료용 2 KiB를 남깁니다. 실제 파일 디스크립터 표준 출력에는 전용 격리 Python 출력 보조 프로세스 하나를 사용합니다. 크기가 제한된 직렬화된 공개 바이트만 받고 공유 디스크립터 플래그를 바꾸거나 기록·네이티브 데이터를 읽지 않습니다. 부모는 IPC까지 원래 총예산으로 제한하고 새 기록 읽기·검증 단계 전에도 기한을 확인합니다. 총 시간 안에서 최종 출력·정리에 100 ms를 남기므로 조회는 더 일찍 끝날 수 있습니다. 시간 초과·SIGINT·오류 시 보조 프로세스를 종료·회수하지만 중단할 수 없는 OS 작업의 엄격한 실시간 보장은 아닙니다. 부분 쓰기나 완료 응답 부재 시 종료를 포함한 후속 프레임을 금지하며 소비자는 잘린 마지막 NDJSON 줄을 거부해야 합니다. 보조 프로세스 완료는 OS 쓰기 완료이며 수신자 소비의 증거가 아닙니다. 스냅샷은 모든 이벤트를 빠짐없이 전달하는 스트림이 아니며 로컬 UTC 관측은 ACK 도착 순서의 증거가 아닙니다. 개인정보, 유지되는 제출·ACK·대기의 의미, 읽기 전용 수집기 조회와 기존 격리 조건은 [handoff 런타임 가이드](handoff-runtime.md)를 참고하세요. 감시는 네이티브 메시지 프로세스나 수집기를 시작하지 않습니다. 이 후보는 배포된 릴리스가 아닙니다. 페이로드·권한 다이제스트를 노출하지 않으며 공개 targetGeneration의 기존 계약은 유지합니다. 읽기 전용 기록은 불변 스냅샷 바이트를 읽은 뒤 파싱·검증 전에 공유 잠금을 해제합니다. 중단 시 최종 출력·정리는 남은 총예산 안에서 최대 100 ms로 제한합니다. 전체 성공에는 완전한 성공 종료 프레임과 종료 코드 0이 모두 필요합니다. 해당 프레임 뒤에도 정리가 실패할 수 있습니다. 0이 아닌 종료는 전체 실패이며 ACK를 상향 해석하거나 독립적으로 유효한 제출 사실을 버리거나 재시도해도 된다는 뜻은 아닙니다. +감시는 읽기 전용이며 의도·대기·수신 확인 생성, 기록 쓰기, 제출·깨우기를 수행하지 않습니다. 호스트·기기·Windows 경로나 Codex ACK를 추가하지 않습니다. 프레임은 16 KiB, 전체는 256 KiB로 제한하고 종료용 2 KiB를 남깁니다. 실제 파일 디스크립터 표준 출력에는 전용 격리 Python 출력 보조 프로세스 하나를 사용합니다. 크기가 제한된 직렬화된 공개 바이트만 받고 공유 디스크립터 플래그를 바꾸거나 기록·네이티브 데이터를 읽지 않습니다. 부모는 IPC까지 원래 총예산으로 제한하고 새 기록 읽기·검증 단계 전에도 기한을 확인합니다. 총 시간 안에서 최종 출력·정리에 100 ms를 남기므로 조회는 더 일찍 끝날 수 있습니다. 시간 초과·SIGINT·오류 시 보조 프로세스를 종료·회수합니다. 다만 중단할 수 없는 OS 작업이 있어 엄격한 실시간 보장을 제공하지는 못합니다. 부분 쓰기나 완료 응답 부재 시 종료를 포함한 후속 프레임을 금지하며 소비자는 잘린 마지막 NDJSON 줄을 거부해야 합니다. 보조 프로세스 완료는 OS 쓰기 완료이며 수신자 소비의 증거가 아닙니다. 스냅샷은 모든 이벤트를 빠짐없이 전달하는 스트림이 아니며 로컬 UTC 관측은 ACK 도착 순서의 증거가 아닙니다. 개인정보, 유지되는 제출·ACK·대기의 의미, 읽기 전용 수집기 조회와 기존 격리 조건은 [handoff 런타임 가이드](handoff-runtime.md)를 참고하세요. 감시는 네이티브 메시지 프로세스나 수집기를 시작하지 않습니다. 이 후보는 배포된 릴리스가 아닙니다. 페이로드·권한 다이제스트를 노출하지 않으며 공개 targetGeneration의 기존 계약은 유지합니다. 읽기 전용 기록은 불변 스냅샷 바이트를 읽은 뒤 파싱·검증 전에 공유 잠금을 해제합니다. 중단 시 최종 출력·정리는 남은 총예산 안에서 최대 100 ms로 제한합니다. 전체 성공에는 완전한 성공 종료 프레임과 종료 코드 0이 모두 필요합니다. 해당 프레임 뒤에도 정리가 실패할 수 있습니다. 0이 아닌 종료는 전체 실패이며 ACK를 상향 해석하거나 독립적으로 유효한 제출 사실을 버리거나 재시도해도 된다는 뜻은 아닙니다. ### 업데이트 diff --git a/docs/ko/handoff-runtime.md b/docs/ko/handoff-runtime.md index 76d41d64..032b30b3 100644 --- a/docs/ko/handoff-runtime.md +++ b/docs/ko/handoff-runtime.md @@ -46,7 +46,7 @@ session-peer handoff status --correlation-id PREPARED_UUID 이미 준비한 ID를 사용합니다. 사람이 읽는 화면은 로컬 소유자의 기록을 한 번 조회해 확인된 제출 사실, 주입 관측, ACK의 확인 근거와 지연 여부, 마지막으로 저장된 대기, 재시도 금지와 지원되는 다음 조치를 보여줍니다. ACK와 대기는 별도로 표시하므로 늦은 ACK가 만료된 대기를 다시 쓰지 않습니다. 마지막으로 저장된 대기는 전체 대기 이력이 아닙니다. ACK 지연 여부는 첫 대기를 기준으로 하며 마지막으로 저장된 대기와는 다를 수 있습니다. 만료되거나 격리된 기록의 수신 확인·주입 상세는 확인 불가로 표시하며, 대기 계속 조치가 지원되지 않는 대기 중 ACK는 추가 대기가 가능하다는 뜻이 아닙니다. JSON 필드 구조와 종료 코드의 의미는 유지합니다. 조회 성공은 0, 실패는 1이며, 조회 성공은 수신의 증거가 아닙니다. -상태 조회는 전송·깨우기, 기록 초기화·복구, 수신 확인 권한 발급, 대기 변경을 수행하지 않습니다. 읽기 전용 기록 경로에서는 변경을 저장하지 않지만, 메모리에서 보존 기간을 적용한 결과 상세 정보가 없는 보존 표식(tombstone)이 표시될 수 있습니다. 복원 후 명시적 격리는 제출 상태를 unknown으로 바꾸고 종료된 대기를 디스크에 보존합니다. 화면은 유효한 공개 결과를 구성할 수 있을 때만 마지막 대기를 보여줍니다. 알려진 수명주기 한계로, 격리 시 ACK 상세가 지워지면 보존된 satisfied 대기는 기존 공개 검증 조건을 충족하지 못해 상태 조회가 invalid_handoff(종료 코드 1)로 거부됩니다. 기록을 복구하거나 다시 쓰지는 않으며, 이는 #181의 남은 수명주기 검증 항목입니다. 기존 상태 조회 로직은 다음 조치를 판단할 시계 근거를 얻기 위해 실행 중인 수집기에 질의할 수 있지만 수집기를 시작하지는 않습니다. 이 화면은 대화 기록이나 메시지 본문을 읽거나 원본 권한 토큰을 노출하지 않습니다. +상태 조회는 제출·깨우기, 기록 초기화·복구, 수신 확인 권한 발급, 대기 변경을 수행하지 않습니다. 읽기 전용 기록 경로에서는 변경을 저장하지 않지만, 메모리에서 보존 기간을 적용한 결과 상세 정보가 없는 보존 표식(tombstone)이 표시될 수 있습니다. 복원 후 명시적 격리는 제출 상태를 unknown으로 바꾸고 종료된 대기를 디스크에 보존합니다. 화면은 유효한 공개 결과를 구성할 수 있을 때만 마지막 대기를 보여줍니다. 알려진 수명주기 한계로, 격리 시 ACK 상세가 지워지면 보존된 satisfied 대기는 기존 공개 검증 조건을 충족하지 못해 상태 조회가 invalid_handoff(종료 코드 1)로 거부됩니다. 기록을 복구하거나 다시 쓰지는 않으며, 이는 #181의 남은 수명주기 검증 항목입니다. 기존 상태 조회 로직은 다음 조치를 판단할 시계 근거를 얻기 위해 실행 중인 수집기에 질의할 수 있지만 수집기를 시작하지는 않습니다. 이 화면은 대화 기록이나 메시지 본문을 읽거나 원본 권한 토큰을 노출하지 않습니다. 수집기의 시계 조회도 읽기 전용입니다. 이전 수집기는 이 조회를 거부할 수 있어 가능한 다음 조치에 `keep_waiting`이 없을 수 있습니다. 이때 상태 조회는 기록을 쓰는 질의로 전환하거나 수집기를 재시작하지 않습니다. 기록 스냅샷과 선택적 수집기 시계 관측은 별도이며, 원자적으로 함께 관측한 증거가 아닙니다. 대기가 history_unavailable로 실패한 뒤 수집기 시계를 다시 확인할 수 있게 된 경우, 이제 상태 조회는 확인된 제출과 실패한 대기를 보존하면서 keep_waiting을 안내하지 않습니다. 다른 대기 생성 의미는 바꾸지 않습니다. 보존 표식의 최신 대기 상세는 만료된 기록으로 인해 확인 불가로 표시하며, 대기를 요청하지 않았다는 증거로 해석하지 않습니다. @@ -58,11 +58,11 @@ session-peer handoff status --correlation-id PREPARED_UUID session-peer handoff watch --correlation-id PREPARED_UUID --watch-timeout 30 --interval-ms 1000 --json ``` -감시는 기존 로컬 소유자의 POSIX 기록만 반복 조회합니다. 시간 제한은 ASCII 정수 1..60초(기본 30), 간격은 ASCII 정수 1000..5000밀리초(기본 1000)입니다. 최대 61번 조회하고 예산 안에 유효한 공개 결과를 구성할 수 있을 때만 최초 스냅샷을 출력하며 이후 공개 메타데이터나 historyPhase가 바뀔 때만 출력합니다. 스냅샷을 하나도 출력하지 못할 수 있으므로 모든 이벤트를 빠짐없이 전달하는 스트림은 아닙니다. 확인된 제출, ACK 근거, 첫 대기를 기준으로 한 ACK 지연, 최신 저장 대기는 상태 화면과 같은 별도의 의미를 유지합니다. 로컬 UTC 관측 시각은 ACK 도착 시각이나 순서의 증거가 아닙니다. historyPhase는 prepared, attempted, terminal, tombstone 또는 quarantined이며 attempted는 영속적인 시도 방지 기록을 뜻하고 실행 중이라는 의미는 아닙니다. +감시는 기존 로컬 소유자의 POSIX 기록만 반복 조회합니다. 시간 제한은 ASCII 정수 1..60초(기본 30), 간격은 ASCII 정수 1000..5000밀리초(기본 1000)입니다. 최대 61번 조회하고 예산 안에 유효한 공개 결과를 구성할 수 있을 때만 최초 스냅샷을 출력하며 이후 공개 메타데이터나 historyPhase가 바뀔 때만 출력합니다. 스냅샷을 하나도 출력하지 못할 수 있습니다. 모든 이벤트를 빠짐없이 전달하는 스트림은 아닙니다. 확인된 제출, ACK 근거, 첫 대기를 기준으로 한 ACK 지연, 최신 저장 대기는 상태 화면과 같은 별도의 의미를 유지합니다. 로컬 UTC 관측 시각은 ACK 도착 시각이나 순서의 증거가 아닙니다. historyPhase는 prepared, attempted, terminal, tombstone 또는 quarantined이며 attempted는 영속적인 시도 방지 기록을 뜻하고 실행 중이라는 의미는 아닙니다. JSON 출력은 watch 전용 필드를 포함한 공통 schemaVersion 1 봉투의 NDJSON이며 operation은 watch, event는 snapshot 또는 end입니다. 스냅샷은 바뀌지 않은 공개 handoff 객체를 포함하며 기존 status JSON은 유지합니다. 기한 종료는 0으로 반환하고 감시 종료만 뜻하며 ACK 도착이나 대기 성공을 의미하지 않습니다. SIGINT는 130, 기록·출력 실패는 1입니다. 기록 부재·손상이나 ledgerEpoch 변경 시 handoff나 epoch를 만들지 않고 감시를 종료합니다. 격리된 satisfied 대기의 기존 거부는 위에 설명한 #181 수명주기 조건으로 유지합니다. -출력 프레임은 각각 16 KiB, 전체는 256 KiB이며 종료 프레임용 2 KiB를 남깁니다. 실제 파일 디스크립터 표준 출력은 전용 격리 Python 출력 보조 프로세스 하나를 사용하며, 이 프로세스는 크기가 제한된 직렬화된 공개 바이트만 받아 공유 디스크립터 플래그를 바꾸거나 기록·네이티브 데이터를 읽지 않습니다. 부모는 보조 프로세스 IPC까지 원래 총 기한으로 제한하고 새 기록 읽기·검증 단계 전에도 기한을 확인합니다. 총 시간 안에서 최종 출력·정리에 100 ms를 남기므로 조회는 더 일찍 끝날 수 있습니다. 시간 초과·SIGINT·오류 시 부모는 자신이 시작한 보조 프로세스를 종료하고 회수합니다. 중단할 수 없는 OS 작업 때문에 엄격한 실시간 보장은 아닙니다. 쓰기가 부분적이거나 완료 응답이 없으면 종료를 포함한 후속 프레임을 보내지 않습니다. 소비자는 잘린 마지막 NDJSON 줄을 거부해야 합니다. 보조 프로세스의 완료 응답은 OS 쓰기 완료만 뜻하며 수신자의 소비를 증명하지 않습니다. 감시는 네이티브 본문·대화 기록을 읽거나 권한이나 페이로드·권한 다이제스트를 노출하지 않으며 공개 targetGeneration의 기존 계약은 유지합니다. 의도·대기·수신 확인 생성, 제출, 깨우기, 초기화, 복구나 기록 쓰기를 수행하지 않습니다. 실행 중인 로컬 수집기의 기존 읽기 전용 시계 조회만 사용할 수 있으며 수집기나 네이티브 메시지 프로세스를 시작하지 않습니다. 호스트·기기 라우팅과 Windows 감시는 지원하지 않으며 Codex ACK 지원도 추가하지 않습니다. 읽기 전용 기록은 불변 스냅샷 바이트를 읽은 뒤 파싱·검증 전에 공유 잠금을 해제합니다. 중단 시 최종 출력·정리는 남은 총예산 안에서 최대 100 ms로 제한합니다. 전체 성공에는 완전한 성공 종료 프레임과 종료 코드 0이 모두 필요합니다. 해당 프레임 뒤에도 정리가 실패할 수 있습니다. 0이 아닌 종료는 전체 실패이며 ACK를 상향 해석하거나 독립적으로 유효한 제출 사실을 버리거나 재시도해도 된다는 뜻은 아닙니다. +출력 프레임은 각각 16 KiB, 전체는 256 KiB이며 종료 프레임용 2 KiB를 남깁니다. 실제 파일 디스크립터 표준 출력은 전용 격리 Python 출력 보조 프로세스 하나를 사용하며, 이 프로세스는 크기가 제한된 직렬화된 공개 바이트만 받아 공유 디스크립터 플래그를 바꾸거나 기록·네이티브 데이터를 읽지 않습니다. 부모는 보조 프로세스 IPC까지 원래 총 기한으로 제한하고 새 기록 읽기·검증 단계 전에도 기한을 확인합니다. 총 시간 안에서 최종 출력·정리에 100 ms를 남기므로 조회는 더 일찍 끝날 수 있습니다. 시간 초과·SIGINT·오류 시 부모는 자신이 시작한 보조 프로세스를 종료하고 회수합니다. 중단할 수 없는 OS 작업이 있어 엄격한 실시간 보장을 제공하지는 못합니다. 쓰기가 부분적이거나 완료 응답이 없으면 종료를 포함한 후속 프레임을 보내지 않습니다. 소비자는 잘린 마지막 NDJSON 줄을 거부해야 합니다. 보조 프로세스의 완료 응답은 OS 쓰기 완료만 뜻하며 수신자의 소비를 증명하지 않습니다. 감시는 네이티브 본문·대화 기록을 읽거나 권한이나 페이로드·권한 다이제스트를 노출하지 않으며 공개 targetGeneration의 기존 계약은 유지합니다. 의도·대기·수신 확인 생성, 제출, 깨우기, 초기화, 복구나 기록 쓰기를 수행하지 않습니다. 실행 중인 로컬 수집기의 기존 읽기 전용 시계 조회만 사용할 수 있으며 수집기나 네이티브 메시지 프로세스를 시작하지 않습니다. 호스트·기기 라우팅과 Windows 감시는 지원하지 않으며 Codex ACK 지원도 추가하지 않습니다. 읽기 전용 기록은 불변 스냅샷 바이트를 읽은 뒤 파싱·검증 전에 공유 잠금을 해제합니다. 중단 시 최종 출력·정리는 남은 총예산 안에서 최대 100 ms로 제한합니다. 전체 성공에는 완전한 성공 종료 프레임과 종료 코드 0이 모두 필요합니다. 해당 프레임 뒤에도 정리가 실패할 수 있습니다. 0이 아닌 종료는 전체 실패이며 ACK를 상향 해석하거나 독립적으로 유효한 제출 사실을 버리거나 재시도해도 된다는 뜻은 아닙니다. ## 증거·개인정보·시간 제한 From 9000ef6b5753b6745a2542a580f401abb4d4a033 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 23 Oct 2026 10:43:33 +0900 Subject: [PATCH 5/6] fix(handoff): preserve watch output reserve across sleep slack --- session_peer.py | 15 ++++++++++++++- session_peer_core/handoff_watch.py | 15 ++++++++++++++- tests/core/test_handoff_watch.py | 29 +++++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/session_peer.py b/session_peer.py index 0182bac7..4d66840d 100644 --- a/session_peer.py +++ b/session_peer.py @@ -8644,6 +8644,7 @@ def cmd_handoff_send(args): HANDOFF_WATCH_END_RESERVE = 2048 HANDOFF_WATCH_FINISH_SECONDS = 0.1 HANDOFF_WATCH_REAP_SECONDS = 0.025 +HANDOFF_WATCH_SLEEP_SLICE_SECONDS = 0.05 HANDOFF_WATCH_WRITER_SCRIPT = r''' import os import select @@ -8867,6 +8868,18 @@ def handoff_watch_frame(args, payload, *, ok): str(payload["snapshotsEmitted"]) + "; samples: " + str(payload["samples"]) + ".") +def handoff_watch_pause(until): + # Some schedulers coalesce a long relative sleep by a proportion of its + # duration. Recheck the same absolute deadline after short sleeps rather + # than consuming the final-output reserve in one long sleep. This changes + # neither the sampling interval nor the original total watch budget. + while True: + remaining = until - handoff_now() + if remaining <= 0: + return + time.sleep(min(HANDOFF_WATCH_SLEEP_SLICE_SECONDS, remaining)) + + def cmd_handoff_watch(args): if IS_WINDOWS or fcntl is None: payload = {"operation": "watch", "event": "end", "reason": "unsupported", @@ -8929,7 +8942,7 @@ def cmd_handoff_watch(args): remaining = deadline - handoff_now() if remaining <= 0: raise HandoffReadDeadlineExceeded() - time.sleep(min(interval / 1000, remaining)) + handoff_watch_pause(min(deadline, handoff_now() + interval / 1000)) else: reason = "duration_elapsed" except KeyboardInterrupt: diff --git a/session_peer_core/handoff_watch.py b/session_peer_core/handoff_watch.py index 2a256c8e..70257652 100644 --- a/session_peer_core/handoff_watch.py +++ b/session_peer_core/handoff_watch.py @@ -4,6 +4,7 @@ HANDOFF_WATCH_END_RESERVE = 2048 HANDOFF_WATCH_FINISH_SECONDS = 0.1 HANDOFF_WATCH_REAP_SECONDS = 0.025 +HANDOFF_WATCH_SLEEP_SLICE_SECONDS = 0.05 HANDOFF_WATCH_WRITER_SCRIPT = r''' import os import select @@ -227,6 +228,18 @@ def handoff_watch_frame(args, payload, *, ok): str(payload["snapshotsEmitted"]) + "; samples: " + str(payload["samples"]) + ".") +def handoff_watch_pause(until): + # Some schedulers coalesce a long relative sleep by a proportion of its + # duration. Recheck the same absolute deadline after short sleeps rather + # than consuming the final-output reserve in one long sleep. This changes + # neither the sampling interval nor the original total watch budget. + while True: + remaining = until - handoff_now() + if remaining <= 0: + return + time.sleep(min(HANDOFF_WATCH_SLEEP_SLICE_SECONDS, remaining)) + + def cmd_handoff_watch(args): if IS_WINDOWS or fcntl is None: payload = {"operation": "watch", "event": "end", "reason": "unsupported", @@ -289,7 +302,7 @@ def cmd_handoff_watch(args): remaining = deadline - handoff_now() if remaining <= 0: raise HandoffReadDeadlineExceeded() - time.sleep(min(interval / 1000, remaining)) + handoff_watch_pause(min(deadline, handoff_now() + interval / 1000)) else: reason = "duration_elapsed" except KeyboardInterrupt: diff --git a/tests/core/test_handoff_watch.py b/tests/core/test_handoff_watch.py index 9c2638fd..bfd2e4fc 100644 --- a/tests/core/test_handoff_watch.py +++ b/tests/core/test_handoff_watch.py @@ -44,6 +44,18 @@ def invoke(self, lookup=None, stream=None, *, real_clock=False): output = stream if stream is not None else io.StringIO() popen = peer.subprocess.Popen self.output_processes = [] + self.output_diagnostics = [] + write_output = peer.HandoffWatchOutput.write + def diagnosed_write(writer, text, **kwargs): + result = write_output(writer, text, **kwargs) + # Bounded output-only codes/timings: never include frame contents, + # correlation IDs, ledger paths or native error text in CI logs. + self.output_diagnostics.append({ + "end": bool(kwargs.get("end")), "result": result, + "remainingMs": round((writer.deadline - peer.handoff_now()) * 1000), + "helperExit": writer.process.poll() if writer.process else None, + }) + return result def output_helper(command, **kwargs): self.assertIsNotNone(stream, "unexpected process during captured watch") self.assertEqual(command[:5], [peer.sys.executable, "-I", "-S", "-c", peer.HANDOFF_WATCH_WRITER_SCRIPT]) @@ -68,6 +80,7 @@ def output_helper(command, **kwargs): (peer, "post_to_socket"), (peer, "handoff_codex_submit")): stack.enter_context(mock.patch.object(owner, name, side_effect=AssertionError("watch performed an effect"))) stack.enter_context(mock.patch.object(peer.subprocess, "Popen", side_effect=output_helper)) + stack.enter_context(mock.patch.object(peer.HandoffWatchOutput, "write", new=diagnosed_write)) stack.enter_context(contextlib.redirect_stdout(output)) code = peer.cmd_handoff_watch(self.args) if stream is not None: @@ -369,7 +382,7 @@ def test_tty_output_completes_without_changing_inherited_flags(self): self.args.watch_timeout = 1 with mock.patch.object(peer.fcntl, "fcntl", side_effect=AssertionError("inherited flag mutation")): code, _ = self.invoke(stream=stream, real_clock=True) - self.assertEqual(code, 0) + self.assertEqual(code, 0, self.output_diagnostics) rows = [json.loads(line) for line in os.read(master, 8192).decode().splitlines()] self.assertEqual([row["event"] for row in rows], ["snapshot", "end"]) self.assertEqual(peer.fcntl.fcntl(slave, peer.fcntl.F_GETFL), before) @@ -609,7 +622,7 @@ def test_inherited_nonblocking_stdout_waits_for_drain_and_succeeds(self): with mock.patch.object(peer.fcntl, "fcntl", side_effect=AssertionError("inherited flag mutation")): code, unused = self.invoke(stream=stream, real_clock=True) drainer.join() - self.assertEqual(code, 0) + self.assertEqual(code, 0, self.output_diagnostics) rows = [json.loads(line) for line in os.read(read_fd, 8192).decode().splitlines()] self.assertEqual([row["event"] for row in rows], ["snapshot", "end"]) self.assertEqual(peer.fcntl.fcntl(write_fd, peer.fcntl.F_GETFL), before) @@ -652,6 +665,18 @@ def test_cleanup_failure_requires_nonzero_exit_even_after_end_frame(self): self.assertEqual(code, 1) self.assertEqual(frames[-1]["reason"], "duration_elapsed") + def test_proportional_sleep_slack_preserves_final_output_reserve(self): + self.args.watch_timeout = 1 + def coalesced_sleep(seconds): + # Model proportional timer coalescing, not a claim about the + # exact scheduler responsible for a historical CI failure. + self.clock.now += seconds * 1.1 + with mock.patch.object(self.clock, "sleep", side_effect=coalesced_sleep): + code, frames = self.invoke() + self.assertEqual(code, 0) + self.assertEqual([row["event"] for row in frames], ["snapshot", "end"]) + self.assertLess(self.clock.now, 1 - peer.HANDOFF_WATCH_REAP_SECONDS) + if __name__ == "__main__": unittest.main() From e69e5821da3c260601960a42081c16226acb4de3 Mon Sep 17 00:00:00 2001 From: YEONGUK LEE Date: Fri, 23 Oct 2026 10:43:33 +0900 Subject: [PATCH 6/6] fix(relay): retire expired rooms before asynchronous cleanup --- session_peer_relay/relay.py | 24 +++++++++--- tests/relay/test_native_relay.py | 7 +++- tests/relay/test_relay_diagnostics.py | 53 +++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/session_peer_relay/relay.py b/session_peer_relay/relay.py index ef385bd1..438c14cc 100644 --- a/session_peer_relay/relay.py +++ b/session_peer_relay/relay.py @@ -281,12 +281,25 @@ async def release_slot(): connection.lab_diagnostic_capable = capable return None + def close_waiting_room(self, key, slot, reason): + if self.waiting.get(key) is slot: + self.waiting.pop(key, None) + self.counters['roomsClosedBeforeAttach'] += 1 + self.diagnostic_event('room_close', roomId=slot['id'], + reason=reason, ageMs=max(0, round((time.monotonic()-slot['opened'])*1000)), + attemptId=slot['attemptId']) + async def handler(self, ws): self.connections.add(ws) account = ws.lab_account key = account['room'] other_role = 'client' if account['role'] == 'receiver' else 'receiver' slot = self.waiting.get(key) + if slot is not None and time.monotonic() >= slot['deadline']: + # A delayed timeout callback must not let a new arrival revive an + # expired room while its original handler is still cleaning up. + self.close_waiting_room(key, slot, slot['expiryReason']) + slot = None watcher = None paired = False attach_sent = False @@ -306,6 +319,8 @@ async def watch_authorization(): slot = {'members': {}, 'joined': {}, 'closedByRelay': set(), 'ready': asyncio.Event(), 'opened': time.monotonic(), 'id': secrets.token_hex(8), 'attemptId': getattr(ws, 'lab_attempt_id', None)} + slot['deadline'] = slot['opened'] + (self.receiver_wait if account['role'] == 'receiver' else self.client_wait) + slot['expiryReason'] = 'receiver_idle_expiry' if account['role'] == 'receiver' else 'client_wait_expiry' self.waiting[key] = slot self.counters['roomsOpened'] += 1 self.counters['receiverRoomsOpened' if account['role'] == 'receiver' else 'clientFirstRooms'] += 1 @@ -341,6 +356,8 @@ async def watch_authorization(): close_reason = 'receiver_idle_expiry' if account['role'] == 'receiver' else 'client_wait_expiry' counter = 'receiverIdleExpired' if account['role'] == 'receiver' else 'clientWaitExpired' self.counters[counter] += 1 + # Remove eligibility before cancellation cleanup can yield. + self.close_waiting_room(key, slot, close_reason) return paired = True finally: @@ -412,12 +429,7 @@ async def watch_authorization(): self.connections.discard(ws) if slot['members'].get(account['role']) is ws: slot['members'].pop(account['role'], None) - if self.waiting.get(key) is slot: - self.waiting.pop(key, None) - self.counters['roomsClosedBeforeAttach'] += 1 - self.diagnostic_event('room_close', roomId=slot['id'], - reason=close_reason, ageMs=max(0, round((time.monotonic()-slot['opened'])*1000)), - attemptId=slot['attemptId']) + self.close_waiting_room(key, slot, close_reason) other = slot['members'].get(other_role) if other: slot['closedByRelay'].add(other_role) diff --git a/tests/relay/test_native_relay.py b/tests/relay/test_native_relay.py index b098afe7..01542901 100644 --- a/tests/relay/test_native_relay.py +++ b/tests/relay/test_native_relay.py @@ -160,7 +160,12 @@ async def test_direct_native_discovery_and_delivery(self): async def test_relay_native_delivery_ciphertext_and_cross_route_dedup(self): ident=str(uuid.uuid4());body={'target':'review','message':'SP_NATIVE_RELAY_SENTINEL'} - result=await self.call('send',body,ident,'relay');self.assertTrue(result['ok']) + result=await self.call('send',body,ident,'relay') + # Keep failure evidence to protocol codes/booleans, without payloads, + # identities, paths, or native exception text. + details = {key: result.get(key) for key in ( + 'ok', 'status', 'reason', 'submitted', 'retryAllowed', 'consumptionConfirmed')} + self.assertTrue(result['ok'], details) duplicate=await self.call('send',body,ident,'direct');self.assertTrue(duplicate['duplicate']) self.assertEqual(len(self.effects),1) self.assertNotIn(b'SP_NATIVE_RELAY_SENTINEL',b''.join(self.frames)) diff --git a/tests/relay/test_relay_diagnostics.py b/tests/relay/test_relay_diagnostics.py index 5ec699e1..2ba6f26e 100644 --- a/tests/relay/test_relay_diagnostics.py +++ b/tests/relay/test_relay_diagnostics.py @@ -160,6 +160,59 @@ async def test_re_admission_delay_longer_than_client_wait_leaves_no_pair(self): receiver_task.cancel() await asyncio.gather(receiver_task, return_exceptions=True) + async def test_expired_client_cannot_pair_during_timeout_cleanup(self): + relay = Relay([]) + relay.client_wait = .01 + cleanup_started, release_cleanup = asyncio.Event(), asyncio.Event() + client = FakeWebSocket('client') + original_wait_closed = client.wait_closed + + async def delayed_cleanup(): + try: + await original_wait_closed() + finally: + cleanup_started.set() + await release_cleanup.wait() + + client.wait_closed = delayed_cleanup + client_task = asyncio.create_task(relay.handler(client)) + receiver = FakeWebSocket('receiver') + receiver_task = None + try: + await asyncio.wait_for(cleanup_started.wait(), 1) + receiver_task = asyncio.create_task(relay.handler(receiver)) + await self.wait_for(lambda: receiver in relay.connections) + self.assertEqual(relay.metrics()['counters']['clientWaitExpired'], 1) + self.assertEqual(relay.metrics()['counters']['roomsPaired'], 0) + self.assertEqual(client.sent, []) + self.assertEqual(receiver.sent, []) + finally: + release_cleanup.set() + await client.close() + await receiver.close() + await asyncio.wait_for(asyncio.gather(client_task, *([receiver_task] if receiver_task else [])), 1) + + async def test_arrival_after_deadline_cannot_pair_before_timeout_callback(self): + relay = Relay([]) + client = FakeWebSocket('client') + receiver = FakeWebSocket('receiver') + client_task = asyncio.create_task(relay.handler(client)) + receiver_task = None + try: + await self.wait_for(lambda: bool(relay.waiting)) + slot = relay.waiting['SECRET-ROOM'] + # Advance the recorded deadline without yielding to the timeout + # callback, modelling an arrival scheduled ahead of expired work. + slot['deadline'] = time.monotonic() - 1 + receiver_task = asyncio.create_task(relay.handler(receiver)) + await self.wait_for(lambda: receiver in relay.connections) + self.assertEqual(relay.metrics()['counters']['roomsPaired'], 0) + self.assertIsNot(relay.waiting['SECRET-ROOM'], slot) + finally: + await client.close() + await receiver.close() + await asyncio.wait_for(asyncio.gather(client_task, *([receiver_task] if receiver_task else [])), 1) + async def test_stalled_receiver_consumes_room_then_next_client_times_out(self): relay = Relay([], diagnostic_events=True) relay.client_wait = .03 # scaled fixture; production bound stays 10 s