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