@team-agent/installer 0.5.52 → 0.5.54
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/adapters.rs +18 -2
- package/crates/team-agent/src/cli/emit.rs +111 -3
- package/crates/team-agent/src/cli/mod.rs +155 -4
- package/crates/team-agent/src/cli/send/persist.rs +1 -0
- package/crates/team-agent/src/cli/send/presentation.rs +1 -0
- package/crates/team-agent/src/cli/send.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +4 -1
- package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
- package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
- package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
- package/crates/team-agent/src/cli/types.rs +12 -0
- package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
- package/crates/team-agent/src/db/message_store.rs +31 -2
- package/crates/team-agent/src/db/migration.rs +7 -6
- package/crates/team-agent/src/db/schema.rs +18 -5
- package/crates/team-agent/src/diagnose/orphans.rs +24 -3
- package/crates/team-agent/src/kill_audit.rs +67 -0
- package/crates/team-agent/src/leader/lease.rs +324 -57
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
- package/crates/team-agent/src/leader/rediscover.rs +6 -0
- package/crates/team-agent/src/leader/start.rs +144 -23
- package/crates/team-agent/src/leader/tests/idle.rs +3 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
- package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch.rs +14 -2
- package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
- package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
- package/crates/team-agent/src/lifecycle/types.rs +17 -0
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
- package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
- package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
- package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
- package/crates/team-agent/src/mcp_server/tools.rs +89 -4
- package/crates/team-agent/src/mcp_server/types.rs +7 -0
- package/crates/team-agent/src/mcp_server/wire.rs +65 -4
- package/crates/team-agent/src/messaging/delivery.rs +2 -0
- package/crates/team-agent/src/messaging/helpers.rs +1 -0
- package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
- package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
- package/crates/team-agent/src/messaging/mod.rs +2 -1
- package/crates/team-agent/src/messaging/persist.rs +68 -2
- package/crates/team-agent/src/messaging/presentation.rs +307 -0
- package/crates/team-agent/src/messaging/results.rs +130 -3
- package/crates/team-agent/src/messaging/selftest.rs +1 -0
- package/crates/team-agent/src/messaging/send.rs +54 -2
- package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
- package/crates/team-agent/src/messaging/types.rs +2 -0
- package/crates/team-agent/src/messaging/watchers.rs +1 -0
- package/crates/team-agent/src/provider/adapter.rs +54 -13
- package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
- package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/session/capture.rs +395 -52
- package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
- package/crates/team-agent/src/provider/session/mod.rs +6 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
- package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
- package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
- package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
- package/crates/team-agent/src/provider/session_scan.rs +3 -3
- package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
- package/crates/team-agent/src/provider/tests.rs +1 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
- package/crates/team-agent/src/tmux_backend.rs +29 -2
- package/package.json +4 -4
- package/schemas/result-envelope.schema.json +10 -0
- package/skills/team-agent/SKILL.md +11 -3
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
//! Typed presentation metadata shared by message and result ingress.
|
|
2
|
+
|
|
3
|
+
use serde::{Deserialize, Serialize};
|
|
4
|
+
use serde_json::Value;
|
|
5
|
+
|
|
6
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
7
|
+
#[serde(rename_all = "snake_case")]
|
|
8
|
+
pub enum PresentationSink {
|
|
9
|
+
Leader,
|
|
10
|
+
Casefile,
|
|
11
|
+
Silent,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
impl PresentationSink {
|
|
15
|
+
pub const fn as_str(self) -> &'static str {
|
|
16
|
+
match self {
|
|
17
|
+
Self::Leader => "leader",
|
|
18
|
+
Self::Casefile => "casefile",
|
|
19
|
+
Self::Silent => "silent",
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fn parse(value: &str) -> Option<Self> {
|
|
24
|
+
match value {
|
|
25
|
+
"leader" => Some(Self::Leader),
|
|
26
|
+
"casefile" => Some(Self::Casefile),
|
|
27
|
+
"silent" => Some(Self::Silent),
|
|
28
|
+
_ => None,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
34
|
+
#[serde(rename_all = "snake_case")]
|
|
35
|
+
pub enum PresentationClass {
|
|
36
|
+
Message,
|
|
37
|
+
Progress,
|
|
38
|
+
StageResult,
|
|
39
|
+
StagePass,
|
|
40
|
+
Bounce,
|
|
41
|
+
Blocking,
|
|
42
|
+
FinalReview,
|
|
43
|
+
Timeout,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
47
|
+
pub enum PresentationSource {
|
|
48
|
+
Send,
|
|
49
|
+
ReportResult,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
impl PresentationClass {
|
|
53
|
+
pub const fn as_str(self) -> &'static str {
|
|
54
|
+
match self {
|
|
55
|
+
Self::Message => "message",
|
|
56
|
+
Self::Progress => "progress",
|
|
57
|
+
Self::StageResult => "stage_result",
|
|
58
|
+
Self::StagePass => "stage_pass",
|
|
59
|
+
Self::Bounce => "bounce",
|
|
60
|
+
Self::Blocking => "blocking",
|
|
61
|
+
Self::FinalReview => "final_review",
|
|
62
|
+
Self::Timeout => "timeout",
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn parse(value: &str) -> Option<Self> {
|
|
67
|
+
match value {
|
|
68
|
+
"message" => Some(Self::Message),
|
|
69
|
+
"progress" => Some(Self::Progress),
|
|
70
|
+
"stage_result" => Some(Self::StageResult),
|
|
71
|
+
"stage_pass" => Some(Self::StagePass),
|
|
72
|
+
"bounce" => Some(Self::Bounce),
|
|
73
|
+
"blocking" => Some(Self::Blocking),
|
|
74
|
+
"final_review" => Some(Self::FinalReview),
|
|
75
|
+
"timeout" => Some(Self::Timeout),
|
|
76
|
+
_ => None,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
82
|
+
pub struct PresentationRequest {
|
|
83
|
+
pub sink: PresentationSink,
|
|
84
|
+
pub class: PresentationClass,
|
|
85
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
86
|
+
pub case_id: Option<String>,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
90
|
+
pub struct PresentationDecision {
|
|
91
|
+
pub sink: PresentationSink,
|
|
92
|
+
pub class: PresentationClass,
|
|
93
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
94
|
+
pub case_id: Option<String>,
|
|
95
|
+
pub requested_sink: PresentationSink,
|
|
96
|
+
pub effective_sink: PresentationSink,
|
|
97
|
+
pub policy_reason: String,
|
|
98
|
+
pub policy_version: String,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
impl Default for PresentationRequest {
|
|
102
|
+
fn default() -> Self {
|
|
103
|
+
Self {
|
|
104
|
+
sink: PresentationSink::Leader,
|
|
105
|
+
class: PresentationClass::Message,
|
|
106
|
+
case_id: None,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
impl Default for PresentationDecision {
|
|
112
|
+
fn default() -> Self {
|
|
113
|
+
decide_presentation(
|
|
114
|
+
&PresentationRequest::default(),
|
|
115
|
+
PresentationSource::ReportResult,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
pub fn decide_presentation(
|
|
121
|
+
request: &PresentationRequest,
|
|
122
|
+
source: PresentationSource,
|
|
123
|
+
) -> PresentationDecision {
|
|
124
|
+
let critical = matches!(
|
|
125
|
+
request.class,
|
|
126
|
+
PresentationClass::StagePass
|
|
127
|
+
| PresentationClass::Bounce
|
|
128
|
+
| PresentationClass::Blocking
|
|
129
|
+
| PresentationClass::FinalReview
|
|
130
|
+
| PresentationClass::Timeout
|
|
131
|
+
);
|
|
132
|
+
let (effective_sink, policy_reason) = if critical {
|
|
133
|
+
(
|
|
134
|
+
PresentationSink::Leader,
|
|
135
|
+
format!("critical_class:{}", request.class.as_str()),
|
|
136
|
+
)
|
|
137
|
+
} else if source == PresentationSource::ReportResult
|
|
138
|
+
&& request.class != PresentationClass::StageResult
|
|
139
|
+
{
|
|
140
|
+
(
|
|
141
|
+
PresentationSink::Leader,
|
|
142
|
+
format!("user_delivery_class:{}", request.class.as_str()),
|
|
143
|
+
)
|
|
144
|
+
} else {
|
|
145
|
+
(
|
|
146
|
+
request.sink,
|
|
147
|
+
format!("requested_sink:{}", request.sink.as_str()),
|
|
148
|
+
)
|
|
149
|
+
};
|
|
150
|
+
PresentationDecision {
|
|
151
|
+
sink: request.sink,
|
|
152
|
+
class: request.class,
|
|
153
|
+
case_id: request.case_id.clone(),
|
|
154
|
+
requested_sink: request.sink,
|
|
155
|
+
effective_sink,
|
|
156
|
+
policy_reason,
|
|
157
|
+
policy_version: "team-presentation-v1".to_string(),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
pub fn normalize_report_presentation(
|
|
162
|
+
value: Option<&Value>,
|
|
163
|
+
) -> (PresentationRequest, Option<String>) {
|
|
164
|
+
let (request, error) = normalize_presentation(value);
|
|
165
|
+
if error.is_some() {
|
|
166
|
+
return (request, error);
|
|
167
|
+
}
|
|
168
|
+
let missing_case_id = request.class == PresentationClass::StageResult
|
|
169
|
+
&& request.sink != PresentationSink::Leader
|
|
170
|
+
&& request
|
|
171
|
+
.case_id
|
|
172
|
+
.as_deref()
|
|
173
|
+
.is_none_or(|case_id| case_id.trim().is_empty());
|
|
174
|
+
if missing_case_id {
|
|
175
|
+
return (request, Some("missing_case_id".to_string()));
|
|
176
|
+
}
|
|
177
|
+
(request, None)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
pub fn normalize_presentation(value: Option<&Value>) -> (PresentationRequest, Option<String>) {
|
|
181
|
+
let Some(value) = value else {
|
|
182
|
+
return (PresentationRequest::default(), None);
|
|
183
|
+
};
|
|
184
|
+
let Some(object) = value.as_object() else {
|
|
185
|
+
return (
|
|
186
|
+
PresentationRequest::default(),
|
|
187
|
+
Some("malformed_presentation".to_string()),
|
|
188
|
+
);
|
|
189
|
+
};
|
|
190
|
+
let Some(sink) = object.get("sink").and_then(Value::as_str) else {
|
|
191
|
+
return (
|
|
192
|
+
PresentationRequest::default(),
|
|
193
|
+
Some("missing_sink".to_string()),
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
let Some(sink) = PresentationSink::parse(sink) else {
|
|
197
|
+
return (
|
|
198
|
+
PresentationRequest::default(),
|
|
199
|
+
Some(format!("unknown_sink:{sink}")),
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
let Some(class) = object.get("class").and_then(Value::as_str) else {
|
|
203
|
+
return (
|
|
204
|
+
PresentationRequest::default(),
|
|
205
|
+
Some("missing_class".to_string()),
|
|
206
|
+
);
|
|
207
|
+
};
|
|
208
|
+
let Some(class) = PresentationClass::parse(class) else {
|
|
209
|
+
return (
|
|
210
|
+
PresentationRequest::default(),
|
|
211
|
+
Some(format!("unknown_class:{class}")),
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
let case_id = object
|
|
215
|
+
.get("case_id")
|
|
216
|
+
.and_then(Value::as_str)
|
|
217
|
+
.map(ToOwned::to_owned);
|
|
218
|
+
(
|
|
219
|
+
PresentationRequest {
|
|
220
|
+
sink,
|
|
221
|
+
class,
|
|
222
|
+
case_id,
|
|
223
|
+
},
|
|
224
|
+
None,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#[cfg(test)]
|
|
229
|
+
mod tests {
|
|
230
|
+
use super::*;
|
|
231
|
+
use serde_json::json;
|
|
232
|
+
|
|
233
|
+
#[test]
|
|
234
|
+
fn missing_metadata_keeps_legacy_leader_default() {
|
|
235
|
+
assert_eq!(
|
|
236
|
+
normalize_presentation(None),
|
|
237
|
+
(PresentationRequest::default(), None)
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#[test]
|
|
242
|
+
fn malformed_or_unknown_metadata_is_observable() {
|
|
243
|
+
assert_eq!(
|
|
244
|
+
normalize_presentation(Some(&json!({"sink": "casefile"}))).1,
|
|
245
|
+
Some("missing_class".to_string())
|
|
246
|
+
);
|
|
247
|
+
assert_eq!(
|
|
248
|
+
normalize_presentation(Some(&json!({"sink": "bogus", "class": "message"}))).1,
|
|
249
|
+
Some("unknown_sink:bogus".to_string())
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#[test]
|
|
254
|
+
fn critical_classes_force_leader_while_prose_is_ignored() {
|
|
255
|
+
let request = PresentationRequest {
|
|
256
|
+
sink: PresentationSink::Casefile,
|
|
257
|
+
class: PresentationClass::Blocking,
|
|
258
|
+
case_id: None,
|
|
259
|
+
};
|
|
260
|
+
let decision = decide_presentation(&request, PresentationSource::ReportResult);
|
|
261
|
+
assert_eq!(decision.effective_sink, PresentationSink::Leader);
|
|
262
|
+
assert_eq!(decision.policy_reason, "critical_class:blocking");
|
|
263
|
+
|
|
264
|
+
let benign = PresentationRequest {
|
|
265
|
+
sink: PresentationSink::Casefile,
|
|
266
|
+
class: PresentationClass::Message,
|
|
267
|
+
case_id: None,
|
|
268
|
+
};
|
|
269
|
+
assert_eq!(
|
|
270
|
+
decide_presentation(&benign, PresentationSource::Send).effective_sink,
|
|
271
|
+
PresentationSink::Casefile
|
|
272
|
+
);
|
|
273
|
+
assert_eq!(
|
|
274
|
+
decide_presentation(&benign, PresentationSource::ReportResult).effective_sink,
|
|
275
|
+
PresentationSink::Leader
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#[test]
|
|
280
|
+
fn report_stage_result_requires_case_id_only_for_non_leader_sink() {
|
|
281
|
+
assert_eq!(
|
|
282
|
+
normalize_report_presentation(Some(
|
|
283
|
+
&json!({"sink": "casefile", "class": "stage_result"})
|
|
284
|
+
))
|
|
285
|
+
.1,
|
|
286
|
+
Some("missing_case_id".to_string())
|
|
287
|
+
);
|
|
288
|
+
assert_eq!(
|
|
289
|
+
normalize_report_presentation(Some(&json!({
|
|
290
|
+
"sink": "casefile",
|
|
291
|
+
"class": "stage_result",
|
|
292
|
+
"case_id": "case-1"
|
|
293
|
+
})))
|
|
294
|
+
.1,
|
|
295
|
+
None
|
|
296
|
+
);
|
|
297
|
+
assert_eq!(
|
|
298
|
+
normalize_presentation(Some(&json!({
|
|
299
|
+
"sink": "casefile",
|
|
300
|
+
"class": "stage_result"
|
|
301
|
+
})))
|
|
302
|
+
.1,
|
|
303
|
+
None,
|
|
304
|
+
"send normalization remains unchanged"
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -628,6 +628,17 @@ fn report_result_for_owner_team_inner(
|
|
|
628
628
|
fallback_primary_error: Option<&str>,
|
|
629
629
|
) -> Result<serde_json::Value, MessagingError> {
|
|
630
630
|
validate_result_envelope(envelope)?;
|
|
631
|
+
let (presentation_request, presentation_error) =
|
|
632
|
+
super::presentation::normalize_report_presentation(envelope.get("presentation"));
|
|
633
|
+
if let Some(error) = presentation_error {
|
|
634
|
+
return Err(MessagingError::Validation(format!(
|
|
635
|
+
"invalid presentation: {error}"
|
|
636
|
+
)));
|
|
637
|
+
}
|
|
638
|
+
let presentation = super::presentation::decide_presentation(
|
|
639
|
+
&presentation_request,
|
|
640
|
+
super::presentation::PresentationSource::ReportResult,
|
|
641
|
+
);
|
|
631
642
|
let store = MessageStore::open(workspace)?;
|
|
632
643
|
let result_id = envelope
|
|
633
644
|
.get("result_id")
|
|
@@ -645,6 +656,12 @@ fn report_result_for_owner_team_inner(
|
|
|
645
656
|
);
|
|
646
657
|
}
|
|
647
658
|
}
|
|
659
|
+
if let Some(obj) = stored.as_object_mut() {
|
|
660
|
+
obj.insert(
|
|
661
|
+
"presentation".to_string(),
|
|
662
|
+
serde_json::to_value(&presentation)?,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
648
665
|
let conn = crate::db::schema::open_db(store.db_path())?;
|
|
649
666
|
let state_for_owner =
|
|
650
667
|
crate::state::persist::load_runtime_state(workspace).unwrap_or(serde_json::json!({}));
|
|
@@ -710,6 +727,71 @@ fn report_result_for_owner_team_inner(
|
|
|
710
727
|
out.insert("notification_event_id".to_string(), serde_json::Value::Null);
|
|
711
728
|
return Ok(serde_json::Value::Object(out));
|
|
712
729
|
}
|
|
730
|
+
let event_log = EventLog::new(workspace);
|
|
731
|
+
if presentation.effective_sink != super::presentation::PresentationSink::Leader {
|
|
732
|
+
event_log.write(
|
|
733
|
+
"presentation.stored_without_live_inject",
|
|
734
|
+
serde_json::json!({
|
|
735
|
+
"result_id": result_id,
|
|
736
|
+
"owner_team_id": owner_team,
|
|
737
|
+
"requested_sink": presentation.requested_sink,
|
|
738
|
+
"effective_sink": presentation.effective_sink,
|
|
739
|
+
"class": presentation.class,
|
|
740
|
+
"policy_reason": presentation.policy_reason,
|
|
741
|
+
}),
|
|
742
|
+
)?;
|
|
743
|
+
event_log.write(
|
|
744
|
+
"mcp.report_result",
|
|
745
|
+
serde_json::json!({
|
|
746
|
+
"leader_notified": false,
|
|
747
|
+
"notification_channel": presentation.effective_sink,
|
|
748
|
+
"notification_message_id": serde_json::Value::Null,
|
|
749
|
+
"notification_status": "stored_not_presented",
|
|
750
|
+
"owner_team_id": owner_team,
|
|
751
|
+
"result_id": result_id,
|
|
752
|
+
}),
|
|
753
|
+
)?;
|
|
754
|
+
let mut out = serde_json::Map::new();
|
|
755
|
+
out.insert("ok".to_string(), serde_json::Value::Bool(true));
|
|
756
|
+
out.insert(
|
|
757
|
+
"result_id".to_string(),
|
|
758
|
+
serde_json::Value::String(result_id),
|
|
759
|
+
);
|
|
760
|
+
out.insert(
|
|
761
|
+
"task_id".to_string(),
|
|
762
|
+
serde_json::Value::String(task_id.to_string()),
|
|
763
|
+
);
|
|
764
|
+
copy_report_attribution_fields(envelope, &mut out);
|
|
765
|
+
out.insert(
|
|
766
|
+
"agent_id".to_string(),
|
|
767
|
+
serde_json::Value::String(agent_id.to_string()),
|
|
768
|
+
);
|
|
769
|
+
out.insert("acknowledged_messages".to_string(), serde_json::json!([]));
|
|
770
|
+
out.insert(
|
|
771
|
+
"leader_notified".to_string(),
|
|
772
|
+
serde_json::Value::Bool(false),
|
|
773
|
+
);
|
|
774
|
+
out.insert(
|
|
775
|
+
"notification_message_id".to_string(),
|
|
776
|
+
serde_json::Value::Null,
|
|
777
|
+
);
|
|
778
|
+
out.insert(
|
|
779
|
+
"notification_status".to_string(),
|
|
780
|
+
serde_json::Value::String("stored_not_presented".to_string()),
|
|
781
|
+
);
|
|
782
|
+
out.insert(
|
|
783
|
+
"notification_channel".to_string(),
|
|
784
|
+
serde_json::Value::String(presentation.effective_sink.as_str().to_string()),
|
|
785
|
+
);
|
|
786
|
+
out.insert("notification_event_id".to_string(), serde_json::Value::Null);
|
|
787
|
+
if let Some(warnings) = report_result_array(envelope, "warnings") {
|
|
788
|
+
out.insert(
|
|
789
|
+
"warnings".to_string(),
|
|
790
|
+
serde_json::Value::Array(warnings.clone()),
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
return Ok(serde_json::Value::Object(out));
|
|
794
|
+
}
|
|
713
795
|
// #230 N31/N32 funnel: report_result must go through the shared leader-delivery
|
|
714
796
|
// primitive synchronously, NOT via a parallel queued scheduled_events row. The
|
|
715
797
|
// legacy path was MUST-8 / I-3 violating (the deferred notification status was returned
|
|
@@ -717,8 +799,7 @@ fn report_result_for_owner_team_inner(
|
|
|
717
799
|
let content =
|
|
718
800
|
format_report_result_notification(&result_id, task_id, agent_id, status, envelope);
|
|
719
801
|
let state = report_owner_state(&state_for_owner, &owner_team);
|
|
720
|
-
let
|
|
721
|
-
let mut outcome = match super::leader_receiver::send_to_leader_receiver(
|
|
802
|
+
let mut outcome = match super::leader_receiver::send_to_leader_receiver_with_presentation(
|
|
722
803
|
workspace,
|
|
723
804
|
&state,
|
|
724
805
|
"leader",
|
|
@@ -727,6 +808,8 @@ fn report_result_for_owner_team_inner(
|
|
|
727
808
|
agent_id,
|
|
728
809
|
false,
|
|
729
810
|
Some(&result_id),
|
|
811
|
+
None,
|
|
812
|
+
&presentation,
|
|
730
813
|
&event_log,
|
|
731
814
|
) {
|
|
732
815
|
Ok(outcome) => outcome,
|
|
@@ -1245,7 +1328,8 @@ pub fn collect_results_and_notify_watchers(
|
|
|
1245
1328
|
|
|
1246
1329
|
#[cfg(test)]
|
|
1247
1330
|
mod tests {
|
|
1248
|
-
use super::format_report_result_notification;
|
|
1331
|
+
use super::{format_report_result_notification, report_result};
|
|
1332
|
+
use crate::message_store::MessageStore;
|
|
1249
1333
|
|
|
1250
1334
|
#[test]
|
|
1251
1335
|
fn report_result_notification_includes_full_envelope_sections() {
|
|
@@ -1292,4 +1376,47 @@ mod tests {
|
|
|
1292
1376
|
assert!(notification.contains("Next actions: ship after review"));
|
|
1293
1377
|
assert!(notification.contains("Result id: res_1"));
|
|
1294
1378
|
}
|
|
1379
|
+
|
|
1380
|
+
#[test]
|
|
1381
|
+
fn casefile_result_is_stored_without_a_leader_notification_row() {
|
|
1382
|
+
let workspace = std::env::temp_dir().join(format!(
|
|
1383
|
+
"ta-casefile-result-{}-{}",
|
|
1384
|
+
std::process::id(),
|
|
1385
|
+
super::next_result_id()
|
|
1386
|
+
));
|
|
1387
|
+
std::fs::create_dir_all(&workspace).unwrap();
|
|
1388
|
+
let out = report_result(
|
|
1389
|
+
&workspace,
|
|
1390
|
+
&serde_json::json!({
|
|
1391
|
+
"schema_version": "result_envelope_v1",
|
|
1392
|
+
"task_id": "task-1",
|
|
1393
|
+
"agent_id": "worker",
|
|
1394
|
+
"status": "success",
|
|
1395
|
+
"summary": "stage evidence",
|
|
1396
|
+
"changes": [],
|
|
1397
|
+
"tests": [],
|
|
1398
|
+
"risks": [],
|
|
1399
|
+
"artifacts": [],
|
|
1400
|
+
"next_actions": [],
|
|
1401
|
+
"presentation": {
|
|
1402
|
+
"sink": "casefile",
|
|
1403
|
+
"class": "stage_result",
|
|
1404
|
+
"case_id": "case-1"
|
|
1405
|
+
}
|
|
1406
|
+
}),
|
|
1407
|
+
)
|
|
1408
|
+
.unwrap();
|
|
1409
|
+
assert_eq!(out["notification_status"], "stored_not_presented");
|
|
1410
|
+
assert_eq!(out["leader_notified"], false);
|
|
1411
|
+
let store = MessageStore::open(&workspace).unwrap();
|
|
1412
|
+
let conn = crate::db::schema::open_db(store.db_path()).unwrap();
|
|
1413
|
+
let result_count: i64 = conn
|
|
1414
|
+
.query_row("select count(*) from results", [], |row| row.get(0))
|
|
1415
|
+
.unwrap();
|
|
1416
|
+
let message_count: i64 = conn
|
|
1417
|
+
.query_row("select count(*) from messages", [], |row| row.get(0))
|
|
1418
|
+
.unwrap();
|
|
1419
|
+
assert_eq!(result_count, 1);
|
|
1420
|
+
assert_eq!(message_count, 0);
|
|
1421
|
+
}
|
|
1295
1422
|
}
|
|
@@ -9,7 +9,10 @@ use crate::model::ids::{AgentId, TaskId, TeamKey};
|
|
|
9
9
|
use crate::transport::{PaneId, Transport};
|
|
10
10
|
|
|
11
11
|
use super::helpers::{status_wire, MessageStatusShadow};
|
|
12
|
-
use super::leader_receiver::{send_to_leader_receiver,
|
|
12
|
+
use super::leader_receiver::{send_to_leader_receiver, send_to_leader_receiver_with_presentation};
|
|
13
|
+
use super::presentation::{
|
|
14
|
+
decide_presentation, PresentationRequest, PresentationSink, PresentationSource,
|
|
15
|
+
};
|
|
13
16
|
use super::{
|
|
14
17
|
persist_resolved_send, DeliveryBlocker, DeliveryOutcome, DeliveryRefusal, DeliveryStatus,
|
|
15
18
|
InitialDisposition, LogicalRecipient, MessagingError, PersistResolution, ResolvedSendIntent,
|
|
@@ -72,6 +75,7 @@ pub struct SendOptions {
|
|
|
72
75
|
/// the store insert uses this id verbatim; a repeat with the same id is rejected
|
|
73
76
|
/// as [`DeliveryRefusal::Duplicate`] instead of creating a second row.
|
|
74
77
|
pub message_id: Option<String>,
|
|
78
|
+
pub presentation: PresentationRequest,
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
impl Default for SendOptions {
|
|
@@ -91,6 +95,7 @@ impl Default for SendOptions {
|
|
|
91
95
|
block_until_delivered: true,
|
|
92
96
|
team: None,
|
|
93
97
|
message_id: None,
|
|
98
|
+
presentation: PresentationRequest::default(),
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
101
|
}
|
|
@@ -118,7 +123,53 @@ pub fn send_message(
|
|
|
118
123
|
backfill_leader_binding_for_delivery_view(&mut state, &raw_state);
|
|
119
124
|
let recipient = match target {
|
|
120
125
|
MessageTarget::Single(target) if target == "leader" => {
|
|
121
|
-
let
|
|
126
|
+
let presentation = decide_presentation(&opts.presentation, PresentationSource::Send);
|
|
127
|
+
if presentation.effective_sink != PresentationSink::Leader {
|
|
128
|
+
let mut intent = ResolvedSendIntent::accepted(
|
|
129
|
+
opts.origin,
|
|
130
|
+
workspace,
|
|
131
|
+
opts.team.clone(),
|
|
132
|
+
LogicalRecipient::Leader,
|
|
133
|
+
opts.sender.clone(),
|
|
134
|
+
opts.task_id.clone(),
|
|
135
|
+
content,
|
|
136
|
+
None,
|
|
137
|
+
false,
|
|
138
|
+
opts.message_id.clone(),
|
|
139
|
+
);
|
|
140
|
+
intent.initial_disposition = InitialDisposition::StoredOnly;
|
|
141
|
+
intent.presentation = presentation.clone();
|
|
142
|
+
let persisted = match persist_resolved_send(&intent)? {
|
|
143
|
+
PersistResolution::Persisted(persisted) => persisted,
|
|
144
|
+
PersistResolution::Duplicate(message_id) => {
|
|
145
|
+
return Ok(refused_outcome_with_id(
|
|
146
|
+
DeliveryRefusal::Duplicate,
|
|
147
|
+
Some(message_id),
|
|
148
|
+
));
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
event_log.write(
|
|
152
|
+
"presentation.stored_without_live_inject",
|
|
153
|
+
serde_json::json!({
|
|
154
|
+
"message_id": persisted.message_id,
|
|
155
|
+
"requested_sink": presentation.requested_sink,
|
|
156
|
+
"effective_sink": presentation.effective_sink,
|
|
157
|
+
"class": presentation.class,
|
|
158
|
+
"policy_reason": presentation.policy_reason,
|
|
159
|
+
}),
|
|
160
|
+
)?;
|
|
161
|
+
return Ok(DeliveryOutcome {
|
|
162
|
+
ok: true,
|
|
163
|
+
status: DeliveryStatus::StoredOnly,
|
|
164
|
+
message_status: MessageStatusShadow("stored_only".to_string()),
|
|
165
|
+
message_id: Some(persisted.message_id),
|
|
166
|
+
verification: Some("durable_without_live_inject".to_string()),
|
|
167
|
+
stage: None,
|
|
168
|
+
reason: None,
|
|
169
|
+
channel: Some(presentation.effective_sink.as_str().to_string()),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
let outcome = send_to_leader_receiver_with_presentation(
|
|
122
173
|
workspace,
|
|
123
174
|
&state,
|
|
124
175
|
"leader",
|
|
@@ -128,6 +179,7 @@ pub fn send_message(
|
|
|
128
179
|
opts.requires_ack,
|
|
129
180
|
None,
|
|
130
181
|
opts.message_id.as_deref(),
|
|
182
|
+
&presentation,
|
|
131
183
|
&event_log,
|
|
132
184
|
)?;
|
|
133
185
|
if matches!(outcome.status, DeliveryStatus::Queued) && owner_pane_is_dead(&state) {
|