Skip to content

Commit 4eb6cf1

Browse files
committed
fix(runtime): restore empty Codex and Claude session startup
Materialize new Codex threads through native metadata and verify durable history before publishing a usable session or attaching the native TUI, without submitting a model turn. Allow transcript-free Claude recovery for positively empty jobs with zero output-token usage while retaining strict validation for existing or unknown history. Add shared Actor/Analyst persistence regressions and offline native-terminal cold-resume probes; synchronize lifecycle fixtures and runtime contracts.
1 parent 25075ed commit 4eb6cf1

10 files changed

Lines changed: 412 additions & 9 deletions

File tree

‎crates/cccc-daemon/src/ops/codex_voice_analyst/claude.rs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,11 +233,13 @@ fn known_empty_session(config_dir: &Path, session_id: &str) -> io::Result<bool>
233233
found = true;
234234
// Positive empty-job evidence, not just a missing transcript. Any
235235
// prompt, output, consumed bytes or published path requires history.
236+
// Agent View can initialize its output-token counter to zero; zero
237+
// usage is not evidence of a conversation. Retain nonzero/unknown usage.
236238
if state["intent"].as_str() != Some("")
237239
|| state["linkScanOffset"].as_u64() != Some(0)
238240
|| !state["linkScanPath"].is_null()
239241
|| state.get("output") != Some(&Value::Null)
240-
|| !state["tokens"].is_null()
242+
|| !(state["tokens"].is_null() || state["tokens"].as_f64() == Some(0.0))
241243
{
242244
return Ok(false);
243245
}
@@ -1580,12 +1582,19 @@ mod tests {
15801582
cccc_core::fs::write_json(&job.join("state.json"), &empty).expect("empty state");
15811583
assert!(known_empty_session(temp.path(), id).expect("empty"));
15821584
assert!(!known_empty_session(temp.path(), "other").expect("unknown"));
1585+
let mut counted_empty = empty.clone();
1586+
counted_empty["tokens"] = json!(0);
1587+
cccc_core::fs::write_json(&job.join("state.json"), &counted_empty)
1588+
.expect("initialized empty counter");
1589+
assert!(known_empty_session(temp.path(), id).expect("zero usage is still empty"));
15831590
for (field, value) in [
15841591
("intent", json!("first prompt")),
15851592
("linkScanOffset", json!(42)),
15861593
("linkScanPath", json!("missing-history.jsonl")),
15871594
("output", json!("answer")),
15881595
("tokens", json!(1)),
1596+
("tokens", json!("0")),
1597+
("tokens", json!(-1)),
15891598
] {
15901599
let mut state = empty.clone();
15911600
state[field] = value;

‎crates/cccc-daemon/src/ops/codex_voice_analyst/launch_codex.rs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,38 @@ impl AnalystSession {
128128
"Codex app-server resumed a different thread",
129129
));
130130
}
131+
if !thread_resumed {
132+
// thread/start reserves an id/path but does not persist an empty
133+
// rollout. Native TUI resume needs that rollout, even on the same
134+
// app-server. Naming the new thread materializes it without a model
135+
// turn or synthetic conversation item; never rename resumed history.
136+
let name = match purpose {
137+
SessionPurpose::Actor => "CCCC Actor",
138+
SessionPurpose::VoiceAnalyst => "CCCC Voice Analyst",
139+
};
140+
protocol
141+
.request(
142+
"thread/name/set",
143+
json!({"threadId":thread_id,"name":name}),
144+
Duration::from_secs(20),
145+
)
146+
.await?;
147+
let persisted = protocol
148+
.request(
149+
"thread/read",
150+
json!({"threadId":thread_id,"includeTurns":true}),
151+
Duration::from_secs(20),
152+
)
153+
.await?;
154+
if persisted["thread"]["id"].as_str() != Some(thread_id.as_str())
155+
|| !persisted["thread"]["turns"].is_array()
156+
{
157+
return Err(io::Error::new(
158+
io::ErrorKind::InvalidData,
159+
"Codex did not expose the new thread's durable history for terminal resume",
160+
));
161+
}
162+
}
131163
Ok(Self {
132164
#[cfg(test)]
133165
binding,

‎crates/cccc-daemon/src/ops/codex_voice_analyst/tests/live_claude.rs‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,21 @@ async fn live_claude_empty_session_resumes_without_a_prompt() {
199199
let config_dir = temp.path().join("claude");
200200
configure_isolated_claude(&config_dir);
201201
let mut thread_id = None;
202+
let group_id = uuid::Uuid::new_v4().simple().to_string();
202203
for _ in 0..3 {
203204
let mut config = LaunchConfig::new(&root);
204205
config.runtime = ActorRuntime::Claude;
205206
config.environment.insert(
206207
"CLAUDE_CONFIG_DIR".into(),
207208
config_dir.to_string_lossy().into_owned(),
208209
);
210+
config.environment.extend([
211+
("ANTHROPIC_BASE_URL".into(), "http://127.0.0.1:9".into()),
212+
(
213+
"ANTHROPIC_AUTH_TOKEN".into(),
214+
"cccc-local-input-test".into(),
215+
),
216+
]);
209217
config.resume_thread_id = thread_id.clone();
210218
let session = AnalystSession::launch(&home, config)
211219
.await
@@ -214,10 +222,53 @@ async fn live_claude_empty_session_resumes_without_a_prompt() {
214222
assert_eq!(session.thread_id(), expected);
215223
}
216224
thread_id = Some(session.thread_id().to_owned());
225+
let attached = cccc_runtime::start(cccc_runtime::LaunchSpec {
226+
group_id: group_id.clone(),
227+
actor_id: "empty-claude".into(),
228+
runner: cccc_contracts::RunnerKind::Pty,
229+
command: session.actor_tui_command(),
230+
cwd: root.clone(),
231+
env: session.tui_environment(),
232+
cols: 120,
233+
rows: 40,
234+
});
235+
let ready = if attached.is_ok() {
236+
tokio::task::block_in_place(|| {
237+
cccc_runtime::wait_for_input_ready(
238+
&group_id,
239+
"empty-claude",
240+
Duration::from_secs(10),
241+
&std::sync::atomic::AtomicBool::new(false),
242+
)
243+
})
244+
} else {
245+
Ok(false)
246+
};
217247
session
218248
.stop(session.generation())
219249
.await
220250
.expect("stop empty session");
251+
cccc_runtime::stop(&group_id, "empty-claude").expect("stop empty terminal");
252+
attached.expect("attach empty native terminal");
253+
assert!(ready.expect("terminal readiness"));
254+
// The real worker need not publish its zero counter before stopping.
255+
// Model that valid Agent View refresh in this isolated, stopped job so
256+
// every cold resume exercises tokens=0, not just provisional null.
257+
for job in std::fs::read_dir(config_dir.join("jobs")).expect("jobs") {
258+
let job = job.expect("job");
259+
if !job.file_type().expect("type").is_dir() {
260+
continue;
261+
}
262+
let path = job.path().join("state.json");
263+
let mut state: serde_json::Value = cccc_core::fs::read_json(&path).expect("state");
264+
if state["sessionId"].as_str() == Some(session.thread_id()) {
265+
assert!(state["tokens"].is_null() || state["tokens"] == 0);
266+
assert!(state["linkScanPath"].is_null());
267+
assert_eq!(state["intent"], "");
268+
state["tokens"] = serde_json::json!(0);
269+
cccc_core::fs::write_json(&path, &state).expect("zero-usage metadata");
270+
}
271+
}
221272
}
222273
}
223274

‎crates/cccc-daemon/src/ops/codex_voice_analyst/tests/live_session.rs‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,174 @@ use super::live_support::{
44
};
55
use cccc_core::HomeLayout;
66

7+
/// Offline real-CLI probe: no credentials, model request, or synthetic prompt.
8+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
9+
async fn live_codex_empty_actor_and_analyst_resume_with_native_terminal() {
10+
use futures_util::FutureExt as _;
11+
use serde_json::json;
12+
use std::time::Duration;
13+
14+
if std::env::var("CCCC_CODEX_EMPTY_LIVE").as_deref() != Ok("1") {
15+
return;
16+
}
17+
let temp = tempfile::tempdir().expect("tempdir");
18+
let home = HomeLayout::from_path(temp.path().join("home")).expect("home");
19+
home.initialize().expect("initialize");
20+
let root = temp.path().join("project");
21+
let codex_home = temp.path().join("codex");
22+
std::fs::create_dir_all(&root).expect("project");
23+
std::fs::create_dir_all(&codex_home).expect("Codex home");
24+
let group = cccc_core::GroupStore::new(home.clone())
25+
.expect("store")
26+
.create("Empty Codex startup probe", "")
27+
.expect("group");
28+
let environment = BTreeMap::from([
29+
(
30+
"CODEX_HOME".into(),
31+
codex_home.to_string_lossy().into_owned(),
32+
),
33+
("TERM".into(), "xterm-256color".into()),
34+
]);
35+
let command = vec![
36+
std::env::var("CCCC_CODEX_EXECUTABLE").unwrap_or_else(|_| "codex".into()),
37+
"-c".into(),
38+
"model_provider=\"offline-probe\"".into(),
39+
"-c".into(),
40+
"model_providers.offline-probe.name=\"Offline probe\"".into(),
41+
"-c".into(),
42+
"model_providers.offline-probe.base_url=\"http://127.0.0.1:9\"".into(),
43+
"-c".into(),
44+
"model_providers.offline-probe.wire_api=\"responses\"".into(),
45+
];
46+
for purpose in [SessionPurpose::Actor, SessionPurpose::VoiceAnalyst] {
47+
let mut thread_id = None;
48+
for cycle in 0..3 {
49+
let session = match purpose {
50+
SessionPurpose::Actor => {
51+
AnalystSession::launch_actor(
52+
&home,
53+
ActorLaunchConfig {
54+
workdir: root.clone(),
55+
group_id: group.group_id.clone(),
56+
actor_id: "empty-codex".into(),
57+
runtime: ActorRuntime::Codex,
58+
command: command.clone(),
59+
environment: environment.clone(),
60+
},
61+
)
62+
.await
63+
}
64+
SessionPurpose::VoiceAnalyst => {
65+
AnalystSession::launch(
66+
&home,
67+
LaunchConfig {
68+
workdir: root.clone(),
69+
runtime: ActorRuntime::Codex,
70+
command: command.clone(),
71+
environment: environment.clone(),
72+
resume_thread_id: thread_id.clone(),
73+
},
74+
)
75+
.await
76+
}
77+
}
78+
.expect("launch empty managed session");
79+
let observed = std::panic::AssertUnwindSafe(async {
80+
if let Some(expected) = &thread_id {
81+
assert_eq!(session.thread_id(), expected);
82+
assert!(session.thread_resumed);
83+
}
84+
let ManagedProtocol::Codex(protocol) = &session.protocol else {
85+
unreachable!()
86+
};
87+
let read = protocol
88+
.request(
89+
"thread/read",
90+
json!({"threadId":session.thread_id(),"includeTurns":true}),
91+
Duration::from_secs(5),
92+
)
93+
.await?;
94+
assert_eq!(read["thread"]["id"], session.thread_id());
95+
assert_eq!(
96+
read["thread"]["turns"],
97+
json!([]),
98+
"startup must not run a model"
99+
);
100+
assert!(
101+
std::path::Path::new(read["thread"]["path"].as_str().expect("rollout path"))
102+
.is_file()
103+
);
104+
if cycle == 0 {
105+
protocol
106+
.request(
107+
"thread/name/set",
108+
json!({"threadId":session.thread_id(),"name":"User chosen name"}),
109+
Duration::from_secs(5),
110+
)
111+
.await?;
112+
} else {
113+
assert_eq!(
114+
read["thread"]["name"], "User chosen name",
115+
"resume must not rename history"
116+
);
117+
}
118+
cccc_runtime::start(cccc_runtime::LaunchSpec {
119+
group_id: group.group_id.clone(),
120+
actor_id: "empty-codex".into(),
121+
runner: cccc_contracts::RunnerKind::Pty,
122+
command: session.actor_tui_command(),
123+
cwd: root.clone(),
124+
env: session.tui_environment(),
125+
cols: 120,
126+
rows: 40,
127+
})
128+
.map_err(io::Error::other)?;
129+
let ready = tokio::task::block_in_place(|| {
130+
cccc_runtime::wait_for_input_ready(
131+
&group.group_id,
132+
"empty-codex",
133+
Duration::from_secs(10),
134+
&std::sync::atomic::AtomicBool::new(false),
135+
)
136+
})
137+
.map_err(io::Error::other)?;
138+
assert!(ready, "native terminal did not initialize");
139+
tokio::time::sleep(Duration::from_secs(1)).await;
140+
let output = cccc_runtime::retained_history(&group.group_id, "empty-codex")
141+
.map_err(io::Error::other)?
142+
.data;
143+
assert!(!output.contains("Failed to resume session"), "{output}");
144+
assert!(!output.contains("no rollout found"), "{output}");
145+
assert!(
146+
cccc_runtime::status(&group.group_id, "empty-codex")
147+
.map_err(io::Error::other)?
148+
.running
149+
);
150+
let read = protocol
151+
.request(
152+
"thread/read",
153+
json!({"threadId":session.thread_id(),"includeTurns":true}),
154+
Duration::from_secs(5),
155+
)
156+
.await?;
157+
assert_eq!(read["thread"]["turns"], json!([]));
158+
Ok::<(), io::Error>(())
159+
})
160+
.catch_unwind()
161+
.await;
162+
thread_id = Some(session.thread_id().to_owned());
163+
let _ = cccc_runtime::stop(&group.group_id, "empty-codex");
164+
session
165+
.stop(session.generation())
166+
.await
167+
.expect("stop empty managed session");
168+
observed
169+
.expect("startup probe assertion")
170+
.expect("durable empty session and native attach");
171+
}
172+
}
173+
}
174+
7175
fn live_launch_config(root: &std::path::Path) -> LaunchConfig {
8176
let mut config = LaunchConfig::new(root);
9177
let model = std::env::var("CCCC_VOICE_ANALYST_MODEL").unwrap_or_else(|_| "gpt-5.6-sol".into());

0 commit comments

Comments
 (0)