diff --git a/README.md b/README.md index 6aab04842a..eb190b352d 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ **Start with one goal. Keep long-running agent work verifiable.** -Tell the Agent you already use what you want done. LoopX keeps the goal, -work, approvals and evidence durable behind the scenes, so the next session can -continue without making you learn LoopX internals first.
-Work with Codex, Claude Code, DeepSeek Harness and other runtimes. Advanced docs still expose the local-first semantic kernel, ownership model and research workbench. +Tell the Agent you already use what you want done. LoopX keeps the goal, work, +needed decisions and verifiable results durable behind the scenes, so the next +session can continue without making you learn LoopX internals first.
+Work with Codex, Claude Code, DeepSeek Harness and other runtimes. Advanced docs still expose the local-first architecture, ownership model and research workbench. [![Release](https://img.shields.io/github/v/release/loopx-project/loopx?color=brightgreen)](https://github.com/loopx-project/loopx/releases/latest) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![TypeScript core](https://img.shields.io/badge/core-TypeScript-3178C6?logo=typescript&logoColor=white)](docs/architecture/rfcs/typescript-control-plane-migration-v0.md) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](pyproject.toml) -[![Local first](https://img.shields.io/badge/control--plane-local--first-brightgreen.svg)](docs/public-private-boundary.md) [![Loop Agents](https://img.shields.io/badge/status-loop%20agents%20supported-brightgreen.svg)](docs/product/release-readiness.md) [![GitHub contributors](https://img.shields.io/github/contributors-anon/loopx-project/loopx?color=blue)](https://github.com/loopx-project/loopx/graphs/contributors) [![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/XmGgQyCFZd) [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/loopx-project/loopx) +[![Local-first state](https://img.shields.io/badge/local--first-state-brightgreen.svg)](docs/public-private-boundary.md) [![Loop Agents](https://img.shields.io/badge/status-loop%20agents%20supported-brightgreen.svg)](docs/product/release-readiness.md) [![GitHub contributors](https://img.shields.io/github/contributors-anon/loopx-project/loopx?color=blue)](https://github.com/loopx-project/loopx/graphs/contributors) [![Discord](https://img.shields.io/badge/Discord-Join-5865F2?logo=discord&logoColor=white)](https://discord.gg/XmGgQyCFZd) [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/loopx-project/loopx) [Get started](#quick-start) · [Why LoopX](#why-loopx) · [See it in action](#see-loopx-in-action) · [Evidence](#evidence) · [Contribute](#contributing) · [Docs](https://loopx-project.github.io/loopx/docs/) · [简体中文](README.zh-CN.md) @@ -41,9 +41,10 @@ then send one natural-language goal: $loopx speed up this project's slowest workflow; compare approaches and verify the gains ``` -LoopX turns that into visible tasks, gates and evidence as needed. Claude Code -uses `/loopx `; see [host setup](#start-from-your-agent) for other -runtimes. Prefer a guided first run? [Choose a task below](#try-loopx). +LoopX turns that into a visible plan, decisions that need you, and verification +as needed. Claude Code uses `/loopx `; see +[host setup](#start-from-your-agent) for other runtimes. Prefer a guided first +run? [Choose a task below](#try-loopx). ## 📰 News @@ -54,10 +55,10 @@ runtimes. Prefer a guided first run? [Choose a task below](#try-loopx). dates (UTC). --> - **2026-10-25 · 🤝 [Long-horizon agent research: collaborators welcome](https://loopx-project.github.io/loopx/blog/edgebench-feedback-and-memory/)** — Exploring **breakthroughs in long-horizon outcomes under equal budgets**, through feedback, replanning and reproducible evaluation on EdgeBench and real-world tasks. Academic labs and industry teams are welcome to collaborate, comparing outcomes and total cost. [Discuss collaboration](https://github.com/loopx-project/loopx/discussions). -- **2026-10-20 · 🚀 [LoopX 2.0](https://github.com/loopx-project/loopx/releases/tag/v2.0.0)** — A shared TypeScript control plane and long-horizon research workbench. [Migration guide](docs/product/releases/v2.0.0.md) · [Engineering story](https://loopx-project.github.io/loopx/blog/typescript-control-plane-refactor/). +- **2026-10-20 · 🚀 [LoopX 2.0](https://github.com/loopx-project/loopx/releases/tag/v2.0.0)** — A shared TypeScript state layer and long-horizon research workbench. [Migration guide](docs/product/releases/v2.0.0.md) · [Engineering story](https://loopx-project.github.io/loopx/blog/typescript-control-plane-refactor/). - **2026-10-14 · 🤖 [Agentic software](https://loopx-project.github.io/loopx/blog/semantic-software-development/)** — Connecting goals, evidence and human feedback to engineering delivery. - **2026-10-02 · 🧠 [Long-horizon bootstrapping & RSI](https://loopx-project.github.io/loopx/blog/long-horizon-rsi/)** — Four months of developing LoopX with LoopX: public project history and a design perspective on system-level self-improvement. -- **2026-09-18 · 🧪 [LoopX × LHTB](https://loopx-project.github.io/loopx/benchmarks/lhtb/)** — A 46-task, same-model study provides empirical support for **trading more compute for better outcomes**: LoopX Heartbeat mean reward is 17.3% above Plain Codex and 10.6% above native Goal. +- **2026-09-18 · 🧪 [LoopX × LHTB](https://loopx-project.github.io/loopx/benchmarks/lhtb/)** — A 46-task, same-model study provides empirical support for **trading more compute for better outcomes**: LoopX 1.0.3 Heartbeat continued runs reach mean reward 17.3% above Plain Codex and 10.6% above native Goal. - **2026-09-06 · 🎉 [LoopX 1.0](https://github.com/loopx-project/loopx/releases/tag/v1.0.0)** — A Personal Agent Workspace to follow agent work, act on decisions, and inspect delivered results across sessions. [Workspace guide](https://github.com/loopx-project/loopx/blob/v1.0.0/docs/guides/personal-workspace-user-guide.md). - **2026-08-02 · ⏱️ [200+ hour trajectories shared on X](https://x.com/huangruiteng/status/2083904257494024425)** — Early owner-run examples spanning 200+ elapsed hours drew broad attention on X, showing work across execution, waits, human decisions and recovery. [Trajectories and limits](#open-source-issue-fix). @@ -78,12 +79,12 @@ hand work to peers. A timer can keep a loop running without making it useful. - **Continue without starting over.** Keep the Goal, accepted requirements, decisions and evidence across sessions and restarts. -- **[Coordinate without losing ownership.](https://loopx-project.github.io/loopx/blog/agent-facing-kanban/#walkthrough)** Connect delegated work, dependent +- **[Coordinate without dropping context.](https://loopx-project.github.io/loopx/blog/agent-facing-kanban/#walkthrough)** Connect delegated work, dependent artifacts, review and returned results while keeping responsibility explicit. - **Judge progress by what changed.** Inspect deliverables, supporting and refuting evidence, costs and the next decision—not just activity. -Your Agent runtime supplies the model and tools. LoopX’s semantic control plane +Your Agent runtime supplies the model and tools. LoopX’s durable state layer preserves goals, acceptance, decisions, ownership and evidence across sessions and runtimes for engineering, research and operations. Agents and people use those facts to reassess what to do next. Human authorization still governs @@ -97,25 +98,25 @@ protected actions. | Manage personal projects, schedules and decisions in one place | [Personal Agent Workspace](#meet-the-personal-agent-workspace) | | Let agents collaborate and deliver verifiable results | [Agent collaboration guide](docs/product/use-cases/cross-runtime/README.md) | -LoopX keeps the durable control state in one compact layer: +LoopX keeps the durable work state in one compact layer: Your harness runs the turn. LoopX carries the work. -Agent runtimes execute the work. LoopX governs the state that lets engineering, +Agent runtimes execute the work. LoopX keeps the state that lets engineering, research, discovery, and operations loops continue across runs. It is not another agent framework or a provider-specific orchestration runtime. +
+Advanced: how the control layer works and where it fits + - One turn is a governed state transition—not just a timer. + Advanced view of a governed state transition, not just a timer. -
-How the control plane works and where it fits - A useful mental model is an **[agent-native Kanban for long-running work](docs/development/control-plane-course/00-concept-primer.md)**. Cards carry identity, authority, evidence, and continuation. Moves are validated diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx index 1e55e0f8d5..f52ffbcfb3 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-analysis.tsx @@ -634,7 +634,7 @@ export function ResearchAnalysisPanel({analysis: a, onHistory}: {analysis: Contr return
{if (event.key === "Escape") {event.stopPropagation(); a.close();}}}>
{result?.synthesis ? <>

{zh ? "研究汇报" : "Research report"}

{scopeHelp}
{p?.title ?

{p.title}

: null}{scope}
- : <>

{zh ? "轨迹深度分析" : "Trajectory analysis"}

{p?.title ?

{p.title}

: null}}
{result?.synthesis && onHistory ? : null}{result ? : null}
+ : <>

{zh ? "轨迹深度分析" : "Trajectory analysis"}

{p?.title ?

{p.title}

: null}}
{result?.synthesis && onHistory ? : null}{result ? : null}
{a.error ?
{zh ? "分析请求未完成,已保存结果保留。" : "The analysis request did not complete; saved results are preserved."}

{a.error}

: null} {a.snapshot?.catalog.unreadable_pack_ids?.length && !p?.continuation_unavailable ?

{zh ? "部分历史分析暂不可读" : "Some historical analyses cannot be read"} · {a.snapshot.catalog.unreadable_pack_ids.length}

: null} diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 482bad0e80..f738472bde 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -3923,18 +3923,22 @@ export function DashboardPage() { function selectGoal(goalId: string, view?: WorkspaceGoalTab) { void navigate({ hash: search.goalId === goalId ? keepCaseChapterHash : undefined, - search: (current) => ({ - ...current, - ...("goalId" in current && current.goalId === goalId ? {} : clearedResearchSearch), - goalId, - workspace: undefined, - conversationBinding: undefined, - conversationSession: undefined, - messageSession: undefined, message: undefined, - workAgent: undefined, workSession: undefined, - todo: undefined, - view: view === "chat" ? "conversation" : view, - }), + search: (current) => { + const currentSearch = current as Partial; + const keepResearch = Boolean(currentSearch.research_goal) || currentSearch.goalId === goalId; + return ({ + ...current, + ...(keepResearch ? {} : clearedResearchSearch), + goalId, + workspace: undefined, + conversationBinding: undefined, + conversationSession: undefined, + messageSession: undefined, message: undefined, + workAgent: undefined, workSession: undefined, + todo: undefined, + view: view === "chat" ? "conversation" : view, + }); + }, }); } @@ -3976,7 +3980,6 @@ export function DashboardPage() { onChangeGoalRoot={rootGoalId => void navigate({ hash: !rootGoalId || rootGoalId === search.goalId ? keepCaseChapterHash : undefined, search: current => ({...current, - ...(!rootGoalId || ("goalId" in current && current.goalId === rootGoalId) ? {} : clearedResearchSearch), workAgent: undefined, workSession: undefined, messageSession: undefined, message: undefined, rootGoalId: rootGoalId ?? undefined, goalId: rootGoalId ?? ("goalId" in current ? current.goalId : ""), workspace: undefined}), })} diff --git a/apps/presentation/site/src/App.tsx b/apps/presentation/site/src/App.tsx index 74f2c15204..deb3447a29 100644 --- a/apps/presentation/site/src/App.tsx +++ b/apps/presentation/site/src/App.tsx @@ -32,25 +32,23 @@ import mlEvidenceUrl from "../../../../docs/assets/long-running-loop-ml-experime type Language = "en" | "zh"; const setupPrompts: Record = { - en: `Help me start LoopX in the current project: https://github.com/loopx-project/loopx + en: `Connect the current project to LoopX: https://github.com/loopx-project/loopx Do not clone LoopX. Follow the README Quick Start to use the no-clone installer and \`loopx doctor\`, then reuse or \`loopx connect/bootstrap\` the current project state. After setup, first report whether the project is connected, then ask me for one natural-language goal unless I already included it. -When you receive \`/loopx \`, first provide a concise ordered plan, then write it as P0/P1/P2 todos so I can see what needs to be done, what is blocked by a gate, and what the next safe action is. +When you receive \`/loopx \`, first provide a concise ordered plan, save that plan as ordered LoopX Todos, then read back the current work, any decision that needs me, and the next safe action. Keep LoopX internal terms behind advanced or debugging explanations unless a decision requires them.`, - zh: `帮我在当前项目启动 LoopX: https://github.com/loopx-project/loopx + zh: `把当前项目接入 LoopX: https://github.com/loopx-project/loopx 不要 clone LoopX;按 README Quick Start 使用 no-clone installer、 \`loopx doctor\`,复用或 \`loopx connect/bootstrap\` 当前项目状态。 接入后先汇报项目是否已连接;如果我还没有给目标,就先问我要一个自然语言目标。 -收到 \`/loopx \` 后,先给出简洁有序计划,再把它写成 -P0/P1/P2 todos,让我能看到当前要做什么、什么被 gate 卡住、 -下一步安全动作是什么。 +收到 \`/loopx \` 后,先给出简洁有序计划,把计划按顺序保存为 LoopX 待办,再读回当前要做什么、哪里需要我判断、下一步安全动作是什么。 LoopX 内部机制术语默认放在高级或调试说明里,只有决策需要时再展开。`, }; @@ -76,7 +74,7 @@ const content = { secondPrefix: "Keep ", secondAccent: "progress visible", }, - body: "Use the agent you already trust. LoopX turns one natural-language goal into durable work, gates, and evidence behind the scenes, so the next session can continue without making you learn LoopX internals first.", + body: "Use the agent you already trust. LoopX turns one natural-language goal into durable work, needed decisions, and verifiable results behind the scenes, so the next session can continue without making you learn LoopX internals first.", copy: "Get started", mac: { download: "Download for Mac", @@ -87,7 +85,7 @@ const content = { setup: { eyebrow: "Goal-first setup", title: "Start from the outcome.", - body: "Give your current agent one setup prompt, then send the goal. Shell remains available when you want the manual path.", + body: "Give your current agent one setup prompt, then send the goal. Prefer the shell? Install LoopX manually.", agentTitle: "Guided agent start", agentBody: "Ask Codex, Claude Code, OpenCode, TraeX, Pi, or another shell-capable agent to connect this project and keep the machinery behind the scenes.", shellTitle: "Manual shell setup", @@ -98,24 +96,24 @@ const content = { }, demo: "See in action", panel: { - label: "ONE GOAL · ACROSS SESSIONS", + label: "ONE OUTCOME · ACROSS SESSIONS", title: "Ship the next release", status: "On track", - running: "Agent work", - researcher: "Research and implementation", - gate: "Human gate", - gateBody: "Merge needs your approval", - waiting: "Waiting", - evidence: "Evidence", - evidenceBody: "Validation and handoff written", - complete: "Complete", + running: "Work in progress", + researcher: "Research and implementation underway", + gate: "Your decision", + gateBody: "Approve the release when ready", + waiting: "Needs you", + evidence: "Verified result", + evidenceBody: "Checks and handoff written", + complete: "Saved", next: "Next safe action", - nextBody: "Keep independent tasks moving while review waits", + nextBody: "Continue work that does not depend on approval", }, proof: [ ["No vocabulary tax", "Start from the outcome; open LoopX details only when they matter."], ["Durable work", "The next session resumes from governed state, not memory."], - ["Reviewable evidence", "Every transition leaves a result another person can verify."], + ["Verifiable results", "Every transition leaves a result another person can check."], ], sectionEyebrow: "One goal-first lifecycle", sectionTitle: "Useful work can start before the machinery is familiar.", @@ -123,18 +121,18 @@ const content = { steps: [ ["01", "One goal", "A natural-language outcome"], ["02", "Plan", "Visible work to do"], - ["03", "Gate", "A concrete decision"], - ["04", "Evidence", "A validated result"], + ["03", "Decision", "A concrete choice"], + ["04", "Proof", "A validated result"], ["05", "Resume", "Recoverable progress"], ], capabilities: { - eyebrow: "Default simple, kernel intact", + eyebrow: "Simple by default, durable underneath", title: "A natural entry backed by durable state.", body: "Codex, Claude Code, OpenCode, TraeX, Pi, and custom runners can share one durable lifecycle. First contact starts with one goal; advanced surfaces expose ownership, scheduling, recovery, and protocols when needed.", items: [ - ["01", "Goal-first entry", "Start from a natural-language outcome; LoopX records the durable Goal and Todo structure as work becomes concrete."], + ["01", "Goal-first entry", "Start from a natural-language outcome; LoopX records durable work as the plan becomes concrete."], ["02", "Cross-runtime continuity", "Change the executor or host without reconstructing the objective from chat history."], - ["03", "Gate-aware progress", "A blocked P0 stays visible while independent P1 and P2 lanes continue safely."], + ["03", "Decision-aware progress", "A blocked decision stays visible while independent safe work can continue."], ["04", "Reviewable outcomes", "Every run writes validation, ownership, review, and handoff evidence into the next loop."], ], }, @@ -195,7 +193,7 @@ const content = { secondPrefix: "让进展", secondAccent: "持续可见", }, - body: "继续使用你已经信任的 Agent。LoopX 在背后把一个自然语言目标变成持久工作、决策门和证据,让下一轮会话可以接着推进,而不要求你先学会 LoopX 内部机制。", + body: "继续使用你已经信任的 Agent。LoopX 在背后把一个自然语言目标变成持久工作、需要你判断的决策和可验证结果,让下一轮会话可以接着推进,而不要求你先学会 LoopX 内部机制。", copy: "开始使用", mac: { download: "下载 Mac App", @@ -217,19 +215,19 @@ const content = { }, demo: "查看实战", panel: { - label: "一个目标 · 跨越多轮会话", + label: "一个结果 · 跨越多轮会话", title: "交付下一个版本", status: "进展正常", - running: "Agent 执行", - researcher: "研究与实现", - gate: "Human Gate", - gateBody: "合并前,等待你的批准", - waiting: "等待中", - evidence: "Evidence", - evidenceBody: "验证与交接已写回", - complete: "已完成", + running: "正在推进", + researcher: "研究与实现正在进行", + gate: "需要你判断", + gateBody: "版本准备好后由你批准", + waiting: "等你决定", + evidence: "已验证结果", + evidenceBody: "检查与交接已写回", + complete: "已保存", next: "下一步安全动作", - nextBody: "审批等待中,继续推进不依赖审批的任务", + nextBody: "继续推进不依赖审批的工作", }, proof: [ ["无术语门槛", "先描述结果;LoopX 细节只在需要决策或调试时展开。"], @@ -242,8 +240,8 @@ const content = { steps: [ ["01", "一个目标", "自然语言结果"], ["02", "计划", "可见的待办"], - ["03", "Gate", "一个具体判断"], - ["04", "Evidence", "一个验证结果"], + ["03", "判断", "一个具体选择"], + ["04", "证明", "一个验证结果"], ["05", "恢复", "可继续的进展"], ], capabilities: { @@ -251,9 +249,9 @@ const content = { title: "自然入口背后,是持久状态。", body: "Codex、Claude Code、OpenCode、TraeX、Pi 与自定义 Runner 可以共享同一套持久生命周期。第一次接触从一个目标开始;需要时,高级界面再展开 ownership、调度、恢复与协议。", items: [ - ["01", "目标优先入口", "从自然语言结果开始;随着工作具体化,LoopX 写入持久 Goal 与 Todo 结构。"], + ["01", "目标优先入口", "从自然语言结果开始;随着计划具体化,LoopX 记录可持续推进的工作。"], ["02", "跨运行时连续性", "更换执行器或 Host 时,无需从聊天记录重建长期目标。"], - ["03", "Gate-aware 进展", "P0 等待决策时保持可见,独立的 P1、P2 线路可以安全继续。"], + ["03", "决策可见的进展", "需要你判断的事项保持可见,不依赖它的安全工作可以继续。"], ["04", "可审查结果", "每轮运行都把验证、归属、审查和交接证据写入下一轮闭环。"], ], }, @@ -350,7 +348,7 @@ function ControlPlane({ language }: { language: Language }) {
- {language === "en" ? "ACTIVE GOAL" : "当前目标"} + {language === "en" ? "CURRENT GOAL" : "当前目标"} {panel.title}
{panel.status} @@ -365,7 +363,7 @@ function ControlPlane({ language }: { language: Language }) {
- P0 · {panel.running} + {language === "en" ? "NOW" : "当前"} · {panel.running} {panel.researcher}
@@ -378,7 +376,7 @@ function ControlPlane({ language }: { language: Language }) {
- GATE · {panel.gate} + {language === "en" ? "DECISION" : "判断"} · {panel.gate} {panel.gateBody}
{panel.waiting} @@ -458,10 +456,10 @@ const terminalCases = { }, lines: [ ["GOAL", "Ship the focused fix and preserve reusable knowledge.", "交付聚焦修复,并保留可复用知识。", "ACTIVE", "active"], - ["P0", "Focused PR delivery · bounded diff and validation", "聚焦式 PR 交付 · 有边界的改动与验证", "RUNNING", "running"], - ["GATE", "Reviewer authority · approve the focused change", "审查权限 · 批准聚焦改动", "WAITING", "waiting"], - ["P1", "Repository knowledge and terminal resume remain available", "仓库知识与终端恢复保持可用", "READY", "ready"], - ["EVIDENCE", "Merge/open result linked to the next reusable discovery", "合并 / 开放结果连接到下一轮可复用发现", "WRITTEN", "written"], + ["NOW", "Focused PR delivery · bounded diff and validation", "聚焦式 PR 交付 · 有边界的改动与验证", "RUNNING", "running"], + ["DECISION", "Reviewer authority · approve the focused change", "审查权限 · 批准聚焦改动", "WAITING", "waiting"], + ["SUPPORT", "Repository knowledge and terminal resume remain available", "仓库知识与终端恢复保持可用", "READY", "ready"], + ["PROOF", "Merge/open result linked to the next reusable discovery", "合并 / 开放结果连接到下一轮可复用发现", "WRITTEN", "written"], ["NEXT", "Review the focused diff, then continue the capability lane.", "先审查聚焦改动,再继续能力沉淀线路。", "SAFE", "safe"], ], }, @@ -473,15 +471,15 @@ const terminalCases = { command: "$ loopx status --goal-id auto-ml", image: mlEvidenceUrl, imageTitle: { - en: "Evidence branches remain visible until promotion or stop.", - zh: "证据分支保持可见,直到晋级或停止。", + en: "Result branches remain visible until promotion or stop.", + zh: "结果分支保持可见,直到晋级或停止。", }, lines: [ ["BASELINE", "Matched candidate and evaluation contract locked.", "匹配候选项与评估契约已锁定。", "MATCHED", "active"], ["BRANCHES", "Positive · Negative · Invalid lineage · Running replicate", "正向 · 负向 · 无效谱系 · 运行中复现", "VISIBLE", "ready"], - ["P1", "Replicate capacity stays bounded while evidence accumulates", "证据持续积累,同时保持复现容量有界", "RUNNING", "running"], - ["EVIDENCE", "Negative results and invalid lineages remain traceable", "负向结果和无效谱系持续可追溯", "WRITTEN", "written"], - ["P0 GATE", "Promote or stop after the running replicate completes", "运行中复现完成后,决定晋级或停止", "WAITING", "waiting"], + ["SUPPORT", "Replicate capacity stays bounded while evidence accumulates", "证据持续积累,同时保持复现容量有界", "RUNNING", "running"], + ["PROOF", "Negative results and invalid lineages remain traceable", "负向结果和无效谱系持续可追溯", "WRITTEN", "written"], + ["DECISION", "Promote or stop after the running replicate completes", "运行中复现完成后,决定晋级或停止", "WAITING", "waiting"], ["NEXT", "Compare replicate evidence, then record the decision.", "比较复现证据,再记录最终决策。", "SAFE", "safe"], ], }, diff --git a/apps/presentation/site/src/page-metadata.ts b/apps/presentation/site/src/page-metadata.ts index 24b109ba44..e0e2b28d7d 100644 --- a/apps/presentation/site/src/page-metadata.ts +++ b/apps/presentation/site/src/page-metadata.ts @@ -27,11 +27,11 @@ export const pageMetadata = { path: "", en: { title: "LoopX — Start long-running agent work with one goal", - description: "Tell Codex, Claude Code or another agent one natural-language goal. LoopX keeps the work, gates, evidence and recovery durable across sessions.", + description: "Tell Codex, Claude Code or another agent one natural-language goal. LoopX keeps the work, decisions, verification and recovery durable across sessions.", }, zh: { title: "LoopX — 从一个目标开始长程 Agent 工作", - description: "给 Codex、Claude Code 等 Agent 一个自然语言目标。LoopX 跨会话保留工作、决策门、证据与恢复状态。", + description: "给 Codex、Claude Code 等 Agent 一个自然语言目标。LoopX 跨会话保留工作、判断、验证结果与恢复状态。", }, }, sweMarathon: { diff --git a/apps/presentation/site/src/styles.css b/apps/presentation/site/src/styles.css index c492cfb874..d083f3650e 100644 --- a/apps/presentation/site/src/styles.css +++ b/apps/presentation/site/src/styles.css @@ -260,6 +260,7 @@ button { .hero-copy { position: relative; z-index: 2; + min-width: 0; max-width: 590px; } @@ -298,6 +299,8 @@ body[data-language="zh"] .hero h1 { .hero-title-line { display: block; + max-width: 100%; + overflow-wrap: break-word; } .hero-accent { @@ -1805,10 +1808,14 @@ body.dialog-open { } .site-header { + grid-template-columns: minmax(0, 1fr) auto; height: 64px; } .header-download { + justify-content: center; + width: 40px; + flex: 0 0 40px; padding: 0 12px; } @@ -1817,7 +1824,7 @@ body.dialog-open { } .header-download-compact { - display: inline; + display: none; } .hero { @@ -1825,6 +1832,18 @@ body.dialog-open { padding: 48px 0 56px; } + .hero-copy, + .hero-system { + width: 100%; + max-width: 100%; + min-width: 0; + } + + .hero-copy > *, + .hero-system > * { + max-width: 100%; + } + .hero h1 { font-size: clamp(29px, 7.5vw, 42px); } diff --git a/examples/frontstage-share-bundle-smoke.mjs b/examples/frontstage-share-bundle-smoke.mjs index 7a3644bd1d..e9304c0c33 100644 --- a/examples/frontstage-share-bundle-smoke.mjs +++ b/examples/frontstage-share-bundle-smoke.mjs @@ -333,13 +333,14 @@ for (const promptContract of [ "no-clone installer", "loopx doctor", "loopx connect/bootstrap", - "project connection status, current user gate, top agent todo", - "next safe action, and available commands", - "/loopx ", + "one natural-language goal", "/loopx ", "concise ordered plan", - "P0/P1/P2 todos", + "save that plan as ordered LoopX Todos", + "read back the current work, any decision that needs me, and the next safe action", + "Keep LoopX internal terms behind advanced or debugging explanations", "把当前项目接入 LoopX", + "把计划按顺序保存为 LoopX 待办", "下一步安全动作是什么", ]) { if (!homepageSource.includes(promptContract)) { diff --git a/examples/personal-workspace-browser/composer-session-admission.mjs b/examples/personal-workspace-browser/composer-session-admission.mjs index c7ad6a0f8f..43413d1cfd 100644 --- a/examples/personal-workspace-browser/composer-session-admission.mjs +++ b/examples/personal-workspace-browser/composer-session-admission.mjs @@ -199,9 +199,28 @@ export const composerSessionAdmissionScenario = { if (await composerInput.inputValue() !== nextManagerDraft) throw new Error("Returning lost the unsent request's text"); await openGoalChat(); if (new Set(adjustments.slice(0, 3).map(row => row.client_ingress_id)).size !== 1) throw new Error("Uncertain retries minted new ingress identities"); - await composerInput.fill(instruction); + for (let attempt = 0; attempt < 20 && await composerInput.inputValue() !== instruction; attempt += 1) { + await composerInput.fill(instruction); + await page.waitForTimeout(50); + } + if (await composerInput.inputValue() !== instruction) { + const statuses = await page.getByRole("status").allTextContents().catch(() => []); + throw new Error(`Confirmed non-delivery retry could not stage its draft: statuses=${JSON.stringify(statuses)} draft=${JSON.stringify(await composerInput.inputValue())}`); + } + if (await sendButton.isDisabled()) throw new Error("Confirmed non-delivery retry was blocked before it reached steering"); await composerInput.press("Meta+Enter"); - await page.getByRole("status").filter({ hasText: "本次未送达" }).waitFor(); + for (let attempt = 0; attempt < 100 && adjustments.length < 4; attempt += 1) await page.waitForTimeout(50); + if (adjustments.length < 4) { + const statuses = await page.getByRole("status").allTextContents().catch(() => []); + throw new Error(`Confirmed non-delivery retry did not reach steering: adjustments=${JSON.stringify(adjustments)} statuses=${JSON.stringify(statuses)} draft=${JSON.stringify(await composerInput.inputValue())}`); + } + try { + await page.getByRole("status").filter({ hasText: "本次未送达" }).waitFor(); + } catch (error) { + const statuses = await page.getByRole("status").allTextContents().catch(() => []); + const feedbacks = await page.locator(".personal-action-feedback").allTextContents().catch(() => []); + throw new Error(`Confirmed non-delivery feedback did not render: adjustments=${JSON.stringify(adjustments)} statuses=${JSON.stringify(statuses)} feedbacks=${JSON.stringify(feedbacks)} draft=${JSON.stringify(await composerInput.inputValue())}`, { cause: error }); + } await composerInput.press("Meta+Enter"); await page.getByRole("status").filter({ hasText: "执行器已接收本轮追加指令" }).waitFor(); if (adjustments[3].client_ingress_id === adjustments[4].client_ingress_id) throw new Error("Confirmed non-delivery could not retry after recovery"); diff --git a/examples/personal-workspace-browser/conversation-activity.mjs b/examples/personal-workspace-browser/conversation-activity.mjs index bf5b85bc7e..7a2794865d 100644 --- a/examples/personal-workspace-browser/conversation-activity.mjs +++ b/examples/personal-workspace-browser/conversation-activity.mjs @@ -24,7 +24,27 @@ export const conversationActivityScenario = { response.on("close", () => { if (streams.get(request.url) === response) streams.delete(request.url); }); }); await new Promise(resolveListen => server.listen(0, "127.0.0.1", resolveListen)); - const context = await openWorkspacePage(browser, url, { collectCoverage }); + const context = await openWorkspacePage(browser, url, { collectCoverage, beforeGoto: async (_api, page) => { + await page.addInitScript(() => { + window.__loopxSetSessionStorageFailure = message => { + if (!window.__loopxNativeSetItem) window.__loopxNativeSetItem = Storage.prototype.setItem; + const failForSessionStorage = function setItem(key, value) { + if (this === window.sessionStorage) throw new Error(message); + return window.__loopxNativeSetItem.call(this, key, value); + }; + Storage.prototype.setItem = failForSessionStorage; + Object.defineProperty(window.sessionStorage, "setItem", { + configurable: true, + value: () => { throw new Error(message); }, + }); + }; + window.__loopxRestoreSessionStorage = () => { + delete window.sessionStorage.setItem; + if (window.__loopxNativeSetItem) Storage.prototype.setItem = window.__loopxNativeSetItem; + delete window.__loopxNativeSetItem; + }; + }); + }}); const { page, api } = context; try { for (const pattern of ["**/events/**", "**/api/chat/sessions/*/turns/*/events"]) { @@ -103,6 +123,38 @@ export const conversationActivityScenario = { const composer = page.getByLabel("向 LoopX 发送消息"); const submit = page.getByRole("button", { name: "发送", exact: true }); const feedback = page.locator(".personal-action-feedback"); + const makeStorageUnavailable = reason => page.evaluate(message => { + window.__loopxSetSessionStorageFailure(message); + }, reason); + const restoreStorage = () => page.evaluate(() => { + window.__loopxRestoreSessionStorage(); + }); + const assertStorageUnavailable = async reason => { + const message = await page.evaluate(() => { + try { + window.sessionStorage.setItem("loopx-storage-probe", "1"); + return ""; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + }); + assert.equal(message, reason); + }; + const waitForFeedback = async (text, detail) => { + await feedback.filter({ hasText: text }).waitFor({timeout: 3000}).catch(async error => { + const storage = await page.evaluate(() => { + let probe = ""; + try { + window.sessionStorage.setItem("loopx-storage-probe-after", "1"); + probe = "ok"; + } catch (cause) { + probe = cause instanceof Error ? cause.message : String(cause); + } + return { probe, steering: window.sessionStorage.getItem("loopx-pw-composer-steering") }; + }); + throw new Error(`${detail}: ${error.message}; feedback=${JSON.stringify(await feedback.allTextContents())}; adjustments=${adjustments.length}; turn_requests=${api.turnRequests.length}; send_disabled=${await submit.isDisabled()}; composer_value=${JSON.stringify(await composer.inputValue())}; active=${await page.evaluate(() => document.activeElement?.id ?? document.activeElement?.tagName)}; storage=${JSON.stringify(storage)}`); + }); + }; await pending.getByRole("button", { name: "调整本轮", exact: true }).click(); assert.equal(await composer.evaluate(node => node === document.activeElement), true, "Adjust focuses the shared composer"); assert.equal(await page.locator("textarea:visible").count(), 1, "Only one editable conversation input is visible"); @@ -157,6 +209,40 @@ export const conversationActivityScenario = { const correctionIndex = conversation.findIndex(row => row.user && row.text.includes("先核对依赖")); assert.ok(originalIndex >= 0 && originalIndex < workIndex && workIndex < correctionIndex, "The request, ongoing work and accepted correction remain together in conversation order"); + await pending.getByRole("button", { name: "调整本轮", exact: true }).click(); + await makeStorageUnavailable("storage unavailable"); + await assertStorageUnavailable("storage unavailable"); + await composer.fill("先检查最新证据。"); + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click(); + await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + assert.equal(await composer.inputValue(), "先检查最新证据。", "Blocked storage preserves the page draft across manager views"); + const dispatchedBeforeStorageFailure = adjustments.length; + const turnsBeforeStorageFailure = api.turnRequests.length; + await composer.focus(); + await page.keyboard.press("Meta+Enter"); + await waitForFeedback("调整尚未发送", "Storage failure did not report a retained steering draft"); + assert.equal(adjustments.length, dispatchedBeforeStorageFailure, "Storage failure cannot dispatch steering"); + assert.equal(api.turnRequests.length, turnsBeforeStorageFailure, "Storage failure cannot queue a new conversation Turn"); + assert.equal(await composer.inputValue(), "先检查最新证据。"); + await page.screenshot({path: resolve(outputDir, "conversation-steering-storage-unavailable.png"), animations: "disabled"}); + await restoreStorage(); + await composer.fill("先检查最新证据,再继续。"); + await composer.focus(); + await page.keyboard.press("Meta+Enter"); + await feedback.filter({ hasText: "原回合指令未确认" }).waitFor(); + const durableRequest = adjustments.at(-1); + await makeStorageUnavailable("QuotaExceededError"); + await assertStorageUnavailable("QuotaExceededError"); + await composer.fill("另一个尚未保存的调整。"); + await submit.click(); + await feedback.filter({ hasText: "上条投递尚未确认" }).waitFor(); + assert.equal(adjustments.length, dispatchedBeforeStorageFailure + 1, "A changed draft cannot replace an uncertain request"); + await composer.fill("先检查最新证据,再继续。"); + await submit.click(); + await feedback.filter({ hasText: "执行器已接收本轮追加指令" }).waitFor(); + assert.equal(adjustments.at(-1).client_ingress_id, durableRequest.client_ingress_id, "A storage failure cannot replace an existing durable retry identity"); + await restoreStorage(); + const adjustmentsAfterStorageFailure = adjustments.length; await pending.locator("summary").filter({ hasText: "最近活动" }).click(); assert.deepEqual((await pending.locator(".personal-message-activity li").allTextContents()).slice(-3), ["Agent 正在执行命令", "Agent 正在检索", "Agent 正在执行命令"]); await page.screenshot({ path: resolve(outputDir, "conversation-activity-desktop.png"), animations: "disabled" }); @@ -239,7 +325,7 @@ export const conversationActivityScenario = { await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: "总览", exact: true }).click(); await page.getByRole("navigation", { name: "管家视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); assert.equal(await composer.inputValue(), "保留我的未发送草稿。", "finished turn retains the unsent draft across unmount"); - assert.equal(adjustments.length, 4, "Completion never dispatches the retained draft"); + assert.equal(adjustments.length, adjustmentsAfterStorageFailure, "Completion never dispatches the retained draft"); await page.unroute("**/interrupt"); // The same interaction is present in Goal Chat, through the same timeline. @@ -319,7 +405,7 @@ export const conversationActivityScenario = { await page.reload({ waitUntil: "domcontentloaded" }); await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); await page.waitForFunction(() => document.querySelector("#personal-conversation-composer")?.value === "旧版保留的原回合草稿。"); - assert.equal(adjustments.length, 4, "Migrating an old draft never dispatches it"); + assert.equal(adjustments.length, adjustmentsAfterStorageFailure, "Migrating an old draft never dispatches it"); const migrated = await page.evaluate(() => JSON.parse(sessionStorage.getItem("loopx-pw-composer-steering")) .filter(([, request]) => request.id === "legacy-exact-ingress")); assert.equal(migrated.length, 1, "One retry owner remains after durable migration"); @@ -329,32 +415,6 @@ export const conversationActivityScenario = { await feedback.filter({ hasText: "执行器已接收本轮追加指令" }).waitFor(); assert.equal(adjustments.at(-1).client_ingress_id, "legacy-exact-ingress"); await composer.fill(""); - await page.evaluate(() => { window.sessionStorage.setItem = () => { throw new Error("storage unavailable"); }; }); - await composer.fill("先检查最新证据。"); - await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: "任务", exact: true }).click(); - await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); - assert.equal(await composer.inputValue(), "先检查最新证据。", "Blocked storage preserves the page draft across views"); - const dispatchedBeforeStorageFailure = adjustments.length; - await composer.press("Meta+Enter"); - await feedback.filter({ hasText: "调整尚未发送" }).waitFor(); - assert.equal(adjustments.length, dispatchedBeforeStorageFailure, "Storage failure cannot dispatch steering"); - assert.equal(await composer.inputValue(), "先检查最新证据。"); - await page.screenshot({path: resolve(outputDir, "conversation-steering-storage-unavailable.png"), animations: "disabled"}); - await page.evaluate(() => { delete window.sessionStorage.setItem; }); - await composer.fill("先检查最新证据,再继续。"); - await composer.press("Meta+Enter"); - await feedback.filter({ hasText: "原回合指令未确认" }).waitFor(); - const durableRequest = adjustments.at(-1); - await page.evaluate(() => { window.sessionStorage.setItem = () => { throw new Error("QuotaExceededError"); }; }); - await composer.fill("另一个尚未保存的调整。"); - await submit.click(); - await feedback.filter({ hasText: "上条投递尚未确认" }).waitFor(); - assert.equal(adjustments.length, dispatchedBeforeStorageFailure + 1, "A changed draft cannot replace an uncertain request"); - await composer.fill("先检查最新证据,再继续。"); - await submit.click(); - await feedback.filter({ hasText: "执行器已接收本轮追加指令" }).waitFor(); - assert.equal(adjustments.at(-1).client_ingress_id, durableRequest.client_ingress_id, "A storage failure cannot replace an existing durable retry identity"); - await page.evaluate(() => { delete window.sessionStorage.setItem; }); await page.getByRole("button", { name: "中断本轮", exact: true }).click(); await page.getByText("已中断。你可以在当前会话继续发送消息。", { exact: true }).waitFor(); await page.locator(".personal-chat-activity").waitFor({state: "hidden"}); diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 046b031a2b..1b84b840e8 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -1466,7 +1466,11 @@ export async function installApi(page, { fixturePort = port, goalSubagentConfigu const answer = (typeof scriptedAnswer === "object" ? scriptedAnswer?.message : scriptedAnswer) || "已沿用当前 Goal 与 Agent Session。接下来会先核对状态,再继续推进。"; await new Promise((resolveWait) => setTimeout(resolveWait, /(中断控制|刷新恢复)/u.test(turnMessages.get(turnId) ?? "") ? 5000 : 1200)); - await route.fulfill({ contentType: "text/event-stream", body: finishTurn(sessionId, turnId, answer, null, scriptedAnswer?.goal_draft, scriptedAnswer?.proposals ?? []), status: 200 }); + try { + await route.fulfill({ contentType: "text/event-stream", body: finishTurn(sessionId, turnId, answer, null, scriptedAnswer?.goal_draft, scriptedAnswer?.proposals ?? []), status: 200 }); + } catch (error) { + if (!/Route is already handled/u.test(String(error?.message ?? error))) throw error; + } return; } if (url.pathname === "/api/chat/goals/contexts") { diff --git a/examples/personal-workspace-browser/goal-hierarchy.mjs b/examples/personal-workspace-browser/goal-hierarchy.mjs index ff211a1234..b8fca40394 100644 --- a/examples/personal-workspace-browser/goal-hierarchy.mjs +++ b/examples/personal-workspace-browser/goal-hierarchy.mjs @@ -143,6 +143,21 @@ export const goalHierarchyScenario = { "opening a scoped workspace reads its recursive subtree without opening unrelated Goal state"); assert.equal(new URL(page.url()).searchParams.get("rootGoalId"), "research"); assertResearchFocus(); + const scopedCalibration = page.url(); + const unscopedGraphBookmark = new URL(scopedCalibration); + unscopedGraphBookmark.searchParams.delete("research_goal"); + unscopedGraphBookmark.searchParams.delete("research_analysis_goal"); + await page.goto(unscopedGraphBookmark.href, {waitUntil: "networkidle"}); + await row("graph").locator(".personal-goal-link").click(); + await page.waitForURL(target => target.searchParams.get("goalId") === "graph"); + const unscopedQuery = new URL(page.url()).searchParams; + assert.equal(unscopedQuery.get("research_lens"), null, + "switching Goals clears an unscoped research lens instead of applying it to the destination Goal"); + assert.equal(unscopedQuery.get("research_node"), null, + "switching Goals clears unscoped research node bookmarks together with the lens"); + await page.goto(scopedCalibration, {waitUntil: "networkidle"}); + await row("calibration").locator('[aria-current="page"]').waitFor(); + assertResearchFocus(); await assertTreeLayout(); await page.screenshot({path: resolve(outputDir, "goal-hierarchy-focused.png"), animations: "disabled"}); await page.getByRole("button", {name: "调整 Goal 顺序", exact: true}).click(); diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index 6eb99eaf65..7923aa7192 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -795,7 +795,7 @@ export const researchHistoryScenario = { assert.equal(await analysis.getByRole('tab', {name: /下一步实验/}).getAttribute('aria-selected'), 'true'); await synthesisPage.setViewportSize({width: 1280, height: 900}); const packBeforeHistory = new URL(synthesisPage.url()).searchParams.get('research_pack'); - await analysis.getByRole('button', {name: 'Goal 研究历程', exact: true}).click(); + await analysis.getByRole('button', {name: '研究历程', exact: true}).click(); assert.equal(await analysis.count(), 0); assert.equal(new URL(synthesisPage.url()).searchParams.get('research_pack'), packBeforeHistory); await synthesisPage.waitForFunction(() => document.activeElement?.classList.contains('research-history-heading')); @@ -807,12 +807,13 @@ export const researchHistoryScenario = { await synthesisPage.reload({waitUntil: 'domcontentloaded'}); await synthesisPage.setViewportSize({width: 390, height: 844}); await synthesisPage.getByRole('button', {name: 'Read deeper', exact: true}).click(); - const english = synthesisPage.locator('.research-analysis'); + const english = synthesisPage.getByRole('region', {name: 'Trajectory analysis', exact: true}); + await english.waitFor(); assert.match(await english.locator('.analysis-report-meta').innerText(), /3 research episodes.*Purposive sample/); await english.getByRole('button', {name: 'Analysis scope', exact: true}).focus(); await synthesisPage.getByRole('tooltip').getByText('Agent analysis · uncalibrated', {exact: true}).waitFor(); await synthesisPage.keyboard.press('Escape'); - assert(await english.getByRole('button', {name: 'Goal research history', exact: true}).isVisible()); + assert(await english.getByRole('button', {name: 'Research history', exact: true}).isVisible()); const englishAccount = english.locator('.research-report-account').first(); assert(await englishAccount.getByRole('heading', {name: 'Observation and evidence', exact: true}).isVisible()); assert(await englishAccount.getByRole('heading', {name: 'Interpretation', exact: true}).isVisible()); diff --git a/examples/personal-workspace-browser/research-turn-costs.mjs b/examples/personal-workspace-browser/research-turn-costs.mjs index 23582e2d0a..49b13cebec 100644 --- a/examples/personal-workspace-browser/research-turn-costs.mjs +++ b/examples/personal-workspace-browser/research-turn-costs.mjs @@ -61,7 +61,10 @@ Path(sys.argv[1]).write_text(json.dumps(dict(goal_id=goal,display_name='Syntheti assert.match(await items.first().innerText(), /2,000/); await costs.getByRole('combobox').selectOption('elapsed'); assert.match(await items.first().innerText(), /第 7 轮.*未完成/s); - await items.first().getByRole('button').click(); + const turnCostEvidence = items.first().getByRole('button'); + const turnCostEvidenceHandle = await turnCostEvidence.elementHandle(); + assert(turnCostEvidenceHandle); + await turnCostEvidence.click(); const source = page.getByRole('region', {name: '引用原文', exact: true}); await source.locator('pre').waitFor(); assert.match(await source.innerText(), /turn-7/); @@ -69,7 +72,7 @@ Path(sys.argv[1]).write_text(json.dumps(dict(goal_id=goal,display_name='Syntheti assert.match(await source.innerText(), /token_count/); assert.ok(!(await source.innerText()).includes('SENSITIVE_TEXT_NOT_FOR_BROWSER')); await source.focus(); await page.keyboard.press('Escape'); - assert.equal(await items.first().getByRole('button').evaluate(element => element === document.activeElement), true); + await page.waitForFunction(element => element === document.activeElement, turnCostEvidenceHandle); await costs.getByRole('combobox').selectOption('time'); await costs.getByRole('button', {name: '下一页', exact: true}).click(); assert.equal(await items.count(), 3); diff --git a/examples/personal-workspace-browser/research-worker-readback.mjs b/examples/personal-workspace-browser/research-worker-readback.mjs index 5d75523b95..c5c8f08fb1 100644 --- a/examples/personal-workspace-browser/research-worker-readback.mjs +++ b/examples/personal-workspace-browser/research-worker-readback.mjs @@ -36,7 +36,8 @@ export async function startAuthority(mode) { lines.once("line", line => { clearTimeout(timeout); accept(JSON.parse(line)); }); }); assert.ok(Number.isSafeInteger(value.port) && value.port > 0); - return {...value, url: `http://127.0.0.1:${value.port}`, close}; + return {...value, url: `http://127.0.0.1:${value.port}`, root, + diagnostic: () => diagnostic, exited: () => ({exitCode: child.exitCode, signalCode: child.signalCode}), close}; } catch (error) { await close(); throw error; } } @@ -76,7 +77,12 @@ export const researchWorkerReadbackScenario = { writes += 1; writePaths.push(path); } - const response = await fetchWorkspaceBackend(route, authority.url); + let response; + try { + response = await fetchWorkspaceBackend(route, authority.url); + } catch (error) { + throw new Error(`Worker backend proxy failed: root=${authority.root} child=${JSON.stringify(authority.exited())} stderr=${authority.diagnostic()}`, {cause: error}); + } if (emptySession) sessionPreparations.push({status: response.status(), payload: await response.json()}); if (response.status() >= 400) failedReads.push({path: new URL(route.request().url()).pathname, status: response.status(), body: (await response.text()).slice(0, 800)}); diff --git a/examples/personal-workspace-browser/scenario-context.mjs b/examples/personal-workspace-browser/scenario-context.mjs index 93562ede9c..0f49f75046 100644 --- a/examples/personal-workspace-browser/scenario-context.mjs +++ b/examples/personal-workspace-browser/scenario-context.mjs @@ -2,7 +2,7 @@ import { installApi } from "./fixture.mjs"; // A disposable backend has a different port from the browser fixture. Rebase // same-origin requests with it; foreign origins must still reach the real guard. -export function fetchWorkspaceBackend(route, backendUrl, { pathname } = {}) { +export async function fetchWorkspaceBackend(route, backendUrl, { pathname } = {}) { const request = route.request(); const source = new URL(request.url()); const target = new URL(backendUrl); @@ -10,7 +10,17 @@ export function fetchWorkspaceBackend(route, backendUrl, { pathname } = {}) { target.search = source.search; const headers = { ...request.headers(), host: target.host }; if (headers.origin === source.origin) headers.origin = target.origin; - return route.fetch({ url: target.href, headers }); + for (let attempt = 0; ; attempt += 1) { + try { + return await route.fetch({ url: target.href, headers }); + } catch (error) { + const retryable = ["GET", "HEAD"].includes(request.method()) + && /\b(?:ECONNRESET|ECONNREFUSED)\b/u.test(String(error?.message ?? error)) + && attempt < 2; + if (!retryable) throw error; + await new Promise(resolve => setTimeout(resolve, 50 * (attempt + 1))); + } + } } export async function openWorkspacePage(