From ef9e1662ffd865a299705cc6cc7043ab866b3d1f Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Tue, 6 Oct 2026 17:39:37 +0100 Subject: [PATCH] fix: report agent-work delivery readiness at launch and handoff --- framework/cli/README.md | 32 ++++ framework/cli/package.json | 2 +- framework/cli/src/commands/bridge.ts | 36 ++-- framework/cli/src/commands/list.ts | 25 ++- framework/cli/src/commands/serve.ts | 8 +- framework/cli/src/commands/stop.ts | 19 +- framework/cli/src/lib/bridge.ts | 20 +- framework/cli/src/lib/delivery.ts | 133 ++++++++++++++ framework/cli/src/lib/registry.ts | 42 ++--- framework/cli/test/delivery-fixture.mjs | 54 ++++++ framework/cli/test/delivery.test.mjs | 231 ++++++++++++++++++++++++ skills/creator/SKILL.md | 30 ++- skills/modify/SKILL.md | 13 ++ 13 files changed, 573 insertions(+), 72 deletions(-) create mode 100644 framework/cli/src/lib/delivery.ts create mode 100644 framework/cli/test/delivery-fixture.mjs create mode 100644 framework/cli/test/delivery.test.mjs diff --git a/framework/cli/README.md b/framework/cli/README.md index a7cca4e..271b6d8 100644 --- a/framework/cli/README.md +++ b/framework/cli/README.md @@ -79,6 +79,38 @@ agent-app bridge start --foreground # run the loop here; Ctrl-C stops af agent-app bridge stop ``` +**Launch and handoff readiness.** Both a fresh `serve` and an already-serving +result inspect the live app. An app whose code queues agent work gets a +prominent warning and the exact `bridge start` command if no local bridge is +running. `serve` still returns success for a healthy server and does not start +agent runs automatically. Its JSON adds `agentWork`: `queuesAgentWork`, `state` +(`absent`, `stale`, `running`), `running`, `tasksWaiting`, the live `baseUrl` and +`bridgeTargetsLive` (true/false, or null for an unknown recorded endpoint). +`list --json` and `bridge` expose the same inspection; bridge's existing +`running` and `tasksWaiting` fields remain available. These reads always inspect +live even when operate commands target a recorded dev instance. +New bridge records include the endpoint actually selected at startup. A bridge +still watching dev, or a legacy record with an unknown endpoint, is flagged by +serve/list/bridge. Restart it after dev is gone before handing off live work. + +`list` marks queueing apps as `bridge:missing` or `bridge:stale` and shows waiting +work. `tasksWaiting` is the count observed in the submitted-task poll, not a +guaranteed total; `null`/`waiting:unknown` means the queue could not be read. +Identity is verified before sending the agent credential, and network probes +have short deadlines including body reads. Detection reuses the static trigger +scanner: dynamic/custom enqueue code may be missed, so observed waiting work +also makes an app relevant. Adapter queue support alone does not imply that an +app queues work. + +A missing local bridge does not rule out an external harness polling the queue. +A live PID or available harness route does not prove successful delivery. +Creator/modify finish queueing features with a live delivery check and, where +authorized, a detached `bridge start` that remains up after the launching +command exits. A one-shot or interactive foreground test is not that handoff. +`stop` leaves the bridge running and reports how to stop it separately; it can +resume polling when the app is served again. Reboot startup and crash supervision +are not provided by this check. + Flags: `--harness `, `--interval ` (default 5000), `--task-timeout ` (default 15 min), `--capability ` (repeatable — deliver only these). **Harness profiles** live in `$A2APP_HOME/harnesses.json` (`~/.a2app/harnesses.json`). `claude`, `codex`, `gemini` and `aider` are built in; an entry with the same `id` replaces a built-in outright rather than merging into it, so what the file says is what runs. diff --git a/framework/cli/package.json b/framework/cli/package.json index f676470..c529042 100644 --- a/framework/cli/package.json +++ b/framework/cli/package.json @@ -35,7 +35,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "tsc -p tsconfig.json && node test/copy-tree.test.mjs && node test/manifest-merge.test.mjs && node test/containment.test.mjs && node test/connect.test.mjs && node test/bridge.test.mjs && node test/agent-state.test.mjs", + "test": "tsc -p tsconfig.json && node test/copy-tree.test.mjs && node test/manifest-merge.test.mjs && node test/containment.test.mjs && node test/connect.test.mjs && node test/bridge.test.mjs && node test/agent-state.test.mjs && node test/delivery.test.mjs", "bundle-blueprints": "node scripts/bundle-blueprints.mjs", "bundle-skills": "node scripts/bundle-skills.mjs", "bundle": "node scripts/bundle-blueprints.mjs && node scripts/bundle-skills.mjs", diff --git a/framework/cli/src/commands/bridge.ts b/framework/cli/src/commands/bridge.ts index 3bb8421..c77ce00 100644 --- a/framework/cli/src/commands/bridge.ts +++ b/framework/cli/src/commands/bridge.ts @@ -30,7 +30,6 @@ import { bridgeLockPath, bridgeLogPath, clearBridgeRecord, - countWaiting, DEFAULT_INTERVAL_MS, DEFAULT_TASK_TIMEOUT_MS, logTail, @@ -47,16 +46,16 @@ import { isPidAlive, killTreeForce, terminateTree, waitForExit } from "../lib/pr import { loadProject, UsageError, type Project } from "../lib/project.js"; import { connect } from "../lib/target.js"; import { log } from "../lib/log.js"; +import { appCommand, inspectDelivery, inspectLocalBridge, reportDelivery, type RunningBridge } from "../lib/delivery.js"; /** A bridge that is recorded AND actually alive. A record whose process is gone * is not a running bridge — it is a leftover, and treating it as one would make * `start` refuse forever after a crash. */ -function runningBridge(dir: string): { pid: number; harness: string; mode: string; startedAt: string } | null { - const rec = readBridgeRecord(dir); +function runningBridge(dir: string): RunningBridge | null { + const rec = inspectLocalBridge(dir).running; if (rec === null) return null; if (rec.pid === process.pid) return null; - if (!isPidAlive(rec.pid)) return null; - return { pid: rec.pid, harness: rec.harness, mode: rec.mode, startedAt: rec.startedAt }; + return rec; } /** The five rungs as a person needs to read them when nothing worked. */ @@ -80,7 +79,8 @@ function explainNoRoute(app: string, ladder: RungReport[]): void { async function status(args: string[], app: string, project: Project): Promise { const loaded = loadHarnesses(); const selection = await selectProfile(loaded, flag(args, "harness")); - const running = runningBridge(project.dir); + const agentWork = await inspectDelivery(project.dir, project.manifest.id, project.baseUrl); + const running = agentWork.running; let ladder: RungReport[] = []; let mode: string | null = null; @@ -90,14 +90,6 @@ async function status(args: string[], app: string, project: Project): Promise p.id), }; @@ -117,8 +110,11 @@ async function status(args: string[], app: string, project: Project): Promise 0) log.info(`${waiting} task(s) waiting in the queue`); + else log.info(`route available: ${selection.profile.id} via ${mode} (${selection.why}); this does not verify delivery`); + reportDelivery(agentWork, project.dir); + if (!agentWork.queuesAgentWork && agentWork.state === "absent") { + log.info(agentWork.tasksWaiting === null ? "waiting work: unknown (live queue could not be read)" : `${agentWork.tasksWaiting} task(s) waiting in the live queue`); + } for (const line of logTail(project.dir)) log.info(` log: ${line}`); log.raw(JSON.stringify(report, null, 2)); @@ -354,6 +350,7 @@ async function start(args: string[], app: string, project: Project): Promise { // That task is not lost: it stays `working` until the app's own sweeper // returns it to the queue, which is the same path a crashed agent takes. log.info( - `a task being worked on right now was interrupted; the app returns it to the queue by itself.\n` + - `The app keeps queueing tasks either way — nothing will claim them until: agent-app ${app} bridge start`, + "Any task interrupted by this stop can return to the queue after its claim expires.\n" + + `The app can still queue work. Resume this local listener: ${appCommand(project.dir, "bridge start")}\n` + + "Another harness may be polling the queue independently.", ); log.raw(JSON.stringify({ ok: true, stopped: rec.pid }, null, 2)); return 0; diff --git a/framework/cli/src/commands/list.ts b/framework/cli/src/commands/list.ts index 6421d3a..6bc26e9 100644 --- a/framework/cli/src/commands/list.ts +++ b/framework/cli/src/commands/list.ts @@ -11,6 +11,7 @@ import { hasFlag } from "../lib/args.js"; import { list, prune } from "../lib/registry.js"; import { registryPath } from "../lib/registry.js"; import { log } from "../lib/log.js"; +import { appCommand } from "../lib/delivery.js"; export async function run(args: string[]): Promise { if (hasFlag(args, "prune")) { @@ -41,13 +42,25 @@ export async function run(args: string[]): Promise { // row — an abandoned candidate that only lived in `.a2app/dev.json` was // invisible here, which is exactly how it stayed abandoned. const dev = app.dev === null ? "" : app.dev.answering ? ` dev:${app.dev.port}` : " dev:stale"; - // A running bridge means this app can start agent runs on this machine. - // That is a standing capability, not a detail of one command, so it belongs - // where someone looks to see what their apps are doing. - const bridge = app.bridge === null ? "" : ` bridge:${app.bridge.harness}/${app.bridge.mode}`; + // Keep local process state separate from app health and queue observations. + const work = app.agentWork; + const relevant = work !== null && (work.queuesAgentWork || work.state !== "absent"); + const bridge = app.bridge !== null ? ` bridge:${app.bridge.harness}/${app.bridge.mode}` + : relevant ? ` bridge:${work.state === "stale" ? "stale" : "missing"}` : ""; + const waiting = relevant ? ` waiting:${work.tasksWaiting ?? "unknown"}` : ""; + const target = app.bridge !== null && work !== null && work.bridgeTargetsLive !== true + ? ` bridge-target:${work.bridgeTargetsLive === false ? "other" : "unknown"}` : ""; log.raw( - `${mark[app.status]} ${app.name.padEnd(width)} ${app.id} :${port.padEnd(5)} ${app.status.padEnd(11)} ${where}${dev}${bridge}`, + `${mark[app.status]} ${app.name.padEnd(width)} ${app.id} :${port.padEnd(5)} ${app.status.padEnd(11)} ${where}${dev}${bridge}${waiting}${target}`, ); + if (relevant && app.bridge === null) { + log.warn(`"${app.name}": ${work.tasksWaiting === null ? "waiting work unknown" : `${work.tasksWaiting} waiting task(s) observed`}; no local bridge. ` + + `Delivery is unverified; an external harness may be polling. Start: ${appCommand(app.path, "bridge start")} · diagnose: ${appCommand(app.path, "bridge")}`); + } + if (target) { + log.warn(`"${app.name}": the local bridge ${work?.bridgeTargetsLive === false ? "targets a different endpoint from live" : "has no recorded endpoint"}. ` + + `Check ${appCommand(app.path, "bridge")} before treating agent work as operational.`); + } } if (apps.some((a) => a.status === "unreachable")) { log.warn("▲ unreachable: another process holds that app's port — stop it, or give the app a different port."); @@ -63,7 +76,7 @@ export async function run(args: string[]): Promise { } if (apps.some((a) => a.bridge !== null)) { log.info( - "bridge:/ — that app's queue is being watched and can start agent runs here. " + + "bridge:/ — a local bridge process is running; its PID does not verify task delivery. " + "`agent-app bridge` for detail · `agent-app bridge stop` to end it.", ); } diff --git a/framework/cli/src/commands/serve.ts b/framework/cli/src/commands/serve.ts index 82cc132..0023971 100644 --- a/framework/cli/src/commands/serve.ts +++ b/framework/cli/src/commands/serve.ts @@ -34,6 +34,7 @@ import { identifyApp, pollHealth } from "../lib/net.js"; import { killTreeForce, spawnBackgroundShell } from "../lib/proc.js"; import { runShell } from "../lib/shell.js"; import { log } from "../lib/log.js"; +import { inspectDelivery, reportDelivery } from "../lib/delivery.js"; function readServe(file: string): { pid: number } | null { try { @@ -86,6 +87,8 @@ export async function run(args: string[], app: string): Promise { if (servingId === project.manifest.id) { const prev = readServe(servePath); log.ok(`already serving on ${project.baseUrl}${prev ? ` (pid ${prev.pid})` : ""}`); + const agentWork = await inspectDelivery(project.dir, project.manifest.id, project.baseUrl); + reportDelivery(agentWork, project.dir); const shown = await show(); log.raw( JSON.stringify( @@ -96,6 +99,7 @@ export async function run(args: string[], app: string): Promise { url: project.baseUrl, pid: prev?.pid ?? null, alreadyRunning: true, + agentWork, ...shown, }, null, @@ -201,6 +205,8 @@ export async function run(args: string[], app: string): Promise { "\n", ); log.ok(`serving "${project.manifest.name}" on ${project.baseUrl} (pid ${pid})`); + const agentWork = await inspectDelivery(project.dir, project.manifest.id, project.baseUrl); + reportDelivery(agentWork, project.dir); // A relaunch after a code change is the moment a tab opened earlier goes // stale, and the person looking at it has no way to know. The View's update // watcher tells them; say so here so the loop is visible from the terminal @@ -221,7 +227,7 @@ export async function run(args: string[], app: string): Promise { const shown = await show(); log.raw( JSON.stringify( - { ok: true, id: project.manifest.id, name: project.manifest.name, url: project.baseUrl, pid, port, ...shown }, + { ok: true, id: project.manifest.id, name: project.manifest.name, url: project.baseUrl, pid, port, agentWork, ...shown }, null, 2, ), diff --git a/framework/cli/src/commands/stop.ts b/framework/cli/src/commands/stop.ts index f066ce2..47e85cb 100644 --- a/framework/cli/src/commands/stop.ts +++ b/framework/cli/src/commands/stop.ts @@ -18,6 +18,7 @@ import { withLock } from "../lib/lock.js"; import { identifyApp } from "../lib/net.js"; import { isPidAlive, killTreeForce, terminateTree, waitForExit } from "../lib/proc.js"; import { log } from "../lib/log.js"; +import { appCommand, inspectLocalBridge } from "../lib/delivery.js"; /** * `stop --dev`: tear down the DEV instance (abandoning the candidate) and @@ -65,6 +66,14 @@ export async function run(args: string[], app: string): Promise { return withLock(serveLock, async () => { const servePath = join(project.dir, ".a2app", "serve.json"); + const reportStopped = (stopped: number | null): void => { + const bridge = inspectLocalBridge(project.dir); + if (bridge.running !== null) { + log.info(`the local bridge remains running (pid ${bridge.running.pid}) and continues polling; ` + + `it can resume when the app returns. Stop it separately: ${appCommand(project.dir, "bridge stop")}`); + } + log.raw(JSON.stringify({ ok: true, stopped, bridge }, null, 2)); + }; const clearRecord = async (): Promise => { rmSync(servePath, { force: true }); @@ -84,7 +93,7 @@ export async function run(args: string[], app: string): Promise { if (!existsSync(servePath)) { log.info("not serving (no .a2app/serve.json)"); - log.raw(JSON.stringify({ ok: true, stopped: null }, null, 2)); + reportStopped(null); return 0; } @@ -100,7 +109,7 @@ export async function run(args: string[], app: string): Promise { if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 1) { log.warn(`serve.json has no usable pid — clearing the record without killing anything.`); await clearRecord(); - log.raw(JSON.stringify({ ok: true, stopped: null }, null, 2)); + reportStopped(null); return 0; } @@ -115,13 +124,13 @@ export async function run(args: string[], app: string): Promise { `this app is not answering on port ${appPort} (recorded pid ${pid}) — clearing a stale serve record without killing.`, ); await clearRecord(); - log.raw(JSON.stringify({ ok: true, stopped: null }, null, 2)); + reportStopped(null); return 0; } } else if (!isPidAlive(pid)) { log.info(`recorded process (pid ${pid}) is not running — clearing stale record.`); await clearRecord(); - log.raw(JSON.stringify({ ok: true, stopped: null }, null, 2)); + reportStopped(null); return 0; } @@ -143,7 +152,7 @@ export async function run(args: string[], app: string): Promise { await clearRecord(); log.ok(`stopped (pid ${pid})`); - log.raw(JSON.stringify({ ok: true, stopped: pid }, null, 2)); + reportStopped(pid); return 0; }); } diff --git a/framework/cli/src/lib/bridge.ts b/framework/cli/src/lib/bridge.ts index 878414e..ab349f4 100644 --- a/framework/cli/src/lib/bridge.ts +++ b/framework/cli/src/lib/bridge.ts @@ -88,6 +88,8 @@ export interface BridgeRecord { mode: string; intervalMs: number; startedAt: string; + /** Endpoint fixed at startup; a dev bridge must not be mistaken for live. */ + baseUrl?: string; /** pid of a gateway this bridge started, so `stop` can take it down too */ gatewayPid?: number; } @@ -111,13 +113,14 @@ export function readBridgeRecord(dir: string): BridgeRecord | null { if (!existsSync(file)) return null; try { const raw = readJsonFile>(file); - if (typeof raw.pid !== "number") return null; + if (typeof raw.pid !== "number" || !Number.isInteger(raw.pid) || raw.pid <= 1) return null; return { pid: raw.pid, - harness: raw.harness ?? "unknown", - mode: raw.mode ?? "unknown", + harness: typeof raw.harness === "string" ? raw.harness : "unknown", + mode: typeof raw.mode === "string" ? raw.mode : "unknown", intervalMs: raw.intervalMs ?? DEFAULT_INTERVAL_MS, - startedAt: raw.startedAt ?? "", + startedAt: typeof raw.startedAt === "string" ? raw.startedAt : "", + ...(typeof raw.baseUrl === "string" ? { baseUrl: raw.baseUrl } : {}), ...(typeof raw.gatewayPid === "number" ? { gatewayPid: raw.gatewayPid } : {}), }; } catch { @@ -802,11 +805,16 @@ export async function pumpLoop(ctx: BridgeContext, intervalMs: number, stopped: } /** How many tasks are waiting right now — used by `bridge` status. */ -export async function countWaiting(client: A2AppClient): Promise { +export async function countWaiting(client: Pick): Promise { try { const res = await client.pollTasks("submitted"); if (!res.ok) return null; - return parseTasks(res.json).length; + const body = res.json as { tasks?: unknown } | null; + if (!body || !Array.isArray(body.tasks)) return null; + const tasks = parseTasks(body); + // A malformed success envelope is not evidence of an empty queue. + if (tasks.length !== body.tasks.length) return null; + return tasks.length; } catch { return null; } diff --git a/framework/cli/src/lib/delivery.ts b/framework/cli/src/lib/delivery.ts new file mode 100644 index 0000000..c122642 --- /dev/null +++ b/framework/cli/src/lib/delivery.ts @@ -0,0 +1,133 @@ +/** Read-only launch/handoff inspection. A local bridge PID is not proof of + * delivery, and its absence does not rule out an external queue subscriber. */ +import { existsSync } from "node:fs"; +import type { A2AppResponse } from "@a2app/sdk"; +import { scanAgentState } from "./agentState.js"; +import { bridgeRecordPath, countWaiting, readBridgeRecord, type BridgeRecord } from "./bridge.js"; +import { isPidAlive } from "./proc.js"; +import { readAgentToken } from "./project.js"; +import { log } from "./log.js"; + +export type RunningBridge = Pick; +export interface LocalBridge { + state: "absent" | "stale" | "running"; + running: RunningBridge | null; +} + +export function inspectLocalBridge(dir: string): LocalBridge { + const rec = readBridgeRecord(dir); + if (rec === null || !isPidAlive(rec.pid)) { + return { state: existsSync(bridgeRecordPath(dir)) ? "stale" : "absent", running: null }; + } + return { + state: "running", + running: { pid: rec.pid, harness: rec.harness, mode: rec.mode, startedAt: rec.startedAt, ...(rec.baseUrl ? { baseUrl: rec.baseUrl } : {}) }, + }; +} + +export interface DeliveryInspection extends LocalBridge { + /** Static trigger detection or observed submitted work; not queue support alone. + * Dynamic/custom enqueue implementations may not be found by the scanner. */ + queuesAgentWork: boolean; + /** Number observed in the poll response; null means unavailable, never zero. */ + tasksWaiting: number | null; + /** Always the live endpoint, even when operate commands target dev. */ + baseUrl: string; + /** null for legacy records without an endpoint, or no local process. */ + bridgeTargetsLive: boolean | null; +} + +/** The SDK timeout currently bounds response headers, not a stalled body. + * Status probes need a deadline through the entire read, without leaving an + * abandoned fetch alive after the caller has returned. */ +async function readReply(baseUrl: string, path: string, timeoutMs: number, token: string | null = null): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(`${baseUrl}${path}`, { + signal: controller.signal, + redirect: "error", + headers: { "X-A2App-Agent": "agent-app-status", ...(token ? { "X-A2App-Token": token } : {}) }, + }); + const reader = res.body?.getReader(); + const decoder = new TextDecoder(); + let body = ""; + let bytes = 0; + if (reader) { + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > 1024 * 1024) { controller.abort(); throw new Error("status response exceeds 1 MiB"); } + body += decoder.decode(value, { stream: true }); + } + body += decoder.decode(); + } finally { reader.releaseLock(); } + } + return { status: res.status, ok: res.ok, body, json: JSON.parse(body) }; + } finally { clearTimeout(timer); } +} + +/** At most two bounded requests, including response-body reads. Nothing is + * claimed, started, cleared or written. Credentials go only to the verified app. */ +export async function inspectDelivery( + dir: string, + appId: string, + baseUrl: string, + timeoutMs = 750, +): Promise { + const local = inspectLocalBridge(dir); + let queuesAgentWork = false; + try { + queuesAgentWork = scanAgentState(dir).triggers.length > 0; + } catch { + // A malformed/unreadable canon must not turn a status courtesy into a + // failed launch. A queue read can still establish that work is waiting. + } + let tasksWaiting: number | null = null; + try { + const identity = await readReply(baseUrl, "/api/_a2app", timeoutMs); + const body = identity.json as { a2app?: boolean; app?: { id?: string } } | null; + if (identity.ok && body?.a2app === true && body.app?.id === appId) { + tasksWaiting = await countWaiting({ + pollTasks: () => readReply(baseUrl, "/api/_a2app/tasks?status=submitted", timeoutMs, readAgentToken(dir)), + }); + } + } catch { + // Missing credential, stopped app, wrong identity or failed queue read: + // the queue state is unknown, not an empty queue. + } + const bridgeTargetsLive = local.running?.baseUrl ? local.running.baseUrl === baseUrl : null; + return { ...local, queuesAgentWork: queuesAgentWork || (tasksWaiting ?? 0) > 0, tasksWaiting, baseUrl, bridgeTargetsLive }; +} + +/** A copyable command, including paths with spaces/apostrophes. */ +export function appCommand(dir: string, command: string): string { + const quoted = process.platform === "win32" + ? `'${dir.replace(/'/g, "''")}'` + : `'${dir.replace(/'/g, "'\\''")}'`; + return `agent-app ${quoted} ${command}`; +} + +export function reportDelivery(info: DeliveryInspection, dir: string): void { + if (!info.queuesAgentWork && info.state === "absent") return; + if (info.running !== null) { + log.info(`local bridge process running (pid ${info.running.pid}) — ${info.running.harness}/${info.running.mode}; task delivery is not verified by process liveness`); + if (info.bridgeTargetsLive !== true) { + log.warn( + `AGENT WORK DELIVERY NOT VERIFIED: ${info.bridgeTargetsLive === false ? `the local bridge watches ${info.running.baseUrl}, rather than live at ${info.baseUrl}` : "the recorded bridge endpoint is unknown"}.\n` + + ` After dev is gone, restart it for live: ${appCommand(dir, "bridge stop")} then ${appCommand(dir, "bridge start")}`, + ); + } + } else { + log.warn( + `AGENT WORK DELIVERY NOT VERIFIED: this app ${info.queuesAgentWork ? "queues agent work" : "has a stale bridge record"}, ` + + `but no local bridge is running${info.state === "stale" ? " (stale record)" : ""}. Tasks may remain unclaimed.\n` + + ` Start a background bridge: ${appCommand(dir, "bridge start")}\n` + + ` Check the harness route: ${appCommand(dir, "bridge")}\n` + + " A harness polling the queue independently is also valid; no local bridge does not prove no external listener.", + ); + } + log.info(info.tasksWaiting === null ? "waiting work: unknown (live queue could not be read)" : `${info.tasksWaiting} waiting task(s) observed in the live queue`); +} diff --git a/framework/cli/src/lib/registry.ts b/framework/cli/src/lib/registry.ts index 1769517..a9ffdd9 100644 --- a/framework/cli/src/lib/registry.ts +++ b/framework/cli/src/lib/registry.ts @@ -27,6 +27,7 @@ import { homePath, REGISTRY_FILE, writeFileAtomic } from "./home.js"; import { readDevRecord } from "./instance.js"; import { withHomeLock } from "./lock.js"; import { log } from "./log.js"; +import { inspectDelivery, type DeliveryInspection } from "./delivery.js"; export const REGISTRY_VERSION = 1; @@ -78,6 +79,8 @@ export interface AppView extends RegistryEntry { * each one, and a bridge someone started weeks ago is invisible. */ bridge: { pid: number; harness: string; mode: string } | null; + /** Read-only delivery inspection of live, independent of dev routing. */ + agentWork: DeliveryInspection | null; } export function registryPath(): string { @@ -276,30 +279,6 @@ function serveRecord(appDir: string): { pid: number; port: number; url: string } } } -/** - * The bridge record for an app, when its process is still alive. - * - * A record whose pid is gone is a leftover, not a bridge — reporting one would - * tell a user their app can reach an agent when nothing is watching its queue. - */ -function bridgeRecord(appDir: string): AppView["bridge"] { - const file = join(appDir, ".a2app", "bridge.json"); - if (!existsSync(file)) return null; - try { - const rec = JSON.parse(readFileSync(file, "utf8")) as { pid?: number; harness?: string; mode?: string }; - if (typeof rec.pid !== "number") return null; - try { - process.kill(rec.pid, 0); - } catch (err) { - // EPERM means it exists but is not ours to signal; anything else means gone. - if ((err as NodeJS.ErrnoException).code !== "EPERM") return null; - } - return { pid: rec.pid, harness: rec.harness ?? "unknown", mode: rec.mode ?? "unknown" }; - } catch { - return null; - } -} - /** The app's own manifest, when readable — authoritative over the entry. */ function manifestOf(appDir: string): { id?: string; name?: string; port?: number } | null { const file = join(appDir, "manifest.json"); @@ -326,7 +305,7 @@ async function devView(dir: string, appId: string): Promise { export async function view(entry: RegistryEntry): Promise { const dir = resolve(entry.path); if (!existsSync(join(dir, "manifest.json"))) { - return { ...entry, status: "missing", url: null, pid: null, dev: null, bridge: null }; + return { ...entry, status: "missing", url: null, pid: null, dev: null, bridge: null, agentWork: null }; } const manifest = manifestOf(dir); const port = manifest?.port ?? entry.port; @@ -336,15 +315,19 @@ export async function view(entry: RegistryEntry): Promise { name: manifest?.name ?? entry.name, ...(port !== undefined ? { port } : {}), }; - const dev = await devView(dir, fresh.id); - const bridge = bridgeRecord(dir); + const [dev, agentWork] = await Promise.all([ + devView(dir, fresh.id), + inspectDelivery(dir, fresh.id, `http://127.0.0.1:${port ?? 8090}`), + ]); + const running = agentWork.running; + const bridge = running === null ? null : { pid: running.pid, harness: running.harness, mode: running.mode }; if (port === undefined || !(await portInUse(port))) { - return { ...fresh, status: "stopped", url: null, pid: null, dev, bridge }; + return { ...fresh, status: "stopped", url: null, pid: null, dev, bridge, agentWork }; } // Something holds the port — is it this app, or a stranger? const servingId = await identify(port); if (servingId === null || servingId !== fresh.id) { - return { ...fresh, status: "unreachable", url: null, pid: null, dev, bridge }; + return { ...fresh, status: "unreachable", url: null, pid: null, dev, bridge, agentWork }; } return { ...fresh, @@ -353,6 +336,7 @@ export async function view(entry: RegistryEntry): Promise { pid: serveRecord(dir)?.pid ?? null, dev, bridge, + agentWork, }; } diff --git a/framework/cli/test/delivery-fixture.mjs b/framework/cli/test/delivery-fixture.mjs new file mode 100644 index 0000000..8380590 --- /dev/null +++ b/framework/cli/test/delivery-fixture.mjs @@ -0,0 +1,54 @@ +/** Wire-level app for the delivery lifecycle test; launched by serve, not by + * the test runner. State survives stop/serve so later work can be delivered. */ +import { createServer } from "node:http"; +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; +const stateFile = new URL("./state.json", import.meta.url); +const requestsFile = new URL("./requests.jsonl", import.meta.url); +const read = () => JSON.parse(readFileSync(stateFile, "utf8")); +const save = (state) => writeFileSync(stateFile, JSON.stringify(state)); +const server = createServer(async (req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + appendFileSync(requestsFile, JSON.stringify({ path: url.pathname, method: req.method, credential: !!req.headers["x-a2app-token"] }) + "\n"); + const send = (code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; + let raw = ""; + for await (const chunk of req) raw += chunk; + const body = raw ? JSON.parse(raw) : {}; + const state = read(); + const path = url.pathname; + if (path === "/api/_a2app" || path === "/.well-known/a2app.json") { + return send(200, { a2app: true, protocol: "0.1", adapterVersion: "0.1.0", app: { id: state.appId ?? "delivery_test", name: "Delivery Test" } }); + } + if (path === "/api/_a2app/describe") { + return send(200, { a2app: true, level: "root", app: { id: "delivery_test", name: "Delivery Test" }, modules: [], next: [] }); + } + if (path === "/api/_a2app/tasks") { + if (state.queueMode === "forbidden") return send(401, { code: "unauthorized" }); + if (state.queueMode === "malformed") return send(200, { notTasks: [] }); + if (state.queueMode === "bad-task") return send(200, { tasks: [null] }); + if (state.queueMode === "hang") return; + if (state.queueMode === "hang-body") { + res.writeHead(200, { "content-type": "application/json" }); res.write('{"tasks":'); return; + } + return send(200, { a2app: true, tasks: state.tasks.filter((t) => t.status === (url.searchParams.get("status") ?? "submitted")), pollAfterMs: 100 }); + } + if (path.startsWith("/api/_a2app/tasks/")) { + const [id, action] = path.slice("/api/_a2app/tasks/".length).split("/"); + const task = state.tasks.find((t) => t.id === id); + if (!task) return send(404, { code: "task_not_found" }); + if (!action) return send(200, task); + if (action === "claim") { + if (task.status !== "submitted") return send(409, { code: "task_not_claimable" }); + task.status = "working"; + task.claim = { credentialId: "delivery_test", principal: "owner", claimedAt: new Date().toISOString() }; + } else if (action === "progress") { + if (task.status !== "working") return send(409, { code: "task_not_claimable" }); + task.progress = { ...task.progress, ...body }; + } else if (action === "complete") { + if (task.status !== "working") return send(409, { code: "task_not_claimable" }); + task.status = body.status; task.result = body.result ?? null; task.reason = body.reason ?? null; + } else return send(404, { code: "usage" }); + task.updatedAt = new Date().toISOString(); save(state); return send(200, task); + } + send(404, { code: "usage" }); +}); +server.listen(Number(process.env.PORT), "127.0.0.1"); diff --git a/framework/cli/test/delivery.test.mjs b/framework/cli/test/delivery.test.mjs new file mode 100644 index 0000000..26c8167 --- /dev/null +++ b/framework/cli/test/delivery.test.mjs @@ -0,0 +1,231 @@ +/** #12: launch and handoff must not silently strand later work. These tests + * drive the actual CLIs and detached processes against a wire-level app. */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { freeEphemeralPort } from "../dist/lib/net.js"; +import { inspectDelivery, inspectLocalBridge } from "../dist/lib/delivery.js"; +import { isPidAlive, killTreeForce } from "../dist/lib/proc.js"; +import { writeSystemHashes } from "../dist/lib/canon.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const entry = resolve(here, "../dist/agent-app.js"); +const base = mkdtempSync(join(tmpdir(), "a2app-delivery-")); +const dir = join(base, "app with spaces"); +const home = join(base, "home"); +mkdirSync(join(dir, ".a2app"), { recursive: true }); mkdirSync(home); +// Pi's own shell tool must reach the CLI built from this checkout, not a +// globally installed older framework. The shim lives only in this temp tree. +const bin = join(base, "bin"); mkdirSync(bin); +const operate = resolve(here, "../dist/a2app.js"); +const shQuote = (s) => `'${s.replace(/'/g, "'\\''")}'`; +writeFileSync(join(bin, "a2app"), `#!/bin/sh\nexec ${shQuote(process.execPath.replace(/\\/g, "/"))} ${shQuote(operate.replace(/\\/g, "/"))} "$@"\n`, { mode: 0o755 }); +writeFileSync(join(bin, "a2app.cmd"), `@echo off\r\n"${process.execPath}" "${operate}" %*\r\n`); +const env = { ...process.env, A2APP_HOME: home }; +const pathKey = Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "PATH"; +env[pathKey] = bin + delimiter + (env[pathKey] ?? ""); +const port = await freeEphemeralPort(); +const baseUrl = `http://127.0.0.1:${port}`; +const manifest = { + id: "delivery_test", name: "Delivery Test", agentAppVersion: "0.1.0", adapterVersion: "0.1.0", + authMode: "none", modules: [{ name: "work" }], port, + pipeline: { install: "", build: "", start: `"${process.execPath}" server.mjs`, health: "/api/_a2app" }, +}; +writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest)); +writeFileSync(join(dir, ".agent-token"), "delivery_test_credential\n"); +copyFileSync(join(here, "delivery-fixture.mjs"), join(dir, "server.mjs")); +writeSystemHashes(dir, ["manifest.json"]); +const schema = join(dir, "schema.py"); +writeFileSync(schema, 'trigger("review.requested", {}, capability="summarize")\n'); // no human View +const statePath = join(dir, "state.json"); +const readState = () => JSON.parse(readFileSync(statePath, "utf8")); +const writeState = (state) => writeFileSync(statePath, JSON.stringify(state)); +writeState({ tasks: [] }); +const profiles = (routes) => writeFileSync(join(home, "harnesses.json"), JSON.stringify({ version: 1, default: "delivery", harnesses: [{ id: "delivery", routes }] })); +const mock = join(base, "harness.mjs"); +writeFileSync(mock, 'console.log("The requested summary is: Later work was delivered.");\n'); +profiles([{ mode: "headless", command: process.execPath, args: [mock, "{prompt}"] }]); +const task = (id) => ({ + id, app: "delivery_test", event: null, status: "submitted", request: { capability: "summarize", payload: { text: "A detached bridge should deliver work queued after its launching session ends." } }, + claim: null, progress: {}, result: null, reason: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), +}); +function cli(args) { + return new Promise((done, reject) => { + const child = spawn(process.execPath, [entry, ...args], { env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); + let stdout = "", stderr = ""; + const timer = setTimeout(() => { killTreeForce(child.pid); reject(new Error(`CLI timed out: ${args.join(" ")}\n${stderr}`)); }, 25_000); + child.stdout.on("data", (d) => stdout += d); child.stderr.on("data", (d) => stderr += d); + child.on("error", (e) => { clearTimeout(timer); reject(e); }); + child.on("close", (code) => { clearTimeout(timer); done({ code, stdout, stderr }); }); + }); +} +async function run(args) { + const r = await cli(args); assert.equal(r.code, 0, `${args.join(" ")}\n${r.stderr}`); return r; +} +const json = (r) => JSON.parse(r.stdout); +async function until(fn, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { if (fn()) return; await new Promise((r) => setTimeout(r, 100)); } + throw new Error("condition did not become true in time"); +} +const waiting = (id) => readState().tasks.find((t) => t.id === id); +let bridgePid = null, servePid = null; +try { + const first = await run([dir, "serve"]); servePid = json(first).pid; + assert.equal(json(first).ok, true); assert.equal(json(first).agentWork.queuesAgentWork, true); + assert.equal(json(first).agentWork.state, "absent"); assert.equal(json(first).agentWork.tasksWaiting, 0); + assert.match(first.stderr, /AGENT WORK DELIVERY NOT VERIFIED/); assert.match(first.stderr, /app with spaces.*bridge start/); + assert.match(first.stderr, /external listener/); assert.equal(existsSync(join(dir, ".a2app/bridge.json")), false); + const again = await run([dir, "serve"]); + assert.equal(json(again).alreadyRunning, true); assert.deepEqual(json(again).agentWork, json(first).agentWork); + assert.match(again.stderr, /AGENT WORK DELIVERY NOT VERIFIED/); + + // Exactly the original failure: a one-shot test works, later work is stranded. + writeState({ tasks: [task("one_shot")] }); + await run([dir, "bridge", "start", "--once"]); + assert.equal(waiting("one_shot").status, "completed"); + writeState({ tasks: [task("later")] }); + const warned = await run([dir, "serve"]); + assert.equal(json(warned).agentWork.tasksWaiting, 1); assert.match(warned.stderr, /DELIVERY NOT VERIFIED/); + const list = await run(["list"]); + assert.match(list.stdout, /bridge:missing.*waiting:1/); assert.match(list.stderr, /external harness may be polling/); + const listed = json(await run(["list", "--json"])).apps[0]; + assert.equal(listed.status, "running"); assert.equal(listed.bridge, null); assert.equal(listed.agentWork.tasksWaiting, 1); + const status = json(await run([dir, "bridge"])); + assert.equal(status.running, null); assert.equal(status.tasksWaiting, 1); assert.equal(status.agentWork.baseUrl, baseUrl); + + // Subscribe-only routes do not manufacture a local process or claim work. + profiles([{ mode: "subscribe", hint: "Keep the harness's own polling loop running" }]); + const subscribed = json(await run([dir, "bridge", "start"])); + assert.equal(subscribed.started, false); assert.equal(subscribed.mode, "subscribe"); + assert.equal(json(await run([dir, "bridge"])).running, null); assert.equal(waiting("later").status, "submitted"); + profiles([{ mode: "headless", command: process.execPath, args: [mock, "{prompt}"] }]); + + // Stale/malformed records stay on disk and are never presented as live. + const stalePath = join(dir, ".a2app/bridge.json"); + for (const record of [{ pid: 0 }, { pid: -1 }, { pid: 1 }, { pid: 1.5 }, { pid: "123" }, null]) { + writeFileSync(stalePath, JSON.stringify(record)); + assert.equal(inspectLocalBridge(dir).state, "stale"); assert.equal(inspectLocalBridge(dir).running, null); + } + const dead = spawn(process.execPath, ["-e", ""], { windowsHide: true }); const deadPid = dead.pid; + await new Promise((r) => dead.on("close", r)); + writeFileSync(stalePath, JSON.stringify({ pid: deadPid })); + assert.equal(inspectLocalBridge(dir).state, "stale"); + assert.match((await run(["list"])).stdout, /bridge:stale.*waiting:1/); + assert.equal(existsSync(stalePath), true); rmSync(stalePath); + + // A failed/malformed/unresponsive queue never looks like zero waiting work. + for (const queueMode of ["forbidden", "malformed", "bad-task", "hang", "hang-body"]) { + writeState({ tasks: [], queueMode }); + const started = Date.now(); + const info = await inspectDelivery(dir, "delivery_test", baseUrl, 80); + assert.equal(info.tasksWaiting, null, queueMode); assert.ok(Date.now() - started < 1200, queueMode); + } + const unknown = json(await run(["list", "--json"])).apps[0].agentWork; + assert.equal(unknown.tasksWaiting, null); assert.match((await run(["list"])).stdout, /waiting:unknown/); + + // A stranger on the live port must never receive the app's credential/queue read. + writeState({ tasks: [task("stranger")], appId: "different_app" }); + const before = readFileSync(join(dir, "requests.jsonl"), "utf8").trim().split("\n").length; + assert.equal((await inspectDelivery(dir, "delivery_test", baseUrl)).tasksWaiting, null); + const seen = readFileSync(join(dir, "requests.jsonl"), "utf8").trim().split("\n").slice(before).map(JSON.parse); + assert.equal(seen.length, 1); assert.equal(seen[0].path, "/api/_a2app"); assert.equal(seen[0].credential, false); + writeState({ tasks: [task("live_only")] }); + + // Status inspects live, even though operate traffic would target dev. + let devReads = 0; + let devTasks = [task("dev_1"), task("dev_2")]; + const dev = createServer((req, res) => { + if (req.url.includes("tasks")) devReads++; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ a2app: true, app: { id: "delivery_test" }, tasks: devTasks })); + }); + await new Promise((r) => dev.listen(0, "127.0.0.1", r)); + try { + const devPort = dev.address().port; + writeFileSync(join(dir, ".a2app/dev.json"), JSON.stringify({ pid: process.pid, port: devPort, url: `http://127.0.0.1:${devPort}`, bootDir: join(dir, ".a2app/dev/test"), startedAt: new Date().toISOString(), healthy: true })); + for (const args of [[dir, "serve"], [dir, "bridge"]]) { + assert.equal(json(await run(args)).agentWork.tasksWaiting, 1); + } + const row = json(await run(["list", "--json"])).apps[0]; + assert.equal(row.agentWork.tasksWaiting, 1); assert.equal(row.dev.answering, true); assert.equal(devReads, 0); + // A bridge started against dev is genuinely alive, yet cannot deliver + // live work. Persist its actual endpoint so handoff can catch this case. + devTasks = []; + bridgePid = json(await run([dir, "bridge", "start", "--interval", "250"])).pid; + const wrongTarget = await run([dir, "serve"]); + assert.equal(json(wrongTarget).agentWork.state, "running"); + assert.equal(json(wrongTarget).agentWork.bridgeTargetsLive, false); + assert.equal(json(wrongTarget).agentWork.running.baseUrl, `http://127.0.0.1:${devPort}`); + assert.match(wrongTarget.stderr, /rather than live/); + assert.match((await run(["list"])).stdout, /bridge-target:other/); + await run([dir, "bridge", "stop"]); bridgePid = null; + } finally { rmSync(join(dir, ".a2app/dev.json")); await new Promise((r) => dev.close(r)); } + + // Queue support and capability-free events alone must stay quiet. Waiting + // work still counts if custom/dynamic enqueue code eludes the static scanner. + writeFileSync(schema, 'trigger("changed", {}, None)\n# trigger("comment", {}, "summarize")\n'); + writeState({ tasks: [] }); + const quiet = await run([dir, "serve"]); + assert.equal(json(quiet).agentWork.queuesAgentWork, false); assert.doesNotMatch(quiet.stderr, /DELIVERY NOT VERIFIED/); + assert.doesNotMatch((await run(["list"])).stdout, /bridge:missing/); + writeState({ tasks: [task("dynamic")] }); + assert.equal(json(await run([dir, "serve"])).agentWork.queuesAgentWork, true); + assert.match((await run(["list"])).stdout, /bridge:missing.*waiting:1/); + + // The launching CLI exits; then new work is delivered by the detached bridge. + writeState({ tasks: [] }); + const started = json(await run([dir, "bridge", "start", "--interval", "250"])); bridgePid = started.pid; + assert.ok(isPidAlive(bridgePid)); + const reused = json(await run([dir, "bridge", "start"])); + assert.equal(reused.alreadyRunning, true); assert.equal(reused.pid, bridgePid); + assert.equal(reused.baseUrl, baseUrl); + const bridgePath = join(dir, ".a2app/bridge.json"); + const currentRecord = readFileSync(bridgePath, "utf8"); + const legacy = JSON.parse(currentRecord); delete legacy.baseUrl; + writeFileSync(bridgePath, JSON.stringify(legacy)); + const legacyStatus = json(await run([dir, "bridge"])); + assert.equal(legacyStatus.agentWork.state, "running"); + assert.equal(legacyStatus.agentWork.bridgeTargetsLive, null); + assert.match((await run(["list"])).stdout, /bridge-target:unknown/); + writeFileSync(bridgePath, currentRecord); + writeState({ tasks: [task("after_session")] }); + await until(() => waiting("after_session")?.status === "completed"); + assert.equal(json(await run([dir, "serve"])).agentWork.running.pid, bridgePid); + assert.equal(json(await run([dir, "serve"])).agentWork.bridgeTargetsLive, true); + const stopped = await run([dir, "stop"]); + assert.match(stopped.stderr, /bridge remains running/); assert.match(stopped.stderr, /bridge stop/); + assert.equal(json(stopped).bridge.running.pid, bridgePid); assert.ok(isPidAlive(bridgePid)); servePid = null; + writeState({ tasks: [task("after_restart")] }); + const restarted = json(await run([dir, "serve"])); servePid = restarted.pid; + assert.equal(restarted.agentWork.state, "running"); + await until(() => waiting("after_restart")?.status === "completed"); + assert.equal(json(await run([dir, "bridge"])).running.pid, bridgePid); + await run([dir, "bridge", "stop"]); bridgePid = null; + + // Optional real Pi check: credentials and executable belong to the local + // operator. Never required by CI. The bridge starts Pi after its caller exits. + const piLauncher = process.env.A2APP_TEST_PI_LAUNCHER; + if (piLauncher) { + profiles([{ mode: "headless", command: process.execPath, args: [piLauncher, "-p", "{prompt}"] }]); + writeState({ tasks: [] }); + bridgePid = json(await run([dir, "bridge", "start", "--interval", "250", "--task-timeout", "180000"])).pid; + writeState({ tasks: [task("real_pi_after_session")] }); + await until(() => waiting("real_pi_after_session")?.status === "completed", 200_000); + const result = waiting("real_pi_after_session"); + assert.equal(typeof result.result?.summary, "string"); assert.ok(result.result.summary.length > 0); + console.log(`Real Pi handoff passed: ${result.result.summary}`); + await run([dir, "bridge", "stop"]); bridgePid = null; + } + console.log("✓ delivery: launch warnings, truthful queue/bridge state, live/dev isolation, detached handoff and restart"); +} finally { + if (bridgePid && isPidAlive(bridgePid)) killTreeForce(bridgePid); + if (servePid && isPidAlive(servePid)) killTreeForce(servePid); + // Cleanup only the temp tree this test created, after managed processes exit. + assert.equal(dirname(base), tmpdir()); + try { rmSync(base, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); } catch { /* Windows log handles may close later. */ } +} diff --git a/skills/creator/SKILL.md b/skills/creator/SKILL.md index 8d99e66..965e255 100644 --- a/skills/creator/SKILL.md +++ b/skills/creator/SKILL.md @@ -249,11 +249,13 @@ says whether yours does and how to reach it. If it does not, handle the event with plain code and record the limitation in `reference/requirements.md` rather than inventing a mechanism. -Nothing is delivered until someone is listening: the user runs -`agent-app bridge start` (the framework triggers their harness) or their -harness polls `a2app tasks next --wait`. Say so when you ship a feature -that queues work, or it will look broken. `agent-app bridge` reports -whether this machine can reach a harness at all. +Nothing is delivered until someone is listening: a background +`agent-app bridge start` triggers their harness, or their harness polls +`a2app tasks next --wait`. For a feature that queues work, delivery is +part of the handoff, not just a command to mention. Complete the live delivery +check below. `agent-app bridge` reports the LIVE queue and local bridge, +plus which harness route is available; a route alone does not mean delivery +is running. **The UI that queues work must show that work until it is done.** An agent run takes seconds to minutes. A button that fires a toast and then goes quiet @@ -355,6 +357,24 @@ own and render that field the same way. manifest pipeline as a managed background process, polls health, and prints the URL — open it, see **Showing the app to the user** below. Never start a server by hand. +6. **Check live agent-work delivery.** If any delivered feature queues agent + work, run `agent-app bridge` after promote + serve. When app-triggered + agent runs are authorized and a delivery route exists, start/reuse plain + `agent-app bridge start`, then run `agent-app bridge` again and + confirm a running background process after the start command has exited. + Check `agentWork.bridgeTargetsLive` is true: a bridge started during testing + may still be watching the old dev port. If it is false or unknown, stop that + bridge and restart it after dev is gone, then inspect again. + A test with `--once`, `--dry-run`, or an interactive `--foreground` process + does not satisfy this handoff. Start the persistent bridge only after the + dev instance is destroyed: bridge start follows dev routing while dev is up. + A subscribe-only harness needs its own continuing polling loop; verify that + path where possible rather than expecting a local bridge. Existing user + authorization applies; do not ask again just because the build was promoted. + If delivery is unavailable, not authorized, or cannot be verified, name the + limitation and remedy in the handoff. A healthy server or a live bridge PID + alone does not prove the agent feature works. The View-state walk still + applies, and no local bridge does not prove there is no external listener. **Showing the app to the user.** A running app is not a delivered app until the person can see it. The framework cannot know what your harness can do, so YOU diff --git a/skills/modify/SKILL.md b/skills/modify/SKILL.md index 06f5d46..c59a649 100644 --- a/skills/modify/SKILL.md +++ b/skills/modify/SKILL.md @@ -139,6 +139,19 @@ agent-app open # and show it to the user (see below) `promote` applies migrations and launches NOTHING, so a change is not in front of the user until you serve again. Do not end a modify at `promote`. +**Check live agent-work delivery before finishing.** If the app has features +that queue agent work, follow the creator skill's live delivery check after +promote + serve: inspect `agent-app bridge`, start/reuse plain background +`bridge start` where agent runs are authorized and a route exists, then confirm +the process is still running after the command exits. Do not leave only a +`--once`, `--dry-run`, or interactive foreground test. Start after dev is gone +so the bridge watches live. A subscribe-only harness needs its own continuing +polling loop; verify it where possible. Confirm `agentWork.bridgeTargetsLive` +is true; a bridge left on the dev port needs stopping and restarting after dev +is gone. Existing authorization still applies. +Report any unavailable or unverified delivery and its remedy explicitly; a +successful serve does not establish that the agent feature is operational. + **Showing the app to the user.** A running app is not a delivered app until the person can see it. The framework cannot know what your harness can do, so YOU decide which of these you are: