Skip to content
Draft
1 change: 1 addition & 0 deletions src/commands/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ pub fn run(argv: &[String], flags: &GlobalFlags) -> Result<i32> {
args: merged_args,
persisted_args: None,
prior_session_id: None,
retained_principal: None,
tag,
system_prompt,
initial_prompt,
Expand Down
69 changes: 66 additions & 3 deletions src/commands/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ pub struct ListArgs {
pub name: Option<String>,
/// Field to extract (used with name)
pub field: Option<String>,
/// Look up the exact instance recorded for a durable principal ID
#[arg(long, value_name = "ID", conflicts_with = "name")]
pub principal: Option<String>,
/// Show recently stopped agents
#[arg(long)]
pub stopped: bool,
Expand Down Expand Up @@ -99,7 +102,8 @@ pub fn cmd_list(db: &HcomDb, args: &ListArgs, ctx: Option<&CommandContext>) -> i
let names_output = args.names;
let sh_output = args.sh;
let format_template = args.format.clone();
let target_name = args.name.as_deref();
let principal_target = args.principal.as_deref();
let target_name = principal_target.or(args.name.as_deref());
let field_name = args.field.as_deref();

// Resolve current instance identity
Expand Down Expand Up @@ -134,11 +138,67 @@ pub fn cmd_list(db: &HcomDb, args: &ListArgs, ctx: Option<&CommandContext>) -> i
return 1;
}

let mut queried_principal = None;
let lookup_name = if is_self {
current_name.clone().unwrap_or_default()
} else {
let resolved = resolve_display_name(db, target);
resolved.unwrap_or_else(|| target.to_string())
let resolved = principal_target
.is_none()
.then(|| resolve_display_name(db, target))
.flatten();
if let Some(name) = resolved {
name
} else {
match db.lookup_principal(target) {
Ok(crate::db::PrincipalLookup::Resolved { instance_name, .. }) => {
queried_principal = Some(target.to_string());
instance_name
}
Ok(crate::db::PrincipalLookup::Unresolved { instance_name }) => {
let payload = serde_json::json!({
"name": instance_name,
"principal": target,
"session_id": null,
"status": "unresolved",
});
if json_output {
println!("{}", serde_json::to_string(&payload).unwrap_or_default());
return 0;
}
eprintln!(
"Principal {target} is unresolved (recorded instance: {instance_name})"
);
return 1;
}
Ok(crate::db::PrincipalLookup::MissingBinding { claiming_instances }) => {
let payload = serde_json::json!({
"name": null,
"principal": target,
"session_id": null,
"status": "unresolved",
"reason": "missing_binding",
"claiming_instances": claiming_instances,
});
if json_output {
println!("{}", serde_json::to_string(&payload).unwrap_or_default());
return 0;
}
eprintln!(
"Principal {target} is unresolved (binding missing; instance claims are diagnostic only)"
);
return 1;
}
Ok(crate::db::PrincipalLookup::Unknown) if principal_target.is_some() => {
eprintln!("Error: unknown principal: {target}");
return 1;
}
Ok(crate::db::PrincipalLookup::Unknown) => target.to_string(),
Err(error) => {
eprintln!("Error: principal lookup failed: {error}");
return 1;
}
}
}
};

if lookup_name.is_empty() {
Expand All @@ -150,6 +210,7 @@ pub fn cmd_list(db: &HcomDb, args: &ListArgs, ctx: Option<&CommandContext>) -> i
Ok(Some(data)) => {
let mut payload = serde_json::json!({
"name": lookup_name,
"principal": queried_principal.as_deref().or(data.principal.as_deref()),
"session_id": data.session_id,
"status": data.status,
"directory": data.directory,
Expand Down Expand Up @@ -181,6 +242,7 @@ pub fn cmd_list(db: &HcomDb, args: &ListArgs, ctx: Option<&CommandContext>) -> i
if is_self {
let payload = serde_json::json!({
"name": lookup_name,
"principal": null,
"session_id": sender_identity.as_ref().and_then(|id| id.session_id.as_deref()).unwrap_or(""),
});
if let Some(field) = field_name {
Expand Down Expand Up @@ -250,6 +312,7 @@ pub fn cmd_list(db: &HcomDb, args: &ListArgs, ctx: Option<&CommandContext>) -> i
"unread_count": unread_counts.get(&data.name).copied().unwrap_or(0),
"headless": data.background != 0,
"session_id": data.session_id.as_deref().unwrap_or(""),
"principal": data.principal,
"directory": data.directory,
"parent_name": data.parent_name,
"agent_id": data.agent_id,
Expand Down
87 changes: 87 additions & 0 deletions src/commands/resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,19 @@ fn prepare_resume_plan_from_source(
} else {
Some(display_name.clone())
};
let retained_principal = if !fork && !is_adoption {
Some(
db.principal_for_tracked_resume(&display_name)
.map_err(|error| {
anyhow::anyhow!(
"cannot retain tracked resume identity for '{}': {error}",
display_name
)
})?,
)
} else {
None
};
let tracked_fork_identity = if fork && !is_adoption {
Some(TrackedForkIdentity {
parent_name: display_name.clone(),
Expand Down Expand Up @@ -541,6 +554,7 @@ fn prepare_resume_plan_from_source(
// Forks start a new session on first turn; only plain resume
// inherits the prior id so kill-before-bind stays resumable.
prior_session_id: (!fork).then(|| session_id.clone()),
retained_principal,
tag: launch_tag,
system_prompt: effective_system_prompt,
initial_prompt: fork_initial_prompt,
Expand Down Expand Up @@ -2831,6 +2845,7 @@ mod tests {
data.insert("status".into(), json!(ST_INACTIVE));
data.insert("created_at".into(), json!(1.0));
db.save_instance_named("zeno", &data).unwrap();
db.create_principal_binding("p-zeno", "zeno").unwrap();

// Emit a stopped life event so load_stopped_snapshot can find the snapshot
let snapshot = serde_json::json!({
Expand Down Expand Up @@ -3125,6 +3140,71 @@ mod tests {
);
}

#[test]
fn tracked_fork_route_mints_a_principal_distinct_from_parent() {
let db = test_db();
let mut data = serde_json::Map::new();
data.insert("session_id".into(), json!("session-123"));
data.insert("tool".into(), json!("codex"));
data.insert("status".into(), json!("listening"));
data.insert("created_at".into(), json!(1.0));
db.save_instance_named("luna", &data).unwrap();
db.create_principal_binding("p-parent", "luna").unwrap();

let plan = prepare_resume_plan(&db, "luna", true, &[], &GlobalFlags::default()).unwrap();
let launch = prepare_launch_for_execution(&db, &plan).unwrap();
let child_name = launch.name.expect("tracked fork reserved child name");
let mut child_env = std::collections::HashMap::new();
let child_principal =
crate::launcher::attach_launch_principal(&db, &child_name, &mut child_env).unwrap();

assert_ne!(child_principal, "p-parent");
assert_eq!(
db.principal_for_instance(&child_name).unwrap().as_deref(),
Some(child_principal.as_str())
);
}

#[test]
fn adoption_route_mints_a_new_principal_for_each_lifecycle() {
let db = test_db();
let source = || ResumeSource::Disk {
session_id: "019f6550-1111-7222-8333-123456789abc".to_string(),
tool: "codex".to_string(),
cwd_hint: Some("/tmp".to_string()),
};

let first_plan =
prepare_resume_plan_from_source(&db, source(), false, &[], &GlobalFlags::default())
.unwrap();
assert!(
first_plan.launch.name.is_none(),
"adoption allocates at launch"
);
let first_name = crate::instance_names::generate_unique_name(&db).unwrap();
let first = crate::launcher::attach_launch_principal(
&db,
&first_name,
&mut std::collections::HashMap::new(),
)
.unwrap();

let second_plan =
prepare_resume_plan_from_source(&db, source(), false, &[], &GlobalFlags::default())
.unwrap();
assert!(second_plan.launch.name.is_none());
let second_name = crate::instance_names::generate_unique_name(&db).unwrap();
let second = crate::launcher::attach_launch_principal(
&db,
&second_name,
&mut std::collections::HashMap::new(),
)
.unwrap();

assert_ne!(first_name, second_name);
assert_ne!(first, second);
}

#[test]
fn test_resume_inherits_prior_session_id_fork_does_not() {
let db = test_db();
Expand All @@ -3134,6 +3214,7 @@ mod tests {
data.insert("status".into(), json!(ST_INACTIVE));
data.insert("created_at".into(), json!(1.0));
db.save_instance_named("luna", &data).unwrap();
db.create_principal_binding("p-luna", "luna").unwrap();

// Inactive rows resolve via the stopped-snapshot life event.
let snapshot = serde_json::json!({
Expand All @@ -3156,6 +3237,11 @@ mod tests {
.unwrap();

let resume = prepare_resume_plan(&db, "luna", false, &[], &GlobalFlags::default()).unwrap();
assert_eq!(
resume.launch.retained_principal.as_deref(),
Some("p-luna"),
"tracked resume must carry the prior lifecycle principal into launch"
);
assert_eq!(
resume.launch.prior_session_id.as_deref(),
Some("session-123"),
Expand All @@ -3164,6 +3250,7 @@ mod tests {
);

let fork = prepare_resume_plan(&db, "luna", true, &[], &GlobalFlags::default()).unwrap();
assert_eq!(fork.launch.retained_principal, None);
assert_eq!(
fork.launch.prior_session_id, None,
"forks bind a fresh session on first turn; must not inherit the parent's"
Expand Down
Loading
Loading