@team-agent/installer 0.3.6 → 0.3.7
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/diagnose.rs +9 -0
- package/crates/team-agent/src/cli/emit.rs +63 -0
- package/crates/team-agent/src/cli/mod.rs +334 -35
- package/crates/team-agent/src/cli/status_port.rs +62 -0
- package/crates/team-agent/src/cli/tests/base.rs +9 -4
- package/crates/team-agent/src/cli/tests/run_delegation.rs +10 -2
- package/crates/team-agent/src/cli/types.rs +3 -2
- package/crates/team-agent/src/compiler.rs +73 -50
- package/crates/team-agent/src/coordinator/tick.rs +108 -20
- package/crates/team-agent/src/db/migration.rs +17 -1
- package/crates/team-agent/src/lifecycle/launch.rs +182 -47
- package/crates/team-agent/src/lifecycle/restart/common.rs +4 -9
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +75 -2
- package/crates/team-agent/src/lifecycle/restart/selection.rs +6 -4
- package/crates/team-agent/src/lifecycle/tests/core.rs +46 -3
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +221 -7
- package/crates/team-agent/src/mcp_server/normalize.rs +29 -7
- package/crates/team-agent/src/mcp_server/tests/golden.rs +7 -5
- package/crates/team-agent/src/mcp_server/tests/normalize.rs +5 -2
- package/crates/team-agent/src/mcp_server/tools.rs +25 -1
- package/crates/team-agent/src/mcp_server/wire.rs +11 -1
- package/crates/team-agent/src/model/paths.rs +7 -0
- package/crates/team-agent/src/model/spec.rs +23 -1
- package/crates/team-agent/src/packaging/install.rs +42 -4
- package/crates/team-agent/src/packaging/tests.rs +91 -14
- package/crates/team-agent/src/packaging/types.rs +13 -1
- package/crates/team-agent/src/provider/adapter.rs +204 -0
- package/crates/team-agent/src/state/selector.rs +48 -14
- package/crates/team-agent/src/tmux_backend.rs +14 -2
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +82 -5
|
@@ -400,15 +400,20 @@ latest result: none";
|
|
|
400
400
|
let payload = err.to_payload(Path::new("/tmp/cli-error-123.log"), "quick-start");
|
|
401
401
|
assert_eq!(payload.reason.as_deref(), Some("tmux_session_name_conflict"));
|
|
402
402
|
assert_eq!(payload.session_name.as_deref(), Some("my-team"));
|
|
403
|
+
// E8 (N38): quick-start 撞已有 runtime 引导到 restart(resume),明确 --fresh 会丢上下文。
|
|
403
404
|
assert_eq!(
|
|
404
405
|
payload.action,
|
|
405
|
-
"tmux session `my-team` already exists. It may be
|
|
406
|
-
|
|
407
|
-
change `name:` in TEAM.md and run quick-start again.
|
|
406
|
+
"tmux session `my-team` already exists. It may be your own existing team. \
|
|
407
|
+
To resume it use `team-agent restart` (NOT --fresh, which discards context). \
|
|
408
|
+
Only if you want a separate team, change `name:` in TEAM.md and run quick-start again. \
|
|
409
|
+
Never terminate existing tmux sessions from quick-start."
|
|
408
410
|
);
|
|
409
411
|
assert_eq!(
|
|
410
412
|
payload.next_actions,
|
|
411
|
-
Some(vec![
|
|
413
|
+
Some(vec![
|
|
414
|
+
"If this is your existing team, resume it with `team-agent restart`.".to_string(),
|
|
415
|
+
"If you want a separate team, change `name:` in TEAM.md and run `team-agent quick-start` again.".to_string(),
|
|
416
|
+
])
|
|
412
417
|
);
|
|
413
418
|
}
|
|
414
419
|
|
|
@@ -209,10 +209,18 @@ fn current_uid() -> Option<String> {
|
|
|
209
209
|
json: true,
|
|
210
210
|
};
|
|
211
211
|
let _ = cmd_quick_start(&args); // real quick_start compiles the spec before any coordinator/launch step
|
|
212
|
+
// E5: spec compiled to .team/runtime/<team_key>/, NOT the user team dir.
|
|
213
|
+
let team_key = team.file_name().unwrap().to_string_lossy().to_string();
|
|
214
|
+
let workspace = crate::model::paths::team_workspace(&team).unwrap();
|
|
215
|
+
let runtime_spec = crate::model::paths::runtime_spec_path(&workspace, &team_key);
|
|
212
216
|
assert!(
|
|
213
|
-
|
|
217
|
+
runtime_spec.exists(),
|
|
214
218
|
"cmd_quick_start must delegate to crate::lifecycle::quick_start, which compiles team.spec.yaml \
|
|
215
|
-
under
|
|
219
|
+
under .team/runtime/<team_key>/; the placeholder never writes it"
|
|
220
|
+
);
|
|
221
|
+
assert!(
|
|
222
|
+
!team.join("team.spec.yaml").exists(),
|
|
223
|
+
"E5: quick_start must NOT write spec into the user team dir"
|
|
216
224
|
);
|
|
217
225
|
}
|
|
218
226
|
|
|
@@ -86,10 +86,11 @@ impl CliError {
|
|
|
86
86
|
payload.session_name = Some(session.clone());
|
|
87
87
|
if command == "quick-start" {
|
|
88
88
|
payload.action = format!(
|
|
89
|
-
"tmux session `{session}` already exists. It may be
|
|
89
|
+
"tmux session `{session}` already exists. It may be your own existing team. To resume it use `team-agent restart` (NOT --fresh, which discards context). Only if you want a separate team, change `name:` in TEAM.md and run quick-start again. Never terminate existing tmux sessions from quick-start."
|
|
90
90
|
);
|
|
91
91
|
payload.next_actions = Some(vec![
|
|
92
|
-
"
|
|
92
|
+
"If this is your existing team, resume it with `team-agent restart`.".to_string(),
|
|
93
|
+
"If you want a separate team, change `name:` in TEAM.md and run `team-agent quick-start` again.".to_string(),
|
|
93
94
|
]);
|
|
94
95
|
} else {
|
|
95
96
|
payload.action = format!(
|
|
@@ -141,56 +141,9 @@ pub fn compile_team(team_dir: &Path) -> Result<Value, ModelError> {
|
|
|
141
141
|
let mut agents = Vec::new();
|
|
142
142
|
let mut agent_ids = Vec::new();
|
|
143
143
|
for path in role_paths {
|
|
144
|
-
let
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
let provider = required_string(&meta, &path, "provider")?;
|
|
148
|
-
let model = resolve_model(&meta, &team_meta, &provider);
|
|
149
|
-
let auth_mode = string_field(&meta, "auth_mode")
|
|
150
|
-
.or_else(|| string_field(&team_meta, "default_auth_mode"))
|
|
151
|
-
.unwrap_or_else(|| "subscription".to_string());
|
|
152
|
-
if auth_mode != "subscription" && meta.get("profile").is_none() {
|
|
153
|
-
return Err(ModelError::Validation(format!(
|
|
154
|
-
"{}: profile is required when auth_mode is '{auth_mode}'",
|
|
155
|
-
path.display(),
|
|
156
|
-
)));
|
|
157
|
-
}
|
|
158
|
-
let tools = required_tools(&meta, &path)?;
|
|
159
|
-
let prompt_inline = non_empty_trimmed(&body).unwrap_or_else(|| role.clone());
|
|
160
|
-
agent_ids.push(id.clone());
|
|
161
|
-
let mut agent_items = vec![
|
|
162
|
-
("id", Value::Str(id.clone())),
|
|
163
|
-
("role", Value::Str(role.clone())),
|
|
164
|
-
("provider", Value::Str(provider)),
|
|
165
|
-
("model", model),
|
|
166
|
-
("auth_mode", Value::Str(auth_mode)),
|
|
167
|
-
("working_directory", Value::Str(workspace_s.clone())),
|
|
168
|
-
(
|
|
169
|
-
"system_prompt",
|
|
170
|
-
map(vec![
|
|
171
|
-
("inline", Value::Str(prompt_inline)),
|
|
172
|
-
("file", Value::Null),
|
|
173
|
-
]),
|
|
174
|
-
),
|
|
175
|
-
("tools", list_str(tools)),
|
|
176
|
-
("permission_mode", Value::Str("restricted".to_string())),
|
|
177
|
-
("preferred_for", list_str(vec![id, role])),
|
|
178
|
-
("avoid_for", Value::List(Vec::new())),
|
|
179
|
-
(
|
|
180
|
-
"output_contract",
|
|
181
|
-
map(vec![
|
|
182
|
-
("format", Value::Str("result_envelope_v1".to_string())),
|
|
183
|
-
(
|
|
184
|
-
"required_fields",
|
|
185
|
-
list_str(vec!["task_id", "status", "summary", "artifacts"]),
|
|
186
|
-
),
|
|
187
|
-
]),
|
|
188
|
-
),
|
|
189
|
-
];
|
|
190
|
-
if let Some(profile) = string_field(&meta, "profile") {
|
|
191
|
-
agent_items.push(("profile", Value::Str(profile)));
|
|
192
|
-
}
|
|
193
|
-
agents.push(map(agent_items));
|
|
144
|
+
let compiled = compile_role_agent(&path, &team_meta, &workspace_s)?;
|
|
145
|
+
agent_ids.push(compiled.id);
|
|
146
|
+
agents.push(compiled.agent);
|
|
194
147
|
}
|
|
195
148
|
|
|
196
149
|
let default_assignee = agent_ids.first().cloned().unwrap_or_default();
|
|
@@ -327,6 +280,76 @@ pub fn compile_team(team_dir: &Path) -> Result<Value, ModelError> {
|
|
|
327
280
|
Ok(spec)
|
|
328
281
|
}
|
|
329
282
|
|
|
283
|
+
/// 单个角色文档 → 编译后的 agent spec 条目(从 [`compile_team`] 的 per-role 循环抽出)。
|
|
284
|
+
/// E5 Bug1:add-agent 复用它**就地读** role 文件编译,不再 copy 进平台目录。
|
|
285
|
+
pub struct CompiledRole {
|
|
286
|
+
pub id: String,
|
|
287
|
+
pub role: String,
|
|
288
|
+
pub agent: Value,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/// 把一份 role 文档编译成 agent spec 条目。`team_meta` 供 model/auth_mode 继承;
|
|
292
|
+
/// `workspace_s` 是 working_directory。**纯读 `role_path`,无任何文件落地。**
|
|
293
|
+
pub fn compile_role_agent(
|
|
294
|
+
role_path: &Path,
|
|
295
|
+
team_meta: &Value,
|
|
296
|
+
workspace_s: &str,
|
|
297
|
+
) -> Result<CompiledRole, ModelError> {
|
|
298
|
+
let (meta, body) = read_front_matter(role_path)?;
|
|
299
|
+
let id = required_string(&meta, role_path, "name")?;
|
|
300
|
+
let role = required_string(&meta, role_path, "role")?;
|
|
301
|
+
let provider = required_string(&meta, role_path, "provider")?;
|
|
302
|
+
let model = resolve_model(&meta, team_meta, &provider);
|
|
303
|
+
let auth_mode = string_field(&meta, "auth_mode")
|
|
304
|
+
.or_else(|| string_field(team_meta, "default_auth_mode"))
|
|
305
|
+
.unwrap_or_else(|| "subscription".to_string());
|
|
306
|
+
if auth_mode != "subscription" && meta.get("profile").is_none() {
|
|
307
|
+
return Err(ModelError::Validation(format!(
|
|
308
|
+
"{}: profile is required when auth_mode is '{auth_mode}'",
|
|
309
|
+
role_path.display(),
|
|
310
|
+
)));
|
|
311
|
+
}
|
|
312
|
+
let tools = required_tools(&meta, role_path)?;
|
|
313
|
+
let prompt_inline = non_empty_trimmed(&body).unwrap_or_else(|| role.clone());
|
|
314
|
+
let mut agent_items = vec![
|
|
315
|
+
("id", Value::Str(id.clone())),
|
|
316
|
+
("role", Value::Str(role.clone())),
|
|
317
|
+
("provider", Value::Str(provider)),
|
|
318
|
+
("model", model),
|
|
319
|
+
("auth_mode", Value::Str(auth_mode)),
|
|
320
|
+
("working_directory", Value::Str(workspace_s.to_string())),
|
|
321
|
+
(
|
|
322
|
+
"system_prompt",
|
|
323
|
+
map(vec![
|
|
324
|
+
("inline", Value::Str(prompt_inline)),
|
|
325
|
+
("file", Value::Null),
|
|
326
|
+
]),
|
|
327
|
+
),
|
|
328
|
+
("tools", list_str(tools)),
|
|
329
|
+
("permission_mode", Value::Str("restricted".to_string())),
|
|
330
|
+
("preferred_for", list_str(vec![id.clone(), role.clone()])),
|
|
331
|
+
("avoid_for", Value::List(Vec::new())),
|
|
332
|
+
(
|
|
333
|
+
"output_contract",
|
|
334
|
+
map(vec![
|
|
335
|
+
("format", Value::Str("result_envelope_v1".to_string())),
|
|
336
|
+
(
|
|
337
|
+
"required_fields",
|
|
338
|
+
list_str(vec!["task_id", "status", "summary", "artifacts"]),
|
|
339
|
+
),
|
|
340
|
+
]),
|
|
341
|
+
),
|
|
342
|
+
];
|
|
343
|
+
if let Some(profile) = string_field(&meta, "profile") {
|
|
344
|
+
agent_items.push(("profile", Value::Str(profile)));
|
|
345
|
+
}
|
|
346
|
+
Ok(CompiledRole {
|
|
347
|
+
id,
|
|
348
|
+
role,
|
|
349
|
+
agent: map(agent_items),
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
|
|
330
353
|
fn map(items: Vec<(&str, Value)>) -> Value {
|
|
331
354
|
Value::Map(items.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
|
|
332
355
|
}
|
|
@@ -228,8 +228,18 @@ impl Coordinator {
|
|
|
228
228
|
);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
// B-4 / 036b N36 三路可用 — 监测步(runtime_prompts / sync_health /
|
|
232
|
+
// detect_abnormal_exits)失败必须降级+continue,**不能**用 `?` 中断 tick,
|
|
233
|
+
// 否则 deliver_pending(下行投递主干)够不到,消息卡 accepted。
|
|
234
|
+
// bug-084 哲学 + A-6 同族:每步独立 try,失败写 `coordinator.tick.<step>_failed`
|
|
235
|
+
// 事件后继续走下一步;tick 本身仍返 Ok。
|
|
231
236
|
self.record_step("runtime_prompts");
|
|
232
|
-
self.handle_runtime_approval_prompts(&mut state, &event_log)
|
|
237
|
+
if let Err(error) = self.handle_runtime_approval_prompts(&mut state, &event_log) {
|
|
238
|
+
let _ = event_log.write(
|
|
239
|
+
"coordinator.tick.runtime_prompts_failed",
|
|
240
|
+
serde_json::json!({"error": error.to_string()}),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
233
243
|
|
|
234
244
|
self.record_step("sync_health");
|
|
235
245
|
// P5 (C-P5-1, N3): ONE pane snapshot per tick, shared by sync_health and the
|
|
@@ -237,32 +247,71 @@ impl Coordinator {
|
|
|
237
247
|
// this tick; every tick re-reads).
|
|
238
248
|
let pane_snapshot = self.transport.list_targets().unwrap_or_default();
|
|
239
249
|
let captures_by_agent =
|
|
240
|
-
self.sync_agent_health(&mut state, &store, &event_log, &pane_snapshot)
|
|
250
|
+
match self.sync_agent_health(&mut state, &store, &event_log, &pane_snapshot) {
|
|
251
|
+
Ok(captures) => captures,
|
|
252
|
+
Err(error) => {
|
|
253
|
+
let _ = event_log.write(
|
|
254
|
+
"coordinator.tick.sync_health_failed",
|
|
255
|
+
serde_json::json!({"error": error.to_string()}),
|
|
256
|
+
);
|
|
257
|
+
BTreeMap::new()
|
|
258
|
+
}
|
|
259
|
+
};
|
|
241
260
|
// C-3-4 cr verdict — copilot 一期 classify→None(Unknown);为防 silent,
|
|
242
261
|
// tick 每次发现 copilot agent(从 state.agents 直接扫,不依赖 captures —
|
|
243
262
|
// 离线/未起 tmux 场景仍能写)就发 `provider.classify.unsupported` 事件
|
|
244
263
|
// (字面 reason=`phase1_unknown_pending_sample`,含 provider="copilot" + "classify"
|
|
245
264
|
// 串)。二期接 sqlite turns 表后这条删/降级,届时改 reason 区分。
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
"provider"
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
//
|
|
266
|
+
// B-4 P4 / 036b 防 dedup-flood:同 (provider, agent_id, reason) 状态跨 tick
|
|
267
|
+
// 只发一次(check_key 范式,同 abnormal_check_key tick.rs:603 同精神);状态
|
|
268
|
+
// 变了才再发。check_key 落 state.agents.<id>.classify_unsupported.last_key,
|
|
269
|
+
// tick-only metadata,不进 #235 owner / receiver 等持久态。
|
|
270
|
+
let agents_snapshot: Vec<(String, Option<String>)> =
|
|
271
|
+
if let Some(agents) = state.get("agents").and_then(Value::as_object) {
|
|
272
|
+
agents
|
|
273
|
+
.iter()
|
|
274
|
+
.filter_map(|(agent_id, agent)| {
|
|
275
|
+
let is_copilot = agent
|
|
276
|
+
.get("provider")
|
|
277
|
+
.and_then(Value::as_str)
|
|
278
|
+
.and_then(parse_provider)
|
|
279
|
+
.is_some_and(|p| matches!(p, crate::model::enums::Provider::Copilot));
|
|
280
|
+
if !is_copilot {
|
|
281
|
+
return None;
|
|
282
|
+
}
|
|
283
|
+
let last_key = agent
|
|
284
|
+
.get("classify_unsupported")
|
|
285
|
+
.and_then(|v| v.get("last_key"))
|
|
286
|
+
.and_then(Value::as_str)
|
|
287
|
+
.map(str::to_string);
|
|
288
|
+
Some((agent_id.clone(), last_key))
|
|
289
|
+
})
|
|
290
|
+
.collect()
|
|
291
|
+
} else {
|
|
292
|
+
Vec::new()
|
|
293
|
+
};
|
|
294
|
+
for (agent_id, last_key) in agents_snapshot {
|
|
295
|
+
let check_key = format!("copilot|{agent_id}|phase1_unknown_pending_sample");
|
|
296
|
+
if last_key.as_deref() == Some(check_key.as_str()) {
|
|
297
|
+
continue;
|
|
263
298
|
}
|
|
299
|
+
let _ = event_log.write(
|
|
300
|
+
"provider.classify.unsupported",
|
|
301
|
+
serde_json::json!({
|
|
302
|
+
"provider": "copilot",
|
|
303
|
+
"agent_id": agent_id,
|
|
304
|
+
"reason": "phase1_unknown_pending_sample",
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
mark_classify_unsupported(&mut state, &agent_id, &check_key);
|
|
308
|
+
}
|
|
309
|
+
if let Err(error) = self.detect_abnormal_exits(&mut state, &event_log, &pane_snapshot) {
|
|
310
|
+
let _ = event_log.write(
|
|
311
|
+
"coordinator.tick.detect_abnormal_failed",
|
|
312
|
+
serde_json::json!({"error": error.to_string()}),
|
|
313
|
+
);
|
|
264
314
|
}
|
|
265
|
-
self.detect_abnormal_exits(&mut state, &event_log, &pane_snapshot)?;
|
|
266
315
|
|
|
267
316
|
self.record_step("deliver_pending");
|
|
268
317
|
let delivered = crate::messaging::deliver_pending_messages(
|
|
@@ -389,6 +438,12 @@ impl Coordinator {
|
|
|
389
438
|
let team = crate::state::projection::team_state_key(&snapshot);
|
|
390
439
|
let team_key = Some(crate::model::ids::TeamKey::new(team.clone()));
|
|
391
440
|
let session_name = state.get("session_name").and_then(Value::as_str).map(str::to_string);
|
|
441
|
+
// B-4 / 036b N36 三路可用 — sync_health 内 per-agent capture 失败本就降级
|
|
442
|
+
// (写 coordinator.agent_capture_failed 后 continue),不打断 deliver_pending
|
|
443
|
+
// 主干。但 contract 要求一条【tick 级】可观测的 step-failed 信号 —
|
|
444
|
+
// sync_health 失败一旦发生就在末尾 emit `coordinator.tick.sync_health_failed`
|
|
445
|
+
// (含 "tick" + "_failed" 双串),避免 silent。
|
|
446
|
+
let mut had_capture_failure = false;
|
|
392
447
|
// P5 (C-P5-2): one list-windows per SESSION per tick — memoized across the
|
|
393
448
|
// agent loop instead of one fork per agent.
|
|
394
449
|
let mut windows_by_session: BTreeMap<String, Result<Vec<crate::transport::WindowName>, String>> =
|
|
@@ -409,6 +464,7 @@ impl Coordinator {
|
|
|
409
464
|
}) {
|
|
410
465
|
Ok(windows) => windows.clone(),
|
|
411
466
|
Err(error) => {
|
|
467
|
+
had_capture_failure = true;
|
|
412
468
|
event_log.write(
|
|
413
469
|
"coordinator.agent_capture_failed",
|
|
414
470
|
serde_json::json!({
|
|
@@ -429,6 +485,7 @@ impl Coordinator {
|
|
|
429
485
|
{
|
|
430
486
|
Ok(captured) => captured,
|
|
431
487
|
Err(error) => {
|
|
488
|
+
had_capture_failure = true;
|
|
432
489
|
event_log.write(
|
|
433
490
|
"coordinator.agent_capture_failed",
|
|
434
491
|
serde_json::json!({
|
|
@@ -506,6 +563,15 @@ impl Coordinator {
|
|
|
506
563
|
},
|
|
507
564
|
);
|
|
508
565
|
}
|
|
566
|
+
// B-4 step-level signal:若本 tick 有任一 capture 失败,emit
|
|
567
|
+
// `coordinator.tick.sync_health_failed`(含 "tick" + "_failed")让 contract
|
|
568
|
+
// 可观测,deliver_pending 主干不受影响。
|
|
569
|
+
if had_capture_failure {
|
|
570
|
+
let _ = event_log.write(
|
|
571
|
+
"coordinator.tick.sync_health_failed",
|
|
572
|
+
serde_json::json!({"step": "sync_health", "degraded": true}),
|
|
573
|
+
);
|
|
574
|
+
}
|
|
509
575
|
Ok(captures)
|
|
510
576
|
}
|
|
511
577
|
|
|
@@ -1738,6 +1804,28 @@ fn mark_abnormal_suppressed(state: &mut Value, agent_id: &str, key: &str) {
|
|
|
1738
1804
|
}
|
|
1739
1805
|
}
|
|
1740
1806
|
|
|
1807
|
+
/// B-4 P4 dedup marker — 把 classify.unsupported check_key 写到
|
|
1808
|
+
/// state.agents.<id>.classify_unsupported.last_key,只在状态变了才再发事件。
|
|
1809
|
+
/// 同 abnormal_check_key (line 603) 同精神,但落 agents 子 obj(per-agent locality,
|
|
1810
|
+
/// 不污染 abnormal_exit_watch 命名空间)。
|
|
1811
|
+
fn mark_classify_unsupported(state: &mut Value, agent_id: &str, key: &str) {
|
|
1812
|
+
let Some(agents) = state.get_mut("agents").and_then(Value::as_object_mut) else {
|
|
1813
|
+
return;
|
|
1814
|
+
};
|
|
1815
|
+
let Some(agent) = agents.get_mut(agent_id).and_then(Value::as_object_mut) else {
|
|
1816
|
+
return;
|
|
1817
|
+
};
|
|
1818
|
+
let entry = agent
|
|
1819
|
+
.entry("classify_unsupported".to_string())
|
|
1820
|
+
.or_insert_with(|| serde_json::json!({}));
|
|
1821
|
+
if !entry.is_object() {
|
|
1822
|
+
*entry = serde_json::json!({});
|
|
1823
|
+
}
|
|
1824
|
+
if let Some(obj) = entry.as_object_mut() {
|
|
1825
|
+
obj.insert("last_key".to_string(), serde_json::json!(key));
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1741
1829
|
fn mark_abnormal_checked(state: &mut Value, agent_id: &str, key: &str) {
|
|
1742
1830
|
if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
|
|
1743
1831
|
let entry = watch
|
|
@@ -84,12 +84,16 @@ pub struct RebuildEvent {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/// `schema_diagnosis` 的只读结论。
|
|
87
|
-
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
87
|
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
|
88
88
|
pub struct Diagnosis {
|
|
89
89
|
pub ok: bool,
|
|
90
90
|
pub status: String,
|
|
91
91
|
pub user_version: i64,
|
|
92
92
|
pub layout_diffs: Vec<String>,
|
|
93
|
+
/// Python parity (`schema_migration.py:188/206`): the layered guidance line —
|
|
94
|
+
/// "missing" carries the initialize_schema first-use hint; drift carries the
|
|
95
|
+
/// fix-schema command; healthy carries "none".
|
|
96
|
+
pub recommended_action: String,
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
fn table_exists(conn: &Connection, table: &str) -> Result<bool, DbError> {
|
|
@@ -316,11 +320,18 @@ pub fn ensure_table_layout(
|
|
|
316
320
|
/// `schema_migration.py:schema_diagnosis`:只读判定(不变更 DB)。
|
|
317
321
|
pub fn schema_diagnosis(db_path: &Path, schema_version: i64) -> Result<Diagnosis, DbError> {
|
|
318
322
|
if !db_path.exists() {
|
|
323
|
+
// T3-3 cr verdict (A parity lock, 2026-06-10): a missing db is the LEGAL
|
|
324
|
+
// first-use state — ok:true is layered with the explicit status axis and the
|
|
325
|
+
// recommended_action guidance (Python schema_migration.py:180-190 verbatim),
|
|
326
|
+
// never a silent fake-green.
|
|
319
327
|
return Ok(Diagnosis {
|
|
320
328
|
ok: true,
|
|
321
329
|
status: "missing".to_string(),
|
|
322
330
|
user_version: 0,
|
|
323
331
|
layout_diffs: vec![],
|
|
332
|
+
recommended_action:
|
|
333
|
+
"No team.db exists yet; initialize_schema will create it on first use."
|
|
334
|
+
.to_string(),
|
|
324
335
|
});
|
|
325
336
|
}
|
|
326
337
|
let conn = Connection::open(db_path)?;
|
|
@@ -332,6 +343,11 @@ pub fn schema_diagnosis(db_path: &Path, schema_version: i64) -> Result<Diagnosis
|
|
|
332
343
|
ok,
|
|
333
344
|
status: if ok { "ok".to_string() } else { "schema_repair_available".to_string() },
|
|
334
345
|
user_version: uv,
|
|
346
|
+
recommended_action: if diff_tables.is_empty() {
|
|
347
|
+
"none".to_string()
|
|
348
|
+
} else {
|
|
349
|
+
"run team-agent doctor --fix-schema --json".to_string()
|
|
350
|
+
},
|
|
335
351
|
layout_diffs: diff_tables,
|
|
336
352
|
})
|
|
337
353
|
}
|