@team-agent/installer 0.5.56 → 0.5.58
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/src/cli/mod.rs +1 -0
- package/crates/team-agent/src/db/message_store.rs +284 -5
- package/crates/team-agent/src/leader/incident.rs +5 -0
- package/crates/team-agent/src/leader/mod.rs +2 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +89 -95
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +104 -0
- package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +109 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +49 -0
- package/crates/team-agent/src/lifecycle/launch.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +1 -0
- package/crates/team-agent/src/messaging/delivery.rs +7 -39
- package/crates/team-agent/src/messaging/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/messaging/watchers.rs +33 -16
- package/crates/team-agent/src/provider/session/capture.rs +37 -1
- package/crates/team-agent/src/provider/session/context_fork/claude.rs +85 -0
- package/crates/team-agent/src/provider/session/context_fork/codex.rs +65 -0
- package/crates/team-agent/src/provider/session/context_fork/outcome.rs +138 -0
- package/crates/team-agent/src/provider/session/context_fork.rs +30 -135
- package/crates/team-agent/src/provider/session/mod.rs +2 -1
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -2484,6 +2484,7 @@ pub mod lifecycle_port {
|
|
|
2484
2484
|
"new_agent_id": report.new_agent_id.as_str(),
|
|
2485
2485
|
"session_id": report.session_id.as_ref().map(|session| session.as_str()),
|
|
2486
2486
|
"new_session_id": report.session_id.as_ref().map(|session| session.as_str()),
|
|
2487
|
+
"backing_state": report.backing_state,
|
|
2487
2488
|
})),
|
|
2488
2489
|
Err(e) => Ok(error_value(e)),
|
|
2489
2490
|
}
|
|
@@ -52,8 +52,14 @@ pub enum MessageStoreError {
|
|
|
52
52
|
Sqlite(#[from] rusqlite::Error),
|
|
53
53
|
#[error("io: {0}")]
|
|
54
54
|
Io(#[from] std::io::Error),
|
|
55
|
+
#[error("event log: {0}")]
|
|
56
|
+
EventLog(#[from] crate::event_log::EventLogError),
|
|
55
57
|
#[error("delivery receipt missing for message: {0}")]
|
|
56
58
|
DeliveryReceiptMissing(String),
|
|
59
|
+
#[error("invalid message transition from {from} to {to}")]
|
|
60
|
+
InvalidTransition { from: String, to: String },
|
|
61
|
+
#[error("submitted recovery refused: {0}")]
|
|
62
|
+
RecoveryRefused(&'static str),
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
/// Outcome of [`MessageStore::claim_leader_notification_delivery`]
|
|
@@ -88,19 +94,96 @@ pub enum MessageRowStatus {
|
|
|
88
94
|
StoredOnly,
|
|
89
95
|
QueuedUntilLeaderAttach,
|
|
90
96
|
QueuedCoordinatorUnavailable,
|
|
97
|
+
QueuedPaneMissing,
|
|
98
|
+
TargetResolved,
|
|
99
|
+
SubmittedAwaitingReceipt,
|
|
100
|
+
SubmittedUnverified,
|
|
101
|
+
Delivered,
|
|
102
|
+
Acknowledged,
|
|
103
|
+
Consumed,
|
|
104
|
+
Failed,
|
|
105
|
+
BlockedLeaderUnbound,
|
|
106
|
+
BlockedWorkerPaneMissing,
|
|
91
107
|
}
|
|
92
108
|
|
|
93
109
|
impl MessageRowStatus {
|
|
110
|
+
pub const ALL: &[Self] = &[
|
|
111
|
+
Self::Accepted,
|
|
112
|
+
Self::StoredOnly,
|
|
113
|
+
Self::QueuedUntilLeaderAttach,
|
|
114
|
+
Self::QueuedCoordinatorUnavailable,
|
|
115
|
+
Self::QueuedPaneMissing,
|
|
116
|
+
Self::TargetResolved,
|
|
117
|
+
Self::SubmittedAwaitingReceipt,
|
|
118
|
+
Self::SubmittedUnverified,
|
|
119
|
+
Self::Delivered,
|
|
120
|
+
Self::Acknowledged,
|
|
121
|
+
Self::Consumed,
|
|
122
|
+
Self::Failed,
|
|
123
|
+
Self::BlockedLeaderUnbound,
|
|
124
|
+
Self::BlockedWorkerPaneMissing,
|
|
125
|
+
];
|
|
126
|
+
|
|
94
127
|
pub const fn as_str(self) -> &'static str {
|
|
95
128
|
match self {
|
|
96
129
|
Self::Accepted => "accepted",
|
|
97
130
|
Self::StoredOnly => "stored_only",
|
|
98
131
|
Self::QueuedUntilLeaderAttach => "queued_until_leader_attach",
|
|
99
132
|
Self::QueuedCoordinatorUnavailable => "queued_coordinator_unavailable",
|
|
133
|
+
Self::QueuedPaneMissing => "queued_pane_missing",
|
|
134
|
+
Self::TargetResolved => "target_resolved",
|
|
135
|
+
Self::SubmittedAwaitingReceipt => "submitted_pending_acceptance",
|
|
136
|
+
Self::SubmittedUnverified => "submitted_unverified",
|
|
137
|
+
Self::Delivered => "delivered",
|
|
138
|
+
Self::Acknowledged => "acknowledged",
|
|
139
|
+
Self::Consumed => "consumed",
|
|
140
|
+
Self::Failed => "failed",
|
|
141
|
+
Self::BlockedLeaderUnbound => "blocked_leader_unbound",
|
|
142
|
+
Self::BlockedWorkerPaneMissing => "queued_pane_missing",
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
|
148
|
+
pub struct BlockedLeaderRequeueCounts {
|
|
149
|
+
pub blocked_leader_unbound: usize,
|
|
150
|
+
pub queued_until_leader_attach: usize,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
154
|
+
pub struct RecoveryBudget {
|
|
155
|
+
pub attempt_budget: u32,
|
|
156
|
+
pub max_age_seconds: u64,
|
|
157
|
+
pub owner_epoch: i64,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
161
|
+
pub struct PaneAvailable {
|
|
162
|
+
pub agent_id: String,
|
|
163
|
+
pub pane_id: String,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
167
|
+
pub enum RedisplayKind {
|
|
168
|
+
FrameworkReplay,
|
|
169
|
+
ProviderRerender,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
impl RedisplayKind {
|
|
173
|
+
pub const fn as_str(self) -> &'static str {
|
|
174
|
+
match self {
|
|
175
|
+
Self::FrameworkReplay => "framework_replay",
|
|
176
|
+
Self::ProviderRerender => "provider_rerender",
|
|
100
177
|
}
|
|
101
178
|
}
|
|
102
179
|
}
|
|
103
180
|
|
|
181
|
+
impl BlockedLeaderRequeueCounts {
|
|
182
|
+
pub const fn total(self) -> usize {
|
|
183
|
+
self.blocked_leader_unbound + self.queued_until_leader_attach
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
104
187
|
/// Fully resolved durable-message insert. Grammar, scope and transport data do
|
|
105
188
|
/// not belong here; callers must resolve those before crossing this boundary.
|
|
106
189
|
pub struct PersistMessageInput<'a> {
|
|
@@ -294,7 +377,7 @@ impl MessageStore {
|
|
|
294
377
|
) -> Result<(), MessageStoreError> {
|
|
295
378
|
let conn = crate::db::schema::open_db(&self.path)?;
|
|
296
379
|
let now = now_ts();
|
|
297
|
-
conn.execute(
|
|
380
|
+
let changed = conn.execute(
|
|
298
381
|
"update messages
|
|
299
382
|
set status = case
|
|
300
383
|
when status = 'acknowledged'
|
|
@@ -310,9 +393,55 @@ impl MessageStore {
|
|
|
310
393
|
end,
|
|
311
394
|
acknowledged_at = case when ?2 = 'acknowledged' then ?3 else acknowledged_at end,
|
|
312
395
|
error = case when ?2 = 'delivered' then null else coalesce(?4, error) end
|
|
313
|
-
where message_id = ?1
|
|
396
|
+
where message_id = ?1
|
|
397
|
+
and status not in ('acknowledged', 'consumed', 'submitted_pending_acceptance')
|
|
398
|
+
and (status != 'delivered' or ?2 in ('delivered', 'acknowledged'))
|
|
399
|
+
and (?2 != 'acknowledged' or status = 'delivered')",
|
|
314
400
|
params![message_id, status, now, error],
|
|
315
401
|
)?;
|
|
402
|
+
if changed == 0 {
|
|
403
|
+
let prior: Option<String> = conn
|
|
404
|
+
.query_row(
|
|
405
|
+
"select status from messages where message_id = ?1",
|
|
406
|
+
params![message_id],
|
|
407
|
+
|row| row.get(0),
|
|
408
|
+
)
|
|
409
|
+
.optional()?;
|
|
410
|
+
let workspace = self
|
|
411
|
+
.path
|
|
412
|
+
.parent()
|
|
413
|
+
.and_then(Path::parent)
|
|
414
|
+
.and_then(Path::parent)
|
|
415
|
+
.unwrap_or(Path::new("."));
|
|
416
|
+
if status == MessageRowStatus::Acknowledged.as_str()
|
|
417
|
+
&& prior.as_deref() != Some(MessageRowStatus::Delivered.as_str())
|
|
418
|
+
{
|
|
419
|
+
crate::event_log::EventLog::new(workspace).write(
|
|
420
|
+
"message.mark_refused",
|
|
421
|
+
serde_json::json!({
|
|
422
|
+
"message_id": message_id,
|
|
423
|
+
"prior_status": prior,
|
|
424
|
+
"requested_status": status,
|
|
425
|
+
"reason": "invalid_acknowledgement_transition",
|
|
426
|
+
}),
|
|
427
|
+
)?;
|
|
428
|
+
return Err(MessageStoreError::InvalidTransition {
|
|
429
|
+
from: prior.unwrap_or_else(|| "missing".to_string()),
|
|
430
|
+
to: status.to_string(),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
let event_log = crate::event_log::EventLog::new(workspace);
|
|
434
|
+
event_log.write(
|
|
435
|
+
"message.mark_refused",
|
|
436
|
+
serde_json::json!({
|
|
437
|
+
"message_id": message_id,
|
|
438
|
+
"prior_status": prior,
|
|
439
|
+
"requested_status": status,
|
|
440
|
+
"reason": "protected_prior_state",
|
|
441
|
+
}),
|
|
442
|
+
)?;
|
|
443
|
+
return Ok(());
|
|
444
|
+
}
|
|
316
445
|
Ok(())
|
|
317
446
|
}
|
|
318
447
|
|
|
@@ -397,6 +526,155 @@ impl MessageStore {
|
|
|
397
526
|
Ok(rows == 1)
|
|
398
527
|
}
|
|
399
528
|
|
|
529
|
+
/// Read inbox rows through [`Self::inbox`]; recovery transitions below are
|
|
530
|
+
/// separate repository owners and are not claim eligibility.
|
|
531
|
+
pub fn requeue_blocked_leader_messages(
|
|
532
|
+
&self,
|
|
533
|
+
owner_team_id: &str,
|
|
534
|
+
) -> Result<BlockedLeaderRequeueCounts, MessageStoreError> {
|
|
535
|
+
let mut conn = crate::db::schema::open_db(&self.path)?;
|
|
536
|
+
let tx = conn.transaction()?;
|
|
537
|
+
let now = now_ts();
|
|
538
|
+
let blocked_leader_unbound = tx.execute(
|
|
539
|
+
"update messages
|
|
540
|
+
set status = 'accepted', error = null, updated_at = ?1
|
|
541
|
+
where recipient = 'leader'
|
|
542
|
+
and owner_team_id = ?2
|
|
543
|
+
and status = ?3
|
|
544
|
+
and error = 'leader_not_attached'",
|
|
545
|
+
params![now, owner_team_id, MessageRowStatus::Failed.as_str()],
|
|
546
|
+
)?;
|
|
547
|
+
let queued_until_leader_attach = tx.execute(
|
|
548
|
+
"update messages
|
|
549
|
+
set status = 'accepted', error = null, updated_at = ?1
|
|
550
|
+
where recipient = 'leader'
|
|
551
|
+
and owner_team_id = ?2
|
|
552
|
+
and status = ?3",
|
|
553
|
+
params![
|
|
554
|
+
now,
|
|
555
|
+
owner_team_id,
|
|
556
|
+
MessageRowStatus::QueuedUntilLeaderAttach.as_str()
|
|
557
|
+
],
|
|
558
|
+
)?;
|
|
559
|
+
tx.commit()?;
|
|
560
|
+
Ok(BlockedLeaderRequeueCounts {
|
|
561
|
+
blocked_leader_unbound,
|
|
562
|
+
queued_until_leader_attach,
|
|
563
|
+
})
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
pub fn recover_worker_pane_available(
|
|
567
|
+
&self,
|
|
568
|
+
agent_id: &str,
|
|
569
|
+
owner_team_id: Option<&str>,
|
|
570
|
+
) -> Result<Vec<String>, MessageStoreError> {
|
|
571
|
+
let mut conn = crate::db::schema::open_db(&self.path)?;
|
|
572
|
+
let tx = conn.transaction()?;
|
|
573
|
+
let ids = {
|
|
574
|
+
let mut stmt = tx.prepare(
|
|
575
|
+
"select message_id from messages
|
|
576
|
+
where recipient = ?1
|
|
577
|
+
and status = ?2
|
|
578
|
+
and error = 'tmux_target_missing'
|
|
579
|
+
and (
|
|
580
|
+
(?3 is null and owner_team_id is null)
|
|
581
|
+
or owner_team_id = ?3
|
|
582
|
+
)
|
|
583
|
+
order by created_at, message_id",
|
|
584
|
+
)?;
|
|
585
|
+
let rows = stmt
|
|
586
|
+
.query_map(
|
|
587
|
+
params![
|
|
588
|
+
agent_id,
|
|
589
|
+
MessageRowStatus::QueuedPaneMissing.as_str(),
|
|
590
|
+
owner_team_id
|
|
591
|
+
],
|
|
592
|
+
|row| row.get::<_, String>(0),
|
|
593
|
+
)?
|
|
594
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
595
|
+
rows
|
|
596
|
+
};
|
|
597
|
+
let now = now_ts();
|
|
598
|
+
for message_id in &ids {
|
|
599
|
+
tx.execute(
|
|
600
|
+
"update messages
|
|
601
|
+
set status = ?1, error = null, updated_at = ?2
|
|
602
|
+
where message_id = ?3",
|
|
603
|
+
params![MessageRowStatus::Accepted.as_str(), now, message_id],
|
|
604
|
+
)?;
|
|
605
|
+
}
|
|
606
|
+
tx.commit()?;
|
|
607
|
+
Ok(ids)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
pub fn retry_submitted_explicit(
|
|
611
|
+
&self,
|
|
612
|
+
message_id: &str,
|
|
613
|
+
incident_id: &str,
|
|
614
|
+
operator_reason: &str,
|
|
615
|
+
budget: RecoveryBudget,
|
|
616
|
+
) -> Result<bool, MessageStoreError> {
|
|
617
|
+
if incident_id.trim().is_empty() || operator_reason.trim().is_empty() {
|
|
618
|
+
return Err(MessageStoreError::RecoveryRefused(
|
|
619
|
+
"incident_id_and_operator_reason_required",
|
|
620
|
+
));
|
|
621
|
+
}
|
|
622
|
+
if budget.attempt_budget == 0 || budget.max_age_seconds == 0 || budget.owner_epoch <= 0 {
|
|
623
|
+
return Err(MessageStoreError::RecoveryRefused(
|
|
624
|
+
"invalid_recovery_budget",
|
|
625
|
+
));
|
|
626
|
+
}
|
|
627
|
+
let conn = crate::db::schema::open_db(&self.path)?;
|
|
628
|
+
let row: Option<(String, i64, String)> = conn
|
|
629
|
+
.query_row(
|
|
630
|
+
"select status, delivery_attempts, updated_at
|
|
631
|
+
from messages where message_id = ?1",
|
|
632
|
+
params![message_id],
|
|
633
|
+
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
|
634
|
+
)
|
|
635
|
+
.optional()?;
|
|
636
|
+
let Some((status, attempts, updated_at)) = row else {
|
|
637
|
+
return Ok(false);
|
|
638
|
+
};
|
|
639
|
+
if status != MessageRowStatus::SubmittedAwaitingReceipt.as_str()
|
|
640
|
+
&& status != MessageRowStatus::SubmittedUnverified.as_str()
|
|
641
|
+
{
|
|
642
|
+
return Err(MessageStoreError::RecoveryRefused(
|
|
643
|
+
"message_not_submitted_recoverable",
|
|
644
|
+
));
|
|
645
|
+
}
|
|
646
|
+
if attempts >= i64::from(budget.attempt_budget) {
|
|
647
|
+
return Err(MessageStoreError::RecoveryRefused(
|
|
648
|
+
"attempt_budget_exhausted",
|
|
649
|
+
));
|
|
650
|
+
}
|
|
651
|
+
let updated_at = chrono::DateTime::parse_from_rfc3339(&updated_at)
|
|
652
|
+
.map_err(|_| MessageStoreError::RecoveryRefused("invalid_updated_at"))?;
|
|
653
|
+
let age = chrono::Utc::now()
|
|
654
|
+
.signed_duration_since(updated_at.with_timezone(&chrono::Utc))
|
|
655
|
+
.num_seconds();
|
|
656
|
+
if age < 0 || u64::try_from(age).unwrap_or(u64::MAX) > budget.max_age_seconds {
|
|
657
|
+
return Err(MessageStoreError::RecoveryRefused("message_too_old"));
|
|
658
|
+
}
|
|
659
|
+
let changed = conn.execute(
|
|
660
|
+
"update messages
|
|
661
|
+
set status = ?1, error = ?2, updated_at = ?3
|
|
662
|
+
where message_id = ?4
|
|
663
|
+
and status in (?5, ?6)
|
|
664
|
+
and delivery_attempts < ?7",
|
|
665
|
+
params![
|
|
666
|
+
MessageRowStatus::Accepted.as_str(),
|
|
667
|
+
format!("explicit_recovery:{incident_id}:{operator_reason}"),
|
|
668
|
+
now_ts(),
|
|
669
|
+
message_id,
|
|
670
|
+
MessageRowStatus::SubmittedAwaitingReceipt.as_str(),
|
|
671
|
+
MessageRowStatus::SubmittedUnverified.as_str(),
|
|
672
|
+
budget.attempt_budget
|
|
673
|
+
],
|
|
674
|
+
)?;
|
|
675
|
+
Ok(changed == 1)
|
|
676
|
+
}
|
|
677
|
+
|
|
400
678
|
/// Read inbox rows for an agent. This projection intentionally has no owner-team
|
|
401
679
|
/// filter when the caller does not provide one: legacy/CLI inbox must surface
|
|
402
680
|
/// NULL-owner messages stored for the agent.
|
|
@@ -863,6 +1141,7 @@ mod tests {
|
|
|
863
1141
|
let mid = s
|
|
864
1142
|
.create_message(Some("t"), "s", "r", "c", None, true, None)
|
|
865
1143
|
.unwrap();
|
|
1144
|
+
s.mark(&mid, "delivered", None).unwrap();
|
|
866
1145
|
s.mark(&mid, "acknowledged", None).unwrap();
|
|
867
1146
|
assert_eq!(status_of(&read(&s), &mid), "acknowledged");
|
|
868
1147
|
assert!(col_str(&read(&s), &mid, "acknowledged_at").is_some());
|
|
@@ -882,15 +1161,15 @@ mod tests {
|
|
|
882
1161
|
}
|
|
883
1162
|
|
|
884
1163
|
#[test]
|
|
885
|
-
fn
|
|
886
|
-
// 'failed' is NOT in the guarded delivery set → it overwrites acknowledged.
|
|
1164
|
+
fn mark_acknowledged_then_failed_stays_terminal() {
|
|
887
1165
|
let s = store();
|
|
888
1166
|
let mid = s
|
|
889
1167
|
.create_message(Some("t"), "s", "r", "c", None, true, None)
|
|
890
1168
|
.unwrap();
|
|
1169
|
+
s.mark(&mid, "delivered", None).unwrap();
|
|
891
1170
|
s.mark(&mid, "acknowledged", None).unwrap();
|
|
892
1171
|
s.mark(&mid, "failed", Some("x")).unwrap();
|
|
893
|
-
assert_eq!(status_of(&read(&s), &mid), "
|
|
1172
|
+
assert_eq!(status_of(&read(&s), &mid), "acknowledged");
|
|
894
1173
|
}
|
|
895
1174
|
|
|
896
1175
|
#[test]
|
|
@@ -76,6 +76,7 @@ use crate::state::StateError;
|
|
|
76
76
|
|
|
77
77
|
// ── submodules(by responsibility) ──────────────────────────────────────────
|
|
78
78
|
mod helpers;
|
|
79
|
+
pub mod incident;
|
|
79
80
|
pub mod inject;
|
|
80
81
|
pub mod lease;
|
|
81
82
|
pub mod owner_bind;
|
|
@@ -87,6 +88,7 @@ pub mod takeover;
|
|
|
87
88
|
pub mod types;
|
|
88
89
|
|
|
89
90
|
// ── RE-EXPORT INVARIANT:每个先前 root-visible 项原路径不变 ────────────────────
|
|
91
|
+
pub use incident::*;
|
|
90
92
|
pub use inject::*;
|
|
91
93
|
pub use lease::*;
|
|
92
94
|
pub use owner_bind::*;
|
|
@@ -57,66 +57,13 @@ pub fn fork_agent_with_transport(
|
|
|
57
57
|
let text = std::fs::read_to_string(&read_spec_path)
|
|
58
58
|
.map_err(|e| LifecycleError::Compile(format!("{}: {e}", read_spec_path.display())))?;
|
|
59
59
|
let spec = yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
60
|
-
if
|
|
60
|
+
if fork_spec_agent(&spec, as_agent_id).is_some() || leader_id_matches(&spec, as_agent_id) {
|
|
61
61
|
return Err(LifecycleError::RequirementUnmet(format!(
|
|
62
62
|
"agent id already exists: {as_agent_id}"
|
|
63
63
|
)));
|
|
64
64
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
})?;
|
|
68
|
-
// Fork requires the complete source tuple before treating session_id as
|
|
69
|
-
// resumable truth; a scalar-only row has no confirmed backing.
|
|
70
|
-
let source_agent_state = state
|
|
71
|
-
.get("agents")
|
|
72
|
-
.and_then(|v| v.get(source_agent_id.as_str()))
|
|
73
|
-
.ok_or_else(|| {
|
|
74
|
-
LifecycleError::Provider(format!(
|
|
75
|
-
"cannot fork {source_agent_id}: source agent row not in state"
|
|
76
|
-
))
|
|
77
|
-
})?;
|
|
78
|
-
let tuple_field_ok = |field: &str| -> bool {
|
|
79
|
-
source_agent_state
|
|
80
|
-
.get(field)
|
|
81
|
-
.and_then(|v| v.as_str())
|
|
82
|
-
.is_some_and(|s| !s.is_empty())
|
|
83
|
-
};
|
|
84
|
-
let session_id_str = source_agent_state
|
|
85
|
-
.get("session_id")
|
|
86
|
-
.and_then(|v| v.as_str())
|
|
87
|
-
.filter(|s| !s.is_empty());
|
|
88
|
-
let rollout_path_str = source_agent_state
|
|
89
|
-
.get("rollout_path")
|
|
90
|
-
.and_then(|v| v.as_str())
|
|
91
|
-
.filter(|s| !s.is_empty());
|
|
92
|
-
if session_id_str.is_none()
|
|
93
|
-
|| rollout_path_str.is_none()
|
|
94
|
-
|| !tuple_field_ok("captured_at")
|
|
95
|
-
|| !tuple_field_ok("captured_via")
|
|
96
|
-
{
|
|
97
|
-
return Err(LifecycleError::Provider(format!(
|
|
98
|
-
"cannot fork {source_agent_id}: source session backing is missing or incomplete \
|
|
99
|
-
(session_id+rollout_path+captured_at+captured_via required)"
|
|
100
|
-
)));
|
|
101
|
-
}
|
|
102
|
-
let Some(source_backing_raw) = rollout_path_str else {
|
|
103
|
-
return Err(LifecycleError::Provider(format!(
|
|
104
|
-
"cannot fork {source_agent_id}: source session backing is missing"
|
|
105
|
-
)));
|
|
106
|
-
};
|
|
107
|
-
let source_backing = Path::new(source_backing_raw);
|
|
108
|
-
if !source_backing.is_file() {
|
|
109
|
-
return Err(LifecycleError::Provider(format!(
|
|
110
|
-
"cannot fork {source_agent_id}: source session backing is not readable: {}",
|
|
111
|
-
source_backing.display()
|
|
112
|
-
)));
|
|
113
|
-
}
|
|
114
|
-
let Some(source_session_id) = session_id_str else {
|
|
115
|
-
return Err(LifecycleError::Provider(format!(
|
|
116
|
-
"cannot fork {source_agent_id}: source session id is missing"
|
|
117
|
-
)));
|
|
118
|
-
};
|
|
119
|
-
let session_id = crate::provider::SessionId::new(source_session_id.to_string());
|
|
65
|
+
// Source existence authority: state.get("agents"), matching clone-agent.
|
|
66
|
+
let (session_id, source_backing) = fork_source_tuple(&state, source_agent_id)?;
|
|
120
67
|
let session_name = state
|
|
121
68
|
.get("session_name")
|
|
122
69
|
.and_then(|v| v.as_str())
|
|
@@ -158,7 +105,7 @@ pub fn fork_agent_with_transport(
|
|
|
158
105
|
crate::model::spec::validate_spec(&new_spec, &validate_ws)
|
|
159
106
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
160
107
|
write_spec_atomic(&spec_path, &new_spec)?;
|
|
161
|
-
let new_agent =
|
|
108
|
+
let new_agent = fork_spec_agent(&new_spec, as_agent_id).ok_or_else(|| {
|
|
162
109
|
LifecycleError::RequirementUnmet(format!("unknown worker agent id: {as_agent_id}"))
|
|
163
110
|
})?;
|
|
164
111
|
let provider = new_agent
|
|
@@ -296,13 +243,21 @@ pub fn fork_agent_with_transport(
|
|
|
296
243
|
plan.provider_projects_root = source_backing.parent().map(Path::to_path_buf);
|
|
297
244
|
}
|
|
298
245
|
let window = WindowName::new(as_agent_id.as_str());
|
|
246
|
+
let mut claude_fork = prepare_claude_fork_backing(
|
|
247
|
+
provider,
|
|
248
|
+
&plan,
|
|
249
|
+
&source_backing,
|
|
250
|
+
&session_id,
|
|
251
|
+
)
|
|
252
|
+
.map_err(|error| {
|
|
253
|
+
let _ = std::fs::write(&spec_path, text.as_bytes());
|
|
254
|
+
cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
|
|
255
|
+
error
|
|
256
|
+
})?;
|
|
257
|
+
// The framework-created Claude snapshot is only fork input, not provider
|
|
258
|
+
// proof. Observe changes made after materialization so a spawn-only
|
|
259
|
+
// provider cannot turn the copied source backing into a Verified result.
|
|
299
260
|
let backing_before = crate::provider::session::ContextBackingSnapshot::capture(provider, &plan);
|
|
300
|
-
let mut claude_fork = prepare_claude_fork_backing(provider, &plan, source_backing, &session_id)
|
|
301
|
-
.map_err(|error| {
|
|
302
|
-
let _ = std::fs::write(&spec_path, text.as_bytes());
|
|
303
|
-
cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
|
|
304
|
-
error
|
|
305
|
-
})?;
|
|
306
261
|
let mut env =
|
|
307
262
|
inherited_env_with_team_overrides(&workspace, as_agent_id.as_str(), Some(&fork_team));
|
|
308
263
|
apply_profile_launch_env(&mut env, &profile_launch);
|
|
@@ -401,7 +356,7 @@ pub fn fork_agent_with_transport(
|
|
|
401
356
|
})?;
|
|
402
357
|
let convergence_deadline =
|
|
403
358
|
crate::provider::session::context_fork_convergence_deadline(provider);
|
|
404
|
-
let
|
|
359
|
+
let context_outcome = crate::provider::session::observe_context_fork(
|
|
405
360
|
provider,
|
|
406
361
|
&session_id,
|
|
407
362
|
&plan,
|
|
@@ -411,9 +366,40 @@ pub fn fork_agent_with_transport(
|
|
|
411
366
|
&workspace,
|
|
412
367
|
&spawned_at,
|
|
413
368
|
convergence_deadline,
|
|
414
|
-
)
|
|
415
|
-
|
|
416
|
-
|
|
369
|
+
);
|
|
370
|
+
let context_proof = match context_outcome {
|
|
371
|
+
crate::provider::session::ContextForkOutcome::Verified(proof) => Some(proof),
|
|
372
|
+
crate::provider::session::ContextForkOutcome::Pending(pending) => {
|
|
373
|
+
if let Err(error) = finalize_pending_fork_state(ForkPendingFinalizeInput {
|
|
374
|
+
workspace: &workspace,
|
|
375
|
+
team_key: &fork_team,
|
|
376
|
+
source_agent_id,
|
|
377
|
+
agent_id: as_agent_id,
|
|
378
|
+
spec_agent: new_agent,
|
|
379
|
+
safety: &safety,
|
|
380
|
+
plan: &plan,
|
|
381
|
+
profile_launch: &profile_launch,
|
|
382
|
+
spawn: &spawn,
|
|
383
|
+
profile_dir: &profile_dir,
|
|
384
|
+
dynamic_role_file: materialized_role.path(),
|
|
385
|
+
pending: &pending,
|
|
386
|
+
spawn_epoch,
|
|
387
|
+
}) {
|
|
388
|
+
rollback_fork_after_spawn(
|
|
389
|
+
&workspace,
|
|
390
|
+
transport,
|
|
391
|
+
&session_name,
|
|
392
|
+
&window,
|
|
393
|
+
&mcp_config_path,
|
|
394
|
+
as_agent_id,
|
|
395
|
+
&profile_launch,
|
|
396
|
+
&fork_team,
|
|
397
|
+
);
|
|
398
|
+
return Err(error);
|
|
399
|
+
}
|
|
400
|
+
None
|
|
401
|
+
}
|
|
402
|
+
crate::provider::session::ContextForkOutcome::Rejected(error) => {
|
|
417
403
|
rollback_fork_after_spawn(
|
|
418
404
|
&workspace,
|
|
419
405
|
transport,
|
|
@@ -427,33 +413,35 @@ pub fn fork_agent_with_transport(
|
|
|
427
413
|
return Err(LifecycleError::Provider(error.to_string()));
|
|
428
414
|
}
|
|
429
415
|
};
|
|
430
|
-
if let
|
|
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
|
-
|
|
416
|
+
if let Some(context_proof) = context_proof.as_ref() {
|
|
417
|
+
if let Err(error) = finalize_fork_state(ForkFinalizeInput {
|
|
418
|
+
workspace: &workspace,
|
|
419
|
+
team_key: &fork_team,
|
|
420
|
+
source_agent_id,
|
|
421
|
+
agent_id: as_agent_id,
|
|
422
|
+
spec_agent: new_agent,
|
|
423
|
+
safety: &safety,
|
|
424
|
+
plan: &plan,
|
|
425
|
+
profile_launch: &profile_launch,
|
|
426
|
+
spawn: &spawn,
|
|
427
|
+
profile_dir: &profile_dir,
|
|
428
|
+
dynamic_role_file: materialized_role.path(),
|
|
429
|
+
context_proof: &context_proof,
|
|
430
|
+
spawned_at: &spawned_at,
|
|
431
|
+
spawn_epoch,
|
|
432
|
+
}) {
|
|
433
|
+
rollback_fork_after_spawn(
|
|
434
|
+
&workspace,
|
|
435
|
+
transport,
|
|
436
|
+
&session_name,
|
|
437
|
+
&window,
|
|
438
|
+
&mcp_config_path,
|
|
439
|
+
as_agent_id,
|
|
440
|
+
&profile_launch,
|
|
441
|
+
&fork_team,
|
|
442
|
+
);
|
|
443
|
+
return Err(error);
|
|
444
|
+
}
|
|
457
445
|
}
|
|
458
446
|
if let Err(error) =
|
|
459
447
|
verify_fork_registration(&workspace, &fork_team, as_agent_id, &spawn, &window)
|
|
@@ -487,6 +475,11 @@ pub fn fork_agent_with_transport(
|
|
|
487
475
|
if let Some(materialized) = copilot_fork.as_mut() {
|
|
488
476
|
materialized.keep();
|
|
489
477
|
}
|
|
478
|
+
let backing_state = if context_proof.is_some() {
|
|
479
|
+
ForkBackingState::Verified
|
|
480
|
+
} else {
|
|
481
|
+
ForkBackingState::PendingContextFork
|
|
482
|
+
};
|
|
490
483
|
Ok(ForkAgentReport {
|
|
491
484
|
source_agent_id: source_agent_id.clone(),
|
|
492
485
|
new_agent_id: as_agent_id.clone(),
|
|
@@ -495,6 +488,7 @@ pub fn fork_agent_with_transport(
|
|
|
495
488
|
state_file: crate::state::persist::runtime_state_path(&workspace),
|
|
496
489
|
coordinator_started,
|
|
497
490
|
},
|
|
498
|
-
session_id:
|
|
491
|
+
session_id: context_proof.map(|proof| proof.new_session_id),
|
|
492
|
+
backing_state,
|
|
499
493
|
})
|
|
500
494
|
}
|