FN-8064: add proactive task chat status updates
Task-detail chat now narrates engine progress and review outcomes in real time. - Emit bounded, redacted status rows for step lifecycle and review paths. - Present status entries with a distinct task-chat treatment. - Cover status narration and diagnostic sanitization with engine tests. Files changed: .changeset/fn-8064-proactive-chat.md | 7 + docs/architecture.md | 1 + packages/dashboard/app/components/TaskChatTab.css | 16 ++ packages/dashboard/app/components/TaskChatTab.tsx | 9 +- .../engine/src/__tests__/executor-prompt.test.ts | 28 +++- .../engine/src/__tests__/proactive-status.test.ts | 54 +++++++ packages/engine/src/executor.ts | 176 ++++++++++++++++----- packages/engine/src/proactive-status.ts | 117 ++++++++++++++ 8 files changed, 365 insertions(+), 43 deletions(-) Fusion-Task-Id: FN-8064 Fusion-Task-Lineage: c6d0a9b5-0946-4bf4-8338-e982e1cbfd53 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8064-proactive-chat.md
Normal file
7
.changeset/fn-8064-proactive-chat.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Task-detail chat now proactively narrates step progress, failures, and review outcomes in real time.
|
||||
category: feature
|
||||
dev: Adds bounded, secret-redacted engine status narration across step-session, default execution, graph review, and legacy review paths.
|
||||
@@ -746,6 +746,7 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/
|
||||
|
||||
### Observability and reflection
|
||||
- `AgentLogger` (`agent-logger.ts`) — structured per-agent run logging
|
||||
- FN-8064 writes best-effort proactive `status` rows to the task-detail chat for step start, completion, intentional skips, and failure in both step-session/graph callbacks and store-accepted default `fn_task_update` transitions. Graph and legacy review paths each write one reviewer row: plan/spec APPROVE uses the plan-verified message; other real verdicts use the verdict message; `UNAVAILABLE` and reviewer exceptions write a safe operational "review could not complete" status without claiming a verdict. Failure and review-summary diagnostics are nullish-safe, secret-redacted, absolute-path/stack/newline stripped, and capped at 300 characters, so narration never blocks execution or persists raw command output.
|
||||
- `RunAudit` (`run-audit.ts`) — mutation audit tracking (DB/git/filesystem)
|
||||
- FN-7214: `task:reenter-paused-aborted-workflow-node` records executor re-entry after a typed workflow graph node was interrupted by engine pause/resume. Metadata includes `nodeId`, `fromColumn`, retry `attempt`/`maxAttempts`, `abortProvenance`, whether the task was preserved in `in-review`, and the re-entry `mode`.
|
||||
- FN-7220: `task:classify-stale-in-review-plan-pause-abort-replay` records executor classification of a stale generic `in-review` plan-node pause/resume replay. Metadata includes `nodeId`, `fromColumn`, `abortProvenance`, whether a stale failure was cleared, `graphResumeRetryCount`, and `mode: "preserved-in-review"`.
|
||||
|
||||
@@ -220,6 +220,22 @@ FN-7231 requires task-detail chat text, user, tool, and thinking blocks to keep
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ProactiveChatStatus 2026-07-16-12:05:
|
||||
FN-8064 makes engine status rows a real-time task-progress report. Give complete status
|
||||
messages a tokenized warning-toned distinction and reuse the shared status-dot convention;
|
||||
the existing compact padding rule keeps the affordance usable below the mobile breakpoint.
|
||||
*/
|
||||
.task-chat-entry--status {
|
||||
border-color: color-mix(in srgb, var(--color-warning) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--color-warning) 10%, var(--surface));
|
||||
}
|
||||
|
||||
.task-chat-entry--status .task-chat-entry-label-row {
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.task-chat-entry--user {
|
||||
max-width: min(100%, calc(var(--space-2xl) * 18));
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
|
||||
@@ -405,7 +405,14 @@ function TaskChatText({ entries }: { entries: AgentLogEntry[] }) {
|
||||
className={`task-chat-entry task-chat-entry--${firstEntry.type.replace("_", "-")}`}
|
||||
data-testid={`task-chat-entry-${firstEntry.type}`}
|
||||
>
|
||||
<TaskChatTimestampMeta timestamp={getLatestEntryTimestamp(entries)} label="Text block timestamp" />
|
||||
{firstEntry.type === "status" && (
|
||||
<div className="task-chat-entry-label-row">
|
||||
<span className="status-dot status-dot--pending" aria-hidden="true" />
|
||||
<span className="task-chat-entry-kicker">Status update</span>
|
||||
<TaskChatTimestamp timestamp={getLatestEntryTimestamp(entries)} label="Status update timestamp" />
|
||||
</div>
|
||||
)}
|
||||
{firstEntry.type !== "status" && <TaskChatTimestampMeta timestamp={getLatestEntryTimestamp(entries)} label="Text block timestamp" />}
|
||||
<div className="markdown-body task-chat-markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{entries.map((entry) => entry.text).join("")}
|
||||
|
||||
@@ -2613,14 +2613,13 @@ describe("fn_task_update bare-call guard (P1 api-contract)", () => {
|
||||
// createTaskUpdateTool is a private executor method; the bare-call guard runs
|
||||
// before any store access, so we reach it via the lowest-cost seam: construct
|
||||
// a TaskExecutor over a mock store and invoke the private method with `as any`.
|
||||
function makeTool() {
|
||||
const store = createMockStore();
|
||||
function makeTool(store = createMockStore()) {
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
return (executor as any).createTaskUpdateTool("FN-001", new Map(), { current: null }, new Map());
|
||||
return { store, tool: (executor as any).createTaskUpdateTool("FN-001", new Map(), { current: null }, new Map()) };
|
||||
}
|
||||
|
||||
it("returns isError with a self-describing message when no fields are supplied", async () => {
|
||||
const tool = makeTool();
|
||||
const { tool } = makeTool();
|
||||
const result = await tool.execute("call-1", {});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
@@ -2630,13 +2629,32 @@ describe("fn_task_update bare-call guard (P1 api-contract)", () => {
|
||||
});
|
||||
|
||||
it("does not trigger the guard when a dependencies-only patch is supplied", async () => {
|
||||
const tool = makeTool();
|
||||
const { tool } = makeTool();
|
||||
const result = await tool.execute("call-1", { dependencies: [] });
|
||||
// Reaches the dependencies path, not the bare-call guard.
|
||||
expect(result.isError).not.toBe(true);
|
||||
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
||||
expect(text).not.toContain("fn_task_update requires at least one of");
|
||||
});
|
||||
|
||||
it("narrates a store-accepted skipped transition exactly once", async () => {
|
||||
const { store, tool } = makeTool();
|
||||
store.updateStep.mockResolvedValue(createMockTaskDetail({
|
||||
steps: [{ name: "No code change needed", status: "skipped", dependsOn: [] }],
|
||||
}));
|
||||
|
||||
const result = await tool.execute("call-1", { step: 0, status: "skipped" });
|
||||
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(store.appendAgentLog).toHaveBeenCalledTimes(1);
|
||||
expect(store.appendAgentLog).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"Step 0 was skipped — No code change needed.",
|
||||
"status",
|
||||
undefined,
|
||||
"executor",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
54
packages/engine/src/__tests__/proactive-status.test.ts
Normal file
54
packages/engine/src/__tests__/proactive-status.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildPlanVerifiedMessage,
|
||||
buildReviewRollbackFailureMessage,
|
||||
buildReviewUnavailableMessage,
|
||||
buildReviewVerdictMessage,
|
||||
buildStepFailureMessage,
|
||||
buildStepSkippedMessage,
|
||||
buildStepStartMessage,
|
||||
buildStepSuccessMessage,
|
||||
emitProactiveStatus,
|
||||
sanitizeFailureReason,
|
||||
} from "../proactive-status.js";
|
||||
|
||||
describe("proactive status narration", () => {
|
||||
it("sanitizes nullish, stack, path, token and multiline diagnostics", () => {
|
||||
const raw = "Error: token=super-secret\n at run (/Users/a/project/file.ts:1:2)\nBearer abcdefghijklmnopqrstuvwxyz\n" + "output\n".repeat(200);
|
||||
const safe = sanitizeFailureReason(raw);
|
||||
expect(safe).toContain("[REDACTED]");
|
||||
expect(safe).not.toContain("/Users/a");
|
||||
expect(safe).not.toContain("\n");
|
||||
expect(safe.length).toBeLessThanOrEqual(300);
|
||||
expect(sanitizeFailureReason(undefined)).toBe("No failure reason was provided.");
|
||||
expect(sanitizeFailureReason(null)).toBe("No failure reason was provided.");
|
||||
expect(sanitizeFailureReason({ toString: () => { throw new Error("nope"); } })).toBe("No failure reason was provided.");
|
||||
});
|
||||
|
||||
it("elides absolute paths outside the common home-directory roots", () => {
|
||||
const safe = sanitizeFailureReason("Command failed reading /etc/fusion/secrets.env from /srv/fusion/current/config.yaml");
|
||||
expect(safe).toBe("Command failed reading [path] from [path]");
|
||||
expect(safe).not.toContain("/etc/");
|
||||
expect(safe).not.toContain("/srv/");
|
||||
});
|
||||
|
||||
it("builds complete status messages and safely reports unavailable reviews", () => {
|
||||
expect(buildStepStartMessage(2, "Ship it")).toBe("Starting Step 2: Ship it");
|
||||
expect(buildStepSuccessMessage(2, "Ship it")).toBe("Step 2 finished — Ship it.");
|
||||
expect(buildStepSkippedMessage(2, "No code change needed")).toBe("Step 2 was skipped — No code change needed.");
|
||||
expect(buildStepSkippedMessage(2, "Step 2")).toBe("Step 2 was skipped.");
|
||||
expect(buildStepFailureMessage(2, "Ship it", sanitizeFailureReason(undefined))).toContain("No failure reason");
|
||||
expect(buildPlanVerifiedMessage()).toBe("The plan was written and verified.");
|
||||
expect(buildReviewVerdictMessage("UNAVAILABLE", "nope")).toBeNull();
|
||||
expect(buildReviewUnavailableMessage("token=secret /etc/fusion/secrets.env")).toBe("Review could not complete: token=[REDACTED] [path]");
|
||||
expect(buildReviewVerdictMessage("RETHINK", "token=secret /home/private/file")).toMatch(/\[REDACTED\]/);
|
||||
expect(buildReviewRollbackFailureMessage(sanitizeFailureReason("/Users/me/private.ts"))).toBe("Review could not roll the step back: [path]");
|
||||
});
|
||||
|
||||
it("persists status narration best-effort", async () => {
|
||||
const appendAgentLog = vi.fn().mockResolvedValue(undefined);
|
||||
await emitProactiveStatus({ appendAgentLog } as never, "FN-1", "Finished", "executor", "safe detail");
|
||||
expect(appendAgentLog).toHaveBeenCalledWith("FN-1", "Finished", "status", "safe detail", "executor");
|
||||
await expect(emitProactiveStatus({ appendAgentLog: vi.fn().mockRejectedValue(new Error("down")) } as never, "FN-1", "Finished", "reviewer")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,18 @@ import { createWorkflowRuntimePrimitiveProvider } from "./workflow-runtime-primi
|
||||
import { WorkflowCustomNodeExecutionService } from "./workflow-custom-node-execution.js";
|
||||
import { WorkflowReviewService } from "./workflow-review-service.js";
|
||||
import { WorkflowPlanningService } from "./workflow-planning-service.js";
|
||||
import {
|
||||
buildPlanVerifiedMessage,
|
||||
buildReviewUnavailableMessage,
|
||||
buildReviewRollbackFailureMessage,
|
||||
buildReviewVerdictMessage,
|
||||
buildStepFailureMessage,
|
||||
buildStepSkippedMessage,
|
||||
buildStepStartMessage,
|
||||
buildStepSuccessMessage,
|
||||
emitProactiveStatus,
|
||||
sanitizeFailureReason,
|
||||
} from "./proactive-status.js";
|
||||
import {
|
||||
ApprovalRequestStore,
|
||||
buildExecutionMemoryInstructions,
|
||||
@@ -5035,7 +5047,14 @@ export class TaskExecutor {
|
||||
* (the read path threads the same instanceId through `runGraphTaskStep`). */
|
||||
private graphStepActiveContext = new Map<string, ForeachActiveContext>();
|
||||
|
||||
/** Composite key for {@link graphStepActiveContext}: per-instance, not per-task. */
|
||||
/**
|
||||
* FNXC:ProactiveChatStatus 2026-07-16-12:30:
|
||||
* Keep a graph RETHINK summary until its rework reset succeeds. The status wording says the step
|
||||
* was rolled back, so it must not reach the task chat before resetStepToBaseline completes.
|
||||
*/
|
||||
private graphRethinkNarrations = new Map<string, string>();
|
||||
|
||||
/** Composite key for graph-owned per-instance state: never share parallel foreach instances. */
|
||||
private graphActiveContextKey(taskId: string, instanceId: string): string {
|
||||
return `${taskId}:${instanceId}`;
|
||||
}
|
||||
@@ -5437,6 +5456,9 @@ export class TaskExecutor {
|
||||
for (const key of this.graphStepActiveContext.keys()) {
|
||||
if (key.startsWith(ctxPrefix)) this.graphStepActiveContext.delete(key);
|
||||
}
|
||||
for (const key of this.graphRethinkNarrations.keys()) {
|
||||
if (key.startsWith(ctxPrefix)) this.graphRethinkNarrations.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5911,30 +5933,50 @@ export class TaskExecutor {
|
||||
}
|
||||
}
|
||||
const liveSteps = await this.store.getTask(taskId).then((t) => t.steps).catch(() => []);
|
||||
await resetStepToBaseline(
|
||||
{
|
||||
store: this.store,
|
||||
worktreePath,
|
||||
// No single session ref for graph-owned step-sessions — rewind is skipped
|
||||
// when checkpointId resolves but no session is current (KTD-2 partial path).
|
||||
sessionRef: { current: null },
|
||||
reviewType: "code",
|
||||
// Branch-scoped RETHINK under worktree isolation makes the guard structural
|
||||
// (the reset can only touch the instance's own branch); shared isolation
|
||||
// keeps the defensive ancestry guard (KTD-2/KTD-11).
|
||||
blastRadiusGuard: branchScoped
|
||||
? undefined
|
||||
: makeAncestryBlastRadiusGuard({
|
||||
worktreePath,
|
||||
task: { id: taskId, steps: liveSteps },
|
||||
stepIndex: active.stepIndex,
|
||||
}),
|
||||
},
|
||||
{ id: taskId, steps: liveSteps },
|
||||
active.stepIndex,
|
||||
active.baselineSha,
|
||||
active.checkpointId,
|
||||
);
|
||||
const narrationKey = this.graphActiveContextKey(taskId, active.instanceId);
|
||||
const reviewSummary = this.graphRethinkNarrations.get(narrationKey);
|
||||
try {
|
||||
await resetStepToBaseline(
|
||||
{
|
||||
store: this.store,
|
||||
worktreePath,
|
||||
// No single session ref for graph-owned step-sessions — rewind is skipped
|
||||
// when checkpointId resolves but no session is current (KTD-2 partial path).
|
||||
sessionRef: { current: null },
|
||||
reviewType: "code",
|
||||
// Branch-scoped RETHINK under worktree isolation makes the guard structural
|
||||
// (the reset can only touch the instance's own branch); shared isolation
|
||||
// keeps the defensive ancestry guard (KTD-2/KTD-11).
|
||||
blastRadiusGuard: branchScoped
|
||||
? undefined
|
||||
: makeAncestryBlastRadiusGuard({
|
||||
worktreePath,
|
||||
task: { id: taskId, steps: liveSteps },
|
||||
stepIndex: active.stepIndex,
|
||||
}),
|
||||
},
|
||||
{ id: taskId, steps: liveSteps },
|
||||
active.stepIndex,
|
||||
active.baselineSha,
|
||||
active.checkpointId,
|
||||
);
|
||||
if (reviewSummary !== undefined) {
|
||||
const narration = buildReviewVerdictMessage("RETHINK", reviewSummary);
|
||||
void emitProactiveStatus(this.store, taskId, narration, "reviewer", sanitizeFailureReason(reviewSummary));
|
||||
}
|
||||
} catch (error) {
|
||||
const safeReason = sanitizeFailureReason(error);
|
||||
void emitProactiveStatus(
|
||||
this.store,
|
||||
taskId,
|
||||
buildReviewRollbackFailureMessage(safeReason),
|
||||
"reviewer",
|
||||
safeReason,
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
this.graphRethinkNarrations.delete(narrationKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -7063,6 +7105,8 @@ export class TaskExecutor {
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
reviewerLog.error(`${seamTask.id}: step-review failed: ${message}`);
|
||||
const narration = buildReviewUnavailableMessage(err);
|
||||
void emitProactiveStatus(this.store, seamTask.id, narration, "reviewer", sanitizeFailureReason(err));
|
||||
return { verdict: "UNAVAILABLE", review: `reviewer error: ${message}` };
|
||||
}
|
||||
|
||||
@@ -7071,6 +7115,17 @@ export class TaskExecutor {
|
||||
`${config.type} step-review Step ${stepIndex}: ${review.verdict}${config.advisory ? " (advisory)" : ""}`,
|
||||
review.summary,
|
||||
);
|
||||
const narration = config.type === "plan" && review.verdict === "APPROVE"
|
||||
? buildPlanVerifiedMessage()
|
||||
: review.verdict === "UNAVAILABLE"
|
||||
? buildReviewUnavailableMessage(review.summary)
|
||||
: buildReviewVerdictMessage(review.verdict, review.summary);
|
||||
if (review.verdict === "RETHINK") {
|
||||
// RETHINK's rollback claim is emitted by applyGraphRethinkReset only after reset succeeds.
|
||||
this.graphRethinkNarrations.set(this.graphActiveContextKey(seamTask.id, active.instanceId), review.summary);
|
||||
} else {
|
||||
void emitProactiveStatus(this.store, seamTask.id, narration, "reviewer", narration ? sanitizeFailureReason(review.summary) : undefined);
|
||||
}
|
||||
|
||||
// Single-writer rule (KTD-4): advisory (split-branch) reviews never write
|
||||
// the projection — they are fan-out checks that cannot clobber the
|
||||
@@ -10465,6 +10520,7 @@ export class TaskExecutor {
|
||||
this.store.updateStep(task.id, stepIndex, "in-progress", stepProjectionOptions).catch((err) => {
|
||||
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status to in-progress: ${err}`);
|
||||
});
|
||||
void emitProactiveStatus(this.store, task.id, buildStepStartMessage(stepIndex, detail.steps[stepIndex]?.name), "executor");
|
||||
} catch (err) {
|
||||
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status to in-progress: ${err}`);
|
||||
}
|
||||
@@ -10475,6 +10531,12 @@ export class TaskExecutor {
|
||||
this.store.updateStep(task.id, stepIndex, result.success ? "done" : "skipped", stepProjectionOptions).catch((err) => {
|
||||
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status: ${err}`);
|
||||
});
|
||||
const stepName = detail.steps[stepIndex]?.name;
|
||||
const safeReason = result.success ? undefined : sanitizeFailureReason(result.error);
|
||||
const message = result.success
|
||||
? buildStepSuccessMessage(stepIndex, stepName)
|
||||
: buildStepFailureMessage(stepIndex, stepName, safeReason!);
|
||||
void emitProactiveStatus(this.store, task.id, message, "executor", safeReason);
|
||||
} catch (err) {
|
||||
executorLog.warn(`${task.id}: failed to update step ${stepIndex} status: ${err}`);
|
||||
}
|
||||
@@ -13211,6 +13273,18 @@ export class TaskExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
// FNXC:ProactiveChatStatus 2026-07-16-12:45:
|
||||
// Only store-accepted transitions narrate progress. A skipped step is terminal work too,
|
||||
// so it needs its own status row rather than silently looking like an ignored no-op.
|
||||
const narration = status === "in-progress"
|
||||
? buildStepStartMessage(stepIndex, stepInfo.name)
|
||||
: status === "done"
|
||||
? buildStepSuccessMessage(stepIndex, stepInfo.name)
|
||||
: status === "skipped"
|
||||
? buildStepSkippedMessage(stepIndex, stepInfo.name)
|
||||
: null;
|
||||
void emitProactiveStatus(this.store, taskId, narration, "executor");
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text" as const,
|
||||
@@ -14367,6 +14441,7 @@ export class TaskExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
let rollbackFailureNarrated = false;
|
||||
try {
|
||||
// Merge per-task effective workflow settings (U3, KTD-3) so the
|
||||
// validator model-lane reads below pick up workflow values; this tool
|
||||
@@ -14457,6 +14532,15 @@ export class TaskExecutor {
|
||||
result.summary,
|
||||
);
|
||||
reviewerLog.log(`${taskId}: Step ${step} ${reviewType} → ${result.verdict}`);
|
||||
const narration = (reviewType === "plan" || reviewType === ("spec" as typeof reviewType)) && result.verdict === "APPROVE"
|
||||
? buildPlanVerifiedMessage()
|
||||
: result.verdict === "UNAVAILABLE"
|
||||
? buildReviewUnavailableMessage(result.summary)
|
||||
: buildReviewVerdictMessage(result.verdict, result.summary);
|
||||
// A RETHINK message asserts rollback completion, so wait for resetStepToBaseline below.
|
||||
if (result.verdict !== "RETHINK") {
|
||||
void emitProactiveStatus(store, taskId, narration, "reviewer", narration ? sanitizeFailureReason(result.summary) : undefined);
|
||||
}
|
||||
stuckDetector?.recordProgress(taskId);
|
||||
|
||||
// Track code review verdicts for enforcement. Plan reviews remain
|
||||
@@ -14515,19 +14599,33 @@ export class TaskExecutor {
|
||||
// agent-supplied baseline (KTD-2 — the guard is for graph-owned
|
||||
// shared-isolation resets), so behavior stays byte-identical.
|
||||
const checkpointId = stepCheckpoints.get(stepIndex);
|
||||
await resetStepToBaseline(
|
||||
{
|
||||
try {
|
||||
await resetStepToBaseline(
|
||||
{
|
||||
store,
|
||||
worktreePath,
|
||||
sessionRef,
|
||||
reviewType: reviewType === "plan" ? "plan" : "code",
|
||||
summary: result.summary,
|
||||
},
|
||||
{ id: taskId, steps: taskSteps },
|
||||
stepIndex,
|
||||
reviewType === "code" ? baseline : undefined,
|
||||
checkpointId,
|
||||
);
|
||||
} catch (error) {
|
||||
const safeReason = sanitizeFailureReason(error);
|
||||
void emitProactiveStatus(
|
||||
store,
|
||||
worktreePath,
|
||||
sessionRef,
|
||||
reviewType: reviewType === "plan" ? "plan" : "code",
|
||||
summary: result.summary,
|
||||
},
|
||||
{ id: taskId, steps: taskSteps },
|
||||
stepIndex,
|
||||
reviewType === "code" ? baseline : undefined,
|
||||
checkpointId,
|
||||
);
|
||||
taskId,
|
||||
buildReviewRollbackFailureMessage(safeReason),
|
||||
"reviewer",
|
||||
safeReason,
|
||||
);
|
||||
rollbackFailureNarrated = true;
|
||||
throw error;
|
||||
}
|
||||
void emitProactiveStatus(store, taskId, narration, "reviewer", sanitizeFailureReason(result.summary));
|
||||
|
||||
if (reviewType === "plan") {
|
||||
text = `RETHINK\n\nYour plan was rejected. Here is why:\n\n${result.review}\n\nTake a different approach to planning this step. Do NOT repeat the rejected strategy.`;
|
||||
@@ -14573,6 +14671,10 @@ export class TaskExecutor {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
reviewerLog.error(`${taskId}: review failed: ${errorMessage}`);
|
||||
await store.logEntry(taskId, `${reviewType} review failed: ${errorMessage}`);
|
||||
if (!rollbackFailureNarrated) {
|
||||
const narration = buildReviewUnavailableMessage(err);
|
||||
void emitProactiveStatus(store, taskId, narration, "reviewer", sanitizeFailureReason(err));
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ReviewerProviderErrors 2026-07-15-11:20:
|
||||
|
||||
117
packages/engine/src/proactive-status.ts
Normal file
117
packages/engine/src/proactive-status.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { redactSecrets, type AgentRole, type TaskStore } from "@fusion/core";
|
||||
import type { ReviewVerdict } from "./reviewer.js";
|
||||
|
||||
const GENERIC_FAILURE_REASON = "No failure reason was provided.";
|
||||
const MAX_REASON_LENGTH = 300;
|
||||
|
||||
/**
|
||||
* FNXC:ProactiveChatStatus 2026-07-16-12:00:
|
||||
* Issue #2153 requires the task-detail chat to narrate step start, success, intentional skips, safe
|
||||
* failure reasons, and review/rollback outcomes as standalone status rows, making it a real-time progress report.
|
||||
* Both step-session callbacks and the default fn_task_update seam use this shared wording. Failure
|
||||
* and review-summary diagnostics are nullish-safe, redacted, path/stack stripped, single-line, and
|
||||
* capped at 300 characters; UNAVAILABLE is not a verdict but is narrated as a safe operational failure.
|
||||
*/
|
||||
export function sanitizeFailureReason(rawError: unknown): string {
|
||||
let candidate: string;
|
||||
try {
|
||||
if (rawError === null || rawError === undefined) return GENERIC_FAILURE_REASON;
|
||||
if (rawError instanceof Error) candidate = rawError.message || String(rawError);
|
||||
else if (typeof rawError === "string") candidate = rawError;
|
||||
else candidate = String(rawError);
|
||||
} catch {
|
||||
return GENERIC_FAILURE_REASON;
|
||||
}
|
||||
if (!candidate.trim()) return GENERIC_FAILURE_REASON;
|
||||
|
||||
let sanitized = redactSecrets(candidate)
|
||||
// Remove conventional JavaScript stack frames before general whitespace collapse.
|
||||
.replace(/\s*at\s+[^\n]+\([^\n]*:\d+:\d+\)/g, " ")
|
||||
.replace(/\s*at\s+[^\n]+:\d+:\d+/g, " ")
|
||||
// Any absolute filesystem path is environment-sensitive, including system/service roots.
|
||||
.replace(/(?:[A-Za-z]:\\|\/)[^\s:),]+/g, "[path]")
|
||||
.replace(/[\r\n\t]+/g, " ")
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
if (!sanitized) return GENERIC_FAILURE_REASON;
|
||||
if (sanitized.length > MAX_REASON_LENGTH) sanitized = `${sanitized.slice(0, MAX_REASON_LENGTH - 1).trimEnd()}…`;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function stepLabel(stepIndex: number, stepName?: string): string {
|
||||
const fallback = `Step ${stepIndex}`;
|
||||
return stepName?.trim() || fallback;
|
||||
}
|
||||
|
||||
export function buildStepStartMessage(stepIndex: number, stepName?: string): string {
|
||||
return `Starting Step ${stepIndex}: ${stepLabel(stepIndex, stepName)}`;
|
||||
}
|
||||
|
||||
export function buildStepSuccessMessage(stepIndex: number, stepName?: string): string {
|
||||
const name = stepName?.trim();
|
||||
return name && name !== `Step ${stepIndex}` ? `Step ${stepIndex} finished — ${name}.` : `Step ${stepIndex} finished.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ProactiveChatStatus 2026-07-16-12:45:
|
||||
* Store-accepted skipped transitions are terminal task outcomes too. Narrate them distinctly so
|
||||
* preflight and intentional no-op flows remain visible without fabricating a failure reason.
|
||||
*/
|
||||
export function buildStepSkippedMessage(stepIndex: number, stepName?: string): string {
|
||||
const name = stepName?.trim();
|
||||
return name && name !== `Step ${stepIndex}` ? `Step ${stepIndex} was skipped — ${name}.` : `Step ${stepIndex} was skipped.`;
|
||||
}
|
||||
|
||||
export function buildStepFailureMessage(stepIndex: number, stepName: string | undefined, safeReason: string): string {
|
||||
const prefix = stepName?.trim() && stepName.trim() !== `Step ${stepIndex}` ? `Step ${stepIndex} (${stepName.trim()})` : `Step ${stepIndex}`;
|
||||
return `${prefix} did not complete: ${safeReason}`;
|
||||
}
|
||||
|
||||
export function buildReviewVerdictMessage(verdict: ReviewVerdict, summary: unknown): string | null {
|
||||
const safeSummary = sanitizeFailureReason(summary);
|
||||
switch (verdict) {
|
||||
case "APPROVE": return `Review passed — ${safeSummary}`;
|
||||
case "REVISE": return `Review requested changes: ${safeSummary}`;
|
||||
case "RETHINK": return `Review rolled the step back to rethink the approach: ${safeSummary}`;
|
||||
case "UNAVAILABLE": return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPlanVerifiedMessage(): string {
|
||||
return "The plan was written and verified.";
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ProactiveChatStatus 2026-07-16-13:10:
|
||||
* A reviewer outage is progress-relevant even though UNAVAILABLE is not a verdict. Report it as
|
||||
* an operational status, with the same bounded diagnostic policy as failures, so the chat tells
|
||||
* the operator why review did not complete without misrepresenting it as APPROVE/REVISE/RETHINK.
|
||||
*/
|
||||
export function buildReviewUnavailableMessage(reason: unknown): string {
|
||||
return `Review could not complete: ${sanitizeFailureReason(reason)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ProactiveChatStatus 2026-07-16-12:30:
|
||||
* A RETHINK narration may claim that work was rolled back only after the baseline reset succeeds.
|
||||
* Reset failures instead need a safe status row that tells the operator the rollback did not finish.
|
||||
*/
|
||||
export function buildReviewRollbackFailureMessage(safeReason: string): string {
|
||||
return `Review could not roll the step back: ${safeReason}`;
|
||||
}
|
||||
|
||||
export async function emitProactiveStatus(
|
||||
store: Pick<TaskStore, "appendAgentLog">,
|
||||
taskId: string,
|
||||
message: string | null | undefined,
|
||||
role: AgentRole,
|
||||
detail?: string,
|
||||
): Promise<void> {
|
||||
if (!message) return;
|
||||
try {
|
||||
await store.appendAgentLog(taskId, message, "status", detail, role);
|
||||
} catch {
|
||||
// Proactive narration is strictly observational and must not affect execution.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user