diff --git a/docs/architecture/browser-control.md b/docs/architecture/browser-control.md index 7dcbace7..b418a0b7 100644 --- a/docs/architecture/browser-control.md +++ b/docs/architecture/browser-control.md @@ -34,6 +34,12 @@ calls are rejected rather than queued. The client marks its executor with `stallRecovery: 'fail_closed'`, so an earlier computer-use loop deadline cannot trigger the legacy observation retry or idle-action skip behavior. +An action explicitly rejected with `action_rejected` / `not_dispatched` can +recover in the shared study loop. It records the rejection, withholds the rest +of that action batch and takes a fresh observation before asking the participant +what to do next. It never automatically replays input. Other executor failures, +including uncertain outcomes and failed observations, still end the session. + ## Wire contract Each frame is a four-byte unsigned big-endian length followed by strict UTF-8 diff --git a/docs/contracts/schemas.md b/docs/contracts/schemas.md index 8d1b34bd..86201777 100644 --- a/docs/contracts/schemas.md +++ b/docs/contracts/schemas.md @@ -3,7 +3,7 @@ Date: 2026-06-02 (current-state note updated 2026-07-14) Status: reference map for the major contracts shipped through source version -`0.99.0`; it is not an exhaustive inventory of command/result envelopes. Exported types, +`0.99.1`; it is not an exhaustive inventory of command/result envelopes. Exported types, schema constants, parsers, and validators in `src/` are authoritative. Rows marked "reserved" name layering intent only — no code emits or validates them yet. Do not emit a reserved schema. diff --git a/docs/goals/current.md b/docs/goals/current.md index e7e9dfda..8e42f854 100644 --- a/docs/goals/current.md +++ b/docs/goals/current.md @@ -1,9 +1,9 @@ # Current Goals -Status date: 2026-09-24. Release baseline: `0.99.0`. +Status date: 2026-09-24. Release baseline: `0.99.1`. This page guides work on current merged source. Published behavior is described -in the [release notes](../release/0.99.0-local-browser-mac.md). +in the [release notes](../release/0.99.1-action-recovery.md). The [September 9 history](https://github.com/danielgwilson/humanish/blob/main/docs/goals/current-history-2026-09-09.md) preserves the former status log; its queues do not supersede this page. @@ -88,7 +88,7 @@ requires decision-equivalent retained evidence and a real deletion branch. No first-party deletion branch has met that gate. Public demonstrations do not substitute for it. -## Current Program Truth (source `0.99.0`) +## Current Program Truth (source `0.99.1`) | Surface | Available in merged source | Remaining boundary | | --- | --- | --- | @@ -123,8 +123,8 @@ ordinary TAP/NAT networking. Continue managed-local work from this complete stud path; the earlier offline owner/service qualification experiments are historical fixtures, not an installation architecture or a prerequisite queue. Explicit Linux local labs now use the installed CLI/TUI, with a verified runtime download -before the first live run. Mac support, inbox integration and optional media -remain unfinished. Existing hosted labs retain their behavior. +before the first live run. Mac support is also shipped; inbox integration and +optional media remain unfinished. Existing hosted labs retain their behavior. ## Gates And Deferred Work diff --git a/docs/ramp/README.md b/docs/ramp/README.md index 1d09ad38..fc1fb851 100644 --- a/docs/ramp/README.md +++ b/docs/ramp/README.md @@ -2,7 +2,7 @@ Status: public-safe contributor and agent ramp. -Package/source version in this tree: `0.99.0` (2026-09-24). The Observer is phone-usable as a stated requirement (observer/AGENTS.md); interactive primitives start from Base UI. The Observer renderer is the observer/ workspace artifact only; the legacy string-concat renderer was deleted at cutover (#426), and rollback is a version pin to 0.42.0. The containment boundary introduced in +Package/source version in this tree: `0.99.1` (2026-09-24). The Observer is phone-usable as a stated requirement (observer/AGENTS.md); interactive primitives start from Base UI. The Observer renderer is the observer/ workspace artifact only; the legacy string-concat renderer was deleted at cutover (#426), and rollback is a version pin to 0.42.0. The containment boundary introduced in `0.15.1` remains in force: managed run and output paths bind to validated physical filesystem identities, and stored provider IDs are evidence, not cleanup authority. The bundled OSS meta-lab is dry-run only until @@ -54,6 +54,9 @@ If a change does not improve one of those loops, it probably belongs elsewhere. ## Current State +The [0.99.1 release note](../release/0.99.1-action-recovery.md) describes +participant recovery from browser actions rejected before dispatch. + The [0.99.0 release note](../release/0.99.0-local-browser-mac.md) describes local browser studies on supported Apple Silicon Macs through Lima, public ARM runtime setup, Codex account participants and automatic analysis. diff --git a/docs/release/0.99.1-action-recovery.md b/docs/release/0.99.1-action-recovery.md new file mode 100644 index 00000000..b73daf66 --- /dev/null +++ b/docs/release/0.99.1-action-recovery.md @@ -0,0 +1,19 @@ +# 0.99.1 — Recover from rejected browser actions + +A participant can now recover when the desktop explicitly rejects an action +before sending any input. For example, typing without an editable field focused +previously ended a local browser study with a harness error, even though the +browser remained usable. + +The shared execution loop records the rejection, withholds the remaining actions +in that batch, and takes a fresh screenshot before asking the participant what +to do next. This prevents a queued submit from running after rejected typing. +The next turn receives accurate execution acknowledgements and a recovery hint; +rejected actions are not recorded as completed input. The harness does not +replay them automatically. + +Uncertain outcomes, transport failures, revoked sessions and failed observations +retain their terminal handling. Existing time and no-progress limits still bound +repeated rejections. Updated runtime images keep the browser-control connection +open for this specific rejection; the shared host-side loop then allows recovery. +Account configuration and desktop setup are unchanged. diff --git a/package.json b/package.json index e4806cb0..938a673e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "humanish", - "version": "0.99.0", + "version": "0.99.1", "description": "Open-source-safe CLI for persona simulation, observer review, and public-safe feedback drafts.", "author": "Daniel G Wilson ", "keywords": [ diff --git a/site/content/docs/cli.mdx b/site/content/docs/cli.mdx index 98b89ed7..1491c4eb 100644 --- a/site/content/docs/cli.mdx +++ b/site/content/docs/cli.mdx @@ -5,7 +5,7 @@ description: Every command, argument, and option in the Humanish CLI. {/* Generated by scripts/generate-cli-docs.ts from createProgram(). Do not edit by hand. */} -Reference for **Humanish 0.99.0**. Run `npx humanish --version` to check your installation, or `npx humanish --help` for its exact flags. +Reference for **Humanish 0.99.1**. Run `npx humanish --version` to check your installation, or `npx humanish --help` for its exact flags. Start with the [quickstart](/docs). The examples below use the installed `humanish` binary; prefix them with `npx` from your project if it is not on your PATH. diff --git a/src/browser-control-client.ts b/src/browser-control-client.ts index 55436c5b..79e2fbec 100644 --- a/src/browser-control-client.ts +++ b/src/browser-control-client.ts @@ -23,8 +23,9 @@ export function createBrowserControlClient(options: BrowserControlClientOptions) const operation = pending; pending = undefined; operation.dispose(); if (operation.reply!.ok) operation.resolve(operation.reply!); else { - transport.close(operation.reply!.error.code); - operation.reject(new CuaExecutorError(operation.reply!.error.code, operation.reply!.error.disposition)); + const { code, disposition } = operation.reply!.error; + if (operation.request.operation !== "EXECUTE" || code !== "action_rejected" || disposition !== "not_dispatched") transport.close(code); + operation.reject(new CuaExecutorError(code, disposition)); } }; const transport = new BrowserControlTransport(options.transport, value => { diff --git a/src/browser-control-dispatcher.ts b/src/browser-control-dispatcher.ts index 5b9fdaba..af40ca06 100644 --- a/src/browser-control-dispatcher.ts +++ b/src/browser-control-dispatcher.ts @@ -62,7 +62,9 @@ export function attachBrowserControlDispatcher(options: BrowserControlDispatcher if (closed) return; try { await transport.send(reply); - if (!reply.ok) { transport.close(reply.error.code); return; } + if (!reply.ok && (request.operation !== "EXECUTE" || reply.error.code !== "action_rejected" || reply.error.disposition !== "not_dispatched")) { + transport.close(reply.error.code); return; + } } catch { transport.close("transport_failed"); } finally { dispose(); busy = false; } } diff --git a/src/computer-use.ts b/src/computer-use.ts index 8f42cbae..6bba75cc 100644 --- a/src/computer-use.ts +++ b/src/computer-use.ts @@ -1407,6 +1407,7 @@ export async function runComputerUseLoop(options: CuaLoopOptions): Promise= noProgressRecoverySteps && consecutiveNoProgress < noProgressSteps) { contextHints.push( `No visible progress for ${consecutiveNoProgress} step(s). ` + diff --git a/src/local-runtime-release.ts b/src/local-runtime-release.ts index 03063310..21ca0a64 100644 --- a/src/local-runtime-release.ts +++ b/src/local-runtime-release.ts @@ -2,19 +2,19 @@ import type { LocalRuntimeRelease } from "./local-runtime.js"; /** Updated with the verified, public runtime artifact before release. */ export const LOCAL_RUNTIME_RELEASE: LocalRuntimeRelease = { - url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.09.24.1/runtime-linux-amd64.tar.gz", - sha256: "8df88a82b9615bf35ddcd4a60b1be6f95f178a254d88aab79871aace4a22ac56", - bytes: 596136865, - image: "sha256:9597abdb46255190640443ea485010d2a45f51699e7cb47372d6ee895d280f5c" + url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.09.24.3/runtime-linux-amd64.tar.gz", + sha256: "936e4bdc5c844347c6d6ae2149f1f96cfcabab0c33c8dd7fb3ae1b74b3b91812", + bytes: 596154336, + image: "sha256:062f7e9eb8096f6e48906ec0d277c3a9d086ef4ebd275aa2efc43edcd86e5349" }; /** Architecture-specific artifacts; each retains matching sources and notices. */ export const LOCAL_RUNTIME_RELEASES: Partial> = { amd64: LOCAL_RUNTIME_RELEASE, arm64: { - url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.09.24.2/runtime-linux-arm64.tar.gz", - sha256: "16f10b21f6436ef39f18fb9615cae7045d3fc2a20798c1e42122d574f58c759b", - bytes: 582695626, - image: "sha256:52cd53169236cd70a445938bda008987f18a97303794ae0ff75cc15bbbd26671" + url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.09.24.4/runtime-linux-arm64.tar.gz", + sha256: "9b396d1d222917ffbac21ae448340fdcc563a9b2f757682d9806248941125cda", + bytes: 582659561, + image: "sha256:ed92f65a1f5b064952ea8b289f6bc1e7b5a9babe15ed1c7efec49e395e86eead" } }; diff --git a/tests/browser-control-client.test.ts b/tests/browser-control-client.test.ts index 507d5f55..4f28a6a4 100644 --- a/tests/browser-control-client.test.ts +++ b/tests/browser-control-client.test.ts @@ -146,9 +146,18 @@ describe("browser control client and dispatcher", () => { expect(error).toMatchObject({ code: "action_rejected", disposition: "outcome_uncertain" }); expect(JSON.stringify(f.rightWrites.map(bytes => bytes.toString()))).not.toContain(secret); f.close(); }); - it("accepts an explicit genuine driver rejection without claiming completion", async () => { - const f = setup({ executor: { observe: async () => observation(), execute: async () => { throw new CuaExecutorError("action_rejected", "not_dispatched"); } } }); - await expect(f.client.executor.execute(click)).rejects.toMatchObject({ code: "action_rejected", disposition: "not_dispatched" }); f.close(); + it("keeps the channel usable after a genuine pre-dispatch rejection", async () => { + const execute = vi.fn().mockRejectedValueOnce(new CuaExecutorError("action_rejected", "not_dispatched")).mockResolvedValue(undefined); + const f = setup({ executor: { observe: async () => observation(), execute } }); + await expect(f.client.executor.execute(click)).rejects.toMatchObject({ code: "action_rejected", disposition: "not_dispatched" }); + await expect(f.client.executor.observe()).resolves.toHaveProperty("screenshot"); + await expect(f.client.executor.execute(click)).resolves.toBeUndefined(); + expect(execute).toHaveBeenCalledTimes(2); f.close(); + }); + it("still closes the channel when an observation is rejected before dispatch", async () => { + const f = setup({ executor: { observe: async () => { throw new CuaExecutorError("action_rejected", "not_dispatched"); }, execute: async () => {} } }); + await expect(f.client.executor.observe()).rejects.toMatchObject({ code: "action_rejected" }); + await expect(f.client.executor.execute(click)).rejects.toMatchObject({ code: "executor_closed" }); f.close(); }); it.each([ request(1, "EXECUTE", { actionId: "action-1", action: click }), request(1, "HELLO", { secret: "extra" }), diff --git a/tests/cua-executor-error.test.ts b/tests/cua-executor-error.test.ts index abacf7c9..076c6a08 100644 --- a/tests/cua-executor-error.test.ts +++ b/tests/cua-executor-error.test.ts @@ -4,7 +4,7 @@ import { type CuaExecutorErrorCode, type CuaExecutorDisposition } from "../src/cua-executor-error.js"; import { - runComputerUseLoop, type CuaAction, type CuaExecutor, type CuaLoopOptions, type CuaProvider + runComputerUseLoop, type CuaAction, type CuaExecutor, type CuaLoopOptions, type CuaProvider, type CuaTurn } from "../src/computer-use.js"; import { defaultRedactionHooks } from "../src/redaction.js"; @@ -97,16 +97,61 @@ describe("executor failure attribution", () => { expect(JSON.stringify(result)).not.toContain("synthetic-private-value"); }); - it("retains an explicit refusal before dispatch without claiming uncertainty", async () => { + it.each([undefined, "fail_closed"] as const)("lets the participant recover from a rejected action with request policy %s", async (requestPolicy) => { + const rejected: CuaAction = { kind: "type", text: "synthetic-private-value" }; + const submit: CuaAction = { kind: "keypress", keys: ["Enter"] }; + const first: CuaAction = { kind: "click", x: 1, y: 2 }; + const recovery: CuaAction[] = [{ kind: "click", x: 3, y: 4 }, { kind: "type", text: "replacement" }]; + const turn = (actions: CuaAction[]): CuaTurn => ({ actions, done: actions.length === 0, pendingSafetyChecks: [], + providerRequest: { dispatched: true, usageComplete: false, cleanup: "confirmed" } }); + const actor = { ...provider(), ...(requestPolicy ? { requestPolicy } : {}), + nextTurn: vi.fn() + .mockResolvedValueOnce(turn([first, rejected, submit])) + .mockResolvedValueOnce(turn(recovery)) + .mockResolvedValueOnce(turn([])) }; + let frame = 0; + const execute = vi.fn(async (action: CuaAction) => { + if (action === rejected) throw new CuaExecutorError("action_rejected", "not_dispatched"); + }); + const result = await run({ observe: async () => ({ stateSignature: `frame-${++frame}` }), execute }, { provider: actor }); + expect(result).toMatchObject({ status: "passed", completionReason: "goal_satisfied" }); + expect(execute.mock.calls.map(([action]) => action)).toEqual([first, rejected, ...recovery]); + const next = actor.nextTurn.mock.calls[1]![0]; + expect(next.observation.stateSignature).toBe("frame-2"); + expect(next.contextHint).toContain("rejected before dispatch"); + if (requestPolicy) expect(next.previousExecution?.actions).toEqual([ + { index: 0, status: "completed" }, { index: 1, status: "not_dispatched" }, { index: 2, status: "not_dispatched" } + ]); + expect(result.trace.items.filter(item => item.kind === "ui_action")).toHaveLength(3); + expect(result.trace.counts).toMatchObject({ actions: 4, materialActions: 3 }); + expect(result.trace.items.find(item => item.title === "action rejected before dispatch")).toMatchObject({ + status: "warn", text: expect.stringContaining("remaining batch actions not dispatched: 1") + }); + expect(JSON.stringify(result)).not.toContain("synthetic-private-value"); + }); + + it("bounds repeated pre-dispatch rejection without counting it as progress", async () => { const result = await run({ observe: async () => ({ stateSignature: "fixture" }), execute: async () => { throw new CuaExecutorError("action_rejected", "not_dispatched"); } - }, { provider: provider([{ kind: "click", x: 1, y: 2 }]) }); + }, { provider: provider([{ kind: "click", x: 1, y: 2 }]), noProgressSteps: 2 }); + expect(result.completionReason).toBe("gave_up"); + expect(result.trace.items.filter(item => item.kind === "ui_action")).toEqual([]); + expect(result.trace.counts.actions).toBe(3); + expect(result.trace.counts.materialActions).toBe(0); + }); + + it.each([ + ["action_rejected", "outcome_uncertain"], ["transport_failed", "not_dispatched"], + ["session_revoked", "not_dispatched"], ["invalid_request", "not_dispatched"] + ] satisfies [CuaExecutorErrorCode, CuaExecutorDisposition][])("still stops for %s / %s", async (code, disposition) => { + const actor = provider([{ kind: "type", text: "private" }]); + const execute = vi.fn(async () => { throw new CuaExecutorError(code, disposition); }); + const result = await run({ observe: async () => ({ stateSignature: "fixture" }), execute }, { provider: actor }); expect(result.completionReason).toBe("harness_error"); - expect(result.reason).toBe("desktop executor error: action_rejected; disposition: not_dispatched"); + expect(execute).toHaveBeenCalledOnce(); + expect(actor.nextTurn).toHaveBeenCalledOnce(); expect(result.trace.items.filter(item => item.kind === "ui_action")).toEqual([]); - expect(result.trace.counts.actions).toBe(1); // The participant chose it. - expect(result.trace.counts.materialActions).toBe(0); // The executor refused before dispatch. }); it("keeps acknowledged actions when the following observation fails, without retrying", async () => { diff --git a/tests/release.test.ts b/tests/release.test.ts index cfd3af93..43cd9f34 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -20,7 +20,7 @@ describe("release readiness", () => { }; expect(packageJson.private).toBeUndefined(); - expect(packageJson.version).toBe("0.99.0"); + expect(packageJson.version).toBe("0.99.1"); expect(packageJson.license).toBe("MIT"); expect(packageJson.publishConfig?.access).toBe("public"); expect(packageJson.dependencies).not.toHaveProperty("@e2b/desktop");