mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 17:10:50 -10:00
wip: unfinished review fix-up (interrupted by the session usage limit)
Uncommitted edits from the fix-up stage, saved so nothing is lost. Not compiled or tested after these edits; the next pass reviews and finishes them. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
1 parent
3bedc41a91
commit
74cf2c043c
4 files changed
+224
-22
No files matched your search
@@ -10099,7 +10099,12 @@ impl ToolSpec for AgentTool {
|
||||
"runtime": {
|
||||
"type": "string",
|
||||
"enum": ["local", "cloud"],
|
||||
"description": "For action=start. local (default) runs the agent here. cloud only proposes a long job for a cloud sandbox that raises a branch and opens a PR: it starts nothing and spends nothing, and returns a job the person confirms with /dispatch confirm <id>. Never confirm it yourself. Pass only prompt, plus remote (github, cnb or gitee) when the repository has several forges."
|
||||
"description": "For action=start. local (default) runs the agent here. cloud only proposes a long job for a cloud sandbox that raises a branch and opens a PR: it starts nothing and spends nothing, and returns a job the person confirms with /dispatch confirm <id>. Never confirm it yourself. Pass only prompt, plus remote when the repository has several forges."
|
||||
},
|
||||
"remote": {
|
||||
"type": "string",
|
||||
"enum": ["github", "cnb", "gitee"],
|
||||
"description": "For runtime=cloud only: which forge gets the branch and PR when the repository has several."
|
||||
},
|
||||
"detached": {
|
||||
"type": "boolean",
|
||||
|
||||
@@ -5844,6 +5844,7 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() {
|
||||
"offset",
|
||||
"profile",
|
||||
"prompt",
|
||||
"remote",
|
||||
"resume_from",
|
||||
"runtime",
|
||||
"thinking",
|
||||
|
||||
@@ -342,7 +342,11 @@ fn finish_workflow_controller(state: &WorkflowWorkspaceState, record: &WorkflowR
|
||||
if let Some(report) = &report {
|
||||
receipt["report"] = json!(report);
|
||||
}
|
||||
let summary = workflow_receipt_summary(record, report.as_deref());
|
||||
let summary = workflow_receipt_summary(
|
||||
record,
|
||||
&controller.driver.task_records_snapshot(),
|
||||
report.as_deref(),
|
||||
);
|
||||
let payload =
|
||||
format!("{summary}\n<codewhale:subagent.done>{receipt}</codewhale:subagent.done>");
|
||||
debug_assert!(payload.len() <= WORKFLOW_COMPLETION_MAX_BYTES);
|
||||
@@ -358,8 +362,14 @@ fn finish_workflow_controller(state: &WorkflowWorkspaceState, record: &WorkflowR
|
||||
/// The plain-language half of a workflow's parent receipt: what ended, how
|
||||
/// many agents finished and why the others stopped, where the report is, and
|
||||
/// a bounded result preview. Event names and the JSON receipt carry the
|
||||
/// machine-readable status; this is for the reader.
|
||||
fn workflow_receipt_summary(record: &WorkflowRunRecord, report: Option<&str>) -> String {
|
||||
/// machine-readable status; this is for the reader. `tasks` is the driver's
|
||||
/// per-task ledger, which counts the agents once the run's retained event
|
||||
/// tail has been trimmed.
|
||||
fn workflow_receipt_summary(
|
||||
record: &WorkflowRunRecord,
|
||||
tasks: &[RuntimeTaskRecord],
|
||||
report: Option<&str>,
|
||||
) -> String {
|
||||
let name = record
|
||||
.workflow_goal
|
||||
.as_deref()
|
||||
@@ -367,7 +377,13 @@ fn workflow_receipt_summary(record: &WorkflowRunRecord, report: Option<&str>) ->
|
||||
.filter(|goal| !goal.is_empty())
|
||||
.map(|goal| format!("Workflow \"{}\"", truncate_chars(goal, 160)))
|
||||
.unwrap_or_else(|| format!("Workflow {}", truncate_chars(&record.run_id, 64)));
|
||||
let tally = WorkflowTally::from_events(&record.events);
|
||||
let mut tally = WorkflowTally::from_events(&record.events);
|
||||
if record.events_dropped > 0 {
|
||||
tally.count_from_ledger(
|
||||
tasks.iter().map(|task| task.status),
|
||||
record.dispatch_failure_count,
|
||||
);
|
||||
}
|
||||
let mut summary = format!(
|
||||
"{name} {}: {}.",
|
||||
view::run_outcome_phrase(record.status),
|
||||
@@ -1706,6 +1722,9 @@ fn cancel_workflow_run(
|
||||
state.mark_owner_missing(run_id);
|
||||
let mut failed = snapshot;
|
||||
failed.error = Some(format!("workflow cancellation journal failed: {err}"));
|
||||
if let Some(controller) = controller.as_ref() {
|
||||
write_run_report_artifact(&controller.driver.workspace, &failed);
|
||||
}
|
||||
finish_workflow_controller(&state, &failed);
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"workflow cancellation journal failed: {err}"
|
||||
@@ -1717,6 +1736,9 @@ fn cancel_workflow_run(
|
||||
// the live panel finalizes running rows and cannot remain visually failed.
|
||||
if let Some(controller) = controller {
|
||||
controller.driver.emit_ui_event(&cancelled_event);
|
||||
// The receipt links the report, so write it first; the VM's own
|
||||
// terminal path may rewrite it moments later.
|
||||
write_run_report_artifact(&controller.driver.workspace, &snapshot);
|
||||
}
|
||||
finish_workflow_controller(&state, &snapshot);
|
||||
workflow_result_for(run_id, state, owner_session_id)
|
||||
@@ -12325,8 +12347,11 @@ FINAL RECEIPT
|
||||
));
|
||||
record.result = Some(json!({"a": "ok", "b": null}));
|
||||
|
||||
let summary =
|
||||
workflow_receipt_summary(&record, Some(".codewhale/reports/workflow_d08d912f.md"));
|
||||
let summary = workflow_receipt_summary(
|
||||
&record,
|
||||
&[],
|
||||
Some(".codewhale/reports/workflow_d08d912f.md"),
|
||||
);
|
||||
let first_line = summary.lines().next().expect("sentence");
|
||||
assert_eq!(
|
||||
first_line,
|
||||
@@ -12336,13 +12361,86 @@ FINAL RECEIPT
|
||||
assert!(summary.contains("\nReport: .codewhale/reports/workflow_d08d912f.md"));
|
||||
assert!(!summary.contains("Degraded") && !summary.contains(" ended "));
|
||||
|
||||
let unreported = workflow_receipt_summary(&record, None);
|
||||
let unreported = workflow_receipt_summary(&record, &[], None);
|
||||
assert!(
|
||||
unreported.contains("No report file was written; `workflow status workflow_d08d912f`"),
|
||||
"{unreported}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_counts_from_the_task_ledger_once_events_were_trimmed() {
|
||||
let mut record = WorkflowRunRecord::new(
|
||||
"workflow_trimmed".to_string(),
|
||||
Some("session-test".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
record.status = WorkflowRunStatus::Degraded;
|
||||
record.workflow_goal = Some("Survey".to_string());
|
||||
// Chatty run: logs push every task_started out of the retained tail.
|
||||
for index in 0..(WORKFLOW_RUN_EVENTS_MAX_RETAINED + 50) {
|
||||
record.push_event(WorkflowUiEvent::at(
|
||||
1,
|
||||
"session-test",
|
||||
WorkflowUiEventKind::Log {
|
||||
message: format!("line {index}"),
|
||||
},
|
||||
));
|
||||
}
|
||||
let budget = TaskCompletion::BudgetExhausted {
|
||||
message: "child step budget exhausted".to_string(),
|
||||
};
|
||||
let (reason, kind) = view::task_stop(&budget);
|
||||
record.push_event(WorkflowUiEvent::at(
|
||||
2,
|
||||
"session-test",
|
||||
WorkflowUiEventKind::TaskCompleted {
|
||||
task_id: "agent_mid".to_string(),
|
||||
status: IrWorkflowRunStatus::BudgetExceeded,
|
||||
reason,
|
||||
kind,
|
||||
usage: None,
|
||||
},
|
||||
));
|
||||
record.dispatch_failure_count = 1;
|
||||
assert!(record.events_dropped > 0);
|
||||
|
||||
// Events alone still count the agent they name as stopped.
|
||||
let events_only = workflow_receipt_summary(&record, &[], None);
|
||||
assert!(
|
||||
events_only.starts_with(
|
||||
"Workflow \"Survey\" finished with gaps: 0 of 1 agent finished, 1 failed \
|
||||
(agent_mid: stopped at the step limit)."
|
||||
),
|
||||
"{events_only}"
|
||||
);
|
||||
|
||||
let task = |index: usize, status| RuntimeTaskRecord {
|
||||
agent_id: format!("agent_{index}"),
|
||||
label: None,
|
||||
role: None,
|
||||
status,
|
||||
output: None,
|
||||
schema_error: None,
|
||||
usage: None,
|
||||
};
|
||||
let mut ledger: Vec<RuntimeTaskRecord> = (0..598)
|
||||
.map(|index| task(index, IrWorkflowRunStatus::Succeeded))
|
||||
.collect();
|
||||
ledger.push(task(598, IrWorkflowRunStatus::BudgetExceeded));
|
||||
ledger.push(task(599, IrWorkflowRunStatus::Failed));
|
||||
let summary = workflow_receipt_summary(&record, &ledger, None);
|
||||
assert!(
|
||||
summary.starts_with(
|
||||
"Workflow \"Survey\" finished with gaps: 598 of 601 agents finished, 2 failed, \
|
||||
1 could not start (agent_mid: stopped at the step limit; 2 more)."
|
||||
),
|
||||
"{summary}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detached_receipt_links_the_written_report_and_keeps_event_names() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
@@ -12844,6 +12942,53 @@ FINAL RECEIPT
|
||||
assert_eq!(tool.manager.read().await.active_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_cancel_receipt_links_the_report_it_wrote() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let (mut tool, context, mut completion_rx) = native_lifecycle_tool(tmp.path());
|
||||
let (event_tx, mut event_rx) = mpsc::channel(32);
|
||||
tool.runtime.event_tx = Some(event_tx);
|
||||
let started = tool
|
||||
.execute(
|
||||
json!({"action":"start", "script":"phase('spin'); while (true) {}"}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect("detached start");
|
||||
let started: Value = serde_json::from_str(&started.content).expect("start json");
|
||||
let run_id = started["run_id"].as_str().expect("run id").to_string();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Event::WorkflowUi { event, .. } =
|
||||
event_rx.recv().await.expect("event channel")
|
||||
&& event["type"] == "phase_started"
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("VM entered its phase");
|
||||
tool.execute(json!({"action":"cancel", "run_id":run_id}), &context)
|
||||
.await
|
||||
.expect("cancel");
|
||||
let completion =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), completion_rx.recv())
|
||||
.await
|
||||
.expect("cancel receipt arrives")
|
||||
.expect("parent inbox open");
|
||||
let receipt = terminal_workflow_receipt(&completion.payload);
|
||||
assert_eq!(receipt["status"], "cancelled");
|
||||
let report = format!(".codewhale/reports/{run_id}.md");
|
||||
assert_eq!(receipt["report"], report.as_str());
|
||||
assert!(
|
||||
completion.payload.contains(&format!("\nReport: {report}")),
|
||||
"{}",
|
||||
completion.payload
|
||||
);
|
||||
assert!(!completion.payload.contains("No report file was written"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_cancel_closes_queued_admission_without_cancelling_parent() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
@@ -13034,6 +13179,14 @@ FINAL RECEIPT
|
||||
.contains("workflow.max_children limit (1)"),
|
||||
"{second}"
|
||||
);
|
||||
// Both attempts found the one slot free, so neither was announced as
|
||||
// waiting for it.
|
||||
assert!(
|
||||
driver.state.runs.lock().expect("runs")[&driver.run_id]
|
||||
.events
|
||||
.iter()
|
||||
.all(|event| event.event_type() != "task_queued")
|
||||
);
|
||||
assert_eq!(driver.child_counter.load(Ordering::SeqCst), 1);
|
||||
assert!(driver.child_ids.lock().expect("children").is_empty());
|
||||
for name in ["max_children", "max_concurrent", "max_depth"] {
|
||||
|
||||
@@ -132,16 +132,7 @@ impl WorkflowTally {
|
||||
kind,
|
||||
..
|
||||
} => {
|
||||
let state = match status {
|
||||
IrWorkflowRunStatus::Succeeded => RowState::Finished,
|
||||
IrWorkflowRunStatus::Cancelled => RowState::Cancelled,
|
||||
IrWorkflowRunStatus::Pending | IrWorkflowRunStatus::Running => {
|
||||
RowState::Running
|
||||
}
|
||||
IrWorkflowRunStatus::Failed
|
||||
| IrWorkflowRunStatus::BudgetExceeded
|
||||
| IrWorkflowRunStatus::ReplayDiverged => RowState::Failed,
|
||||
};
|
||||
let state = row_state(*status);
|
||||
set_row(&mut rows, task_id, state);
|
||||
if matches!(state, RowState::Failed | RowState::Cancelled) {
|
||||
tally.stopped.push(StoppedAgent {
|
||||
@@ -201,6 +192,36 @@ impl WorkflowTally {
|
||||
tally
|
||||
}
|
||||
|
||||
/// Recount agents from the driver's own per-task ledger. A run keeps only
|
||||
/// its newest events, so once older ones were trimmed the events alone
|
||||
/// under-count; the ledger and the exact dispatch-failure total do not.
|
||||
/// `stopped` stays the named sample the retained events carry.
|
||||
pub(super) fn count_from_ledger(
|
||||
&mut self,
|
||||
statuses: impl IntoIterator<Item = IrWorkflowRunStatus>,
|
||||
dispatch_failures: u64,
|
||||
) {
|
||||
let (mut running, mut finished, mut failed, mut cancelled) = (0, 0, 0, 0);
|
||||
for status in statuses {
|
||||
match row_state(status) {
|
||||
RowState::Running => running += 1,
|
||||
RowState::Finished => finished += 1,
|
||||
RowState::Failed => failed += 1,
|
||||
RowState::Cancelled => cancelled += 1,
|
||||
}
|
||||
}
|
||||
if running + finished + failed + cancelled == 0 {
|
||||
return;
|
||||
}
|
||||
self.running = running;
|
||||
self.finished = finished;
|
||||
self.failed = failed;
|
||||
self.cancelled = cancelled;
|
||||
self.not_started = self
|
||||
.not_started
|
||||
.max(usize::try_from(dispatch_failures).unwrap_or(usize::MAX));
|
||||
}
|
||||
|
||||
/// Agents that started, plus tasks refused before they could.
|
||||
pub(super) fn total(&self) -> usize {
|
||||
self.running + self.finished + self.failed + self.cancelled + self.not_started
|
||||
@@ -249,8 +270,14 @@ impl WorkflowTally {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if self.stopped.len() > SHOWN {
|
||||
details.push(format!("{} more", self.stopped.len() - SHOWN));
|
||||
// The named list can be a sample of a trimmed run; the counts
|
||||
// are the whole run, so "more" is measured against them.
|
||||
let stopped_total = self
|
||||
.stopped
|
||||
.len()
|
||||
.max(self.failed + self.cancelled + self.not_started);
|
||||
if stopped_total > details.len() {
|
||||
details.push(format!("{} more", stopped_total - details.len()));
|
||||
}
|
||||
sentence.push_str(&format!(" ({})", details.join("; ")));
|
||||
}
|
||||
@@ -258,14 +285,30 @@ impl WorkflowTally {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_row(rows: &mut [(&str, RowState)], task_id: &str, state: RowState) -> bool {
|
||||
fn row_state(status: IrWorkflowRunStatus) -> RowState {
|
||||
match status {
|
||||
IrWorkflowRunStatus::Succeeded => RowState::Finished,
|
||||
IrWorkflowRunStatus::Cancelled => RowState::Cancelled,
|
||||
IrWorkflowRunStatus::Pending | IrWorkflowRunStatus::Running => RowState::Running,
|
||||
IrWorkflowRunStatus::Failed
|
||||
| IrWorkflowRunStatus::BudgetExceeded
|
||||
| IrWorkflowRunStatus::ReplayDiverged => RowState::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a row's state, adding the row when its `task_started` event was
|
||||
/// trimmed from the retained tail. True when the row is new or changed.
|
||||
fn set_row<'a>(rows: &mut Vec<(&'a str, RowState)>, task_id: &'a str, state: RowState) -> bool {
|
||||
match rows.iter_mut().find(|(id, _)| *id == task_id) {
|
||||
Some(row) => {
|
||||
let changed = row.1 != state;
|
||||
row.1 = state;
|
||||
changed
|
||||
}
|
||||
None => false,
|
||||
None => {
|
||||
rows.push((task_id, state));
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user