@team-agent/installer 0.5.115 → 0.5.117
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 +143 -12
- package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
- package/crates/team-agent/src/contract_runtime/backend.rs +88 -8
- package/crates/team-agent/src/contract_runtime/commands.rs +88 -7
- package/crates/team-agent/src/contract_runtime/discovery.rs +42 -10
- package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +2 -0
- package/crates/team-agent/src/contract_runtime/registry.rs +86 -9
- package/crates/team-agent/src/contract_runtime/workspace.rs +79 -11
- package/crates/team-agent/src/leader/lease.rs +1 -18
- package/crates/team-agent/src/lifecycle/pane_input_lock.rs +86 -8
- package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +101 -72
- package/crates/team-agent/src/lifecycle/restart/selection.rs +30 -8
- package/crates/team-agent/src/messaging/delivery.rs +132 -27
- package/crates/team-agent/src/messaging/enter_rescue.rs +334 -0
- package/crates/team-agent/src/messaging/enter_rescue_tests.rs +566 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/mod.rs +1 -0
- package/crates/team-agent/src/messaging/scheduler.rs +8 -0
- package/crates/team-agent/src/messaging/tests/basic.rs +2 -0
- package/crates/team-agent/src/messaging/types.rs +1 -0
- package/crates/team-agent/src/provider/session/capture.rs +24 -1
- package/crates/team-agent/src/state/persist.rs +72 -2
- package/crates/team-agent/src/state/projection.rs +29 -0
- package/crates/team-agent/src/state/repository/tests.rs +70 -0
- package/crates/team-agent/src/state/repository.rs +52 -1
- package/crates/team-agent/src/tmux_backend/native_input.rs +494 -0
- package/crates/team-agent/src/tmux_backend/native_input_tests.rs +781 -0
- package/crates/team-agent/src/tmux_backend.rs +49 -6
- package/crates/team-agent/src/transport/native_input.rs +594 -0
- package/crates/team-agent/src/transport.rs +19 -0
- package/package.json +4 -4
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
//! Durable Enter-only recovery. A claimed/unknown job is never replayed as a paste.
|
|
2
|
+
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
|
|
5
|
+
use chrono::{DateTime, Duration, Utc};
|
|
6
|
+
use rusqlite::{params, OptionalExtension};
|
|
7
|
+
use serde::{Deserialize, Serialize};
|
|
8
|
+
use sha2::{Digest, Sha256};
|
|
9
|
+
|
|
10
|
+
use super::delivery::{render_message, top_level_state_matches_owner_team};
|
|
11
|
+
use super::MessagingError;
|
|
12
|
+
use crate::event_log::EventLog;
|
|
13
|
+
use crate::message_store::MessageStore;
|
|
14
|
+
use crate::transport::native_input::{EnterRescueOutcome, NativeInputEvidence, NativeInputPhase};
|
|
15
|
+
use crate::transport::Transport;
|
|
16
|
+
|
|
17
|
+
#[cfg(test)]
|
|
18
|
+
#[path = "enter_rescue_tests.rs"]
|
|
19
|
+
mod tests;
|
|
20
|
+
|
|
21
|
+
const DELAY_SECONDS: i64 = 90;
|
|
22
|
+
const EXPIRE_SECONDS: i64 = 120;
|
|
23
|
+
|
|
24
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
25
|
+
struct RescueJob {
|
|
26
|
+
evidence: NativeInputEvidence,
|
|
27
|
+
expires_at: String,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fn digest(text: &str) -> String {
|
|
31
|
+
format!("{:x}", Sha256::digest(text.as_bytes()))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
fn expiration(job: &RescueJob) -> Result<DateTime<Utc>, MessagingError> {
|
|
35
|
+
DateTime::parse_from_rfc3339(&job.expires_at)
|
|
36
|
+
.map(|value| value.with_timezone(&Utc))
|
|
37
|
+
.map_err(|_| MessagingError::Validation("enter_rescue: invalid expiry".into()))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// The claim consumed no physical attempt. Never use this after a paste intent.
|
|
41
|
+
pub(super) fn release_no_effect(
|
|
42
|
+
store: &MessageStore,
|
|
43
|
+
message_id: &str,
|
|
44
|
+
attempt: u32,
|
|
45
|
+
) -> Result<(), MessagingError> {
|
|
46
|
+
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
47
|
+
conn.execute(
|
|
48
|
+
"update messages set error='native_input_not_ready', delivery_attempts=delivery_attempts-1, updated_at=?3
|
|
49
|
+
where message_id=?1 and status='target_resolved' and error is null and delivery_attempts=?2",
|
|
50
|
+
params![message_id, attempt, Utc::now().to_rfc3339()],
|
|
51
|
+
)?;
|
|
52
|
+
Ok(())
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// Original uncertainty and its one job are committed together under the same CAS.
|
|
56
|
+
pub(super) fn park(
|
|
57
|
+
store: &MessageStore,
|
|
58
|
+
evidence: &NativeInputEvidence,
|
|
59
|
+
attempt: u32,
|
|
60
|
+
reason: &str,
|
|
61
|
+
log: &EventLog,
|
|
62
|
+
) -> Result<(), MessagingError> {
|
|
63
|
+
park_at(store, evidence, attempt, reason, log, Utc::now())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn park_at(
|
|
67
|
+
store: &MessageStore,
|
|
68
|
+
evidence: &NativeInputEvidence,
|
|
69
|
+
attempt: u32,
|
|
70
|
+
reason: &str,
|
|
71
|
+
log: &EventLog,
|
|
72
|
+
now: DateTime<Utc>,
|
|
73
|
+
) -> Result<(), MessagingError> {
|
|
74
|
+
if !evidence.request.supported() || evidence.phase < NativeInputPhase::MayHavePasted {
|
|
75
|
+
return Err(MessagingError::Validation(
|
|
76
|
+
"enter_rescue: owned proof missing".into(),
|
|
77
|
+
));
|
|
78
|
+
}
|
|
79
|
+
let mut conn = crate::db::schema::open_db(store.db_path())?;
|
|
80
|
+
let tx = conn.transaction()?;
|
|
81
|
+
let changed = tx.execute(
|
|
82
|
+
"update messages set status='submitted_unverified', error=?4, updated_at=?5
|
|
83
|
+
where message_id=?1 and owner_team_id=?2 and recipient=?3
|
|
84
|
+
and status='target_resolved' and error is null and delivery_attempts=?6",
|
|
85
|
+
params![
|
|
86
|
+
evidence.request.message_id,
|
|
87
|
+
evidence.request.owner_team_id,
|
|
88
|
+
evidence.request.recipient,
|
|
89
|
+
reason,
|
|
90
|
+
now.to_rfc3339(),
|
|
91
|
+
attempt
|
|
92
|
+
],
|
|
93
|
+
)?;
|
|
94
|
+
if changed != 1 {
|
|
95
|
+
return Err(MessagingError::Validation(
|
|
96
|
+
"enter_rescue: claim changed".into(),
|
|
97
|
+
));
|
|
98
|
+
}
|
|
99
|
+
let job = RescueJob {
|
|
100
|
+
evidence: evidence.clone(),
|
|
101
|
+
expires_at: (now + Duration::seconds(EXPIRE_SECONDS)).to_rfc3339(),
|
|
102
|
+
};
|
|
103
|
+
tx.execute(
|
|
104
|
+
"insert into scheduled_events(kind,target,payload_json,due_at,status,owner_team_id,created_at) values('enter_rescue',?1,?2,?3,'pending',?4,?5)",
|
|
105
|
+
params![evidence.request.recipient, serde_json::to_string(&job)?,
|
|
106
|
+
(now + Duration::seconds(DELAY_SECONDS)).to_rfc3339(), evidence.request.owner_team_id, now.to_rfc3339()],
|
|
107
|
+
)?;
|
|
108
|
+
tx.commit()?;
|
|
109
|
+
log.write("enter_rescue.scheduled", serde_json::json!({
|
|
110
|
+
"message_id": evidence.request.message_id, "recipient": evidence.request.recipient,
|
|
111
|
+
"phase": evidence.phase, "delay_seconds": DELAY_SECONDS, "expire_seconds": EXPIRE_SECONDS,
|
|
112
|
+
}))?;
|
|
113
|
+
Ok(())
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fn canonical_valid(
|
|
117
|
+
workspace: &Path,
|
|
118
|
+
store: &MessageStore,
|
|
119
|
+
job: &RescueJob,
|
|
120
|
+
) -> Result<bool, MessagingError> {
|
|
121
|
+
let evidence = &job.evidence;
|
|
122
|
+
let request = &evidence.request;
|
|
123
|
+
if Utc::now() > expiration(job)? || !request.supported() {
|
|
124
|
+
return Ok(false);
|
|
125
|
+
}
|
|
126
|
+
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
127
|
+
let original = conn.query_row(
|
|
128
|
+
"select sender, task_id, content, created_at from messages
|
|
129
|
+
where message_id=?1 and owner_team_id=?2 and recipient=?3 and status='submitted_unverified'",
|
|
130
|
+
params![request.message_id, request.owner_team_id, request.recipient],
|
|
131
|
+
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?)),
|
|
132
|
+
).optional()?;
|
|
133
|
+
let Some((sender, task, content, created_at)) = original else {
|
|
134
|
+
return Ok(false);
|
|
135
|
+
};
|
|
136
|
+
if digest(&render_message(
|
|
137
|
+
&sender,
|
|
138
|
+
task.as_deref(),
|
|
139
|
+
&content,
|
|
140
|
+
&request.message_id,
|
|
141
|
+
)) != evidence.payload_sha256
|
|
142
|
+
{
|
|
143
|
+
return Ok(false);
|
|
144
|
+
}
|
|
145
|
+
let newer: bool = conn.query_row(
|
|
146
|
+
"select exists(select 1 from messages where owner_team_id=?1 and recipient=?2
|
|
147
|
+
and message_id<>?3 and created_at>=?4 and delivery_attempts>0)",
|
|
148
|
+
params![
|
|
149
|
+
request.owner_team_id,
|
|
150
|
+
request.recipient,
|
|
151
|
+
request.message_id,
|
|
152
|
+
created_at
|
|
153
|
+
],
|
|
154
|
+
|row| row.get(0),
|
|
155
|
+
)?;
|
|
156
|
+
if newer {
|
|
157
|
+
return Ok(false);
|
|
158
|
+
}
|
|
159
|
+
let raw = crate::state::persist::load_runtime_state(workspace)?;
|
|
160
|
+
if !matches!(crate::state::projection::resolve_owner_team_id(&raw, &request.owner_team_id),
|
|
161
|
+
crate::state::projection::OwnerTeamResolution::Canonical(team) if team == request.owner_team_id)
|
|
162
|
+
{
|
|
163
|
+
return Ok(false);
|
|
164
|
+
}
|
|
165
|
+
let state = if raw
|
|
166
|
+
.get("teams")
|
|
167
|
+
.and_then(serde_json::Value::as_object)
|
|
168
|
+
.is_some_and(|teams| teams.contains_key(&request.owner_team_id))
|
|
169
|
+
{
|
|
170
|
+
crate::state::projection::project_top_level_view(&raw, &request.owner_team_id)
|
|
171
|
+
} else if top_level_state_matches_owner_team(&raw, &request.owner_team_id) {
|
|
172
|
+
raw
|
|
173
|
+
} else {
|
|
174
|
+
return Ok(false);
|
|
175
|
+
};
|
|
176
|
+
let Some(agent) = state
|
|
177
|
+
.get("agents")
|
|
178
|
+
.and_then(|agents| agents.get(&request.recipient))
|
|
179
|
+
else {
|
|
180
|
+
return Ok(false);
|
|
181
|
+
};
|
|
182
|
+
Ok(
|
|
183
|
+
agent.get("status").and_then(serde_json::Value::as_str) == Some("running")
|
|
184
|
+
&& agent
|
|
185
|
+
.get("explicit_stop")
|
|
186
|
+
.and_then(serde_json::Value::as_bool)
|
|
187
|
+
!= Some(true)
|
|
188
|
+
&& agent.get("pane_id").and_then(serde_json::Value::as_str)
|
|
189
|
+
== Some(request.pane_id.as_str())
|
|
190
|
+
&& agent.get("pane_pid").and_then(serde_json::Value::as_u64)
|
|
191
|
+
== Some(u64::from(request.pane_pid))
|
|
192
|
+
&& request.pane_pid == evidence.binding.pane_pid
|
|
193
|
+
&& state
|
|
194
|
+
.get("session_name")
|
|
195
|
+
.and_then(serde_json::Value::as_str)
|
|
196
|
+
== Some(request.session.as_str())
|
|
197
|
+
&& agent
|
|
198
|
+
.get("window")
|
|
199
|
+
.and_then(serde_json::Value::as_str)
|
|
200
|
+
.unwrap_or(&request.recipient)
|
|
201
|
+
== request.window
|
|
202
|
+
&& agent.get("provider").cloned().and_then(|value| {
|
|
203
|
+
serde_json::from_value::<crate::model::enums::Provider>(value).ok()
|
|
204
|
+
}) == Some(request.provider),
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// Called only for due jobs. CAS precedes every possible effect; stale `claimed`
|
|
209
|
+
/// rows intentionally remain unknown after a crash, rather than becoming runnable.
|
|
210
|
+
pub(super) fn fire(
|
|
211
|
+
workspace: &Path,
|
|
212
|
+
store: &MessageStore,
|
|
213
|
+
transport: &dyn Transport,
|
|
214
|
+
log: &EventLog,
|
|
215
|
+
id: i64,
|
|
216
|
+
payload: &str,
|
|
217
|
+
) -> Result<bool, MessagingError> {
|
|
218
|
+
fire_with(
|
|
219
|
+
workspace,
|
|
220
|
+
store,
|
|
221
|
+
log,
|
|
222
|
+
id,
|
|
223
|
+
payload,
|
|
224
|
+
&|evidence, authorize| {
|
|
225
|
+
super::delivery::with_rescue_submit_observer(
|
|
226
|
+
workspace,
|
|
227
|
+
store,
|
|
228
|
+
evidence,
|
|
229
|
+
log,
|
|
230
|
+
|observer| {
|
|
231
|
+
transport
|
|
232
|
+
.rescue_enter(evidence, authorize, Some(observer))
|
|
233
|
+
.map_err(Into::into)
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
},
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
fn fire_with(
|
|
241
|
+
workspace: &Path,
|
|
242
|
+
store: &MessageStore,
|
|
243
|
+
log: &EventLog,
|
|
244
|
+
id: i64,
|
|
245
|
+
payload: &str,
|
|
246
|
+
execute: &dyn Fn(
|
|
247
|
+
&NativeInputEvidence,
|
|
248
|
+
&dyn Fn() -> bool,
|
|
249
|
+
) -> Result<EnterRescueOutcome, MessagingError>,
|
|
250
|
+
) -> Result<bool, MessagingError> {
|
|
251
|
+
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
252
|
+
if conn.execute(
|
|
253
|
+
"update scheduled_events set status='claimed', fired_at=?2
|
|
254
|
+
where id=?1 and kind='enter_rescue' and status='pending' and due_at<=?2 and payload_json=?3",
|
|
255
|
+
params![id, Utc::now().to_rfc3339(), payload],
|
|
256
|
+
)? != 1 { return Ok(false); }
|
|
257
|
+
log.write("enter_rescue.claimed", serde_json::json!({"event_id": id}))?;
|
|
258
|
+
let result = (|| -> Result<(String, serde_json::Value, Option<String>), MessagingError> {
|
|
259
|
+
let job: RescueJob = serde_json::from_str(payload)?;
|
|
260
|
+
let scope_matches: bool = conn.query_row(
|
|
261
|
+
"select owner_team_id=?2 and target=?3 from scheduled_events where id=?1",
|
|
262
|
+
params![
|
|
263
|
+
id,
|
|
264
|
+
job.evidence.request.owner_team_id,
|
|
265
|
+
job.evidence.request.recipient
|
|
266
|
+
],
|
|
267
|
+
|row| row.get(0),
|
|
268
|
+
)?;
|
|
269
|
+
if !scope_matches {
|
|
270
|
+
return Ok((
|
|
271
|
+
"refused".into(),
|
|
272
|
+
serde_json::json!({"reason":"job_scope_changed"}),
|
|
273
|
+
None,
|
|
274
|
+
));
|
|
275
|
+
}
|
|
276
|
+
if !canonical_valid(workspace, store, &job)? {
|
|
277
|
+
return Ok((
|
|
278
|
+
"refused".into(),
|
|
279
|
+
serde_json::json!({"reason":"canonical_changed_or_expired"}),
|
|
280
|
+
None,
|
|
281
|
+
));
|
|
282
|
+
}
|
|
283
|
+
let authorize = || canonical_valid(workspace, store, &job).unwrap_or(false);
|
|
284
|
+
let outcome = execute(&job.evidence, &authorize)?;
|
|
285
|
+
match outcome {
|
|
286
|
+
EnterRescueOutcome::NoEffect { reason, reobserve } => {
|
|
287
|
+
let next = Utc::now() + Duration::seconds(5);
|
|
288
|
+
let due = (reobserve && next <= expiration(&job)?).then(|| next.to_rfc3339());
|
|
289
|
+
Ok((
|
|
290
|
+
if due.is_some() { "pending" } else { "refused" }.into(),
|
|
291
|
+
serde_json::json!({"reason":reason,"effect":"none"}),
|
|
292
|
+
due,
|
|
293
|
+
))
|
|
294
|
+
}
|
|
295
|
+
EnterRescueOutcome::Consumed | EnterRescueOutcome::Entered { accepted: true } => {
|
|
296
|
+
// Row-level CAS cannot downgrade an acknowledgement/other terminal owner.
|
|
297
|
+
let changed = conn.execute(
|
|
298
|
+
"update messages set status='delivered',delivered_at=?2,updated_at=?2,error=null
|
|
299
|
+
where message_id=?1 and status='submitted_unverified' and owner_team_id=?3 and recipient=?4",
|
|
300
|
+
params![job.evidence.request.message_id, Utc::now().to_rfc3339(),
|
|
301
|
+
job.evidence.request.owner_team_id, job.evidence.request.recipient],
|
|
302
|
+
)?;
|
|
303
|
+
Ok((
|
|
304
|
+
"done".into(),
|
|
305
|
+
serde_json::json!({"accepted":true,"message_updated":changed==1}),
|
|
306
|
+
None,
|
|
307
|
+
))
|
|
308
|
+
}
|
|
309
|
+
EnterRescueOutcome::Entered { accepted: false } => Ok((
|
|
310
|
+
"unknown".into(),
|
|
311
|
+
serde_json::json!({"accepted":false,"effect":"may_have_submitted"}),
|
|
312
|
+
None,
|
|
313
|
+
)),
|
|
314
|
+
}
|
|
315
|
+
})();
|
|
316
|
+
let (status, result, due) = match result {
|
|
317
|
+
Ok(result) => result,
|
|
318
|
+
Err(error) => (
|
|
319
|
+
"unknown".into(),
|
|
320
|
+
serde_json::json!({"error":error.to_string(),"retry":false}),
|
|
321
|
+
None,
|
|
322
|
+
),
|
|
323
|
+
};
|
|
324
|
+
conn.execute(
|
|
325
|
+
"update scheduled_events set status=?2,result_json=?3,due_at=coalesce(?4,due_at)
|
|
326
|
+
where id=?1 and kind='enter_rescue' and status='claimed'",
|
|
327
|
+
params![id, status, result.to_string(), due],
|
|
328
|
+
)?;
|
|
329
|
+
log.write(
|
|
330
|
+
"enter_rescue.result",
|
|
331
|
+
serde_json::json!({"event_id":id,"status":status,"result":result}),
|
|
332
|
+
)?;
|
|
333
|
+
Ok(true)
|
|
334
|
+
}
|