@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
|
@@ -172,6 +172,110 @@ pub(super) fn finalize_fork_state(input: ForkFinalizeInput<'_>) -> Result<(), Li
|
|
|
172
172
|
.map_err(|error| LifecycleError::StatePersist(error.to_string()))
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
pub(super) struct ForkPendingFinalizeInput<'a> {
|
|
176
|
+
pub workspace: &'a Path,
|
|
177
|
+
pub team_key: &'a str,
|
|
178
|
+
pub source_agent_id: &'a AgentId,
|
|
179
|
+
pub agent_id: &'a AgentId,
|
|
180
|
+
pub spec_agent: &'a Value,
|
|
181
|
+
pub safety: &'a DangerousApproval,
|
|
182
|
+
pub plan: &'a crate::provider::CommandPlan,
|
|
183
|
+
pub profile_launch: &'a crate::provider::ProviderProfileLaunch,
|
|
184
|
+
pub spawn: &'a crate::transport::SpawnResult,
|
|
185
|
+
pub profile_dir: &'a Path,
|
|
186
|
+
pub dynamic_role_file: &'a Path,
|
|
187
|
+
pub pending: &'a crate::provider::session::PendingContextFork,
|
|
188
|
+
pub spawn_epoch: u64,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
pub(super) fn finalize_pending_fork_state(
|
|
192
|
+
input: ForkPendingFinalizeInput<'_>,
|
|
193
|
+
) -> Result<(), LifecycleError> {
|
|
194
|
+
let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
|
|
195
|
+
workspace: input.workspace,
|
|
196
|
+
operation: "fork-agent-pending",
|
|
197
|
+
team: Some(input.team_key),
|
|
198
|
+
agent_id: Some(input.agent_id),
|
|
199
|
+
})?;
|
|
200
|
+
let mut next_state = crate::state::selector::resolve_active_team(
|
|
201
|
+
input.workspace,
|
|
202
|
+
Some(input.team_key),
|
|
203
|
+
crate::state::selector::SelectorMode::RequireSpec,
|
|
204
|
+
)
|
|
205
|
+
.map_err(|error| LifecycleError::TeamSelect(error.to_string()))?
|
|
206
|
+
.state;
|
|
207
|
+
upsert_pending_forked_agent_state(
|
|
208
|
+
&mut next_state,
|
|
209
|
+
input.source_agent_id,
|
|
210
|
+
input.agent_id,
|
|
211
|
+
input.spec_agent,
|
|
212
|
+
input.safety,
|
|
213
|
+
input.plan,
|
|
214
|
+
input.profile_launch,
|
|
215
|
+
input.spawn,
|
|
216
|
+
Some(input.profile_dir),
|
|
217
|
+
input.dynamic_role_file,
|
|
218
|
+
input.pending,
|
|
219
|
+
input.spawn_epoch,
|
|
220
|
+
)?;
|
|
221
|
+
maybe_fail_fork_after_spawn("save_runtime_state")?;
|
|
222
|
+
crate::state::repository::StateRepository::new(input.workspace)
|
|
223
|
+
.save(
|
|
224
|
+
crate::state::repository::StateWriteIntent::ForkAgent {
|
|
225
|
+
team_key: input.team_key,
|
|
226
|
+
agent_id: input.agent_id.as_str(),
|
|
227
|
+
},
|
|
228
|
+
&next_state,
|
|
229
|
+
)
|
|
230
|
+
.map_err(|error| LifecycleError::StatePersist(error.to_string()))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
pub(crate) fn finalize_pending_fork_capture(
|
|
234
|
+
agent: &mut serde_json::Map<String, serde_json::Value>,
|
|
235
|
+
captured: &crate::provider::CapturedSession,
|
|
236
|
+
) -> bool {
|
|
237
|
+
let Some(session_id) = captured.session_id.as_ref() else {
|
|
238
|
+
return false;
|
|
239
|
+
};
|
|
240
|
+
let Some(rollout_path) = captured.rollout_path.as_ref() else {
|
|
241
|
+
return false;
|
|
242
|
+
};
|
|
243
|
+
if agent
|
|
244
|
+
.get("fork_source_session_id")
|
|
245
|
+
.and_then(serde_json::Value::as_str)
|
|
246
|
+
== Some(session_id.as_str())
|
|
247
|
+
{
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
agent.insert(
|
|
251
|
+
"session_id".to_string(),
|
|
252
|
+
serde_json::json!(session_id.as_str()),
|
|
253
|
+
);
|
|
254
|
+
agent.insert(
|
|
255
|
+
"rollout_path".to_string(),
|
|
256
|
+
serde_json::json!(rollout_path.as_path().to_string_lossy()),
|
|
257
|
+
);
|
|
258
|
+
agent.insert(
|
|
259
|
+
"captured_at".to_string(),
|
|
260
|
+
serde_json::json!(chrono::Utc::now().to_rfc3339()),
|
|
261
|
+
);
|
|
262
|
+
agent.insert(
|
|
263
|
+
"captured_via".to_string(),
|
|
264
|
+
serde_json::to_value(captured.captured_via).unwrap_or(serde_json::Value::Null),
|
|
265
|
+
);
|
|
266
|
+
agent.insert(
|
|
267
|
+
"attribution_confidence".to_string(),
|
|
268
|
+
serde_json::to_value(captured.attribution_confidence).unwrap_or(serde_json::Value::Null),
|
|
269
|
+
);
|
|
270
|
+
agent.remove("_pending_session_id");
|
|
271
|
+
agent.remove("attribution_ambiguous");
|
|
272
|
+
agent.remove("fork_source_session_id");
|
|
273
|
+
agent.remove("pending_target_agent");
|
|
274
|
+
agent.remove("pending_grace_secs");
|
|
275
|
+
agent.insert("capture_state".to_string(), serde_json::json!("captured"));
|
|
276
|
+
true
|
|
277
|
+
}
|
|
278
|
+
|
|
175
279
|
pub(super) fn verify_fork_registration(
|
|
176
280
|
workspace: &Path,
|
|
177
281
|
team_key: &str,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
#[allow(clippy::too_many_arguments)]
|
|
4
|
+
pub(crate) fn upsert_pending_forked_agent_state(
|
|
5
|
+
state: &mut serde_json::Value,
|
|
6
|
+
source_agent_id: &AgentId,
|
|
7
|
+
as_agent_id: &AgentId,
|
|
8
|
+
spec_agent: &Value,
|
|
9
|
+
safety: &DangerousApproval,
|
|
10
|
+
plan: &crate::provider::CommandPlan,
|
|
11
|
+
profile_launch: &crate::provider::ProviderProfileLaunch,
|
|
12
|
+
spawn: &crate::transport::SpawnResult,
|
|
13
|
+
profile_dir: Option<&Path>,
|
|
14
|
+
dynamic_role_file: &Path,
|
|
15
|
+
pending: &crate::provider::session::PendingContextFork,
|
|
16
|
+
spawn_epoch: u64,
|
|
17
|
+
) -> Result<(), LifecycleError> {
|
|
18
|
+
let root = state.as_object_mut().ok_or_else(|| {
|
|
19
|
+
LifecycleError::StatePersist("runtime state root is not an object".to_string())
|
|
20
|
+
})?;
|
|
21
|
+
let agents = root
|
|
22
|
+
.entry("agents".to_string())
|
|
23
|
+
.or_insert_with(|| serde_json::json!({}))
|
|
24
|
+
.as_object_mut()
|
|
25
|
+
.ok_or_else(|| {
|
|
26
|
+
LifecycleError::StatePersist("runtime state agents is not an object".to_string())
|
|
27
|
+
})?;
|
|
28
|
+
let mut entry = serde_json::Map::new();
|
|
29
|
+
entry.insert("status".to_string(), serde_json::json!("running"));
|
|
30
|
+
entry.insert(
|
|
31
|
+
"capture_state".to_string(),
|
|
32
|
+
serde_json::json!("pending_context_fork"),
|
|
33
|
+
);
|
|
34
|
+
entry.insert(
|
|
35
|
+
"agent_id".to_string(),
|
|
36
|
+
serde_json::json!(as_agent_id.as_str()),
|
|
37
|
+
);
|
|
38
|
+
entry.insert(
|
|
39
|
+
"window".to_string(),
|
|
40
|
+
serde_json::json!(as_agent_id.as_str()),
|
|
41
|
+
);
|
|
42
|
+
entry.insert(
|
|
43
|
+
"forked_from".to_string(),
|
|
44
|
+
serde_json::json!(source_agent_id.as_str()),
|
|
45
|
+
);
|
|
46
|
+
entry.insert(
|
|
47
|
+
"fork_source_session_id".to_string(),
|
|
48
|
+
serde_json::json!(pending.source_session_id.as_str()),
|
|
49
|
+
);
|
|
50
|
+
entry.insert(
|
|
51
|
+
"pending_target_agent".to_string(),
|
|
52
|
+
serde_json::json!(pending.target_agent),
|
|
53
|
+
);
|
|
54
|
+
entry.insert(
|
|
55
|
+
"dynamic_role_file".to_string(),
|
|
56
|
+
serde_json::json!(dynamic_role_file.to_string_lossy().to_string()),
|
|
57
|
+
);
|
|
58
|
+
entry.insert(
|
|
59
|
+
"role_source_ownership".to_string(),
|
|
60
|
+
serde_json::json!("managed"),
|
|
61
|
+
);
|
|
62
|
+
entry.insert(
|
|
63
|
+
"spawn_cwd".to_string(),
|
|
64
|
+
serde_json::json!(pending
|
|
65
|
+
.scanner_context
|
|
66
|
+
.spawn_cwd
|
|
67
|
+
.to_string_lossy()
|
|
68
|
+
.to_string()),
|
|
69
|
+
);
|
|
70
|
+
entry.insert(
|
|
71
|
+
"pane_id".to_string(),
|
|
72
|
+
serde_json::json!(spawn.pane_id.as_str()),
|
|
73
|
+
);
|
|
74
|
+
entry.insert(
|
|
75
|
+
"spawned_at".to_string(),
|
|
76
|
+
serde_json::json!(pending.spawned_at),
|
|
77
|
+
);
|
|
78
|
+
entry.insert("spawn_epoch".to_string(), serde_json::json!(spawn_epoch));
|
|
79
|
+
if let Some(pid) = spawn.child_pid {
|
|
80
|
+
entry.insert("pane_pid".to_string(), serde_json::json!(pid));
|
|
81
|
+
}
|
|
82
|
+
for key in [
|
|
83
|
+
"provider",
|
|
84
|
+
"auth_mode",
|
|
85
|
+
"model",
|
|
86
|
+
"profile",
|
|
87
|
+
"role",
|
|
88
|
+
"effort",
|
|
89
|
+
] {
|
|
90
|
+
if let Some(value) = spec_agent.get(key) {
|
|
91
|
+
entry.insert(key.to_string(), yaml_value_to_json(value));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if spec_agent.get("profile").is_some() {
|
|
95
|
+
if let Some(profile_dir) = profile_dir {
|
|
96
|
+
entry.insert(
|
|
97
|
+
"_profile_dir".to_string(),
|
|
98
|
+
serde_json::json!(profile_dir.to_string_lossy().to_string()),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
persist_command_plan_state(&mut entry, plan, profile_launch);
|
|
103
|
+
persist_effective_approval_policy(&mut entry, safety);
|
|
104
|
+
agents.insert(
|
|
105
|
+
as_agent_id.as_str().to_string(),
|
|
106
|
+
serde_json::Value::Object(entry),
|
|
107
|
+
);
|
|
108
|
+
Ok(())
|
|
109
|
+
}
|
|
@@ -14,6 +14,10 @@ use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest}
|
|
|
14
14
|
|
|
15
15
|
use super::*;
|
|
16
16
|
|
|
17
|
+
#[path = "fork_pending.rs"]
|
|
18
|
+
mod fork_pending;
|
|
19
|
+
pub(super) use fork_pending::upsert_pending_forked_agent_state;
|
|
20
|
+
|
|
17
21
|
pub(super) fn reserve_forked_agent_state(
|
|
18
22
|
state: &mut serde_json::Value,
|
|
19
23
|
source_agent_id: &AgentId,
|
|
@@ -134,6 +138,51 @@ pub(super) fn find_spec_agent<'a>(spec: &'a Value, agent_id: &AgentId) -> Option
|
|
|
134
138
|
})
|
|
135
139
|
}
|
|
136
140
|
|
|
141
|
+
pub(super) fn fork_spec_agent<'a>(spec: &'a Value, agent_id: &AgentId) -> Option<&'a Value> {
|
|
142
|
+
find_spec_agent(spec, agent_id)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
pub(super) fn fork_source_tuple(
|
|
146
|
+
state: &serde_json::Value,
|
|
147
|
+
source_agent_id: &AgentId,
|
|
148
|
+
) -> Result<(crate::provider::SessionId, PathBuf), LifecycleError> {
|
|
149
|
+
let source = state
|
|
150
|
+
.get("agents")
|
|
151
|
+
.and_then(|agents| agents.get(source_agent_id.as_str()))
|
|
152
|
+
.ok_or_else(|| {
|
|
153
|
+
LifecycleError::Provider(format!("unknown worker agent id: {source_agent_id}"))
|
|
154
|
+
})?;
|
|
155
|
+
let field = |name: &str| {
|
|
156
|
+
source
|
|
157
|
+
.get(name)
|
|
158
|
+
.and_then(serde_json::Value::as_str)
|
|
159
|
+
.filter(|value| !value.is_empty())
|
|
160
|
+
};
|
|
161
|
+
let (Some(session_id), Some(backing)) = (field("session_id"), field("rollout_path")) else {
|
|
162
|
+
return Err(LifecycleError::Provider(format!(
|
|
163
|
+
"cannot fork {source_agent_id}: source session backing is missing or incomplete \
|
|
164
|
+
(session_id+rollout_path+captured_at+captured_via required)"
|
|
165
|
+
)));
|
|
166
|
+
};
|
|
167
|
+
if field("captured_at").is_none() || field("captured_via").is_none() {
|
|
168
|
+
return Err(LifecycleError::Provider(format!(
|
|
169
|
+
"cannot fork {source_agent_id}: source session backing is missing or incomplete \
|
|
170
|
+
(session_id+rollout_path+captured_at+captured_via required)"
|
|
171
|
+
)));
|
|
172
|
+
}
|
|
173
|
+
let backing = PathBuf::from(backing);
|
|
174
|
+
if !backing.is_file() {
|
|
175
|
+
return Err(LifecycleError::Provider(format!(
|
|
176
|
+
"cannot fork {source_agent_id}: source session backing is not readable: {}",
|
|
177
|
+
backing.display()
|
|
178
|
+
)));
|
|
179
|
+
}
|
|
180
|
+
Ok((
|
|
181
|
+
crate::provider::SessionId::new(session_id.to_string()),
|
|
182
|
+
backing,
|
|
183
|
+
))
|
|
184
|
+
}
|
|
185
|
+
|
|
137
186
|
pub(super) fn append_forked_agent(
|
|
138
187
|
spec: &Value,
|
|
139
188
|
source_agent: &Value,
|
|
@@ -664,6 +664,14 @@ pub struct ForkAgentReport {
|
|
|
664
664
|
pub new_agent_id: AgentId,
|
|
665
665
|
pub env: AgentActionEnvelope,
|
|
666
666
|
pub session_id: Option<SessionId>,
|
|
667
|
+
pub backing_state: ForkBackingState,
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
671
|
+
#[serde(rename_all = "snake_case")]
|
|
672
|
+
pub enum ForkBackingState {
|
|
673
|
+
Verified,
|
|
674
|
+
PendingContextFork,
|
|
667
675
|
}
|
|
668
676
|
|
|
669
677
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
@@ -130,6 +130,7 @@ pub(crate) fn fork_agent(
|
|
|
130
130
|
"state_file": report.env.state_file.to_string_lossy().to_string(),
|
|
131
131
|
"coordinator_started": report.env.coordinator_started,
|
|
132
132
|
"session_id": report.session_id.as_ref().map(|session| session.as_str()),
|
|
133
|
+
"backing_state": report.backing_state,
|
|
133
134
|
})),
|
|
134
135
|
})
|
|
135
136
|
}
|
|
@@ -848,35 +848,7 @@ pub fn requeue_worker_target_missing_messages(
|
|
|
848
848
|
recipient: &str,
|
|
849
849
|
owner_team_id: Option<&str>,
|
|
850
850
|
) -> Result<Vec<String>, MessagingError> {
|
|
851
|
-
let
|
|
852
|
-
let now = chrono::Utc::now().to_rfc3339();
|
|
853
|
-
let mut stmt = conn.prepare(
|
|
854
|
-
"select message_id from messages
|
|
855
|
-
where recipient = ?1
|
|
856
|
-
and status = 'queued_pane_missing'
|
|
857
|
-
and error = 'tmux_target_missing'
|
|
858
|
-
and (
|
|
859
|
-
(?2 is null and owner_team_id is null)
|
|
860
|
-
or owner_team_id = ?2
|
|
861
|
-
)
|
|
862
|
-
order by created_at, message_id",
|
|
863
|
-
)?;
|
|
864
|
-
let ids = stmt
|
|
865
|
-
.query_map(params![recipient, owner_team_id], |row| {
|
|
866
|
-
row.get::<_, String>(0)
|
|
867
|
-
})?
|
|
868
|
-
.collect::<Result<Vec<_>, _>>()?;
|
|
869
|
-
drop(stmt);
|
|
870
|
-
for message_id in &ids {
|
|
871
|
-
conn.execute(
|
|
872
|
-
"update messages
|
|
873
|
-
set status = 'accepted',
|
|
874
|
-
error = null,
|
|
875
|
-
updated_at = ?2
|
|
876
|
-
where message_id = ?1",
|
|
877
|
-
params![message_id, now.as_str()],
|
|
878
|
-
)?;
|
|
879
|
-
}
|
|
851
|
+
let ids = store.recover_worker_pane_available(recipient, owner_team_id)?;
|
|
880
852
|
if !ids.is_empty() {
|
|
881
853
|
event_log.write(
|
|
882
854
|
"worker_receiver.blocked_messages_requeued",
|
|
@@ -2446,10 +2418,8 @@ fn save_scoped_state(
|
|
|
2446
2418
|
state: &serde_json::Value,
|
|
2447
2419
|
owner_team_id: Option<&str>,
|
|
2448
2420
|
) -> Result<(), MessagingError> {
|
|
2449
|
-
let scoped_owner_team_id = owner_team_id
|
|
2450
|
-
|
|
2451
|
-
.filter(|_| {
|
|
2452
|
-
state
|
|
2421
|
+
let scoped_owner_team_id = owner_team_id.filter(|team| !team.is_empty()).filter(|_| {
|
|
2422
|
+
state
|
|
2453
2423
|
.get("teams")
|
|
2454
2424
|
.and_then(serde_json::Value::as_object)
|
|
2455
2425
|
.is_some_and(|teams| {
|
|
@@ -2461,7 +2431,7 @@ fn save_scoped_state(
|
|
|
2461
2431
|
})
|
|
2462
2432
|
.is_some_and(|team| teams.contains_key(&team))
|
|
2463
2433
|
})
|
|
2464
|
-
|
|
2434
|
+
});
|
|
2465
2435
|
crate::state::repository::StateRepository::new(workspace).save(
|
|
2466
2436
|
crate::state::repository::StateWriteIntent::MessagingDeliveryState {
|
|
2467
2437
|
owner_team_id: scoped_owner_team_id,
|
|
@@ -2480,10 +2450,8 @@ fn save_scoped_state_reapplying_after_conflict<F>(
|
|
|
2480
2450
|
where
|
|
2481
2451
|
F: FnOnce(&mut serde_json::Value),
|
|
2482
2452
|
{
|
|
2483
|
-
let scoped_owner_team_id = owner_team_id
|
|
2484
|
-
|
|
2485
|
-
.filter(|_| {
|
|
2486
|
-
state
|
|
2453
|
+
let scoped_owner_team_id = owner_team_id.filter(|team| !team.is_empty()).filter(|_| {
|
|
2454
|
+
state
|
|
2487
2455
|
.get("teams")
|
|
2488
2456
|
.and_then(serde_json::Value::as_object)
|
|
2489
2457
|
.is_some_and(|teams| {
|
|
@@ -2495,7 +2463,7 @@ where
|
|
|
2495
2463
|
})
|
|
2496
2464
|
.is_some_and(|team| teams.contains_key(&team))
|
|
2497
2465
|
})
|
|
2498
|
-
|
|
2466
|
+
});
|
|
2499
2467
|
crate::state::repository::StateRepository::new(workspace).save_reapplying(
|
|
2500
2468
|
crate::state::repository::StateWriteIntent::MessagingDeliveryState {
|
|
2501
2469
|
owner_team_id: scoped_owner_team_id,
|
|
@@ -23,6 +23,7 @@ fn stuck_cancel_snapshot_delivered_message_ids_uses_golden_status_set() {
|
|
|
23
23
|
let m_ack = store
|
|
24
24
|
.create_message(None, "leader", "w1", "ack-me", None, true, Some("teamX"))
|
|
25
25
|
.unwrap();
|
|
26
|
+
store.mark(&m_ack, "delivered", None).unwrap();
|
|
26
27
|
store.mark(&m_ack, "acknowledged", None).unwrap();
|
|
27
28
|
let m_vis = store
|
|
28
29
|
.create_message(None, "leader", "w1", "vis", None, true, Some("teamX"))
|
|
@@ -9,6 +9,12 @@ use crate::message_store::{MessageStore, NotificationClaimParams};
|
|
|
9
9
|
use crate::model::ids::{TaskId, TeamKey};
|
|
10
10
|
use crate::transport::PaneId;
|
|
11
11
|
|
|
12
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
13
|
+
pub struct RecoveryIncident {
|
|
14
|
+
pub incident_id: String,
|
|
15
|
+
pub occurred_at: String,
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
use super::{MessagingError, WatcherNotice, RESULT_DELIVERY_MAX_ATTEMPTS};
|
|
13
19
|
|
|
14
20
|
/// `notify_result_watchers` (`result_delivery.py:38`):匹配 + 去重 + (有界) 投递 result 给 leader
|
|
@@ -460,13 +466,31 @@ pub fn requeue_after_claim_leader(
|
|
|
460
466
|
)?;
|
|
461
467
|
}
|
|
462
468
|
let requeued_blocked =
|
|
463
|
-
requeue_blocked_leader_messages(
|
|
469
|
+
requeue_blocked_leader_messages(store, event_log, owner_team_id, claimed_pane_id)?;
|
|
464
470
|
if !out.is_empty() || requeued_blocked > 0 {
|
|
465
471
|
let _ = retry_result_deliveries(workspace, event_log)?;
|
|
466
472
|
}
|
|
467
473
|
Ok(out)
|
|
468
474
|
}
|
|
469
475
|
|
|
476
|
+
pub fn recover_watchers_for_incident(
|
|
477
|
+
workspace: &Path,
|
|
478
|
+
store: &MessageStore,
|
|
479
|
+
event_log: &EventLog,
|
|
480
|
+
owner_team_id: &TeamKey,
|
|
481
|
+
claimed_pane_id: &PaneId,
|
|
482
|
+
incident: &RecoveryIncident,
|
|
483
|
+
) -> Result<Vec<WatcherNotice>, MessagingError> {
|
|
484
|
+
requeue_after_claim_leader(
|
|
485
|
+
workspace,
|
|
486
|
+
store,
|
|
487
|
+
event_log,
|
|
488
|
+
owner_team_id,
|
|
489
|
+
claimed_pane_id,
|
|
490
|
+
Some(&incident.occurred_at),
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
|
|
470
494
|
/// 0.5.5 gate054 round-2: attach-leader (and claim-leader) requeue for leader messages
|
|
471
495
|
/// that were refused with `rebind_required` while no leader pane was attached.
|
|
472
496
|
///
|
|
@@ -477,7 +501,7 @@ pub fn requeue_after_claim_leader(
|
|
|
477
501
|
/// already crossed the transport boundary and is claim-immutable. Any future
|
|
478
502
|
/// retry belongs to a separate typed recovery arm, not attach/claim convergence.
|
|
479
503
|
pub(crate) fn requeue_blocked_leader_messages(
|
|
480
|
-
|
|
504
|
+
store: &MessageStore,
|
|
481
505
|
event_log: &EventLog,
|
|
482
506
|
owner_team_id: &TeamKey,
|
|
483
507
|
claimed_pane_id: &PaneId,
|
|
@@ -492,19 +516,8 @@ pub(crate) fn requeue_blocked_leader_messages(
|
|
|
492
516
|
// could not have churned while the leader was unattached.
|
|
493
517
|
// `submitted_pending_acceptance` remains parked and claim-immutable; a
|
|
494
518
|
// future typed recovery arm must own any deliberate retry.
|
|
495
|
-
let
|
|
496
|
-
|
|
497
|
-
set status = 'accepted',
|
|
498
|
-
error = null,
|
|
499
|
-
updated_at = ?2
|
|
500
|
-
where recipient = 'leader'
|
|
501
|
-
and owner_team_id = ?1
|
|
502
|
-
and (
|
|
503
|
-
(status = 'failed' and error = 'leader_not_attached')
|
|
504
|
-
or status = 'queued_until_leader_attach'
|
|
505
|
-
)",
|
|
506
|
-
params![owner_team_id.as_str(), chrono::Utc::now().to_rfc3339()],
|
|
507
|
-
)?;
|
|
519
|
+
let counts = store.requeue_blocked_leader_messages(owner_team_id.as_str())?;
|
|
520
|
+
let requeued = counts.total();
|
|
508
521
|
if requeued > 0 {
|
|
509
522
|
event_log.write(
|
|
510
523
|
"leader_receiver.blocked_messages_requeued",
|
|
@@ -512,6 +525,10 @@ pub(crate) fn requeue_blocked_leader_messages(
|
|
|
512
525
|
"team_id": owner_team_id.as_str(),
|
|
513
526
|
"claimed_pane_id": claimed_pane_id.as_str(),
|
|
514
527
|
"count": requeued,
|
|
528
|
+
"by_prior_state": {
|
|
529
|
+
"blocked_leader_unbound": counts.blocked_leader_unbound,
|
|
530
|
+
"queued_until_leader_attach": counts.queued_until_leader_attach,
|
|
531
|
+
},
|
|
515
532
|
}),
|
|
516
533
|
)?;
|
|
517
534
|
}
|
|
@@ -573,7 +590,7 @@ pub fn requeue_delivery_exhausted_watchers(
|
|
|
573
590
|
)?;
|
|
574
591
|
}
|
|
575
592
|
drop(stmt);
|
|
576
|
-
let _ = requeue_blocked_leader_messages(
|
|
593
|
+
let _ = requeue_blocked_leader_messages(store, event_log, owner_team_id, claimed_pane_id)?;
|
|
577
594
|
Ok(out)
|
|
578
595
|
}
|
|
579
596
|
|
|
@@ -469,13 +469,25 @@ fn classify_pending_capture_state(
|
|
|
469
469
|
const TRIGGER_GRACE_SECS: i64 = 15;
|
|
470
470
|
let past_spawn_grace = elapsed_since_spawn >= SPAWN_GRACE_SECS;
|
|
471
471
|
let past_trigger_grace = has_trigger && elapsed_since_trigger >= TRIGGER_GRACE_SECS;
|
|
472
|
+
let fork_pending_expired = agent_obj.get("capture_state").and_then(Value::as_str)
|
|
473
|
+
== Some("pending_context_fork")
|
|
474
|
+
&& crate::provider::session::transition_pending_context_fork(
|
|
475
|
+
has_trigger,
|
|
476
|
+
past_spawn_grace && past_trigger_grace,
|
|
477
|
+
)
|
|
478
|
+
.is_some();
|
|
472
479
|
// transcript_missing requires BOTH: a trigger occurred AND we've
|
|
473
480
|
// waited past the spawn grace (so we're not blamed for slow startup).
|
|
474
481
|
// candidate_count == 0 is the "no backing" signal; non-zero
|
|
475
482
|
// candidates that didn't satisfy assignment go through the ambiguous
|
|
476
483
|
// path above and don't reach here.
|
|
477
|
-
if has_trigger && past_spawn_grace && past_trigger_grace
|
|
484
|
+
if (fork_pending_expired || has_trigger && past_spawn_grace && past_trigger_grace)
|
|
485
|
+
&& candidate_count == 0
|
|
486
|
+
{
|
|
478
487
|
"transcript_missing"
|
|
488
|
+
} else if agent_obj.get("capture_state").and_then(Value::as_str) == Some("pending_context_fork")
|
|
489
|
+
{
|
|
490
|
+
"pending_context_fork"
|
|
479
491
|
} else {
|
|
480
492
|
"pending_first_turn"
|
|
481
493
|
}
|
|
@@ -1143,6 +1155,9 @@ fn apply_captured_session(
|
|
|
1143
1155
|
agent_obj: &mut serde_json::Map<String, Value>,
|
|
1144
1156
|
captured: &CapturedSession,
|
|
1145
1157
|
) -> bool {
|
|
1158
|
+
if agent_obj.get("capture_state").and_then(Value::as_str) == Some("pending_context_fork") {
|
|
1159
|
+
return crate::lifecycle::finalize_pending_fork_capture(agent_obj, captured);
|
|
1160
|
+
}
|
|
1146
1161
|
let Some(session_id) = captured.session_id.as_ref() else {
|
|
1147
1162
|
return false;
|
|
1148
1163
|
};
|
|
@@ -1984,6 +1999,27 @@ mod u1_tests {
|
|
|
1984
1999
|
);
|
|
1985
2000
|
}
|
|
1986
2001
|
|
|
2002
|
+
#[test]
|
|
2003
|
+
fn pending_context_fork_keeps_typed_state_until_trigger_grace_expires() {
|
|
2004
|
+
let old = (chrono::Utc::now() - chrono::Duration::seconds(60)).to_rfc3339();
|
|
2005
|
+
let mut agent = serde_json::Map::new();
|
|
2006
|
+
agent.insert("spawned_at".to_string(), serde_json::json!(old));
|
|
2007
|
+
agent.insert(
|
|
2008
|
+
"capture_state".to_string(),
|
|
2009
|
+
serde_json::json!("pending_context_fork"),
|
|
2010
|
+
);
|
|
2011
|
+
assert_eq!(
|
|
2012
|
+
super::classify_pending_capture_state(&agent, 0),
|
|
2013
|
+
"pending_context_fork"
|
|
2014
|
+
);
|
|
2015
|
+
let trigger = (chrono::Utc::now() - chrono::Duration::seconds(20)).to_rfc3339();
|
|
2016
|
+
agent.insert("first_send_at".to_string(), serde_json::json!(trigger));
|
|
2017
|
+
assert_eq!(
|
|
2018
|
+
super::classify_pending_capture_state(&agent, 0),
|
|
2019
|
+
"transcript_missing"
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
1987
2023
|
#[test]
|
|
1988
2024
|
fn canonical_pending_capture_preserves_legacy_missing_boundary() {
|
|
1989
2025
|
let agent = serde_json::json!({
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub(super) fn verify_claude_fork(
|
|
4
|
+
provider: Provider,
|
|
5
|
+
source_session_id: &SessionId,
|
|
6
|
+
plan: &CommandPlan,
|
|
7
|
+
before: &ContextBackingSnapshot,
|
|
8
|
+
expected_backing_path: Option<&Path>,
|
|
9
|
+
spawn_cwd: &Path,
|
|
10
|
+
deadline: Duration,
|
|
11
|
+
) -> Result<ContextForkProof, ContextForkTermination> {
|
|
12
|
+
let expected = plan.expected_session_id.as_ref().ok_or_else(|| {
|
|
13
|
+
ProviderError::CaptureFailed(
|
|
14
|
+
"context_fork_unverified: Claude plan has no expected session id".to_string(),
|
|
15
|
+
)
|
|
16
|
+
})?;
|
|
17
|
+
let path = expected_backing_path.ok_or_else(|| {
|
|
18
|
+
ProviderError::CaptureFailed(
|
|
19
|
+
"context_fork_unverified: Claude plan has no exact snapshot backing".to_string(),
|
|
20
|
+
)
|
|
21
|
+
})?;
|
|
22
|
+
let expected_name = format!("{}.jsonl", expected.as_str());
|
|
23
|
+
if path.file_name().and_then(|name| name.to_str()) != Some(expected_name.as_str()) {
|
|
24
|
+
return Err(ProviderError::CaptureFailed(format!(
|
|
25
|
+
"context_fork_unverified: Claude snapshot backing does not match expected session {}",
|
|
26
|
+
expected.as_str()
|
|
27
|
+
))
|
|
28
|
+
.into());
|
|
29
|
+
}
|
|
30
|
+
let started = std::time::Instant::now();
|
|
31
|
+
loop {
|
|
32
|
+
if let Some(stamp) = file_stamp(path) {
|
|
33
|
+
let changed = before.files.get(path).is_none_or(|old| *old != stamp);
|
|
34
|
+
let observed_matches =
|
|
35
|
+
session_id_from_jsonl(path).is_none_or(|observed| observed == expected.as_str());
|
|
36
|
+
if changed && readable_jsonl(path) && observed_matches && expected != source_session_id
|
|
37
|
+
{
|
|
38
|
+
return Ok(ContextForkProof {
|
|
39
|
+
provider,
|
|
40
|
+
source_session_id: source_session_id.clone(),
|
|
41
|
+
new_session_id: expected.clone(),
|
|
42
|
+
backing_path: path.to_path_buf(),
|
|
43
|
+
captured_via: "context_fork_verified".to_string(),
|
|
44
|
+
attribution_confidence: "high".to_string(),
|
|
45
|
+
managed_backing_root: None,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if let Some(provider_path) = std::env::var_os("HOME")
|
|
50
|
+
.map(PathBuf::from)
|
|
51
|
+
.and_then(|home| {
|
|
52
|
+
crate::provider::session_scan::claude::projects_dir_for_cwd(&home, spawn_cwd)
|
|
53
|
+
})
|
|
54
|
+
.map(|root| root.join(&expected_name))
|
|
55
|
+
{
|
|
56
|
+
let observed_matches = session_id_from_jsonl(&provider_path)
|
|
57
|
+
.is_some_and(|observed| observed == expected.as_str());
|
|
58
|
+
let changed = file_stamp(&provider_path)
|
|
59
|
+
.is_some_and(|stamp| before.files.get(&provider_path) != Some(&stamp));
|
|
60
|
+
if changed
|
|
61
|
+
&& readable_jsonl(&provider_path)
|
|
62
|
+
&& observed_matches
|
|
63
|
+
&& expected != source_session_id
|
|
64
|
+
{
|
|
65
|
+
return Ok(ContextForkProof {
|
|
66
|
+
provider,
|
|
67
|
+
source_session_id: source_session_id.clone(),
|
|
68
|
+
new_session_id: expected.clone(),
|
|
69
|
+
backing_path: provider_path,
|
|
70
|
+
captured_via: "context_fork_verified".to_string(),
|
|
71
|
+
attribution_confidence: "high".to_string(),
|
|
72
|
+
managed_backing_root: None,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if started.elapsed() >= deadline {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
std::thread::sleep(Duration::from_millis(50));
|
|
80
|
+
}
|
|
81
|
+
Err(ContextForkTermination::Timeout {
|
|
82
|
+
provider,
|
|
83
|
+
deadline_ms: deadline.as_millis(),
|
|
84
|
+
})
|
|
85
|
+
}
|