From 2089c3ef9357ff1a48b9aae0d6d0992b7cfcf87d Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Mon, 2 Nov 2026 02:46:06 +0000 Subject: [PATCH 1/2] feat(hub): show what a long sandbox command is doing while it runs A command the hub runs in the sandbox said nothing until it ended, so the person watched a fixed phrase and a clock for up to two minutes at a time, and for the whole of the first workspace install. A command may now print "progress: " on stderr; the sandbox relays each such line to a small file beside the workspace, out of the sandbox's reach, and removes the file when the command ends. The hub's activity read attaches the line to the run when the sidecar is on its own machine and the line is fresh, and the page shows it in place of its reading of the tool call. The workspace install, which prints nothing the hub can relay, is noted as "installing the app's packages" for its whole length. Lines meant for the person stay out of the command's output, and only commands the hub runs are asked for them. --- apps/hub/src/artifacts/service.ts | 45 ++++++++- apps/hub/src/builds/posix-sandbox.ts | 94 +++++++++++++++++-- apps/hub/src/builds/sidecar-tools.ts | 36 ++++--- .../pages/workspace/agent-activity.test.ts | 29 ++++++ .../src/pages/workspace/agent-activity.tsx | 3 + packages/schemas/src/activity.ts | 13 +++ 6 files changed, 199 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/pages/workspace/agent-activity.test.ts diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index 11dfe165..1ba55ab6 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -1,5 +1,5 @@ import { stat } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { type } from "arktype"; import { and, desc, eq, inArray, ne, sql } from "drizzle-orm"; import { PostgresJsDatabase } from "drizzle-orm/postgres-js"; @@ -82,9 +82,11 @@ import { ProjectExportMaterial, } from "@corbits-builder/schemas/project"; import JSZip from "jszip"; -import type { - AgentActivity, - ProjectActivity, +import { + COMMAND_PROGRESS_FILE, + CommandProgress, + type AgentActivity, + type ProjectActivity, } from "@corbits-builder/schemas/activity"; import { artifactApproval, @@ -171,6 +173,8 @@ const APPROVAL_COVERS: Record boolean> = { // Whole documents, the filed app and approval package among them. const MAX_READ_CHARS = MAX_APPROVAL_PACKAGE_BYTES; const RECENT_FAILURES = 5; +// A command's progress line older than this was left by a command that died; it is not shown. +const COMMAND_PROGRESS_STALE_MS = 5 * 60_000; const RunError = type({ "+": "delete", message: "string", "code?": "string" }); const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; const isUuid = (value: string) => new RegExp("^" + UUID + "$", "i").test(value); @@ -830,6 +834,36 @@ export function createArtifactService( // or running between turns. async activity(actor: Actor, projectId: string): Promise { await requireProject(actor, projectId, "read"); + // What a long command the run executes last said about itself, from the file the sandbox + // keeps beside the workspace on this machine's sidecar. A command that died leaves its file + // behind, so a stale line is not shown. + const commandProgress = async ( + address: string | null, + bundle: unknown, + ): Promise => { + if (!sidecarDataDir || address === null) return undefined; + const step = spawnedWorkflowStep(bundle); + if (!step) return undefined; + const file = join( + dirname(warmStepWorkspace(sidecarDataDir, address, step.stepId)), + COMMAND_PROGRESS_FILE, + ); + // No file is the usual case: nothing long is running. A file caught mid-write reads as + // broken JSON for a moment and is read again on the next poll. + let raw: unknown; + try { + raw = await Bun.file(file).json(); + } catch (error) { + if (hasCode(error) && error.code === "ENOENT") return undefined; + if (error instanceof SyntaxError) return undefined; + throw error; + } + const progress = CommandProgress(raw); + if (progress instanceof type.errors) return undefined; + if (Date.now() - Date.parse(progress.at) > COMMAND_PROGRESS_STALE_MS) + return undefined; + return progress; + }; const agents: AgentActivity[] = []; const coordinator = await activeCoordinator(projectId); const seen = coordinator && tracker?.of(coordinator.runId); @@ -844,6 +878,7 @@ export function createArtifactService( const specialists = await rawDB .select({ runId: workflowRun.id, + address: workflowRun.address, bundle: workflowRunLaunchSpec.frozenApprovalBundle, }) .from(workflowRun) @@ -865,12 +900,14 @@ export function createArtifactService( // A finished specialist stays parked for follow-up mail; only a turn in progress is work, // timed from that turn's start rather than the run's deploy. if (!shown?.working || !shown.since) continue; + const command = await commandProgress(run.address, run.bundle); agents.push({ runId: run.runId, role, since: shown.since, tools: shown.tools, ...(shown.said ? { said: shown.said } : {}), + ...(command ? { command } : {}), }); } return { agents }; diff --git a/apps/hub/src/builds/posix-sandbox.ts b/apps/hub/src/builds/posix-sandbox.ts index 3eb49620..3478d90d 100644 --- a/apps/hub/src/builds/posix-sandbox.ts +++ b/apps/hub/src/builds/posix-sandbox.ts @@ -23,9 +23,89 @@ import { requestErrorMetadata } from "../request-error.ts"; import type { buildPackageLayout } from "./package-layout.ts"; import { BROWSER_ARGS } from "./browser.ts"; import { CODER_REGISTRY_HOSTS } from "../config/packages.ts"; +import { COMMAND_PROGRESS_FILE } from "@corbits-builder/schemas/activity"; const log = getLogger(["corbits-builder", "hub", "posix-sandbox"]); +// A command's own word on its progress: a stderr line with this prefix, which the sandbox relays +// to the hub and keeps out of the command's output. Set for commands the hub runs, so a command +// the agent runs itself prints no such lines into what the agent reads. +const PROGRESS_PREFIX = "progress: "; +const PROGRESS_ENV = "SB_PROGRESS"; +const MAX_PROGRESS_LINE = 200; +// Progress is read on a poll; writing it more often than this changes nothing the person sees. +const PROGRESS_MIN_INTERVAL_MS = 1_000; + +/** Where a command's latest word about itself sits: beside the workspace, out of the sandbox's reach. */ +const progressFile = (workspace: string) => + join(dirname(workspace), COMMAND_PROGRESS_FILE); + +/** + * Records what a long command in the workspace is doing now, for the hub to show the person. A + * note that cannot be written costs the person a line, never the command, so it is logged instead. + */ +export async function noteCommandProgress(workspace: string, line: string) { + try { + await writeFile( + progressFile(workspace), + JSON.stringify({ + line: line.replace(/\s+/g, " ").trim().slice(0, MAX_PROGRESS_LINE), + at: new Date().toISOString(), + }), + ); + } catch (error) { + log.warn("Command progress could not be noted", { + workspace, + ...requestErrorMetadata(error), + }); + } +} + +/** Forgets the command's progress once it has ended. */ +export async function clearCommandProgress(workspace: string) { + try { + await rm(progressFile(workspace), { force: true }); + } catch (error) { + log.warn("Command progress could not be cleared", { + workspace, + ...requestErrorMetadata(error), + }); + } +} + +/** + * Relays a command's progress lines to the hub as they arrive and keeps the rest of its stderr + * for a failure. A progress line is noted at most once a second, since it is read on a poll. + */ +async function relayStderr( + stderr: ReadableStream, + workspace: string, + errors: string[], +) { + const decoder = new TextDecoder(); + let pending = ""; + let noted = 0; + const take = async (line: string) => { + if (!line.startsWith(PROGRESS_PREFIX)) { + errors.push(line); + return; + } + const now = Date.now(); + if (now - noted < PROGRESS_MIN_INTERVAL_MS) return; + noted = now; + await noteCommandProgress(workspace, line.slice(PROGRESS_PREFIX.length)); + }; + for await (const chunk of stderr) { + pending += decoder.decode(chunk, { stream: true }); + const end = pending.lastIndexOf("\n"); + if (end === -1) continue; + const lines = pending.slice(0, end).split("\n"); + pending = pending.slice(end + 1); + for (const line of lines) await take(line); + } + if (pending !== "") await take(pending); +} + export type BuildPackageLayout = Awaited>; // The workspace's dev app: its address, whether it runs and its recent output. stopped names the // workspace whose dev script exited while the rest kept running, as `bun --filter` names it. @@ -947,7 +1027,7 @@ export async function createPosixBuildExecutor( ], { cwd, - env: childEnv, + env: { ...childEnv, [PROGRESS_ENV]: "1" }, stdin: "ignore", stdout: "pipe", stderr: "pipe", @@ -958,17 +1038,18 @@ export async function createPosixBuildExecutor( const stop = () => kill(child); const timer = setTimeout(stop, limits.commandTimeoutMs); signal.addEventListener("abort", stop, { once: true }); + // Progress lines are relayed as they arrive; the rest of stderr is kept for a failure. + const errors: string[] = []; + const relay = relayStderr(child.stderr, cwd, errors); try { - const [output, errors] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]); + const output = await new Response(child.stdout).text(); + await relay; const exitCode = await child.exited; // A command that died before printing its checklist would otherwise fail with nothing said. const text = exitCode === 0 ? output - : [output, errors.slice(-2000)] + : [output, errors.join("\n").slice(-2000)] .filter((part) => part.trim()) .join("\n"); return { exitCode, output: text.replaceAll(cwd + sep, "") }; @@ -978,6 +1059,7 @@ export async function createPosixBuildExecutor( // Chrome may outlive the command that started it. stop(); commands.delete(child); + await clearCommandProgress(cwd); } }, /** What the running app complained of since the last call: its server's errors and its page's. */ diff --git a/apps/hub/src/builds/sidecar-tools.ts b/apps/hub/src/builds/sidecar-tools.ts index c700695b..12047676 100644 --- a/apps/hub/src/builds/sidecar-tools.ts +++ b/apps/hub/src/builds/sidecar-tools.ts @@ -14,7 +14,11 @@ import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; import { resolveWorkflowProjectScope } from "../workflows/project-scope.ts"; import { WorkflowHttpError } from "../workflows/http-json.ts"; import { APP_STATUS_TOOL, createCodingTools } from "./coding-tools.ts"; -import { createPosixBuildExecutor } from "./posix-sandbox.ts"; +import { + clearCommandProgress, + createPosixBuildExecutor, + noteCommandProgress, +} from "./posix-sandbox.ts"; import { browserTools } from "./browser.ts"; import type { BrowserCatalog } from "./browser-catalog.ts"; import { @@ -154,6 +158,8 @@ const passedAppCheck = (call: ToolCall, result: ToolResult) => // A cold install; under the executor's own ceiling. const SETUP_TIMEOUT_MS = 115_000; +// What the person reads while the workspace installs its packages, which can take the whole timeout. +const SETUP_PROGRESS = "installing the app's packages"; // A source-closure-safe declaration: only explicit policy is captured. The // native child supplies the step workspace; the sandbox is built inside it. @@ -282,17 +288,25 @@ export function createSidecarCodingTools(policy: { ); try { if (setup) { - const result = await executor.run( - { - id: "workspace-setup", - name: "run_shell", - arguments: { - command: setup.command, - timeout: SETUP_TIMEOUT_MS, + // The install says nothing the hub can relay until it ends, so the person is told what + // is running for its whole length. + await noteCommandProgress(workspace, SETUP_PROGRESS); + let result: ToolResult; + try { + result = await executor.run( + { + id: "workspace-setup", + name: "run_shell", + arguments: { + command: setup.command, + timeout: SETUP_TIMEOUT_MS, + }, }, - }, - signal, - ); + signal, + ); + } finally { + await clearCommandProgress(workspace); + } if (!succeeded(result)) throw new Error( `workspace_setup_failed: ${String(result.content).slice(-2000)}`, diff --git a/apps/web/src/pages/workspace/agent-activity.test.ts b/apps/web/src/pages/workspace/agent-activity.test.ts new file mode 100644 index 00000000..6eb36606 --- /dev/null +++ b/apps/web/src/pages/workspace/agent-activity.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { activityPhrase } from "./agent-activity.tsx"; + +const coder = { + runId: "run-coder", + role: "coder" as const, + since: "2026-11-02T01:41:30.000Z", + tools: [ + { + name: "read_file", + subject: ".inputs/brief.md", + running: true, + at: "2026-11-02T01:41:31.000Z", + }, + ], +}; + +test("a long command's own word on its progress replaces the reading of the call", () => { + expect(activityPhrase(coder).phrase).toMatch(/brief/); + expect( + activityPhrase({ + ...coder, + command: { + line: "installing the app's packages", + at: "2026-11-02T01:41:40.000Z", + }, + }), + ).toEqual({ phrase: "installing the app's packages", asking: false }); +}); diff --git a/apps/web/src/pages/workspace/agent-activity.tsx b/apps/web/src/pages/workspace/agent-activity.tsx index 1ee43c6b..5cef90ec 100644 --- a/apps/web/src/pages/workspace/agent-activity.tsx +++ b/apps/web/src/pages/workspace/agent-activity.tsx @@ -94,6 +94,9 @@ export function activityPhrase(agent: AgentActivity): { phrase: string; asking: boolean; } { + // A long command's own word on what it is doing beats a reading of the call that started it. + if (agent.command !== undefined) + return { phrase: agent.command.line, asking: false }; const latest = agent.tools.at(-1); if (latest === undefined) { if (agent.role === "coordinator") diff --git a/packages/schemas/src/activity.ts b/packages/schemas/src/activity.ts index 6a2fd36c..54a63ee2 100644 --- a/packages/schemas/src/activity.ts +++ b/packages/schemas/src/activity.ts @@ -12,6 +12,18 @@ export const ActivityTool = type({ }); export type ActivityTool = typeof ActivityTool.infer; +// What a long command a run is executing last said about itself: a line in the person's words, +// such as "installing the app's packages" or "checking the mockup: page 7 of 18", and when. The +// sandbox writes it beside the run's workspace while the command runs and removes it after, and +// the hub reads it from a sidecar on its own machine. +export const COMMAND_PROGRESS_FILE = "command-progress.json"; +export const CommandProgress = type({ + "+": "reject", + line: "string > 0", + at: "string > 0", +}); +export type CommandProgress = typeof CommandProgress.infer; + // A run at work in the project, with what its own inference events last showed. A run the hub // has seen no event from since the hub started has no tools and no text. export const AgentActivity = type({ @@ -21,6 +33,7 @@ export const AgentActivity = type({ since: "string > 0", tools: ActivityTool.array().atMostLength(3), "said?": "string > 0", + "command?": CommandProgress, }); export type AgentActivity = typeof AgentActivity.infer; From 922b92e92efaf7d465f449f5b60a03f2f0da39bf Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Mon, 2 Nov 2026 02:46:06 +0000 Subject: [PATCH 2/2] feat(coder): report each page check-app opens while it runs check-app printed nothing until its checklist was complete, up to 100 s after it started. It now reports starting the app and each page it opens, with how many it has still to open, as progress lines the hub relays to the person; an agent running it itself sees no such lines. --- workflows/coder/check/check-app.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/workflows/coder/check/check-app.ts b/workflows/coder/check/check-app.ts index dd878ff9..51bb67e6 100644 --- a/workflows/coder/check/check-app.ts +++ b/workflows/coder/check/check-app.ts @@ -280,6 +280,11 @@ const WIDTHS = [ const MAX_PAGES = 30; // run_shell's executor stops a command at 120 s; stop crawling early enough to print a result. const DEADLINE = Date.now() + 100_000; +// A word on progress for the person watching, on stderr with the prefix the hub's sandbox relays. +// Only when the hub runs this check: an agent running it itself reads stderr and needs no such lines. +const progress = (line: string) => { + if (process.env["SB_PROGRESS"]) console.error(`progress: ${line}`); +}; const problems: Problem[] = []; // Overlap misfired 30 times across 8 well-built sites at two widths, so it is reported, not failed. @@ -630,6 +635,7 @@ if (staticRoot) { } const port = await freePort(); origin = `http://127.0.0.1:${port}`; + progress("checking the app: starting it"); const server = Bun.spawn(["bun", "run", "start"], { env: { ...process.env, PORT: String(port) }, stdout: "ignore", @@ -836,6 +842,10 @@ while (queue.length && visited.length < MAX_PAGES && Date.now() < DEADLINE) { const path = queue.shift()!; if (visited.includes(path)) continue; visited.push(path); + const ahead = new Set(queue.filter((link) => !visited.includes(link))).size; + progress( + `${staticRoot ? "checking the mockup" : "checking the app"}: page ${String(visited.length)}, ${String(ahead)} more to open`, + ); page = path; for (const [w, h] of WIDTHS) { width = w;