feat: 计划模式 + 后续消息 + 会话分支接云端 (#65)
- 计划模式:tasks 加 plan 列;plan 与 approval=full / goal 冲突时
400;执行器在 approval 参数后追加 --sandbox read-only(本地
dispatch/run/goal/popen 与 Docker 内层命令全覆盖),host 任务
存下 sandbox 后续轮次自动继承只读。
- 后续消息:新表 task_followups;POST/GET /tasks/{id}/followups
(仅 queued/running 的非 goal 任务;direct 立即往链路追加
phase=followup-direct 条目,本轮跑完后优先于排队执行;排队
按 direct 优先、队内先来先服务逐条再跑一轮,同一 thread;
一条失败即停、剩余 pending 留给重试入口;取消时回 pending)。
- 会话分支:POST /tasks/{id}/fork(仅终态任务;继承 executor/
模型/强度/权限/计划,goal 字段置空);worker 先 fork 再跑首轮,
首轮失败不自动重试、不重复分叉,失败后走重试入口重跑首轮。
- 修任务级模型被覆盖:worker 此前无条件写成主模型,控制台
选的模型实际不生效;现为任务级 > worker 主模型 > 配置。
- Docker 租户准备抽成 _docker_task_context,供普通尝试与 fork 共用。
- console:新建任务计划勾选(含冲突拦截)、详情计划徽标与
分支来源、分支按钮与表单、后续消息区、列表 [计划]/[分支] 标记。
- 测试:cloud/tests/test_task_links.py 17 个(server 校验与继承、
worker 模型优先、只读命令、排空顺序与停排、goal 不排空、
fork 单次性);全量 cloud 542、host 49、deploy 14。
Co-authored-by: host <host@test.local>
This commit is contained in:
1 parent
6da3745127
commit
f4443dca4f
7 files changed
+1120
-74
No files matched your search
+71
-1
@@ -128,6 +128,7 @@
|
||||
<option value="cloud">cloud</option>
|
||||
</select>
|
||||
<label><input id="goalMode" type="checkbox"> goal 模式</label>
|
||||
<label><input id="planMode" type="checkbox"> 计划模式(只读)</label>
|
||||
</div>
|
||||
<textarea id="prompt" placeholder="任务 prompt(必填)"></textarea>
|
||||
<div class="bar">
|
||||
@@ -183,13 +184,28 @@
|
||||
<button id="btnCancel" class="danger">取消</button>
|
||||
<button id="btnMerge">合并(completed → merged)</button>
|
||||
<button id="btnPr">提 PR(须 merged)</button>
|
||||
<button id="btnFork">分支</button>
|
||||
<button id="btnCost">查成本</button>
|
||||
<span id="actionMsg" class="muted"></span>
|
||||
</div>
|
||||
<div id="forkForm" style="display:none">
|
||||
<input id="forkMsg" type="text" placeholder="分支首轮消息(必填,带历史的新会话)" style="width:100%; box-sizing:border-box; margin:6px 0;">
|
||||
<div class="bar"><button id="btnForkConfirm" class="primary">创建分支任务</button><span id="forkMsgSpan" class="muted"></span></div>
|
||||
</div>
|
||||
<pre id="detailBody" style="display:none"></pre>
|
||||
<h3>实时链路</h3>
|
||||
<div id="traceBox" class="muted">点任务行查看详情后,这里显示过程链路</div>
|
||||
<div id="costBox"></div>
|
||||
<h3>后续消息</h3>
|
||||
<div id="followupForm">
|
||||
<textarea id="followupText" placeholder="后续消息(任务运行中/排队中可用;排队不打断当前轮,直接发送跑完当前轮后优先执行)"></textarea>
|
||||
<div class="bar">
|
||||
<button id="btnQueueFollowup">排队</button>
|
||||
<button id="btnDirectFollowup" class="primary">直接发送</button>
|
||||
<span id="followupMsg" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="followupList" class="muted"></div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
@@ -300,7 +316,7 @@ async function loadTasks() {
|
||||
var pr = t.pr_url ? "<a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>#" + esc(t.pr_number) + "</a>" : "";
|
||||
return "<tr data-id='" + esc(t.id) + "' class='" + (currentTask && currentTask.id === t.id ? "selected" : "") + "'>" +
|
||||
"<td>" + esc(shortId(t.id)) + "</td>" +
|
||||
"<td>" + esc(String(t.prompt || "").slice(0, 60)) + "</td>" +
|
||||
"<td>" + (t.plan ? "[计划] " : "") + (t.forked_from ? "[分支] " : "") + esc(String(t.prompt || "").slice(0, 60)) + "</td>" +
|
||||
"<td class='st-" + esc(t.status) + "'>" + esc(t.status) + "</td>" +
|
||||
"<td>" + esc(t.executor || "") + "</td>" +
|
||||
"<td>" + esc(t.worktree_host || "") + "</td>" +
|
||||
@@ -424,6 +440,8 @@ async function showDetail(taskId) {
|
||||
"executor " + esc(t.executor || "") + " | 主机 " + esc(t.worktree_host || "") +
|
||||
" | 模型 " + esc(t.model || "默认") + " | 强度 " + esc(t.effort || "默认") +
|
||||
" | 权限 " + esc(t.approval || "默认") +
|
||||
(t.plan ? " | <b>计划模式(只读)</b>" : "") +
|
||||
(t.forked_from ? " | 分支自 " + esc(shortId(t.forked_from)) : "") +
|
||||
(t.pr_url ? " | <a href='" + esc(t.pr_url) + "' target='_blank' rel='noopener'>PR #" + esc(t.pr_number) + "</a>" : "") +
|
||||
"<br>prompt:<br>" + esc(t.prompt || "");
|
||||
var body = "";
|
||||
@@ -434,9 +452,15 @@ async function showDetail(taskId) {
|
||||
pre.textContent = body;
|
||||
pre.style.display = "block";
|
||||
$("#detailActions").style.display = "flex";
|
||||
$("#btnFork").style.display = TRACE_TERMINAL.indexOf(t.status) >= 0 ? "" : "none";
|
||||
$("#actionMsg").textContent = "";
|
||||
$("#costBox").innerHTML = "";
|
||||
$("#forkForm").style.display = "none";
|
||||
$("#forkMsgSpan").textContent = "";
|
||||
$("#followupMsg").textContent = "";
|
||||
$("#followupForm").style.display = ["queued", "running"].indexOf(t.status) >= 0 ? "block" : "none";
|
||||
startTrace(taskId, t.status);
|
||||
loadFollowups(taskId);
|
||||
} catch (e) { $("#detail").innerHTML = "<span class='err'>加载失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
|
||||
@@ -452,6 +476,29 @@ async function doAction(kind) {
|
||||
} catch (e) { $("#actionMsg").innerHTML = "<span class='err'>" + kind + " 失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
|
||||
async function loadFollowups(taskId) {
|
||||
var box = $("#followupList");
|
||||
try {
|
||||
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/followups", "GET");
|
||||
var list = d.followups || [];
|
||||
if (!list.length) { box.innerHTML = "(暂无后续消息)"; return; }
|
||||
box.innerHTML = list.map(function (f) {
|
||||
return "[" + esc(f.mode) + "|" + esc(f.status) + "] " + esc(String(f.body || "").slice(0, 120));
|
||||
}).join("<br>");
|
||||
} catch (e) { box.innerHTML = "<span class='err'>后续消息加载失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
async function postFollowup(mode) {
|
||||
if (!currentTask) return;
|
||||
var text = $("#followupText").value.trim();
|
||||
if (!text) { $("#followupMsg").textContent = "后续消息不能为空"; return; }
|
||||
try {
|
||||
await api("/tasks/" + encodeURIComponent(currentTask.id) + "/followups", "POST", { body: text, mode: mode });
|
||||
$("#followupMsg").innerHTML = "<span class='ok'>已" + (mode === "direct" ? "直接发送" : "排队") + "</span>";
|
||||
$("#followupText").value = "";
|
||||
loadFollowups(currentTask.id);
|
||||
} catch (e) { $("#followupMsg").innerHTML = "<span class='err'>发送失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
|
||||
$("#saveKey").onclick = function () {
|
||||
var v = $("#key").value.trim();
|
||||
if (!v) { $("#keyState").textContent = "key 为空,未保存"; return; }
|
||||
@@ -503,6 +550,11 @@ $("#createTask").onclick = async function () {
|
||||
if (ap === "full" && !confirm("完全访问 = 自动批准 + 不受工作区限制,agent 可读写工作区外路径。确认提交吗?")) return;
|
||||
body.approval = ap;
|
||||
}
|
||||
if ($("#planMode").checked) {
|
||||
if (ap === "full") { $("#taskMsg").textContent = "计划模式不能与完全访问同用"; return; }
|
||||
if ($("#goalMode").checked) { $("#taskMsg").textContent = "计划模式不能与 goal 模式同用"; return; }
|
||||
body.plan = 1;
|
||||
}
|
||||
if ($("#goalMode").checked) {
|
||||
body.goal = $("#goal").value.trim() || prompt;
|
||||
body.verify_command = $("#verifyCommand").value.trim();
|
||||
@@ -519,6 +571,24 @@ $("#btnRetry").onclick = function () { doAction("retry"); };
|
||||
$("#btnCancel").onclick = function () { doAction("cancel"); };
|
||||
$("#btnMerge").onclick = function () { doAction("merge"); };
|
||||
$("#btnPr").onclick = function () { doAction("pr"); };
|
||||
$("#btnFork").onclick = function () {
|
||||
if (!currentTask) return;
|
||||
if (TRACE_TERMINAL.indexOf(currentTask.status) < 0) { $("#forkMsgSpan").textContent = "仅终态任务可分支"; return; }
|
||||
$("#forkForm").style.display = "block";
|
||||
};
|
||||
$("#btnForkConfirm").onclick = async function () {
|
||||
if (!currentTask) return;
|
||||
var msg = $("#forkMsg").value.trim();
|
||||
if (!msg) { $("#forkMsgSpan").textContent = "分支消息必填"; return; }
|
||||
try {
|
||||
var t = await api("/tasks/" + encodeURIComponent(currentTask.id) + "/fork", "POST", { message: msg });
|
||||
$("#forkMsgSpan").innerHTML = "<span class='ok'>分支任务已建:" + esc(shortId(t.id)) + "</span>";
|
||||
$("#forkMsg").value = "";
|
||||
loadTasks();
|
||||
} catch (e) { $("#forkMsgSpan").innerHTML = "<span class='err'>分支失败:" + esc(e.message) + "</span>"; }
|
||||
};
|
||||
$("#btnQueueFollowup").onclick = function () { postFollowup("queued"); };
|
||||
$("#btnDirectFollowup").onclick = function () { postFollowup("direct"); };
|
||||
$("#btnCost").onclick = async function () {
|
||||
if (!currentTask) return;
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user