@ljwei-stak/dsh-model-route-bot 0.65.0 → 0.66.1
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/.dsh-plugin/client.js +3 -3
- package/README.i18n.yaml +2 -2
- package/README.md +6 -6
- package/README.zh.md +6 -6
- package/apps/integration-client/tasks-panel.jsx +2 -2
- package/package.json +3 -1
- package/packages/capability-broker/src/authority.mjs +15 -1
- package/packages/model-router-plugin/README.md +3 -3
- package/packages/model-router-plugin/package.json +1 -1
- package/packages/orchestration/src/product-composition.mjs +48 -8
- package/packages/orchestration/src/sqlite-router-state.mjs +33 -8
- package/packages/orchestration/src/stop-proof.mjs +37 -0
- package/packages/orchestration/src/task-contract.mjs +42 -2
- package/packages/orchestration/src/task-journal.mjs +122 -39
- package/packages/orchestration/src/task-service.mjs +110 -18
- package/packages/orchestration/src/task-stage.mjs +36 -0
- package/packages/orchestration/src/write-retry.mjs +56 -0
- package/remote-computer-agent/dist/cli.mjs +11 -1
- package/remote-computer-agent/dist/cli.mjs.map +2 -2
- package/remote-computer-agent/dist/index.mjs +11 -1
- package/remote-computer-agent/dist/index.mjs.map +2 -2
package/.dsh-plugin/client.js
CHANGED
|
@@ -30933,7 +30933,7 @@ var xterm_default = `/**
|
|
|
30933
30933
|
`;
|
|
30934
30934
|
|
|
30935
30935
|
// .dsh-plugin/client/host-version.mjs
|
|
30936
|
-
var ROUTER_CLIENT_VERSION = "0.
|
|
30936
|
+
var ROUTER_CLIENT_VERSION = "0.66.1", STALE_HOST_MESSAGE = "插件后台版本较旧,请完全退出并重启 Harness(包括托盘图标)后再使用。";
|
|
30937
30937
|
function isMissingRemoteMethod(message2) {
|
|
30938
30938
|
let value = String(message2 ?? "");
|
|
30939
30939
|
return /transport failure for [^:]+: HTTP 404\b/.test(value) || /Remote method \S+ is no longer mounted/.test(value);
|
|
@@ -33504,7 +33504,7 @@ function TaskDetail({ task, inspect, busy, actions, dispatch, onClose, bot, open
|
|
|
33504
33504
|
}), () => controller.abort());
|
|
33505
33505
|
}, [key, task.updatedAt, inspect, actions.includes("task.inspect"), reload]);
|
|
33506
33506
|
let data = history?.task ?? task;
|
|
33507
|
-
return /* @__PURE__ */ import_react63.default.createElement("article", { className: "bp-card bp-task-detail", "aria-label": "任务详情", ref, tabIndex: -1 }, /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-card-title" }, /* @__PURE__ */ import_react63.default.createElement("div", null, /* @__PURE__ */ import_react63.default.createElement("h2", null, "任务详情"), /* @__PURE__ */ import_react63.default.createElement("p", null, taskTitle(task))), /* @__PURE__ */ import_react63.default.createElement("button", { type: "button", onClick: onClose }, "关闭")), /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-actions" }, /* @__PURE__ */ import_react63.default.createElement(Tag2, null, TASK_STATES[data.state] ?? data.state), /* @__PURE__ */ import_react63.default.createElement("span", null, bot?.name ?? task.botId ?? "未取得归属"), bot?.sessionId && openSession && /* @__PURE__ */ import_react63.default.createElement("button", { type: "button", onClick: () => openSession(bot.sessionId) }, "打开 Bot 私聊"), /* @__PURE__ */ import_react63.default.createElement(TaskActions, { row: data, actions, dispatch, busy })), /* @__PURE__ */ import_react63.default.createElement(RemoteNotice, { row: data }), /* @__PURE__ */ import_react63.default.createElement("dl", { className: "bp-facts" }, /* @__PURE__ */ import_react63.default.createElement(Value, { label: "创建" }, date(data.createdAt)), /* @__PURE__ */ import_react63.default.createElement(Value, { label: "最近更新" }, date(data.updatedAt)), /* @__PURE__ */ import_react63.default.createElement(Value, { label: "期限" }, date(data.deadlineAt))), /* @__PURE__ */ import_react63.default.createElement("h3", null, "任务时间线"), loading && /* @__PURE__ */ import_react63.default.createElement("p", { role: "status", className: "bp-muted" }, "正在读取原任务日志…"), failure && /* @__PURE__ */ import_react63.default.createElement("p", { role: "alert", className: "bp-error" }, failure), inspect && actions.includes("task.inspect") && /* @__PURE__ */ import_react63.default.createElement("button", { type: "button", disabled: loading, onClick: () => setReload((value) => value + 1) }, "重新读取时间线"), !actions.includes("task.inspect") && /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-muted" }, "此宿主尚未提供任务时间线,可在下方查看现有收据依据。"), history && /* @__PURE__ */ import_react63.default.createElement(import_react63.default.Fragment, null, /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-muted" }, history.attempts.length, " 次执行尝试 · ", history.timeline.total, " 条日志", history.timeline.truncated ? `,显示最近 ${history.timeline.items.length} 条` : "", "。停止请求需等待原操作确认。"), /* @__PURE__ */ import_react63.default.createElement("ol", { className: "bp-task-timeline" }, history.timeline.items.map((event) => /* @__PURE__ */ import_react63.default.createElement("li", { key: event.seq }, /* @__PURE__ */ import_react63.default.createElement("span", { className: "bp-timeline-dot", "aria-hidden": "true" }), /* @__PURE__ */ import_react63.default.createElement("div", null, /* @__PURE__ */ import_react63.default.createElement("strong", null, TASK_EVENTS[event.kind] ?? "任务状态更新"), /* @__PURE__ */ import_react63.default.createElement("small", null, /* @__PURE__ */ import_react63.default.createElement("time", null, date(event.at)), event.attemptNumber != null ? ` · 第 ${event.attemptNumber} 次尝试` : "", event.outcome ? ` · ${TASK_STATES[event.outcome] ?? (event.outcome === "unknown" ? "结果未知" : event.outcome)}` : ""), event.code && /* @__PURE__ */ import_react63.default.createElement("small", { className: "bp-code" }, event.code))))), history.timeline.items.length === 0 && /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-muted" }, "原日志中暂无事件。"), /* @__PURE__ */ import_react63.default.createElement("details", { className: "bp-technical" }, /* @__PURE__ */ import_react63.default.createElement("summary", null, "每次尝试的身份与时间"), /* @__PURE__ */ import_react63.default.createElement("ul", { className: "bp-checklist" }, history.attempts.map((attempt) => /* @__PURE__ */ import_react63.default.createElement("li", { key: attempt.attemptId }, /* @__PURE__ */ import_react63.default.createElement("strong", null, "第 ", attempt.number, " 次 · ", TASK_STATES[attempt.state] ?? attempt.state), /* @__PURE__ */ import_react63.default.createElement("small", null, "开始 ", date(attempt.startedAt), " · 结束 ", date(attempt.finishedAt)), /* @__PURE__ */ import_react63.default.createElement("small", { className: "bp-code" }, attempt.attemptId, " · ", attempt.operationId)))))), /* @__PURE__ */ import_react63.default.createElement("details", { className: "bp-technical" }, /* @__PURE__ */ import_react63.default.createElement("summary", null, "原任务与操作编号"), /* @__PURE__ */ import_react63.default.createElement("dl", { className: "bp-facts" }, /* @__PURE__ */ import_react63.default.createElement(Value, { label: "Task" }, taskIdOf(data)), /* @__PURE__ */ import_react63.default.createElement(Value, { label: "Package" }, data.packageId), /* @__PURE__ */ import_react63.default.createElement(Value, { label: "Attempt" }, data.attemptId), /* @__PURE__ */ import_react63.default.createElement(Value, { label: "Operation" }, data.operationId), /* @__PURE__ */ import_react63.default.createElement(Value, { label: "MR 运行" }, data.legacyRunIds?.join("、")))), /* @__PURE__ */ import_react63.default.createElement("details", { className: "bp-technical" }, /* @__PURE__ */ import_react63.default.createElement("summary", null, "执行与收据依据"), /* @__PURE__ */ import_react63.default.createElement("pre", null, JSON.stringify({ attempts: history?.attempts ?? task.attempts, executionRef: task.executionRef, remoteReceipt: data.result?.remote, reconciliation: data.reconciliation, packages: task.packages }, null, 2))));
|
|
33507
|
+
return /* @__PURE__ */ import_react63.default.createElement("article", { className: "bp-card bp-task-detail", "aria-label": "任务详情", ref, tabIndex: -1 }, /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-card-title" }, /* @__PURE__ */ import_react63.default.createElement("div", null, /* @__PURE__ */ import_react63.default.createElement("h2", null, "任务详情"), /* @__PURE__ */ import_react63.default.createElement("p", null, taskTitle(task))), /* @__PURE__ */ import_react63.default.createElement("button", { type: "button", onClick: onClose }, "关闭")), /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-actions" }, /* @__PURE__ */ import_react63.default.createElement(Tag2, null, TASK_STATES[data.state] ?? data.state), /* @__PURE__ */ import_react63.default.createElement("span", null, bot?.name ?? task.botId ?? "未取得归属"), bot?.sessionId && openSession && /* @__PURE__ */ import_react63.default.createElement("button", { type: "button", onClick: () => openSession(bot.sessionId) }, "打开 Bot 私聊"), /* @__PURE__ */ import_react63.default.createElement(TaskActions, { row: data, actions, dispatch, busy })), /* @__PURE__ */ import_react63.default.createElement(RemoteNotice, { row: data }), /* @__PURE__ */ import_react63.default.createElement("dl", { className: "bp-facts" }, /* @__PURE__ */ import_react63.default.createElement(Value, { label: "创建" }, date(data.createdAt)), /* @__PURE__ */ import_react63.default.createElement(Value, { label: "最近更新" }, date(data.updatedAt)), /* @__PURE__ */ import_react63.default.createElement(Value, { label: "期限" }, date(data.deadlineAt))), /* @__PURE__ */ import_react63.default.createElement("h3", null, "任务时间线"), loading && /* @__PURE__ */ import_react63.default.createElement("p", { role: "status", className: "bp-muted" }, "正在读取原任务日志…"), failure && /* @__PURE__ */ import_react63.default.createElement("p", { role: "alert", className: "bp-error" }, failure), inspect && actions.includes("task.inspect") && /* @__PURE__ */ import_react63.default.createElement("button", { type: "button", disabled: loading, onClick: () => setReload((value) => value + 1) }, "重新读取时间线"), !actions.includes("task.inspect") && /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-muted" }, "此宿主尚未提供任务时间线,可在下方查看现有收据依据。"), history && /* @__PURE__ */ import_react63.default.createElement(import_react63.default.Fragment, null, /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-muted" }, history.attempts.length, " 次执行尝试 · ", history.timeline.total, " 条日志", history.timeline.truncated ? `,显示最近 ${history.timeline.items.length} 条` : "", "。停止请求需等待原操作确认。"), /* @__PURE__ */ import_react63.default.createElement("ol", { className: "bp-task-timeline" }, history.timeline.items.map((event) => /* @__PURE__ */ import_react63.default.createElement("li", { key: event.seq }, /* @__PURE__ */ import_react63.default.createElement("span", { className: "bp-timeline-dot", "aria-hidden": "true" }), /* @__PURE__ */ import_react63.default.createElement("div", null, /* @__PURE__ */ import_react63.default.createElement("strong", null, TASK_EVENTS[event.kind] ?? "任务状态更新"), /* @__PURE__ */ import_react63.default.createElement("small", null, /* @__PURE__ */ import_react63.default.createElement("time", null, date(event.at)), event.attemptNumber != null ? ` · 第 ${event.attemptNumber} 次尝试` : "", event.outcome ? ` · ${TASK_STATES[event.outcome] ?? (event.outcome === "unknown" ? "结果未知" : event.outcome)}` : ""), event.code && /* @__PURE__ */ import_react63.default.createElement("small", { className: "bp-code" }, event.code))))), history.timeline.items.length === 0 && /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-muted" }, "原日志中暂无事件。"), /* @__PURE__ */ import_react63.default.createElement("details", { className: "bp-technical" }, /* @__PURE__ */ import_react63.default.createElement("summary", null, "每次尝试的身份与时间"), /* @__PURE__ */ import_react63.default.createElement("ul", { className: "bp-checklist" }, history.attempts.map((attempt) => /* @__PURE__ */ import_react63.default.createElement("li", { key: attempt.attemptId }, /* @__PURE__ */ import_react63.default.createElement("strong", null, "第 ", attempt.number, " 次 · ", TASK_STATES[attempt.state] ?? attempt.state), /* @__PURE__ */ import_react63.default.createElement("small", null, "开始 ", date(attempt.startedAt), " · 结束 ", date(attempt.finishedAt)), /* @__PURE__ */ import_react63.default.createElement("small", { className: "bp-code" }, attempt.attemptId, " · ", attempt.operationId)))))), /* @__PURE__ */ import_react63.default.createElement("details", { className: "bp-technical" }, /* @__PURE__ */ import_react63.default.createElement("summary", null, "原任务与操作编号"), /* @__PURE__ */ import_react63.default.createElement("dl", { className: "bp-facts" }, /* @__PURE__ */ import_react63.default.createElement(Value, { label: "Task" }, taskIdOf(data)), /* @__PURE__ */ import_react63.default.createElement(Value, { label: "Package" }, data.packageId), /* @__PURE__ */ import_react63.default.createElement(Value, { label: "Attempt" }, data.attemptId), /* @__PURE__ */ import_react63.default.createElement(Value, { label: "Operation" }, data.operationId), /* @__PURE__ */ import_react63.default.createElement(Value, { label: "MR 运行" }, data.legacyRunIds?.join("、")), data.attribution?.label && /* @__PURE__ */ import_react63.default.createElement(Value, { label: "执行进度" }, data.attribution.label))), /* @__PURE__ */ import_react63.default.createElement("details", { className: "bp-technical" }, /* @__PURE__ */ import_react63.default.createElement("summary", null, "执行与收据依据"), /* @__PURE__ */ import_react63.default.createElement("pre", null, JSON.stringify({ attempts: history?.attempts ?? task.attempts, executionRef: task.executionRef, remoteReceipt: data.result?.remote, reconciliation: data.reconciliation, packages: task.packages }, null, 2))));
|
|
33508
33508
|
}
|
|
33509
33509
|
function TaskWorkspace({ snapshot, dispatch, busy, scope, inspect, openSession, routingCard }) {
|
|
33510
33510
|
let progress = (0, import_react63.useMemo)(() => taskProgress(snapshot), [snapshot]), [botId, setBotId] = (0, import_react63.useState)(""), [status, setStatus] = (0, import_react63.useState)("all"), [query, setQuery] = (0, import_react63.useState)(""), [expanded, setExpanded] = (0, import_react63.useState)(!1), [selection, setSelection] = (0, import_react63.useState)(null);
|
|
@@ -33517,7 +33517,7 @@ function TaskWorkspace({ snapshot, dispatch, busy, scope, inspect, openSession,
|
|
|
33517
33517
|
return /* @__PURE__ */ import_react63.default.createElement("li", { key: taskKey(row), className: `bp-attention-row bp-attention-${row.stage}` }, /* @__PURE__ */ import_react63.default.createElement("div", null, /* @__PURE__ */ import_react63.default.createElement("strong", null, row.title), /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-muted" }, row.detail), row.stage === "approve" && /* @__PURE__ */ import_react63.default.createElement("small", null, "打开 Bot 私聊查看原审批请求。"), remoteUncertain(original) && /* @__PURE__ */ import_react63.default.createElement("small", null, "原操作仍待核对,请查看任务详情。")), /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-actions" }, /* @__PURE__ */ import_react63.default.createElement("button", { type: "button", onClick: () => select(original) }, "打开任务"), row.stage === "approve" && openSession && snapshot.bots?.find((bot) => bot.id === row.botId)?.sessionId && /* @__PURE__ */ import_react63.default.createElement("button", { type: "button", onClick: () => openSession(snapshot.bots.find((bot) => bot.id === row.botId).sessionId) }, "打开审批会话"), row.stage === "reconcile" && actions.includes("task.reconcile") && /* @__PURE__ */ import_react63.default.createElement(TaskActions, { row: original, actions: ["task.reconcile"], dispatch, busy }), row.stage === "failed" && actions.includes("task.retry") && /* @__PURE__ */ import_react63.default.createElement(TaskActions, { row: original, actions: ["task.retry"], dispatch, busy })));
|
|
33518
33518
|
})), progress.attention.length > 6 && /* @__PURE__ */ import_react63.default.createElement("button", { type: "button", "aria-expanded": expanded, onClick: () => setExpanded((value) => !value) }, expanded ? "收起待处理事项" : `查看全部 ${progress.attention.length} 项待处理`)), task && /* @__PURE__ */ import_react63.default.createElement(TaskDetail, { key: taskKey(task), task, inspect, busy, actions, dispatch, onClose: () => setSelection(null), bot: snapshot.bots?.find((bot) => bot.id === task.botId), openSession }), /* @__PURE__ */ import_react63.default.createElement("section", { className: "bp-card", "aria-label": "任务记录" }, /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-card-title" }, /* @__PURE__ */ import_react63.default.createElement("h2", null, "任务记录"), /* @__PURE__ */ import_react63.default.createElement("span", { className: "bp-muted", role: "status" }, "显示 ", rows2.length, " / ", progress.items.length, " 条")), /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-task-search" }, /* @__PURE__ */ import_react63.default.createElement("label", null, "搜索任务", /* @__PURE__ */ import_react63.default.createElement("input", { type: "search", value: query, placeholder: "任务名称、Bot 或任务 / 计划 / 操作编号", onChange: (event) => setQuery(event.target.value) })), (query || botId || status !== "all") && /* @__PURE__ */ import_react63.default.createElement("button", { type: "button", onClick: () => {
|
|
33519
33519
|
setQuery(""), setBotId(""), setStatus("all");
|
|
33520
|
-
} }, "清除筛选")), progress.items.length ? rows2.length === 0 ? /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-empty" }, "没有符合当前筛选的任务。可清除筛选查看其他记录。") : /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-table-wrap" }, /* @__PURE__ */ import_react63.default.createElement("table", null, /* @__PURE__ */ import_react63.default.createElement("thead", null, /* @__PURE__ */ import_react63.default.createElement("tr", null, /* @__PURE__ */ import_react63.default.createElement("th", null, "任务"), /* @__PURE__ */ import_react63.default.createElement("th", null, "Bot"), /* @__PURE__ */ import_react63.default.createElement("th", null, "状态"), /* @__PURE__ */ import_react63.default.createElement("th", null, "执行尝试"), /* @__PURE__ */ import_react63.default.createElement("th", null, "操作"))), /* @__PURE__ */ import_react63.default.createElement("tbody", null, rows2.map((row) => /* @__PURE__ */ import_react63.default.createElement("tr", { key: taskKey(row) }, /* @__PURE__ */ import_react63.default.createElement("td", null, /* @__PURE__ */ import_react63.default.createElement("button", { className: "bp-text-button", onClick: () => select(row) }, taskTitle(row)), /* @__PURE__ */ import_react63.default.createElement("small", null, "更新 ", date(row.updatedAt))), /* @__PURE__ */ import_react63.default.createElement("td", null, snapshot.bots?.find((bot) => bot.id === row.botId)?.name ?? row.botId ?? "未取得归属"), /* @__PURE__ */ import_react63.default.createElement("td", null, /* @__PURE__ */ import_react63.default.createElement(Tag2, null, TASK_STATES[row.state] ?? row.state), row.blockedReason && /* @__PURE__ */ import_react63.default.createElement("small", null, row.blockedReason), /* @__PURE__ */ import_react63.default.createElement(RemoteNotice, { row })), /* @__PURE__ */ import_react63.default.createElement("td", null, Number.isSafeInteger(row.attempts) ? row.attempts : row.attempts?.length ?? row.attemptCount ?? "未知"), /* @__PURE__ */ import_react63.default.createElement("td", { className: "bp-actions" }, /* @__PURE__ */ import_react63.default.createElement(TaskActions, { row, actions, dispatch, busy }))))))) : /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-empty" }, "尚无持久任务。在 Main 私聊中提出目标后,可在这里查看工作包、执行尝试与收据。")), snapshot.tasks.plans?.length > 0 && /* @__PURE__ */ import_react63.default.createElement("section", { className: "bp-task-plans" }, /* @__PURE__ */ import_react63.default.createElement("button", { type: "button", "aria-expanded": showPlans, onClick: () => setShowPlans((value) => !value) }, showPlans ? "收起协作计划" : `查看 ${snapshot.tasks.plans.length} 个协作计划`), snapshot.tasks.plans.filter((plan) => showPlans || plan.needsReconciliation || !["succeeded", "cancelled", "failed"].includes(plan.state)).map((plan, index) => /* @__PURE__ */ import_react63.default.createElement("article", { className: "bp-card", key: plan.planId }, /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-card-title" }, /* @__PURE__ */ import_react63.default.createElement("div", null, /* @__PURE__ */ import_react63.default.createElement("h2", null, "协作计划 ", index + 1), /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-muted" }, plan.packages.length, " 个工作包 · ", plan.packages.filter((item) => item.state === "succeeded").length, " 个已完成")), /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-actions" }, /* @__PURE__ */ import_react63.default.createElement(Tag2, null, TASK_STATES[plan.state] ?? plan.state), actions.includes("task.plan.cancel") && !["succeeded", "cancelled"].includes(plan.state) && /* @__PURE__ */ import_react63.default.createElement("button", { disabled: busy, onClick: () => dispatch("task.plan.cancel", { botId: plan.ownerBotId, planId: plan.planId }) }, "停止整个计划"))), plan.needsReconciliation && /* @__PURE__ */ import_react63.default.createElement("p", { role: "status" }, "有工作包停止尚未确认,请核对原收据;计划尚未完成停止。"), /* @__PURE__ */ import_react63.default.createElement("ul", { className: "bp-plan-list" }, plan.packages.map((item, step) => {
|
|
33520
|
+
} }, "清除筛选")), progress.items.length ? rows2.length === 0 ? /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-empty" }, "没有符合当前筛选的任务。可清除筛选查看其他记录。") : /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-table-wrap" }, /* @__PURE__ */ import_react63.default.createElement("table", null, /* @__PURE__ */ import_react63.default.createElement("thead", null, /* @__PURE__ */ import_react63.default.createElement("tr", null, /* @__PURE__ */ import_react63.default.createElement("th", null, "任务"), /* @__PURE__ */ import_react63.default.createElement("th", null, "Bot"), /* @__PURE__ */ import_react63.default.createElement("th", null, "状态"), /* @__PURE__ */ import_react63.default.createElement("th", null, "执行尝试"), /* @__PURE__ */ import_react63.default.createElement("th", null, "操作"))), /* @__PURE__ */ import_react63.default.createElement("tbody", null, rows2.map((row) => /* @__PURE__ */ import_react63.default.createElement("tr", { key: taskKey(row) }, /* @__PURE__ */ import_react63.default.createElement("td", null, /* @__PURE__ */ import_react63.default.createElement("button", { className: "bp-text-button", onClick: () => select(row) }, taskTitle(row)), /* @__PURE__ */ import_react63.default.createElement("small", null, "更新 ", date(row.updatedAt))), /* @__PURE__ */ import_react63.default.createElement("td", null, snapshot.bots?.find((bot) => bot.id === row.botId)?.name ?? row.botId ?? "未取得归属"), /* @__PURE__ */ import_react63.default.createElement("td", null, /* @__PURE__ */ import_react63.default.createElement(Tag2, null, TASK_STATES[row.state] ?? row.state), row.attribution?.label && /* @__PURE__ */ import_react63.default.createElement("small", null, row.attribution.label), row.blockedReason && /* @__PURE__ */ import_react63.default.createElement("small", null, row.blockedReason), /* @__PURE__ */ import_react63.default.createElement(RemoteNotice, { row })), /* @__PURE__ */ import_react63.default.createElement("td", null, Number.isSafeInteger(row.attempts) ? row.attempts : row.attempts?.length ?? row.attemptCount ?? "未知"), /* @__PURE__ */ import_react63.default.createElement("td", { className: "bp-actions" }, /* @__PURE__ */ import_react63.default.createElement(TaskActions, { row, actions, dispatch, busy }))))))) : /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-empty" }, "尚无持久任务。在 Main 私聊中提出目标后,可在这里查看工作包、执行尝试与收据。")), snapshot.tasks.plans?.length > 0 && /* @__PURE__ */ import_react63.default.createElement("section", { className: "bp-task-plans" }, /* @__PURE__ */ import_react63.default.createElement("button", { type: "button", "aria-expanded": showPlans, onClick: () => setShowPlans((value) => !value) }, showPlans ? "收起协作计划" : `查看 ${snapshot.tasks.plans.length} 个协作计划`), snapshot.tasks.plans.filter((plan) => showPlans || plan.needsReconciliation || !["succeeded", "cancelled", "failed"].includes(plan.state)).map((plan, index) => /* @__PURE__ */ import_react63.default.createElement("article", { className: "bp-card", key: plan.planId }, /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-card-title" }, /* @__PURE__ */ import_react63.default.createElement("div", null, /* @__PURE__ */ import_react63.default.createElement("h2", null, "协作计划 ", index + 1), /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-muted" }, plan.packages.length, " 个工作包 · ", plan.packages.filter((item) => item.state === "succeeded").length, " 个已完成")), /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-actions" }, /* @__PURE__ */ import_react63.default.createElement(Tag2, null, TASK_STATES[plan.state] ?? plan.state), actions.includes("task.plan.cancel") && !["succeeded", "cancelled"].includes(plan.state) && /* @__PURE__ */ import_react63.default.createElement("button", { disabled: busy, onClick: () => dispatch("task.plan.cancel", { botId: plan.ownerBotId, planId: plan.planId }) }, "停止整个计划"))), plan.needsReconciliation && /* @__PURE__ */ import_react63.default.createElement("p", { role: "status" }, "有工作包停止尚未确认,请核对原收据;计划尚未完成停止。"), /* @__PURE__ */ import_react63.default.createElement("ul", { className: "bp-plan-list" }, plan.packages.map((item, step) => {
|
|
33521
33521
|
let linked = progress.items.find((row) => taskIdOf(row) === item.taskId && row.botId === item.targetBotId);
|
|
33522
33522
|
return /* @__PURE__ */ import_react63.default.createElement("li", { key: item.id }, /* @__PURE__ */ import_react63.default.createElement("span", { className: "bp-plan-step" }, item.state === "succeeded" ? /* @__PURE__ */ import_react63.default.createElement(UiIcon, { name: "check", size: 13 }) : step + 1), /* @__PURE__ */ import_react63.default.createElement("div", { className: "bp-plan-copy" }, linked ? /* @__PURE__ */ import_react63.default.createElement("button", { className: "bp-text-button", onClick: () => select(linked) }, taskTitle(linked)) : /* @__PURE__ */ import_react63.default.createElement("strong", null, plan.plan?.packages?.find((pkg) => pkg.id === item.id)?.objective ?? `工作包 ${step + 1}`), /* @__PURE__ */ import_react63.default.createElement("small", null, snapshot.bots?.find((bot) => bot.id === item.targetBotId)?.name ?? item.targetBotId, item.dependsOn.length > 0 && ` · 前置:${item.dependsOn.join("、")}`, item.result?.code === "DEPENDENCY_FAILED" && " · 前置包失败,尚未执行")), /* @__PURE__ */ import_react63.default.createElement(Tag2, null, TASK_STATES[item.state] ?? item.state));
|
|
33523
33523
|
})), /* @__PURE__ */ import_react63.default.createElement("details", { className: "bp-technical bp-plan-id" }, /* @__PURE__ */ import_react63.default.createElement("summary", null, "计划编号"), /* @__PURE__ */ import_react63.default.createElement("p", { className: "bp-code" }, plan.planId))))), routingCard);
|
package/README.i18n.yaml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
# README translation pairing for this repository.
|
|
2
2
|
# Reviewed together; Git blob hashes identify the exact synchronized texts.
|
|
3
|
-
README.md:
|
|
4
|
-
README.zh.md:
|
|
3
|
+
README.md: f7d396b12450d3239282e926efee1c997cba2442
|
|
4
|
+
README.zh.md: 2eb71c9a81a811da9dd25d2599dbe9f829a68542
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Turn a task into a reviewable execution plan, select suitable configured models
|
|
|
14
14
|
|
|
15
15
|
This is a community plugin. It reuses Harness model configuration and Host services; each provider or CLI uses its own account and access rights.
|
|
16
16
|
|
|
17
|
-
**Current stable version: 0.
|
|
17
|
+
**Current stable version: 0.66.1 · Windows Remote Agent: 0.1.1**
|
|
18
18
|
|
|
19
19
|
[Choose an edition](#editions) · [Install](#install) · [First task](#quick-evaluation) · [Bot delegation](#bot-routing) · [Remote computers](#computers) · [Support](#troubleshooting)
|
|
20
20
|
|
|
@@ -52,8 +52,8 @@ Use **one edition per Harness profile**. Both editions share the routing configu
|
|
|
52
52
|
## Installation
|
|
53
53
|
|
|
54
54
|
1. Open **Plugins → Add plugin** in Harness Desktop and enter **one** package:
|
|
55
|
-
- Full workspace: `@ljwei-stak/dsh-model-route-bot@0.
|
|
56
|
-
- Routing only: `@ljwei-stak/dsh-model-router@0.
|
|
55
|
+
- Full workspace: `@ljwei-stak/dsh-model-route-bot@0.66.1`
|
|
56
|
+
- Routing only: `@ljwei-stak/dsh-model-router@0.66.1`
|
|
57
57
|
2. Enable it, fully quit Harness **including its tray process**, then restart so the Host loads the installed version.
|
|
58
58
|
3. Configure provider credentials and models in the official Harness **Models** settings. Open the Bot workspace for the full edition, or **Model Router** in the sidebar for the routing-only edition.
|
|
59
59
|
|
|
@@ -61,10 +61,10 @@ CLI installation is also supported. Choose the command for your edition:
|
|
|
61
61
|
|
|
62
62
|
```powershell
|
|
63
63
|
# Full Bot workspace
|
|
64
|
-
dsh plugin --profile desktop add @ljwei-stak/dsh-model-route-bot@0.
|
|
64
|
+
dsh plugin --profile desktop add @ljwei-stak/dsh-model-route-bot@0.66.1
|
|
65
65
|
|
|
66
66
|
# Routing-only workspace
|
|
67
|
-
dsh plugin --profile desktop add @ljwei-stak/dsh-model-router@0.
|
|
67
|
+
dsh plugin --profile desktop add @ljwei-stak/dsh-model-router@0.66.1
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
Alternatively, import the matching `.tgz` from [GitHub Releases](https://github.com/Alice-Marx/dsh-model-router/releases/latest). For an extracted local package, select the inner `package` directory containing `package.json` and `.dsh-plugin`. A global npm installation alone does not register a Harness plugin.
|
|
@@ -140,7 +140,7 @@ Integration mode reuses Harness Sessions. An optional independent runtime has it
|
|
|
140
140
|
|
|
141
141
|
## Multi-Bot task progress and pending items
|
|
142
142
|
|
|
143
|
-
**Available in the full Bot edition 0.
|
|
143
|
+
**Available in the full Bot edition 0.66.1.** On **Tasks**, inspect task counts per Bot, filter by **All / In progress / Needs attention / Ended**, or search task names and task, plan, or operation IDs. Counts and filters cover loaded records; funding admission is in progress, while budget waiting needs attention.
|
|
144
144
|
|
|
145
145
|
**Needs your attention** reports the full count and expands beyond six items. Each reminder opens the original task; approval reminders can open the owning Bot's conversation to show its original request. Open a task's **timeline** for queue, admission, execution, stop-request, and reconciliation events, with the original identities for each attempt. Reading history does not dispatch tasks or invoke models; stop requests still require confirmation. Older Hosts without history support retain existing receipt details.
|
|
146
146
|
|
package/README.zh.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
本项目是社区插件,复用 Harness 的模型配置和 Host 服务;各模型供应商与 CLI 使用各自的账户及访问权限。
|
|
16
16
|
|
|
17
|
-
**当前正式版:0.
|
|
17
|
+
**当前正式版:0.66.1 · Windows Remote Agent:0.1.1**
|
|
18
18
|
|
|
19
19
|
[选择版本](#editions) · [安装](#install) · [第一个任务](#quick-evaluation) · [Bot 分工](#bot-routing) · [远程电脑](#computers) · [问题反馈](#troubleshooting)
|
|
20
20
|
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
## 安装
|
|
53
53
|
|
|
54
54
|
1. 在 Harness Desktop 打开 **插件 → 添加插件**,输入以下包名之一:
|
|
55
|
-
- 完整工作区:`@ljwei-stak/dsh-model-route-bot@0.
|
|
56
|
-
- 仅模型路由:`@ljwei-stak/dsh-model-router@0.
|
|
55
|
+
- 完整工作区:`@ljwei-stak/dsh-model-route-bot@0.66.1`
|
|
56
|
+
- 仅模型路由:`@ljwei-stak/dsh-model-router@0.66.1`
|
|
57
57
|
2. 启用后,**连同托盘进程一起完全退出 Harness**,再重新启动,让 Host 加载已安装版本。
|
|
58
58
|
3. 在 Harness 官方 **模型** 设置中配置 Provider 凭据和模型。完整版本进入 Bot 工作区;纯路由版本从侧栏打开 **模型路由**。
|
|
59
59
|
|
|
@@ -61,10 +61,10 @@
|
|
|
61
61
|
|
|
62
62
|
```powershell
|
|
63
63
|
# 完整 Bot 工作区
|
|
64
|
-
dsh plugin --profile desktop add @ljwei-stak/dsh-model-route-bot@0.
|
|
64
|
+
dsh plugin --profile desktop add @ljwei-stak/dsh-model-route-bot@0.66.1
|
|
65
65
|
|
|
66
66
|
# 仅模型路由
|
|
67
|
-
dsh plugin --profile desktop add @ljwei-stak/dsh-model-router@0.
|
|
67
|
+
dsh plugin --profile desktop add @ljwei-stak/dsh-model-router@0.66.1
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
离线安装可从 [GitHub Releases](https://github.com/Alice-Marx/dsh-model-router/releases/latest) 导入对应 `.tgz`。本地解压安装时,选择同时包含 `package.json` 与 `.dsh-plugin` 的内层 `package` 目录。仅全局安装 npm 包不会将其注册为 Harness 插件。
|
|
@@ -140,7 +140,7 @@ dsh plugin --profile desktop add @ljwei-stak/dsh-model-router@0.65.0
|
|
|
140
140
|
|
|
141
141
|
## 多 Bot 任务进度与待处理
|
|
142
142
|
|
|
143
|
-
**适用于完整 Bot 版本 0.
|
|
143
|
+
**适用于完整 Bot 版本 0.66.1。** 在 **任务** 页面按 Bot 查看任务数量,通过 **全部 / 进行中 / 待处理 / 已结束** 筛选,或搜索任务名称及任务、计划、操作编号。计数和筛选针对已加载记录;资金准入检查属于进行中,等待预算属于待处理。
|
|
144
144
|
|
|
145
145
|
**需要你处理** 显示完整数量,超过六项时可展开全部。每项都能打开原任务;等待审批时可进入对应 Bot 私聊查看原请求。打开任务后可查看 **任务时间线**,了解排队、资金准入、执行、停止请求及收据核对,并展开每次尝试的原编号。日志读取不会重新派发任务或调用模型;停止请求仍需等待确认。旧宿主未提供时间线时,页面保留现有收据详情。
|
|
146
146
|
|
|
@@ -78,7 +78,7 @@ function TaskDetail({ task, inspect, busy, actions, dispatch, onClose, bot, open
|
|
|
78
78
|
<strong>第 {attempt.number} 次 · {TASK_STATES[attempt.state] ?? attempt.state}</strong><small>开始 {date(attempt.startedAt)} · 结束 {date(attempt.finishedAt)}</small><small className="bp-code">{attempt.attemptId} · {attempt.operationId}</small>
|
|
79
79
|
</li>)}</ul></details>
|
|
80
80
|
</>}
|
|
81
|
-
<details className="bp-technical"><summary>原任务与操作编号</summary><dl className="bp-facts"><Value label="Task">{taskIdOf(data)}</Value><Value label="Package">{data.packageId}</Value><Value label="Attempt">{data.attemptId}</Value><Value label="Operation">{data.operationId}</Value><Value label="MR 运行">{data.legacyRunIds?.join('、')}</Value
|
|
81
|
+
<details className="bp-technical"><summary>原任务与操作编号</summary><dl className="bp-facts"><Value label="Task">{taskIdOf(data)}</Value><Value label="Package">{data.packageId}</Value><Value label="Attempt">{data.attemptId}</Value><Value label="Operation">{data.operationId}</Value><Value label="MR 运行">{data.legacyRunIds?.join('、')}</Value>{data.attribution?.label && <Value label="执行进度">{data.attribution.label}</Value>}</dl></details>
|
|
82
82
|
<details className="bp-technical"><summary>执行与收据依据</summary><pre>{JSON.stringify({ attempts: history?.attempts ?? task.attempts, executionRef: task.executionRef, remoteReceipt: data.result?.remote, reconciliation: data.reconciliation, packages: task.packages }, null, 2)}</pre></details>
|
|
83
83
|
</article>
|
|
84
84
|
}
|
|
@@ -124,7 +124,7 @@ export function TaskWorkspace({ snapshot, dispatch, busy, scope, inspect, openSe
|
|
|
124
124
|
{!progress.items.length ? <p className="bp-empty">尚无持久任务。在 Main 私聊中提出目标后,可在这里查看工作包、执行尝试与收据。</p> : rows.length === 0 ? <p className="bp-empty">没有符合当前筛选的任务。可清除筛选查看其他记录。</p> : <div className="bp-table-wrap"><table>
|
|
125
125
|
<thead><tr><th>任务</th><th>Bot</th><th>状态</th><th>执行尝试</th><th>操作</th></tr></thead><tbody>{rows.map(row => <tr key={taskKey(row)}>
|
|
126
126
|
<td><button className="bp-text-button" onClick={() => select(row)}>{taskTitle(row)}</button><small>更新 {date(row.updatedAt)}</small></td>
|
|
127
|
-
<td>{snapshot.bots?.find(bot => bot.id === row.botId)?.name ?? row.botId ?? '未取得归属'}</td><td><Tag>{TASK_STATES[row.state] ?? row.state}</Tag>{row.blockedReason && <small>{row.blockedReason}</small>}<RemoteNotice row={row} /></td>
|
|
127
|
+
<td>{snapshot.bots?.find(bot => bot.id === row.botId)?.name ?? row.botId ?? '未取得归属'}</td><td><Tag>{TASK_STATES[row.state] ?? row.state}</Tag>{row.attribution?.label && <small>{row.attribution.label}</small>}{row.blockedReason && <small>{row.blockedReason}</small>}<RemoteNotice row={row} /></td>
|
|
128
128
|
<td>{Number.isSafeInteger(row.attempts) ? row.attempts : row.attempts?.length ?? row.attemptCount ?? '未知'}</td><td className="bp-actions"><TaskActions row={row} actions={actions} dispatch={dispatch} busy={busy} /></td>
|
|
129
129
|
</tr>)}</tbody></table></div>}
|
|
130
130
|
</section>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ljwei-stak/dsh-model-route-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DSH Bot workspace with integrated model routing, official tools and remote computers",
|
|
6
6
|
"repository": {
|
|
@@ -336,6 +336,8 @@
|
|
|
336
336
|
"build:client": "node scripts/build-client.mjs",
|
|
337
337
|
"build:router-package": "node scripts/build-router-package.mjs",
|
|
338
338
|
"check:client": "node scripts/build-client.mjs --check",
|
|
339
|
+
"check:contracts": "node scripts/check-contracts.mjs",
|
|
340
|
+
"contracts:write": "node scripts/check-contracts.mjs --write",
|
|
339
341
|
"build:agent": "node remote-computer-agent/build.mjs",
|
|
340
342
|
"check:source": "node scripts/integration-product-source.mjs",
|
|
341
343
|
"pretest": "node scripts/prepare-product-tests.mjs",
|
|
@@ -2,7 +2,21 @@ import { createHash, randomBytes } from 'node:crypto'
|
|
|
2
2
|
import { performance } from 'node:perf_hooks'
|
|
3
3
|
|
|
4
4
|
export class PolicyError extends Error {
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `detail.gate` names the checkpoint that refused, and `detail.rule` the rule
|
|
7
|
+
* text when one matched. A refusal that only says "TOOL_DENIED" leaves the
|
|
8
|
+
* reader to guess which of several gates stopped it, and support then runs on
|
|
9
|
+
* pasted logs.
|
|
10
|
+
*/
|
|
11
|
+
constructor(code, detail = {}) {
|
|
12
|
+
super(`Capability refused: ${code}`)
|
|
13
|
+
this.name = 'PolicyError'
|
|
14
|
+
this.code = code
|
|
15
|
+
if (detail && typeof detail === 'object') {
|
|
16
|
+
if (typeof detail.gate === 'string' && detail.gate) this.gate = detail.gate
|
|
17
|
+
if (typeof detail.rule === 'string' && detail.rule) this.rule = detail.rule
|
|
18
|
+
}
|
|
19
|
+
}
|
|
6
20
|
}
|
|
7
21
|
|
|
8
22
|
const refuse = code => { throw new PolicyError(code) }
|
|
@@ -8,16 +8,16 @@ English · [简体中文](README.zh.md)
|
|
|
8
8
|
|
|
9
9
|
Describe the result you want, choose suitable configured models or native CLI tools, review the plan, then follow execution and accounting from one workbench. This edition adds a **Model Router** sidebar entry and keeps the existing Harness workspace.
|
|
10
10
|
|
|
11
|
-
**Stable version: 0.
|
|
11
|
+
**Stable version: 0.66.1.** For Bot conversations, multi-Bot delegation, remote computers, private libraries, and routines, use the [full Bot edition](https://github.com/Alice-Marx/dsh-model-router/blob/main/README.md): **`@ljwei-stak/dsh-model-route-bot`**. Select one edition per profile.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
15
|
-
1. In Harness **Plugins → Add plugin**, enter `@ljwei-stak/dsh-model-router@0.
|
|
15
|
+
1. In Harness **Plugins → Add plugin**, enter `@ljwei-stak/dsh-model-router@0.66.1`.
|
|
16
16
|
2. Enable it, quit Harness including its tray process, and restart.
|
|
17
17
|
3. Configure provider credentials/models in Harness **Models**, then open **Model Router** from the sidebar.
|
|
18
18
|
|
|
19
19
|
```powershell
|
|
20
|
-
dsh plugin --profile desktop add @ljwei-stak/dsh-model-router@0.
|
|
20
|
+
dsh plugin --profile desktop add @ljwei-stak/dsh-model-router@0.66.1
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
## Run your first task
|
|
@@ -64,6 +64,9 @@ const validPolicy = value => Boolean(value) && ['run', 'check', 'filterRoutes'].
|
|
|
64
64
|
// instead of silently dropping every managed task.
|
|
65
65
|
const TASK_RUNNER_TTL_MS = 10_000
|
|
66
66
|
const TASK_RUNNER_CLAIM_MS = 12_000
|
|
67
|
+
// Frequent enough that a stopped runner is rebuilt inside one lease period,
|
|
68
|
+
// rare enough that a healthy Host only reads one row every five seconds.
|
|
69
|
+
const TASK_RUNNER_WATCHDOG_MS = 5_000
|
|
67
70
|
const TASK_TTL_MS = 120_000
|
|
68
71
|
const TASK_BACKEND_LIMIT = 64
|
|
69
72
|
const TASK_READ_LIMIT = 512
|
|
@@ -94,6 +97,17 @@ export function compileProductGrant(bot, { routes, hydrated }) {
|
|
|
94
97
|
modelRoutes: hydrated && provider && model && routes.has(key) ? [{ provider, model }] : [] }
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
/**
|
|
101
|
+
* An action that is only meaningful for a signed-in person says so at its own
|
|
102
|
+
* entry. The authorization seam decides who may call, but a route that forgets to
|
|
103
|
+
* declare its caller class inherits whatever the seam defaults to, and a later
|
|
104
|
+
* change there would hand a non-owner somebody's console.
|
|
105
|
+
*/
|
|
106
|
+
const ownerOnly = fn => (payload, context) => {
|
|
107
|
+
if (context?.kind !== 'owner') throw Object.assign(new Error('OWNER_ONLY'), { code: 'OWNER_ONLY' })
|
|
108
|
+
return fn(payload, context)
|
|
109
|
+
}
|
|
110
|
+
|
|
97
111
|
/** Subscription usage is not API spend; missing token evidence stays unknown. */
|
|
98
112
|
export function projectModelCallAmount(receipt, resolution = null) {
|
|
99
113
|
if (resolution) return resolution.costUsd
|
|
@@ -388,7 +402,7 @@ export function startBotProduct(ctx, options = {}) {
|
|
|
388
402
|
|
|
389
403
|
const grants = new Map()
|
|
390
404
|
let machines, product, releaseMachineSecrets, funding, botSettings, fundingAccounts, modelAccounting, routingSettings, turnBackends, routineExecution, machineHeartbeat
|
|
391
|
-
let journal, taskService, taskBackends, releaseTaskTools, cliSessionFactory, remoteCliBackend, sharedAdmission
|
|
405
|
+
let journal, taskService, taskBackends, releaseTaskTools, cliSessionFactory, remoteCliBackend, sharedAdmission, taskRunnerWatchdog
|
|
392
406
|
const committedBots = new Map()
|
|
393
407
|
let ownerCatalogStamp=''
|
|
394
408
|
let routes = new Set(), hydrated = false, refreshing = false, lastRefresh = 0, timer = null
|
|
@@ -627,7 +641,7 @@ export function startBotProduct(ctx, options = {}) {
|
|
|
627
641
|
if (typeof factory !== 'function') throw new TypeError('TASK_CLI_SESSION_FACTORY_REQUIRED')
|
|
628
642
|
cliSessionFactory = factory
|
|
629
643
|
}
|
|
630
|
-
journal = funding.store.openTaskJournal()
|
|
644
|
+
journal = funding.store.openTaskJournal({leaseClock:'db'})
|
|
631
645
|
const modelBackends = createTaskBackends({ ctx, mr, authority, directory, store: funding.store,
|
|
632
646
|
machineId, workspaceId, workspace: managedWorkspace, runtimeRoot: join(managedRoot, 'cli-runtime'),
|
|
633
647
|
bindingFor: botId => botSettings.get(botId, { publishChange: false }),
|
|
@@ -652,6 +666,13 @@ export function startBotProduct(ctx, options = {}) {
|
|
|
652
666
|
return privateCall&&['private','handoff'].includes(plan.origin.kind)&&product?.lifecycle.committedMainBotIds().includes(id)?product.lifecycle.committedBots().map(bot=>bot.id):[id]
|
|
653
667
|
},
|
|
654
668
|
runnerTtlMs: TASK_RUNNER_TTL_MS, taskTtlMs: TASK_TTL_MS,
|
|
669
|
+
// Lease expiry reads the ledger's clock, not this process's: a Host
|
|
670
|
+
// whose wall clock drifted must not be able to declare its own live
|
|
671
|
+
// lease expired and let a second runner take the same tasks.
|
|
672
|
+
leaseClock: 'db',
|
|
673
|
+
// The deployment refuses a writer that cannot prove a stop, so a new
|
|
674
|
+
// backend cannot be added without also teaching it how to say so.
|
|
675
|
+
requireStopProof: true,
|
|
655
676
|
// The committed binding's maxConcurrentTasks is the per-Bot limit; an
|
|
656
677
|
// unconfigured Bot keeps the conservative single-task default.
|
|
657
678
|
concurrencyFor: botId => {
|
|
@@ -669,6 +690,19 @@ export function startBotProduct(ctx, options = {}) {
|
|
|
669
690
|
await new Promise(resolve => { const pending = setTimeout(resolve, 250); pending.unref() })
|
|
670
691
|
}
|
|
671
692
|
}
|
|
693
|
+
// A runner that stops (a lease lost to a clock jump, a journal write that
|
|
694
|
+
// never settled) must come back on its own, or the Host keeps accepting
|
|
695
|
+
// work nobody will claim. The watchdog reports what it did instead of
|
|
696
|
+
// hiding a permanent stop behind a quiet 5-second tick.
|
|
697
|
+
taskRunnerWatchdog = setInterval(() => {
|
|
698
|
+
try {
|
|
699
|
+
const state = taskService.state()
|
|
700
|
+
if (state.closed || state.running) return
|
|
701
|
+
const outcome = taskService.restart({ reason: state.stopReason ?? 'TASK_RUNNER_WATCHDOG' })
|
|
702
|
+
if (!outcome.accepted) ctx.logger?.('task-runner-restart-deferred', { code: outcome.code, reason: state.stopReason })
|
|
703
|
+
} catch (error) { ctx.logger?.('task-runner-watchdog-failed', { code: error?.code ?? 'TASK_RUNNER_WATCHDOG_FAILED' }) }
|
|
704
|
+
}, TASK_RUNNER_WATCHDOG_MS)
|
|
705
|
+
taskRunnerWatchdog.unref?.()
|
|
672
706
|
releaseTaskTools = registerTaskTools(ctx, defineTool, { service: taskService, directory })
|
|
673
707
|
}
|
|
674
708
|
const machineActions=Object.fromEntries(['pair','bind','probe','revoke','reconcile'].map(name=>[`machine.${name}`,(payload,context)=>machines[name](payload,context)]))
|
|
@@ -866,16 +900,20 @@ export function startBotProduct(ctx, options = {}) {
|
|
|
866
900
|
// N09: owner task management runs on the owning Bot's own subject, so
|
|
867
901
|
// the Bot's committed grant authorizes every cancel/retry/reconcile.
|
|
868
902
|
...(taskService ? {
|
|
869
|
-
|
|
903
|
+
// Task management is the person's own console, not a Bot surface. Every
|
|
904
|
+
// action here names its caller class instead of inheriting the default,
|
|
905
|
+
// so a change at the authorization seam cannot quietly hand a non-owner
|
|
906
|
+
// the ability to cancel, retry or reconcile somebody's work.
|
|
907
|
+
'task.inspect': ownerOnly(payload => inspectProductTask({ service: taskService, journal, subject: taskOwner(payload), taskId: payload?.taskId })),
|
|
870
908
|
// Bot routing turns a preview into a real package: the decision names the Bot, the
|
|
871
909
|
// worker binding is committed first, and only then is the plan submitted. A failure in
|
|
872
910
|
// either step leaves no half-dispatched task behind.
|
|
873
911
|
'botRouting.dispatch': (payload, context) => botRouting.dispatch(payload, context),
|
|
874
|
-
'task.plan.submit': payload => taskService.submitPlan(taskOwner(payload),{requestKey:payload?.requestKey,plan:payload?.plan},{owner:true}),
|
|
875
|
-
'task.plan.cancel': payload => taskService.cancelPlan(taskOwner(payload),{planId:payload?.planId}),
|
|
876
|
-
'task.cancel': payload => taskService.cancel(taskOwner(payload), { taskId: payload?.taskId }),
|
|
877
|
-
'task.retry': payload => taskService.retry(taskOwner(payload), { taskId: payload?.taskId, requestKey: payload?.requestKey }),
|
|
878
|
-
'task.reconcile': payload => taskService.reconcile(taskOwner(payload), { taskId: payload?.taskId, requestKey: payload?.requestKey }),
|
|
912
|
+
'task.plan.submit': ownerOnly(payload => taskService.submitPlan(taskOwner(payload),{requestKey:payload?.requestKey,plan:payload?.plan},{owner:true})),
|
|
913
|
+
'task.plan.cancel': ownerOnly(payload => taskService.cancelPlan(taskOwner(payload),{planId:payload?.planId})),
|
|
914
|
+
'task.cancel': ownerOnly(payload => taskService.cancel(taskOwner(payload), { taskId: payload?.taskId })),
|
|
915
|
+
'task.retry': ownerOnly(payload => taskService.retry(taskOwner(payload), { taskId: payload?.taskId, requestKey: payload?.requestKey })),
|
|
916
|
+
'task.reconcile': ownerOnly(payload => taskService.reconcile(taskOwner(payload), { taskId: payload?.taskId, requestKey: payload?.requestKey })),
|
|
879
917
|
} : {}),
|
|
880
918
|
// An unconfirmed receipt fail-closes its Bot until an owner resolves it:
|
|
881
919
|
// attest the vendor-confirmed amount or waive it, with a required note.
|
|
@@ -906,6 +944,7 @@ export function startBotProduct(ctx, options = {}) {
|
|
|
906
944
|
await routineExecution?.close?.()
|
|
907
945
|
await machines?.closeDesktopSessions?.()
|
|
908
946
|
for(const cleanup of [()=>product?.dispose(),()=>releaseMachineSecrets?.(),()=>releaseTaskTools?.(),()=>machines?.close(),()=>botSettings?.close(),()=>fundingAccounts?.close(),()=>modelAccounting?.close(),()=>routingSettings?.close()])try{cleanup()}catch{}
|
|
947
|
+
if(taskRunnerWatchdog)clearInterval(taskRunnerWatchdog)
|
|
909
948
|
if(taskService)await taskService.close().catch(()=>{})
|
|
910
949
|
else journal?.close()
|
|
911
950
|
turnBackends?.close()
|
|
@@ -936,6 +975,7 @@ export function startBotProduct(ctx, options = {}) {
|
|
|
936
975
|
await routineExecution?.close?.()
|
|
937
976
|
await machines?.closeDesktopSessions?.()
|
|
938
977
|
for(const cleanup of [()=>product?.dispose(),()=>releaseMachineSecrets?.(),()=>releaseTaskTools?.(),()=>machines?.close(),()=>botSettings?.close(),()=>fundingAccounts?.close(),()=>modelAccounting?.close(),()=>routingSettings?.close()])try{cleanup()}catch{}
|
|
978
|
+
if(taskRunnerWatchdog)clearInterval(taskRunnerWatchdog)
|
|
939
979
|
if(taskService)await taskService.close().catch(()=>{})
|
|
940
980
|
else journal?.close()
|
|
941
981
|
turnBackends?.close()
|
|
@@ -54,7 +54,31 @@ export async function openSqliteRouterState({Database,file,legacyFile,now=Date.n
|
|
|
54
54
|
}
|
|
55
55
|
try{const result=work();db.exec('COMMIT');return result}catch(e){db.exec('ROLLBACK');throw e}
|
|
56
56
|
}
|
|
57
|
-
|
|
57
|
+
// Rewrite only what changed. Every submit used to delete all rows and insert
|
|
58
|
+
// them again: with MAX_RUNS at 200 and synchronous=FULL that is 200 fsyncs of
|
|
59
|
+
// unchanged data on the hot path, which is also how a long write lock turns
|
|
60
|
+
// into a SQLITE_BUSY for the other Host.
|
|
61
|
+
const writeRuns=(runs,budgetReservations)=>{
|
|
62
|
+
const before=new Map(db.prepare('SELECT id,payload FROM router_runs').all().map(row=>[row.id,row.payload]))
|
|
63
|
+
const after=new Map(runs.map(run=>[run.id,serialize(run)]))
|
|
64
|
+
for(const [id,payload] of after)if(before.get(id)!==payload)db.prepare('INSERT INTO router_runs VALUES(?,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload').run(id,payload)
|
|
65
|
+
for(const id of before.keys())if(!after.has(id))db.prepare('DELETE FROM router_runs WHERE id=?').run(id)
|
|
66
|
+
const heldBefore=new Map(db.prepare('SELECT id,payload FROM router_holds').all().map(row=>[row.id,row.payload]))
|
|
67
|
+
const heldAfter=new Map(Object.entries(budgetReservations).map(([id,hold])=>[id,serialize(hold)]))
|
|
68
|
+
for(const [id,payload] of heldAfter){
|
|
69
|
+
const existing=heldBefore.get(id)
|
|
70
|
+
if(existing===payload)continue
|
|
71
|
+
if(existing===undefined)db.prepare('INSERT INTO router_holds VALUES(?,?,?)').run(id,budgetReservations[id].runId,payload)
|
|
72
|
+
else db.prepare('UPDATE router_holds SET payload=? WHERE id=?').run(payload,id)
|
|
73
|
+
}
|
|
74
|
+
for(const id of heldBefore.keys())if(!heldAfter.has(id))db.prepare('DELETE FROM router_holds WHERE id=?').run(id)
|
|
75
|
+
}
|
|
76
|
+
const writeFull=(runs,budgetReservations)=>{
|
|
77
|
+
db.prepare('DELETE FROM router_holds').run();db.prepare('DELETE FROM router_runs').run()
|
|
78
|
+
for(const run of runs)db.prepare('INSERT INTO router_runs VALUES(?,?)').run(run.id,serialize(run))
|
|
79
|
+
for(const [id,hold] of Object.entries(budgetReservations))db.prepare('INSERT INTO router_holds VALUES(?,?,?)').run(id,hold.runId,serialize(hold))
|
|
80
|
+
}
|
|
81
|
+
const write=(value,revision,fundingEpoch=epoch,{fullWrite=false}={})=>{
|
|
58
82
|
// Capture each run before MAX_RUNS retention folds it into the global archive.
|
|
59
83
|
// These are projections, never a second monetary writer.
|
|
60
84
|
funding.project(value,fundingEpoch)
|
|
@@ -62,9 +86,8 @@ export async function openSqliteRouterState({Database,file,legacyFile,now=Date.n
|
|
|
62
86
|
const serialized=serialize(state)
|
|
63
87
|
if(new Set(runs.map(run=>run.id)).size!==runs.length)fail('ROUTER_DUPLICATE_RUN_ID')
|
|
64
88
|
if(Object.entries(budgetReservations).some(([id,hold])=>id!==hold.runId||!runs.some(run=>run.id===hold.runId)))fail('ROUTER_ORPHAN_RESERVATION')
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
for(const [id,hold] of Object.entries(budgetReservations))db.prepare('INSERT INTO router_holds VALUES(?,?,?)').run(id,hold.runId,serialize(hold))
|
|
89
|
+
if(fullWrite)writeFull(runs,budgetReservations)
|
|
90
|
+
else writeRuns(runs,budgetReservations)
|
|
68
91
|
db.prepare('INSERT INTO router_meta VALUES(1,2,?,?) ON CONFLICT(singleton) DO UPDATE SET revision=excluded.revision,payload=excluded.payload').run(revision,serialize(meta))
|
|
69
92
|
db.prepare('INSERT INTO router_commits VALUES(?,?,?)').run(revision,now(),hash(serialized))
|
|
70
93
|
}
|
|
@@ -136,7 +159,9 @@ export async function openSqliteRouterState({Database,file,legacyFile,now=Date.n
|
|
|
136
159
|
funding.identity({id:receipt.runId,modelCall:{botId:receipt.botId,...(receipt.fundingScopeId?{fundingScopeId:receipt.fundingScopeId}:{}),...(receipt.fundingProjectId?{fundingProjectId:receipt.fundingProjectId}:{})}},epoch)
|
|
137
160
|
db.prepare('INSERT INTO router_model_calls VALUES(?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload').run(receipt.id,receipt.botId,receipt.taskId,receipt.runId,identity,serialize(receipt))
|
|
138
161
|
}
|
|
139
|
-
|
|
162
|
+
// `fullWrite` is the diagnostic escape hatch: it forces the old delete-and-insert
|
|
163
|
+
// path, which the incremental writer is compared against in tests.
|
|
164
|
+
const update=(change,{retrySafe=false,modelReceipt,fullWrite=false}={})=>{
|
|
140
165
|
const admission=currentTaskAdmission()
|
|
141
166
|
if(modelReceipt)modelReceipt=normalizeModelReceipt(modelReceipt)
|
|
142
167
|
const next=chain.then(async()=>{
|
|
@@ -151,7 +176,7 @@ export async function openSqliteRouterState({Database,file,legacyFile,now=Date.n
|
|
|
151
176
|
if(modelReceipt&&result?.accepted!==false)applyReceipt(modelReceipt)
|
|
152
177
|
const publish=prepareTaskAdmission(admission,db,candidate,result)
|
|
153
178
|
if(publish)atomicStep('task-linked-before-money')
|
|
154
|
-
write(candidate,row.revision+1)
|
|
179
|
+
write(candidate,row.revision+1,undefined,{fullWrite})
|
|
155
180
|
if(publish)atomicStep('money-written-before-commit')
|
|
156
181
|
return publish
|
|
157
182
|
})
|
|
@@ -195,9 +220,9 @@ export async function openSqliteRouterState({Database,file,legacyFile,now=Date.n
|
|
|
195
220
|
}
|
|
196
221
|
const activeAuthority=()=>{if(closed)fail('ROUTER_STORE_CLOSED');const m=migration();if(m?.phase!=='active'||m.id!==epoch)fail('ROUTER_AUTHORITY_INACTIVE')}
|
|
197
222
|
return Object.freeze({...createRouterStateAPI({file,read,update,now}),kind:'sqlite-router-state',legacyFile,rollback,modelCalls,
|
|
198
|
-
openTaskJournal(){
|
|
223
|
+
openTaskJournal({leaseClock='app',writeDeadlineMs=10_000}={}){
|
|
199
224
|
activeAuthority()
|
|
200
|
-
const journal=openTaskJournal(Database,file,{database:db,financialEpoch:()=>{activeAuthority();return epoch},onClose:()=>journals.delete(journal)})
|
|
225
|
+
const journal=openTaskJournal(Database,file,{database:db,leaseClock,writeDeadlineMs,financialEpoch:()=>{activeAuthority();return epoch},onClose:()=>journals.delete(journal)})
|
|
201
226
|
journals.add(journal);return journal
|
|
202
227
|
},
|
|
203
228
|
async modelCall(botId,id){await chain;await checkMarker();const row=db.prepare('SELECT payload FROM router_model_calls WHERE bot_id=? AND id=?').get(botId,id);return row?JSON.parse(row.payload):null},
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which task backends owe a stop proof, and which evidence each accepts.
|
|
3
|
+
*
|
|
4
|
+
* One truth for two readers: the journal decides whether a terminal attempt may
|
|
5
|
+
* keep a terminal state (`needsStopProof`), and the service refuses to register
|
|
6
|
+
* a side-effecting backend that nobody taught to prove itself. Keeping the list
|
|
7
|
+
* in one module is what stops a new backend from silently landing in the second
|
|
8
|
+
* set — the failure that turns a failed write into a silently repeated one.
|
|
9
|
+
*
|
|
10
|
+
* `remote-command` is on the list because a container command with a side effect
|
|
11
|
+
* is exactly what must not be retried on a guess; it was missing while its
|
|
12
|
+
* driver already produced `container-supervisor` evidence, which the journal did
|
|
13
|
+
* not accept either. Both halves are fixed here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const EVIDENCE = Object.freeze({
|
|
17
|
+
'codex-read': Object.freeze(['native-tree', 'cli-no-dispatch']),
|
|
18
|
+
'bot-turn-read': Object.freeze(['bot-turn-ended', 'bot-turn-not-sent']),
|
|
19
|
+
'bot-turn-write': Object.freeze(['bot-turn-ended', 'bot-turn-not-sent']),
|
|
20
|
+
'remote-write': Object.freeze(['agent-stop-receipt', 'agent-write-completed', 'remote-dispatch-not-sent']),
|
|
21
|
+
'remote-command': Object.freeze(['container-supervisor', 'remote-dispatch-not-sent']),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const IDS = Object.freeze(Object.keys(EVIDENCE))
|
|
25
|
+
|
|
26
|
+
/** A frozen set so a caller cannot widen the requirement at run time. */
|
|
27
|
+
export const STOP_PROOF_BACKENDS = Object.freeze(new Set(IDS))
|
|
28
|
+
|
|
29
|
+
/** True when this backend id must present a stop proof before a terminal state stands. */
|
|
30
|
+
export const requiresStopProof = (backend) => STOP_PROOF_BACKENDS.has(backend)
|
|
31
|
+
|
|
32
|
+
/** The evidence strings this backend may cite, or null when it owes no proof. */
|
|
33
|
+
export const stopProofEvidence = (backend) => EVIDENCE[backend] ?? null
|
|
34
|
+
|
|
35
|
+
/** True for backends whose effects can land outside this process. */
|
|
36
|
+
export const hasExternalEffect = (backend) =>
|
|
37
|
+
backend?.effect === 'workspace-write' || (backend?.location === 'remote' && backend?.effect !== 'read')
|
|
@@ -48,10 +48,20 @@ export function parseTaskSubmit(value) {
|
|
|
48
48
|
return {requestKey:v.requestKey,backend:v.backend,input:JSON.parse(canonicalTaskJson(v.input))}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const PLAN_KEYS = ['schemaVersion','taskId','origin','planRevision','packages']
|
|
51
|
+
const PLAN_KEYS = ['schemaVersion','taskId','origin','initiator','planRevision','packages']
|
|
52
52
|
const ORIGIN_KEYS = ['kind','botId','sessionId']
|
|
53
53
|
const PACKAGE_KEYS = ['id','targetBotId','objective','dependsOn','acceptance','effectClass','execution']
|
|
54
|
-
|
|
54
|
+
// Where a plan came from. `handoff` is emitted by buildHandoff(bot-handoff.mjs)
|
|
55
|
+
// and was missing here, so every Bot-to-Bot handoff died at the contract.
|
|
56
|
+
const ORIGIN_KINDS = new Set(['private','group','handoff'])
|
|
57
|
+
/**
|
|
58
|
+
* What started a run, as distinct from whose authority it carries. `origin` says
|
|
59
|
+
* which conversation a plan came from; this says whether anybody was there when
|
|
60
|
+
* it ran. A schedule or a delegation asserts a person's authority while nobody is
|
|
61
|
+
* watching, and the actor column alone can never tell those apart.
|
|
62
|
+
*/
|
|
63
|
+
export const INITIATOR_KINDS = new Set(['person','bot','routine','handoff','responsibility','memory'])
|
|
64
|
+
const INITIATOR_KEYS = ['kind','id','origin']
|
|
55
65
|
const EFFECT_CLASSES = new Set(['read','workspace-write'])
|
|
56
66
|
const PLAN_PACKAGE_LIMIT = 32
|
|
57
67
|
const PLAN_TEXT_LIMIT = 4_000
|
|
@@ -152,8 +162,10 @@ export function parseTaskPlan(value) {
|
|
|
152
162
|
if (!Array.isArray(item.dependsOn) || item.dependsOn.length > PLAN_DEPENDENCY_LIMIT) throw new PolicyError('INVALID_TASK_PLAN')
|
|
153
163
|
for (const dependency of item.dependsOn) taskRef(dependency)
|
|
154
164
|
}
|
|
165
|
+
const initiator = planInitiator(plan)
|
|
155
166
|
return { schemaVersion: 1, taskId: plan.taskId,
|
|
156
167
|
origin: { kind: origin.kind, botId: origin.botId, sessionId: origin.sessionId ?? null },
|
|
168
|
+
initiator,
|
|
157
169
|
planRevision: plan.planRevision,
|
|
158
170
|
packages: plan.packages.map(item => ({ id: item.id, targetBotId: item.targetBotId, objective: item.objective,
|
|
159
171
|
dependsOn: [...item.dependsOn], acceptance: [...item.acceptance], effectClass: item.effectClass,...(item.execution===undefined?{}:{execution:{backend:item.execution.backend,input:['bot-turn-read','bot-turn-write'].includes(item.execution.backend)?{task:item.execution.input.task,...(item.execution.input.machineId===undefined?{}:{machineId:item.execution.input.machineId}),...(item.execution.input.rootId===undefined?{}:{rootId:item.execution.input.rootId})}:item.execution.backend==='remote-write'?parseRemoteTaskWrite(item.execution.input):parseRemoteTaskCommand(item.execution.input)}}) })) }
|
|
@@ -207,6 +219,34 @@ export function validateTaskPlan(plan, { knownBots = null } = {}) {
|
|
|
207
219
|
if (order.length !== plan.packages.length) throw new PolicyError('TASK_PLAN_CYCLE')
|
|
208
220
|
return Object.freeze({ order: Object.freeze(order) })
|
|
209
221
|
}
|
|
222
|
+
/** Validate an explicit initiator: six kinds, an id where the kind needs one. */
|
|
223
|
+
export function parseInitiator(value) {
|
|
224
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new PolicyError('INVALID_INITIATOR')
|
|
225
|
+
if (Object.keys(value).some((key) => !INITIATOR_KEYS.includes(key))) throw new PolicyError('INVALID_INITIATOR')
|
|
226
|
+
if (!INITIATOR_KINDS.has(value.kind)) throw new PolicyError('INVALID_INITIATOR')
|
|
227
|
+
if (value.kind === 'person') {
|
|
228
|
+
if (value.id !== undefined) throw new PolicyError('INVALID_INITIATOR')
|
|
229
|
+
return {kind: 'person'}
|
|
230
|
+
}
|
|
231
|
+
taskRef(value.id)
|
|
232
|
+
if (value.origin !== undefined) taskRef(value.origin)
|
|
233
|
+
// `origin` is the root of the chain, never the previous hop: a hop that names
|
|
234
|
+
// its sender as the origin makes a deep chain unattributable.
|
|
235
|
+
return {kind: value.kind, id: value.id, ...(value.origin ? {origin: value.origin} : {})}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The initiator a plan implies when it does not carry one. A person's own
|
|
240
|
+
* conversation is the only kind a plan can be assumed to have; a delegation
|
|
241
|
+
* knows its sender, so it names the origin Bot rather than a person.
|
|
242
|
+
*/
|
|
243
|
+
export function planInitiator(plan) {
|
|
244
|
+
if (plan?.initiator) return parseInitiator(plan.initiator)
|
|
245
|
+
const origin = plan?.origin
|
|
246
|
+
if (origin?.kind === 'handoff') return {kind: 'handoff', id: taskRef(origin.botId), origin: taskRef(origin.botId)}
|
|
247
|
+
return {kind: 'person'}
|
|
248
|
+
}
|
|
249
|
+
|
|
210
250
|
export function parseTaskReference(value) {
|
|
211
251
|
const v = closed(value,['taskId'])
|
|
212
252
|
return {taskId:taskRef(v.taskId)}
|