FN-9021: send mailbox notices for task recommendations

Completed tasks now optionally send captured recommendations to the operator mailbox.

- Add idempotent, non-blocking mailbox notices after accepted task completion.
- Expose a project setting and localized dashboard control for recommendation notices.
- Cover notification dispatch, settings parity, and completion-path behavior with tests.

Files changed:
 .../fn-9021-recommendation-mailbox-notice.md       |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 docs/settings-reference.md                         |   1 +
 .../core/src/__tests__/settings-parity.test.ts     |   2 +
 .../__tests__/task-recommendation-notice.test.ts   |  77 +++++++++++++
 packages/core/src/config/settings-schema.ts        |   2 +
 packages/core/src/index.ts                         |   9 ++
 packages/core/src/task-recommendation-notice.ts    | 122 +++++++++++++++++++++
 packages/core/src/types/settings/settings-scope.ts |   6 +
 .../__tests__/SettingsModal.general.test.tsx       |  10 ++
 .../app/components/settings/section-keys.ts        |   1 +
 .../settings/sections/GeneralSection.tsx           |  15 +++
 .../settings-default-descriptions.test.tsx         |   1 +
 .../executor-task-recommendations.test.ts          |  39 ++++++-
 .../executor/completion-recommendation-notice.ts   |  52 +++++++++
 .../engine/src/executor/create-task-done-tool.ts   |  12 ++
 packages/engine/src/executor/plan-review-no-op.ts  |  12 ++
 packages/engine/src/runtimes/in-process-runtime.ts |  14 +++
 packages/i18n/locales/en/app.json                  |   4 +-
 packages/i18n/locales/es/app.json                  |   4 +-
 packages/i18n/locales/fr/app.json                  |   4 +-
 packages/i18n/locales/ko/app.json                  |   4 +-
 packages/i18n/locales/pt-BR/app.json               |   4 +-
 packages/i18n/locales/zh-CN/app.json               |   4 +-
 packages/i18n/locales/zh-TW/app.json               |   4 +-
 packages/i18n/src/resources.d.ts                   |  40 ++++++-
 26 files changed, 442 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-9021
Fusion-Task-Lineage: f6c5c93c-a3b6-470b-9efb-79e6b4003985
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-12 21:15:53 -07:00
parent 6d2c1bf0c9
commit ea53cbd4ff
26 changed files with 442 additions and 10 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Send mailbox notices for captured task recommendations.
category: feature
dev: Adds the project setting `recommendationMailboxNoticeEnabled`.

View File

@@ -1499,7 +1499,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou
- The top-level **Chat** tab appears first for active task details and is the default landing tab for non-`done` tasks. It uses the task's effective planning model, but opening the tab is lookup-only: Fusion creates the task-scoped planner chat only after you send a composer message, starter prompt, or planner-question answer. Once a user message exists, the resumable planner chat can appear in the global Chat list; interacted chats are kept when the task reaches `done` and removed when the task is archived. Each send includes bounded server-built task context so the planner can answer current status, progress, recent activity, dependency, and task definition questions. It shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. The composer stays pinned while the transcript, loading, error, starter, history, and streaming states scroll internally; on mobile/narrow task detail, the default focused Chat layout hides nonessential title/metadata/tab/action rows until you collapse it from the in-view expand control.
- The **Activity → Live**, **Feed**, and **Raw Logs** segments remain immediately after **Chat** and share an expand/collapse control that lets the active Activity segment fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed.
- The **Summary** tab appears for `done` tasks and remains their default landing tab. It shows the recorded completion summary, the **Merge Details** card (merge status, commit, PR, timestamp, and message), changed-file/merge stats when available, completed steps, workflow results, retry counts, and a token usage & cost section broken down by model from the already-loaded task detail; unpriced models show cost as unavailable rather than `$0`.
- The **Recommendations** tab appears on a completed task only when at least one recommendation was captured. At accepted completion, executors evaluate optional, non-blocking out-of-scope findings and submit task-ready recommendations; an explicit `[]` means none qualified, not that filler should be invented. The project cap bounds captured results, and `maxRecommendationsPerTask: 0` disables capture and therefore removes the tab entirely. Recommendations are distinct from immediately created/delegated tasks, which remain appropriate for required dependency coordination, explicit task requirements, or operator-directed filing. An empty result surfaces as no tab; otherwise each row shows a task-ready title, category, and description. **Create task** uses the normal guarded intake policy (including duplicate checks), so a duplicate conflict creates no child and leaves the recommendation available to retry; successful repeated clicks reuse the same linked triage task.
- The **Recommendations** tab appears on a completed task only when at least one recommendation was captured. At accepted completion, executors evaluate optional, non-blocking out-of-scope findings and submit task-ready recommendations; an explicit `[]` means none qualified, not that filler should be invented. The project cap bounds captured results, and `maxRecommendationsPerTask: 0` disables capture and therefore removes the tab entirely. A non-empty set sends one mailbox notice per distinct recommendation-id set only after completion is accepted; interrupted or rolled-back handoffs and linking an already-captured recommendation to a created task send nothing. Delivery is asynchronous and best-effort, so it never delays task completion; **Settings → General → Recommendation mailbox notices** can disable only this notice, not capture. Recommendations are distinct from immediately created/delegated tasks, which remain appropriate for required dependency coordination, explicit task requirements, or operator-directed filing. An empty result surfaces as no tab; otherwise each row shows a task-ready title, category, and description. **Create task** uses the normal guarded intake policy (including duplicate checks), so a duplicate conflict creates no child and leaves the recommendation available to retry; successful repeated clicks reuse the same linked triage task.
- The **Cost** tab is available for tasks in every column and sits immediately after **Comments → Terminal** in the tab strip. It shows the read-time derived per-model cost breakdown (input, output, cached, cache-write, total tokens, derived USD) and a task total; no token usage shows an explicit empty state, while unpriced or zero-usage rows use `—` instead of a guessed `$0`.
<!-- FNXC:Settings-ThinkingLevel 2026-07-13-00:27: The task-detail Models tab now persists validatorThinkingLevel and planningThinkingLevel separately so Reviewer and Planning lanes can choose reasoning effort without changing the Executor lane's task.thinkingLevel. -->
- The **Models** tab exposes inline **Thinking Level** selectors for **Executor Model**, **Reviewer Model**, and **Planning Model**. Executor saves the shared task thinking level, while Reviewer and Planning save independent per-lane overrides; leaving either lane on **Default** inherits the shared task thinking level and then the configured workflow/project defaults.

View File

@@ -473,6 +473,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF
| `enginePaused` | `boolean` | `false` | Soft pause: stop dispatching new work while letting active sessions finish. While paused (including shared pause windows with `globalPause`), stuck-task polling/timers are suspended so paused wall-clock time does not count against `taskStuckTimeoutMs`. Clearing pause state resumes runtime scheduling and gives tracked active sessions a fresh stuck-task grace window before normal detection resumes; when `autoMerge` is enabled, eligible `in-review` tasks are re-swept into the auto-merge queue (paused/blocked/failed review tasks remain skipped). |
| `maxConcurrent` | `number` | `2` | Max concurrency for top-level working agents per project across planning, execution, and review/merge. Nested helper agents remain parent-internal and may temporarily exceed this displayed count. Editable from Settings, Command Center, and Engine Control. |
| `maxRecommendationsPerTask` | `number` | `3` | Project-scoped maximum accepted completion recommendations per task. Integers **0–20** only; `0` disables recommendation capture and `1–20` bounds task-ready out-of-scope suggestions. |
| `recommendationMailboxNoticeEnabled` | `boolean` | `true` | Send a best-effort, non-blocking mailbox notice after accepted completion captures a non-empty recommendation set. Disabling it suppresses only the notice; recommendation capture and storage are unchanged. |
| `maxConcurrentVerifications` | `number` | `1` | Max concurrent verification subprocesses (`fn_run_verification`, merge test/build commands) process-wide. Caps stacked monorepo typecheck/build so concurrent tasks do not peg host CPU. Range **1–8** (clamped at runtime and in Settings). Editable from Settings → Scheduling. Each project engine registers its cap; the effective process limit is the **minimum** of registered project caps. |
| `maxTriageConcurrent` | `number` | `2` | Legacy persisted value; ignored. Planning shares `maxConcurrent` and no Max Triage control is displayed. |
| `globalMaxConcurrent` | `number` | `4` | System-wide max concurrent agents across all projects. |

View File

@@ -63,6 +63,8 @@ describe("settings key parity", () => {
expect(isProjectSettingsKey("maxConcurrent")).toBe(true);
expect(isProjectSettingsKey("maxRecommendationsPerTask")).toBe(true);
expect(isGlobalSettingsKey("maxRecommendationsPerTask")).toBe(false);
expect(isProjectSettingsKey("recommendationMailboxNoticeEnabled")).toBe(true);
expect(isGlobalSettingsKey("recommendationMailboxNoticeEnabled")).toBe(false);
expect(isProjectSettingsKey("heartbeatMultiplier")).toBe(true);
expect(isProjectSettingsKey("completionDocumentationMode")).toBe(true);
expect(isProjectSettingsKey("reviewArtifacts")).toBe(true);

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
buildTaskRecommendationNoticeIdempotencyKey,
notifyOperatorOfTaskRecommendations,
registerTaskRecommendationNoticeMailbox,
type MessageCreateInput,
type TaskRecommendation,
} from "../index.js";
const task = { id: "FN-9021", title: "Mailbox recommendation notice" };
const recommendations: TaskRecommendation[] = [
{ id: "follow-up-a", title: "Improve docs", description: "Document the next step.", category: "improvement" },
{ id: "follow-up-b", title: "Add a feature", description: "Expose the operator control.", category: "feature" },
];
function createStore(): object {
return {};
}
describe("task recommendation notice", () => {
it("writes non-empty recommendations with prose only in content", async () => {
const store = createStore();
const sent: Array<{ input: MessageCreateInput; key: string }> = [];
registerTaskRecommendationNoticeMailbox(store as never, {
sendMessageOnce: async (input, key) => { sent.push({ input, key }); },
});
await expect(notifyOperatorOfTaskRecommendations(store as never, task, recommendations, {})).resolves.toBe(true);
expect(sent).toHaveLength(1);
expect(sent[0].input).toMatchObject({
toId: "dashboard",
toType: "user",
type: "system",
metadata: {
kind: "task-recommendation-notice",
taskId: task.id,
recommendationCount: 2,
recommendationIds: ["follow-up-a", "follow-up-b"],
categories: ["improvement", "feature"],
},
});
for (const recommendation of recommendations) {
expect(sent[0].input.content).toContain(recommendation.title);
expect(JSON.stringify(sent[0].input.metadata)).not.toContain(recommendation.title);
expect(JSON.stringify(sent[0].input.metadata)).not.toContain(recommendation.description);
}
});
it.each([
["empty list", [] as TaskRecommendation[], {}],
["omitted list", undefined, {}],
["disabled setting", recommendations, { recommendationMailboxNoticeEnabled: false }],
])("does not write for %s", async (_name, value, settings) => {
const store = createStore();
let calls = 0;
registerTaskRecommendationNoticeMailbox(store as never, { sendMessageOnce: async () => { calls += 1; } });
await expect(notifyOperatorOfTaskRecommendations(store as never, task, value, settings)).resolves.toBe(false);
expect(calls).toBe(0);
});
it("silently degrades without a mailbox and swallows mailbox failures", async () => {
const absentStore = createStore();
await expect(notifyOperatorOfTaskRecommendations(absentStore as never, task, recommendations, {})).resolves.toBe(false);
const failingStore = createStore();
registerTaskRecommendationNoticeMailbox(failingStore as never, { sendMessageOnce: async () => { throw new Error("unavailable"); } });
await expect(notifyOperatorOfTaskRecommendations(failingStore as never, task, recommendations, {})).resolves.toBe(false);
});
it("dedupes equal id sets but changes keys for changed ids", () => {
expect(buildTaskRecommendationNoticeIdempotencyKey(task.id, recommendations)).toBe(
buildTaskRecommendationNoticeIdempotencyKey(task.id, [...recommendations].reverse()),
);
expect(buildTaskRecommendationNoticeIdempotencyKey(task.id, recommendations)).not.toBe(
buildTaskRecommendationNoticeIdempotencyKey(task.id, [recommendations[0]]),
);
});
});

View File

@@ -420,6 +420,8 @@ export const DEFAULT_GLOBAL_SETTINGS = {
export const DEFAULT_PROJECT_SETTINGS = {
// FNXC:TaskRecommendations 2026-08-08-05:02: completion follows-ups stay bounded by default; 0 disables writing them.
maxRecommendationsPerTask: 3,
// FNXC:TaskRecommendations 2026-08-13-03:56: surface completed-task proposals by default; operators can suppress the notice without suppressing capture.
recommendationMailboxNoticeEnabled: true,
globalPause: false,
globalPauseReason: undefined,
defaultWorkflowId: undefined,

View File

@@ -2815,6 +2815,15 @@ export {
type TaskDeleteNoticeMailbox,
type TaskDeleteNoticeSnapshot,
} from "./task-delete-notice.js";
/* FNXC:TaskRecommendations 2026-08-13-03:56: engine-owned mailbox registration reaches this core-scoped best-effort accepted-completion notice seam. */
export {
registerTaskRecommendationNoticeMailbox,
getTaskRecommendationNoticeMailbox,
buildTaskRecommendationNoticeContent,
buildTaskRecommendationNoticeIdempotencyKey,
notifyOperatorOfTaskRecommendations,
type TaskRecommendationNoticeMailbox,
} from "./task-recommendation-notice.js";
/*
FNXC:SessionIdentity 2026-07-26-12:10:
In-process principal channel between the engine (session spawner) and the bundled

View File

@@ -0,0 +1,122 @@
/*
FNXC:TaskRecommendations 2026-08-13-03:56:
SCOPE — notices fire only for a new, non-empty recommendation list at an ACCEPTED completion. Do
not invoke this for a rolled-back handoff, `linkTaskRecommendation`, or the create-from-
recommendation route: those only stamp `createdTaskId` on an existing proposal.
BEST-EFFORT + NON-BLOCKING — callers dispatch without awaiting. This is the sole place mailbox
failures may be observed, and it swallows them so recommendation capture and task completion remain
independent from mailbox availability.
PROSE PLACEMENT — operator-facing prose belongs only in mailbox content. Metadata contains ids,
enums, and counts; this module writes no run-audit prose.
*/
import { createHash } from "node:crypto";
import { createLogger } from "./process/logger.js";
import type { TaskStore } from "./store.js";
import { DASHBOARD_USER_ID, type MessageCreateInput, type TaskRecommendation } from "./types.js";
const noticeLog = createLogger("task-recommendation-notice");
/** Narrow mailbox seam so core does not need to own or import MessageStore. */
export interface TaskRecommendationNoticeMailbox {
sendMessageOnce(input: MessageCreateInput, idempotencyKey: string): Promise<unknown>;
}
/*
FNXC:TaskRecommendations 2026-08-13-03:56:
Registration is store-scoped because one process can host multiple projects; a process-global
mailbox could post one project's proposals into another project's operator inbox. Identity-guarded
teardown keeps an old runtime from erasing a newer registration.
*/
const mailboxes = new WeakMap<TaskStore, TaskRecommendationNoticeMailbox>();
export function registerTaskRecommendationNoticeMailbox(
store: TaskStore,
mailbox: TaskRecommendationNoticeMailbox,
): () => void {
mailboxes.set(store, mailbox);
return () => {
if (mailboxes.get(store) === mailbox) mailboxes.delete(store);
};
}
export function getTaskRecommendationNoticeMailbox(
store: TaskStore,
): TaskRecommendationNoticeMailbox | undefined {
return mailboxes.get(store);
}
/** Builds the operator-facing content; recommendation prose stays out of metadata. */
export function buildTaskRecommendationNoticeContent(
task: { id: string; title?: string },
recommendations: TaskRecommendation[],
): string {
const title = task.title?.trim();
const heading = title ? `## Recommendations from ${task.id} — ${title}` : `## Recommendations from ${task.id}`;
return [
heading,
"",
"These are optional, non-blocking out-of-scope proposals that were **not** executed.",
"",
...recommendations.map((recommendation) => `- **${recommendation.title}** — \`${recommendation.category}\`: ${recommendation.description}`),
"",
`Open ${task.id}'s **Recommendations** tab to review them. **Create task** files one through normal guarded intake.`,
].join("\n");
}
/*
FNXC:TaskRecommendations 2026-08-13-03:56:
`fn_task_done` is re-runnable after a workflow-step revision and both completion paths can rewrite
the same list. A task-only key would hide a genuinely changed proposal set, while plain send would
spam retries. Hashing sorted recommendation ids dedupes a retry yet notices a real id-set change.
*/
export function buildTaskRecommendationNoticeIdempotencyKey(
taskId: string,
recommendations: TaskRecommendation[],
): string {
const ids = recommendations.map(({ id }) => id).sort().join("\n");
const digest = createHash("sha256").update(ids, "utf8").digest("hex").slice(0, 16);
return `task-recommendation-notice:${taskId}:${digest}`;
}
/**
* Best-effort operator notice. It never throws so a mailbox failure cannot fail accepted completion.
*/
export async function notifyOperatorOfTaskRecommendations(
store: TaskStore,
task: { id: string; title?: string },
recommendations: TaskRecommendation[] | undefined,
settings: { recommendationMailboxNoticeEnabled?: boolean },
): Promise<boolean> {
try {
if (settings.recommendationMailboxNoticeEnabled === false || !recommendations?.length) return false;
const mailbox = mailboxes.get(store);
if (!mailbox) return false;
await mailbox.sendMessageOnce(
{
fromId: "system",
fromType: "system",
toId: DASHBOARD_USER_ID,
toType: "user",
type: "system",
content: buildTaskRecommendationNoticeContent(task, recommendations),
metadata: {
kind: "task-recommendation-notice",
taskId: task.id,
recommendationCount: recommendations.length,
recommendationIds: recommendations.map(({ id }) => id),
categories: recommendations.map(({ category }) => category),
},
},
buildTaskRecommendationNoticeIdempotencyKey(task.id, recommendations),
);
return true;
} catch (error) {
noticeLog.warn(
`Operator recommendation notice failed for ${task.id}: ${error instanceof Error ? error.message : String(error)}`,
);
return false;
}
}

View File

@@ -1034,6 +1034,12 @@ export interface ProjectSettings {
* explicit opt-out; executor validation enforces the 0..20 integer boundary.
*/
maxRecommendationsPerTask?: number;
/**
* FNXC:TaskRecommendations 2026-08-13-03:56:
* The operator requested an on/off switch for recommendation mailbox notices. This controls
* best-effort observability only; disabling it never changes recommendation capture or storage.
*/
recommendationMailboxNoticeEnabled?: boolean;
/** Hard stop: when true, all automated agent activity is **immediately**
* terminated — active triage, execution, and merge agent sessions are
* killed, and the scheduler stops dispatching new work. Acts as a

View File

@@ -220,6 +220,15 @@ vi.mock("../FileBrowser", () => ({
}));
describe("SettingsModal", () => {
it("renders recommendation mailbox notices enabled by default and persists disabling it", async () => {
renderModal({ initialSection: "general" });
await waitForSettingsModalReady();
const toggle = screen.getByLabelText("Recommendation mailbox notices");
expect(toggle).toBeChecked();
await settingsModalUser.click(toggle);
await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled());
expect(mockUpdateSettings.mock.calls.at(-1)?.[0]).toMatchObject({ recommendationMailboxNoticeEnabled: false });
});
// Keep Advanced off by default so disclosure default/persist tests stay truthful.
installSettingsModalEnv({ advancedSettings: false });
@@ -1702,6 +1711,7 @@ describe("SettingsModal", () => {
expect(payload.autoMerge).toBeNull();
expect(payload.maxConcurrent).toBeNull();
expect(payload.maxRecommendationsPerTask).toBeNull();
expect(payload.recommendationMailboxNoticeEnabled).toBeNull();
// Global-only key must never appear in a project-scope reset payload.
expect(payload).not.toHaveProperty("themeMode");
expect(mockUpdateGlobalSettings).not.toHaveBeenCalled();

View File

@@ -83,6 +83,7 @@ export const PROJECT_SECTION_KEYS: Readonly<Record<string, readonly string[]>> =
"sessionAdvisorEnabledByDefault",
"mailAutoCleanupDays",
"maxRecommendationsPerTask",
"recommendationMailboxNoticeEnabled",
"mobileNavPrimaryItems",
"operationalLogRetentionDays",
"quickChatButtonMode",

View File

@@ -192,6 +192,21 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
value={form.maxRecommendationsPerTask ?? 3}
onChange={(value) => setForm((current) => ({ ...current, maxRecommendationsPerTask: value ?? 3 }))}
/>
{/*
FNXC:TaskRecommendations 2026-08-13-03:56:
The operator asked to be notified in the mailbox when a completed task produces
recommendations, with an off switch. Turning it off suppresses only the notice, never capture.
*/}
<SettingsToggleRow
descriptor={{
key: "recommendationMailboxNoticeEnabled",
label: t("settings.general.recommendationMailboxNoticeEnabled", "Recommendation mailbox notices"),
help: t("settings.general.recommendationMailboxNoticeEnabledHelp", "Default: enabled. When a completed task captures recommendations, send a summary to your mailbox. Turning this off does not change whether recommendations are captured."),
scope: "project",
}}
value={form.recommendationMailboxNoticeEnabled !== false}
onChange={(v) => setForm((f) => ({ ...f, recommendationMailboxNoticeEnabled: v === true }))}
/>
{/*
FNXC:SettingsGeneral 2026-07-15-17:35:
A blank prefix stores `undefined`, not "": empty means "no prefix configured" and must delete the

View File

@@ -294,6 +294,7 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
showTaskChatsInCommonFeed: "general.showTaskChatsInCommonFeedHint",
taskPrefix: "general.prefixForNewTaskIDsEGKB",
maxRecommendationsPerTask: "general.maxRecommendationsPerTaskHelp",
recommendationMailboxNoticeEnabled: "general.recommendationMailboxNoticeEnabledHelp",
workspaceMode: "general.workspaceModeHint",
defaultWorkflowId: "general.newTasksInheritThisCustomWorkflowsStepsOverridable",
enabledBuiltinWorkflowIds: "general.disabledFusionWorkflowsAreHiddenFromWorkflow",

View File

@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./executor-test-helpers.js";
import { registerTaskRecommendationNoticeMailbox } from "@fusion/core";
import { TaskExecutor, validateCompletionRecommendations } from "../executor.js";
import { __flushPendingRecommendationNotices } from "../executor/completion-recommendation-notice.js";
import * as worktreePool from "../worktree/worktree-pool.js";
import { createMockStore, mockedExecSync, resetExecutorMocks } from "./executor-test-helpers.js";
@@ -29,7 +31,7 @@ function completionTask() {
};
}
function createProductionTaskDoneTool(maximum: number | undefined = 3) {
function createProductionTaskDoneTool(maximum: number | undefined = 3, recommendationMailboxNoticeEnabled?: boolean) {
const store = createMockStore();
const task = completionTask();
store._setRow(task.id, task);
@@ -41,6 +43,7 @@ function createProductionTaskDoneTool(maximum: number | undefined = 3) {
autoMerge: false,
worktreeInitCommand: undefined,
...(maximum === undefined ? {} : { maxRecommendationsPerTask: maximum }),
...(recommendationMailboxNoticeEnabled === undefined ? {} : { recommendationMailboxNoticeEnabled }),
});
const executor = new TaskExecutor(store as any, "/repo");
const tool = (executor as any).createTaskDoneTool(
@@ -92,6 +95,40 @@ describe("fn_task_done recommendation validation", () => {
});
it("sends one non-blocking operator mailbox notice after accepted completion", async () => {
const { store, task, tool } = createProductionTaskDoneTool();
const messages: Array<{ input: any; key: string }> = [];
registerTaskRecommendationNoticeMailbox(store as any, {
sendMessageOnce: async (input, key) => { messages.push({ input, key }); },
});
await expect(tool.execute("call-notice", { recommendations: [recommendation, { ...recommendation, id: "rec-docs", title: "Document exports" }] })).resolves.toMatchObject({ details: {} });
await __flushPendingRecommendationNotices();
expect(messages).toHaveLength(1);
expect(messages[0].input).toMatchObject({
toId: "dashboard",
type: "system",
metadata: { kind: "task-recommendation-notice", taskId: task.id, recommendationCount: 2 },
});
expect(messages[0].input.content).toContain("Export completed tasks");
expect(messages[0].input.content).toContain("Document exports");
await tool.execute("call-notice-retry", { recommendations: [recommendation, { ...recommendation, id: "rec-docs", title: "Document exports" }] });
await __flushPendingRecommendationNotices();
expect(messages[1].key).toBe(messages[0].key);
});
it("persists recommendations but suppresses notices when the project setting is off", async () => {
const { store, task, tool } = createProductionTaskDoneTool(3, false);
let messages = 0;
registerTaskRecommendationNoticeMailbox(store as any, { sendMessageOnce: async () => { messages += 1; } });
await tool.execute("call-notice-off", { recommendations: [recommendation] });
await __flushPendingRecommendationNotices();
expect((await store.getTask(task.id)).recommendations).toEqual([recommendation]);
expect(messages).toBe(0);
});
it("persists an honest empty list and uses the default cap when the setting is absent", async () => {
const { store, task, tool } = createProductionTaskDoneTool(undefined);
const empty = await tool.execute("call-empty", { recommendations: [] });

View File

@@ -0,0 +1,52 @@
/*
FNXC:TaskRecommendations 2026-08-13-03:56:
The durable recommendation write precedes several abort/rollback returns in no-op completion, so
notifying there would announce proposals for a rejected handoff. Dispatch only after accepted
completion. It is fire-and-forget because mailbox latency or failure must never extend or fail
`fn_task_done`; attach a catch synchronously so rejection cannot escape unhandled.
*/
import {
notifyOperatorOfTaskRecommendations,
type Settings,
type TaskRecommendation,
type TaskStore,
} from "@fusion/core";
const pendingNotices = new Set<Promise<void>>();
export function dispatchAcceptedCompletionRecommendationNotice(args: {
store: TaskStore;
taskId: string;
taskTitle?: string;
recommendations?: TaskRecommendation[];
settings?: Settings;
log?: { warn(msg: string): void };
}): void {
if (!args.recommendations?.length) return;
const pending = (async () => {
const settings = args.settings ?? await args.store.getSettings();
const task = await args.store.getTask(args.taskId);
if (!task || task.deletedAt) return;
await notifyOperatorOfTaskRecommendations(
args.store,
{ id: args.taskId, title: args.taskTitle ?? task.title },
args.recommendations,
settings,
);
})();
pendingNotices.add(pending);
void pending.catch((error: unknown) => {
args.log?.warn(
`Failed to dispatch recommendation mailbox notice for ${args.taskId}: ${error instanceof Error ? error.message : String(error)}`,
);
}).finally(() => {
pendingNotices.delete(pending);
});
}
/** Test-only drain; production completion callers must remain strictly non-awaiting. */
export async function __flushPendingRecommendationNotices(): Promise<void> {
await Promise.allSettled([...pendingNotices]);
}

View File

@@ -35,6 +35,7 @@ import { evaluateTaskDoneRefusal } from "./task-done-refusal.js";
import { skipBypassTaintUpdateForRefusal } from "./completion-predicates.js";
import { MAX_TASK_DONE_REQUEUE_RETRIES } from "./task-done-refusal-handler.js";
import { validateCompletionRecommendations } from "./validate-completion-recommendations.js";
import { dispatchAcceptedCompletionRecommendationNotice } from "./completion-recommendation-notice.js";
import type { FinalizeAcceptedNoOpCompletionParams } from "./plan-review-no-op.js";
export type CreateTaskDoneToolDeps = {
@@ -502,6 +503,17 @@ export function createTaskDoneTool(
bulkCompletionRefusalAt: null,
});
await store.logEntry(taskId, "Task marked done by agent", undefined, deps.getRunContextFor(taskId));
// FNXC:TaskRecommendations 2026-08-13-03:56: accepted completion boundary; dispatch after durable handoff without awaiting mailbox I/O.
if (completionRecommendations !== undefined) {
dispatchAcceptedCompletionRecommendationNotice({
store,
taskId,
taskTitle: task.title,
recommendations: completionRecommendations,
settings,
log: executorLog,
});
}
const latestTask = await store.getTask(taskId);
let latestColumn = latestTask.column;

View File

@@ -12,6 +12,7 @@ import { resolveWipTargetForTask } from "@fusion/core";
import { executorLog } from "../logger.js";
import type { EngineRunContext } from "../util/run-audit.js";
import { resolveReboundColumnFor, resolveTerminalColumnsFor } from "./lifecycle-columns.js";
import { dispatchAcceptedCompletionRecommendationNotice } from "./completion-recommendation-notice.js";
export type FinalizeAcceptedNoOpCompletionDeps = {
store: TaskStore;
@@ -167,6 +168,17 @@ export async function finalizeAcceptedNoOpCompletion(
) {
deps.scheduleCompletedTaskWatchdog(task.id, "fn_task_done");
}
// FNXC:TaskRecommendations 2026-08-13-03:56: every rollback guard is above; this accepted boundary dispatches without delaying completion.
if (recommendations !== undefined) {
dispatchAcceptedCompletionRecommendationNotice({
store: deps.store,
taskId: task.id,
taskTitle: task.title,
recommendations,
settings,
log: executorLog,
});
}
return { completed: true, hardPauseActive };
} catch (error) {
/*

View File

@@ -848,6 +848,8 @@ export class InProcessRuntime
private messageStore?: MessageStore;
/** FNXC:TaskDeleteNotice 2026-07-26-16:10: identity-guarded teardown for the delete-notice mailbox seam. */
private unregisterTaskDeleteNoticeMailbox?: () => void;
/** FNXC:TaskRecommendations 2026-08-13-03:56: identity-guarded teardown for the store-scoped recommendation notice seam. */
private unregisterTaskRecommendationNoticeMailbox?: () => void;
private chatStore?: ChatStore;
private detachAgentLinkSync?: () => void;
/**
@@ -927,6 +929,7 @@ export class InProcessRuntime
buildConsumerId,
createProjectScopedPluginMcpProvider,
registerTaskDeleteNoticeMailbox,
registerTaskRecommendationNoticeMailbox,
} = await import("@fusion/core");
if (this.config.externalTaskStore) {
this.taskStore = this.config.externalTaskStore;
@@ -1017,6 +1020,15 @@ export class InProcessRuntime
this.taskStore,
this.messageStore,
);
/*
FNXC:TaskRecommendations 2026-08-13-03:56:
Store-scoped registration prevents a process hosting several projects from delivering one
project's recommendation notice into another project's mailbox, matching the delete notice.
*/
this.unregisterTaskRecommendationNoticeMailbox = registerTaskRecommendationNoticeMailbox(
this.taskStore,
this.messageStore,
);
await yieldEventLoop();
@@ -2078,6 +2090,8 @@ export class InProcessRuntime
// cannot keep writing notices; the unregister is identity-guarded against a newer runtime.
this.unregisterTaskDeleteNoticeMailbox?.();
this.unregisterTaskDeleteNoticeMailbox = undefined;
this.unregisterTaskRecommendationNoticeMailbox?.();
this.unregisterTaskRecommendationNoticeMailbox = undefined;
let stopError: Error | undefined;
try {
if (this.workflowContinuationTimer) {

View File

@@ -6054,7 +6054,9 @@
"reportDiscussionCategoryEmpty": "No Discussion categories are available for the configured repository.",
"reportTargetByActionHelp": "Optional per-action override of the default report target for bug, feedback, idea, or help. No default — unset actions inherit reportTarget / action defaults.",
"maxRecommendationsPerTask": "Maximum recommendations per task",
"maxRecommendationsPerTaskHelp": "Default: 3. Set 0 to disable recommendations; choose a whole number from 1 to 20 to cap each completed task."
"maxRecommendationsPerTaskHelp": "Default: 3. Set 0 to disable recommendations; choose a whole number from 1 to 20 to cap each completed task.",
"recommendationMailboxNoticeEnabled": "Recommendation mailbox notices",
"recommendationMailboxNoticeEnabledHelp": "Default: enabled. When a completed task captures recommendations, send a summary to your mailbox. Turning this off does not change whether recommendations are captured."
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": " and shows update notices in the CLI and dashboard. Cadence is governed by the frequency below. Default: enabled. ",

View File

@@ -6030,7 +6030,9 @@
"refinementTaskWorkflow": "",
"refinementTaskWorkflowHelp": "",
"maxRecommendationsPerTask": "",
"maxRecommendationsPerTaskHelp": ""
"maxRecommendationsPerTaskHelp": "",
"recommendationMailboxNoticeEnabled": "",
"recommendationMailboxNoticeEnabledHelp": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",

View File

@@ -6030,7 +6030,9 @@
"refinementTaskWorkflow": "",
"refinementTaskWorkflowHelp": "",
"maxRecommendationsPerTask": "",
"maxRecommendationsPerTaskHelp": ""
"maxRecommendationsPerTaskHelp": "",
"recommendationMailboxNoticeEnabled": "",
"recommendationMailboxNoticeEnabledHelp": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",

View File

@@ -6030,7 +6030,9 @@
"refinementTaskWorkflow": "",
"refinementTaskWorkflowHelp": "",
"maxRecommendationsPerTask": "",
"maxRecommendationsPerTaskHelp": ""
"maxRecommendationsPerTaskHelp": "",
"recommendationMailboxNoticeEnabled": "",
"recommendationMailboxNoticeEnabledHelp": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",

View File

@@ -6033,7 +6033,9 @@
"reportDiscussionCategorySelect": "Selecione uma categoria de discussão",
"reportDiscussionCategoryUnavailable": "As categorias de discussão não estão disponíveis para o repositório configurado.",
"reportDiscussionCategoryEmpty": "Nenhuma categoria de discussão está disponível para o repositório configurado.",
"reportTargetByActionHelp": "Substituição opcional por ação do destino padrão do relatório para bug, feedback, ideia ou ajuda. Sem padrão — ações não definidas herdam reportTarget / os padrões da ação."
"reportTargetByActionHelp": "Substituição opcional por ação do destino padrão do relatório para bug, feedback, ideia ou ajuda. Sem padrão — ações não definidas herdam reportTarget / os padrões da ação.",
"recommendationMailboxNoticeEnabled": "",
"recommendationMailboxNoticeEnabledHelp": ""
},
"mobileNav": {
"label": "Seção de configurações"

View File

@@ -6030,7 +6030,9 @@
"refinementTaskWorkflow": "",
"refinementTaskWorkflowHelp": "",
"maxRecommendationsPerTask": "",
"maxRecommendationsPerTaskHelp": ""
"maxRecommendationsPerTaskHelp": "",
"recommendationMailboxNoticeEnabled": "",
"recommendationMailboxNoticeEnabledHelp": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",

View File

@@ -6030,7 +6030,9 @@
"refinementTaskWorkflow": "",
"refinementTaskWorkflowHelp": "",
"maxRecommendationsPerTask": "",
"maxRecommendationsPerTaskHelp": ""
"maxRecommendationsPerTaskHelp": "",
"recommendationMailboxNoticeEnabled": "",
"recommendationMailboxNoticeEnabledHelp": ""
},
"globalGeneral": {
"andShowsUpdateNoticesInTheCLIAnd": "",

View File

@@ -1553,6 +1553,33 @@ export default interface Resources {
"throughput": "Throughput",
"throughputPerDay": "Throughput / day"
},
"agentActivity": {
"agentFilter": "Filter by agent",
"allAgents": "All agents",
"allTypes": "All event types",
"empty": "No agent activity yet.",
"end": "End of activity history",
"eventTypes": {
"agent:state-changed": "Agent state changed",
"approval:requested": "Approval requested",
"task:completed": "Task completed",
"task:handed-off": "Task handed off",
"task:started": "Task started",
"workflow:gate-failed": "Workflow gate failed",
"workflow:gate-passed": "Workflow gate passed"
},
"live": "Live",
"loadOlder": "Load older",
"loadingOlder": "Loading…",
"mode": "Activity mode",
"openAgent": "Open agent {{agentId}}",
"openTask": "Open task {{taskId}}",
"rangeNote": "Time range filters loaded events; it is not sent to the server.",
"showMore": "Show more loaded activity",
"taskFilter": "Filter by task",
"timeline": "Timeline",
"typeFilter": "Filter by event type"
},
"area": {
"empty": "No data for the selected range.",
"loading": "Loading…"
@@ -1811,6 +1838,7 @@ export default interface Resources {
"tablistLabel": "Command Center sections",
"tabs": {
"activity": "Activity",
"agentActivity": "Agent Activity",
"ecosystem": "Ecosystem",
"github": "GitHub",
"missionControl": "Mission Control",
@@ -5982,6 +6010,8 @@ export default interface Resources {
"quickChatCloseOnOutsideClickHint": "When enabled, clicking outside the Quick Chat window closes it. Disable to keep it open until you close it explicitly. Default: enabled.",
"quickChatLauncherHint": "Choose whether Quick Chat opens from the draggable floating button, a footer button beside Terminal, or stays hidden. Default: off (hidden).",
"recentVerbatimRoomMessages": "Recent verbatim room messages",
"recommendationMailboxNoticeEnabled": "Recommendation mailbox notices",
"recommendationMailboxNoticeEnabledHelp": "Default: enabled. When a completed task captures recommendations, send a summary to your mailbox. Turning this off does not change whether recommendations are captured.",
"refinementTaskWorkflow": "Refinement task workflow",
"refinementTaskWorkflowHelp": "Workflow applied to refinement tasks — the follow-up card spawned from a done or in-review task plus your feedback. Choose \"Selected workflow\" to follow your current board workflow (falling back to the project default workflow). No default — unset means Selected workflow.",
"removeNavItem": "Remove {{item}}",
@@ -6329,6 +6359,8 @@ export default interface Resources {
"gitRemoteThatMergedResultsArePushedTo": "Git remote that merged results are pushed to. Default: \"origin\".",
"gitRemoteToPushToEGOrigin": "Git remote to push to (e.g. \"origin\"). Can include branch name (e.g. \"origin main\"). Default: \"origin\".",
"githubAuthTokenHint": "No default — unset.",
"githubNativeAutoMerge": "Use GitHub native auto-merge",
"githubNativeAutoMergeHelp": "GitHub waits for its full ruleset before merging. The repository must allow auto-merge; Fusion fails closed if it is unavailable. Default: disabled.",
"ignoreOverlapDetectionPreserveLegacyBehavior": "Ignore overlap detection — preserve legacy behavior",
"inYourProjectRootWillShowTheNew": " in your project root will show the new commits inverted as &quot;staged changes&quot; until you pull manually. Only applies to direct merges. ",
"includeTaskIDInCommitScope": " Include task ID in commit scope ",
@@ -6368,6 +6400,8 @@ export default interface Resources {
"pushTargetBranchHelp": "Branch on the remote that merged results are pushed to. Leave on the default to push the integration branch to its same-named remote branch; pick a listed remote branch or choose Custom… to type one that doesn't exist on the remote yet (the push creates it).",
"pushToRemoteAfterMerge": " Push to remote after merge ",
"readyToCleanUp": " ready to clean up.",
"requiredChecks": "Required pull-request checks",
"requiredChecksHelp": "No default — unset. Comma-separated check names match GitHub exactly (case-sensitive). Leaving this empty uses GitHub required-status checks only; a named check that never reports blocks the merge.",
"retriesTheAIAgentRatherThanAutoPicking": " retries the AI agent rather than auto-picking a side. ",
"reuseTaskWorktreeDefault": "Reuse task worktree (default)",
"sameAsIntegrationBranchDefault": "(same as integration branch — default)",
@@ -6529,6 +6563,8 @@ export default interface Resources {
"webhookNotifications": " Webhook notifications ",
"webhookURL": "Webhook URL",
"webhookUrlHint": "No default — unset.",
"wedgeNotificationSettleMs": "Terminal-wedge settle window (ms)",
"wedgeNotificationSettleMsHelp": "How long a terminal failure must persist before an operator alert. 0 = notify immediately. Default: 300000 (5 minutes).",
"yourNtfyShTopicName164Alphanumeric": " Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters). No default — unset."
},
"plugins": {
@@ -8023,8 +8059,8 @@ export default interface Resources {
"recommendations": {
"create": "Create task",
"created": "Created {{taskId}}",
"empty": "No recommendations were produced for this task.",
"creating": "Creating…",
"empty": "No recommendations were produced for this task.",
"error": "Could not create task. Try again.",
"retry": "Retry creating task",
"title": "Recommendations"
@@ -8378,6 +8414,7 @@ export default interface Resources {
"effective": "Effective: {{label}}",
"effectiveFrozen": "Effective: {{label}} — frozen on entry to review",
"errorSep": " · Error: {{message}}",
"fixedInReview": "Fixed in review",
"followDefault": "Follow default",
"loadError": "Failed to load review data.",
"loadingData": "Loading review data…",
@@ -8405,6 +8442,7 @@ export default interface Resources {
"showMarkdown": "Show formatted markdown",
"showRawText": "Show raw text",
"startedAtSep": " · Started: {{timestamp}}",
"superseded": "Superseded",
"upToDate": "Up to date",
"updateFailed": "Failed to update {{taskId}}: {{error}}"
},