Skip to content

Commit bf6242a

Browse files
authored
Merge pull request #537 from danshapiro/integration/waveB-preflight
feat: wave-B restart-resilience — reconcile handshake live, codex locator, recover-my-panes, fresh-agent verdicts (4 lanes, integrated)
2 parents 2dfbba5 + d9b71f5 commit bf6242a

142 files changed

Lines changed: 24991 additions & 5634 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/freshell-freshagent/src/claude.rs

Lines changed: 395 additions & 7 deletions
Large diffs are not rendered by default.

crates/freshell-freshagent/src/codex.rs

Lines changed: 632 additions & 33 deletions
Large diffs are not rendered by default.
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
//! P1.13 crate-boundary bridge: fresh-agent identity events flow OUT of this
2+
//! crate through this trait; `freshell-server` implements it over the pane
3+
//! ledger (this crate must not depend on `freshell-ws`, where the ledger
4+
//! lives — the dependency edge runs the other way).
5+
6+
use std::sync::Arc;
7+
8+
/// Resume-invocation record (campaign plan §4.2): exactly what the
9+
/// provider-native resume command needs.
10+
#[derive(Debug, Clone, Default, PartialEq)]
11+
pub struct FreshAgentSettings {
12+
pub model: Option<String>,
13+
pub sandbox: Option<String>,
14+
pub permission_mode: Option<String>,
15+
pub effort: Option<String>,
16+
pub cwd: Option<String>,
17+
}
18+
19+
/// One fresh-agent identity event. Settings are a FULL snapshot (replace,
20+
/// not merge). `resolves_pending` names a pending marker (placeholder id)
21+
/// this binding supersedes.
22+
#[derive(Debug, Clone, PartialEq)]
23+
pub struct FreshAgentBindingUpsert {
24+
pub provider: String,
25+
pub session_id: String,
26+
pub mode: String,
27+
pub create_request_id: Option<String>,
28+
pub resolves_pending: Option<String>,
29+
/// G3 supersession (V8/A14): OLD session id this binding replaces
30+
/// (codex crash-respawn passes the old thread id; everyone else None).
31+
pub supersedes: Option<String>,
32+
pub settings: FreshAgentSettings,
33+
}
34+
35+
/// Write-completion future (see Interfaces block for the style citation:
36+
/// BoxFuture aliases at freshell-opencode/src/serve.rs:44 /
37+
/// freshell-codex/src/app_server.rs:62; no async-trait dep in the workspace).
38+
pub type SinkWrite =
39+
std::pin::Pin<Box<dyn std::future::Future<Output = std::io::Result<()>> + Send + 'static>>;
40+
41+
/// AWAITED writes (wave-A durable-before-answer policy, V8/A11): callers
42+
/// `.await` the returned future before replying/broadcasting/proceeding.
43+
/// Implementations run fsync work on `spawn_blocking` and propagate failures
44+
/// as `Err` — call sites surface them user-visibly, then proceed (a write
45+
/// failure never blocks the identity event). Reads are memory-fast + sync.
46+
pub trait PaneIdentitySink: Send + Sync {
47+
fn record_pending(&self, placeholder_id: &str, mode: &str, cwd: Option<&str>) -> SinkWrite;
48+
fn record_binding(&self, upsert: FreshAgentBindingUpsert) -> SinkWrite;
49+
fn load_settings(&self, provider: &str, session_id: &str) -> Option<FreshAgentSettings>;
50+
/// True iff a fresh-agent binding row was EVER recorded for this key —
51+
/// the SETTINGS_RESET alarm gate (V7/A10): alarm only when the ledger
52+
/// proves prior recording; never for never-recorded sessions.
53+
fn was_recorded(&self, provider: &str, session_id: &str) -> bool;
54+
}
55+
56+
pub type SharedPaneIdentitySink = Arc<dyn PaneIdentitySink>;
57+
58+
/// In-memory sink for tests, crate-wide. Mutations happen synchronously
59+
/// before the (already-completed) future is returned, so tests can assert
60+
/// immediately after `.await`.
61+
#[cfg(test)]
62+
#[derive(Default)]
63+
pub(crate) struct FakeIdentitySink {
64+
pub pendings: std::sync::Mutex<Vec<(String, String, Option<String>)>>,
65+
pub bindings: std::sync::Mutex<Vec<FreshAgentBindingUpsert>>,
66+
pub settings: std::sync::Mutex<std::collections::HashMap<(String, String), FreshAgentSettings>>,
67+
/// Keys ever recorded (or seeded) — backs `was_recorded`.
68+
pub recorded: std::sync::Mutex<std::collections::HashSet<(String, String)>>,
69+
/// When true, write futures resolve to Err — for failure-surfacing tests.
70+
pub fail_writes: std::sync::atomic::AtomicBool,
71+
}
72+
73+
#[cfg(test)]
74+
impl FakeIdentitySink {
75+
#[allow(dead_code)] // used by identity-event tasks (Tasks 4-10 tests)
76+
pub fn seed(&self, provider: &str, session_id: &str, s: FreshAgentSettings) {
77+
self.recorded
78+
.lock()
79+
.unwrap()
80+
.insert((provider.into(), session_id.into()));
81+
self.settings
82+
.lock()
83+
.unwrap()
84+
.insert((provider.into(), session_id.into()), s);
85+
}
86+
/// Mark a key as previously recorded WITHOUT a recoverable snapshot —
87+
/// the SETTINGS_RESET-alarm-positive fixture (V7/A10 gating).
88+
#[allow(dead_code)] // used by identity-event tasks (Tasks 4-10 tests)
89+
pub fn seed_recorded_only(&self, provider: &str, session_id: &str) {
90+
self.recorded
91+
.lock()
92+
.unwrap()
93+
.insert((provider.into(), session_id.into()));
94+
}
95+
fn write_result(&self) -> SinkWrite {
96+
if self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) {
97+
Box::pin(std::future::ready(Err(std::io::Error::other(
98+
"fake write failure",
99+
))))
100+
} else {
101+
Box::pin(std::future::ready(Ok(())))
102+
}
103+
}
104+
}
105+
106+
#[cfg(test)]
107+
impl PaneIdentitySink for FakeIdentitySink {
108+
fn record_pending(&self, placeholder_id: &str, mode: &str, cwd: Option<&str>) -> SinkWrite {
109+
if !self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) {
110+
self.pendings.lock().unwrap().push((
111+
placeholder_id.into(),
112+
mode.into(),
113+
cwd.map(Into::into),
114+
));
115+
}
116+
self.write_result()
117+
}
118+
fn record_binding(&self, upsert: FreshAgentBindingUpsert) -> SinkWrite {
119+
if !self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) {
120+
self.recorded
121+
.lock()
122+
.unwrap()
123+
.insert((upsert.provider.clone(), upsert.session_id.clone()));
124+
self.settings.lock().unwrap().insert(
125+
(upsert.provider.clone(), upsert.session_id.clone()),
126+
upsert.settings.clone(),
127+
);
128+
self.bindings.lock().unwrap().push(upsert);
129+
}
130+
self.write_result()
131+
}
132+
fn load_settings(&self, provider: &str, session_id: &str) -> Option<FreshAgentSettings> {
133+
self.settings
134+
.lock()
135+
.unwrap()
136+
.get(&(provider.into(), session_id.into()))
137+
.cloned()
138+
}
139+
fn was_recorded(&self, provider: &str, session_id: &str) -> bool {
140+
self.recorded
141+
.lock()
142+
.unwrap()
143+
.contains(&(provider.into(), session_id.into()))
144+
}
145+
}
146+
147+
#[cfg(test)]
148+
mod tests {
149+
use super::*;
150+
use std::sync::Arc;
151+
152+
#[tokio::test]
153+
async fn fake_sink_records_and_serves_settings() {
154+
let fake = Arc::new(FakeIdentitySink::default());
155+
fake.record_pending("freshopencode-r1", "freshopencode", Some("/w"))
156+
.await
157+
.expect("pending write ok");
158+
fake.record_binding(FreshAgentBindingUpsert {
159+
provider: "opencode".into(),
160+
session_id: "ses_1".into(),
161+
mode: "freshopencode".into(),
162+
create_request_id: Some("r1".into()),
163+
resolves_pending: Some("freshopencode-r1".into()),
164+
supersedes: None,
165+
settings: FreshAgentSettings {
166+
model: Some("m".into()),
167+
sandbox: None,
168+
permission_mode: None,
169+
effort: Some("low".into()),
170+
cwd: Some("/w".into()),
171+
},
172+
})
173+
.await
174+
.expect("binding write ok");
175+
let s = fake.load_settings("opencode", "ses_1").expect("settings");
176+
assert_eq!(s.model.as_deref(), Some("m"));
177+
assert_eq!(s.effort.as_deref(), Some("low"));
178+
assert_eq!(fake.pendings.lock().unwrap().len(), 1);
179+
assert_eq!(fake.bindings.lock().unwrap().len(), 1);
180+
assert!(fake.load_settings("opencode", "nope").is_none());
181+
assert!(fake.was_recorded("opencode", "ses_1"));
182+
assert!(!fake.was_recorded("opencode", "nope"));
183+
}
184+
185+
#[tokio::test]
186+
async fn fake_sink_failure_knob_returns_err() {
187+
let fake = Arc::new(FakeIdentitySink::default());
188+
fake.fail_writes
189+
.store(true, std::sync::atomic::Ordering::SeqCst);
190+
assert!(
191+
fake.record_pending("p", "freshopencode", None)
192+
.await
193+
.is_err(),
194+
"failure must surface as Err, never be swallowed"
195+
);
196+
}
197+
}

0 commit comments

Comments
 (0)