Make G006 task policy state machine executable

Typed task packets, policy decisions, lane board status, and session liveness now have concrete runtime contracts and focused regressions for Stream 4.

Constraint: G006 requires task/lane operation without pane scraping while preserving legacy task packet callers.
Rejected: waiting on stale worker worktrees | all G006 worker worktrees remained at main with no commits, so leader integrated the verified slice directly.
Confidence: high
Scope-risk: moderate
Directive: Keep task packet serde defaults when adding fields so older packets continue to deserialize.
Tested: git diff --check; cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo check --manifest-path rust/Cargo.toml -p runtime -p tools -p rusty-claude-cli; cargo test --manifest-path rust/Cargo.toml -p runtime task_packet -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p runtime policy_engine -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p runtime task_registry -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p runtime session_heartbeat -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p tools run_task_packet_creates_packet_backed_task -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p tools lane_completion -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli status_json_surfaces -- --nocapture
Not-tested: full workspace test suite; PR/issue reconciliation deferred to G011/G012

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
bellman
2026-05-15 09:29:26 +09:00
parent 41b769fc5a
commit f7235ca932
9 changed files with 808 additions and 13 deletions

View File

@@ -8,6 +8,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use crate::json::{JsonError, JsonValue};
use crate::usage::TokenUsage;
use serde::{Deserialize, Serialize};
const SESSION_VERSION: u32 = 1;
const ROTATE_AFTER_BYTES: u64 = 256 * 1024;
@@ -82,6 +83,25 @@ struct SessionPersistence {
path: PathBuf,
}
/// Running-state liveness classification for a session heartbeat.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionLiveness {
Healthy,
Stalled,
TransportDead,
Unknown,
}
/// Heartbeat emitted from canonical session state, independent of terminal rendering.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionHeartbeat {
pub session_id: String,
pub observed_at_ms: u64,
pub transport_alive: bool,
pub liveness: SessionLiveness,
}
/// Persisted conversational state for the runtime and CLI session manager.
///
/// `workspace_root` binds the session to the worktree it was created in. The
@@ -250,6 +270,35 @@ impl Session {
self.push_message(ConversationMessage::user_text(text))
}
pub fn record_health_check(&mut self, timestamp_ms: u64) {
self.last_health_check_ms = Some(timestamp_ms);
self.touch();
}
#[must_use]
pub fn heartbeat_at(
&self,
now_ms: u64,
stalled_after_ms: u64,
transport_alive: bool,
) -> SessionHeartbeat {
let liveness = match (transport_alive, self.last_health_check_ms) {
(false, _) => SessionLiveness::TransportDead,
(true, Some(last)) if now_ms.saturating_sub(last) <= stalled_after_ms => {
SessionLiveness::Healthy
}
(true, Some(_)) => SessionLiveness::Stalled,
(true, None) => SessionLiveness::Unknown,
};
SessionHeartbeat {
session_id: self.session_id.clone(),
observed_at_ms: now_ms,
transport_alive,
liveness,
}
}
pub fn record_compaction(&mut self, summary: impl Into<String>, removed_message_count: usize) {
self.touch();
let count = self.compaction.as_ref().map_or(1, |value| value.count + 1);
@@ -1599,4 +1648,26 @@ mod workspace_sessions_dir_tests {
fs::remove_dir_all(&tmp_a).ok();
fs::remove_dir_all(&tmp_b).ok();
}
#[test]
fn session_heartbeat_classifies_healthy_stalled_transport_dead_and_unknown() {
let mut session = Session::new();
assert_eq!(
session.heartbeat_at(1_000, 500, true).liveness,
SessionLiveness::Unknown
);
session.record_health_check(800);
assert_eq!(
session.heartbeat_at(1_000, 500, true).liveness,
SessionLiveness::Healthy
);
assert_eq!(
session.heartbeat_at(2_000, 500, true).liveness,
SessionLiveness::Stalled
);
assert_eq!(
session.heartbeat_at(1_000, 500, false).liveness,
SessionLiveness::TransportDead
);
}
}