diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index fbfc7a0f..a26a2563 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; // A run held on an approval is mid-turn, so its activity reads as work; the pending record is what // says it waits on the person. Otherwise it is at work or parked between turns, which is idle. const inFlightState = ( @@ -862,6 +866,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); @@ -876,6 +910,7 @@ export function createArtifactService( const specialists = await rawDB .select({ runId: workflowRun.id, + address: workflowRun.address, bundle: workflowRunLaunchSpec.frozenApprovalBundle, }) .from(workflowRun) @@ -897,12 +932,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 7c26e9a0..63bb503a 100644 --- a/apps/web/src/pages/workspace/agent-activity.tsx +++ b/apps/web/src/pages/workspace/agent-activity.tsx @@ -98,6 +98,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; 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;