Promote artifact registration to a primary run outcome
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".
This commit is contained in:
1 parent
db9375fdfe
commit
23a19a8180
3 files changed
+91
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user