diff --git a/collector-service/app/oneliner_features.py b/collector-service/app/oneliner_features.py index 9e4968a..be4226a 100644 --- a/collector-service/app/oneliner_features.py +++ b/collector-service/app/oneliner_features.py @@ -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), diff --git a/tests/test_main_agent_governance.py b/tests/test_main_agent_governance.py index 4b17013..dbe4b95 100644 --- a/tests/test_main_agent_governance.py +++ b/tests/test_main_agent_governance.py @@ -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", diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 5fdc7b2..bc0b237 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -1029,6 +1029,7 @@ function renderOneLinerRunsHtml() { : currentRun.run_status === "failed" ? "orange" : ""; + const canRetryCurrentRun = ["blocked", "failed", "cancelled"].includes(currentRun.run_status); return `

近期运行概况

@@ -1113,6 +1114,7 @@ function renderOneLinerRunsHtml() { ` : ` ${escapeHtml(currentRun.status_summary || "主 Agent 正在推进中")} `} + ${canRetryCurrentRun ? `重新执行` : ""} ${hasResultPayload ? `查看结果` : ""} ${recommendedAction?.action ? `${escapeHtml(recommendedAction.label || "回到对应页面")}` : ""}
@@ -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 `

没有返回执行结果

当前执行器没有附带额外数据。

`; @@ -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(); diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 9e12604..6e8bea4 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -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()");