feat: retry main agent runs

This commit is contained in:
kris
2026-03-29 22:59:15 +08:00
parent 568e8091c1
commit 32c28fb7d6
4 changed files with 172 additions and 0 deletions

View File

@@ -182,6 +182,10 @@ class AgentRunCancelRequest(BaseModel):
reason: str = ""
class AgentRunRetryRequest(BaseModel):
reason: str = ""
INTENT_ACTIONS: dict[str, list[dict[str, Any]]] = {
"create_project": [{"key": "goto-intake", "label": "去我的项目", "kind": "navigate"}],
"create_assistant": [{"key": "open-create-assistant", "label": "创建 Agent", "kind": "ui_action"}],
@@ -4811,6 +4815,92 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None:
assert row is not None
return _agent_run_payload(row)
@app.post("/v2/oneliner/runs/{run_id}/retry")
def retry_oneliner_run(
run_id: str,
request: AgentRunRetryRequest,
account: dict[str, Any] = Depends(legacy.require_approved),
) -> dict[str, Any]:
row = _load_owned_agent_run(run_id, account)
current_status = str(row.get("run_status") or "")
if current_status not in {"cancelled", "blocked", "failed", "done"}:
raise HTTPException(status_code=409, detail="Run can not be retried yet")
project_id = str(row.get("project_id") or "").strip()
platform = str(row.get("platform") or "").strip()
platform_scope = _normalize_platform_scope(str(row.get("platform_scope") or "single_platform"))
governance = _effective_policy_payload(
subject_account=account,
subject_project_id=project_id,
platform=platform,
)
plan = _parse_json(row.get("plan_json"), {})
session_id = str(row.get("session_id") or "").strip()
_touch_session_for_run(session_id, platform=platform, intent_key=str(row.get("intent_key") or "custom").strip() or "custom")
next_run_id = make_id("oline_run")
timestamp = now()
active_admin_override_notice = governance.get("active_admin_override_notice") or {}
legacy.db.execute(
"""
INSERT INTO agent_runs (
id, user_id, project_id, session_id, source_screen, source_action_key, title, summary,
intent_key, platform, platform_scope, delivery_mode, run_status, scheduling_mode,
active_executor_key, plan_json, governance_json, result_json, status_summary,
needs_user_input, blocked_reason, active_admin_override_notice_json,
created_at, updated_at, started_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'needs_confirmation', ?, 'main_agent', ?, ?, '{}', ?, 1, '', ?, ?, ?, '', '')
""",
(
next_run_id,
account["id"],
project_id,
session_id,
str(row.get("source_screen") or "").strip(),
str(row.get("source_action_key") or "").strip(),
str(row.get("title") or plan.get("goal") or "主 Agent 任务").strip() or "主 Agent 任务",
str(request.reason or row.get("summary") or "").strip() or str(row.get("summary") or "").strip(),
str(row.get("intent_key") or "custom").strip() or "custom",
platform,
platform_scope,
_normalize_delivery_mode(str(row.get("delivery_mode") or "hybrid")),
_normalize_scheduling_mode(str(row.get("scheduling_mode") or "queued")),
_dump(plan),
_dump(governance),
"等待你确认执行计划",
_dump(active_admin_override_notice),
timestamp,
timestamp,
),
)
_log_agent_run_event(
next_run_id,
event_type="run.created",
summary=f"已创建待确认任务:{str(row.get('title') or plan.get('goal') or '主 Agent 任务').strip() or '主 Agent 任务'}",
details={
"run_status": "needs_confirmation",
"source_screen": str(row.get("source_screen") or "").strip(),
"source_action_key": str(row.get("source_action_key") or "").strip(),
"platform": platform,
"platform_scope": platform_scope,
},
)
_log_agent_run_event(
next_run_id,
event_type="run.retried",
summary=str(request.reason or "已基于上一轮主 Agent 任务重新生成待确认执行卡").strip(),
details={"retry_from_run_id": run_id, "from_status": current_status},
)
_log_agent_run_event(
run_id,
event_type="run.retried",
summary=str(request.reason or "用户要求重新发起当前主 Agent 任务").strip(),
details={"new_run_id": next_run_id, "from_status": current_status},
)
created = legacy.db.fetch_one("SELECT * FROM agent_runs WHERE id = ?", (next_run_id,))
assert created is not None
return _agent_run_payload(created)
@app.get("/v2/oneliner/runs")
def list_oneliner_runs(
project_id: str | None = Query(default=None),

View File

@@ -299,6 +299,54 @@ class MainAgentGovernanceTests(unittest.TestCase):
self.assertIn("run.progress", event_types)
self.assertIn("run.done", event_types)
def test_cancelled_run_can_be_retried_as_a_new_pending_run(self) -> None:
create = self.client.post(
"/v2/oneliner/runs",
headers=self.ctx["member_headers"],
json={
"project_id": self.ctx["project_id"],
"source_screen": "production",
"source_action_key": "handoff-to-main-agent",
"title": "恢复失败任务",
"summary": "让主 Agent 重新接单",
"intent_key": "custom",
"platform": "douyin",
"platform_scope": "single_platform",
"plan_request": {
"goal": "恢复失败任务",
"steps": ["检查失败原因", "重新安排执行", "确认落点"],
},
},
)
self.assertEqual(create.status_code, 200, create.text)
original_run = create.json()
run_id = original_run["id"]
cancel = self.client.post(
f"/v2/oneliner/runs/{run_id}/cancel",
headers=self.ctx["member_headers"],
json={"reason": "user cancelled"},
)
self.assertEqual(cancel.status_code, 200, cancel.text)
self.assertEqual(cancel.json()["run_status"], "cancelled")
retry = self.client.post(
f"/v2/oneliner/runs/{run_id}/retry",
headers=self.ctx["member_headers"],
json={"reason": "retry from runtime"},
)
self.assertEqual(retry.status_code, 200, retry.text)
payload = retry.json()
self.assertNotEqual(payload["id"], run_id)
self.assertEqual(payload["run_status"], "needs_confirmation")
self.assertEqual(payload["title"], original_run["title"])
self.assertEqual(payload["project_id"], self.ctx["project_id"])
self.assertEqual(payload["plan"]["goal"], "恢复失败任务")
self.assertEqual(payload["recommended_preview_action"]["screen"], "production")
event_types = [item["event_type"] for item in payload["events"]]
self.assertIn("run.created", event_types)
self.assertIn("run.retried", event_types)
def test_effective_policy_merges_system_user_global_and_platform_layers(self) -> None:
system_response = self.client.put(
"/v2/admin/oneliner/governance/system/main-agent",

View File

@@ -1029,6 +1029,7 @@ function renderOneLinerRunsHtml() {
: currentRun.run_status === "failed"
? "orange"
: "";
const canRetryCurrentRun = ["blocked", "failed", "cancelled"].includes(currentRun.run_status);
return `
<div class="task-item compact" style="margin-bottom:10px;">
<h4>近期运行概况</h4>
@@ -1113,6 +1114,7 @@ function renderOneLinerRunsHtml() {
` : `
<span class="tag ${statusTone}">${escapeHtml(currentRun.status_summary || "主 Agent 正在推进中")}</span>
`}
${canRetryCurrentRun ? `<span class="tag clickable-tag" data-action="retry-oneliner-run" data-run-id="${escapeHtml(currentRun.id)}">重新执行</span>` : ""}
${hasResultPayload ? `<span class="tag clickable-tag" data-action="open-oneliner-run-result" data-run-id="${escapeHtml(currentRun.id)}">查看结果</span>` : ""}
${recommendedAction?.action ? `<span class="tag clickable-tag" data-action="${escapeHtml(recommendedAction.action)}" ${resultLandingAttrs}>${escapeHtml(recommendedAction.label || "回到对应页面")}</span>` : ""}
</div>
@@ -1848,6 +1850,18 @@ async function cancelOneLinerRun(runId, reason = "") {
return payload;
}
async function retryOneLinerRun(runId, reason = "") {
const payload = await storyforgeFetch(`/v2/oneliner/runs/${encodeURIComponent(runId)}/retry`, {
method: "POST",
body: { reason }
});
await loadAgentControlSurfaces(getOneLinerProjectId());
appState.selectedOnelinerRunId = payload?.id || choosePreferredOneLinerRunId(appState.onelinerRuns, "");
appState.onelinerRunFilter = "focus";
rememberAction("主 Agent 已重新生成待确认卡", payload?.title || payload?.plan?.goal || "你可以先确认新的执行计划。", "green", payload);
return payload;
}
function renderOneLinerExecutionPayloadHtml(payload) {
if (!payload || typeof payload !== "object") {
return `<div class="task-item compact"><h4>没有返回执行结果</h4><p>当前执行器没有附带额外数据。</p></div>`;
@@ -10020,6 +10034,18 @@ document.addEventListener("click", async (event) => {
}
return;
}
if (name === "retry-oneliner-run") {
try {
setBusy(true, "正在重新生成待确认卡...");
await retryOneLinerRun(action.dataset.runId || "", "user requested retry");
openOneLinerPanel();
} catch (error) {
presentActionFailure(error, "主 Agent 重开失败");
} finally {
setBusy(false, "");
}
return;
}
if (name === "select-oneliner-run") {
appState.selectedOnelinerRunId = action.dataset.runId || "";
renderAll();

View File

@@ -329,6 +329,14 @@ test("opening a main agent run result keeps that run selected in the floating ru
assert.match(resultAction, /appState\.onelinerRunFilter = currentRun\.run_status === "done" \? "done" : appState\.onelinerRunFilter/);
});
test("oneliner runtime exposes retry for retryable runs and wires the action handler", () => {
const runtime = extractBetween(APP, "function renderOneLinerRunsHtml()", "function renderOneLinerMessagesHtml()");
const actions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {");
assert.match(runtime, /retry-oneliner-run/);
assert.match(actions, /name === "retry-oneliner-run"/);
assert.match(actions, /await retryOneLinerRun\(action\.dataset\.runId \|\| "", "user requested retry"\)/);
});
test("oneliner panel auto-polls active runs while the floating panel stays open", () => {
const render = extractBetween(APP, "function renderOneLinerUi()", "function openOneLinerPanel()");
const open = extractBetween(APP, "function openOneLinerPanel()", "function closeOneLinerPanel()");