1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
// SPDX-License-Identifier: Apache-2.0
//! `heddle try -- <cmd>` — atomic-ephemeral-thread sugar.
//!
//! Implements item 3.1 of the heddle 6→8 plan: spin up an ephemeral
//! thread with an isolated checkout, run `<cmd>` inside that checkout,
//! and either land a captured state on success or roll everything back
//! on failure. The user's parent-thread working tree is the
//! load-bearing invariant — it MUST end in exactly the state it
//! started, regardless of whether `<cmd>` succeeded or failed.
//!
//! ### Working-tree invariant — how it's enforced
//!
//! 1. We never touch the parent's working tree. The cmd runs with
//! `Command::current_dir(&thread.execution_path)`; the child
//! inherits a cwd inside the ephemeral thread's isolated checkout
//! and any writes land there.
//! 2. Capture happens against the *thread's* repo (opened from
//! `thread.execution_path`), not the parent. The parent's HEAD
//! only advances when `--auto-merge` is set and the merge
//! succeeds.
//! 3. Failure path: drop the ephemeral thread (best-effort), then
//! surface the original cmd's exit code via `process::exit`. We
//! never return after spawning the cmd unless we either captured
//! cleanly or the failure was funneled through the drop step.
//!
//! ### File naming
//!
//! `try.rs` is a Rust keyword and would force every reference to use
//! `r#try`. The module is named `try_cmd.rs` and exposed as `cmd_try`
//! for symmetry with the rest of the CLI. The user-visible verb is
//! still `heddle try`.
use std::{
path::PathBuf,
process::{Command, Stdio},
time::Instant,
};
use anyhow::{Result, anyhow};
use repo::{Repository, ThreadManager, shell_quote};
use serde::Serialize;
use super::{
action_line::print_next,
advice::RecoveryAdvice,
command_catalog::{ActionFields, ActionTemplate},
git_overlay_health::action_templates,
merge::merge_thread_into_current,
snapshot::{SnapshotAgentOverrides, create_snapshot},
thread::start_thread,
thread_cmd::{DropOutcome, drop_thread_silent},
};
use crate::{
cli::{Cli, ThreadStartArgs, TryArgs, WorkspaceModeArg, should_output_json, style},
config::UserConfig,
};
/// What `heddle try` returns to JSON consumers. Mirrors
/// `OperatorCommandOutput` shape (status / action / message) plus the
/// fields specific to a try (the ephemeral thread, the cmd's exit,
/// the captured state if any).
#[derive(Debug, Serialize)]
struct TryOutput {
/// `"completed"` on a clean success path (zero exit + capture
/// landed). `"failed"` when the user's command exited non-zero.
status: &'static str,
action: &'static str,
message: String,
/// The ephemeral thread Heddle created.
thread: String,
/// `true` when the thread was dropped at the end (either because
/// the cmd failed, or because `--auto-merge` consumed it).
thread_dropped: bool,
/// When cleanup of the ephemeral thread fails (lock contention,
/// filesystem error, etc.) on a path where we tried to drop it,
/// this carries the error message so automation can detect the
/// orphan instead of relying on `thread_dropped` alone. `None` when
/// no cleanup was attempted, or when cleanup succeeded.
#[serde(skip_serializing_if = "Option::is_none")]
cleanup_error: Option<String>,
/// The exit code observed from `<cmd>`. `None` when the process
/// was killed by a signal.
exit_code: Option<i32>,
/// Wall-clock duration of `<cmd>` in milliseconds.
duration_ms: u128,
/// The captured state on the ephemeral thread, when one landed.
/// `None` on the failure path or when capture itself failed.
#[serde(skip_serializing_if = "Option::is_none")]
captured_state: Option<String>,
/// When `--auto-merge` is set and the merge ran, this holds the
/// merge state's change-id on the parent thread. Pulled from the
/// merge command's structured output.
#[serde(skip_serializing_if = "Option::is_none")]
merge_state: Option<String>,
/// Hint surfaced to the user when `--auto-merge` is *not* set:
/// the exact merge preview command they should run. Always
/// printed in non-JSON mode; included for JSON consumers so the
/// agent doesn't have to reconstruct the verb.
#[serde(skip_serializing_if = "Option::is_none")]
next_action: Option<String>,
next_action_template: Option<ActionTemplate>,
/// Same primary command as `next_action`, under the cross-command
/// verification/action field name agents already inspect.
#[serde(skip_serializing_if = "Option::is_none")]
recommended_action: Option<String>,
recommended_action_template: Option<ActionTemplate>,
/// Secondary safe commands. For a successful non-auto-merge try,
/// the primary action lands the thread and this command discards
/// it. Keeping them separate makes every emitted action parseable.
recovery_commands: Vec<String>,
recovery_action_templates: Vec<ActionTemplate>,
}
pub fn cmd_try(cli: &Cli, args: TryArgs) -> Result<()> {
if args.command.is_empty() {
return Err(anyhow!(RecoveryAdvice::invalid_usage(
"try_command_required",
"Usage: heddle try -- <cmd...>",
"Pass a command after `--` so Heddle can run it inside an ephemeral thread.",
"heddle try -- <cmd...>",
)));
}
let repo_root_arg = cli
.repo
.as_ref()
.cloned()
.unwrap_or(std::env::current_dir()?);
let repo = Repository::open(&repo_root_arg)?;
// Snapshot the parent's HEAD up front. The parent's HEAD must not
// move unless `--auto-merge` advances it explicitly. We compare
// against this at the end as the invariant check (the parent's
// working tree is also untouched because we never `cd` into it).
let parent_head_before = repo.head()?.map(|id| id.to_string_full());
let thread_name = args
.name
.clone()
.unwrap_or_else(|| default_try_name(&args.command));
// Reject collisions with an existing thread up front. Without
// this guard, `start_thread` is create-or-resume: a `heddle try
// --name <existing>` would attach to the user's real thread, and
// the failure-path `drop_thread_silent` later in this function
// would then abandon it. That's a footgun even when the run
// succeeds (we'd silently mutate the existing thread's state).
// Only the user-supplied `--name` path needs the check —
// auto-generated names embed a uuid and won't collide.
if args.name.is_some() && thread_name_in_use(&repo, &thread_name)? {
return Err(anyhow!(try_thread_name_collision_advice(&thread_name)));
}
// Use start_thread directly so the ephemeral thread is registered
// exactly the same way `heddle start` does. `auto` resolves to a
// materialized checkout here: virtualized mounts are awkward to
// execute commands inside, and a real checkout is what the cmd
// expects.
let workspace = match args.workspace {
WorkspaceModeArg::Auto => WorkspaceModeArg::Materialized,
other => other,
};
let start_args = ThreadStartArgs {
name: thread_name.clone(),
from: None,
path: None,
workspace,
agent_provider: None,
agent_model: None,
task: Some(format!("try: {}", display_cmd(&args.command))),
parent_thread: None,
automated: true,
print_cd_path: false,
daemon: true,
no_daemon: false,
shared_target: false,
hydrate: false,
};
let start_output = start_thread(&repo, start_args)?;
let thread_path = start_output
.execution_path
.as_ref()
.map(PathBuf::from)
.ok_or_else(|| anyhow!("Could not determine ephemeral thread checkout path"))?;
// Run the cmd inside the thread's checkout.
let started = Instant::now();
let status = Command::new(&args.command[0])
.args(&args.command[1..])
.current_dir(&thread_path)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status();
let duration_ms = started.elapsed().as_millis();
let exit = match status {
Ok(status) => status,
Err(err) => {
// The cmd never even started (program-not-found, etc.).
// This is not a "the user's code is broken" failure — it's
// an operator error. Drop the thread and surface a real
// error so the caller can fix the invocation.
let _ = drop_thread_silent(&repo, &thread_name, true, true);
return Err(anyhow!(
"Failed to execute `{}`: {}",
display_cmd(&args.command),
err
));
}
};
let exit_code = exit.code();
if !exit.success() {
// Failure path. Drop the thread and exit with the cmd's code.
// We deliberately use a best-effort drop here: if the drop
// fails (e.g. lock contention), we still surface the cmd's
// failure rather than masking it with a teardown error — but
// we report the cleanup failure honestly in the JSON shape
// and as a stderr warning, so automation isn't fooled into
// thinking the orphan ephemeral thread was cleaned up.
let drop_result = drop_thread_silent(&repo, &thread_name, true, true);
let (thread_dropped, cleanup_error) =
interpret_drop_result(&thread_name, drop_result, "try cleanup");
// Verify the parent's HEAD didn't drift. If it did, that's a
// real bug in this code path; we surface it loudly rather
// than hiding behind the cmd's exit code.
verify_parent_unchanged(&repo, parent_head_before.as_deref())?;
let drop_msg = if thread_dropped {
format!("thread '{thread_name}' dropped")
} else {
format!("thread '{thread_name}' NOT dropped (cleanup failed)")
};
let recovery_commands = if thread_dropped {
Vec::new()
} else {
vec![format!("heddle thread drop {thread_name}")]
};
let output = TryOutput {
status: "failed",
action: "try",
message: format!(
"`{}` failed (exit {}); {}",
display_cmd(&args.command),
exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into()),
drop_msg
),
thread: thread_name.clone(),
thread_dropped,
cleanup_error,
exit_code,
duration_ms,
captured_state: None,
merge_state: None,
next_action: None,
next_action_template: None,
recommended_action: None,
recommended_action_template: None,
recovery_action_templates: action_templates(&recovery_commands),
recovery_commands,
};
emit(cli, &repo, &output)?;
// Exit with the cmd's exit code — this is the contract: try
// passes through the failure mode of the wrapped program.
std::process::exit(exit_code.unwrap_or(1));
}
// Success path. Capture the thread's state.
//
// We open the *thread's* repo (rather than the parent's) so the
// capture lands on the thread's HEAD. The thread is a full
// Heddle-managed checkout; opening it gives us a Repository
// pointing at the same store but anchored on the thread's HEAD.
let thread_repo = Repository::open(&thread_path)?;
let user_config = UserConfig::load_default().unwrap_or_default();
let intent = format!("try: {}", display_cmd(&args.command));
// Confidence picks up a small bump when --auto-merge is set —
// the user is implicitly stating "I'm comfortable letting this
// land". Without --auto-merge we stay conservative at 0.85.
let confidence = if args.auto_merge { 0.9 } else { 0.85 };
let snapshot = create_snapshot(
&thread_repo,
&user_config,
Some(intent),
Some(confidence),
SnapshotAgentOverrides {
provider: None,
model: None,
session: None,
segment: None,
policy: None,
no_policy: false,
no_agent: false,
},
);
let captured_state = match snapshot {
Ok(out) => Some(out.change_id),
Err(err) => {
// Capture failed despite the cmd succeeding (e.g. nothing
// changed in the worktree, or a hook vetoed). We don't
// tear down the thread on this branch — the user might
// want to inspect it. Surface the warning and continue.
tracing::warn!(error = %err, "capture failed in try thread; leaving thread in place");
None
}
};
// Auto-merge if requested and capture succeeded.
let mut merge_state: Option<String> = None;
let mut thread_dropped = false;
let mut cleanup_error: Option<String> = None;
if args.auto_merge && captured_state.is_some() {
let merge_output = merge_thread_into_current(
&repo,
&thread_name,
Some(format!("try: {}", display_cmd(&args.command))),
false,
false,
true,
false,
false,
)?;
merge_state = merge_output.merge_state.clone();
// Drop the thread after a clean merge unless the user asked
// us to keep it. The merge has already moved the parent's
// HEAD; the thread's checkout is a leftover sandbox at this
// point. Defensive guard: only drop on a clean merge — should
// never fire as non-clean given `preview=false`, but being
// explicit keeps the failure mode obvious.
if !args.keep_on_success && merge_output.conflicts.is_empty() {
let drop_result = drop_thread_silent(&repo, &thread_name, true, true);
let (dropped, err) =
interpret_drop_result(&thread_name, drop_result, "auto-merge cleanup");
thread_dropped = dropped;
cleanup_error = err;
}
}
// Final invariant check: if --auto-merge was *not* set, the
// parent's HEAD must equal what it was before we started.
if !args.auto_merge {
verify_parent_unchanged(&repo, parent_head_before.as_deref())?;
}
let next_action = if !args.auto_merge {
// Quote defensively at construction (heddle#464 defense-in-depth): a
// thread id flows into the validated next_action / recommended_action
// fields, so an unsafe one must render as a single shell token, never
// bare. A clean slug passes through unchanged.
Some(format!(
"heddle ready --thread {}",
shell_quote(&thread_name)
))
} else {
None
};
let recommended_action = next_action.clone();
let recommended_action_fields =
ActionFields::from_optional_action_ref(recommended_action.as_deref());
let recovery_commands = if !args.auto_merge || !thread_dropped {
vec![format!("heddle thread drop {thread_name}")]
} else {
Vec::new()
};
let recovery_action_templates = action_templates(&recovery_commands);
let message = if args.auto_merge {
match (&captured_state, &merge_state) {
(Some(state), Some(merge)) => format!(
"`{}` succeeded; captured {}, merged into parent as {}",
display_cmd(&args.command),
state,
merge
),
(Some(state), None) => format!(
"`{}` succeeded; captured {}, merge into parent skipped",
display_cmd(&args.command),
state
),
_ => format!(
"`{}` succeeded; nothing to capture",
display_cmd(&args.command)
),
}
} else {
match &captured_state {
Some(state) => format!(
"`{}` succeeded; thread '{}' ready (state {}). Check readiness with `heddle ready --thread {}` before landing.",
display_cmd(&args.command),
thread_name,
state,
thread_name
),
None => format!(
"`{}` succeeded; thread '{}' ready (no capture).",
display_cmd(&args.command),
thread_name
),
}
};
let output = TryOutput {
status: "completed",
action: "try",
message,
thread: thread_name,
thread_dropped,
cleanup_error,
exit_code,
duration_ms,
captured_state,
merge_state,
next_action,
next_action_template: recommended_action_fields.template.clone(),
recommended_action,
recommended_action_template: recommended_action_fields.template,
recovery_commands,
recovery_action_templates,
};
emit(cli, &repo, &output)
}
/// Check whether a thread name is in use by ANY route that
/// `start_thread` would resume from. Returns `Ok(true)` if the name
/// resolves to an existing thread via either:
/// 1. `ThreadManager::find_by_thread` / `ThreadManager::load`
/// — covers locally-registered threads with a manager record.
/// 2. `repo.refs().get_thread` — covers ref-only threads (e.g.
/// legacy or synced repos where a ref exists without a
/// corresponding manager record).
///
/// The `start_thread` create-or-resume contract reads from both
/// sources, so the guard must too — otherwise a manager-less ref can
/// silently slip past a `--name`/`--name-prefix` check and land us
/// attached to (and later dropping) a real existing thread.
pub(crate) fn thread_name_in_use(repo: &Repository, name: &str) -> Result<bool> {
let manager = ThreadManager::new(repo.heddle_dir());
if manager.find_by_thread(name)?.is_some() || manager.load(name)?.is_some() {
return Ok(true);
}
if repo
.refs()
.get_thread(&objects::object::ThreadName::new(name))?
.is_some()
{
return Ok(true);
}
Ok(false)
}
/// Confirm the parent's HEAD didn't drift while we were running. The
/// invariant is "parent worktree unchanged on every path other than
/// `--auto-merge`". A drift here is a bug in this command, not user
/// error; surface it loudly.
fn verify_parent_unchanged(repo: &Repository, before: Option<&str>) -> Result<()> {
let after = repo.head()?.map(|id| id.to_string_full());
let after_ref = after.as_deref();
if before != after_ref {
return Err(anyhow!(
"internal error: parent HEAD drifted during `heddle try` (before={:?} after={:?}); please file a bug",
before,
after_ref
));
}
Ok(())
}
/// Render `<cmd>` for human messages. `display_cmd(["cargo", "test"])`
/// returns `"cargo test"`. Quoting is intentionally simple — the user
/// can run with `--output json` for a structured shape.
fn display_cmd(cmd: &[String]) -> String {
cmd.join(" ")
}
fn try_thread_name_collision_advice(thread_name: &str) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"try_thread_name_collision",
format!("thread '{thread_name}' already exists"),
"Pick a different `--name`, or omit it so Heddle can generate a collision-resistant name.",
format!("`heddle try --name {thread_name}` would target an existing thread"),
"reusing that thread name could attach to and later clean up an existing user thread",
"no try thread was spawned and the existing thread was left unchanged",
"heddle try --name <different-name> -- <cmd...>",
vec![
"heddle try --name <different-name> -- <cmd...>".to_string(),
"heddle try -- <cmd...>".to_string(),
],
)
}
/// Build the default thread name from the cmd. `try-<8-hex>` of a
/// hash over the cmd vector + a high-resolution timestamp. The
/// timestamp ensures back-to-back `heddle try -- true` invocations
/// don't collide.
fn default_try_name(command: &[String]) -> String {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut hasher = DefaultHasher::new();
for arg in command {
arg.hash(&mut hasher);
}
// Mix in a monotonic-ish nonce so two `heddle try -- true` calls
// back-to-back generate distinct names.
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
.hash(&mut hasher);
let digest = hasher.finish();
format!("try-{:08x}", digest as u32)
}
fn emit(cli: &Cli, repo: &Repository, output: &TryOutput) -> Result<()> {
if should_output_json(cli, Some(repo.config())) {
println!("{}", serde_json::to_string(output)?);
} else {
let painted = match output.status {
"completed" => style::accent(&output.message),
_ => style::warn(&output.message),
};
println!("{}", painted);
if let Some(next) = &output.next_action {
print_next(next);
}
if let Some(discard) = output.recovery_commands.first() {
println!("Discard: {}", style::bold(discard));
}
}
Ok(())
}
/// Decide what to surface when `drop_thread_silent` either succeeds or
/// fails on a path where we tried to drop the ephemeral thread. Returns
/// `(thread_dropped, cleanup_error)` so the caller can plug them into
/// `TryOutput` directly. On error we also emit a stderr warning so
/// interactive users see what happened — automation reads
/// `cleanup_error` from the JSON shape.
fn interpret_drop_result(
thread_name: &str,
result: Result<DropOutcome>,
context: &str,
) -> (bool, Option<String>) {
match result {
Ok(_) => (true, None),
Err(err) => {
let msg = err.to_string();
tracing::warn!(thread = %thread_name, error = %err, context, "drop failed");
eprintln!(
"warning: failed to drop ephemeral thread '{thread_name}' during {context}: {msg}"
);
(false, Some(msg))
}
}
}
#[cfg(test)]
mod tests {
use objects::object::{ChangeId, ThreadName};
use super::*;
fn init_repo() -> (tempfile::TempDir, Repository) {
let temp = tempfile::TempDir::new().unwrap();
let repo = Repository::init_default(temp.path()).unwrap();
(temp, repo)
}
#[test]
fn thread_name_in_use_returns_false_for_unknown_name() {
let (_temp, repo) = init_repo();
assert!(!thread_name_in_use(&repo, "no-such-thread").unwrap());
}
#[test]
fn thread_name_in_use_detects_ref_only_thread() {
// Write a thread ref directly with NO ThreadManager record —
// the legacy / synced-repo shape that the previous guard
// missed. `thread_name_in_use` must catch it.
let (_temp, repo) = init_repo();
let id = ChangeId::generate();
repo.refs()
.set_thread(&ThreadName::new("ref-only-thread"), &id)
.unwrap();
// ThreadManager has no record (we didn't go through start_thread).
let manager = ThreadManager::new(repo.heddle_dir());
assert!(manager.find_by_thread("ref-only-thread").unwrap().is_none());
assert!(manager.load("ref-only-thread").unwrap().is_none());
// The helper still refuses — the ref-level lookup catches it.
assert!(thread_name_in_use(&repo, "ref-only-thread").unwrap());
}
#[test]
fn cmd_try_refuses_name_collision_via_ref_only_thread() {
// End-to-end shape of Fix 2: a thread ref that exists without
// a manager record (legacy / synced repo) must cause `heddle
// try --name <that-ref-name>` to refuse before it ever calls
// start_thread. We invoke cmd_try with all the args wired up;
// the guard short-circuits with the precise message.
let (_temp, repo) = init_repo();
let id = ChangeId::generate();
repo.refs()
.set_thread(&ThreadName::new("legacy-ref-thread"), &id)
.unwrap();
let make_args = || TryArgs {
name: Some("legacy-ref-thread".into()),
workspace: WorkspaceModeArg::Materialized,
auto_merge: false,
keep_on_success: false,
command: vec!["true".into()],
};
let cli = Cli {
command: crate::cli::Commands::Try(make_args()),
output: None,
no_color: true,
repo: Some(repo.root().to_path_buf()),
verbose: 0,
quiet: false,
op_id: None,
};
let err = cmd_try(&cli, make_args()).expect_err("must refuse ref-only collision");
let advice = err
.chain()
.find_map(|cause| cause.downcast_ref::<RecoveryAdvice>())
.expect("try collision refusal should carry typed recovery advice");
assert_eq!(advice.kind, "try_thread_name_collision");
let msg = err.to_string();
assert!(
msg.contains("legacy-ref-thread") && msg.contains("already exists"),
"expected precise collision message; got: {msg}"
);
}
#[test]
fn interpret_drop_result_ok_marks_dropped_with_no_cleanup_error() {
let (dropped, cleanup_error) =
interpret_drop_result("ephemeral-x", Ok(DropOutcome::Deleted), "try cleanup");
assert!(dropped);
assert!(cleanup_error.is_none());
}
#[test]
fn interpret_drop_result_err_marks_not_dropped_and_carries_message() {
let err: Result<DropOutcome> = Err(anyhow!(
"simulated lock contention on .heddle/locks/threads"
));
let (dropped, cleanup_error) = interpret_drop_result("ephemeral-x", err, "try cleanup");
assert!(!dropped, "thread_dropped must be false when cleanup fails");
let msg = cleanup_error.expect("cleanup_error must carry the failure message");
assert!(
msg.contains("simulated lock contention"),
"cleanup_error should include the underlying message; got: {msg}"
);
}
#[test]
fn try_output_serializes_cleanup_error_only_when_present() {
// Field-shape contract: when cleanup_error is None it must NOT
// appear in the JSON (skip_serializing_if). When it IS Some it
// must appear alongside thread_dropped: false. Automation
// depends on this exact shape — a misleading thread_dropped:
// true with no cleanup_error would mask an orphan ephemeral
// thread.
let ok_output = TryOutput {
status: "failed",
action: "try",
message: "ok-path".into(),
thread: "t".into(),
thread_dropped: true,
cleanup_error: None,
exit_code: Some(1),
duration_ms: 0,
captured_state: None,
merge_state: None,
next_action: None,
next_action_template: None,
recommended_action: None,
recommended_action_template: None,
recovery_commands: Vec::new(),
recovery_action_templates: Vec::new(),
};
let json = serde_json::to_string(&ok_output).unwrap();
assert!(
!json.contains("cleanup_error"),
"field must be skipped when None: {json}"
);
assert!(json.contains("\"thread_dropped\":true"));
let err_output = TryOutput {
status: "failed",
action: "try",
message: "err-path".into(),
thread: "t".into(),
thread_dropped: false,
cleanup_error: Some("lock held".into()),
exit_code: Some(1),
duration_ms: 0,
captured_state: None,
merge_state: None,
next_action: None,
next_action_template: None,
recommended_action: None,
recommended_action_template: None,
recovery_commands: Vec::new(),
recovery_action_templates: Vec::new(),
};
let json = serde_json::to_string(&err_output).unwrap();
assert!(
json.contains("\"thread_dropped\":false"),
"thread_dropped must be false when cleanup failed: {json}"
);
assert!(
json.contains("\"cleanup_error\":\"lock held\""),
"cleanup_error must surface the message: {json}"
);
}
}