From 23a19a818083c584f50fb637184dd6965a948fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Apr 2026 14:55:23 -0400 Subject: [PATCH] Promote artifact registration to a primary run outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-tool `afterToolCall` projection stays the primary write path but is now backed by an end-of-run reconciliation sweep on `RuntimeBridge`. `reconcileWorkspaceArtifacts` re-scans the workspace via `git status` before the run's resources are released, idempotently upserting any file the per-tool hook skipped — the exact gap that was leaving the DocumentCenter empty while the disk filled up. Projection failures stop being swallowed silently: the hook now logs a tool-level summary at INFO and throws are routed to ERROR so they are visible in the desktop log tail. The reconciliation sweep surfaces its own failures the same way, so an operator can tell the difference between "no files produced" and "files produced but registration broken". --- src/main/agent-runtime/artifact-projector.ts | 44 ++++++++++++++++++++ src/main/agent-runtime/hook-handlers.ts | 19 +++++++-- src/main/agent-runtime/runtime-bridge.ts | 31 ++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/main/agent-runtime/artifact-projector.ts b/src/main/agent-runtime/artifact-projector.ts index 35d8a76c..de578a07 100644 --- a/src/main/agent-runtime/artifact-projector.ts +++ b/src/main/agent-runtime/artifact-projector.ts @@ -141,6 +141,50 @@ export function projectToolExecutionToWorkProduct( return [{ kind: "skipped", reason: "unsupported_tool" }]; } +/** + * End-of-run reconciliation sweep. The per-tool `afterToolCall` hook + * registers artifacts eagerly, but individual projections can skip + * (bash against a non-git workspace, a path on the ignore list, a + * transient `git status` failure). Without a final sweep, those files + * land on disk but never in `work_products` — the DocumentCenter then + * looks empty even though the agent produced deliverables. + * + * The sweep uses the same `git status` source of truth as + * `projectBashArtifacts`; non-git workspaces are deliberately a no-op + * because there is no authoritative diff against HEAD. It is + * idempotent via `findWorkProductByExternalId`. + */ +export function reconcileWorkspaceArtifacts( + db: AppDatabase, + ctx: ArtifactProjectorRunContext, +): ProjectionOutcome[] { + if (!ctx.workspacePath) return [{ kind: "skipped", reason: "no_workspace" }]; + if (!isGitRepo(ctx.workspacePath)) return [{ kind: "skipped", reason: "not_a_git_repo" }]; + + let changed: string[]; + try { + changed = listChangedRelPaths(ctx.workspacePath); + } catch { + return [{ kind: "skipped", reason: "git_status_failed" }]; + } + if (changed.length === 0) return [{ kind: "skipped", reason: "no_changes" }]; + + const outcomes: ProjectionOutcome[] = []; + const budget = changed.slice(0, MAX_BASH_PROJECTED_FILES); + for (const rel of budget) { + const absPath = resolve(ctx.workspacePath, rel); + if (!isRegistrablePath(absPath, ctx.workspacePath)) { + outcomes.push({ kind: "skipped", reason: "path_ignored" }); + continue; + } + outcomes.push(upsertFilesystemArtifact(db, ctx, ctx.workspacePath, absPath)); + } + if (changed.length > MAX_BASH_PROJECTED_FILES) { + outcomes.push({ kind: "skipped", reason: "bash_scan_cap_exceeded" }); + } + return outcomes; +} + function projectSinglePathArtifact( db: AppDatabase, ctx: ArtifactProjectorRunContext, diff --git a/src/main/agent-runtime/hook-handlers.ts b/src/main/agent-runtime/hook-handlers.ts index ad0dd3bb..6ee10da3 100644 --- a/src/main/agent-runtime/hook-handlers.ts +++ b/src/main/agent-runtime/hook-handlers.ts @@ -158,6 +158,13 @@ function checkOperationalApprovalBlock( * Post-execution projection. Today only filesystem artifacts flow * through this hook; as further projections (browser actions, social * posts) migrate off the event-observer path they plug in here. + * + * Projection is treated as a primary persistence action: failures are + * logged at ERROR so they stay visible in the desktop log tail, and the + * `run-finished` reconciliation sweep (`RuntimeBridge`) revisits the + * workspace to register anything the hook missed. Silent swallow was + * the failure mode that left `work_products` empty while the workspace + * accumulated files the UI never surfaced. */ export function handleAfterToolCall( deps: HookHandlerDeps, @@ -172,7 +179,7 @@ export function handleAfterToolCall( workspacePath: ctx.workspacePath, }; try { - projectToolExecutionToWorkProduct( + const outcomes = projectToolExecutionToWorkProduct( deps.db, runCtx, { @@ -182,9 +189,15 @@ export function handleAfterToolCall( isError: payload.isError, }, ); + const registered = outcomes.filter((o) => o.kind === "inserted" || o.kind === "updated").length; + if (registered > 0) { + deps.logger.info( + `[hook-handlers] projection registered ${registered} artifact(s) for run ${ctx.runId} via ${payload.toolName}`, + ); + } } catch (error) { - deps.logger.warn( - `[hook-handlers] artifact projection failed for run ${ctx.runId} tool ${payload.toolName}: ${error instanceof Error ? error.message : String(error)}`, + deps.logger.error( + `[hook-handlers] artifact projection threw for run ${ctx.runId} tool ${payload.toolName}: ${error instanceof Error ? error.message : String(error)}`, ); } return NO_OP_AFTER; diff --git a/src/main/agent-runtime/runtime-bridge.ts b/src/main/agent-runtime/runtime-bridge.ts index c4f8d609..f88fb18c 100644 --- a/src/main/agent-runtime/runtime-bridge.ts +++ b/src/main/agent-runtime/runtime-bridge.ts @@ -11,6 +11,7 @@ import type { CredentialResolver } from "@main/credentials"; import { getConnectorDefinition } from "@main/connectors"; import { projectInboxMessageToPi } from "./inbox-projection"; import { projectAgentEventToLogChunk, extractTextBody } from "./agent-event-projection"; +import { reconcileWorkspaceArtifacts } from "./artifact-projector"; import { handleBeforeToolCall, handleAfterToolCall, @@ -90,14 +91,44 @@ export class RuntimeBridge { * and the inbound-correlation log. Callers must invoke after a * `run-finished` worker message so the maps stay bounded by concurrent- * run count rather than process lifetime. + * + * Before eviction, sweeps the workspace to register any filesystem + * artifacts the per-tool `afterToolCall` hook missed. This is the + * backstop that keeps the DocumentCenter consistent with the disk even + * when individual projections skipped (bash against a non-git repo, + * path-ignore false positives, transient `git status` failures). */ releaseRunResources(runId: string): void { const ctx = this.activeRuns.get(runId); + if (ctx) this.reconcileArtifactsForRun(ctx); this.activeRuns.delete(runId); this.inboundCorrelations.delete(runId); if (ctx) this.sessionStores.delete(ctx.taskSessionId); } + private reconcileArtifactsForRun(ctx: ActiveRunContext): void { + if (!ctx.workspacePath) return; + try { + const outcomes = reconcileWorkspaceArtifacts(this.deps.db, { + runId: ctx.runId, + agentId: ctx.agentId, + companyId: ctx.companyId, + taskId: ctx.taskId, + workspacePath: ctx.workspacePath, + }); + const inserted = outcomes.filter((o) => o.kind === "inserted").length; + if (inserted > 0) { + this.deps.logger.info( + `[runtime-bridge] reconciliation registered ${inserted} new artifact(s) for run ${ctx.runId}`, + ); + } + } catch (error) { + this.deps.logger.error( + `[runtime-bridge] artifact reconciliation failed for run ${ctx.runId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + /** * Register an inbound message that is about to enter (or has already * entered) the recipient's transcript as a user-role turn. Called from