Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/orca-plugin-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Orca native plugin tests
on:
push:
branches:
- "feat/active-turn-worker-wait-*"
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
node-tests:
runs-on: ubuntu-22.04
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
node-version: ['18', '22']
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ matrix.node-version }}
- name: Verify syntax
run: |
node --check src/worker_wait.mjs
node --check src/tools.mjs
- name: Run entire plugin regression suite
run: npm test
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
- **恢复与诊断**:使用 Orca 官方 Run/Task/Dispatch/Terminal 查询恢复状态,复用有效 Coordinator,避免无证据地新建终端。
- **对话分页读取**:对读取来源、分页游标和内容完整性进行验证;无法获取完整原始对话时明确标记为不完整。

共提供 **21 个工具**,分为只读查询和有副作用的编排操作。
当前实验分支提供 **22 个工具**(主分支 v0.3.1 为 21 个),分为只读查询和有副作用的编排操作。

## 环境要求

Expand Down Expand Up @@ -57,20 +57,34 @@ timeout_secs = 30

| 分类 | 工具 |
| --- | --- |
| 环境与查询 | `orca_health`、`orca_list_workspaces`、`orca_list_workers`、`orca_read_worker`、`orca_read_worker_pages`、`orca_show_worker` |
| 环境与查询 | `orca_health`、`orca_list_workspaces`、`orca_list_workers`、`orca_wait_workers`、`orca_read_worker`、`orca_read_worker_pages`、`orca_show_worker` |
| 状态恢复 | `orca_worker_launch_intent`、`orca_request_status`、`orca_coordinator_lookup`、`orca_recovery_snapshot`、`orca_check_messages` |
| 编排与通信 | `orca_create_run`、`orca_create_coordinator`、`orca_start_worker`、`orca_send_prompt`、`orca_send_message`、`orca_receive_delivery`、`orca_ack_messages`、`orca_reply_message` |
| 回收与释放 | `orca_stop_worker`、`orca_release_worker` |

对有副作用的操作,不应在超时或连接中断后直接重试;先通过只读恢复工具核实真实状态。

## 实验性:当前回合 Worker 等待(非 Host 主动唤醒)

`orca_wait_workers` 仅在已有 ChatGPT 模型回合执行期间进行限时、只读状态查询,**不会**在本回合结束后自动启动新的 ChatGPT 回合。

输入遵循 Native Plugin v1 的标量字段限制:

```json
{"dispatchId":"dispatch_a","dispatchId2":"dispatch_b","waitSeconds":10}
```

最多指定 3 个互不相同的 Dispatch ID(`dispatchId3` 可选),`waitSeconds` 为 0–12 秒。结果区分 `all_settled`、`needs_attention` 和 `wait_elapsed`,返回每个 Worker 状态和 `newModelTurnStarted:false`。查询时限不包含已开始的 CLI 查询收尾耗时。不得将 `all_settled` 直接等同所有 Worker 成功(另看 `allSucceeded`)。

Host 在后台无用户输入时自动唤醒模型仍然是独立的、未通过验收的能力;见 [Host 唤醒边界](docs/HOST_WAKE_BOUNDARY.md)。

## 测试与已知限制

```bash
npm test
```

2026-10-13 本机验证:**61/61 项自动化测试通过(Node.js 24.13.0)**。测试使用模拟 Orca CLI,不能替代真实多 Agent 端到端验收。真实 Orca 只读回归、Plugin check/reload 记录见 [v0.3.1 测试报告](docs/TEST_REPORT_v0.3.1.md)。
2026-10-14 本机实验分支验证:**73/73 项自动化测试通过(Node.js 24.13.0)**;v0.3.1 主分支历史记录为 2026-10-13 的 61/61。测试使用模拟 Orca CLI,不能替代真实多 Agent 端到端验收。真实 Orca 只读回归、Plugin check/reload 记录见 [v0.3.1 测试报告](docs/TEST_REPORT_v0.3.1.md)。

以下能力仍有限制:

Expand Down
50 changes: 50 additions & 0 deletions docs/HOST_WAKE_BOUNDARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Host 后台主动唤醒:架构边界与验收门槛

更新:2026-10-14。本文只描述已审查源代码、已验证的替代方案和仍缺失的 Host 原生能力,不声明已经支持无人值守自动续轮。

## 目标与现有行为

用户希望 ChatGPT 通过 WebCodex 调度 Orca Coordinator + Claude Code / Antigravity Workers,结束当前回答后,在某 Worker 完成时由 Host **自动开启一个新模型回合**,准确恢复 Run / Dispatch,并在需要时继续分配任务。

目前可用的两条路径:

1. **当前回合只读等待**:本仓库 `orca_wait_workers` 在模型回合尚活跃时,经 Native Tool Plugin v1 → Orca CLI 的 `orchestration worker-show` 对 1–3 个精确 Dispatch ID 轮询。成功完成此工具调用之后的下一步仍属于**原回合**。此方式不依赖 MCP iframe,但也不具备无人值守唤醒能力。
2. **Job Terminal MCP App 通知**:WebCodex Fork 的 `present_job_terminal_continuation` 使用 iframe `setTimeout` 调用 Server 状态接口,并在一条持久 Wait 的 prepare fence 之后,通过 `ui/message` 尝试交付给 Host。该消息的成功 ACK **只证明交付被 Host 接受**,不证明 Host 创建了新的 ChatGPT 模型回合。后台页可能暂停 iframe 计时器。

审查代码位置:

- WebCodex `src/job_terminal_attention.rs`:`JobTerminalContinuationAdapter` 是 Server 内部抽象;当前提交仅通过 `#[cfg(test)] install_adapter_for_tests` 安装测试实现。没有证明生产端存在可从无人值守后台调用的原生 Host 转轮接口。
- WebCodex `src/agent_wake.rs`:`ContinuationAdapter` 以 `production_auto_resume_available` 明确区分真实生产模型唤醒与 mock / 手工调度。
- WebCodex `src/mcp_job_terminal_continuation_app.html`:`ui/message` 的投递及其 ACK 与模型回合启动证据分离。
- WebCodex `docs/implementation/mcp-app-auto-resume-restoration.md`:包含 Agent / Job 各三种可见性和延迟组合,共六项真实 Host 验收条件。

2026-10-14 的实机探测:Job 完成至 Host prepare 相差 2,608 秒,最终 `delivered`,但没有无用户操作、自动新模型回合的证据。不能将 `WEBCODEX_MCP_APP_RESUME_MODE` 强行改为 `unattended` 作为修复。

## 真正的原生 Host Wake 最小合同

该能力**必须由有权限的 Host 提供并确认**,不能由 Orca 插件伪造:

1. **授权**:绑定当前用户、ChatGPT 会话/窗口、项目与精确 Wait(或 Agent Wake),且只接受一次明确授权。不能从 Run ID 猜测 conversation、也不能把浏览器页面令牌当成任意会话续轮权限。
2. **唤醒请求**:Host 自身持久化监听 Job terminal 事件;不依赖网页 iframe 定时器存活。持久键为 `(principal, conversation, wake_id, generation)`,同一逻辑通知在状态不明时不盲发第二次。
3. **投递与执行双重回执**:分别记录 `dispatch_accepted` 与 `fresh_turn_started`(关联精确对话和模型回合 ID)。新模型回合必须实际查询 `observe_jobs` 或消费 Agent Wake,才能记录 `continuation_observed`;缺任一层则仍是未验证。
4. **冲突关闭**:用户撤销授权、原会话不可用、过期/跨主身份、stale generation、接管的终端、Host 响应未知时,一律停止自动操作,只保留可观测恢复路径。
5. **运行不变式**:恢复原有 Orca Run / Coordinator / Worker;不得凭通知重复调用 `orca_start_worker`,也不得擅自新建 Coordinator 或清理用户接管的终端。

建议按独立的能力发现与授权合同接入 Host;在 Host 未提供此 API 之前,保持 readiness=unknown,而不是增加无人值守声明。

## 发布门槛(真实 ChatGPT Host)

在隔离的测试会话和测试工作区中,按 WebCodex 的官方六项矩阵验证 Agent/Job × 前台短延迟/前台长延迟/后台长延迟。每项需记录:

- Job terminal 或 Agent Wake 的 UTC 时间
- Host 接受投递的时间及不确定结果
- 原回合终止后的**新模型回合**起始时间
- 新回合调用精确 `observe_jobs` / `consume_agent_wake` 的证据
- 没有新用户消息、没有重复 Worker 启动、没有重复模型回合
- 页面隐藏、重开、Runner 重启、网络抖动下的恢复和关闭行为

`npm test`、Node 回归和 Rust CI 只能检查各自的逻辑合同,不能替代这六项真实 Host 的自动唤醒验收。

## 本仓库目前的回退策略

插件测试将 `wait_elapsed`、`start_unknown`、`needs_attention` 和失败 Worker 单独报告。长任务应在原回合结束后由下一次**实际用户发起的回合**查询 Orca 快照恢复;未来如果 Host 提供被授权的 native wake,再由 Host 注入带精确身份的事件。未知状态绝不自动重启 Worker。
12 changes: 12 additions & 0 deletions src/tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { integrity, readPages } from './transcript.mjs';
import { once, intentStatus } from './journal.mjs';
import { lifecycle } from './lifecycle.mjs';
import { findCoordinator, COORDINATOR_TITLE } from './coordinator.mjs';
import { waitForWorkers } from './worker_wait.mjs';
const f = field;
const r = async (op, args, mutating=false, timeoutMs=20000) => normalizeResult(op, await runOrca(args,{mutating,timeoutMs}));
const readOnly = {readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false};
Expand Down Expand Up @@ -76,6 +77,17 @@ export const tools = [
const receipt=await r('list_workers',args);
return {...receipt, lifecycle:(receipt.result?.workers||[]).map(row=>({dispatchId:row.dispatchId,...lifecycle(row)}))};
},readOnly),
tool('orca_wait_workers','Bounded read-only wait in the current turn; NOT an unattended Host wake. Never launches or retries Workers.',{
dispatchId:f.str('First exact Dispatch ID',128),
dispatchId2:f.optionalStr('Optional second exact Dispatch ID',128),
dispatchId3:f.optionalStr('Optional third exact Dispatch ID',128),
waitSeconds:f.int('Wait in current tool call, 0-12 seconds; default 10'),
},['dispatchId'],async(a)=>({
ok:true,operation:'wait_workers',result:await waitForWorkers({
dispatchIds:[a.dispatchId,a.dispatchId2,a.dispatchId3].filter(x=>x!==undefined),waitSeconds:a.waitSeconds??10,
read:async dispatchId => (await runOrca(['orchestration','worker-show','--dispatch',dispatchId],{timeoutMs:3000})).result,
}),
}),readOnly),
tool('orca_read_worker','Read a bounded page of one worker output, preserving continuation cursor.',{
dispatchId:f.str('Exact Dispatch attempt ID',128),cursor:f.optionalStr('Previous cursor',2048),
limit:f.int('Rows per page, 1-50'),source:f.variant('Output source',['auto','transcript','terminal']),
Expand Down
63 changes: 63 additions & 0 deletions src/worker_wait.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { OrcaError } from './cli.mjs';
import { lifecycle } from './lifecycle.mjs';

// This is a bounded, read-only wait inside the *current* model tool call.
// It does not start a new model turn and is not an unattended Host wake.
const VALID_DISPATCH = /^[A-Za-z0-9_-]{1,128}$/;
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));

export async function waitForWorkers({
dispatchIds, waitSeconds = 10, read,
now = () => performance.now(), sleep = wait,
}) {
if (!Array.isArray(dispatchIds) || dispatchIds.length < 1 || dispatchIds.length > 3
|| dispatchIds.some(id => typeof id !== 'string' || !VALID_DISPATCH.test(id))
|| new Set(dispatchIds).size !== dispatchIds.length) {
throw new OrcaError('invalid_arguments', 'Provide 1-3 distinct exact Dispatch IDs');
}
if (!Number.isInteger(waitSeconds) || waitSeconds < 0 || waitSeconds > 12) {
throw new OrcaError('invalid_arguments', 'waitSeconds must be 0-12');
}
if (typeof read !== 'function') throw new OrcaError('invalid_arguments', 'Worker reader is required');

const started = now();
const deadline = started + waitSeconds * 1000;
let polls = 0;
while (true) {
const workers = [];
// Only read the selected Dispatch attempts. Never create, stop, retry or
// release anything in this call. Each CLI read has its own short timeout.
for (const dispatchId of dispatchIds) {
const raw = await read(dispatchId);
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new OrcaError('invalid_worker_snapshot', 'Orca worker-show did not return an object');
}
if (raw.dispatchId !== undefined && raw.dispatchId !== dispatchId) {
throw new OrcaError('dispatch_mismatch', 'Orca worker-show returned a different Dispatch identity');
}
if (raw.dispatch?.id !== undefined && raw.dispatch.id !== dispatchId) {
throw new OrcaError('dispatch_mismatch', 'Orca worker-show returned a different Dispatch identity');
}
const state = lifecycle(raw);
workers.push({
dispatchId, phase: state.phase, settled: state.settled,
workerState: state.workerState, dispatchStatus: state.dispatchStatus,
userTakeover: state.userTakeover,
});
}
polls += 1;
const allSettled = workers.every(worker => worker.settled);
const needsAttention = workers.some(worker =>
worker.userTakeover || worker.phase === 'start_unknown');
const allSucceeded = !needsAttention && workers.every(worker => worker.phase === 'worker_done');
const elapsedMilliseconds = Math.max(0, Math.floor(now() - started));
if (allSettled || needsAttention || now() >= deadline) {
return {
waitOutcome: needsAttention ? 'needs_attention' : allSettled ? 'all_settled' : 'wait_elapsed',
allSettled, allSucceeded, polls, elapsedMilliseconds,
workers, readOnly: true, newModelTurnStarted: false,
};
}
await sleep(Math.min(1500, Math.max(0, deadline - now())));
}
}
3 changes: 2 additions & 1 deletion tests/fixtures/fake-orca.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ if (mode === 'refuse') { console.log(JSON.stringify({id:'sim',ok:false,error:{me
if (mode === 'silent-fail') process.exit(2);
if (mode === 'delay') await new Promise(resolve=>setTimeout(resolve,2000));
const root = args[0]==='orchestration' ? args[1] : args[0];
const requestedDispatch = args.includes('--dispatch') ? args[args.indexOf('--dispatch')+1] : 'dispatch_demo';
const result = root==='status' ? {runtime:{state:'ready'},app:{running:true},capabilities:['orchestration.contract.v1']} :
root==='worktree' ? {worktrees:[{id:'workspace-demo',path:'D:\\example-workspace'}]} :
root==='run-create' ? {runId:'run_demo'} :
Expand All @@ -17,7 +18,7 @@ root==='task-list' ? {tasks:[{id:'task_demo',runId:'run_demo'}]} :
root==='worker-start' ? {taskId:'task_demo',dispatchId:'dispatch_demo',stage:'ready'} :
root==='worker-list' ? {workers:[{dispatchId:'dispatch_demo',workerState:'running'}]} :
root==='worker-read' ? {rows:[{text:'工作完成'}],nextCursor:'cursor_2'} :
root==='worker-show' ? {dispatchId:'dispatch_demo',workerState:'succeeded',dispatch:{id:'dispatch_demo',status:'completed'},worker:{state:'succeeded'},terminalResource:{ownerDispatchId:'dispatch_demo',ownershipState:'owned',releaseState:'reclaimable',retainedReason:mode==='takeover'?'user_takeover':null}} :
root==='worker-show' ? {dispatchId:requestedDispatch,workerState:'succeeded',dispatch:{id:requestedDispatch,status:'completed'},worker:{state:'succeeded'},terminalResource:{ownerDispatchId:requestedDispatch,ownershipState:'owned',releaseState:'reclaimable',retainedReason:mode==='takeover'?'user_takeover':null}} :
root==='send' ? {messageId:'message_demo'} :
root==='check' ? {deliveryId:'delivery_demo',messages:[{id:'m1',body:'收到'}]} :
root==='reply' ? {messageId:'reply_demo'} :
Expand Down
4 changes: 2 additions & 2 deletions tests/protocol.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ test('webcodex v1 initializes and discovers real tool schema',async()=>{
assert.equal(result.exit,0);
assert.equal(result.records[0].result.protocolVersion,'webcodex-plugin-v1');
const ts=result.records[1].result.tools;
assert.equal(ts.length,21);assert.equal(ts[0].name,'orca_health');
assert.equal(ts.length,22);assert.equal(ts[0].name,'orca_health');
assert.equal(new Set(ts.map(x=>x.name)).size,ts.length);
assert.equal(ts[0].inputSchema.type,'object');
});
Expand All @@ -16,7 +16,7 @@ test('rejects invalid JSON-RPC method before initialization',async()=>{
assert.equal(r.records[0].error.message,'Not initialized');
});

test('all 21 tools stay within WebCodex native v1 schema keyword profile',async()=>{
test('all 22 tools stay within WebCodex native v1 schema keyword profile',async()=>{
const r=await interact([boot,{method:'tools/list',params:{}}]);
const defs=r.records[1].result.tools;
const allowed=new Set(['type','description','title','enum','const','properties','required','additionalProperties','minLength','maxLength','items','minItems','maxItems']);
Expand Down
Loading
Loading