@team-agent/installer 0.3.39 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +7 -0
- package/crates/team-agent/src/cli/adapters.rs +8 -3
- package/crates/team-agent/src/cli/diagnose.rs +12 -3
- package/crates/team-agent/src/cli/emit.rs +1 -0
- package/crates/team-agent/src/cli/mod.rs +250 -9
- package/crates/team-agent/src/cli/send.rs +11 -2
- package/crates/team-agent/src/cli/status_port.rs +39 -4
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +189 -6
- package/crates/team-agent/src/cli/tests/status_send.rs +189 -0
- package/crates/team-agent/src/cli/tests/verb_settle.rs +2 -0
- package/crates/team-agent/src/cli/types.rs +5 -0
- package/crates/team-agent/src/coordinator/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/delivery.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/health_sync.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/mod.rs +83 -0
- package/crates/team-agent/src/coordinator/steps/persist.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +4 -0
- package/crates/team-agent/src/coordinator/steps/session_gate.rs +5 -0
- package/crates/team-agent/src/coordinator/tests/health_sync.rs +156 -0
- package/crates/team-agent/src/coordinator/tick.rs +126 -30
- package/crates/team-agent/src/{message_store.rs → db/message_store.rs} +6 -0
- package/crates/team-agent/src/db/mod.rs +1 -0
- package/crates/team-agent/src/layout/mod.rs +7 -0
- package/crates/team-agent/src/layout/runtime_sessions.rs +312 -0
- package/crates/team-agent/src/layout/tmux_endpoint.rs +162 -0
- package/crates/team-agent/src/leader/start.rs +27 -1
- package/crates/team-agent/src/leader/tests/identity.rs +50 -0
- package/crates/team-agent/src/lib.rs +6 -2
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +32 -0
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +32 -0
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +51 -0
- package/crates/team-agent/src/lifecycle/launch.rs +34 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +17 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +143 -23
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +87 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +308 -0
- package/crates/team-agent/src/lifecycle/restart/selection.rs +87 -22
- package/crates/team-agent/src/lifecycle/restart.rs +6 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +6 -1
- package/crates/team-agent/src/lifecycle/tests/restart.rs +187 -0
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +20 -1
- package/crates/team-agent/src/mcp_server/helpers.rs +72 -1
- package/crates/team-agent/src/mcp_server/tools.rs +29 -4
- package/crates/team-agent/src/provider/adapter.rs +31 -1
- package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +34 -0
- package/crates/team-agent/src/provider/classify.rs +138 -0
- package/crates/team-agent/src/provider/command.rs +71 -0
- package/crates/team-agent/src/provider/mod.rs +3 -0
- package/crates/team-agent/src/{session_capture.rs → provider/session/capture.rs} +139 -6
- package/crates/team-agent/src/provider/session/mod.rs +13 -0
- package/crates/team-agent/src/provider/session/resume.rs +417 -0
- package/crates/team-agent/src/provider/session_scan.rs +63 -0
- package/crates/team-agent/src/provider/types.rs +5 -0
- package/crates/team-agent/src/state/persist.rs +238 -0
- package/crates/team-agent/src/tmux_backend.rs +25 -0
- package/npm/install.mjs +27 -1
- package/package.json +4 -4
|
@@ -72,6 +72,9 @@ pub fn restart_with_transport_with_readiness_deadline(
|
|
|
72
72
|
.map(|agent_id| UnresumableWorker {
|
|
73
73
|
agent_id,
|
|
74
74
|
reason: "session_capture_incomplete".to_string(),
|
|
75
|
+
refusal_reason: Some(
|
|
76
|
+
crate::provider::session::ResumeRefusalReason::SessionCaptureIncomplete,
|
|
77
|
+
),
|
|
75
78
|
session_id: None,
|
|
76
79
|
first_send_at: None,
|
|
77
80
|
})
|
|
@@ -190,6 +193,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
190
193
|
&plan.decisions,
|
|
191
194
|
&forced_fresh_missing,
|
|
192
195
|
forced_fresh_convergence.as_ref(),
|
|
196
|
+
&plan.unresumable,
|
|
193
197
|
)?;
|
|
194
198
|
if !plan.corrupt_entries.is_empty() {
|
|
195
199
|
return Ok(RestartReport::RefusedInvalidFirstSendAt {
|
|
@@ -205,6 +209,29 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
205
209
|
error: "restart requires resumable workers before live spawn; rerun with --allow-fresh to start fresh".to_string(),
|
|
206
210
|
});
|
|
207
211
|
}
|
|
212
|
+
// unit-3 (Stage 1) session-identity preflight: before any kill, reject
|
|
213
|
+
// the case where `state.session_name` actually holds a leader launcher
|
|
214
|
+
// session name (`team-agent-leader-*`). Proceeding would tear down the
|
|
215
|
+
// leader pane (E49 / 0.3.39 leader mis-kill). Nothing is created or
|
|
216
|
+
// killed — the caller gets a structured refusal that distinguishes this
|
|
217
|
+
// dirty-state from a normal resume/atomicity refusal.
|
|
218
|
+
match crate::lifecycle::restart::preflight::check_session_preflight(&state) {
|
|
219
|
+
crate::lifecycle::restart::preflight::SessionPreflight::Ok => {}
|
|
220
|
+
crate::lifecycle::restart::preflight::SessionPreflight::WorkerSessionIsLeaderSession {
|
|
221
|
+
session_name,
|
|
222
|
+
reason,
|
|
223
|
+
} => {
|
|
224
|
+
return Ok(RestartReport::RefusedDirtyTopology {
|
|
225
|
+
session_name: session_name.clone(),
|
|
226
|
+
reason: reason.clone(),
|
|
227
|
+
error: format!(
|
|
228
|
+
"restart refused: state.session_name `{session_name}` is a leader \
|
|
229
|
+
launcher session ({reason}); aborting before any tmux kill. Repair \
|
|
230
|
+
state.session_name to the worker session and re-run."
|
|
231
|
+
),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
208
235
|
let session_name = state_session_name(&state);
|
|
209
236
|
if session_live_or_default(transport, &session_name, false) {
|
|
210
237
|
// 0.3.28 Step 5 (warn-only): per architecture, restart should REFUSE
|
|
@@ -310,6 +337,10 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
310
337
|
Some(&safety),
|
|
311
338
|
layout_placement.as_ref(),
|
|
312
339
|
None,
|
|
340
|
+
// Issue 2 (Round 3b gate review §6): thread the resolved
|
|
341
|
+
// selected.team_key so the worker MCP env carries the right
|
|
342
|
+
// owner_team_id even when top-level active_team_key is stale.
|
|
343
|
+
Some(selected.team_key.as_str()),
|
|
313
344
|
) {
|
|
314
345
|
Ok(spawn) => spawn,
|
|
315
346
|
Err(error) => {
|
|
@@ -396,6 +427,175 @@ pub fn restart_with_transport_with_session_convergence_deadline(
|
|
|
396
427
|
attach_commands,
|
|
397
428
|
});
|
|
398
429
|
}
|
|
430
|
+
// RM-039-SESS-001 step 2 (architect verdict 2026-06-22): post-respawn
|
|
431
|
+
// resume backing validation.
|
|
432
|
+
//
|
|
433
|
+
// Pre-kill preflight already proves backing exists, but killing the
|
|
434
|
+
// provider process can cause the provider to rotate / clean up its
|
|
435
|
+
// transient backing file (the Claude case in the evidence: the
|
|
436
|
+
// sessions/<pid>.json file is the process-tracking record and goes away
|
|
437
|
+
// when the worker dies). Resumed agents preserve the OLD
|
|
438
|
+
// capture tuple (mark_agent_respawned only clears it on Fresh/
|
|
439
|
+
// FreshAfterMissingRollout), so without this re-probe the runtime
|
|
440
|
+
// reports `restart.completed rc:"ok"` while L2 provider backing truth
|
|
441
|
+
// is missing — a false-green restart.
|
|
442
|
+
//
|
|
443
|
+
// Strategy:
|
|
444
|
+
// 1. For every Resumed worker in `successful_agents`, re-run the
|
|
445
|
+
// same `resume_backing_probe_for_agent` used at preflight.
|
|
446
|
+
// 2. Emit `restart.resume_postflight` carrying agent_id, session_id,
|
|
447
|
+
// exists, checked_paths.
|
|
448
|
+
// 3. If exists=false: clear stale capture fields (rollout_path,
|
|
449
|
+
// captured_at, captured_via, attribution_confidence) but
|
|
450
|
+
// preserve session_id; set `_pending_session_id` to the same
|
|
451
|
+
// session_id so convergence searches for the resumed session
|
|
452
|
+
// explicitly. Then run one bounded convergence pass.
|
|
453
|
+
// 4. Re-probe. If still missing and !allow_fresh, demote the agent
|
|
454
|
+
// from successful to failed with phase `resume_postflight` and
|
|
455
|
+
// `session_backing_store_missing_after_restart`. The outer
|
|
456
|
+
// branches below will then return Failed/Partial instead of OK.
|
|
457
|
+
{
|
|
458
|
+
let mut postflight_failed: Vec<crate::lifecycle::types::RestartFailedAgent> = Vec::new();
|
|
459
|
+
let mut survivors: Vec<crate::lifecycle::types::RestartedAgent> =
|
|
460
|
+
Vec::with_capacity(successful_agents.len());
|
|
461
|
+
let convergence_deadline = session_convergence_deadline(session_converge_deadline_ms);
|
|
462
|
+
let convergence_poll = session_convergence_poll_interval();
|
|
463
|
+
for decision in successful_agents.drain(..) {
|
|
464
|
+
if !matches!(decision.restart_mode, crate::lifecycle::types::StartMode::Resumed) {
|
|
465
|
+
survivors.push(decision);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
let Some(agent) = state
|
|
469
|
+
.get("agents")
|
|
470
|
+
.and_then(|v| v.get(decision.agent_id.as_str()))
|
|
471
|
+
.cloned()
|
|
472
|
+
else {
|
|
473
|
+
survivors.push(decision);
|
|
474
|
+
continue;
|
|
475
|
+
};
|
|
476
|
+
let provider = agent_provider(&agent);
|
|
477
|
+
let session_id = match decision.session_id.as_ref() {
|
|
478
|
+
Some(sid) => sid.clone(),
|
|
479
|
+
None => {
|
|
480
|
+
// No session id on a Resumed decision shouldn't happen,
|
|
481
|
+
// but if it does there is nothing to revalidate.
|
|
482
|
+
survivors.push(decision);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
let probe = resume_backing_probe_for_agent(
|
|
487
|
+
&selected.run_workspace,
|
|
488
|
+
&decision.agent_id,
|
|
489
|
+
&agent,
|
|
490
|
+
provider,
|
|
491
|
+
&session_id,
|
|
492
|
+
agent_rollout_path(&agent).as_ref(),
|
|
493
|
+
);
|
|
494
|
+
write_restart_resume_postflight_event(
|
|
495
|
+
&selected.run_workspace,
|
|
496
|
+
&decision.agent_id,
|
|
497
|
+
Some(&session_id),
|
|
498
|
+
probe.exists,
|
|
499
|
+
&probe.checked_paths,
|
|
500
|
+
/* recaptured = */ false,
|
|
501
|
+
)?;
|
|
502
|
+
if probe.exists {
|
|
503
|
+
survivors.push(decision);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
// Backing went missing between preflight and post-spawn.
|
|
507
|
+
// Clear stale capture fields, keep session_id, mark
|
|
508
|
+
// _pending_session_id, run a bounded convergence pass.
|
|
509
|
+
if let Some(agents) = state
|
|
510
|
+
.pointer_mut("/agents")
|
|
511
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
512
|
+
{
|
|
513
|
+
if let Some(agent_obj) = agents
|
|
514
|
+
.get_mut(decision.agent_id.as_str())
|
|
515
|
+
.and_then(serde_json::Value::as_object_mut)
|
|
516
|
+
{
|
|
517
|
+
agent_obj.remove("rollout_path");
|
|
518
|
+
agent_obj.remove("captured_at");
|
|
519
|
+
agent_obj.remove("captured_via");
|
|
520
|
+
agent_obj.remove("attribution_confidence");
|
|
521
|
+
agent_obj.insert(
|
|
522
|
+
"_pending_session_id".to_string(),
|
|
523
|
+
serde_json::json!(session_id.as_str()),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// Persist state before convergence so the helper sees the
|
|
528
|
+
// cleared tuple.
|
|
529
|
+
save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
|
|
530
|
+
let _converge = converge_missing_provider_sessions(
|
|
531
|
+
&mut state,
|
|
532
|
+
convergence_deadline,
|
|
533
|
+
convergence_poll,
|
|
534
|
+
&selected.run_workspace,
|
|
535
|
+
allow_fresh,
|
|
536
|
+
)?;
|
|
537
|
+
save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
|
|
538
|
+
// Re-probe with the freshest agent state.
|
|
539
|
+
let agent_after = state
|
|
540
|
+
.get("agents")
|
|
541
|
+
.and_then(|v| v.get(decision.agent_id.as_str()))
|
|
542
|
+
.cloned()
|
|
543
|
+
.unwrap_or(serde_json::Value::Null);
|
|
544
|
+
let probe_after = resume_backing_probe_for_agent(
|
|
545
|
+
&selected.run_workspace,
|
|
546
|
+
&decision.agent_id,
|
|
547
|
+
&agent_after,
|
|
548
|
+
provider,
|
|
549
|
+
&session_id,
|
|
550
|
+
agent_rollout_path(&agent_after).as_ref(),
|
|
551
|
+
);
|
|
552
|
+
write_restart_resume_postflight_event(
|
|
553
|
+
&selected.run_workspace,
|
|
554
|
+
&decision.agent_id,
|
|
555
|
+
Some(&session_id),
|
|
556
|
+
probe_after.exists,
|
|
557
|
+
&probe_after.checked_paths,
|
|
558
|
+
/* recaptured = */ true,
|
|
559
|
+
)?;
|
|
560
|
+
if probe_after.exists {
|
|
561
|
+
survivors.push(decision);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
// Still missing. Demote the agent.
|
|
565
|
+
let phase = "resume_postflight";
|
|
566
|
+
let error = "session_backing_store_missing_after_restart".to_string();
|
|
567
|
+
mark_agent_restart_failed(&mut state, &decision, &error);
|
|
568
|
+
let _ = write_restart_agent_failed_event(
|
|
569
|
+
&selected.run_workspace,
|
|
570
|
+
&decision,
|
|
571
|
+
phase,
|
|
572
|
+
&error,
|
|
573
|
+
);
|
|
574
|
+
postflight_failed.push(restart_failed_agent(&decision, phase, error));
|
|
575
|
+
}
|
|
576
|
+
successful_agents = survivors;
|
|
577
|
+
failed_agents.extend(postflight_failed);
|
|
578
|
+
if !failed_agents.is_empty() {
|
|
579
|
+
save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if successful_agents.is_empty() && !failed_agents.is_empty() {
|
|
583
|
+
// Postflight demoted every survivor — emit Failed before coordinator start.
|
|
584
|
+
let attach_commands = Vec::new();
|
|
585
|
+
let next_actions = restart_failure_next_actions(&failed_agents);
|
|
586
|
+
write_restart_completed_event(
|
|
587
|
+
&selected.run_workspace,
|
|
588
|
+
&successful_agents,
|
|
589
|
+
&failed_agents,
|
|
590
|
+
"fail",
|
|
591
|
+
)?;
|
|
592
|
+
return Ok(RestartReport::Failed {
|
|
593
|
+
session_name,
|
|
594
|
+
failed_agents,
|
|
595
|
+
next_actions,
|
|
596
|
+
attach_commands,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
399
599
|
let coordinator_started = start_coordinator_for_workspace(&selected.run_workspace)?;
|
|
400
600
|
wait_restart_readiness_or_timeout(
|
|
401
601
|
&selected.run_workspace,
|
|
@@ -939,6 +1139,18 @@ fn mark_agent_respawned(
|
|
|
939
1139
|
"spawn_cwd".to_string(),
|
|
940
1140
|
serde_json::json!(spawn.spawn_cwd.to_string_lossy().to_string()),
|
|
941
1141
|
);
|
|
1142
|
+
// Issue 2 (Round 3b gate review §6): persist the resolved owner_team_id
|
|
1143
|
+
// back into the agent row so future restarts read it directly from the
|
|
1144
|
+
// agent row (cascade priority 2) instead of relying on top-level
|
|
1145
|
+
// active_team_key (priority 3).
|
|
1146
|
+
if let Some(ref team_id) = spawn.owner_team_id {
|
|
1147
|
+
if !team_id.is_empty() {
|
|
1148
|
+
agent.insert(
|
|
1149
|
+
"owner_team_id".to_string(),
|
|
1150
|
+
serde_json::json!(team_id),
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
942
1154
|
if matches!(
|
|
943
1155
|
restart_mode,
|
|
944
1156
|
StartMode::Fresh | StartMode::FreshAfterMissingRollout
|
|
@@ -1099,6 +1311,39 @@ fn write_restart_agent_failed_event(
|
|
|
1099
1311
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
1100
1312
|
}
|
|
1101
1313
|
|
|
1314
|
+
/// RM-039-SESS-001 step 2 (architect verdict 2026-06-22): emit
|
|
1315
|
+
/// `restart.resume_postflight` for each Resumed worker we re-probed after
|
|
1316
|
+
/// spawn. `recaptured=true` means this event is the second probe after
|
|
1317
|
+
/// one bounded `converge_missing_provider_sessions` pass; `false` means
|
|
1318
|
+
/// it's the first probe right after spawn. `exists` reflects whether the
|
|
1319
|
+
/// provider backing file actually exists on disk; `checked_paths` lists
|
|
1320
|
+
/// every path the runtime probed.
|
|
1321
|
+
fn write_restart_resume_postflight_event(
|
|
1322
|
+
workspace: &Path,
|
|
1323
|
+
agent_id: &AgentId,
|
|
1324
|
+
session_id: Option<&SessionId>,
|
|
1325
|
+
exists: bool,
|
|
1326
|
+
checked_paths: &[std::path::PathBuf],
|
|
1327
|
+
recaptured: bool,
|
|
1328
|
+
) -> Result<(), LifecycleError> {
|
|
1329
|
+
crate::event_log::EventLog::new(workspace)
|
|
1330
|
+
.write(
|
|
1331
|
+
"restart.resume_postflight",
|
|
1332
|
+
serde_json::json!({
|
|
1333
|
+
"agent_id": agent_id.as_str(),
|
|
1334
|
+
"session_id": session_id.map(SessionId::as_str),
|
|
1335
|
+
"exists": exists,
|
|
1336
|
+
"checked_paths": checked_paths
|
|
1337
|
+
.iter()
|
|
1338
|
+
.map(|p| p.to_string_lossy().into_owned())
|
|
1339
|
+
.collect::<Vec<_>>(),
|
|
1340
|
+
"recaptured": recaptured,
|
|
1341
|
+
}),
|
|
1342
|
+
)
|
|
1343
|
+
.map(|_| ())
|
|
1344
|
+
.map_err(|e| LifecycleError::StatePersist(e.to_string()))
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1102
1347
|
fn write_restart_completed_event(
|
|
1103
1348
|
workspace: &Path,
|
|
1104
1349
|
successful_agents: &[RestartedAgent],
|
|
@@ -1165,7 +1410,13 @@ fn write_restart_resume_decision_events(
|
|
|
1165
1410
|
decisions: &[RestartedAgent],
|
|
1166
1411
|
forced_fresh_missing: &std::collections::BTreeSet<String>,
|
|
1167
1412
|
forced_fresh_convergence: Option<&crate::session_capture::SessionConvergence>,
|
|
1413
|
+
unresumable: &[crate::lifecycle::types::UnresumableWorker],
|
|
1168
1414
|
) -> Result<(), LifecycleError> {
|
|
1415
|
+
// Layer 2 (leader directive 2026-06-22): every restart.resume_decision
|
|
1416
|
+
// event for a Refuse decision must carry the structured refusal_reason
|
|
1417
|
+
// wire string so operators can see WHY without crawling state.json.
|
|
1418
|
+
let refusal_index: std::collections::BTreeMap<&str, &crate::lifecycle::types::UnresumableWorker> =
|
|
1419
|
+
unresumable.iter().map(|u| (u.agent_id.as_str(), u)).collect();
|
|
1169
1420
|
for decision in decisions {
|
|
1170
1421
|
let agent = state
|
|
1171
1422
|
.get("agents")
|
|
@@ -1187,6 +1438,7 @@ fn write_restart_resume_decision_events(
|
|
|
1187
1438
|
decision_wire,
|
|
1188
1439
|
forced_fresh_missing.contains(decision.agent_id.as_str()),
|
|
1189
1440
|
forced_fresh_convergence,
|
|
1441
|
+
refusal_index.get(decision.agent_id.as_str()).copied(),
|
|
1190
1442
|
)?;
|
|
1191
1443
|
}
|
|
1192
1444
|
Ok(())
|
|
@@ -1201,6 +1453,7 @@ fn write_restart_resume_decision_event(
|
|
|
1201
1453
|
decision: &str,
|
|
1202
1454
|
forced_fresh: bool,
|
|
1203
1455
|
forced_fresh_convergence: Option<&crate::session_capture::SessionConvergence>,
|
|
1456
|
+
unresumable: Option<&crate::lifecycle::types::UnresumableWorker>,
|
|
1204
1457
|
) -> Result<(), LifecycleError> {
|
|
1205
1458
|
use std::io::Write as _;
|
|
1206
1459
|
|
|
@@ -1219,6 +1472,60 @@ fn write_restart_resume_decision_event(
|
|
|
1219
1472
|
"first_send_at": first_send_at,
|
|
1220
1473
|
"session_id": session_id,
|
|
1221
1474
|
});
|
|
1475
|
+
// Layer 2 (leader directive 2026-06-22): when this is a Refuse decision
|
|
1476
|
+
// and we have a structured ResumeRefusalReason, emit the wire string +
|
|
1477
|
+
// optional recovery hint so the event-log audit is actionable. Falls
|
|
1478
|
+
// back to the legacy free-form `reason` string when no structured
|
|
1479
|
+
// refusal_reason is set.
|
|
1480
|
+
if decision == "refuse" {
|
|
1481
|
+
if let Some(u) = unresumable {
|
|
1482
|
+
if let Some(obj) = event.as_object_mut() {
|
|
1483
|
+
obj.insert("refusal_reason".to_string(), serde_json::json!(u.reason));
|
|
1484
|
+
if let Some(structured) = &u.refusal_reason {
|
|
1485
|
+
obj.insert(
|
|
1486
|
+
"refusal_reason_wire".to_string(),
|
|
1487
|
+
serde_json::json!(structured.wire()),
|
|
1488
|
+
);
|
|
1489
|
+
if let crate::provider::session::ResumeRefusalReason::SessionBackingStoreMissing {
|
|
1490
|
+
checked_paths,
|
|
1491
|
+
recovery_hint,
|
|
1492
|
+
} = structured
|
|
1493
|
+
{
|
|
1494
|
+
if !checked_paths.is_empty() {
|
|
1495
|
+
obj.insert(
|
|
1496
|
+
"checked_paths".to_string(),
|
|
1497
|
+
serde_json::json!(checked_paths
|
|
1498
|
+
.iter()
|
|
1499
|
+
.map(|p| p.to_string_lossy().into_owned())
|
|
1500
|
+
.collect::<Vec<_>>()),
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
if let Some(hint) = recovery_hint {
|
|
1504
|
+
let mut h = serde_json::Map::new();
|
|
1505
|
+
h.insert("provider".to_string(), serde_json::json!(hint.provider));
|
|
1506
|
+
if let Some(name) = &hint.provider_session_name_hint {
|
|
1507
|
+
h.insert("name".to_string(), serde_json::json!(name));
|
|
1508
|
+
}
|
|
1509
|
+
if let Some(cwd) = &hint.spawn_cwd {
|
|
1510
|
+
h.insert(
|
|
1511
|
+
"spawn_cwd".to_string(),
|
|
1512
|
+
serde_json::json!(cwd.to_string_lossy()),
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
h.insert(
|
|
1516
|
+
"picker_hint".to_string(),
|
|
1517
|
+
serde_json::json!(hint.picker_hint()),
|
|
1518
|
+
);
|
|
1519
|
+
obj.insert(
|
|
1520
|
+
"recovery_hint".to_string(),
|
|
1521
|
+
serde_json::Value::Object(h),
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1222
1529
|
if forced_fresh {
|
|
1223
1530
|
if let Some(event) = event.as_object_mut() {
|
|
1224
1531
|
event.insert("forced_fresh".to_string(), serde_json::json!(true));
|
|
@@ -1595,6 +1902,7 @@ mod tests {
|
|
|
1595
1902
|
profile_launch: crate::provider::ProviderProfileLaunch::default(),
|
|
1596
1903
|
layout_placement: None,
|
|
1597
1904
|
spawn_cwd: std::path::PathBuf::from("/tmp/team-epoch"),
|
|
1905
|
+
owner_team_id: None,
|
|
1598
1906
|
};
|
|
1599
1907
|
let before = chrono::Utc::now();
|
|
1600
1908
|
|
|
@@ -190,23 +190,37 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
|
|
|
190
190
|
let provider = agent_provider(agent);
|
|
191
191
|
let provider_wire = provider_wire(provider);
|
|
192
192
|
let provider_can_resume = provider_supports_resume(provider);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
session,
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
193
|
+
// Layer 2 self-healing (leader follow-up 2026-06-22): use the
|
|
194
|
+
// structured probe so we can carry the list of paths the runtime
|
|
195
|
+
// actually checked into the refusal — operators need to see WHICH
|
|
196
|
+
// places we looked, not just "missing".
|
|
197
|
+
let (resume_backing_exists, backing_checked_paths) =
|
|
198
|
+
match (workspace, session_id.as_ref(), provider_can_resume) {
|
|
199
|
+
(_, Some(_), false) => (false, Vec::new()),
|
|
200
|
+
(Some(workspace), Some(session), true) => {
|
|
201
|
+
let probe = resume_backing_probe_for_agent(
|
|
202
|
+
workspace,
|
|
203
|
+
&agent_id,
|
|
204
|
+
agent,
|
|
205
|
+
provider,
|
|
206
|
+
session,
|
|
207
|
+
agent_rollout_path(agent).as_ref(),
|
|
208
|
+
);
|
|
209
|
+
(probe.exists, probe.checked_paths)
|
|
210
|
+
}
|
|
211
|
+
(None, Some(_), true) if resumable_provider_requires_backing(provider_wire) => {
|
|
212
|
+
let path_opt = agent_rollout_path(agent);
|
|
213
|
+
let exists = path_opt
|
|
214
|
+
.as_ref()
|
|
215
|
+
.is_some_and(|path| path.as_path().exists());
|
|
216
|
+
let checked = path_opt
|
|
217
|
+
.as_ref()
|
|
218
|
+
.map(|p| vec![p.as_path().to_path_buf()])
|
|
219
|
+
.unwrap_or_default();
|
|
220
|
+
(exists, checked)
|
|
221
|
+
}
|
|
222
|
+
_ => (true, Vec::new()),
|
|
223
|
+
};
|
|
210
224
|
let decision = if session_id.is_some() && provider_can_resume && resume_backing_exists {
|
|
211
225
|
ResumeDecision::Resume
|
|
212
226
|
} else if session_id.is_some() && allow_fresh {
|
|
@@ -219,13 +233,64 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
|
|
|
219
233
|
ResumeDecision::Refuse
|
|
220
234
|
};
|
|
221
235
|
if matches!(decision, ResumeDecision::Refuse) {
|
|
236
|
+
// unit-5: surface structured ResumeRefusalReason alongside the
|
|
237
|
+
// legacy free-form string. The string wire is preserved exactly
|
|
238
|
+
// (round-tripped through ResumeRefusalReason::wire) so the
|
|
239
|
+
// CLI/JSON contract does not change.
|
|
240
|
+
let (reason_str, structured) = if session_id.is_some() {
|
|
241
|
+
if !provider_can_resume {
|
|
242
|
+
(
|
|
243
|
+
"session_unresumable".to_string(),
|
|
244
|
+
crate::provider::session::ResumeRefusalReason::ProviderResumeUnsupported {
|
|
245
|
+
provider: provider_wire.to_string(),
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
} else if !resume_backing_exists {
|
|
249
|
+
// Today the legacy wire collapses backing-missing under
|
|
250
|
+
// the catch-all `session_unresumable` — keep that wire,
|
|
251
|
+
// but record the structured reason so the new shape is
|
|
252
|
+
// available to callers that want it.
|
|
253
|
+
//
|
|
254
|
+
// Layer 2 self-healing (architect probe 2026-06-22): attach
|
|
255
|
+
// a recovery hint pointing at the agent_id (used as
|
|
256
|
+
// launch-time `--name`) and spawn_cwd. Operator-facing
|
|
257
|
+
// diagnostic only — no auto-resume off the hint.
|
|
258
|
+
let recovery_hint = Some(
|
|
259
|
+
crate::provider::session::RecoveryHint {
|
|
260
|
+
provider_session_name_hint: Some(agent_id.as_str().to_string()),
|
|
261
|
+
spawn_cwd: agent
|
|
262
|
+
.get("spawn_cwd")
|
|
263
|
+
.and_then(|v| v.as_str())
|
|
264
|
+
.filter(|s| !s.is_empty())
|
|
265
|
+
.map(std::path::PathBuf::from),
|
|
266
|
+
provider: provider_wire.to_string(),
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
(
|
|
270
|
+
"session_unresumable".to_string(),
|
|
271
|
+
crate::provider::session::ResumeRefusalReason::SessionBackingStoreMissing {
|
|
272
|
+
checked_paths: backing_checked_paths.clone(),
|
|
273
|
+
recovery_hint,
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
} else {
|
|
277
|
+
(
|
|
278
|
+
"session_unresumable".to_string(),
|
|
279
|
+
crate::provider::session::ResumeRefusalReason::Other {
|
|
280
|
+
legacy_reason: "session_unresumable".to_string(),
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
(
|
|
286
|
+
"no_persisted_session_id".to_string(),
|
|
287
|
+
crate::provider::session::ResumeRefusalReason::NoSessionId,
|
|
288
|
+
)
|
|
289
|
+
};
|
|
222
290
|
unresumable.push(UnresumableWorker {
|
|
223
291
|
agent_id: agent_id.clone(),
|
|
224
|
-
reason:
|
|
225
|
-
|
|
226
|
-
} else {
|
|
227
|
-
"no_persisted_session_id".to_string()
|
|
228
|
-
},
|
|
292
|
+
reason: reason_str,
|
|
293
|
+
refusal_reason: Some(structured),
|
|
229
294
|
session_id: session_id.clone(),
|
|
230
295
|
first_send_at: first_send_at_raw.as_str().map(|s| s.to_string()),
|
|
231
296
|
});
|
|
@@ -26,6 +26,7 @@ struct LifecyclePathRefs<'a> {
|
|
|
26
26
|
mod agent;
|
|
27
27
|
mod common;
|
|
28
28
|
mod orchestrator;
|
|
29
|
+
pub mod preflight;
|
|
29
30
|
mod rebuild;
|
|
30
31
|
mod remove;
|
|
31
32
|
mod selection;
|
|
@@ -46,6 +47,11 @@ pub use rebuild::{
|
|
|
46
47
|
pub(crate) use rebuild::restart_with_transport_with_session_convergence_deadline;
|
|
47
48
|
pub use remove::{remove_agent, remove_agent_with_transport};
|
|
48
49
|
pub use selection::{classify_first_send_at, classify_restart_plan, decide_start_mode, python_type_name};
|
|
50
|
+
// Layer 2 (leader follow-up 2026-06-22): test-visible workspace-aware
|
|
51
|
+
// classification so lifecycle/tests/restart.rs can exercise the
|
|
52
|
+
// SessionBackingStoreMissing + checked_paths + RecoveryHint path
|
|
53
|
+
// end-to-end without spinning up a full restart.
|
|
54
|
+
pub(crate) use selection::classify_restart_plan_with_resume_validation;
|
|
49
55
|
pub(crate) use team_state::write_team_state;
|
|
50
56
|
|
|
51
57
|
pub(crate) fn lifecycle_run_workspace(workspace: &Path) -> Result<std::path::PathBuf, LifecycleError> {
|
|
@@ -2544,12 +2544,17 @@ fn quick_start_state_seeds_spec_path_workspace_leader_display_backend() {
|
|
|
2544
2544
|
"tasks",
|
|
2545
2545
|
"display_backend",
|
|
2546
2546
|
"is_external_leader",
|
|
2547
|
+
// 0.4.0 refactor: `team_key` added as top-level topology marker
|
|
2548
|
+
// alongside active_team_key (refactor-modular-architecture). The
|
|
2549
|
+
// owner / leader_receiver / owner_epoch invariants below still
|
|
2550
|
+
// hold — those live only under teams[<active_team_key>].
|
|
2551
|
+
"team_key",
|
|
2547
2552
|
"active_team_key",
|
|
2548
2553
|
"teams",
|
|
2549
2554
|
],
|
|
2550
2555
|
"state.json top-level key order must match golden launch/core.py:62-71 \
|
|
2551
2556
|
plus R1 topology marker and Bug 1/2 team-in-team suffix \
|
|
2552
|
-
(is_external_leader, active_team_key, teams). \
|
|
2557
|
+
(is_external_leader, team_key, active_team_key, teams). \
|
|
2553
2558
|
Bug 2 owner team-scope (N1/N12/N18/N29) deliberately keeps owner / \
|
|
2554
2559
|
leader_receiver / owner_epoch OFF the top level — they live ONLY under \
|
|
2555
2560
|
teams[<active_team_key>] so per-team isolation has a single source of \
|