Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
65 changes: 65 additions & 0 deletions src/commands/resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3125,6 +3125,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 Down
130 changes: 122 additions & 8 deletions src/commands/start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,8 @@ fn start_subagent(db: &HcomDb, info: &SubagentInfo) -> Result<i32> {
)
.ok();

let (subagent_name, was_announced) = match existing {
Some((name, announced)) => (name, announced != 0),
let (subagent_name, was_announced, created_fallback) = match existing {
Some((name, announced)) => (name, announced != 0, false),
None => {
let alloc = instance_names::SubagentAllocation {
agent_id: &info.agent_id,
Expand All @@ -254,10 +254,17 @@ fn start_subagent(db: &HcomDb, info: &SubagentInfo) -> Result<i32> {
status_context: Some("tool:start"),
};
let name = instance_names::allocate_subagent_instance(db, &alloc)?;
(name, false)
(name, false, true)
}
};

if let Err(error) = crate::hooks::claude::ensure_subagent_principal(db, &subagent_name) {
if created_fallback {
let _ = db.delete_instance(&subagent_name);
}
return Err(error);
}

// Flip to active + emit life event so TUI/watchers see the state change.
lifecycle::set_status(
db,
Expand Down Expand Up @@ -362,7 +369,10 @@ fn start_from_orphan(
};

// Core DB registration
let _ = pidtrack::recover_single_orphan_to_db(db, orphan, &name);
pidtrack::recover_single_orphan_to_db(db, orphan, &name).map_err(anyhow::Error::msg)?;
let principal = db
.principal_for_instance(&name)?
.ok_or_else(|| anyhow::anyhow!("recovered orphan '{name}' has no principal"))?;

db.log_event(
"life",
Expand All @@ -372,6 +382,7 @@ fn start_from_orphan(
"by": "cli",
"reason": "orphan_recover",
"orphan_pid": pid,
"principal": principal,
}),
)
.ok();
Expand Down Expand Up @@ -481,7 +492,7 @@ fn start_rebind(
// Create fresh instance with the target name
let tool = ctx.tool.as_str();
let cwd_override = ctx.cwd.to_string_lossy().to_string();
instance_binding::initialize_instance_in_position_file(
if !instance_binding::initialize_instance_in_position_file(
db,
&target_name,
session_id.as_deref(),
Expand All @@ -496,7 +507,16 @@ fn start_rebind(
None, // subagent_timeout
None, // hints
Some(&cwd_override),
);
) {
bail!("failed to initialize reclaimed instance '{target_name}'");
}

let principal = crate::launcher::generate_principal_id();
if let Err(error) = db.create_principal_binding(&principal, &target_name) {
eprintln!("[hcom] warn: principal setup failed for {target_name}: {error}");
let _ = db.delete_instance(&target_name);
return Err(error);
}

// Restore cursor position + mark as announced
{
Expand Down Expand Up @@ -652,6 +672,16 @@ fn load_rebind_target_metadata(db: &HcomDb, name: &str) -> Result<RebindTargetMe
bail!("No rebind metadata found for '{}'", name)
}

fn reusable_launch_principal(ctx: &HcomContext) -> Option<String> {
if !ctx.is_launched
|| ctx.process_id.as_deref().is_none_or(str::is_empty)
|| ctx.launch_batch_id.as_deref().is_none_or(str::is_empty)
{
return None;
}
ctx.principal_id.clone().filter(|value| !value.is_empty())
}

/// Path C: Bare start — detect tool or create adhoc instance.
fn start_bare(
db: &HcomDb,
Expand Down Expand Up @@ -735,7 +765,7 @@ fn start_bare(
return Ok(0);
}

instance_binding::initialize_instance_in_position_file(
if !instance_binding::initialize_instance_in_position_file(
db,
&name,
None, // session_id
Expand All @@ -750,7 +780,17 @@ fn start_bare(
None, // subagent_timeout
None, // hints
None, // cwd_override
);
) {
bail!("failed to initialize instance '{name}'");
}

let principal =
reusable_launch_principal(ctx).unwrap_or_else(crate::launcher::generate_principal_id);
if let Err(error) = db.create_principal_binding(&principal, &name) {
eprintln!("[hcom] warn: principal setup failed for {name}: {error}");
let _ = db.delete_instance(&name);
return Err(error);
}

// Bind process if we have a process_id
if let Some(ref process_id) = ctx.process_id
Expand Down Expand Up @@ -791,6 +831,7 @@ fn start_bare(
"action": "started",
"tool": tool,
"name": name,
"principal": principal,
}),
)
.ok();
Expand Down Expand Up @@ -965,6 +1006,79 @@ mod tests {
"/tmp/dasha-code/.worktrees/layer1-basic-conversation-fixes"
);
assert_eq!(inst.last_event_id, 77);
assert!(
inst.principal
.as_ref()
.is_some_and(|value| !value.is_empty())
);
}

#[test]
#[serial]
fn name_reclaim_route_mints_a_principal_distinct_from_prior_lifecycle() {
let (_dir, _hcom_dir, _home, _guard) = crate::hooks::test_helpers::isolated_test_env();
let db = HcomDb::open().unwrap();
db.conn()
.execute(
"INSERT INTO instances (name, tool, directory, created_at)
VALUES ('nova', 'claude', '/tmp/reclaim', 1.0)",
[],
)
.unwrap();
db.create_principal_binding("p-prior", "nova").unwrap();
db.delete_instance("nova").unwrap();
log_stopped_snapshot(&db, "nova", "claude", "/tmp/reclaim", "sid-nova", 77);
let ctx = make_ctx(&[("CLAUDECODE", "1")], "/tmp/reclaim");

assert_eq!(start_rebind(&db, "nova", &ctx, None).unwrap(), 0);
let current = db
.principal_for_instance("nova")
.unwrap()
.expect("reclaimed principal");
assert_ne!(current, "p-prior");
assert!(db.lookup_principal("p-prior").unwrap().is_unresolved());
}

#[test]
#[serial]
fn bare_start_reuses_only_complete_launch_envelope_principal() {
let (_dir, _hcom_dir, _home, _guard) = crate::hooks::test_helpers::isolated_test_env();
let db = HcomDb::open().unwrap();
let launched = make_ctx(
&[
("HCOM_LAUNCHED", "1"),
("HCOM_PROCESS_ID", "proc-1"),
("HCOM_LAUNCH_BATCH_ID", "batch-1"),
("HCOM_PRINCIPAL_ID", "p-from-launch"),
],
"/tmp",
);
assert_eq!(
start_bare(&db, &paths::hcom_dir(), &launched, Some("mira")).unwrap(),
0
);
assert_eq!(
db.principal_for_instance("mira").unwrap().as_deref(),
Some("p-from-launch")
);

let incomplete = make_ctx(
&[
("HCOM_LAUNCHED", "1"),
("HCOM_PROCESS_ID", "proc-2"),
("HCOM_LAUNCH_BATCH_ID", ""),
("HCOM_PRINCIPAL_ID", "p-spoofed"),
],
"/tmp",
);
assert_eq!(
start_bare(&db, &paths::hcom_dir(), &incomplete, Some("kira")).unwrap(),
0
);
assert_ne!(
db.principal_for_instance("kira").unwrap().as_deref(),
Some("p-spoofed")
);
}

#[test]
Expand Down
Loading
Loading