diff --git a/README.md b/README.md index 2d1893c..bccecc0 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ LongHorizon-Harness turns existing agents into long-running computer-use systems ## ✨ News +- **[v0.1.7 · 2026-08-20]** A finished run is no longer a dead end: the workbench is now a conversation. Read the reply, type a follow-up, and the run continues on its own round ledger instead of replanning from scratch. A message you send mid-round is claimed by the very next round, so stopping and continuing never drops it. Also adds `--reasoning-effort` for every role (with `--manager-reasoning-effort` and friends to override one), forwarded to whichever backend exposes it. The transcript now reads in strict chronological order, and a graceful stop escalates to a force stop only when a worker ignores it. - **[v0.1.6 · 2026-08-15]** Added [OpenCode](https://github.com/anomalyco/opencode) CLI support. LongHorizon-Harness can now run `opencode run prompt` as `--agent opencode`, with role-scoped read/write permissions, OpenCode API endpoint overrides, normalized JSON results, and CLI/config/doctor integration. The Web workbench can select OpenCode Harness and its model independently for each role. - **[v0.1.5 · 2026-08-14]** Added phase-1 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) CLI support. LongHorizon-Harness can now run `dsh --profile headless` as `--agent deepseek_harness`, with an isolated `DSH_HOME`, role-scoped read/write permissions, DeepSeek API endpoint overrides, normalized JSONL results, and CLI/config/doctor integration. The Web workbench can select DeepSeek Harness and its model independently for each role. GUI computer-use and MCP support will follow in a later phase; see [the CLI setup](#5-or-run-a-task-from-the-command-line). - **[v0.1.4 · 2026-08-11]** The new Dashboard has landed: a React/FastAPI workbench you can drive entirely from the browser. Start a task, choose a backend and model per role, answer approvals, send an instruction mid-run, and stop or restart a run. Launch it with `lh-harness web`; see [Run a task in the browser](#4-run-a-task-in-the-browser-recommended). @@ -236,7 +237,7 @@ This creates `./.lh-harness/config.toml` without replacing an existing file; use lh-harness web --workspace-root . ``` -This opens the workbench at `http://127.0.0.1:8799/`. Everything happens there: start a task, pick a backend and model per role, answer approval requests, send an instruction mid-run, and stop or restart a run. `--workspace-root` sets the default working directory for tasks created there; the remaining options are listed under [Dashboard commands](#dashboard-commands). +This opens the workbench at `http://127.0.0.1:8799/`. Everything happens there: start a task, pick a backend and model per role, answer approval requests, send an instruction mid-run, stop or restart a run, and keep asking follow-up questions after it finishes — a follow-up continues the same run from the rounds it already completed. `--workspace-root` sets the default working directory for tasks created there; the remaining options are listed under [Dashboard commands](#dashboard-commands). #### 5. Or run a task from the command line @@ -327,6 +328,7 @@ Task text, run IDs, and API keys are deliberately **not** configurable here; the |---|---|---| | `agent` | `"codex"` | Backend for every role unless a role overrides it: `codex`, `claude_code`, `opencode`, or `deepseek_harness`. | | `model` | `"gpt-5.6-sol"` | Model for every role unless a role overrides it. Must be a model the chosen backend exposes. | +| `reasoning_effort` | commented out | Reasoning depth for every role unless a role overrides it, forwarded to whichever backend exposes it. Unset keeps the provider's own setting. | | `env` | `"local"` | Execution environment. Only `local` today. | | `runs_root` | `"./.lh-harness/runs"` | Where run directories are created. Each run gets `//`. | | `workspace` | commented out | Working directory the agents operate in. Defaults to the directory `lh-harness` was started from, so a task acts on your real project; set it to isolate the run somewhere else. | @@ -354,13 +356,13 @@ Per-episode limits in seconds. One episode is a single role invocation, not the ##### `[run.roles.*]` -Each role can take its own `agent` and `model`, so you can pay for a strong model only where it matters: a capable Manager and Auditor with a cheaper Executor, for example. Every field is commented out by default, meaning "inherit". +Each role can take its own `agent`, `model`, and `reasoning_effort`, so you can pay for a strong model only where it matters: a capable Manager and Auditor with a cheaper Executor, for example. Every field is commented out by default, meaning "inherit". Resolution walks the chain until it finds a value: ``` -gui_executor → executor → [run].agent / [run].model -cli_auditor → auditor → [run].agent / [run].model +gui_executor → executor → [run].agent / [run].model / [run].reasoning_effort +cli_auditor → auditor → [run].agent / [run].model / [run].reasoning_effort ``` | Section | Falls back to | Covers | diff --git a/README.zh-CN.md b/README.zh-CN.md index f737c8c..549e9f9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -36,6 +36,7 @@ LongHorizon-Harness 将现有 Agent 变成可长期运行的 computer-use 系统 ## ✨ News +- **[v0.1.7 · 2026-08-20]** 任务跑完不再是终点,工作台变成了一场对话:看完回复直接追问,任务会沿用自己已完成的轮次继续跑,而不是从头重新规划。运行中发出的消息会被下一轮立即取用,先停止再继续也不会漏掉。同时新增 `--reasoning-effort` 统一设置各角色的推理强度(也可用 `--manager-reasoning-effort` 等单独覆盖),并转发给支持该能力的后端。对话现在严格按时间顺序展示,而强制中止只在 worker 忽略正常停止时才会出现。 - **[v0.1.6 · 2026-08-15]** 新增 [OpenCode](https://github.com/anomalyco/opencode) CLI 支持。LongHorizon-Harness 现在可以通过 `--agent opencode` 调用 `opencode run prompt`,并支持按角色划分的读写权限、OpenCode API 端点覆盖、标准化 JSON 结果,以及 CLI/config/doctor 集成。Web 工作台可以为每个角色单独选择 OpenCode Harness 及其模型。 - **[v0.1.5 · 2026-08-14]** 第一阶段已适配 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) CLI。LongHorizon-Harness 现在可以通过 `--agent deepseek_harness` 调用 `dsh --profile headless`,并提供隔离的 `DSH_HOME`、按角色划分的读写权限、DeepSeek API 端点覆盖、标准化 JSONL 结果以及 CLI/config/doctor 接入。Web 工作台支持为每个角色分别选择 DeepSeek Harness 及其模型;GUI computer-use 和 MCP 支持将在后续阶段补充。使用方式见 [CLI 配置说明](#5-也可以用命令行运行任务)。 - **[v0.1.4 · 2026-08-11]** 新版 Dashboard 已上线:基于 React/FastAPI 的工作台,全部操作都能在浏览器里完成——发起任务、为每个角色分别选择后端和模型、处理审批、运行中追加指令、停止或重启任务。用 `lh-harness web` 启动,见[在网页上运行任务](#4-在网页上运行任务推荐)。 @@ -237,7 +238,7 @@ lh-harness init lh-harness web --workspace-root . ``` -该命令在 `http://127.0.0.1:8799/` 打开工作台,所有操作都在这里完成:发起任务、为每个角色分别选择后端和模型、处理审批请求、在运行中追加指令、停止或重启任务。`--workspace-root` 指定在工作台中创建任务时的默认工作目录,其余参数见 [Dashboard 命令](#dashboard-命令)。 +该命令在 `http://127.0.0.1:8799/` 打开工作台,所有操作都在这里完成:发起任务、为每个角色分别选择后端和模型、处理审批请求、在运行中追加指令、停止或重启任务,以及在任务结束后继续追问——追问会沿用它已完成的轮次继续同一个任务。`--workspace-root` 指定在工作台中创建任务时的默认工作目录,其余参数见 [Dashboard 命令](#dashboard-命令)。 #### 5. 也可以用命令行运行任务 @@ -328,6 +329,7 @@ lh-harness check-update |---|---|---| | `agent` | `"codex"` | 所有角色使用的后端(角色可单独覆盖):`codex`、`claude_code`、`opencode` 或 `deepseek_harness`。 | | `model` | `"gpt-5.6-sol"` | 所有角色使用的模型(角色可单独覆盖)。必须是所选后端支持的模型。 | +| `reasoning_effort` | 默认注释 | 所有角色的推理强度(角色可单独覆盖),转发给支持该能力的后端。不设置则沿用服务方自身的默认值。 | | `env` | `"local"` | 执行环境,目前只有 `local`。 | | `runs_root` | `"./.lh-harness/runs"` | 运行目录的根路径,每次运行生成 `//`。 | | `workspace` | 默认注释 | Agent 实际操作的工作目录。默认就是启动 `lh-harness` 的那个目录,任务直接作用于你的真实项目;需要隔离到别处时才设置。 | @@ -355,13 +357,13 @@ lh-harness check-update ##### `[run.roles.*]` -每个角色都可以单独指定 `agent` 与 `model`,因此可以只在关键位置使用强模型:例如 Manager 与 Auditor 用强模型、Executor 用更便宜的。所有字段默认都是注释状态,表示「继承」。 +每个角色都可以单独指定 `agent`、`model` 与 `reasoning_effort`,因此可以只在关键位置使用强模型:例如 Manager 与 Auditor 用强模型、Executor 用更便宜的。所有字段默认都是注释状态,表示「继承」。 取值时沿以下链路回退,直到找到值为止: ``` -gui_executor → executor → [run].agent / [run].model -cli_auditor → auditor → [run].agent / [run].model +gui_executor → executor → [run].agent / [run].model / [run].reasoning_effort +cli_auditor → auditor → [run].agent / [run].model / [run].reasoning_effort ``` | 配置段 | 回退到 | 作用范围 | diff --git a/frontend/core/src/commands.ts b/frontend/core/src/commands.ts index 9b124c5..5e95d7b 100644 --- a/frontend/core/src/commands.ts +++ b/frontend/core/src/commands.ts @@ -1,5 +1,7 @@ import { MAX_ROUNDS, type Snapshot } from './types'; +const STOPPING_STATUSES = new Set(['stopping', 'aborting', 'stop_requested', 'abort_requested']); + export type CommandName = | 'help' | 'runs' @@ -33,7 +35,7 @@ export const COMMAND_CATALOG: readonly CommandDefinition[] = [ { name: 'approve', args: ' ', description: 'Resolve an approval', capability: 'approvals', requiresRun: true }, { name: 'stop', description: 'Request a graceful stop', capability: 'stop', requiresRun: true }, { name: 'abort', description: 'Abort the active run', capability: 'abort', requiresRun: true }, - { name: 'resume', description: 'Resume a finished run', capability: 'resume', requiresRun: true }, + { name: 'resume', description: 'Continue a stopped run from its recorded rounds', capability: 'resume', requiresRun: true }, { name: 'details', description: 'Open run details', requiresRun: true }, { name: 'events', description: 'Open the event panel', requiresRun: true }, { name: 'artifacts', description: 'Open the artifact panel', requiresRun: true }, @@ -53,10 +55,19 @@ export interface NewRunOptions { workspace?: string; maxRounds?: number; promptLanguage?: 'en' | 'zh'; - roles?: Partial>; + roles?: Partial>; error?: string; } +/** + * Effort values reach the backends as inline TOML (Codex) or argv (the others), + * so the characters that could terminate a TOML string are rejected here as + * well as server-side. An allow-list of tiers would be wrong: the set differs + * per backend and per model, and operators must be able to pass a value a newer + * backend adds. + */ +const EFFORT_RE = /^[A-Za-z0-9._:-]{1,64}$/u; + export function normaliseMaxRounds(value: string | number | null | undefined, fallback = 25): number { const safeFallback = Math.min(MAX_ROUNDS, Math.max(1, Number.isSafeInteger(fallback) ? fallback : 25)); const text = String(value ?? '').trim(); @@ -101,22 +112,32 @@ export function parseNewRunArgs(args: readonly string[]): NewRunOptions { if (parseFlags) { let handledValueFlag = false; for (const flag of [ - '--agent', '--model', '--workspace', - '--manager-agent', '--manager-model', - '--executor-agent', '--executor-model', - '--auditor-agent', '--auditor-model', + '--agent', '--model', '--workspace', '--effort', + '--manager-agent', '--manager-model', '--manager-effort', + '--executor-agent', '--executor-model', '--executor-effort', + '--auditor-agent', '--auditor-model', '--auditor-effort', ] as const) { const parsed = valueFor(token, flag, args[index + 1]); if (parsed.error) return { task: task.join(' ').trim(), error: parsed.error }; if (parsed.value !== undefined) { + if (flag === '--effort' || flag.endsWith('-effort')) { + if (!EFFORT_RE.test(parsed.value)) { + return { task: task.join(' ').trim(), error: `${flag} 只能包含字母、数字、'.'、'_'、':' 或 '-',且不超过 64 个字符` }; + } + } if (flag === '--agent') agent = parsed.value; else if (flag === '--model') model = parsed.value; else if (flag === '--workspace') workspace = parsed.value; - else { - const match = /^--(manager|executor|auditor)-(agent|model)$/u.exec(flag); + else if (flag === '--effort') { + // A global --effort applies to every role, matching --agent/--model. + for (const role of ['manager', 'executor', 'auditor'] as const) { + roles[role] = { ...(roles[role] || {}), reasoning_effort: parsed.value }; + } + } else { + const match = /^--(manager|executor|auditor)-(agent|model|effort)$/u.exec(flag); if (match) { const role = match[1] as 'manager' | 'executor' | 'auditor'; - const field = match[2] as 'agent' | 'model'; + const field = match[2] === 'effort' ? 'reasoning_effort' : (match[2] as 'agent' | 'model'); roles[role] = { ...(roles[role] || {}), [field]: parsed.value }; } } @@ -194,7 +215,10 @@ export function availableCommands( if (command.capability && capabilities[command.capability] === false) return false; if (command.name === 'inject' && snapshot && !snapshot.controls.can_inject) return false; if (command.name === 'stop' && snapshot && !snapshot.controls.can_abort) return false; - if (command.name === 'abort' && snapshot && !snapshot.controls.can_abort) return false; + // `/abort` stays typable while stopping: escalating an ignored SIGTERM is + // the only lifecycle transition left, and `can_abort` is false by then. + if (command.name === 'abort' && snapshot && !snapshot.controls.can_abort + && !STOPPING_STATUSES.has(String(snapshot.run.status || '').trim().toLowerCase())) return false; if (command.name === 'resume' && snapshot && !snapshot.controls.can_resume) return false; return true; }); diff --git a/frontend/core/src/runFeed.ts b/frontend/core/src/runFeed.ts index efe1de3..c422573 100644 --- a/frontend/core/src/runFeed.ts +++ b/frontend/core/src/runFeed.ts @@ -69,12 +69,21 @@ function terminal(status: string): boolean { return ['completed', 'complete', 'success', 'succeeded', 'done', 'finished', 'failed', 'failure', 'blocked', 'incomplete', 'cancelled', 'canceled', 'stopped', 'aborted'].includes(String(status || '').trim().toLowerCase()); } +/** Resume generation of a snapshot; 0 for a run that was never resumed. */ +function epochOf(snapshot: Snapshot): number { + const value = snapshot.run?.resume_epoch; + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; +} + function lifecycleRank(status: unknown): number { const normalized = String(status || '').trim().toLowerCase(); - if (terminal(normalized)) return 5; - if (['stopping', 'aborting', 'stop_requested', 'abort_requested'].includes(normalized)) return 4; - if (['waiting_approval', 'waiting', 'blocked_waiting'].includes(normalized)) return 3; - if (['running', 'active', 'executing'].includes(normalized)) return 2; + if (terminal(normalized)) return 4; + if (['stopping', 'aborting', 'stop_requested', 'abort_requested'].includes(normalized)) return 3; + // `waiting_approval` and `running` are the same phase seen at two moments: a + // run toggles between them every gate. Ranking the gate higher made the + // return to `running` look stale, freezing the UI on the answered approval + // until a reload. + if (['running', 'active', 'executing', 'waiting_approval', 'waiting', 'blocked_waiting'].includes(normalized)) return 2; if (['starting', 'creating'].includes(normalized)) return 1; return 0; } @@ -152,6 +161,13 @@ function mergeApprovals(current: Snapshot['approvals'], incoming: Snapshot['appr if (!meaningful(item[key]) && meaningful(previous[key])) merged[key] = previous[key]; } if (!meaningful(item.resolved_at) && meaningful(previous.resolved_at)) merged.resolved_at = previous.resolved_at; + // A resolved record carries the operator's grant; a later pending frame + // (or an older server that omits the field) must not erase it. 0 is the + // "not chosen" sentinel here, so it counts as absent. + if (!item.extra_rounds && previous.extra_rounds) merged.extra_rounds = previous.extra_rounds; + if (item.allow_extra_rounds === undefined && previous.allow_extra_rounds !== undefined) { + merged.allow_extra_rounds = previous.allow_extra_rounds; + } if (previous.status === 'resolved' && item.status === 'pending') merged.status = previous.status; merged.options = item.options?.length ? item.options : previous.options; merged.answers = item.answers?.length ? item.answers : previous.answers; @@ -189,8 +205,14 @@ function withDurableInteractions(preferred: Snapshot, other: Snapshot): Snapshot * durable role output, completion state, or approval decisions. */ function mergeEqualCursorSnapshot(current: Snapshot, incoming: Snapshot): Snapshot { const run = { ...incoming.run }; - if (lifecycleRank(current.run.status) > lifecycleRank(incoming.run.status)) run.status = current.run.status; - for (const key of ['started_at', 'finished_at', 'completion_satisfied', 'completion_authority', 'report_status', 'exit_code', 'failure_reason', 'final_response', 'agent', 'model', 'workspace', 'max_rounds', 'prompt_language'] as const) { + // A new generation supersedes the previous one outright: keeping its rank or + // back-filling its outcome would leave a resumed run looking finished. + const resumed = epochOf(incoming) > epochOf(current); + if (!resumed && lifecycleRank(current.run.status) > lifecycleRank(incoming.run.status)) run.status = current.run.status; + const carried = resumed + ? (['agent', 'model', 'workspace', 'max_rounds', 'prompt_language'] as const) + : (['started_at', 'finished_at', 'completion_satisfied', 'completion_authority', 'report_status', 'exit_code', 'failure_reason', 'final_response', 'agent', 'model', 'workspace', 'max_rounds', 'prompt_language'] as const); + for (const key of carried) { if (!meaningful(incoming.run[key]) && meaningful(current.run[key])) (run as Record)[key] = current.run[key]; } const roundsByIndex = new Map(current.rounds.map((round) => [round.round_index, round])); @@ -258,6 +280,17 @@ function cursorRelation(current: Snapshot, incoming: Snapshot): -1 | 0 | 1 | nul /** Keep durable state monotonic when REST and WS snapshots race. */ function preferSnapshot(current: Snapshot | null, incoming: Snapshot): Snapshot { if (!current) return incoming; + // A resume reopens a terminal run, so it is the one case where a non-terminal + // frame legitimately follows a terminal one. Every guard below assumes the + // opposite, which left a resumed run showing its old ended state until the + // page was reloaded. A higher generation is newer by definition. + const currentEpoch = epochOf(current); + const incomingEpoch = epochOf(incoming); + if (incomingEpoch !== currentEpoch) { + return incomingEpoch > currentEpoch + ? withDurableInteractions(incoming, current) + : withDurableInteractions(current, incoming); + } // Lifecycle terminality is stronger than event timestamps. A report/status // overlay can arrive without a new event (or with a zero timestamp), and a // timestamp-only comparison would otherwise keep a stale "running" rail diff --git a/frontend/core/src/runView.ts b/frontend/core/src/runView.ts index 58e4067..d058232 100644 --- a/frontend/core/src/runView.ts +++ b/frontend/core/src/runView.ts @@ -49,6 +49,67 @@ export function roundSummary(round: RoundView): string { return parts.join(' · ') || (round.in_progress ? 'In progress' : 'Recorded'); } +export type TranscriptKind = 'plan' | 'assistant' | 'verification' | 'live' | 'user' | 'final'; + +export interface TranscriptOrder { + kind: TranscriptKind; + round?: number; + sortRound?: number; + sortTime?: number; +} + +/** Order inside one round for entries that carry no usable timestamp. */ +const TRANSCRIPT_STAGE_RANK: Record = { + plan: 1, assistant: 2, verification: 3, live: 4, user: 5, final: 6, +}; + +/** + * Order a transcript strictly by time, the way a chat log reads. + * + * A timestamped entry is placed by its own time, so a reply the operator sent + * after an answer can never be shown above it. Entries without a time keep the + * durable round/stage order and are anchored next to their round's timeline. + */ +export function sortTranscript(items: readonly T[]): T[] { + const timeline = new Map(); + for (const item of items) { + const round = item.round; + const time = item.sortTime; + if (round === undefined || !time) continue; + // Anchor untimed entries to the earliest time seen in their round so they + // stay adjacent to it instead of collapsing to the top of the transcript. + const earliest = timeline.get(round); + if (earliest === undefined || time < earliest) timeline.set(round, time); + } + const effectiveTime = (item: TranscriptOrder): number => { + // Pinned entries win over their own timestamp: a resumed run restamps + // `started_at` to the reopen time, which would drag the original request to + // the bottom of the transcript. + if (item.sortRound !== undefined) { + if (item.sortRound < 0) return Number.NEGATIVE_INFINITY; + if (item.sortRound === Number.MAX_SAFE_INTEGER) return Number.POSITIVE_INFINITY; + } + if (item.sortTime) return item.sortTime; + const round = item.sortRound ?? item.round; + return round === undefined ? Number.NEGATIVE_INFINITY : timeline.get(round) ?? Number.NEGATIVE_INFINITY; + }; + return items + .map((item, index) => ({ item, index })) + .sort((left, right) => { + const leftTime = effectiveTime(left.item); + const rightTime = effectiveTime(right.item); + if (leftTime !== rightTime) return leftTime - rightTime; + const leftRound = left.item.sortRound ?? left.item.round ?? 0; + const rightRound = right.item.sortRound ?? right.item.round ?? 0; + if (leftRound !== rightRound) return leftRound - rightRound; + const leftRank = TRANSCRIPT_STAGE_RANK[left.item.kind]; + const rightRank = TRANSCRIPT_STAGE_RANK[right.item.kind]; + if (leftRank !== rightRank) return leftRank - rightRank; + return left.index - right.index; + }) + .map((entry) => entry.item); +} + /** * Prefer the Manager's persisted plan over its internal route token. * diff --git a/frontend/core/src/statusView.ts b/frontend/core/src/statusView.ts index 2c92b61..1d2b9ac 100644 --- a/frontend/core/src/statusView.ts +++ b/frontend/core/src/statusView.ts @@ -87,6 +87,8 @@ export interface StatusView { nextStepDetail: string; approvals: StatusApproval[]; pendingApprovals: StatusApproval[]; + /** A decision is queued but the worker has not yet picked it up. */ + awaitingHandoff: boolean; warnings: string[]; notices: string[]; progress: { @@ -542,6 +544,13 @@ export function projectStatus(snapshot: Snapshot, options: StatusViewOptions = { // keep the rule in the shared projection instead of duplicating it in each // renderer. const pendingApprovals = approvals.filter((item) => item.pending && !isTerminalRun(snapshot.run.status) && !isStoppingRun(snapshot.run.status)); + // A resolved decision only reaches the worker through the command bus, so the + // lifecycle keeps reporting `waiting_approval` for a moment after the click. + // Without this the card disappears and nothing replaces it, which reads as a + // hang until the operator reloads. + const awaitingHandoff = normalized(snapshot.run.status) === 'waiting_approval' + && pendingApprovals.length === 0 + && approvals.some((item) => !item.pending && item.action); const inconsistentCompletion = snapshot.run.completion_satisfied === true && stages.some((item) => item.key !== 'record' && !['done', 'skipped'].includes(item.status)); const primaryWarnings = [ @@ -597,6 +606,7 @@ export function projectStatus(snapshot: Snapshot, options: StatusViewOptions = { nextStepDetail: next.detail, approvals, pendingApprovals, + awaitingHandoff, warnings, notices, progress: { completed: progressDone.length, total, ratio }, diff --git a/frontend/core/src/types.ts b/frontend/core/src/types.ts index 92699bd..06e4980 100644 --- a/frontend/core/src/types.ts +++ b/frontend/core/src/types.ts @@ -31,12 +31,15 @@ export interface Approval { answers: string[]; allow_input: boolean; input_label: string; + /** Budget gates let the operator grant a specific number of extra rounds. */ + allow_extra_rounds?: boolean; context: Record; round_index: number; status: 'pending' | 'resolved' | string; action: string; reason: string; user_input: string; + extra_rounds?: number; created_at: number; resolved_at: number | null; } @@ -105,6 +108,12 @@ export interface Snapshot { workspace?: string; max_rounds?: number; prompt_language?: 'en' | 'zh'; + /** Generation counter: bumped each time a terminal run is resumed in place. */ + resume_epoch?: number; + /** Lifecycle action the operator asked for: 'stop' or 'abort'. */ + requested_action?: string; + /** When the stop signal was sent, used to detect a worker ignoring SIGTERM. */ + stop_requested_at?: number; }; mission: { task: string; contract_path: string; plan_path: string; verified_state_path: string; report_path: string }; rounds: RoundView[]; diff --git a/frontend/core/test/runFeed.test.ts b/frontend/core/test/runFeed.test.ts index af8525a..0017cdb 100644 --- a/frontend/core/test/runFeed.test.ts +++ b/frontend/core/test/runFeed.test.ts @@ -7,7 +7,7 @@ import { } from '../src/runFeed'; import type { EventEnvelope, Snapshot } from '../src/types'; import { availableCommands, commandHelp, normaliseMaxRounds, parseCommand, parseNewRunArgs } from '../src/commands'; -import { managerPlanSummary, managerPlanText } from '../src/runView'; +import { managerPlanSummary, managerPlanText, sortTranscript } from '../src/runView'; import { DEFAULT_PANEL_STATE, reducePanelState } from '../src/panels'; import { projectStatus } from '../src/statusView'; import { isTrajectoryNoise, projectTrajectoryView } from '../src/trajectoryView'; @@ -160,6 +160,89 @@ test('approval responses render optimistically and survive stale snapshots', () assert.equal(state.snapshot?.approvals[0]?.resolved_at, 15); }); +test('a resumed run replaces its own terminal state without a reload', () => { + // Every monotonic guard assumes a terminal frame is final. Resuming in place + // is the one case where it is not, so without the generation counter the UI + // kept showing the ended run (and an empty conversation) until a reload. + const ended = event('resume-ev-1', 10); + let state = reduce(createRunFeedState('run-a'), { + type: 'seed', + snapshot: { + ...snapshot('run-a', [ended]), + run: { id: 'run-a', status: 'incomplete', log_dir: '/tmp/run-a', finished_at: 20, exit_code: 1, failure_reason: 'stopped' }, + }, + }); + assert.equal(state.snapshot?.run.status, 'incomplete'); + + state = reduceRunFeed(state, { + type: 'snapshot', + snapshot: { + ...snapshot('run-a', [ended]), + run: { id: 'run-a', status: 'starting', log_dir: '/tmp/run-a', resume_epoch: 1 }, + }, + }); + assert.equal(state.snapshot?.run.status, 'starting'); + assert.equal(state.snapshot?.run.resume_epoch, 1); + assert.equal(state.snapshot?.run.finished_at, undefined, 'the previous outcome must not be carried over'); + assert.equal(state.snapshot?.run.exit_code, undefined); + assert.equal(state.snapshot?.run.failure_reason, undefined); +}); + +test('a stale frame from the previous generation is ignored', () => { + const ev = event('resume-ev-2', 10); + let state = reduce(createRunFeedState('run-a'), { + type: 'seed', + snapshot: { ...snapshot('run-a', [ev]), run: { id: 'run-a', status: 'running', log_dir: '/tmp/run-a', resume_epoch: 2 } }, + }); + + state = reduceRunFeed(state, { + type: 'snapshot', + snapshot: { ...snapshot('run-a', [ev]), run: { id: 'run-a', status: 'cancelled', log_dir: '/tmp/run-a', finished_at: 5, resume_epoch: 1 } }, + }); + + assert.equal(state.snapshot?.run.status, 'running', 'an older generation must not end the current one'); + assert.equal(state.snapshot?.run.resume_epoch, 2); +}); + +test('terminality still wins inside one generation', () => { + const ev = event('resume-ev-3', 10); + let state = reduce(createRunFeedState('run-a'), { + type: 'seed', + snapshot: { ...snapshot('run-a', [ev]), run: { id: 'run-a', status: 'completed', log_dir: '/tmp/run-a', resume_epoch: 1 } }, + }); + + state = reduceRunFeed(state, { + type: 'snapshot', + snapshot: { ...snapshot('run-a', [ev]), run: { id: 'run-a', status: 'running', log_dir: '/tmp/run-a', resume_epoch: 1 } }, + }); + + assert.equal(state.snapshot?.run.status, 'completed'); +}); + +test('keeps an operator round grant when a stale pending frame arrives', () => { + const base = { + approval_id: 'approval-rounds', title: 'Round limit reached', message: '', options: [], answers: [], + allow_input: true, input_label: '', allow_extra_rounds: true, context: { trigger: 'max_rounds' }, + round_index: 5, status: 'pending', action: '', reason: '', user_input: '', created_at: 10, resolved_at: null, + }; + const first = event('rounds-event-1', 10); + const second = event('rounds-event-2', 20); + let state = reduce(createRunFeedState('run-a'), { + type: 'seed', + snapshot: { ...snapshot('run-a', [first]), approvals: [{ ...base, status: 'resolved', action: 'continue', extra_rounds: 7, resolved_at: 15 }] }, + }); + assert.equal(state.snapshot?.approvals[0]?.extra_rounds, 7); + + // A stale frame (or an older server that omits the field) must not erase it. + state = reduceRunFeed(state, { + type: 'snapshot', + snapshot: { ...snapshot('run-a', [first, second]), approvals: [base] }, + }); + assert.equal(state.snapshot?.approvals[0]?.extra_rounds, 7); + assert.equal(state.snapshot?.approvals[0]?.allow_extra_rounds, true); + assert.equal(state.snapshot?.approvals[0]?.status, 'resolved'); +}); + test('keeps the Manager plan body when a compact route is present', () => { assert.equal( managerPlanText({ round_index: 1, next_step: 'gui', plan_text: 'Task contract:\nOpen the page and verify the screenshot.' }), @@ -190,6 +273,68 @@ Boundaries: Read only.`; assert.equal(managerPlanSummary('Question: Which account should be used?\n\nChoices: Work | Personal'), 'Which account should be used?'); }); +test('orders a transcript strictly by time', () => { + const ordered = sortTranscript([ + { kind: 'plan', round: 1, sortTime: 20, id: 'plan-1' }, + { kind: 'verification', round: 1, sortTime: 40, id: 'audit-1' }, + { kind: 'final', sortTime: 60, id: 'reply-round-1' }, + { kind: 'plan', round: 2, sortTime: 120, id: 'plan-2' }, + { kind: 'user', sortRound: -1, sortTime: 1, id: 'task' }, + // Sent after reading the round-1 answer, so it must land below it. + { kind: 'user', sortTime: 80, id: 'follow-up' }, + ] as const); + + assert.deepEqual(ordered.map((item) => item.id), [ + 'task', 'plan-1', 'audit-1', 'reply-round-1', 'follow-up', 'plan-2', + ]); +}); + +test('an answer never floats below a reply the operator sent earlier', () => { + // The regression: a closing answer written at t=60 was pinned last, so a + // follow-up typed at t=80 appeared above the text it responded to. + const ordered = sortTranscript([ + { kind: 'user', sortTime: 80, id: 'follow-up' }, + { kind: 'final', sortTime: 60, id: 'answer' }, + ] as const); + + assert.deepEqual(ordered.map((item) => item.id), ['answer', 'follow-up']); +}); + +test('untimed entries stay next to their own round', () => { + // Only the plan carries an event time; the rest of round 2 has none yet. + const ordered = sortTranscript([ + { kind: 'user', sortTime: 10, id: 'earlier-reply' }, + { kind: 'live', sortRound: Number.MAX_SAFE_INTEGER, id: 'live' }, + { kind: 'verification', round: 2, id: 'audit' }, + { kind: 'assistant', round: 2, id: 'exec' }, + { kind: 'plan', round: 2, sortTime: 50, id: 'plan' }, + ] as const); + + assert.deepEqual(ordered.map((item) => item.id), ['earlier-reply', 'plan', 'exec', 'audit', 'live']); +}); + +test('the original request stays first after a resume restamps the run', () => { + // Resuming sets `started_at` to the reopen time, so the task card's own + // timestamp is newer than every round it started. + const ordered = sortTranscript([ + { kind: 'plan', round: 1, sortTime: 100, id: 'plan' }, + { kind: 'final', sortTime: 200, id: 'answer' }, + { kind: 'user', sortRound: -1, sortTime: 9_000, id: 'task' }, + ] as const); + + assert.deepEqual(ordered.map((item) => item.id), ['task', 'plan', 'answer']); +}); + +test('a stable sort keeps equal entries in their projected order', () => { + const ordered = sortTranscript([ + { kind: 'user', round: 1, id: 'first' }, + { kind: 'user', round: 1, id: 'second' }, + { kind: 'user', round: 1, id: 'third' }, + ] as const); + + assert.deepEqual(ordered.map((item) => item.id), ['first', 'second', 'third']); +}); + test('normalizes the Web round limit without changing a typed ten to one', () => { assert.equal(normaliseMaxRounds('10'), 10); assert.equal(normaliseMaxRounds('0010'), 10); @@ -382,7 +527,10 @@ test('same cursor snapshots merge durable round output instead of regressing it' assert.equal(state.snapshot?.run.final_response, 'Durable final reply'); }); -test('same cursor lifecycle status does not fall back from waiting to running', () => { +test('a resolved gate lets the run go back to running without a reload', () => { + // A run toggles waiting_approval -> running at every gate, so this is normal + // progress, not a stale frame. Treating it as a regression froze the UI on the + // answered approval until the page was reloaded. const cursor = event('waiting-cursor', 11); let state = reduceRunFeed(createRunFeedState('run-a'), { type: 'seed', @@ -398,7 +546,26 @@ test('same cursor lifecycle status does not fall back from waiting to running', run: { ...snapshot('run-a').run, status: 'running' }, }, }); - assert.equal(state.snapshot?.run.status, 'waiting_approval'); + assert.equal(state.snapshot?.run.status, 'running'); +}); + +test('a terminal status still outranks an active one on the same cursor', () => { + const cursor = event('terminal-cursor', 12); + let state = reduceRunFeed(createRunFeedState('run-a'), { + type: 'seed', + snapshot: { + ...snapshot('run-a', [cursor]), + run: { ...snapshot('run-a').run, status: 'completed' }, + }, + }); + state = reduceRunFeed(state, { + type: 'snapshot', + snapshot: { + ...snapshot('run-a', [cursor]), + run: { ...snapshot('run-a').run, status: 'waiting_approval' }, + }, + }); + assert.equal(state.snapshot?.run.status, 'completed'); }); test('resync snapshots start a new event epoch and discard the old buffer', () => { @@ -452,6 +619,27 @@ test('parses the shared command catalog and gates only explicit capabilities', ( assert.match(commandHelp({}, snapshot('run-a'), true), /\/trajectory/); }); +test('keeps /abort typable while a stop is in flight', () => { + const base = snapshot('run-a'); + // `can_abort` is false once the run is stopping, but escalating an ignored + // SIGTERM is exactly what the operator needs at that point. + const stopping = { + ...base, + run: { ...base.run, status: 'stopping' }, + controls: { can_inject: false, can_abort: false, can_resume: false }, + }; + assert.equal(availableCommands({}, stopping, true).some((item) => item.name === 'abort'), true); + assert.equal(availableCommands({}, stopping, true).some((item) => item.name === 'stop'), false); + + const terminal = { + ...base, + run: { ...base.run, status: 'cancelled' }, + controls: { can_inject: false, can_abort: false, can_resume: true }, + }; + assert.equal(availableCommands({}, terminal, true).some((item) => item.name === 'abort'), false); + assert.equal(availableCommands({ abort: false }, stopping, true).some((item) => item.name === 'abort'), false); +}); + test('parses /new options consistently and rejects malformed flags', () => { assert.deepEqual(parseNewRunArgs([ 'inspect', 'screenshots', '--agent', 'claude_code', '--model=gpt-test', @@ -485,6 +673,38 @@ test('parses /new options consistently and rejects malformed flags', () => { assert.match(parseNewRunArgs(['task', '--language', 'fr']).error || '', /zh 或 en/); }); +test('/new applies a global reasoning effort to every role and allows per-role overrides', () => { + assert.deepEqual(parseNewRunArgs(['task', '--effort', 'high']), { + task: 'task', + roles: { + manager: { reasoning_effort: 'high' }, + executor: { reasoning_effort: 'high' }, + auditor: { reasoning_effort: 'high' }, + }, + }); + assert.deepEqual(parseNewRunArgs([ + 'task', '--effort=medium', '--manager-effort', 'ultra', + ]), { + task: 'task', + roles: { + manager: { reasoning_effort: 'ultra' }, + executor: { reasoning_effort: 'medium' }, + auditor: { reasoning_effort: 'medium' }, + }, + }); +}); + +test('/new rejects effort values that could break out of a TOML string or argv', () => { + // The value reaches Codex as inline TOML and the other backends as argv. + for (const bad of ['a"b', "a'b", 'high low', 'x'.repeat(65), '$(id)']) { + assert.match(parseNewRunArgs(['task', `--effort=${bad}`]).error || '', /只能包含/, bad); + } + // An unknown tier is not rejected: the accepted set differs per backend and + // per model, and a newer tier must not need a harness release. + assert.equal(parseNewRunArgs(['task', '--effort=ultra']).error, undefined); + assert.equal(parseNewRunArgs(['task', '--effort=a.b_c:d-1']).error, undefined); +}); + test('keeps Web panel transitions deterministic', () => { let state = DEFAULT_PANEL_STATE; state = reducePanelState(state, { type: 'open', panel: 'events' }); @@ -675,6 +895,45 @@ test('terminal lifecycle hides stale pending approvals from the actionable proje assert.equal(terminal.approvals.length, 1); assert.equal(terminal.pendingApprovals.length, 0); assert.equal(stopping.pendingApprovals.length, 0); + // A stale record carries no operator decision, so it must not be mistaken for + // a fresh answer still travelling to the worker. + assert.equal(stopping.awaitingHandoff, false); +}); + +test('a submitted decision is projected as awaiting the worker handoff', () => { + const base = snapshot('run-handoff'); + const approval = { + approval_id: 'a-1', title: 'Continue?', message: 'checkpoint', + options: [], answers: [], allow_input: false, input_label: '', context: {}, + round_index: 1, action: '', reason: '', user_input: '', + created_at: 1, + }; + // The lifecycle still reports `waiting_approval` because only the worker can + // clear it, so the answered state is the client's own optimistic record. + const answered = projectStatus({ + ...base, + run: { ...base.run, status: 'waiting_approval' }, + approvals: [{ ...approval, status: 'resolved', action: 'continue', resolved_at: 2 }], + }); + assert.equal(answered.pendingApprovals.length, 0); + assert.equal(answered.awaitingHandoff, true); + + const unanswered = projectStatus({ + ...base, + run: { ...base.run, status: 'waiting_approval' }, + approvals: [{ ...approval, status: 'pending', resolved_at: null }], + }); + assert.equal(unanswered.pendingApprovals.length, 1); + assert.equal(unanswered.awaitingHandoff, false); + + // Once the worker picks the decision up the run reports its own progress, so + // this placeholder must disappear instead of stacking with "Manager is working". + const running = projectStatus({ + ...base, + run: { ...base.run, status: 'running' }, + approvals: [{ ...approval, status: 'resolved', action: 'continue', resolved_at: 2 }], + }); + assert.equal(running.awaitingHandoff, false); }); test('terminal manager-only runs mark never-invoked roles as skipped', () => { diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index 824008b..67c6258 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useReducer, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { AlertTriangle, @@ -41,7 +41,7 @@ import { type LucideIcon, } from 'lucide-react'; import { MAX_ROUNDS, type ArtifactList, type EventEnvelope, type RunSummary, type Snapshot } from '../../core/src/types'; -import { availableCommands, isTrajectoryNoise, managerPlanSummary, managerPlanText, normaliseMaxRounds, projectArtifactView, projectStatus, dedupeEvents, parseCommand, parseNewRunArgs, phaseLabel, projectTrajectoryView, reducePanelState, DEFAULT_PANEL_STATE, type ArtifactProjection, type FileChangeItem, type PanelName, type StatusView, type TrajectoryItem, type ValidationResultSummary } from '../../core/src'; +import { availableCommands, isTrajectoryNoise, managerPlanSummary, managerPlanText, normaliseMaxRounds, projectArtifactView, projectStatus, dedupeEvents, parseCommand, parseNewRunArgs, phaseLabel, projectTrajectoryView, reducePanelState, sortTranscript, DEFAULT_PANEL_STATE, type ArtifactProjection, type FileChangeItem, type PanelName, type StatusView, type TrajectoryItem, type ValidationResultSummary } from '../../core/src'; import { abortRun, createRun, @@ -58,10 +58,13 @@ import { artifactRawUrl, fetchImageSource, idempotencyKey, + isConflict, isUnauthorized, setStoredAuthToken, storedAuthToken, type WebMeta, + type AgentChoice, + type ModelChoice, type RoleRuntimeConfig, type TrajectoryView, } from './api'; @@ -92,6 +95,29 @@ function useful(text?: string | null): text is string { return Boolean(text && text.trim() && !text.includes('produced no readable natural-language output')); } +/** Props that dismiss an overlay on a real backdrop click, but not on a drag. + * + * A plain `onClick` on the backdrop also fires when a text selection *starts* + * inside the dialog and ends on the backdrop, because `click` is dispatched to + * the nearest common ancestor of mousedown/mouseup. That closed the panel and + * discarded the selection mid-gesture, so require both ends on the backdrop. + * The armed flag lives in a ref because a re-render (status polling) can land + * between the two events. + */ +function useBackdropDismiss(onDismiss: () => void) { + const armed = useRef(false); + return { + onMouseDown: (event: ReactMouseEvent) => { + armed.current = event.target === event.currentTarget && event.button === 0; + }, + onMouseUp: (event: ReactMouseEvent) => { + const dismiss = armed.current && event.target === event.currentTarget; + armed.current = false; + if (dismiss) onDismiss(); + }, + }; +} + function compactText(text: string, limit = 1600): string { const value = text.trim(); return value.length > limit ? `${value.slice(0, limit).trimEnd()}…` : value; @@ -165,7 +191,7 @@ function commandHelpText(capabilities: Record, snapshot: Snapsh const descriptions: Record = { help: ['显示可用命令', 'Show available commands'], runs: ['列出任务', 'List tasks'], new: ['开始新任务', 'Start a new task'], attach: ['切换到任务', 'Switch to a task'], inject: ['追加指令', 'Queue an instruction'], approve: ['处理待确认请求', 'Resolve an approval'], stop: ['请求安全停止', 'Request a graceful stop'], abort: ['中止当前任务', 'Abort the active task'], - resume: ['重新开始已结束任务', 'Restart a finished task'], details: ['打开详情', 'Open task details'], events: ['打开事件面板', 'Open the event panel'], artifacts: ['打开运行记录', 'Open run records'], trajectory: ['打开执行轨迹', 'Open the trajectory panel'], + resume: ['从已完成的轮次继续跑', 'Continue from the rounds already finished'], details: ['打开详情', 'Open task details'], events: ['打开事件面板', 'Open the event panel'], artifacts: ['打开运行记录', 'Open run records'], trajectory: ['打开执行轨迹', 'Open the trajectory panel'], }; return availableCommands(capabilities, snapshot, hasRun).map((command) => { const description = descriptions[command.name] || [command.description, command.description]; @@ -195,6 +221,8 @@ interface ConversationMessage { time?: number; /** Internal timestamp used to interleave durable operator follow-ups. */ sortTime?: number; + /** Round slot for messages that sit between rounds rather than inside one. */ + sortRound?: number; /** Whether the text is backed by the harness completion authority. */ authority?: 'final_response' | 'auditor' | 'report' | 'none'; } @@ -224,7 +252,7 @@ const MODEL_PRESETS: Record = { }; type PublicRole = 'manager' | 'executor' | 'auditor'; -type RoleSelection = RoleRuntimeConfig & { custom: boolean }; +type RoleSelection = RoleRuntimeConfig & { custom: boolean; effortCustom?: boolean }; const PUBLIC_ROLES: Array<{ id: PublicRole; label: string; description: string }> = [ { id: 'manager', label: 'Manager', description: '规划、路由与完成判定' }, { id: 'executor', label: 'Executor', description: '执行 GUI / CLI 子任务' }, @@ -262,6 +290,40 @@ function defaultModel(meta: WebMeta | null, agent: string): string { || ''; } +function agentEntry(meta: WebMeta | null, agent: string): AgentChoice | undefined { + return meta?.agents?.find((item) => item.id === agent); +} + +/** Older servers only sent a boolean, so absence of `availability` is not "missing". */ +function agentAvailability(item: Pick): 'usable' | 'found_but_broken' | 'missing' | 'unknown' { + if (item.availability) return item.availability; + if (item.available === true) return 'usable'; + if (item.available === false) return 'missing'; + return 'unknown'; +} + +/** + * Effort choices for one role. Codex advertises them per model and the lists are + * uneven between models, so the selected model wins over the agent-wide list. + */ +function reasoningChoicesFor(meta: WebMeta | null, agent: string, model: string): { id: string; label: string; description?: string }[] { + const support = agentEntry(meta, agent)?.reasoning; + if (!support?.supported) return []; + if (support.scope === 'per_model') { + const modelId = model.trim() || defaultModel(meta, agent); + const entry = normalizedModelEntries(meta, agent).find((item) => item.id === modelId); + const details = entry?.reasoning_effort_details; + if (details?.length) return details.map((item) => ({ id: item.id, label: item.id, description: item.description })); + if (entry?.reasoning_efforts?.length) return entry.reasoning_efforts.map((id) => ({ id, label: id })); + } + return (support.choices || []).map((item) => ({ id: item.id, label: item.label || item.id })); +} + +function normalizedModelEntries(meta: WebMeta | null, agent: string): ModelChoice[] { + const raw = meta?.models?.[agent] || agentEntry(meta, agent)?.models; + return Array.isArray(raw) ? raw.filter((item): item is ModelChoice => Boolean(item) && typeof item === 'object') : []; +} + interface ImageSourceProjection { images: string[]; omittedLargeDataUrls: number; @@ -521,19 +583,46 @@ function approvalResponseText(approval: Snapshot['approvals'][number], language: return label; } -function insertConversationInteraction(messages: ConversationMessage[], message: ConversationMessage): void { - const insertion = message.sortTime - ? messages.findIndex((candidate, index) => index > 0 && Boolean(candidate.sortTime && candidate.sortTime > message.sortTime!)) - : -1; - if (insertion >= 0) messages.splice(insertion, 0, message); - else messages.push(message); +/** Shared clip length for every conversation card. */ +const CONVERSATION_TEXT_LIMIT = 900; + +/** How long a graceful stop may run before a force-kill is offered. */ +const STOP_GRACE_MS = 3_000; + +/** + * When the shown reply was written. + * + * Taken from the `completed` event of the last round that produced one. A + * discarded reply keeps its text in `run.final_response` while its round text + * is cleared, so neither the round list nor the round's latest event (which + * would be the discard) can date it. + */ +function finalResponseTime(snapshot: Snapshot): number | undefined { + const completed = snapshot.events + .filter((event) => event.type === 'round.final_response.completed') + .map((event) => event.ts) + .filter((value) => Number.isFinite(value) && value > 0); + return completed.length ? Math.max(...completed) : snapshot.run.finished_at || undefined; +} + +/** Round a free-form operator message belongs to, from the rounds' own event times. */ +function roundForTime(snapshot: Snapshot, time: number | undefined): number { + if (!time) return Number.MAX_SAFE_INTEGER; + let slot = 0; + for (const round of snapshot.rounds) { + const started = conversationStageTime(snapshot, round.round_index, 'manager'); + if (started && started <= time) slot = round.round_index; + } + return slot; } function conversationFor(snapshot: Snapshot, trajectoryMap: Record, liveTrajectory: TrajectoryView | null, artifacts: ArtifactProjection, language: UiLanguage): ConversationMessage[] { const messages: ConversationMessage[] = []; const task = snapshot.mission.task.trim(); const taskTime = snapshot.run.started_at || snapshot.events.find((event) => event.type === 'run.started')?.ts; - if (task) messages.push({ id: 'task', kind: 'user', role: 'You', title: 'You', text: task, sortTime: taskTime || undefined }); + // The original request precedes every round, including the collapsed-history + // notice, so it gets a slot below the first round rather than round 0. + if (task) messages.push({ id: 'task', kind: 'user', role: 'You', title: 'You', text: task, sortTime: taskTime || undefined, sortRound: -1 }); const hiddenRounds = Math.max(0, snapshot.rounds.length - MAX_TRAJECTORY_ROUNDS); if (hiddenRounds > 0) { @@ -541,6 +630,7 @@ function conversationFor(snapshot: Snapshot, trajectoryMap: Record left.created_at - right.created_at)) { const time = Number.isFinite(item.created_at) && item.created_at > 0 ? item.created_at : undefined; - const message: ConversationMessage = { + messages.push({ id: `operator-${item.id}`, kind: 'user', role: uiText(language, '你', 'You'), @@ -654,13 +750,13 @@ function conversationFor(snapshot: Snapshot, trajectoryMap: Record>({}); + // Kept as raw text so a half-typed value is not silently coerced to a number. + const [approvalRounds, setApprovalRounds] = useState>({}); const [capabilities, setCapabilities] = useState>({}); const [meta, setMeta] = useState(null); const [controlBusy, setControlBusy] = useState(false); + const [stopIgnored, setStopIgnored] = useState(false); const [search, setSearch] = useState(''); const [detailsOpen, setDetailsOpen] = useState(false); const [creatingNew, setCreatingNew] = useState(false); @@ -753,30 +855,40 @@ export default function App() { const canCreateRun = meta !== null && capabilities.create_run !== false; const canStopRun = capabilities.stop !== false && snapshot.controls.can_abort && !terminalLifecycle(snapshot.run.status) && !stoppingLifecycle(snapshot.run.status); - const canAbortRun = capabilities.abort !== false && snapshot.controls.can_abort - && !terminalLifecycle(snapshot.run.status) && !stoppingLifecycle(snapshot.run.status); + // Force-kill is offered only while a stop is still visibly stuck: SIGKILL + // costs the run its final report, so it must not be a same-click alternative + // to a graceful stop, and it must disappear as soon as the worker does. + const canAbortRun = capabilities.abort !== false && stopIgnored + && stoppingLifecycle(snapshot.run.status); const canResumeRun = capabilities.resume !== false && snapshot.controls.can_resume; - const composerInteractive = Boolean(runId && snapshot.controls.can_inject); + // A resumable run accepts typing too: sending reopens the run and delivers the + // text as the first instruction, so the operator does not have to continue + // first and then race the worker to type. + const composerResumes = Boolean(runId) && !snapshot.controls.can_inject && canResumeRun; + const composerInteractive = Boolean(runId && snapshot.controls.can_inject) || composerResumes; const composerCanSend = composerInteractive && Boolean(instruction.trim()) && Boolean( typedCommand ? commandOptions.some((item) => item.name === typedCommandName) : true, ); - const composerPlaceholder = composerInteractive - ? text('输入后续指令', 'Enter a follow-up instruction') - : !runId - ? text('点击左侧“新任务”开始', 'Click “New task” in the sidebar to begin') - : pendingApprovals.length > 0 - ? text('请先处理上方的待确认请求', 'Resolve the approval request above first') - : stoppingLifecycle(snapshot.run.status) - ? text('正在停止任务…', 'Stopping the task…') - : canResumeRun - ? text('任务已结束,可点击顶部“重新开始”继续', 'Task ended. Click “Restart” above to continue') + // Sending from a resumable run goes through the lifecycle call, which tracks + // `controlBusy`; without it the send button stays live and fires twice. + const composerBusy = busy || (composerResumes && controlBusy); + const composerPlaceholder = composerResumes + ? text('输入指令并发送,将带着它继续运行', 'Type an instruction and send to continue the run with it') + : composerInteractive + ? text('输入后续指令', 'Enter a follow-up instruction') + : !runId + ? text('点击左侧“新任务”开始', 'Click “New task” in the sidebar to begin') + : pendingApprovals.length > 0 + ? text('请先处理上方的待确认请求', 'Resolve the approval request above first') + : stoppingLifecycle(snapshot.run.status) + ? text('正在停止任务…', 'Stopping the task…') : terminalLifecycle(snapshot.run.status) ? text('任务已结束', 'Task ended') : text('当前阶段暂不可输入', 'Input is unavailable during the current stage'); - const composerFooter = composerInteractive - ? text('操作指令', 'Command') - : canResumeRun - ? text('可重新开始或创建新任务', 'Restart or create a new task') + const composerFooter = composerResumes + ? text('发送即继续运行', 'Sending continues the run') + : composerInteractive + ? text('操作指令', 'Command') : pendingApprovals.length > 0 ? text('等待你的确认', 'Waiting for your approval') : text('输入已停用', 'Input disabled'); @@ -996,6 +1108,20 @@ export default function App() { if (followLatest && conversationRef.current) conversationRef.current.scrollTop = conversationRef.current.scrollHeight; }, [messages, pendingApprovals.length, snapshot.run.status, followLatest]); + // Measure how long the run stays in `stopping` locally rather than comparing + // `stop_requested_at` against the browser clock, which may be skewed from the + // server's. An abort already requested needs no second escalation offer. + useEffect(() => { + const stopping = stoppingLifecycle(snapshot.run.status); + const alreadyAborting = String(snapshot.run.requested_action || '') === 'abort'; + if (!stopping || alreadyAborting) { + setStopIgnored(false); + return undefined; + } + const timer = window.setTimeout(() => setStopIgnored(true), STOP_GRACE_MS); + return () => window.clearTimeout(timer); + }, [runId, snapshot.run.status, snapshot.run.requested_action, snapshot.run.stop_requested_at]); + useEffect(() => { if (!mobileStatusOpen) return undefined; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setMobileStatusOpen(false); }; @@ -1071,7 +1197,7 @@ export default function App() { if (command.name === 'new') { const parsed = parseNewRunArgs(command.args); if (parsed.error || !parsed.task) { - setError(parsed.error ? commandErrorText(parsed.error, language) : text('用法:/new [--language zh|en] [--manager-agent id] [--manager-model id] [--executor-agent id] [--executor-model id] [--auditor-agent id] [--auditor-model id] [--workspace path] [--rounds n]', 'Usage: /new [--language zh|en] [--manager-agent id] [--manager-model id] [--executor-agent id] [--executor-model id] [--auditor-agent id] [--auditor-model id] [--workspace path] [--rounds n]')); + setError(parsed.error ? commandErrorText(parsed.error, language) : text('用法:/new [--language zh|en] [--effort level] [--manager-agent id] [--manager-model id] [--manager-effort level] [--executor-agent id] [--executor-model id] [--executor-effort level] [--auditor-agent id] [--auditor-model id] [--auditor-effort level] [--workspace path] [--rounds n]', 'Usage: /new [--language zh|en] [--effort level] [--manager-agent id] [--manager-model id] [--manager-effort level] [--executor-agent id] [--executor-model id] [--executor-effort level] [--auditor-agent id] [--auditor-model id] [--auditor-effort level] [--workspace path] [--rounds n]')); setInstruction(''); return; } @@ -1113,6 +1239,11 @@ export default function App() { } if (!runId) return; if (!snapshot.controls.can_inject) { + if (composerResumes) { + setInstruction(''); + await lifecycle('resume', inputText); + return; + } setError(text('当前任务已结束或不可控;可使用 /new、/attach、/resume 或打开详情。', 'This task has ended or cannot accept input. Use /new, /attach, /resume, or open Details.')); setInstruction(''); return; @@ -1120,32 +1251,68 @@ export default function App() { await submitOperatorInstruction(inputText); } - async function approve(approvalId: string, action: string, inputOverride?: string) { + async function approve(approvalId: string, action: string, inputOverride?: string, extraRounds?: number) { const submittedInput = inputOverride ?? (approvalInputs[approvalId] || ''); setBusy(true); setError(''); try { - await resolveApproval(runId, approvalId, action, submittedInput); + await resolveApproval(runId, approvalId, action, submittedInput, undefined, extraRounds); feed.recordApprovalResponse(approvalId, action, submittedInput.trim(), Date.now() / 1000); setApprovalInputs((current) => { const next = { ...current }; delete next[approvalId]; return next; }); + setApprovalRounds((current) => { + const next = { ...current }; + delete next[approvalId]; + return next; + }); await feed.refresh(); } catch (reason) { setError(String(reason)); } finally { setBusy(false); } } - async function lifecycle(action: 'stop' | 'abort' | 'resume') { + /** Deliver a resume instruction once the reopened worker accepts injections. */ + async function queueResumeInstruction(targetRunId: string, messageText: string) { + const requestKey = idempotencyKey('instruction'); + const createdAt = Date.now() / 1000; + feed.appendOperatorMessage({ id: requestKey, text: messageText, created_at: createdAt, status: 'queued' }); + // The worker is forked asynchronously, so `can_control` stays false for a + // moment and the API answers 409. Bounded retries keep a genuine rejection + // (revoked capability, run replaced) from looping forever. + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + await postInstruction(targetRunId, messageText, requestKey); + await feed.refresh(); + return; + } catch (reason) { + if (!isConflict(reason) || attempt === 19) { + feed.appendOperatorMessage({ id: requestKey, text: messageText, created_at: createdAt, status: 'failed' }); + setError(text('续跑指令未能送达,请在任务运行后重新发送。', 'The follow-up instruction could not be delivered. Send it again once the task is running.')); + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + } + + async function lifecycle(action: 'stop' | 'abort' | 'resume' | 'restart', resumeInstruction = '') { setControlBusy(true); setError(''); const request = operationKey(action, runId); try { if (action === 'stop') await stopRun(runId, request.key); if (action === 'abort') await abortRun(runId, request.key); - if (action === 'resume') { - const created = await resumeRun(runId, request.key); - setRunId(created.id); + if (action === 'resume' || action === 'restart') { + const mode = action === 'resume' ? 'continue' : 'retry'; + const created = await resumeRun(runId, request.key, { mode }); + // The instruction cannot be queued before this call: injection requires + // a live worker, so a terminal run rejects it with 409. + if (resumeInstruction) void queueResumeInstruction(created.id, resumeInstruction); + // "continue" reuses the same id, so the feed must still be refreshed to + // pick up the reopened lifecycle status. + if (created.id === runId) await feed.refresh(); + else setRunId(created.id); } else { // Stop/Abort changes supervisor control state without appending a role // event. Refresh immediately so the header and rail switch to @@ -1153,7 +1320,14 @@ export default function App() { await feed.refresh(); } clearOperationKey(request.id); - } catch (reason) { setError(String(reason)); } + } catch (reason) { + // A force-kill races the worker's own exit, so "no longer running" means + // the goal is already met: reconcile instead of showing a dead-end error. + if (action === 'abort' && isConflict(reason)) { + clearOperationKey(request.id); + await feed.refresh().catch(() => undefined); + } else setError(String(reason)); + } finally { setControlBusy(false); } } @@ -1206,18 +1380,19 @@ export default function App() {
-
{currentRun?.task || runId || text('新会话', 'New task')}
{statusLabel(snapshot.run.status, Boolean(statusView.finalResponse), language)}{canStopRun && }{canAbortRun && }{canResumeRun && }
+
{currentRun?.task || runId || text('新会话', 'New task')}
{statusLabel(snapshot.run.status, Boolean(statusView.finalResponse), language)}{canStopRun && }{canAbortRun && }{canResumeRun && }{canResumeRun && }
{ const node = event.currentTarget; setFollowLatest(node.scrollHeight - node.scrollTop - node.clientHeight < 48); }}> {!runId &&

{text('你想完成什么?', 'What do you want to accomplish?')}

{text('开始一个真实的 LongHorizon 任务。计划、中间过程、验证和最终回答会像 Codex 一样显示在这里。', 'Start a real LongHorizon task. Plans, intermediate work, verification, and the final answer will appear here as they happen.')}

{meta && !canCreateRun &&

{text('当前连接为只读模式;启动 ', 'This connection is read-only. Start ')}lh-harness web{text(' 后即可创建任务。', ' to create tasks.')}

}{!meta &&

{text('正在连接 Web 服务…', 'Connecting to the Web service…')}

}
} {runId && messages.map((message) => )} {runId && !messages.length &&
{text('等待 LongHorizon 启动…', 'Waiting for LongHorizon to start…')}
} {['running', 'starting', 'stopping', 'aborting'].includes(snapshot.run.status) &&
{snapshot.run.status === 'stopping' || snapshot.run.status === 'aborting' ? text('正在收尾并停止 worker', 'Finishing up and stopping the worker') : activeRole ? text(`${roleTitle(activeRole)} 正在处理`, `${roleTitle(activeRole)} is working`) : text('LongHorizon 正在处理', 'LongHorizon is working')}
} - {pendingApprovals.map((approval) => setApprovalInputs((current) => ({ ...current, [approval.approval_id]: value }))} onApprove={approve} />)} + {statusView.awaitingHandoff &&
{text('已提交,正在等待 worker 接手…', 'Submitted. Waiting for the worker to pick it up…')}
} + {pendingApprovals.map((approval) => setApprovalInputs((current) => ({ ...current, [approval.approval_id]: value }))} extraRounds={approvalRounds[approval.approval_id] || ''} onExtraRounds={(value) => setApprovalRounds((current) => ({ ...current, [approval.approval_id]: value }))} onApprove={approve} />)}
{visibleError &&
{visibleError}
} -