codebee 0.1.54 → 0.1.56

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/app/ui/app.js CHANGED
@@ -817,6 +817,11 @@ async function api(path, opts) {
817
817
  }
818
818
  }
819
819
  if (!res.ok) throw new Error((data && data.error) || ("HTTP " + res.status));
820
+ const method = String(fetchOpts.method || "GET").toUpperCase();
821
+ if (method !== "GET") {
822
+ if (String(path).startsWith("/api/catalog/")) S._catalogLoaded = false;
823
+ if (String(path).startsWith("/api/models/")) S._modelsLoaded = false;
824
+ }
820
825
  operationFinish(operationToken, "done");
821
826
  return data;
822
827
  } catch (e) {
@@ -931,13 +936,13 @@ function jsq(s) {
931
936
  }
932
937
 
933
938
  function statusChip(st) {
934
- const zh = { queued: t("正在启动"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消"), timeout: t("超时") };
939
+ const zh = { queued: t("排队中"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消"), timeout: t("超时") };
935
940
  return '<span class="chip ' + esc(st) + '">' + (zh[st] || esc(st)) + "</span>";
936
941
  }
937
942
 
938
943
  /* 运行状态文案(传 run 对象):退避窗口内的续跑副本写明「将于 HH:MM 自动
939
944
  * 续跑」,别让 5 分钟等待看起来像卡死/资源排队(2026-09-18 重写任务误判案)。
940
- * 到点后翻回「正在启动」——页面轮询重渲染时 Date.now() 已过预定时刻。 */
945
+ * 到点后翻回「排队中」——页面轮询重渲染时 Date.now() 已过预定时刻。 */
941
946
  function runStatusText(run) {
942
947
  const st = String((run && run.status) || "");
943
948
  if (st === "queued" && run && run.resume_enqueue_at) {
@@ -945,7 +950,7 @@ function runStatusText(run) {
945
950
  if (!isNaN(at) && Date.now() < at)
946
951
  return t("将于 {0} 自动续跑", String(run.resume_enqueue_at).slice(11, 16));
947
952
  }
948
- return { queued: t("正在启动"), running: t("运行中"), done: t("完成"),
953
+ return { queued: t("排队中"), running: t("运行中"), done: t("完成"),
949
954
  failed: t("失败"), cancelled: t("已取消"), timeout: t("超时") }[st] || st;
950
955
  }
951
956
 
@@ -1183,7 +1188,46 @@ async function healthDisableProvider(pid) {
1183
1188
  }
1184
1189
 
1185
1190
  async function refreshState() {
1186
- applyState(await api("/api/state"));
1191
+ applyState(await api("/api/state", { timeout: 15000, busy: false, operation: false }));
1192
+ }
1193
+
1194
+ let _lookupRefresh = null;
1195
+ async function refreshPollLookups(force) {
1196
+ if (_lookupRefresh) return _lookupRefresh;
1197
+ const now = Date.now();
1198
+ const loadCatalog = !!force || !S._catalogLoaded || now - (S._catalogAt || 0) >= 60000;
1199
+ const loadModels = !!force || !S._modelsLoaded || now - (S._modelsAt || 0) >= 30000;
1200
+ if (!loadCatalog && !loadModels) return;
1201
+ if (loadCatalog) S._catalogAt = now;
1202
+ if (loadModels) S._modelsAt = now;
1203
+ _lookupRefresh = (async () => {
1204
+ try {
1205
+ const [cat, models] = await Promise.all([
1206
+ loadCatalog ? api("/api/catalog", { timeout: 15000 }) : Promise.resolve(null),
1207
+ loadModels ? api("/api/models", { timeout: 15000 }) : Promise.resolve(null),
1208
+ ]);
1209
+ if (cat) {
1210
+ S.catalog = cat.catalog;
1211
+ S.catalogChecking = !!cat.checking;
1212
+ S._catalogLoaded = true;
1213
+ }
1214
+ if (models) {
1215
+ S.providers = models.providers; S.bindings = models.bindings;
1216
+ S.modelCatalog = models.catalog || [];
1217
+ S.sourceNames = models.source_names || S.sourceNames || {};
1218
+ S._modelsLoaded = true;
1219
+ renderDirectModelPicker();
1220
+ const provSig = JSON.stringify((S.providers || []).map((p) => [p.id, p.enabled, !!p.api_key]));
1221
+ if (provSig !== S.provSig) { S.provSig = provSig; loadOrchestrator(); }
1222
+ }
1223
+ render();
1224
+ } catch (e) {
1225
+ if (loadCatalog) S._catalogAt = 0;
1226
+ if (loadModels) S._modelsAt = 0;
1227
+ }
1228
+ })();
1229
+ try { await _lookupRefresh; }
1230
+ finally { _lookupRefresh = null; }
1187
1231
  }
1188
1232
 
1189
1233
  let _pollBusy = false;
@@ -1195,22 +1239,8 @@ async function poll() {
1195
1239
  if (_pollBusy) return;
1196
1240
  _pollBusy = true;
1197
1241
  try {
1198
- const [cat, models] = await Promise.all([
1199
- api("/api/catalog", { timeout: 15000 }),
1200
- api("/api/models", { timeout: 15000 }),
1201
- ]);
1202
- S.catalog = cat.catalog;
1203
- S.catalogChecking = !!cat.checking;
1204
- S.providers = models.providers; S.bindings = models.bindings;
1205
- S.modelCatalog = models.catalog || [];
1206
- S.sourceNames = models.source_names || S.sourceNames || {};
1207
- // /api/models 到手即重建对话模型选项(无条件):旧逻辑只在「更多选项」
1208
- // 旧块可见时重建——旧块隐藏后选项永远只有「自动推荐」,厂商写值全部
1209
- // 静默失效(2026-09-21 用户实测「没法选模型」根因)
1210
- renderDirectModelPicker();
1211
- // 供应商集合/启停/密钥变了才重拉编排者:否则「生效中」状态点会在停用厂商后失真
1212
- const provSig = JSON.stringify((S.providers || []).map((p) => [p.id, p.enabled, !!p.api_key]));
1213
- if (provSig !== S.provSig) { S.provSig = provSig; loadOrchestrator(); }
1242
+ // 状态先独立拉取并渲染;目录/模型是低频数据,后台刷新不能挡住任务树和点击。
1243
+ if (!document.hidden) refreshPollLookups(false);
1214
1244
  if (!S.sseLive) await refreshState();
1215
1245
  else if (S._lastSseAt && Date.now() - S._lastSseAt > 60000 && !document.hidden) {
1216
1246
  // SSE 静默看门狗:连接半死时 EventSource 不报错、sseLive 恒真、上面
@@ -1234,7 +1264,7 @@ async function poll() {
1234
1264
 
1235
1265
  function schedulePolling() {
1236
1266
  clearInterval(S.pollTimer);
1237
- S.pollTimer = setInterval(poll, S.sseLive ? 8000 : 2000);
1267
+ S.pollTimer = setInterval(() => { if (!document.hidden) poll(); }, S.sseLive ? 8000 : 2000);
1238
1268
  }
1239
1269
 
1240
1270
  /* SSE:状态变化即时推送;断开自动重连,重连失败降级为 2s 轮询 */
@@ -2817,12 +2847,16 @@ function showResumeHint() {
2817
2847
 
2818
2848
  async function archiveTask(id, archived) {
2819
2849
  try {
2850
+ let restored = null;
2851
+ if (!archived && archivedTaskIds().has(id)) {
2852
+ restored = await loadTaskForPrefill(id);
2853
+ }
2820
2854
  await api("/api/tasks/" + encodeURIComponent(id) + "/archive",
2821
2855
  { method: "POST", body: JSON.stringify({ archived: !!archived }) });
2822
2856
  // 取消归档时把任务工作目录从「已移除目录」里捞回来:用户点时钟开关找回
2823
2857
  // 归档任务再取消归档,直觉上文件夹就该回侧栏(否则只能靠在该目录新建任务)。
2824
2858
  if (!archived) {
2825
- const t0 = ((S.state || {}).tasks || []).concat(((S.state || {}).archived_tasks) || [])
2859
+ const t0 = restored || ((S.state || {}).tasks || []).concat(((S.state || {}).archived_tasks) || [])
2826
2860
  .find((x) => x.id === id);
2827
2861
  if (t0 && t0.workdir) unhideSideDir(t0.workdir);
2828
2862
  }
@@ -2835,9 +2869,11 @@ async function archiveTask(id, archived) {
2835
2869
  const i0 = from.findIndex((x) => x.id === id);
2836
2870
  if (i0 >= 0) {
2837
2871
  const t0 = from.splice(i0, 1)[0];
2838
- t0.archived = !!archived;
2872
+ const moved = restored || t0;
2873
+ moved.archived = !!archived;
2874
+ if (!archived && restored) _taskDetailCache.set(id, restored);
2839
2875
  const to = archived ? (st.archived_tasks = st.archived_tasks || []) : (st.tasks = st.tasks || []);
2840
- to.push(t0);
2876
+ to.push(moved);
2841
2877
  }
2842
2878
  S.sideSig = "";
2843
2879
  }
@@ -2875,6 +2911,9 @@ async function deleteTask(id) {
2875
2911
  if (goneRuns.size) st.runs = (st.runs || []).filter((r) => !goneRuns.has(r.id));
2876
2912
  S.sideSig = ""; // 任务条目少了签名理应变化;显式清掉,不赌签名算法
2877
2913
  }
2914
+ _taskDetailCache.delete(id);
2915
+ _taskRunsCache.delete(id);
2916
+ _taskRunsAt.delete(id);
2878
2917
  render();
2879
2918
  poll();
2880
2919
  if (gone) toast(t("任务已删除"));
@@ -2949,8 +2988,11 @@ function taskContextItems(det) {
2949
2988
  items.push({ label: t("复制工作目录路径"), fn: () => revealPath("tasks", taskId, false) });
2950
2989
  if (runId) items.push({ label: t("复制日志目录路径"), fn: () => revealPath("runs", runId, false) });
2951
2990
  const st = det.dataset.status || "";
2952
- if (st === "failed" || st === "cancelled") items.push({ label: t("↻ 继续任务"), fn: () => retryTask(taskId) });
2953
- items.push({ label: (st === "failed" || st === "cancelled") ? t("✎ 编辑重试") : t("基于此任务新建"),
2991
+ const latestTaskRun = ((S.state || {}).task_latest || {})[taskId] || {};
2992
+ const retryable = runNeedsRetry(latestTaskRun) ||
2993
+ st === "failed" || st === "cancelled" || st === "timeout";
2994
+ if (retryable) items.push({ label: t("↻ 继续任务"), fn: () => retryTask(taskId) });
2995
+ items.push({ label: retryable ? t("✎ 编辑重试") : t("基于此任务新建"),
2954
2996
  fn: () => newFromTask(taskId) });
2955
2997
  const sTask = ((S.state || {}).tasks || []).find((x) => x.id === taskId);
2956
2998
  if (sTask && sTask.serial && st !== "running" && st !== "queued")
@@ -3195,10 +3237,44 @@ window.continueSerial = continueSerial;
3195
3237
  /* 基于此任务新建(通用,不限连载):把旧任务的类型/目标/目录/评审设置预填进
3196
3238
  * 新建表单,确认或修改后提交——「接着写下一批章节」请用连载任务的「继续连载」,
3197
3239
  * 那里才带章节衔接;这里开的是一个全新任务。 */
3240
+ const _taskDetailCache = new Map();
3241
+ const _taskDetailInflight = new Map();
3242
+ const _taskRunsCache = new Map();
3243
+ const _taskRunsInflight = new Map();
3244
+ const _taskRunsAt = new Map();
3245
+ const _runMessagesCache = new Map();
3246
+ const _runMessagesInflight = new Map();
3247
+
3248
+ async function loadTaskForPrefill(id) {
3249
+ const active = ((S.state || {}).tasks || []).find((x) => x.id === id);
3250
+ if (active) return active;
3251
+ const cached = _taskDetailCache.get(id);
3252
+ if (cached) return cached;
3253
+ if (_taskDetailInflight.has(id)) return _taskDetailInflight.get(id);
3254
+ const request = api("/api/tasks/" + encodeURIComponent(id) + "/detail",
3255
+ { timeout: 15000, busy: true, operation: "读取任务详情" })
3256
+ .then((data) => {
3257
+ if (!data || !data.task) throw new Error(t("任务不存在或已删除"));
3258
+ _taskDetailCache.set(id, data.task);
3259
+ return data.task;
3260
+ })
3261
+ .finally(() => _taskDetailInflight.delete(id));
3262
+ _taskDetailInflight.set(id, request);
3263
+ return request;
3264
+ }
3265
+
3198
3266
  function newFromTask(id) {
3267
+ newFromTaskAsync(id);
3268
+ }
3269
+
3270
+ async function newFromTaskAsync(id) {
3199
3271
  // 局部变量不能叫 t:会遮蔽 i18n 函数 t(),下面的文案调用会直接 TypeError
3200
- const tk = ((S.state || {}).tasks || []).find((x) => x.id === id);
3201
- if (!tk) { toast(t("任务不存在或已删除"), true); return; }
3272
+ let tk;
3273
+ try {
3274
+ const inState = ((S.state || {}).tasks || []).some((x) => x.id === id);
3275
+ if (!inState && !_taskDetailCache.has(id)) toast(t("正在读取任务详情…"));
3276
+ tk = await loadTaskForPrefill(id);
3277
+ } catch (e) { toast(t("读取任务详情失败:") + e.message, true); return; }
3202
3278
  exitSettings(); // 回到新建任务表单
3203
3279
  const flow = flowById(tk.type);
3204
3280
  if (flow && $("f-type").value !== tk.type) {
@@ -3446,7 +3522,7 @@ function renderRunList() {
3446
3522
  const can = runDeletable(r);
3447
3523
  // 行结构照参考站的「主副标题 + 右侧状态与相对时间」:标题与摘要竖排成一块,
3448
3524
  // 状态点用侧栏同款字形(staskGlyph),时间用相对档(完整时间戳挪进悬停提示)
3449
- const sub = String(r.summary || r.error || (r.steps ? r.steps.length + t(" 个步骤") : "")).trim();
3525
+ const sub = String(r.summary || r.error || (r.step_count ? r.step_count + t(" 个步骤") : "")).trim();
3450
3526
  return '<div class="item" onclick="openRun(\'' + esc(r.id) + '\')">' +
3451
3527
  '<div class="t">' +
3452
3528
  '<input type="checkbox" class="rcheck"' + (S.selRuns[r.id] ? " checked" : "") + (can ? "" : " disabled") +
@@ -3802,6 +3878,7 @@ window.sideOpenRun = function (id, n) {
3802
3878
  /* 打开任务级详情:任务可能被续跑/重试过多次,步骤分散在多条 run 里。
3803
3879
  * 按时间顺序列出该任务全部 run 的全部步骤,run 之间加分隔条。 */
3804
3880
  window.sideOpenTask = function (key) {
3881
+ S.lastRun = null;
3805
3882
  S.detailTaskKey = key;
3806
3883
  S.detailRunId = null;
3807
3884
  S.focusStep = 0;
@@ -3812,6 +3889,9 @@ window.sideOpenTask = function (key) {
3812
3889
  $("run-detail").classList.remove("hidden");
3813
3890
  document.querySelector("#sub-runs .panel:first-child").classList.add("hidden");
3814
3891
  detailSideReset(); // 换详情目标:任务级 side 缓存作废,等首拉
3892
+ S.rdTab = "steps";
3893
+ S.rdTabSig = "";
3894
+ applyRdTabs();
3815
3895
  syncInspectorVis(); // 详情已铺开:检查器让位(选中保留,返回列表自动滑回)
3816
3896
  renderTaskDetail();
3817
3897
  };
@@ -3821,7 +3901,7 @@ window.openRunInRuns = function (id) { jumpToRun(id); };
3821
3901
  * 「编辑重试」——同一预填行为(newFromTask),标签贴合「改完再跑」的意图;
3822
3902
  * 其余状态维持原标签。两按钮互斥出现,按钮区不因新增功能多占一行。 */
3823
3903
  function setEditRetry(taskId, status, taskExists) {
3824
- const failedish = !!taskId && (status === "failed" || status === "cancelled");
3904
+ const failedish = !!taskId && (status === "failed" || status === "cancelled" || status === "timeout");
3825
3905
  const be = $("btn-editretry");
3826
3906
  if (be) be.classList.toggle("hidden", !failedish);
3827
3907
  const bn = $("btn-newfrom");
@@ -3846,21 +3926,46 @@ function syncArchBtn(task, active) {
3846
3926
  };
3847
3927
  }
3848
3928
 
3849
- /* 重试按钮三态:失败/取消 → 「↻ 继续任务」(断点续跑语义);连载任务完整跑完
3929
+ /* 质量未通过的代码任务也必须能重试:代码流水线会把「实现完成但验收未通过」
3930
+ * 收在 done + verdict.pass=false,不能因此把唯一的重试入口隐藏。 */
3931
+ function runNeedsRetry(run) {
3932
+ if (!run || !run.task_id) return false;
3933
+ if (["failed", "cancelled", "timeout"].includes(run.status)) return true;
3934
+ if (run.status !== "done") return false;
3935
+ const v = run.verdict || {};
3936
+ return v.pass === false || v.publishable === false ||
3937
+ v.verify_pass === false || v.review_pass === false;
3938
+ }
3939
+
3940
+ function retrySnapshotForTask(taskId, task, latestSummary) {
3941
+ const summary = latestSummary || {};
3942
+ if (task && (task.status === "running" || task.status === "queued"))
3943
+ return { task_id: taskId, status: task.status };
3944
+ if (runNeedsRetry(summary)) return summary;
3945
+ if (task && ["failed", "cancelled", "timeout"].includes(task.status))
3946
+ return { task_id: taskId, status: task.status };
3947
+ return summary;
3948
+ }
3949
+
3950
+ /* 重试按钮三态:失败/取消/质量未通过 → 「↻ 继续任务」(断点续跑语义);连载任务完整跑完
3850
3951
  * 但质量未达标(verdict.publishable=false)→ 「↻ 重写未达标章」——后端 retry
3851
3952
  * 会把未过线章从继承清单剔除,只重写重评这几章(store.retry_task 未达标分支);
3852
3953
  * 其余状态隐藏。 */
3853
3954
  function setRetryBtn(run, task) {
3854
3955
  const b = $("btn-retry");
3855
3956
  if (!b) return;
3856
- const failedish = !!run.task_id && (run.status === "failed" || run.status === "cancelled");
3957
+ const retryable = runNeedsRetry(run);
3857
3958
  const rewrite = !!(run.task_id && task && task.serial && run.status === "done"
3858
3959
  && run.verdict && run.verdict.publishable === false);
3859
- b.classList.toggle("hidden", !(failedish || rewrite));
3860
- b.textContent = rewrite ? t("↻ 重写未达标章") : t("↻ 继续任务");
3960
+ b.classList.toggle("hidden", !(retryable || rewrite));
3961
+ b.textContent = rewrite ? t("↻ 重写未达标章")
3962
+ : (run.status === "done" ? t("↻ 重试任务")
3963
+ : run.status === "timeout" ? t("↻ 从超时进度继续") : t("↻ 继续任务"));
3861
3964
  b.title = rewrite
3862
3965
  ? t("只重写上一遍未过线的章节,已过线章节沿用成稿与评分")
3863
- : t("再跑一次这个任务;连载任务会断点续跑,已完成章节不重写");
3966
+ : run.status === "timeout"
3967
+ ? t("从最近一次已保存的大纲和已完成章节断点续跑")
3968
+ : t("再跑一次这个任务;连载任务会断点续跑,已完成章节不重写");
3864
3969
  }
3865
3970
 
3866
3971
  /* 任务级详情:聚合该任务所有 run 的步骤。
@@ -3876,18 +3981,81 @@ function renderTaskDetail() {
3876
3981
  const bc = $("btn-continue");
3877
3982
  if (bc) bc.classList.toggle("hidden",
3878
3983
  !(tk && tk.serial && tk.status !== "running" && tk.status !== "queued"));
3984
+ const latestSummary = ((S.state || {}).task_latest || {})[key] || {};
3985
+ setRetryBtn(retrySnapshotForTask(key, tk, latestSummary), tk);
3879
3986
  setEditRetry(tk ? tk.id : "", tk ? tk.status : "", !!tk);
3880
3987
  syncArchBtn(tk, tk && (tk.status === "running" || tk.status === "queued"));
3881
3988
  const isTask = ((S.state || {}).tasks || []).some((t2) => t2.id === key);
3882
3989
  if (isTask) {
3883
- api("/api/tasks/" + encodeURIComponent(key) + "/runs")
3884
- .then((d) => { if (S.detailTaskKey === key) drawTaskDetail(key, d.runs || []); })
3885
- .catch((e) => { /* 拉取失败静默,等下一轮轮询重试;401 已由 api() 弹令牌门 */ });
3990
+ loadTaskRuns(key, latestSummary);
3886
3991
  return;
3887
3992
  }
3888
3993
  drawTaskDetail(key, ((S.state || {}).runs || []).filter((r) => (r.task_id || r.id) === key));
3889
3994
  }
3890
3995
 
3996
+ function loadTaskRuns(key, latestSummary) {
3997
+ const cached = _taskRunsCache.get(key);
3998
+ if (cached && cached.length && latestSummary && cached[0].id === latestSummary.id) {
3999
+ const detailVerdict = cached[0].verdict;
4000
+ Object.assign(cached[0], latestSummary);
4001
+ if (detailVerdict) cached[0].verdict = Object.assign({}, latestSummary.verdict || {}, detailVerdict);
4002
+ drawTaskDetail(key, cached);
4003
+ }
4004
+ const latest = cached && cached[0];
4005
+ const hasNewRun = !!(latestSummary && latestSummary.id && (!latest || latestSummary.id !== latest.id));
4006
+ const active = !!(latestSummary && ["running", "queued"].includes(latestSummary.status));
4007
+ const minAge = active ? 2000 : 12000;
4008
+ if (_taskRunsInflight.has(key) ||
4009
+ (!hasNewRun && cached && Date.now() - (_taskRunsAt.get(key) || 0) < minAge)) return;
4010
+ if (!cached) {
4011
+ const task = ((S.state || {}).tasks || []).find((x) => x.id === key);
4012
+ $("rd-title").textContent = (task && task.title) || key;
4013
+ const chip = $("rd-status");
4014
+ chip.className = "chip queued";
4015
+ chip.textContent = t("加载中…");
4016
+ $("rd-steps").innerHTML = '<div class="hint" role="status">' + esc(t("正在加载任务详情…")) + "</div>";
4017
+ }
4018
+ _taskRunsAt.set(key, Date.now());
4019
+ const refreshLatestOnly = !!(cached && cached.length && latestSummary && cached[0].id === latestSummary.id);
4020
+ const url = refreshLatestOnly
4021
+ ? "/api/runs/" + encodeURIComponent(latestSummary.id)
4022
+ : "/api/tasks/" + encodeURIComponent(key) + "/runs";
4023
+ const request = api(url, { timeout: 20000 })
4024
+ .then((data) => {
4025
+ let runs = (data && data.runs) || [];
4026
+ if (refreshLatestOnly && data && data.run) {
4027
+ runs = cached.slice();
4028
+ runs[0] = data.run;
4029
+ }
4030
+ _taskRunsCache.delete(key);
4031
+ _taskRunsCache.set(key, runs);
4032
+ while (_taskRunsCache.size > 1) {
4033
+ const oldest = _taskRunsCache.keys().next().value;
4034
+ _taskRunsCache.delete(oldest);
4035
+ _taskRunsAt.delete(oldest);
4036
+ }
4037
+ if (S.detailTaskKey === key) {
4038
+ if (runs.length) drawTaskDetail(key, runs);
4039
+ else {
4040
+ $("rd-status").textContent = t("暂无运行步骤");
4041
+ $("rd-steps").innerHTML = '<div class="empty">' + esc(t("暂无运行步骤")) + "</div>";
4042
+ }
4043
+ }
4044
+ })
4045
+ .catch((e) => {
4046
+ _taskRunsAt.set(key, 0);
4047
+ if (S.detailTaskKey === key && !_taskRunsCache.has(key)) {
4048
+ const chip = $("rd-status");
4049
+ chip.className = "chip failed";
4050
+ chip.textContent = t("加载失败");
4051
+ $("rd-steps").innerHTML = '<div class="hint">' + esc(t("加载失败:")) + esc(e.message) +
4052
+ ' <button type="button" class="ghost small" onclick="renderTaskDetail()">' + esc(t("重试")) + "</button></div>";
4053
+ }
4054
+ })
4055
+ .finally(() => _taskRunsInflight.delete(key));
4056
+ _taskRunsInflight.set(key, request);
4057
+ }
4058
+
3891
4059
  function drawTaskDetail(key, runs) {
3892
4060
  if (!runs.length) return;
3893
4061
  // 消息数/未消费数也入签名:新指令或被 drain 后重画指挥区;步骤状态入签名:
@@ -3900,7 +4068,9 @@ function drawTaskDetail(key, runs) {
3900
4068
  Date.now() < Date.parse(String(lr0.resume_enqueue_at).replace(" ", "T"))) ? 1 : 0;
3901
4069
  const sig = JSON.stringify(runs.map((r) => [r.id, r.status, (r.steps || []).length,
3902
4070
  (r.steps || []).map((s) => s.status).join(""),
3903
- (r.messages || []).length, (r.messages || []).filter((m) => !m.consumed).length])
4071
+ (r.messages || []).length, (r.messages || []).filter((m) => !m.consumed).length,
4072
+ r.message_count, r.pending_message_count,
4073
+ r.summary, r.error, r.cost_usd, r.tokens, JSON.stringify(r.verdict || {})])
3904
4074
  .concat([JSON.stringify((bmTask || {}).book_meta || null), resumePending]));
3905
4075
  if (sig === S.taskSig) return;
3906
4076
  S.taskSig = sig;
@@ -3919,7 +4089,7 @@ function drawTaskDetail(key, runs) {
3919
4089
  chip.className = "chip " + st;
3920
4090
  chip.textContent = resumeIn
3921
4091
  ? t("将于 ") + resumeIn + t(" 自动续跑(第 ") + (Number(latest.auto_resumes) || 0) + t(" 次)")
3922
- : ({ queued: t("正在启动"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消") }[st] || st);
4092
+ : ({ queued: t("排队中"), running: t("运行中"), done: t("完成"), failed: t("失败"), cancelled: t("已取消") }[st] || st);
3923
4093
  const bpt = $("btn-pause");
3924
4094
  if (bpt) bpt.classList.add("hidden");
3925
4095
  $("btn-delete").classList.add("hidden");
@@ -6377,18 +6547,18 @@ function logLiveBadge(live) {
6377
6547
  }
6378
6548
  }
6379
6549
 
6380
- /* 复制抽屉日志全文:报错 JSON 动辄几十行还带横向滚动,手动圈选很折磨 */
6381
- window.rdLogCopy = function () {
6382
- const pre = $("rd-log-text");
6383
- const text = ((pre && pre.textContent) || "").trim();
6384
- const idle = [t("(等待输出…)"), t("(无输出)")];
6385
- if (!text || idle.includes(text)) return;
6386
- const done = () => toast(t("已复制"));
6387
- if (navigator.clipboard && navigator.clipboard.writeText) {
6388
- navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, done));
6389
- } else fallbackCopy(text, done);
6390
- };
6391
-
6550
+ /* 复制抽屉日志全文:报错 JSON 动辄几十行还带横向滚动,手动圈选很折磨 */
6551
+ window.rdLogCopy = function () {
6552
+ const pre = $("rd-log-text");
6553
+ const text = ((pre && pre.textContent) || "").trim();
6554
+ const idle = [t("(等待输出…)"), t("(无输出)")];
6555
+ if (!text || idle.includes(text)) return;
6556
+ const done = () => toast(t("已复制"));
6557
+ if (navigator.clipboard && navigator.clipboard.writeText) {
6558
+ navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, done));
6559
+ } else fallbackCopy(text, done);
6560
+ };
6561
+
6392
6562
  /* 抽屉标题:按 (runId, 日志路径) 反查步骤角色与执行者——不知道在看谁的日志,
6393
6563
  * 抽屉就成了无名黑框 */
6394
6564
  function rdLogStepLabel(runId, rel) {
@@ -6803,7 +6973,40 @@ function renderDirector(run, active) {
6803
6973
  if (!run || !active) { box.classList.add("hidden"); return; }
6804
6974
  if (dirRunId !== run.id) { dirRunId = run.id; dirAtts = []; drawDirAtts(); }
6805
6975
  box.classList.remove("hidden");
6806
- const msgs = run.messages || [];
6976
+ const cached = _runMessagesCache.get(run.id);
6977
+ const expectedCount = run.message_count == null ? null : Number(run.message_count);
6978
+ const expectedPending = run.pending_message_count == null ? null : Number(run.pending_message_count);
6979
+ const runMessages = Array.isArray(run.messages) ? run.messages : null;
6980
+ const runPending = runMessages ? runMessages.filter((m) => !m.consumed).length : null;
6981
+ const runMessagesCurrent = !!runMessages && (expectedCount == null ||
6982
+ (runMessages.length === expectedCount && runPending === expectedPending));
6983
+ const msgs = runMessagesCurrent ? runMessages : (cached ? cached.messages : []);
6984
+ if (runMessagesCurrent) {
6985
+ const pending = msgs.filter((m) => !m.consumed).length;
6986
+ _runMessagesCache.set(run.id, { messages: msgs, count: msgs.length, pending });
6987
+ }
6988
+ if (expectedCount != null) {
6989
+ const messageCache = _runMessagesCache.get(run.id);
6990
+ if (!messageCache || messageCache.count !== expectedCount || messageCache.pending !== expectedPending) {
6991
+ if (!_runMessagesInflight.has(run.id)) {
6992
+ const request = api("/api/runs/" + encodeURIComponent(run.id) + "/messages", { timeout: 10000 })
6993
+ .then((data) => {
6994
+ if (dirRunId !== run.id || !S.lastRun || S.lastRun.id !== run.id) return;
6995
+ const messages = (data && data.messages) || [];
6996
+ _runMessagesCache.clear();
6997
+ _runMessagesCache.set(run.id, {
6998
+ messages, count: messages.length,
6999
+ pending: messages.filter((m) => !m.consumed).length,
7000
+ });
7001
+ renderDirector(Object.assign({}, S.lastRun, { messages }),
7002
+ S.lastRun.status === "running" || S.lastRun.status === "queued");
7003
+ })
7004
+ .catch(() => {})
7005
+ .finally(() => _runMessagesInflight.delete(run.id));
7006
+ _runMessagesInflight.set(run.id, request);
7007
+ }
7008
+ }
7009
+ }
6807
7010
  const mb = $("rd-msgs");
6808
7011
  mb.innerHTML = msgs.map((m) =>
6809
7012
  '<div class="rd-msg' + (m.consumed ? " m-consumed" : "") + '">' +
@@ -9240,11 +9443,15 @@ function autoTplPace(tp) {
9240
9443
  });
9241
9444
  }
9242
9445
 
9243
- /* last_status → 徽章:started=正常灰、error=红「拉起失败」、missed=黄「已错过」 */
9446
+ /* last_status → 徽章:started=正常灰、error=红「拉起失败」、missed=黄「已错过」;
9447
+ * done/failed/cancelled=运行终态(2026-09-23 回写接线后会出现,失败必须可见) */
9244
9448
  function autoStatusTag(tsk) {
9245
9449
  if (tsk.last_status === "error") return '<span class="tag auto-tag-err">' + t("拉起失败") + "</span>";
9246
9450
  if (tsk.last_status === "missed") return '<span class="tag auto-tag-miss">' + t("已错过") + "</span>";
9247
9451
  if (tsk.last_status === "started" || tsk.last_status === "queued") return '<span class="tag">' + t("正常") + "</span>";
9452
+ if (tsk.last_status === "failed") return '<span class="tag auto-tag-err">' + t("上次失败") + "</span>";
9453
+ if (tsk.last_status === "cancelled") return '<span class="tag auto-tag-miss">' + t("已取消") + "</span>";
9454
+ if (tsk.last_status === "done") return '<span class="tag">' + t("已完成") + "</span>";
9248
9455
  return "";
9249
9456
  }
9250
9457
 
@@ -9954,6 +10161,14 @@ function renderZentaoStatus() {
9954
10161
  (v.last_error ? '<span class="zt-err">' + t("最近错误:") + esc(v.last_error) + "</span>" : "");
9955
10162
  }
9956
10163
 
10164
+ /* bug 详情页地址:后端回传探测出的 web 根(含 /zentao 子路径),
10165
+ GET 形态 index.php?m=bug&f=view&bugID=N 对老版/新版、PATH_INFO 开关都通用。 */
10166
+ function zentaoBugUrl(c) {
10167
+ const base = String((S.zentao || {}).bug_base || "").replace(/\/+$/, "");
10168
+ if (!base || !c.bug_id) return "";
10169
+ return base + "/index.php?m=bug&f=view&bugID=" + encodeURIComponent(c.bug_id);
10170
+ }
10171
+
9957
10172
  function renderZentaoClaims() {
9958
10173
  const box = $("zentao-claims");
9959
10174
  if (!box) return;
@@ -9962,7 +10177,13 @@ function renderZentaoClaims() {
9962
10177
  const tasks = (c.tasks || []).map((tk) =>
9963
10178
  "<span>" + t("【") + (tk.side === "frontend" ? t("前端") : t("后端")) + t("】") +
9964
10179
  '<a href="#" onclick="zentaoOpenRun(\'' + esc(tk.run_id || "") + '\');return false;">' + esc(tk.task_id || "?") + "</a></span>").join("");
9965
- return '<div class="card"><div class="head"><span class="name">#' + esc(c.bug_id) + " " + esc(c.title || "") + "</span>" +
10180
+ const bugUrl = zentaoBugUrl(c);
10181
+ const name = "#" + esc(c.bug_id) + " " + esc(c.title || "");
10182
+ const headName = bugUrl
10183
+ ? '<a class="zt-bug-link" href="' + esc(bugUrl) + '" target="_blank" rel="noopener" title="' +
10184
+ esc(t("在禅道中打开 Bug 详情")) + '">' + name + "</a>"
10185
+ : name;
10186
+ return '<div class="card"><div class="head"><span class="name">' + headName + "</span>" +
9966
10187
  zentaoStateTag(c.state) + "</div>" +
9967
10188
  '<div class="auto-meta">' +
9968
10189
  (zentaoTriText(c) ? "<span>" + esc(zentaoTriText(c)) + "</span>" : "") +
@@ -11327,6 +11548,12 @@ function pickLang(lang) {
11327
11548
  window.toggleLangMenu = toggleLangMenu;
11328
11549
  window.pickLang = pickLang;
11329
11550
 
11551
+ /* 顶栏大屏入口:新标签页打开任务驾驶舱;同源共享 orch.token,board 页免二次输令牌 */
11552
+ function openBoard() {
11553
+ window.open("/board.html", "_blank", "noopener");
11554
+ }
11555
+ window.openBoard = openBoard;
11556
+
11330
11557
  function setLangBtn(lang) {
11331
11558
  if (getLang() === lang) return;
11332
11559
  setLang(lang);
@@ -12627,7 +12854,13 @@ document.addEventListener("DOMContentLoaded", () => {
12627
12854
  new MutationObserver(sync).observe(sub, { attributes: true, attributeFilter: ["class"] });
12628
12855
  sync();
12629
12856
  })();
12630
- $("btn-retry").addEventListener("click", () => { const r = S.lastRun; if (r && r.task_id) retryTask(r.task_id); });
12857
+ $("btn-retry").addEventListener("click", () => {
12858
+ const r = S.lastRun;
12859
+ const taskId = (r && r.task_id) ||
12860
+ (((S.state || {}).tasks || []).some((task) => task.id === S.detailTaskKey)
12861
+ ? S.detailTaskKey : "");
12862
+ if (taskId) retryTask(taskId);
12863
+ });
12631
12864
  /* 日志控制台高度:顶边手柄拖拽调整(localStorage 记忆),双击复位为默认弹性高度 */
12632
12865
  (() => {
12633
12866
  const grip = $("rd-log-grip"), box = $("rd-log");